diff --git a/AGENTS.md b/AGENTS.md index 08aa63da2..8acc6b476 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,6 +9,24 @@ doc comment when the signature already says it (e.g. write "returns the access level, respecting the cache" — not a paragraph re-deriving the caching). Aggressively delete comments that narrate obvious implementation details. +## Layout + +`src/` has two sides, `backend/` (the Worker) and `frontend/` (the SPA). There +is no shared directory: the backend owns the contract, and the frontend imports +it through the `@backend/*` alias. Imports within a side stay relative. + +Both sides are organized the same way: + +- `features//` — everything one feature owns. Backend features hold + `routes.ts` plus their storage, models and DTOs; frontend features hold + `queries.ts` and `components/`. +- `lib/` — cross-cutting plumbing that belongs to no single feature. +- `components/` (frontend only) — UI used by more than one feature. + +Anything the frontend imports from a backend feature must be a leaf module — +pure types and functions, no Worker-only imports — or it lands in the client +bundle. + # Cloudflare Workers STOP. Your knowledge of Cloudflare Workers APIs and limits may be outdated. Always retrieve current documentation before any Workers, KV, R2, D1, Durable Objects, Queues, Vectorize, AI, or Agents SDK task. diff --git a/README.md b/README.md index f8acc7dee..4b2634ca7 100644 --- a/README.md +++ b/README.md @@ -150,7 +150,9 @@ To exercise the signed-in-only UI (favorites, insert button) without a real Onshape session, set `FORCE_SIGNED_IN=true` in your `.env`. This is a testing-only escape hatch — it uses a fake user id and Onshape calls it reveals won't actually work, so leave it unset normally. Combine with -`ACCESS_LEVEL_OVERRIDE=admin` to also show editor/admin controls. +`ACCESS_LEVEL_OVERRIDE=admin` to also show editor/admin controls — editor +routes require a session as well as the access level, so the override alone +does not reach them. # Troubleshooting diff --git a/docs/GUIDE.md b/docs/GUIDE.md index 38f9145ad..aee72f8be 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -40,7 +40,7 @@ In the database, Groups correspond to Documents (or more specifically Versions o ### Path types in the codebase -These are defined in `src/shared/onshape-path.ts`: +These are defined in `src/backend/lib/onshape/path.ts`: ```ts // Just a document @@ -78,10 +78,7 @@ The `apiPath()` function in `src/backend/onshape-api/api-path.ts` assembles thes ```ts import { apiPath } from "../api-path"; -import { - toInstanceApiPath, - toElementApiPath -} from "../../../shared/onshape-path"; +import { toInstanceApiPath, toElementApiPath } from "./path"; // Produces: /assemblies/d/{did}/w/{wid}/e/{eid}/features apiPath("assemblies", elementPath, toElementApiPath, { endRoute: "features" }); @@ -90,7 +87,7 @@ apiPath("assemblies", elementPath, toElementApiPath, { endRoute: "features" }); apiPath("documents", instancePath, toInstanceApiPath, { endRoute: "elements" }); ``` -The serializer functions (`toDocumentApiPath`, `toInstanceApiPath`, `toElementApiPath`) are all defined in `src/shared/onshape-path.ts` and convert a path object into its URL segment string. +The serializer functions (`toDocumentApiPath`, `toInstanceApiPath`, `toElementApiPath`) are all defined in `src/backend/lib/onshape/path.ts` and convert a path object into its URL segment string. ### Calling the Onshape API @@ -116,7 +113,7 @@ Use this when you need to expose new functionality to the frontend via a new API ### 1. Add the handler to a routes file -Open the relevant file in `src/backend/routes/` (or create a new one if the functionality is in a new area). Each file creates a Hono sub-app and registers handlers on it: +Open the `routes.ts` of the feature that owns the functionality, under `src/backend/features/` (or add a new feature directory if it belongs to none of them). Each `routes.ts` creates a Hono sub-app and registers handlers on it: ```ts export const myRoutes = getApp(); @@ -160,9 +157,9 @@ import { myRoutes } from "./routes/my-routes"; app.route("/api", myRoutes); ``` -### 3. Define the response type in shared +### 3. Define the response type -If the frontend needs to consume this endpoint, define a TypeScript interface for the response in `src/shared/api-models.ts` so both sides agree on the shape. +If the frontend needs to consume this endpoint, define a TypeScript interface for the response in the feature's `dto.ts` (e.g. `src/backend/features/library/dto.ts`). The backend owns the contract; the frontend imports it through `@backend/features//dto`, so both sides agree on the shape. --- @@ -230,7 +227,7 @@ The schema is the source of truth for what's stored in D1. Drizzle ORM reads it ### 1. Edit the schema -Open `src/shared/schema.ts`. Tables are defined using Drizzle's SQLite helpers: +Open `src/backend/db/schema.ts`. Tables are defined using Drizzle's SQLite helpers: ```ts import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"; @@ -266,7 +263,7 @@ This runs all pending migrations against your local D1 database (used by `npx wr ### 4. Update API response types if needed -If the new data needs to be returned to the frontend, update the relevant interface in `src/shared/api-models.ts` and modify the query in `src/backend/library-data.ts` (if it's part of the main library response) or in the appropriate route handler. +If the new data needs to be returned to the frontend, update the relevant interface in the feature's `dto.ts` and modify the query in `src/backend/features/library/db.ts` (if it's part of the main library response) or in the appropriate route handler. ## Adding a Frontend Route @@ -349,20 +346,20 @@ function GroupPage() { If you just need to fetch data inside an existing component (without creating a new route), follow this pattern. -### 1. Define the query in `queries.ts` +### 1. Define the query in the feature's `queries.ts` -Open `src/frontend/queries.ts` and add a query definition: +Add the key to `src/frontend/lib/query-keys.ts`, then add the query to `src/frontend/features//queries.ts`: ```ts export function getMyDataQuery(someId: string) { return queryOptions({ - queryKey: ["my-data", someId], + queryKey: myDataQueryKey(someId), queryFn: () => apiGet("/my-thing/" + someId) }); } ``` -Keeping query definitions in `queries.ts` means the same query can be used in multiple components and they'll all share the same cache. +Keeping query definitions in the feature's `queries.ts` means the same query can be used in multiple components and they'll all share the same cache. Keys live in `lib/query-keys.ts` so the refresh flows can invalidate across features. ### 2. Use it in a component @@ -426,7 +423,7 @@ function useMyMutation(someId: string) { } ``` -`getQueryUpdater` (from `src/frontend/common/utils.ts`) wraps Immer's `produce()` into a function that React Query's `setQueryData` accepts. Immer allows you to mutate query results directly rather than mutating an original cache value, which is much cleaner for nested data. +`getQueryUpdater` (from `src/frontend/lib/utils.ts`) wraps Immer's `produce()` into a function that React Query's `setQueryData` accepts. Immer allows you to mutate query results directly rather than mutating an original cache value, which is much cleaner for nested data. **Why cancel queries in `onMutate`?** If a background refetch lands after the optimistic update, it will overwrite the cache with stale data. Canceling outstanding queries for that key prevents this race condition. @@ -602,7 +599,7 @@ If rule 2 tempts you to call a hook from a utility function, make the utility fu A custom hook is just a regular TypeScript function that starts with `use` and calls other hooks inside. You write them to extract repeated stateful logic out of components so it can be shared and tested independently. -Example from this codebase — `useUiState()` in `src/frontend/api-utils/ui-state.ts`: +Example from this codebase — `useUiState()` in `src/frontend/lib/ui-state.ts`: ```tsx // In ui-state.ts (a .ts file — no JSX, so no .tsx needed) @@ -656,7 +653,7 @@ Prefer Mantine component props (`c=`, `bg=`, `p=`, `radius=`) over adding new SC ## The `apiGet` / `apiPost` / `apiDelete` Helpers -These are thin wrappers around `fetch` defined in `src/frontend/api-utils/api.ts`. They: +These are thin wrappers around `fetch` defined in `src/frontend/lib/api-client.ts`. They: - Automatically prepend `/api` to the path (so you write `"/context-data"` not `"/api/context-data"`) - Serialize query parameters via `URLSearchParams` diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md index c531099d9..eaf250b7f 100644 --- a/docs/REFERENCE.md +++ b/docs/REFERENCE.md @@ -28,7 +28,7 @@ The app uses five Cloudflare products. Each one is declared as a **binding** in D1 is Cloudflare's managed SQLite database. It is the app's primary persistent store — everything about libraries, groups, parts, users, and favorites lives here. -Queries go through **Drizzle ORM** so you write TypeScript instead of raw SQL. The schema is defined in `src/shared/schema.ts`. SQL migration files live in `drizzle/` and are applied automatically on deploy. +Queries go through **Drizzle ORM** so you write TypeScript instead of raw SQL. The schema is defined in `src/backend/db/schema.ts`. SQL migration files live in `drizzle/` and are applied automatically on deploy. ### KV — Session & Token Storage (`c.env.KV`) @@ -72,7 +72,7 @@ All rendering happens inside the workflow, which keeps Onshape's thumbnail id se ### Workflows — Background Jobs -Cloudflare Workflows let you run a long-running background job that survives beyond a single HTTP request's time limit. They are the only async primitive here — there are no Queues, Durable Objects, or cron triggers. All three are defined in `src/backend/load/workflows.ts`: +Cloudflare Workflows let you run a long-running background job that survives beyond a single HTTP request's time limit. They are the only async primitive here — there are no Queues, Durable Objects, or cron triggers. The two load workflows live in `src/backend/features/load/workflows.ts`; the thumbnail one lives with the feature it serves, in `src/backend/features/thumbnails/workflow.ts`: | Binding | Class | What it does | | ----------------------- | --------------------- | ------------------------------------------------------------------------------------ | @@ -125,51 +125,50 @@ Once the backend confirms authentication and serves the React app, the frontend ## Storage at a Glance -| Store | What it holds | Lifetime | Who reads/writes it | -| ------------------ | -------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | -------------------------------------------------------------------------------------- | -| **D1** | Library data, groups, parts (insertables), configurations, user preferences, favorites | Permanent (until explicitly changed) | Backend Worker on every API request | -| **KV** | OAuth session state (during login) and auth tokens (after login) | Login state: 10 minutes. Tokens: 30 days. | Backend Worker in `src/backend/auth.ts` | -| **R2** | Thumbnail images and per-library search indexes | Defaults and indexes permanent; configuration thumbnails ~90 days | Backend Worker in `src/backend/routes/thumbnails.ts` and `src/backend/library-data.ts` | -| **localStorage** | UI state: open/closed panels, active search query, vendor filters, last-opened group | Persists across browser sessions | Frontend only, via `src/frontend/api-utils/ui-state.ts` | -| **sessionStorage** | Not used | — | — | +| Store | What it holds | Lifetime | Who reads/writes it | +| ------------------ | -------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | +| **D1** | Library data, groups, parts (insertables), configurations, user preferences, favorites | Permanent (until explicitly changed) | Backend Worker on every API request | +| **KV** | OAuth session state (during login) and auth tokens (after login) | Login state: 10 minutes. Tokens: 30 days. | Backend Worker in `src/backend/features/auth/session.ts` | +| **R2** | Thumbnail images and per-library search indexes | Defaults and indexes permanent; configuration thumbnails ~90 days | Backend Worker in `src/backend/features/thumbnails/` and `src/backend/features/library/db.ts` | +| **localStorage** | UI state: open/closed panels, active search query, vendor filters, last-opened group | Persists across browser sessions | Frontend only, via `src/frontend/lib/ui-state.ts` | +| **sessionStorage** | Not used | — | — | ## Codebase Map -The source code lives in three directories under `src/`: +The source lives in two directories under `src/`: `backend/` (the Cloudflare +Worker) and `frontend/` (the React SPA). There is no shared directory — the +backend owns the contract, and the frontend imports it through `@backend/*`. -### `src/shared/` - -Code used by both the frontend and backend. Key files: - -- `schema.ts` — Drizzle table definitions (the database schema) -- `types.ts` — shared enums (`AccessLevel`, `Vendor`, `Theme`) and core interfaces -- `api-models.ts` — TypeScript types for API request/response shapes -- `onshape-path.ts` — `ElementPath`, `InstancePath` types and the serialization helpers used to build Onshape REST URLs +Both sides use the same shape: `features//` for everything one feature +owns, `lib/` for cross-cutting plumbing, and a small set of files at the root. ### `src/backend/` -The Cloudflare Worker. Key files and folders: - -- `index.ts` — Worker entry point; exports the Hono app and the Workflow class -- `auth.ts` — OAuth flow, session cookie management, token storage/retrieval -- `services.ts` — provides `getOnshapeApi()`, `getUserId()`, `getAccessLevel()` to route handlers -- `library-data.ts` — assembles the full library response (groups + insertables + configurations) -- `routes/` — endpoints callable by the frontend -- `onshape-api/` — all code that communicates with Onshape's REST API (`onshape-api.ts` for the client class, `api-path.ts` for URL construction, `endpoints/` for per-category wrappers) -- `load/` — the Workflows and what they run: `workflows.ts` defines all three, `load-group.ts` and `load-insertable.ts` do the work, `load-steps.ts` holds the retry policies, `job-tracker.ts` tracks what is running -- `parse/` — pure functions turning Onshape responses into what we store: configurations, configuration records, vendors, document contents, build checks -- `sign-in-utils.ts` / `access-level-utils.ts` — the two authorization gates: signed in to Onshape at all, versus on the admin team +- `index.ts` — Worker entry point; exports the default app and the three Workflow classes +- `app.ts` — composition root, and nothing else: binds the caller onto each request, mounts every feature's routes, and installs the error handler +- `db/` — `client.ts` (the Drizzle client) and `schema.ts` (table definitions) +- `lib/` — request plumbing shared by every feature: `context.ts` (bindings, typed context, and the caller binding), `cache.ts` (cache-control middleware), `api-error.ts` and `errors.ts` (the one shape every failed response takes), `validate.ts`, `route-params.ts`, `query-params.ts` +- `lib/onshape/` — everything that talks to Onshape's REST API: `client.ts` (the client class), `api-path.ts`, `path.ts` (`ElementPath`/`InstancePath` and their serializers), `endpoints/` (per-category wrappers), `objects/` (feature and query builders) +- `features/` — one directory per feature, each holding its own `routes.ts` plus whatever it owns: + - `auth/` — split by role: `session.ts` stores the session cookie and its KV records, `onshape-oauth.ts` runs the handshake, `caller.ts` resolves who is calling (and exports `productionCaller`, the wiring `createApp` binds), `guards.ts` holds both gates, and `routes.ts` serves the OAuth redirects plus `/access-data` + - `entry/` — `/init`, where Onshape lands: gates on auth, then resumes the caller in the library and theme they last used + - `settings/` — the caller's stored preferences and the `Settings` model + - `library/` — the library response (`db.ts`), its DTOs, and the groups and insertables endpoints + - `load/` — everything that turns Onshape into what we store: the `parse-*` modules (document contents, configurations, configuration records, vendors, fasten info), the per-group and per-insertable loaders, the Workflows that drive them, their retry policies, and the job tracker + - `configurations/` — the configuration domain the frontend shares: models, canonicalization, combination enumeration, and the input parser + + An element's own part number and material live on `insertables.part_data`; a `configurations` row exists exactly when the element has parameters to configure. + - `thumbnails/` — rendering and R2 storage (`store.ts`), its Workflow, the routes, and the key and URL scheme the client shares + - `build-checker/` — build issues, the checks that raise them, and the build-status endpoint + - `favorites/`, `search/` ### `src/frontend/` -The React SPA. Key files and folders: - - `main.tsx` — React root; wraps the app in `QueryClientProvider` and `MantineProvider` -- `queries.ts` — React Query query definitions shared across the app -- `routes/` — file-based routes (`__root.tsx`, `init.tsx`, `app/route.tsx`, `app/groups/index.tsx`, `app/groups/$groupId.tsx`) -- `api-utils/` — helpers for talking to the backend (`api.ts` for fetch wrappers, `ui-state.ts` for localStorage state, `library.ts` for library ID helpers) - -Everything else under `src/frontend/` is organized by feature: `cards/`, `insert/`, `favorites/`, `groups/`, `search/`, `settings/`. +- `routes/` — file-based TanStack Router routes +- `lib/` — cross-cutting helpers: `api-client.ts` (fetch wrappers), `query-keys.ts` (every query key in one place), `query-client.ts`, `ui-state.ts` (localStorage state), `refresh.ts`, `notifications.tsx` +- `components/` — UI used by more than one feature, plus the app shell (`app-navbar.tsx`, `alerts.tsx`, `root-error.tsx`) +- `features/` — `library/`, `favorites/`, `insert/`, `search/`, `settings/`, `thumbnails/`, `build-status/`, `auth/`, each with a `queries.ts` and a `components/` directory Other top-level files: @@ -180,6 +179,6 @@ Other top-level files: The app has three access levels, checked on every protected API call: **ADMIN**, **EDITOR**, and **USER**. Admin and editor access currently grant the same permissions (adding, removing, and renaming groups, toggling insertable visibility), but they are kept separate so permissions can be tightened in the future if needed. USER access allows anyone who logs in via OAuth to browse the library, insert parts, and manage their own favorites. -The Worker determines a user's access level in `src/backend/services.ts` by calling the Onshape API to check team membership against the `ADMIN_TEAM` binding. Backend routes that require elevated access are wrapped with `requireEditorMiddleware` or `requireAdminMiddleware` from `src/backend/access-level-utils.ts`. +The Worker determines a user's access level in `src/backend/features/auth/caller.ts` by calling the Onshape API to check team membership against the `ADMIN_TEAM` binding. Backend routes that require elevated access are wrapped with `requireEditorMiddleware` or `requireAdminMiddleware` from `src/backend/features/auth/guards.ts`. During local development, you can bypass the team membership check by setting `ACCESS_LEVEL_OVERRIDE=admin` (or `editor`/`user`) in your `.env` file. diff --git a/drizzle.config.ts b/drizzle.config.ts index 47c3926b3..c71023731 100644 --- a/drizzle.config.ts +++ b/drizzle.config.ts @@ -1,7 +1,7 @@ import { defineConfig } from "drizzle-kit"; export default defineConfig({ - schema: "./src/shared/schema.ts", + schema: "./src/backend/db/schema.ts", out: "./drizzle", dialect: "sqlite", driver: "d1-http", diff --git a/drizzle/0006_split_part_data.sql b/drizzle/0006_split_part_data.sql new file mode 100644 index 000000000..4b2bfb8c8 --- /dev/null +++ b/drizzle/0006_split_part_data.sql @@ -0,0 +1,12 @@ +/* + An element's own part number, name and material were stored as the first entry + of `configurations.records` — the probe of its default configuration. That made + every probed insertable carry a configurations row, even one with no parameters + to configure, and forced "is it configurable?" to test the parameter count. + + That part data moves to `insertables.part_data`, where it describes the element + rather than a configuration of it. Nothing is carried over: the column starts + null and repopulates on the next load, which is also when a configurations row + that holds no parameters is dropped. +*/ +ALTER TABLE `insertables` ADD `part_metadata` text; diff --git a/drizzle/meta/0006_snapshot.json b/drizzle/meta/0006_snapshot.json new file mode 100644 index 000000000..5798cfafd --- /dev/null +++ b/drizzle/meta/0006_snapshot.json @@ -0,0 +1,538 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "2416035c-6b28-4014-a7b3-bdc33f803962", + "prevId": "0005b1c2-3d4e-4f50-9a6b-7c8d9e0f1a2b", + "tables": { + "configurations": { + "name": "configurations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "parameters": { + "name": "parameters", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "records": { + "name": "records", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "build_issues": { + "name": "build_issues", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + } + }, + "indexes": {}, + "foreignKeys": { + "configurations_id_insertables_id_fk": { + "name": "configurations_id_insertables_id_fk", + "tableFrom": "configurations", + "tableTo": "insertables", + "columnsFrom": [ + "id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "favorites": { + "name": "favorites", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "insertable_id": { + "name": "insertable_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "default_configuration": { + "name": "default_configuration", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "favorites_user_id_library_id_insertable_id_unique": { + "name": "favorites_user_id_library_id_insertable_id_unique", + "columns": [ + "user_id", + "library_id", + "insertable_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "favorites_user_id_users_id_fk": { + "name": "favorites_user_id_users_id_fk", + "tableFrom": "favorites", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "favorites_library_id_libraries_id_fk": { + "name": "favorites_library_id_libraries_id_fk", + "tableFrom": "favorites", + "tableTo": "libraries", + "columnsFrom": [ + "library_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "favorites_insertable_id_insertables_id_fk": { + "name": "favorites_insertable_id_insertables_id_fk", + "tableFrom": "favorites", + "tableTo": "insertables", + "columnsFrom": [ + "insertable_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "groups": { + "name": "groups", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version_id": { + "name": "version_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sort_alphabetically": { + "name": "sort_alphabetically", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "small_thumbnail_url": { + "name": "small_thumbnail_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "large_thumbnail_url": { + "name": "large_thumbnail_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "build_issues": { + "name": "build_issues", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "last_loaded_at": { + "name": "last_loaded_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "groups_document_id_library_id_unique": { + "name": "groups_document_id_library_id_unique", + "columns": [ + "document_id", + "library_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "groups_library_id_libraries_id_fk": { + "name": "groups_library_id_libraries_id_fk", + "tableFrom": "groups", + "tableTo": "libraries", + "columnsFrom": [ + "library_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "insertables": { + "name": "insertables", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "element_id": { + "name": "element_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "element_type": { + "name": "element_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "microversion_id": { + "name": "microversion_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_visible": { + "name": "is_visible", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_open_composite": { + "name": "is_open_composite", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "supports_fasten": { + "name": "supports_fasten", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "index_configurations": { + "name": "index_configurations", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "version_id": { + "name": "version_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "vendors": { + "name": "vendors", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "small_thumbnail_url": { + "name": "small_thumbnail_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "large_thumbnail_url": { + "name": "large_thumbnail_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fasten_info": { + "name": "fasten_info", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "part_data": { + "name": "part_data", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "build_issues": { + "name": "build_issues", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "last_loaded_at": { + "name": "last_loaded_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "insertables_group_id_groups_id_fk": { + "name": "insertables_group_id_groups_id_fk", + "tableFrom": "insertables", + "tableTo": "groups", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "insertables_library_id_libraries_id_fk": { + "name": "insertables_library_id_libraries_id_fk", + "tableFrom": "insertables", + "tableTo": "libraries", + "columnsFrom": [ + "library_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "libraries": { + "name": "libraries", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "cache_version": { + "name": "cache_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'system'" + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'frc-design-lib'" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 9ea0bd3d8..391bafada 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -1,48 +1,55 @@ { - "version": "7", - "dialect": "sqlite", - "entries": [ - { - "idx": 0, - "version": "6", - "when": 1785982272297, - "tag": "0000_init", - "breakpoints": true - }, - { - "idx": 1, - "version": "6", - "when": 1786201159430, - "tag": "0001_sad_arclight", - "breakpoints": true - }, - { - "idx": 2, - "version": "6", - "when": 1786201159431, - "tag": "0002_configuration_records", - "breakpoints": true - }, - { - "idx": 3, - "version": "6", - "when": 1786511719906, - "tag": "0003_drop_search_db", - "breakpoints": true - }, - { - "idx": 4, - "version": "6", - "when": 1786511719907, - "tag": "0004_explicit_thumbnail_urls", - "breakpoints": true - }, - { - "idx": 5, - "version": "6", - "when": 1786511719908, - "tag": "0005_index_configurations", - "breakpoints": true - } - ] -} + "version": "7", + "dialect": "sqlite", + "entries": [ + { + "idx": 0, + "version": "6", + "when": 1785982272297, + "tag": "0000_init", + "breakpoints": true + }, + { + "idx": 1, + "version": "6", + "when": 1786201159430, + "tag": "0001_sad_arclight", + "breakpoints": true + }, + { + "idx": 2, + "version": "6", + "when": 1786201159431, + "tag": "0002_configuration_records", + "breakpoints": true + }, + { + "idx": 3, + "version": "6", + "when": 1786511719906, + "tag": "0003_drop_search_db", + "breakpoints": true + }, + { + "idx": 4, + "version": "6", + "when": 1786511719907, + "tag": "0004_explicit_thumbnail_urls", + "breakpoints": true + }, + { + "idx": 5, + "version": "6", + "when": 1786511719908, + "tag": "0005_index_configurations", + "breakpoints": true + }, + { + "idx": 6, + "version": "6", + "when": 1787265951428, + "tag": "0006_split_part_data", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index e7a0fccba..b4e92ea7d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "@mantine/hooks": "^9.5.1", "@mantine/modals": "^9.5.1", "@mantine/notifications": "^9.5.1", - "@tabler/icons-react": "^3.44.0", + "@phosphor-icons/react": "^2.1.10", "@tanstack/react-query": "^5.100.10", "@tanstack/react-router": "^1.169.2", "@tanstack/react-router-devtools": "^1.166.13", @@ -2837,9 +2837,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2861,9 +2858,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2885,9 +2879,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2909,9 +2900,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3030,6 +3018,19 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/@phosphor-icons/react": { + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/@phosphor-icons/react/-/react-2.1.10.tgz", + "integrity": "sha512-vt8Tvq8GLjheAZZYa+YG/pW7HDbov8El/MANW8pOAz4eGxrwhnbfrQZq0Cp4q8zBEu8NIhHdnr+r8thnfRSNYA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": ">= 16.8", + "react-dom": ">= 16.8" + } + }, "node_modules/@poppinss/colors": { "version": "4.1.6", "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz", @@ -3152,9 +3153,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3172,9 +3170,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3192,9 +3187,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3212,9 +3204,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3362,32 +3351,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@tabler/icons": { - "version": "3.44.0", - "resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.44.0.tgz", - "integrity": "sha512-Wn0AOZG9sg0L+bjfMqq4eNhC6pQjIrk94LvvWYNYkY8KH8wC3YILRzQlrnVJc4FUeMxH/AK97QsYCX35H3LndA==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/codecalm" - } - }, - "node_modules/@tabler/icons-react": { - "version": "3.44.0", - "resolved": "https://registry.npmjs.org/@tabler/icons-react/-/icons-react-3.44.0.tgz", - "integrity": "sha512-8+rvzBbVm/1Z3sG3x7GUNAaxIKxwgz8xaMhRs23nrCnMTKRFAhEC+82zAIFeAA0seXdrAGX5HFCkaLpGK2rVHg==", - "license": "MIT", - "dependencies": { - "@tabler/icons": "3.44.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/codecalm" - }, - "peerDependencies": { - "react": ">= 16" - } - }, "node_modules/@tanstack/history": { "version": "1.162.0", "resolved": "https://registry.npmjs.org/@tanstack/history/-/history-1.162.0.tgz", @@ -6340,9 +6303,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -6364,9 +6324,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ diff --git a/package.json b/package.json index 590a229fd..f7672d38c 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "@mantine/hooks": "^9.5.1", "@mantine/modals": "^9.5.1", "@mantine/notifications": "^9.5.1", - "@tabler/icons-react": "^3.44.0", + "@phosphor-icons/react": "^2.1.10", "@tanstack/react-query": "^5.100.10", "@tanstack/react-router": "^1.169.2", "@tanstack/react-router-devtools": "^1.166.13", diff --git a/src/__test_utils__/configuration-fixtures.ts b/src/__test_utils__/configuration-fixtures.ts index db18da3af..ae9be4b51 100644 --- a/src/__test_utils__/configuration-fixtures.ts +++ b/src/__test_utils__/configuration-fixtures.ts @@ -5,11 +5,12 @@ import { ParameterType, type BooleanParameter, + type ConfigurationRecord, type EnumParameter, type QuantityParameter, type UnitInfo -} from "../shared/configuration-models"; -import { QuantityType, Unit } from "../shared/configuration-enums"; +} from "@backend/features/configurations/models"; +import { QuantityType, Unit } from "@backend/features/configurations/enums"; /** Builds an enum parameter whose options are named after their ids. */ export function enumParam( @@ -32,6 +33,16 @@ export function enumParam( }; } +/** A single enum whose N options enumerate to N configurations. */ +export function paramsWithConfigs(count: number): EnumParameter[] { + return [ + enumParam( + "A", + Array.from({ length: count }, (_, i) => `o${i}`) + ) + ]; +} + export function boolParam(id: string): BooleanParameter { return { id, @@ -70,3 +81,15 @@ export const TEST_UNIT_INFO: UnitInfo = { anglePrecision: 3, realPrecision: 3 }; + +/** A probe of one configuration; override whichever fields a test is about. */ +export function configurationRecord( + overrides: Partial = {} +): ConfigurationRecord { + return { + configuration: {}, + hasMultipleParts: false, + isOpenComposite: false, + ...overrides + }; +} diff --git a/src/__test_utils__/insertable-fixtures.ts b/src/__test_utils__/insertable-fixtures.ts index 717c70827..7fd2ef05a 100644 --- a/src/__test_utils__/insertable-fixtures.ts +++ b/src/__test_utils__/insertable-fixtures.ts @@ -2,9 +2,9 @@ * Factories for the load pipeline's insertable shapes. Import directly: the * barrel re-exports Workers-only helpers these tests cannot resolve. */ -import type { InsertableTarget } from "../backend/load/load-common"; -import type { ParsedInsertable } from "../backend/load/load-insertable"; -import { ElementType } from "../shared/types"; +import type { InsertableTarget } from "@backend/features/load/context"; +import type { ParsedInsertable } from "@backend/features/load/load-insertable"; +import { ElementType } from "@backend/lib/onshape/element-type"; import { TEST_GROUP_ID, TEST_LIBRARY_ID, @@ -39,6 +39,7 @@ export function parsedInsertable( fastenInfo: null, isOpenComposite: false, buildIssues: [], + partMetadata: null, configuration: { parameters: [], records: [] }, ...overrides }; diff --git a/src/__test_utils__/mock-onshape-api.ts b/src/__test_utils__/mock-onshape-api.ts index 1af44af83..92008ced9 100644 --- a/src/__test_utils__/mock-onshape-api.ts +++ b/src/__test_utils__/mock-onshape-api.ts @@ -1,4 +1,4 @@ -import { OAuthApi } from "../backend/onshape-api/onshape-api"; +import { OAuthApi } from "@backend/lib/onshape/client"; /** * A thin shell client extending OAuthApi. diff --git a/src/__test_utils__/seed.ts b/src/__test_utils__/seed.ts index 72630ba0d..5db24a1e0 100644 --- a/src/__test_utils__/seed.ts +++ b/src/__test_utils__/seed.ts @@ -1,4 +1,4 @@ -import { type Db } from "../backend/db"; +import { type Db } from "@backend/db/client"; import { configurations, favorites, @@ -6,13 +6,14 @@ import { insertables, libraries, users -} from "../shared/schema"; +} from "@backend/db/schema"; import { ParameterType, type ConfigurationParameter -} from "../shared/configuration-models"; -import { type ElementPath, type InstancePath } from "../shared/onshape-path"; -import { ElementType, LibraryId } from "../shared/types"; +} from "@backend/features/configurations/models"; +import { type ElementPath, type InstancePath } from "@backend/lib/onshape/path"; +import { ElementType } from "@backend/lib/onshape/element-type"; +import { LibraryId } from "@backend/features/library/library-id"; export const TEST_LIBRARY_ID = LibraryId.FRC_DESIGN_LIB; export const TEST_USER_ID = "test-user"; // matches createTestApp's default userId @@ -119,9 +120,12 @@ export async function seedInsertable( } /** Seeds the standard part-studio insertable (ensures library + group). */ -export async function seedPartStudio(db: Db): Promise { +export async function seedPartStudio( + db: Db, + overrides: Partial = {} +): Promise { await seedGroup(db); - return seedInsertable(db); + return seedInsertable(db, overrides); } /** Seeds the standard assembly insertable (ensures library + group). */ diff --git a/src/__test_utils__/test-app.ts b/src/__test_utils__/test-app.ts index a2b0e4f62..82f352d75 100644 --- a/src/__test_utils__/test-app.ts +++ b/src/__test_utils__/test-app.ts @@ -1,5 +1,5 @@ -import { createApp } from "../backend/create-app"; -import { AccessLevel } from "../shared/types"; +import { createApp } from "@backend/app"; +import { AccessLevel } from "@backend/features/auth/access-level"; import { MOCK_ONSHAPE_API, MockOnshapeApi } from "./mock-onshape-api"; export interface TestAppOptions { @@ -19,8 +19,8 @@ export interface TestAppOptions { } /** - * The real app from `createApp`, with Onshape, userId and access level mocked. - * Drive it with `app.request(path, init, env)`. + * The real app from `createApp`, with a stub caller in place of + * `productionCaller`. Drive it with `app.request(path, init, env)`. */ export function createTestApp(options: TestAppOptions = {}) { const signedIn = options.signedIn ?? true; diff --git a/src/backend/access-level-utils.ts b/src/backend/access-level-utils.ts deleted file mode 100644 index e646edecc..000000000 --- a/src/backend/access-level-utils.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { HttpStatus } from "http-status-ts"; -import type { MiddlewareHandler } from "hono"; -import { HTTPException } from "hono/http-exception"; -import { type AppContext, type AppContextEnv } from "./app"; -import { getOnshapeApi, getSessionId } from "./auth"; -import { getAccessLevel } from "./onshape-api/endpoints/users"; -import { hasEditorAccess, type AccessLevel } from "../shared/types"; - -/** How long a resolved access level is cached in KV. */ -const ACCESS_LEVEL_TTL_SECONDS = 60 * 60; - -function accessLevelKey(sessionId: string): string { - return `access-level:${sessionId}`; -} - -/** Returns the caller's access level, memoized in KV by session. */ -export async function getCachedAccessLevel( - c: AppContext -): Promise { - const key = accessLevelKey(getSessionId(c)); - - const cached = await c.env.KV.get(key); - if (cached) return cached as AccessLevel; - - const level = await getAccessLevel( - await getOnshapeApi(c), - c.env.ADMIN_TEAM - ); - await c.env.KV.put(key, level, { - expirationTtl: ACCESS_LEVEL_TTL_SECONDS - }); - return level; -} - -async function requireEditorAccess(c: AppContext): Promise { - const level = await c.var.getAccessLevel(); - if (!hasEditorAccess(level)) { - throw new HTTPException(HttpStatus.FORBIDDEN, { - message: "You must be on the admin team to use this functionality" - }); - } -} - -/** - * Middleware which requires users to be an editor or an admin. - */ -export const requireEditorMiddleware: MiddlewareHandler = async ( - c, - next -) => { - await requireEditorAccess(c); - await next(); -}; diff --git a/src/backend/app.ts b/src/backend/app.ts index 4ba4af115..f5cc5ae80 100644 --- a/src/backend/app.ts +++ b/src/backend/app.ts @@ -1,166 +1,54 @@ -import { type Context, type MiddlewareHandler, Hono } from "hono"; -import type { - AddGroupParams, - LoadLibraryParams, - ThumbnailWorkflowParams -} from "./load/workflows"; -import { LibraryId, type AccessLevel } from "../shared/types"; -import { type OAuthApi } from "./onshape-api/onshape-api"; -import z from "zod"; -import { HTTPException } from "hono/http-exception"; -import { HttpStatus } from "http-status-ts"; - -export interface AppBindings { - DB: D1Database; - KV: KVNamespace; - ASSETS: Fetcher; - /** Thumbnails and search indexes; prefixes keep them apart. */ - BLOB: R2Bucket; - LOAD_LIBRARY_WORKFLOW: Workflow; - ADD_GROUP_WORKFLOW: Workflow; - /** Renders a configuration's thumbnails outside a request; see ThumbnailWorkflow. */ - THUMBNAIL_WORKFLOW: Workflow; - ADMIN_TEAM: string; - ACCESS_LEVEL_OVERRIDE?: string; - /** Testing-only: treat requests as signed in with a fake user. Not for production. */ - FORCE_SIGNED_IN?: string; -} - -interface AppVariables { - /** Internal cache for {@link getOnshapeApi} in auth.ts. */ - onshapeApi?: OAuthApi; - /** Internal cache for isSignedIn in sign-in-utils.ts. */ - signedIn?: boolean; - /** Set by {@link setCacheTtl}; read by {@link cacheMiddleware}. */ - cacheTtl?: number; - /** Injected getters — see {@link AppServices} / `createApp`. */ - getOnshapeApi: () => Promise; - getUserId: () => Promise; - getAccessLevel: () => Promise; - isAuthenticated: () => Promise; -} - -export interface AppContextEnv { - Bindings: AppBindings; - Variables: AppVariables; -} - -export type AppContext = Context; - /** - * Per-request dependencies injected into the app. + * Composition root: binds the caller onto every request and mounts each + * feature's routes. Everything it wires lives in a feature or in lib. */ -export interface AppServices { - getOnshapeApi: () => Promise; - getUserId: () => Promise; - getAccessLevel: () => Promise; - isAuthenticated: () => Promise; -} - -export type AppServicesFactory = (c: AppContext) => AppServices; - -export function getApp() { - return new Hono(); -} - -export function libraryRoute(): string { - return "/library/:libraryId"; -} - -export function getLibraryParam(c: AppContext): LibraryId { - const libraryId = c.req.param("libraryId"); - const parsed = z.enum(LibraryId).safeParse(libraryId); - if (!parsed.success) { - throw new HTTPException(HttpStatus.BAD_REQUEST, { - message: "Invalid libraryId" - }); +import { accessRoutes, authRoutes } from "./features/auth/routes"; +import { buildStatusRoutes } from "./features/build-checker/routes"; +import { configurationRoutes } from "./features/configurations/routes"; +import { entryRoutes } from "./features/entry/routes"; +import { favoriteRoutes } from "./features/favorites/routes"; +import { groupRoutes } from "./features/library/groups/routes"; +import { insertableRoutes } from "./features/library/insertables/routes"; +import { libraryRoutes } from "./features/library/routes"; +import { settingsRoutes } from "./features/settings/routes"; +import { thumbnailRoutes } from "./features/thumbnails/routes"; +import { logger } from "hono/logger"; +import { cacheMiddleware } from "./lib/cache"; +import { bindCaller, getApp, type CallerFactory } from "./lib/context"; +import { errorHandler } from "./lib/errors"; + +const apiRoutes = [ + accessRoutes, + settingsRoutes, + libraryRoutes, + groupRoutes, + insertableRoutes, + configurationRoutes, + thumbnailRoutes, + favoriteRoutes, + buildStatusRoutes +]; + +export function createApp(makeCaller: CallerFactory) { + const app = getApp(); + + // Reaches Workers Logs, which wrangler.jsonc enables. Only /init, /api/* + // and /auth/* run the Worker, so static assets are not logged. + app.use("*", logger()); + + app.use("*", bindCaller(makeCaller)); + + for (const routes of apiRoutes) { + app.route("/api", routes); } - return parsed.data; -} -/** A year — a versioned url's content never changes, only its version does. */ -const IMMUTABLE_CACHE_TTL = 365 * 24 * 3600; + // Per-request redirects carrying OAuth state; never reusable. + app.use("/auth/*", cacheMiddleware()); + app.route("/auth", authRoutes); -const NO_STORE = "private, no-store"; - -export enum CachePolicy { - /** Never stored, anywhere. */ - NO_CACHE = "no-cache", - /** Immutable, but kept out of shared caches. */ - PRIVATE_CACHE = "private", - /** Immutable and the same for every caller. */ - PUBLIC_CACHE = "public" -} + app.route("/", entryRoutes); -export function immutableCacheControl( - policy: CachePolicy.PRIVATE_CACHE | CachePolicy.PUBLIC_CACHE -): string { - return `${policy}, max-age=${IMMUTABLE_CACHE_TTL}, immutable`; -} - -const cacheVersionSchema = z.object({ v: z.string().min(1) }); - -interface CacheOptions { - /** Pass false only when the url is immutable without a `?v=`. */ - versioned?: boolean; -} - -/** Overrides the route's immutable default for a body its url does not pin. */ -export function setCacheTtl(c: AppContext, maxAge: number): void { - c.set("cacheTtl", maxAge); -} - -/** Declares how a route's response may be cached, and enforces what that takes. */ -export function cacheMiddleware( - policy: CachePolicy = CachePolicy.NO_CACHE, - options: CacheOptions = {} -): MiddlewareHandler { - if (policy === CachePolicy.NO_CACHE) { - return async (c, next) => { - await next(); - c.header("Cache-Control", NO_STORE); - }; - } - - const cacheControl = immutableCacheControl(policy); - const versioned = options.versioned ?? true; - - return async (c, next) => { - if (versioned && !cacheVersionSchema.safeParse(c.req.query()).success) { - throw new HTTPException(HttpStatus.BAD_REQUEST, { - message: "Missing cache version" - }); - } - await next(); - // A miss must stay retryable, so only store what succeeded. - if (!c.res.ok) { - c.header("Cache-Control", NO_STORE); - return; - } - const ttl = c.get("cacheTtl"); - c.header( - "Cache-Control", - ttl === undefined ? cacheControl : `${policy}, max-age=${ttl}` - ); - }; -} - -export function insertableRoute(): string { - return "/insertable/:insertableId"; -} - -export function getInsertableParam(c: AppContext): string { - const id = c.req.param("insertableId"); - if (!id) throw new Error("Missing insertableId route param"); - return id; -} - -export function groupRoute(): string { - return "/group/:groupId"; -} + app.onError(errorHandler); -export function getGroupParam(c: AppContext): string { - const id = c.req.param("groupId"); - if (!id) throw new Error("Missing groupId route param"); - return id; + return app; } diff --git a/src/backend/auth.ts b/src/backend/auth.ts deleted file mode 100644 index 4b3ab51e9..000000000 --- a/src/backend/auth.ts +++ /dev/null @@ -1,290 +0,0 @@ -import { HttpStatus } from "http-status-ts"; -import { generateState, OAuth2Client, OAuth2Tokens } from "arctic"; -import { OAuthApi } from "./onshape-api/onshape-api"; -import { type AppContext, getApp } from "./app"; -import { HTTPException } from "hono/http-exception"; -import { getCookie, setCookie } from "hono/cookie"; -import { env } from "cloudflare:workers"; -import { getSessionInfo, getUserId } from "./onshape-api/endpoints/users"; - -const SESSION_COOKIE = "frc-design-app-cookie"; -const LOGIN_TTL = 600; // 10 minutes -const SESSION_TTL = 30 * 24 * 3600; // 30 days - -export function getSessionId(c: AppContext): string { - const sessionId = getCookie(c, SESSION_COOKIE); - if (!sessionId) { - throw new HTTPException(HttpStatus.UNAUTHORIZED, { - message: "Failed to find a valid session" - }); - } - return sessionId; -} - -function userIdKey(sessionId: string): string { - return `user-id:${sessionId}`; -} - -/** Returns the caller's Onshape user id, memoized in KV by session. */ -export async function getCachedUserId(c: AppContext): Promise { - const key = userIdKey(getSessionId(c)); - - const cached = await c.env.KV.get(key); - if (cached) return cached; - - const userId = await getUserId(await getOnshapeApi(c)); - await c.env.KV.put(key, userId, { expirationTtl: SESSION_TTL }); - return userId; -} - -export async function getOnshapeApiFromSessionId( - kv: KVNamespace, - sessionId: string -): Promise { - const tokens = await getTokens(kv, sessionId); - - const refreshCallback = async () => { - const oauthClient = getOauthClient(); - const newTokens = await oauthClient - .refreshAccessToken(TOKEN_ENDPOINT, tokens.refreshToken, []) - .then((refreshed) => makeAuthTokens(refreshed)); - - void saveTokens(kv, sessionId, newTokens); - - return newTokens.accessToken; - }; - - let accessToken = tokens.accessToken; - // If the token expired in the past, refresh immediately - if (tokens.expiresAt <= Date.now()) { - accessToken = await refreshCallback(); - } - - return new OAuthApi(accessToken, refreshCallback); -} - -export function getSessionCompanyId(c: AppContext) { - return c.req.query("sessionCompanyId") ?? "cad"; -} - -export async function isAuthenticated(c: AppContext): Promise { - try { - const onshapeApi = await c.var.getOnshapeApi(); - const sessionInfo = await getSessionInfo(onshapeApi); - const tokenCompanyId = sessionInfo.company?.id ?? "cad"; - const requestCompanyId = getSessionCompanyId(c); - return requestCompanyId === tokenCompanyId; - } catch { - return false; - } -} - -/** - * Creates/caches an Onshape API instance from the AppContext. - * - * Note this function should not be called directly, as it is bound to the context directly. - */ -export async function getOnshapeApi(c: AppContext): Promise { - const cached = c.get("onshapeApi"); - if (cached) return cached; - const sessionId = getSessionId(c); - const api = await getOnshapeApiFromSessionId(c.env.KV, sessionId); - c.set("onshapeApi", api); - return api; -} - -function getOauthClient(): OAuth2Client { - return new OAuth2Client(env.OAUTH_CLIENT_ID, env.OAUTH_CLIENT_SECRET, null); -} - -const AUTH_ENDPOINT = "https://oauth.onshape.com/oauth/authorize"; -const TOKEN_ENDPOINT = "https://oauth.onshape.com/oauth/token"; - -export const authRoutes = getApp(); - -authRoutes.get("/sign-in", async (c) => { - const query = c.req.query(); - - // If Onshape hits this endpoint from, e.g., the user sign in page, they will populate redirectOnshapeUri with that page - let redirectUrl = query.redirectOnshapeUri; - if (!redirectUrl) { - // Otherwise we should have one passed in - redirectUrl = query.redirectUrl; - } - - if (!redirectUrl) { - throw new HTTPException(HttpStatus.BAD_REQUEST, { - message: "Failed to find valid redirectUrl" - }); - } - - // Standalone sign-in omits sessionCompanyId; leave companyId undefined so the - // user can pick their account on Onshape. - const companyId = query.sessionCompanyId; - const authorizationUrl = await doSignIn(c, redirectUrl, companyId); - return c.redirect(authorizationUrl); -}); - -authRoutes.get("/callback", async (c) => { - return doCallback(c); -}); - -/** - * Stores the redirectUrl and state. - * - * Returns the URL the user should be redirected to. - */ -export async function doSignIn( - c: AppContext, - redirectUrl: string, - companyId?: string -): Promise { - const oauthClient = getOauthClient(); - - const state = generateState(); - - // Store the state and redirectUrl so the callback can complete sign-in. - await initSession(c, { state, redirectUrl }); - - const authorizationUrl = oauthClient.createAuthorizationURL( - AUTH_ENDPOINT, - state, - [] - ); - // Onshape-launched sign-in scopes to a company; standalone sign-in omits it - // so the user picks their account. - if (companyId) { - authorizationUrl.searchParams.set("company_id", companyId); - } - return authorizationUrl.toString(); -} - -export async function doCallback(c: AppContext): Promise { - const search = c.req.query() as Record; - - // The user clicked "Deny access" on the sign in page - if (search.error === "access_denied") { - return c.redirect("/grant-denied"); - } - - const session = await getSession(c); - - // There was a problem with the cookie used to store redirect information - if (!session) { - if (isSafari(c.req.raw)) { - return c.redirect("/safari-error"); - } - return c.redirect("/cookie-error"); - } - - if (!search.code || session.state !== search.state) { - throw new HTTPException(HttpStatus.UNAUTHORIZED, { - message: "Invalid response from Onshape" - }); - } - - const oauthClient = getOauthClient(); - - await oauthClient - .validateAuthorizationCode(TOKEN_ENDPOINT, search.code, null) - .then((tokens) => makeAuthTokens(tokens)) - .then((tokens) => saveTokens(c.env.KV, session.sessionId, tokens)); - - return c.redirect(session.redirectUrl); -} - -function isSafari(request: Request): boolean { - const userAgent = request.headers.get("User-Agent") ?? ""; - return ( - userAgent.includes("Safari/") && - userAgent.includes("AppleWebKit/") && - !userAgent.includes("Chrome/") && - !userAgent.includes("CriOS/") && - !userAgent.includes("FxiOS/") && - !userAgent.includes("Edg/") && - !userAgent.includes("OPR/") - ); -} - -interface OAuthSessionData { - state: string; - redirectUrl: string; -} - -async function getSession( - c: AppContext -): Promise<(OAuthSessionData & { sessionId: string }) | null> { - const sessionId = getCookie(c, SESSION_COOKIE); - if (!sessionId) return null; - const raw = await c.env.KV.get(`login-session:${sessionId}`); - if (!raw) return null; - - const session = JSON.parse(raw); - session.sessionId = sessionId; - - void c.env.KV.delete(`login-session:${sessionId}`); - return session; -} - -async function initSession( - c: AppContext, - data: OAuthSessionData -): Promise { - const sessionId = crypto.randomUUID(); - // SameSite=none + secure required because the app runs embedded in an Onshape iframe - setCookie(c, SESSION_COOKIE, sessionId, { - httpOnly: true, - secure: true, - sameSite: "None", - path: "/", - maxAge: SESSION_TTL - }); - - await c.env.KV.put(`login-session:${sessionId}`, JSON.stringify(data), { - expirationTtl: LOGIN_TTL - }); - return sessionId; -} -interface AuthTokens { - accessToken: string; - refreshToken: string; - expiresAt: number; -} - -function makeAuthTokens(tokens: OAuth2Tokens): AuthTokens { - return { - accessToken: tokens.accessToken(), - refreshToken: tokens.refreshToken(), - expiresAt: tokens.accessTokenExpiresAt().getTime() - }; -} - -/** - * Saves a set of tokens into KV. - */ -async function saveTokens( - kv: KVNamespace, - sessionId: string, - tokens: AuthTokens -) { - const tokenString = JSON.stringify(tokens); - await kv.put(`tokens:${sessionId}`, tokenString, { - expirationTtl: SESSION_TTL - }); -} - -/** - * Retrieves a set of tokens from KV. - */ -async function getTokens( - kv: KVNamespace, - sessionId: string -): Promise { - const raw = await kv.get(`tokens:${sessionId}`); - if (!raw) { - throw new HTTPException(HttpStatus.UNAUTHORIZED, { - message: "Failed to find valid auth tokens to use" - }); - } - return JSON.parse(raw) as AuthTokens; -} diff --git a/src/backend/create-app.ts b/src/backend/create-app.ts deleted file mode 100644 index 409d58405..000000000 --- a/src/backend/create-app.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { HTTPException } from "hono/http-exception"; -import { HttpStatus } from "http-status-ts"; -import { eq } from "drizzle-orm"; -import { getDb } from "./db"; -import { users } from "../shared/schema"; -import { DEFAULT_LIBRARY_ID, DEFAULT_SETTINGS } from "../shared/types"; -import { authRoutes, getSessionCompanyId } from "./auth"; -import { - cacheMiddleware, - getApp, - type AppContext, - type AppServicesFactory -} from "./app"; -import { OnshapeRateLimitError } from "./onshape-api/onshape-api"; -import { userRoutes } from "./routes/user"; -import { libraryRoutes } from "./routes/library"; -import { favoriteRoutes } from "./routes/favorites"; -import { thumbnailRoutes } from "./routes/thumbnails"; -import { insertableRoutes } from "./routes/insertables"; -import { groupRoutes } from "./routes/groups"; -import { configurationRoutes } from "./routes/configurations"; -import { buildStatusRoutes } from "./routes/build-status"; - -/** - * Returns the relative URL of the given requestUrl. - * Used as a workaround to get the current URL without breaking in local dev due to Cloudflare stripping the port number. - */ -function getRelativeUrl(requestUrl: string) { - const { pathname, search } = new URL(requestUrl); - return pathname + search; -} - -/** Builds the url the caller resumes at, seeded with the library and theme they last used. */ -async function getEntryUrl(c: AppContext): Promise { - const db = getDb(c.env.DB); - const user = await db - .select({ libraryId: users.libraryId, theme: users.theme }) - .from(users) - .where(eq(users.id, await c.var.getUserId())) - .get(); - - const search = new URL(c.req.url).searchParams; - const systemTheme = search.get("theme"); - if (systemTheme !== null) { - search.set("systemTheme", systemTheme); - } - const currentTheme = user?.theme ?? DEFAULT_SETTINGS.theme; - search.set("theme", currentTheme); - - const libraryId = user?.libraryId ?? DEFAULT_LIBRARY_ID; - return `/app/library/${libraryId}?${search.toString()}`; -} - -/** - * Composition root. `makeServices` is bound onto each request's context, so - * handlers reach Onshape and access level through `c.var`. - */ -export function createApp(makeServices: AppServicesFactory) { - const app = getApp(); - - app.use("*", async (c, next) => { - const services = makeServices(c); - c.set("getOnshapeApi", services.getOnshapeApi); - c.set("getUserId", services.getUserId); - c.set("getAccessLevel", services.getAccessLevel); - c.set("isAuthenticated", services.isAuthenticated); - await next(); - }); - - // Mount all API routes - app.route("/api", userRoutes); - app.route("/api", libraryRoutes); - app.route("/api", favoriteRoutes); - app.route("/api", thumbnailRoutes); - app.route("/api", groupRoutes); - app.route("/api", insertableRoutes); - app.route("/api", configurationRoutes); - app.route("/api", buildStatusRoutes); - // Per-request redirects carrying OAuth state; never reusable. - app.use("/auth/*", cacheMiddleware()); - app.route("/auth", authRoutes); - - // `/init` is the auth-gated entry point. - app.on("GET", "/init", cacheMiddleware(), async (c) => { - if (!(await c.var.isAuthenticated())) { - const currentUrl = getRelativeUrl(c.req.url); - const signInUrl = `/auth/sign-in?redirectUrl=${encodeURIComponent(currentUrl)}&sessionCompanyId=${getSessionCompanyId(c)}`; - return c.redirect(signInUrl); - } - return c.redirect(await getEntryUrl(c)); - }); - - app.onError((err, c) => { - // Surface an Onshape rate limit as a 429 the client can retry, rather - // than blocking the request thread waiting it out. - if (err instanceof OnshapeRateLimitError) { - return c.json( - { - error: "Onshape rate limit reached. Please try again shortly.", - retryAfterSeconds: err.retryAfterSeconds - }, - HttpStatus.TOO_MANY_REQUESTS - ); - } - if (err instanceof HTTPException) { - return err.getResponse(); - } - console.error(err); - return c.json( - { error: "Internal Server Error" }, - HttpStatus.INTERNAL_SERVER_ERROR - ); - }); - - return app; -} diff --git a/src/backend/db.ts b/src/backend/db/client.ts similarity index 78% rename from src/backend/db.ts rename to src/backend/db/client.ts index e7ea730cf..eb93d91d6 100644 --- a/src/backend/db.ts +++ b/src/backend/db/client.ts @@ -1,5 +1,5 @@ import { drizzle } from "drizzle-orm/d1"; -import * as schema from "../shared/schema"; +import * as schema from "./schema"; export function getDb(d1: D1Database) { return drizzle(d1, { schema }); diff --git a/src/shared/schema.ts b/src/backend/db/schema.ts similarity index 85% rename from src/shared/schema.ts rename to src/backend/db/schema.ts index f78aa0264..66009bde6 100644 --- a/src/shared/schema.ts +++ b/src/backend/db/schema.ts @@ -1,19 +1,16 @@ import { sqliteTable, text, integer, unique } from "drizzle-orm/sqlite-core"; -import { - DEFAULT_LIBRARY_ID, - DEFAULT_SETTINGS, - ElementType, - FastenInfo, - LibraryId, - Theme, - Vendor -} from "./types"; +import { ElementType } from "../lib/onshape/element-type"; +import { FastenInfo } from "../features/library/insertables/fasten"; +import { LibraryId } from "../features/library/library-id"; +import { DEFAULT_SETTINGS, Theme } from "../features/settings/settings"; +import { Vendor } from "../features/library/vendors"; import { ParameterValues, ConfigurationParameter, - ConfigurationRecord -} from "./configuration-models"; -import { BuildIssue } from "./build-issues"; + ConfigurationRecord, + PartMetadata +} from "../features/configurations/models"; +import { BuildIssue } from "../features/build-checker/issues"; export const libraries = sqliteTable("libraries", { id: text("id").primaryKey(), @@ -97,6 +94,11 @@ export const insertables = sqliteTable("insertables", { fastenInfo: text("fasten_info", { mode: "json" }).$type(), + // The element's own part identity, probed from its defaults. Null until a + // probe succeeds; a configurable insertable left unindexed never gets one. + partMetadata: text("part_metadata", { + mode: "json" + }).$type(), // Build-time issues flagged by the build checker, recomputed on reload. buildIssues: text("build_issues", { mode: "json" }) .$type() @@ -116,8 +118,8 @@ export const configurations = sqliteTable("configurations", { .$type() .notNull() .default([]), - // One record per configuration we probed (part number + metadata). Empty - // unless the insertable is indexed. Search dedupes these to a part-number map. + // One record per indexed configuration. Empty unless the insertable is + // indexed; the element's own metadata lives on `insertables.partMetadata`. records: text("records", { mode: "json" }) .$type() .notNull() @@ -137,7 +139,7 @@ export const users = sqliteTable("users", { libraryId: text("library_id") .$type() .notNull() - .default(DEFAULT_LIBRARY_ID) + .default(DEFAULT_SETTINGS.libraryId) }); export const favorites = sqliteTable( diff --git a/src/backend/features/auth/access-level.ts b/src/backend/features/auth/access-level.ts new file mode 100644 index 000000000..4693853f7 --- /dev/null +++ b/src/backend/features/auth/access-level.ts @@ -0,0 +1,43 @@ +/** The permission tiers the app grants, and the predicates routes gate on. */ +export enum AccessLevel { + ADMIN = "admin", + EDITOR = "editor", + USER = "user" +} + +export function hasAdminAccess(accessLevel: AccessLevel) { + return accessLevel === AccessLevel.ADMIN; +} + +export function hasEditorAccess(accessLevel: AccessLevel) { + return ( + accessLevel === AccessLevel.ADMIN || accessLevel === AccessLevel.EDITOR + ); +} + +export function hasUserAccess(accessLevel: AccessLevel) { + return accessLevel === AccessLevel.USER; +} + +const ACCESS_LEVEL_RANK: Record = { + [AccessLevel.USER]: 0, + [AccessLevel.EDITOR]: 1, + [AccessLevel.ADMIN]: 2 +}; + +/** Whether `accessLevel` grants no more than `maxAccessLevel` does. */ +export function isWithinAccessLevel( + accessLevel: AccessLevel, + maxAccessLevel: AccessLevel +): boolean { + return ACCESS_LEVEL_RANK[accessLevel] <= ACCESS_LEVEL_RANK[maxAccessLevel]; +} + +/** + * Server-provided access: the highest level granted plus sign-in state. The + * level the app is currently viewed as is client-side (see useAccessData). + */ +export interface AccessData { + maxAccessLevel: AccessLevel; + signedIn: boolean; +} diff --git a/src/backend/features/auth/caller.ts b/src/backend/features/auth/caller.ts new file mode 100644 index 000000000..1de611342 --- /dev/null +++ b/src/backend/features/auth/caller.ts @@ -0,0 +1,170 @@ +/** + * Resolves who is calling from their session, memoized in KV. `createApp` binds + * `productionCaller` onto every request; guards and routes read it via `c.var`. + */ +import { env as processEnv } from "process"; +import { OAuthApi } from "../../lib/onshape/client"; +import { + getAccessLevel, + getSessionInfo, + getUserId +} from "../../lib/onshape/endpoints/users"; +import { type AppContext, type CallerFactory } from "../../lib/context"; +import { AccessLevel } from "./access-level"; +import { + getOauthClient, + makeAuthTokens, + TOKEN_ENDPOINT +} from "./onshape-oauth"; +import { + getSession, + getSessionCompanyId, + getSessionId, + saveSession +} from "./session"; + +/** How long a resolved access level is cached in KV. */ +const ACCESS_LEVEL_TTL_SECONDS = 60 * 60; + +/** Stable fake user id used for FORCE_SIGNED_IN testing sessions. */ +export const FORCE_SIGNED_IN_USER_ID = "force-signed-in-user"; + +export async function getOnshapeApiFromSessionId( + kv: KVNamespace, + sessionId: string +): Promise { + const session = await getSession(kv, sessionId); + + const refreshCallback = async () => { + const oauthClient = getOauthClient(); + const newTokens = await oauthClient + .refreshAccessToken(TOKEN_ENDPOINT, session.refreshToken, []) + .then((refreshed) => makeAuthTokens(refreshed)); + + // Spread, so a refresh keeps the userId the session already resolved. + void saveSession(kv, sessionId, { ...session, ...newTokens }); + + return newTokens.accessToken; + }; + + let accessToken = session.accessToken; + // If the token expired in the past, refresh immediately + if (session.expiresAt <= Date.now()) { + accessToken = await refreshCallback(); + } + + return new OAuthApi(accessToken, refreshCallback); +} + +/** + * Creates/caches an Onshape API instance from the AppContext. + * + * Note this function should not be called directly, as it is bound to the context directly. + */ +export async function getOnshapeApi(c: AppContext): Promise { + const cached = c.get("onshapeApi"); + if (cached) return cached; + const api = await getOnshapeApiFromSessionId(c.env.KV, getSessionId(c)); + c.set("onshapeApi", api); + return api; +} + +/** Returns the caller's Onshape user id, resolved once and kept on the session. */ +export async function getCachedUserId(c: AppContext): Promise { + const sessionId = getSessionId(c); + const session = await getSession(c.env.KV, sessionId); + if (session.userId) return session.userId; + + const userId = await getUserId(await getOnshapeApi(c)); + await saveSession(c.env.KV, sessionId, { ...session, userId }); + return userId; +} + +export async function isAuthenticated(c: AppContext): Promise { + try { + const onshapeApi = await c.var.getOnshapeApi(); + const sessionInfo = await getSessionInfo(onshapeApi); + const tokenCompanyId = sessionInfo.company?.id ?? "cad"; + return getSessionCompanyId(c) === tokenCompanyId; + } catch { + return false; + } +} + +/** FORCE_SIGNED_IN is a dev-only escape hatch, ignored in production. */ +export function isForceSignedIn(c: AppContext): boolean { + return !!c.env.FORCE_SIGNED_IN && processEnv.NODE_ENV !== "production"; +} + +/** + * Whether the caller has a valid Onshape session, memoized on the request. + * `FORCE_SIGNED_IN` forces it true for testing. + */ +export async function isSignedIn(c: AppContext): Promise { + const cached = c.get("signedIn"); + if (cached !== undefined) return cached; + + let signedIn: boolean; + if (isForceSignedIn(c)) { + signedIn = true; + } else { + try { + await c.var.getOnshapeApi(); + signedIn = true; + } catch { + signedIn = false; + } + } + + c.set("signedIn", signedIn); + return signedIn; +} + +function accessLevelKey(sessionId: string): string { + return `access-level:${sessionId}`; +} + +/** Returns the caller's access level, memoized in KV by session. */ +export async function getCachedAccessLevel( + c: AppContext +): Promise { + const key = accessLevelKey(getSessionId(c)); + + const cached = await c.env.KV.get(key); + if (cached) return cached as AccessLevel; + + const level = await getAccessLevel( + await getOnshapeApi(c), + c.env.ADMIN_TEAM + ); + await c.env.KV.put(key, level, { + expirationTtl: ACCESS_LEVEL_TTL_SECONDS + }); + return level; +} + +/** + * Production wiring. getUserId only runs behind requireSignInMiddleware; + * getAccessLevel falls back to USER for anyone without a real Onshape session. + */ +export const productionCaller: CallerFactory = (c) => ({ + getOnshapeApi: () => getOnshapeApi(c), + getUserId: () => { + // FORCE_SIGNED_IN has no real Onshape session; use a stable fake id. + if (isForceSignedIn(c)) { + return Promise.resolve(FORCE_SIGNED_IN_USER_ID); + } + return getCachedUserId(c); + }, + getAccessLevel: async () => { + const override = c.env.ACCESS_LEVEL_OVERRIDE; + if (override) return override as AccessLevel; + // getCachedAccessLevel needs a real Onshape session, so only call it + // for a genuinely signed-in caller (not FORCE_SIGNED_IN). + if (!isForceSignedIn(c) && (await isSignedIn(c))) { + return getCachedAccessLevel(c); + } + return AccessLevel.USER; + }, + isAuthenticated: () => isAuthenticated(c) +}); diff --git a/src/backend/routes/not-signed-in.test.ts b/src/backend/features/auth/guards.test.ts similarity index 54% rename from src/backend/routes/not-signed-in.test.ts rename to src/backend/features/auth/guards.test.ts index 038bafead..79d20848d 100644 --- a/src/backend/routes/not-signed-in.test.ts +++ b/src/backend/features/auth/guards.test.ts @@ -1,39 +1,23 @@ import { env } from "cloudflare:workers"; import { beforeEach, describe, expect, it } from "vitest"; -import { AccessLevel, LibraryId, Theme } from "../../shared/types"; +import { AccessLevel } from "./access-level"; +import { LibraryId } from "../library/library-id"; +import { Theme } from "../settings/settings"; import { createTestApp, jsonRequest, resetDb, seedLibrary -} from "../../__test_utils__"; -import { getDb } from "../db"; +} from "../../../__test_utils__"; +import { getDb } from "../../db/client"; const db = getDb(env.DB); -describe("not-signed-in access", () => { +describe("requireSignInMiddleware", () => { beforeEach(async () => { await resetDb(db); }); - it("GET /access-data reports signedIn: false when not signed in", async () => { - const app = createTestApp({ - signedIn: false, - accessLevel: AccessLevel.USER - }); - - const res = await app.request( - "/api/access-data", - jsonRequest("GET"), - env - ); - expect(res.status).toBe(200); - expect(await res.json()).toEqual({ - maxAccessLevel: AccessLevel.USER, - signedIn: false - }); - }); - it("blocks sign-in-only routes with 401 when not signed in", async () => { const app = createTestApp({ signedIn: false }); @@ -45,7 +29,7 @@ describe("not-signed-in access", () => { expect(favorites.status).toBe(401); const userData = await app.request( - "/api/user-data", + "/api/settings", jsonRequest("POST", { theme: Theme.DARK }), env ); @@ -64,3 +48,39 @@ describe("not-signed-in access", () => { expect(favorites.status).toBe(200); }); }); + +describe("requireEditorMiddleware", () => { + beforeEach(async () => { + await resetDb(db); + }); + + // Access level alone would admit a signed-out caller wherever it is + // granted without a session, e.g. behind a dev ACCESS_LEVEL_OVERRIDE. + it("401s an editor-level caller who is not signed in", async () => { + const app = createTestApp({ + signedIn: false, + accessLevel: AccessLevel.ADMIN + }); + + const res = await app.request( + `/api/reload-groups/library/${LibraryId.FRC_DESIGN_LIB}`, + jsonRequest("POST"), + env + ); + expect(res.status).toBe(401); + }); + + it("403s a signed-in caller without editor access", async () => { + const app = createTestApp({ + signedIn: true, + accessLevel: AccessLevel.USER + }); + + const res = await app.request( + `/api/reload-groups/library/${LibraryId.FRC_DESIGN_LIB}`, + jsonRequest("POST"), + env + ); + expect(res.status).toBe(403); + }); +}); diff --git a/src/backend/features/auth/guards.ts b/src/backend/features/auth/guards.ts new file mode 100644 index 000000000..afe15042c --- /dev/null +++ b/src/backend/features/auth/guards.ts @@ -0,0 +1,42 @@ +/** The two gates routes mount: signed in to Onshape at all, and on the admin team. */ +import type { MiddlewareHandler } from "hono"; +import { handledError } from "../../lib/api-error"; +import { HttpStatus } from "http-status-ts"; +import type { AppContext, AppContextEnv } from "../../lib/context"; +import { hasEditorAccess } from "./access-level"; +import { isSignedIn } from "./caller"; + +async function requireSignIn(c: AppContext): Promise { + if (!(await isSignedIn(c))) { + throw handledError( + "You must be signed in to Onshape to use this functionality", + HttpStatus.UNAUTHORIZED + ); + } +} + +export const requireSignInMiddleware: MiddlewareHandler = async ( + c, + next +) => { + await requireSignIn(c); + await next(); +}; + +/** + * Editing implies a session: access level alone would admit a signed-out caller + * under a dev `ACCESS_LEVEL_OVERRIDE`, and answer 403 rather than 401 otherwise. + */ +export const requireEditorMiddleware: MiddlewareHandler = async ( + c, + next +) => { + await requireSignIn(c); + if (!hasEditorAccess(await c.var.getAccessLevel())) { + throw handledError( + "You must be on the admin team to use this functionality", + HttpStatus.FORBIDDEN + ); + } + await next(); +}; diff --git a/src/backend/features/auth/onshape-oauth.ts b/src/backend/features/auth/onshape-oauth.ts new file mode 100644 index 000000000..3b6a0ca01 --- /dev/null +++ b/src/backend/features/auth/onshape-oauth.ts @@ -0,0 +1,105 @@ +/** The Onshape OAuth handshake: where we send the user, and what comes back. */ +import { HttpStatus } from "http-status-ts"; +import { generateState, OAuth2Client, OAuth2Tokens } from "arctic"; +import { internalError } from "../../lib/api-error"; +import { env } from "cloudflare:workers"; +import { type AppContext } from "../../lib/context"; +import { + type AuthTokens, + saveSession, + startLoginSession, + takeLoginSession +} from "./session"; + +const AUTH_ENDPOINT = "https://oauth.onshape.com/oauth/authorize"; +export const TOKEN_ENDPOINT = "https://oauth.onshape.com/oauth/token"; + +export function getOauthClient(): OAuth2Client { + return new OAuth2Client(env.OAUTH_CLIENT_ID, env.OAUTH_CLIENT_SECRET, null); +} + +export function makeAuthTokens(tokens: OAuth2Tokens): AuthTokens { + return { + accessToken: tokens.accessToken(), + refreshToken: tokens.refreshToken(), + expiresAt: tokens.accessTokenExpiresAt().getTime() + }; +} + +/** + * Stores the redirectUrl and state. + * + * Returns the URL the user should be redirected to. + */ +export async function doSignIn( + c: AppContext, + redirectUrl: string, + companyId?: string +): Promise { + const oauthClient = getOauthClient(); + + const state = generateState(); + + // Store the state and redirectUrl so the callback can complete sign-in. + await startLoginSession(c, { state, redirectUrl }); + + const authorizationUrl = oauthClient.createAuthorizationURL( + AUTH_ENDPOINT, + state, + [] + ); + // Onshape-launched sign-in scopes to a company; standalone sign-in omits it + // so the user picks their account. + if (companyId) { + authorizationUrl.searchParams.set("company_id", companyId); + } + return authorizationUrl.toString(); +} + +export async function doCallback(c: AppContext): Promise { + const search = c.req.query() as Record; + + // The user clicked "Deny access" on the sign in page + if (search.error === "access_denied") { + return c.redirect("/grant-denied"); + } + + const session = await takeLoginSession(c); + + // There was a problem with the cookie used to store redirect information + if (!session) { + if (isSafari(c.req.raw)) { + return c.redirect("/safari-error"); + } + return c.redirect("/cookie-error"); + } + + if (!search.code || session.state !== search.state) { + throw internalError( + "Invalid response from Onshape", + HttpStatus.UNAUTHORIZED + ); + } + + const oauthClient = getOauthClient(); + + await oauthClient + .validateAuthorizationCode(TOKEN_ENDPOINT, search.code, null) + .then((tokens) => makeAuthTokens(tokens)) + .then((tokens) => saveSession(c.env.KV, session.sessionId, tokens)); + + return c.redirect(session.redirectUrl); +} + +function isSafari(request: Request): boolean { + const userAgent = request.headers.get("User-Agent") ?? ""; + return ( + userAgent.includes("Safari/") && + userAgent.includes("AppleWebKit/") && + !userAgent.includes("Chrome/") && + !userAgent.includes("CriOS/") && + !userAgent.includes("FxiOS/") && + !userAgent.includes("Edg/") && + !userAgent.includes("OPR/") + ); +} diff --git a/src/backend/features/auth/routes.test.ts b/src/backend/features/auth/routes.test.ts new file mode 100644 index 000000000..57cdcdfb3 --- /dev/null +++ b/src/backend/features/auth/routes.test.ts @@ -0,0 +1,48 @@ +import { env } from "cloudflare:workers"; +import { beforeEach, describe, expect, it } from "vitest"; +import { AccessLevel } from "./access-level"; +import { createTestApp, jsonRequest, resetDb } from "../../../__test_utils__"; +import { getDb } from "../../db/client"; + +const db = getDb(env.DB); + +describe("GET /access-data", () => { + beforeEach(async () => { + await resetDb(db); + }); + + it("returns the caller's access level", async () => { + const app = createTestApp({ accessLevel: AccessLevel.EDITOR }); + + const res = await app.request( + "/api/access-data", + jsonRequest("GET"), + env + ); + + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ + maxAccessLevel: AccessLevel.EDITOR, + signedIn: true + }); + }); + + it("reports signedIn: false when not signed in", async () => { + const app = createTestApp({ + signedIn: false, + accessLevel: AccessLevel.USER + }); + + const res = await app.request( + "/api/access-data", + jsonRequest("GET"), + env + ); + + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ + maxAccessLevel: AccessLevel.USER, + signedIn: false + }); + }); +}); diff --git a/src/backend/features/auth/routes.ts b/src/backend/features/auth/routes.ts new file mode 100644 index 000000000..f9e1f5bf9 --- /dev/null +++ b/src/backend/features/auth/routes.ts @@ -0,0 +1,49 @@ +import { HttpStatus } from "http-status-ts"; +import { internalError } from "../../lib/api-error"; +import { getApp } from "../../lib/context"; +import { cacheMiddleware } from "../../lib/cache"; +import { type AccessData } from "./access-level"; +import { isSignedIn } from "./caller"; +import { doCallback, doSignIn } from "./onshape-oauth"; + +/** The OAuth redirects, mounted at /auth. */ +export const authRoutes = getApp(); + +/** What the app needs to know about the caller, mounted at /api. */ +export const accessRoutes = getApp(); + +/** GET /api/access-data */ +accessRoutes.get("/access-data", cacheMiddleware(), async (c) => { + return c.json({ + maxAccessLevel: await c.var.getAccessLevel(), + signedIn: await isSignedIn(c) + } satisfies AccessData); +}); + +authRoutes.get("/sign-in", async (c) => { + const query = c.req.query(); + + // If Onshape hits this endpoint from, e.g., the user sign in page, they will populate redirectOnshapeUri with that page + let redirectUrl = query.redirectOnshapeUri; + if (!redirectUrl) { + // Otherwise we should have one passed in + redirectUrl = query.redirectUrl; + } + + if (!redirectUrl) { + throw internalError( + "Failed to find valid redirectUrl", + HttpStatus.BAD_REQUEST + ); + } + + // Standalone sign-in omits sessionCompanyId; leave companyId undefined so the + // user can pick their account on Onshape. + const companyId = query.sessionCompanyId; + const authorizationUrl = await doSignIn(c, redirectUrl, companyId); + return c.redirect(authorizationUrl); +}); + +authRoutes.get("/callback", async (c) => { + return doCallback(c); +}); diff --git a/src/backend/features/auth/session.ts b/src/backend/features/auth/session.ts new file mode 100644 index 000000000..f88f9afed --- /dev/null +++ b/src/backend/features/auth/session.ts @@ -0,0 +1,107 @@ +/** Session cookie plus the KV records it keys: the session and login state. */ +import { HttpStatus } from "http-status-ts"; +import { internalError } from "../../lib/api-error"; +import { getCookie, setCookie } from "hono/cookie"; +import { type AppContext } from "../../lib/context"; + +const SESSION_COOKIE = "frc-design-app-cookie"; +const LOGIN_TTL = 600; // 10 minutes +export const SESSION_TTL = 30 * 24 * 3600; // 30 days + +export function getSessionId(c: AppContext): string { + const sessionId = getCookie(c, SESSION_COOKIE); + if (!sessionId) { + throw internalError( + "Failed to find a valid session", + HttpStatus.UNAUTHORIZED + ); + } + return sessionId; +} + +export function getSessionCompanyId(c: AppContext) { + return c.req.query("sessionCompanyId") ?? "cad"; +} + +export interface AuthTokens { + accessToken: string; + refreshToken: string; + expiresAt: number; +} + +/** A signed-in session: what it takes to call Onshape, and who is calling. */ +export interface Session extends AuthTokens { + /** Resolved on first use, since signing in never needs to ask. */ + userId?: string; +} + +/** Still `tokens:`, so sessions signed in before this held a userId survive. */ +function sessionKey(sessionId: string): string { + return `tokens:${sessionId}`; +} + +export async function saveSession( + kv: KVNamespace, + sessionId: string, + session: Session +) { + await kv.put(sessionKey(sessionId), JSON.stringify(session), { + expirationTtl: SESSION_TTL + }); +} + +export async function getSession( + kv: KVNamespace, + sessionId: string +): Promise { + const raw = await kv.get(sessionKey(sessionId)); + if (!raw) { + throw internalError( + "Failed to find valid auth tokens to use", + HttpStatus.UNAUTHORIZED + ); + } + return JSON.parse(raw) as Session; +} + +/** What the callback needs to finish a sign-in it did not start. */ +export interface LoginSession { + state: string; + redirectUrl: string; +} + +/** Single-use: reading it also clears it, so a state cannot be replayed. */ +export async function takeLoginSession( + c: AppContext +): Promise<(LoginSession & { sessionId: string }) | null> { + const sessionId = getCookie(c, SESSION_COOKIE); + if (!sessionId) return null; + const raw = await c.env.KV.get(`login-session:${sessionId}`); + if (!raw) return null; + + const session = JSON.parse(raw); + session.sessionId = sessionId; + + void c.env.KV.delete(`login-session:${sessionId}`); + return session; +} + +export async function startLoginSession( + c: AppContext, + data: LoginSession +): Promise { + const sessionId = crypto.randomUUID(); + // SameSite=none + secure required because the app runs embedded in an Onshape iframe + setCookie(c, SESSION_COOKIE, sessionId, { + httpOnly: true, + secure: true, + sameSite: "None", + path: "/", + maxAge: SESSION_TTL + }); + + await c.env.KV.put(`login-session:${sessionId}`, JSON.stringify(data), { + expirationTtl: LOGIN_TTL + }); + return sessionId; +} diff --git a/src/backend/parse/build-checks.test.ts b/src/backend/features/build-checker/checks.test.ts similarity index 77% rename from src/backend/parse/build-checks.test.ts rename to src/backend/features/build-checker/checks.test.ts index 28cf538b7..6cdb91440 100644 --- a/src/backend/parse/build-checks.test.ts +++ b/src/backend/features/build-checker/checks.test.ts @@ -1,10 +1,11 @@ import { describe, expect, it } from "vitest"; -import { ThumbnailSize, ThumbnailUrls, Vendor } from "../../shared/types"; -import { BuildIssueType } from "../../shared/build-issues"; -import { DEFAULT_CANONICAL_CONFIGURATION } from "../../shared/canonical-configuration"; -import { thumbnailUrl } from "../../shared/thumbnails"; -import { checkGroup, checkInsertable } from "./build-checks"; -import type { ConfigurationRecord } from "../../shared/configuration-models"; +import { ThumbnailSize, ThumbnailUrls } from "../thumbnails/types"; +import { Vendor } from "../library/vendors"; +import { BuildIssueType } from "./issues"; +import { DEFAULT_CANONICAL_CONFIGURATION } from "../configurations/canonical"; +import { thumbnailUrl } from "../thumbnails/keys"; +import { checkGroup, checkInsertable } from "./checks"; +import { configurationRecord } from "../../../__test_utils__/configuration-fixtures"; /** What uploadThumbnails returns: the element's default configuration. */ const THUMBNAILS: ThumbnailUrls = { @@ -55,25 +56,14 @@ describe("checkGroup", () => { }); }); -/** An indexed record; only its part number matters to these checks. */ -function record(partNumber: string | null): ConfigurationRecord { - return { - configuration: {}, - partNumber, - name: null, - description: null, - material: null, - vendor: null, - hasMultipleParts: false, - isUnstableComposite: false - }; -} +/** Only the part number matters to these checks. */ +const record = (partNumber?: string) => configurationRecord({ partNumber }); describe("checkInsertable", () => { const HEALTHY_INSERTABLE = { vendors: [Vendor.REV], thumbnailUrls: THUMBNAILS, - records: [record("217-2600")] + probes: [record("217-2600")] }; it("returns no issues when vendors are parsed and thumbnails generated", () => { @@ -99,7 +89,7 @@ describe("checkInsertable", () => { it("warns when a vendor part indexed without a part number", () => { const issues = checkInsertable({ ...HEALTHY_INSERTABLE, - records: [record(null), record(null)] + probes: [record(), record()] }); expect(issues).toEqual([{ type: BuildIssueType.NO_PART_NUMBER }]); }); @@ -107,7 +97,7 @@ describe("checkInsertable", () => { it("does not warn when only some configurations lack one", () => { const issues = checkInsertable({ ...HEALTHY_INSERTABLE, - records: [record(null), record("217-2600")] + probes: [record(), record("217-2600")] }); expect(issues).toEqual([]); }); @@ -117,14 +107,14 @@ describe("checkInsertable", () => { const issues = checkInsertable({ ...HEALTHY_INSERTABLE, vendors: [Vendor.CUSTOM], - records: [record(null)] + probes: [record()] }); expect(issues).toEqual([]); }); // Nothing was probed, so there is nothing to conclude. it("does not warn when the insertable is not indexed", () => { - const issues = checkInsertable({ ...HEALTHY_INSERTABLE, records: [] }); + const issues = checkInsertable({ ...HEALTHY_INSERTABLE, probes: [] }); expect(issues).toEqual([]); }); }); diff --git a/src/backend/parse/build-checks.ts b/src/backend/features/build-checker/checks.ts similarity index 75% rename from src/backend/parse/build-checks.ts rename to src/backend/features/build-checker/checks.ts index 808fb93fb..1643e83c3 100644 --- a/src/backend/parse/build-checks.ts +++ b/src/backend/features/build-checker/checks.ts @@ -1,10 +1,7 @@ -import { ThumbnailUrls, Vendor, isCustomPart } from "../../shared/types"; -import { - addBuildIssue, - BuildIssue, - BuildIssueType -} from "../../shared/build-issues"; -import type { ConfigurationRecord } from "../../shared/configuration-models"; +import { ThumbnailUrls } from "../thumbnails/types"; +import { Vendor, isCustomPart } from "../library/vendors"; +import { addBuildIssue, BuildIssue, BuildIssueType } from "./issues"; +import type { PartMetadata } from "../configurations/models"; interface GroupCheckInput { /** Whether the Onshape document has a designated thumbnail tab/element. */ @@ -45,8 +42,8 @@ interface InsertableCheckInput { vendors: Vendor[]; /** The uploaded thumbnail URLs, or `null` when generation failed. */ thumbnailUrls: ThumbnailUrls | null; - /** Indexed configuration records; empty when the insertable isn't indexed. */ - records: ConfigurationRecord[]; + /** Every probe of the element: its own, plus one per indexed configuration. */ + probes: (PartMetadata | null)[]; } /** @@ -68,7 +65,7 @@ export function checkInsertable(input: InsertableCheckInput): BuildIssue[] { issues = addBuildIssue( issues, - ...checkIndexedPartNumber(input.vendors, input.records) + ...checkIndexedPartNumber(input.vendors, input.probes) ); return issues; @@ -78,14 +75,16 @@ export function checkInsertable(input: InsertableCheckInput): BuildIssue[] { * A custom part is expected to have no part number; anything a vendor sells * should have one in at least one configuration. */ +/** Every probe of an element: its own, plus one per indexed configuration. */ export function checkIndexedPartNumber( vendors: Vendor[], - records: ConfigurationRecord[] + probes: (PartMetadata | null)[] ): BuildIssue[] { - if (isCustomPart(vendors) || records.length === 0) { + const read = probes.filter((probe) => probe !== null); + if (isCustomPart(vendors) || read.length === 0) { return []; } - return records.some((record) => record.partNumber) + return read.some((probe) => probe.partNumber) ? [] : [{ type: BuildIssueType.NO_PART_NUMBER }]; } diff --git a/src/backend/features/build-checker/contract.ts b/src/backend/features/build-checker/contract.ts new file mode 100644 index 000000000..1e35f8a7f --- /dev/null +++ b/src/backend/features/build-checker/contract.ts @@ -0,0 +1,34 @@ +import { BuildIssue } from "./issues"; +import { ConfigurationParameter } from "../configurations/models"; +import { ElementType } from "../../lib/onshape/element-type"; +import { Vendor } from "../library/vendors"; + +export interface ConfigurationBuildStatus { + buildIssues: BuildIssue[]; + parameters: ConfigurationParameter[]; +} + +export interface GroupBuildStatus { + buildIssues: BuildIssue[]; + sortAlphabetically: boolean; + insertableOrder: string[]; + /** When this group was last successfully loaded (epoch ms); null if never. */ + lastLoadedAt: number | null; +} + +export interface InsertableBuildStatus { + buildIssues: BuildIssue[]; + elementType: ElementType; + isVisible: boolean; + supportsFasten: boolean; + indexConfigurations: boolean; + vendors: Vendor[]; + configuration?: ConfigurationBuildStatus; + /** When this insertable was last successfully loaded (epoch ms); null if never. */ + lastLoadedAt: number | null; +} + +export interface LibraryBuildStatus { + groups: Record; + insertables: Record; +} diff --git a/src/shared/build-issues.test.ts b/src/backend/features/build-checker/issues.test.ts similarity index 57% rename from src/shared/build-issues.test.ts rename to src/backend/features/build-checker/issues.test.ts index 32126d28d..97943c21d 100644 --- a/src/shared/build-issues.test.ts +++ b/src/backend/features/build-checker/issues.test.ts @@ -1,12 +1,13 @@ import { describe, expect, it } from "vitest"; import { addBuildIssue, + hasBuildIssue, BuildIssue, BuildIssueSeverity, BuildIssueType, clearBuildIssue, getMaxSeverity -} from "./build-issues"; +} from "./issues"; /** A representative issue type for each severity. */ const TYPE_BY_SEVERITY: Record = { @@ -24,29 +25,13 @@ describe("getMaxSeverity", () => { expect(getMaxSeverity([])).toBeNull(); }); - it("returns the only severity present", () => { - expect(getMaxSeverity([issue(BuildIssueSeverity.INFO)])).toBe( - BuildIssueSeverity.INFO - ); - }); - - it("returns the worst severity for a mix", () => { - expect( - getMaxSeverity([ - issue(BuildIssueSeverity.INFO), - issue(BuildIssueSeverity.ERROR), - issue(BuildIssueSeverity.WARNING) - ]) - ).toBe(BuildIssueSeverity.ERROR); - }); - - it("ranks warning above info", () => { - expect( - getMaxSeverity([ - issue(BuildIssueSeverity.INFO), - issue(BuildIssueSeverity.WARNING) - ]) - ).toBe(BuildIssueSeverity.WARNING); + const { INFO, WARNING, ERROR } = BuildIssueSeverity; + it.each([ + [[INFO], INFO], + [[INFO, WARNING], WARNING], + [[INFO, ERROR, WARNING], ERROR] + ])("takes the worst of %s", (severities, worst) => { + expect(getMaxSeverity(severities.map(issue))).toBe(worst); }); }); @@ -63,7 +48,41 @@ describe("addBuildIssue", () => { const result = addBuildIssue(existing, { type: BuildIssueType.NO_VENDORS }); - expect(result).toBe(existing); + expect(result).toEqual(existing); + }); + + // Callers hold onto the array they passed in, so it must never be the one + // that comes back, even when there was nothing to add. + it("returns a new array even when nothing is added", () => { + const existing: BuildIssue[] = [{ type: BuildIssueType.NO_VENDORS }]; + expect( + addBuildIssue(existing, { type: BuildIssueType.NO_VENDORS }) + ).not.toBe(existing); + expect(addBuildIssue(existing)).not.toBe(existing); + }); +}); + +describe("hasBuildIssue", () => { + const issues = [ + { type: BuildIssueType.NO_PARTS }, + { type: BuildIssueType.NO_VENDORS } + ]; + + it("finds one of the types asked for", () => { + expect(hasBuildIssue(issues, BuildIssueType.NO_PARTS)).toBe(true); + expect( + hasBuildIssue( + issues, + BuildIssueType.LOAD_FAILED, + BuildIssueType.NO_VENDORS + ) + ).toBe(true); + }); + + it("finds none of them", () => { + expect(hasBuildIssue(issues, BuildIssueType.LOAD_FAILED)).toBe(false); + expect(hasBuildIssue([], BuildIssueType.NO_PARTS)).toBe(false); + expect(hasBuildIssue(issues)).toBe(false); }); }); diff --git a/src/shared/build-issues.ts b/src/backend/features/build-checker/issues.ts similarity index 86% rename from src/shared/build-issues.ts rename to src/backend/features/build-checker/issues.ts index 38732017f..112fac38f 100644 --- a/src/shared/build-issues.ts +++ b/src/backend/features/build-checker/issues.ts @@ -5,7 +5,7 @@ import { AUTO_INDEX_THRESHOLD, MAX_PART_NUMBER_CONFIGURATIONS -} from "./configuration-combinations"; +} from "../configurations/combinations"; export enum BuildIssueSeverity { /** A potential issue that is usually fine, e.g. no vendors parsed. */ @@ -24,8 +24,8 @@ export enum BuildIssueType { NO_PART_NUMBER = "no-part-number", NO_PARTS = "no-parts", NO_UNHIDDEN_INSERTABLES = "no-unhidden-insertables", - TOO_MANY_CONFIGURATIONS = "too-many-configurations", - MANY_CONFIGURATIONS = "many-configurations", + CONFIGURATION_LIMIT_EXCEEDED = "configuration-limit-exceeded", + MANUAL_INDEXING_REQUIRED = "manual-indexing-required", MULTIPLE_PARTS = "multiple-parts", UNSTABLE_COMPOSITE = "unstable-composite", INSERTABLES_FAILED = "insertables-failed", @@ -46,8 +46,8 @@ export type BuildIssue = | BuildIssueOf | BuildIssueOf | BuildIssueOf - | BuildIssueOf - | BuildIssueOf + | BuildIssueOf + | BuildIssueOf | BuildIssueOf | BuildIssueOf | BuildIssueOf @@ -68,9 +68,9 @@ export function getIssueDescription(issue: BuildIssue): string { return "This part studio has no parts"; case BuildIssueType.NO_UNHIDDEN_INSERTABLES: return "No unhidden insertables"; - case BuildIssueType.TOO_MANY_CONFIGURATIONS: + case BuildIssueType.CONFIGURATION_LIMIT_EXCEEDED: return `Over the ${MAX_PART_NUMBER_CONFIGURATIONS} configuration limit, so its configurations cannot be indexed`; - case BuildIssueType.MANY_CONFIGURATIONS: + case BuildIssueType.MANUAL_INDEXING_REQUIRED: return `Over ${AUTO_INDEX_THRESHOLD} configurations, so indexing must be enabled manually`; case BuildIssueType.MULTIPLE_PARTS: return "This part studio has more than one part"; @@ -95,8 +95,8 @@ export function getIssueSeverity(issue: BuildIssue): BuildIssueSeverity { case BuildIssueType.LOAD_FAILED: return BuildIssueSeverity.ERROR; case BuildIssueType.NO_THUMBNAIL_TAB: - case BuildIssueType.TOO_MANY_CONFIGURATIONS: - case BuildIssueType.MANY_CONFIGURATIONS: + case BuildIssueType.CONFIGURATION_LIMIT_EXCEEDED: + case BuildIssueType.MANUAL_INDEXING_REQUIRED: case BuildIssueType.NO_PART_NUMBER: return BuildIssueSeverity.WARNING; case BuildIssueType.NO_VENDORS: @@ -112,15 +112,23 @@ export function addBuildIssue( issues: BuildIssue[], ...newIssues: BuildIssue[] ): BuildIssue[] { - let result = issues; + const result = [...issues]; for (const issue of newIssues) { if (!result.some((existing) => existing.type === issue.type)) { - result = [...result, issue]; + result.push(issue); } } return result; } +/** Whether `issues` holds one of `types`. */ +export function hasBuildIssue( + issues: BuildIssue[], + ...types: BuildIssueType[] +): boolean { + return issues.some((issue) => types.includes(issue.type)); +} + /** * Removes any issue whose type is one of `types`. */ diff --git a/src/backend/routes/build-status.test.ts b/src/backend/features/build-checker/routes.test.ts similarity index 93% rename from src/backend/routes/build-status.test.ts rename to src/backend/features/build-checker/routes.test.ts index c4bc6abfc..646699528 100644 --- a/src/backend/routes/build-status.test.ts +++ b/src/backend/features/build-checker/routes.test.ts @@ -1,7 +1,7 @@ import { eq } from "drizzle-orm"; import { env } from "cloudflare:workers"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { group, insertables } from "../../shared/schema"; +import { group, insertables } from "../../db/schema"; import { TEST_GROUP_ID, TEST_LIBRARY_ID, @@ -9,9 +9,9 @@ import { createTestApp, resetDb, seedPartStudio -} from "../../__test_utils__"; -import { getDb } from "../db"; -import type { LibraryBuildStatus } from "../../shared/api-models"; +} from "../../../__test_utils__"; +import { getDb } from "../../db/client"; +import type { LibraryBuildStatus } from "./contract"; const db = getDb(env.DB); diff --git a/src/backend/routes/build-status.ts b/src/backend/features/build-checker/routes.ts similarity index 89% rename from src/backend/routes/build-status.ts rename to src/backend/features/build-checker/routes.ts index 877175abd..b2b9dace7 100644 --- a/src/backend/routes/build-status.ts +++ b/src/backend/features/build-checker/routes.ts @@ -1,19 +1,15 @@ import { asc, eq, inArray } from "drizzle-orm"; -import { - CachePolicy, - cacheMiddleware, - getApp, - getLibraryParam, - libraryRoute -} from "../app"; -import { getDb } from "../db"; -import { requireEditorMiddleware } from "../access-level-utils"; -import { group, insertables, configurations } from "../../shared/schema"; -import { - type LibraryBuildStatus, - type GroupBuildStatus, - type InsertableBuildStatus -} from "../../shared/api-models"; +import { CachePolicy, cacheMiddleware } from "../../lib/cache"; +import { getApp } from "../../lib/context"; +import { getLibraryParam, libraryRoute } from "../../lib/route-params"; +import { getDb } from "../../db/client"; +import { requireEditorMiddleware } from "../auth/guards"; +import { group, insertables, configurations } from "../../db/schema"; +import type { + LibraryBuildStatus, + GroupBuildStatus, + InsertableBuildStatus +} from "./contract"; export const buildStatusRoutes = getApp(); diff --git a/src/shared/canonical-configuration.test.ts b/src/backend/features/configurations/canonical.test.ts similarity index 96% rename from src/shared/canonical-configuration.test.ts rename to src/backend/features/configurations/canonical.test.ts index 316fa88b1..9e847fe6f 100644 --- a/src/shared/canonical-configuration.test.ts +++ b/src/backend/features/configurations/canonical.test.ts @@ -5,14 +5,14 @@ import { canonicalConfigurationKey, canonicalizeConfiguration, encodeCanonicalConfiguration -} from "./canonical-configuration"; -import { ParameterValues, VisibilityType } from "./configuration-models"; -import { QuantityType, Unit } from "./configuration-enums"; +} from "./canonical"; +import { ParameterValues, VisibilityType } from "./models"; +import { QuantityType, Unit } from "./enums"; import { boolParam, enumParam, quantityParam -} from "../__test_utils__/configuration-fixtures"; +} from "../../../__test_utils__/configuration-fixtures"; /** The key of an already-canonical selection, which is what callers compare. */ function keyOf(canonicalConfiguration: ParameterValues): string { diff --git a/src/shared/canonical-configuration.ts b/src/backend/features/configurations/canonical.ts similarity index 96% rename from src/shared/canonical-configuration.ts rename to src/backend/features/configurations/canonical.ts index 696f85042..069424f20 100644 --- a/src/shared/canonical-configuration.ts +++ b/src/backend/features/configurations/canonical.ts @@ -6,9 +6,9 @@ import { type ConfigurationParameter, ParameterType, type ParameterValues -} from "./configuration-models"; -import { QuantityType, Unit, getUnitDisplayStr } from "./configuration-enums"; -import { evaluateCondition } from "./configuration-utils"; +} from "./models"; +import { QuantityType, Unit, getUnitDisplayStr } from "./enums"; +import { evaluateCondition } from "./utils"; import { evaluateBaseValue } from "./input-parser"; /** The element default, which is what an empty canonical configuration encodes. */ diff --git a/src/shared/configuration-combinations.test.ts b/src/backend/features/configurations/combinations.test.ts similarity index 87% rename from src/shared/configuration-combinations.test.ts rename to src/backend/features/configurations/combinations.test.ts index 0ebfdff52..1b68ed1b7 100644 --- a/src/shared/configuration-combinations.test.ts +++ b/src/backend/features/configurations/combinations.test.ts @@ -7,33 +7,21 @@ import { isIndexedParameter, isIndexingEnabled, MAX_PART_NUMBER_CONFIGURATIONS -} from "./configuration-combinations"; +} from "./combinations"; import { OptionVisibilityType, ConfigurationParameter, ParameterType, - QuantityParameter, StringParameter, VisibilityCondition, VisibilityType -} from "./configuration-models"; -import { boolParam, enumParam } from "../__test_utils__/configuration-fixtures"; -import { QuantityType, Unit } from "./configuration-enums"; - -function quantityParam(id: string): QuantityParameter { - return { - id, - name: id, - default: "0", - isCosmetic: false, - type: ParameterType.QUANTITY, - quantityType: QuantityType.LENGTH, - defaultValue: 0, - min: 0, - max: 10, - unit: Unit.MILLIMETER - }; -} +} from "./models"; +import { + boolParam, + enumParam, + paramsWithConfigs, + quantityParam +} from "../../../__test_utils__/configuration-fixtures"; function stringParam(id: string): StringParameter { return { @@ -145,16 +133,6 @@ describe("enumerateConfigurations", () => { }); describe("countConfigurations", () => { - /** A single enum whose N options enumerate to N configurations. */ - function paramsWithConfigs(count: number): ConfigurationParameter[] { - return [ - enumParam( - "A", - Array.from({ length: count }, (_, i) => `o${i}`) - ) - ]; - } - it("counts an insertable with nothing to vary as having none", () => { expect(countConfigurations([])).toMatchObject({ count: 0, @@ -165,7 +143,7 @@ describe("countConfigurations", () => { // Cosmetic and quantity parameters ride their defaults rather than // multiplying the count, so they leave nothing to vary either. it("ignores parameters that don't vary the build", () => { - const cosmetic = { ...enumParam("A", ["x", "y"]), isCosmetic: true }; + const cosmetic = enumParam("A", ["x", "y"], { isCosmetic: true }); expect(countConfigurations([cosmetic])).toMatchObject({ count: 0, band: IndexingBand.AUTOMATIC @@ -225,14 +203,7 @@ describe("isIndexedParameter", () => { it("never varies quantity or text parameters", () => { expect(isIndexedParameter(quantityParam("q"))).toBe(false); - const text: StringParameter = { - id: "s", - name: "s", - default: "", - isCosmetic: false, - type: ParameterType.STRING - }; - expect(isIndexedParameter(text)).toBe(false); + expect(isIndexedParameter(stringParam("s"))).toBe(false); }); it("does not vary a parameter excluded from properties", () => { diff --git a/src/shared/configuration-combinations.ts b/src/backend/features/configurations/combinations.ts similarity index 96% rename from src/shared/configuration-combinations.ts rename to src/backend/features/configurations/combinations.ts index 0aea56057..f9cde0be0 100644 --- a/src/shared/configuration-combinations.ts +++ b/src/backend/features/configurations/combinations.ts @@ -8,8 +8,8 @@ import { ConfigurationParameter, EnumParameter, ParameterType -} from "./configuration-models"; -import { evaluateCondition, getVisibleOptions } from "./configuration-utils"; +} from "./models"; +import { evaluateCondition, getVisibleOptions } from "./utils"; /** * The most combinations we enumerate for one insertable; beyond it nothing is @@ -19,7 +19,7 @@ export const MAX_PART_NUMBER_CONFIGURATIONS = 512; /** * At or above this, indexing waits for an admin, who can trim the count back - * with "exclude from properties"; see the `MANY_CONFIGURATIONS` build issue. + * with "exclude from properties"; see the `MANUAL_INDEXING_REQUIRED` build issue. */ export const AUTO_INDEX_THRESHOLD = 128; diff --git a/src/shared/configuration-enums.ts b/src/backend/features/configurations/enums.ts similarity index 100% rename from src/shared/configuration-enums.ts rename to src/backend/features/configurations/enums.ts diff --git a/src/backend/features/configurations/input-parser.test.ts b/src/backend/features/configurations/input-parser.test.ts new file mode 100644 index 000000000..fd0ee4215 --- /dev/null +++ b/src/backend/features/configurations/input-parser.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; +import { QuantityType, Unit } from "./enums"; +import { + evaluateExpression, + EvaluateOptions, + Result, + valueWithUnits +} from "./input-parser"; + +const defaultOptions = ( + quantityType: QuantityType = QuantityType.LENGTH, + displayUnit: Unit = Unit.MILLIMETER +): EvaluateOptions => ({ + quantityType, + displayPrecision: 2, + displayUnit, + min: valueWithUnits(-100, displayUnit), + max: valueWithUnits(100, displayUnit) +}); + +/** Every valid case reads the same way: an expression in, a display string out. */ +describe("evaluateExpression", () => { + const REAL = defaultOptions(QuantityType.REAL, Unit.UNITLESS); + const LENGTH = defaultOptions(); + const DEGREES = defaultOptions(QuantityType.ANGLE, Unit.DEGREE); + + it.each([ + ["42", REAL, "42"], + ["10 mm", LENGTH, "10 mm"], + ["5 mm + 5 mm", LENGTH, "10 mm"], + ["15 mm - 5 mm", LENGTH, "10 mm"], + ["2 * 5 mm", LENGTH, "10 mm"], + ["10 / 2 mm", LENGTH, "5 mm"], + ["(2 + 3) mm", LENGTH, "5 mm"], + ["-5 mm", LENGTH, "-5 mm"], + ["90 deg", DEGREES, "90 deg"], + // The display unit fills in for an expression that names none. + ["5", LENGTH, "5 mm"], + [" 7 mm + 3 mm ", LENGTH, "10 mm"] + ])("evaluates %s", (expression, options, display) => { + const result = evaluateExpression(expression, options); + expect(result.hasError).toBe(false); + expect((result as Result).displayExpression).toBe(display); + }); + + it("evaluates an angle in radians", () => { + const result = evaluateExpression( + "3.14159265359 rad", + defaultOptions(QuantityType.ANGLE, Unit.RADIAN) + ); + expect(result.hasError).toBe(false); + expect((result as Result).displayExpression).toContain("rad"); + }); + + it.each([ + ["", "there is nothing to evaluate"], + ["5 bananas", "the unit is not one we know"], + ["5 mm + 2 deg", "the units do not match"], + ["10 mm / 0", "it divides by zero"], + ["5 +", "the syntax is incomplete"], + ["(5 mm) mm", "a unit is applied to something already dimensioned"], + ["5 mm * 2 mm", "two units are multiplied"], + ["5 mm / 2 mm", "two units are divided"], + ["-100.001 mm", "it falls below the minimum"], + ["100.001 mm", "it rises above the maximum"] + ])("rejects %s, since %s", (expression) => { + expect(evaluateExpression(expression, LENGTH).hasError).toBe(true); + }); +}); diff --git a/src/shared/input-parser.ts b/src/backend/features/configurations/input-parser.ts similarity index 99% rename from src/shared/input-parser.ts rename to src/backend/features/configurations/input-parser.ts index a849f3c7f..7d9848d58 100644 --- a/src/shared/input-parser.ts +++ b/src/backend/features/configurations/input-parser.ts @@ -11,7 +11,7 @@ import { seq, tok } from "typescript-parsec"; -import { getUnitDisplayStr, QuantityType, Unit } from "./configuration-enums"; +import { getUnitDisplayStr, QuantityType, Unit } from "./enums"; class ParseError extends Error { constructor(message: string) { diff --git a/src/shared/configuration-models.ts b/src/backend/features/configurations/models.ts similarity index 80% rename from src/shared/configuration-models.ts rename to src/backend/features/configurations/models.ts index 866fcceac..e4b9e612a 100644 --- a/src/shared/configuration-models.ts +++ b/src/backend/features/configurations/models.ts @@ -1,4 +1,4 @@ -import { LogicalOp, QuantityType, Unit } from "./configuration-enums"; +import { LogicalOp, QuantityType, Unit } from "./enums"; /** Discriminator of a parsed configuration parameter. */ export enum ParameterType { @@ -81,8 +81,10 @@ export interface ConfigurationResult { * the index and the `/configuration` route can share it. */ export interface SearchRecord { - partNumber: string | null; - name: string | null; + partNumber?: string; + name?: string; + /** The vendor's page for this part, when one can be resolved. */ + url?: string; /** The (enumerated) parameter values that produce it; empty for the default. */ configuration: ParameterValues; } @@ -139,19 +141,32 @@ export type ParameterValues = Record; * What one probed configuration resolves to. Stored per probe, so search and the * UI can read it back without re-querying Onshape. */ -export interface ConfigurationRecord { - /** The parameter values that produce it; empty means the element's defaults. */ - configuration: ParameterValues; - partNumber: string | null; - name: string | null; - description: string | null; +/** + * The part an element resolves to, as one probe read it: from the element's own + * defaults it describes the element, from one configuration a + * {@link ConfigurationRecord}. + */ +export interface PartMetadata { + partNumber?: string; + name?: string; + description?: string; /** Material display name, e.g. "6061 Aluminum". */ - material: string | null; - vendor: string | null; - /** True when a part studio resolved to more than one part. */ + material?: string; + /** Onshape's own, or parsed from the part and its options when it has none. */ + vendor?: string; + /** True when the part studio resolved to more than one part. */ hasMultipleParts: boolean; - /** True when an open composite lost its composite in this configuration. */ - isUnstableComposite: boolean; + /** Whether this probe resolved to an open composite. */ + isOpenComposite: boolean; +} + +/** + * {@link PartMetadata} for one specific configuration — the same fields, plus + * the parameter values that produced them. Stored only for an indexed insertable. + */ +export interface ConfigurationRecord extends PartMetadata { + /** The parameter values that produce it. */ + configuration: ParameterValues; } /** diff --git a/src/backend/routes/configurations.test.ts b/src/backend/features/configurations/routes.test.ts similarity index 54% rename from src/backend/routes/configurations.test.ts rename to src/backend/features/configurations/routes.test.ts index e49afecc8..d606233cf 100644 --- a/src/backend/routes/configurations.test.ts +++ b/src/backend/features/configurations/routes.test.ts @@ -1,6 +1,6 @@ import { env } from "cloudflare:workers"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { QuantityType, Unit } from "../../shared/configuration-enums"; +import { QuantityType, Unit } from "./enums"; import { TEST_PART_STUDIO_ID, createTestApp, @@ -10,9 +10,9 @@ import { seedPartStudio, TEST_INSTANCE_PATH, TEST_PARAMETERS -} from "../../__test_utils__"; -import { getDb } from "../db"; -import * as DocumentEndpoints from "../onshape-api/endpoints/documents"; +} from "../../../__test_utils__"; +import { getDb } from "../../db/client"; +import * as DocumentEndpoints from "../../lib/onshape/endpoints/documents"; const db = getDb(env.DB); @@ -23,13 +23,13 @@ describe("configuration routes", () => { afterEach(() => vi.restoreAllMocks()); - it("GET /configuration/:id returns the stored parameters", async () => { + it("GET /configuration/insertable/:insertableId returns the stored parameters", async () => { await seedPartStudio(db); await seedConfiguration(db); const app = createTestApp(); const res = await app.request( - `/api/configuration/${TEST_PART_STUDIO_ID}?v=abc123`, + `/api/configuration/insertable/${TEST_PART_STUDIO_ID}?v=abc123`, jsonRequest("GET"), env ); @@ -39,10 +39,46 @@ describe("configuration routes", () => { expect(body).toEqual({ parameters: TEST_PARAMETERS, records: [] }); }); - it("GET /configuration/:id 404s for an unknown id", async () => { + // The element's own part data is the record an unset configuration falls + // back to, and it lives on the insertable, not in a configurations row. + it("GET /configuration/insertable/:insertableId serves the element's own part data as a record", async () => { + await seedPartStudio(db, { + partMetadata: { + partNumber: "WCP-0405", + name: "2x1 Tube", + description: undefined, + material: undefined, + vendor: undefined, + hasMultipleParts: false, + isOpenComposite: false + } + }); + const app = createTestApp(); + + const res = await app.request( + `/api/configuration/insertable/${TEST_PART_STUDIO_ID}?v=abc123`, + jsonRequest("GET"), + env + ); + expect(res.status).toBe(200); + + expect(await res.json()).toEqual({ + parameters: [], + records: [ + { + partNumber: "WCP-0405", + name: "2x1 Tube", + url: "https://wcproducts.com/products/wcp-0405", + configuration: {} + } + ] + }); + }); + + it("GET /configuration/insertable/:insertableId 404s for an unknown id", async () => { const app = createTestApp(); const res = await app.request( - "/api/configuration/missing?v=abc123", + "/api/configuration/insertable/missing?v=abc123", jsonRequest("GET"), env ); diff --git a/src/backend/features/configurations/routes.ts b/src/backend/features/configurations/routes.ts new file mode 100644 index 000000000..7710dbc4b --- /dev/null +++ b/src/backend/features/configurations/routes.ts @@ -0,0 +1,101 @@ +import { eq } from "drizzle-orm"; +import { CachePolicy, cacheMiddleware } from "../../lib/cache"; +import { z } from "zod"; +import { validate } from "../../lib/validate"; +import { getApp } from "../../lib/context"; +import { getInsertableParam, insertableRoute } from "../../lib/route-params"; +import { getDb } from "../../db/client"; +import { getUnitInfo } from "../../lib/onshape/endpoints/documents"; +import { configurations, insertables } from "../../db/schema"; +import { type ConfigurationResult, type UnitInfo } from "./models"; +import { toSearchRecords } from "../search/search-index"; +import { toRecords } from "./utils"; +import { QuantityType, type Unit } from "./enums"; +import { INSTANCE_TYPES } from "../../lib/onshape/path"; +import { internalError } from "../../lib/api-error"; +import { HttpStatus } from "http-status-ts"; + +export const configurationRoutes = getApp(); + +const instancePathQuery = z.object({ + documentId: z.string().min(1), + instanceId: z.string().min(1), + instanceType: z.enum(INSTANCE_TYPES) +}); + +/** GET /api/configuration/insertable/:insertableId?v=:microversionId */ +configurationRoutes.get( + "/configuration" + insertableRoute(), + cacheMiddleware(CachePolicy.PUBLIC_CACHE), + async (c) => { + const insertableId = getInsertableParam(c); + const db = getDb(c.env.DB); + // Left join: the element's own part data is the fallback record, and it + // lives on the insertable whether or not it is configurable. + const config = await db + .select({ + partMetadata: insertables.partMetadata, + vendors: insertables.vendors, + parameters: configurations.parameters, + records: configurations.records + }) + .from(insertables) + .leftJoin(configurations, eq(configurations.id, insertables.id)) + .where(eq(insertables.id, insertableId)) + .get(); + + if (!config) { + throw internalError( + "Failed to find configuration", + HttpStatus.NOT_FOUND + ); + } + + const result: ConfigurationResult = { + parameters: config.parameters ?? [], + records: toSearchRecords( + toRecords(config.partMetadata, config.records ?? []), + config.vendors + ) + }; + return c.json(result); + } +); + +/** GET /api/unit-info?documentId=X&instanceId=Y&instanceType=v */ +configurationRoutes.get( + "/unit-info", + cacheMiddleware(), + validate("query", instancePathQuery), + async (c) => { + const onshapeApi = await c.var.getOnshapeApi(); + const instancePath = c.req.valid("query"); + + const rawUnitInfo = await getUnitInfo(onshapeApi, instancePath); + const units: OnshapeUnit[] = rawUnitInfo.defaultUnits.units; + + const angleUnit = getDefaultUnit(units, QuantityType.ANGLE); + const lengthUnit = getDefaultUnit(units, QuantityType.LENGTH); + + const result: UnitInfo = { + angleUnit, + lengthUnit, + anglePrecision: rawUnitInfo.unitsDisplayPrecision[angleUnit], + lengthPrecision: rawUnitInfo.unitsDisplayPrecision[lengthUnit], + realPrecision: 3 + }; + return c.json(result); + } +); + +interface OnshapeUnit { + key: QuantityType; + value: Unit; +} + +function getDefaultUnit( + units: OnshapeUnit[], + quantityType: QuantityType +): Unit { + return units.find((u) => u.key === quantityType)!.value; +} diff --git a/src/backend/features/configurations/utils.test.ts b/src/backend/features/configurations/utils.test.ts new file mode 100644 index 000000000..668018524 --- /dev/null +++ b/src/backend/features/configurations/utils.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from "vitest"; +import { findRecordForConfiguration, getPartUrl } from "./utils"; +import { PartMetadata, SearchRecord } from "./models"; +import { Vendor } from "../library/vendors"; + +function rec( + configuration: Record, + partNumber = "PN" +): SearchRecord { + return { partNumber, configuration }; +} + +describe("findRecordForConfiguration", () => { + it("returns the record whose enumerated values match the selection", () => { + const records = [ + rec({ size: "s" }, "PN-S"), + rec({ size: "l" }, "PN-L") + ]; + // The selection also carries a non-enumerated (quantity) param, ignored. + expect( + findRecordForConfiguration({ size: "l", qty: "3" }, records) + ?.partNumber + ).toBe("PN-L"); + }); + + it("prefers the most specific match when several apply", () => { + const records = [rec({}, "default"), rec({ size: "l" }, "PN-L")]; + expect( + findRecordForConfiguration({ size: "l" }, records)?.partNumber + ).toBe("PN-L"); + }); + + it("falls back to a shorter key-set when a parameter is hidden", () => { + const records = [ + rec({ mode: "a", detail: "x" }, "A-X"), + // `detail` is hidden when mode=b, so this record omits it. + rec({ mode: "b" }, "B") + ]; + expect( + findRecordForConfiguration({ mode: "b", detail: "x" }, records) + ?.partNumber + ).toBe("B"); + }); + + it("returns undefined when nothing matches", () => { + const records = [rec({ size: "s" }, "PN-S")]; + expect( + findRecordForConfiguration({ size: "l" }, records) + ).toBeUndefined(); + }); +}); + +function metadata(fields: Partial): PartMetadata { + return { hasMultipleParts: false, isOpenComposite: false, ...fields }; +} + +describe("getPartUrl", () => { + it("prefers a description that is already a url, naming the exact product", () => { + const url = getPartUrl( + metadata({ + vendor: "WCP", + partNumber: "WCP-1025", + description: "https://wcproducts.com/products/something-else" + }) + ); + expect(url).toBe("https://wcproducts.com/products/something-else"); + }); + + it("falls back to the vendor's page when the description is prose", () => { + const url = getPartUrl( + metadata({ + vendor: "WCP", + partNumber: "WCP-1025", + description: "A gearbox" + }) + ); + expect(url).toBe("https://wcproducts.com/products/wcp-1025"); + }); + + it("has none for a vendor whose urls cannot be derived", () => { + expect( + getPartUrl(metadata({ vendor: "SDS", partNumber: "sds-1234" })) + ).toBeUndefined(); + }); + + it("falls back to the insertable's vendor when the record names none", () => { + const url = getPartUrl(metadata({ partNumber: "WCP-1025" }), [ + Vendor.WCP + ]); + expect(url).toBe("https://wcproducts.com/products/wcp-1025"); + }); + + it("will not guess between several, which do not say which this is", () => { + expect( + getPartUrl(metadata({ partNumber: "1025" }), [ + Vendor.WCP, + Vendor.MCM + ]) + ).toBeUndefined(); + }); + + // A part configurable across vendors carries a generic vendor, but each + // configuration's number still says who sells that one. + it("reads the vendor out of the part number over a generic tagging", () => { + const url = getPartUrl(metadata({ partNumber: "TTB-0016" }), [ + Vendor.WCP, + Vendor.TTB + ]); + expect(url).toBe( + "https://www.thethriftybot.com/search?type=product&q=TTB-0016" + ); + }); + + it("prefers the record's own vendor over the insertable's", () => { + const url = getPartUrl( + metadata({ vendor: "McMaster-Carr", partNumber: "91251A445" }), + [Vendor.WCP] + ); + expect(url).toBe("https://www.mcmaster.com/91251A445/"); + }); +}); diff --git a/src/shared/configuration-utils.ts b/src/backend/features/configurations/utils.ts similarity index 82% rename from src/shared/configuration-utils.ts rename to src/backend/features/configurations/utils.ts index cbac1ac3e..74a788148 100644 --- a/src/shared/configuration-utils.ts +++ b/src/backend/features/configurations/utils.ts @@ -1,4 +1,6 @@ import { + type ConfigurationRecord, + type PartMetadata, ParameterValues, EnumOption, EnumParameter, @@ -10,8 +12,14 @@ import { UnitInfo, VisibilityCondition, VisibilityType -} from "./configuration-models"; -import { LogicalOp, QuantityType, Unit } from "./configuration-enums"; +} from "./models"; +import { + Vendor, + getVendorPartUrl, + parsePartNumberVendor, + toVendor +} from "../library/vendors"; +import { LogicalOp, QuantityType, Unit } from "./enums"; import { type EvaluateOptions, valueWithUnits } from "./input-parser"; /** @@ -79,6 +87,24 @@ export function evaluateCondition( } return true; } +/** + * The page for a part, in descending precision: a description that is already a + * url, then the vendor the part number names, then the taggings standing in. + */ +export function getPartUrl( + record: PartMetadata, + vendors: Vendor[] = [] +): string | undefined { + if (record.description && /^https?:\/\//i.test(record.description)) { + return record.description; + } + const vendor = + parsePartNumberVendor(record.partNumber) ?? + toVendor(record.vendor) ?? + (vendors.length === 1 ? vendors[0] : undefined); + return getVendorPartUrl(vendor, record.partNumber); +} + export function encodeConfigurationForQuery( configuration?: ParameterValues ): string { @@ -187,3 +213,15 @@ export function getEvaluateOptions( ...minAndMax }; } + +/** + * An insertable's full record list: its own part data first — the record an + * unset configuration falls back to — then one per indexed configuration. + */ +export function toRecords( + partMetadata: PartMetadata | null, + records: ConfigurationRecord[] +): ConfigurationRecord[] { + if (!partMetadata) return records; + return [{ ...partMetadata, configuration: {} }, ...records]; +} diff --git a/src/backend/create-app.test.ts b/src/backend/features/entry/routes.test.ts similarity index 93% rename from src/backend/create-app.test.ts rename to src/backend/features/entry/routes.test.ts index 87b41df09..07a28549b 100644 --- a/src/backend/create-app.test.ts +++ b/src/backend/features/entry/routes.test.ts @@ -1,16 +1,17 @@ import { env } from "cloudflare:workers"; import { beforeEach, describe, expect, it } from "vitest"; import { eq } from "drizzle-orm"; -import { users } from "../shared/schema"; -import { LibraryId, Theme } from "../shared/types"; +import { users } from "../../db/schema"; +import { LibraryId } from "../library/library-id"; +import { Theme } from "../settings/settings"; import { TEST_USER_ID, createTestApp, jsonRequest, resetDb, seedUser -} from "../__test_utils__"; -import { getDb } from "./db"; +} from "../../../__test_utils__"; +import { getDb } from "../../db/client"; const db = getDb(env.DB); diff --git a/src/backend/features/entry/routes.ts b/src/backend/features/entry/routes.ts new file mode 100644 index 000000000..00f08b835 --- /dev/null +++ b/src/backend/features/entry/routes.ts @@ -0,0 +1,50 @@ +/** + * `/init` is where Onshape lands. It gates on auth, then resumes the caller in + * the library and theme they last used. + */ +import { eq } from "drizzle-orm"; +import { getDb } from "../../db/client"; +import { users } from "../../db/schema"; +import { cacheMiddleware } from "../../lib/cache"; +import { getApp, type AppContext } from "../../lib/context"; +import { getSessionCompanyId } from "../auth/session"; +import { DEFAULT_SETTINGS } from "../settings/settings"; + +/** Cloudflare strips the port in local dev, so redirect back relatively. */ +function getRelativeUrl(requestUrl: string) { + const { pathname, search } = new URL(requestUrl); + return pathname + search; +} + +/** Builds the url the caller resumes at, seeded with the library and theme they last used. */ +async function getEntryUrl(c: AppContext): Promise { + const db = getDb(c.env.DB); + const user = await db + .select({ libraryId: users.libraryId, theme: users.theme }) + .from(users) + .where(eq(users.id, await c.var.getUserId())) + .get(); + + const search = new URL(c.req.url).searchParams; + const systemTheme = search.get("theme"); + if (systemTheme !== null) { + search.set("systemTheme", systemTheme); + } + search.set("theme", user?.theme ?? DEFAULT_SETTINGS.theme); + + const libraryId = user?.libraryId ?? DEFAULT_SETTINGS.libraryId; + return `/app/library/${libraryId}?${search.toString()}`; +} + +export const entryRoutes = getApp(); + +/** GET /init */ +entryRoutes.get("/init", cacheMiddleware(), async (c) => { + if (!(await c.var.isAuthenticated())) { + const redirectUrl = encodeURIComponent(getRelativeUrl(c.req.url)); + return c.redirect( + `/auth/sign-in?redirectUrl=${redirectUrl}&sessionCompanyId=${getSessionCompanyId(c)}` + ); + } + return c.redirect(await getEntryUrl(c)); +}); diff --git a/src/backend/features/favorites/contract.ts b/src/backend/features/favorites/contract.ts new file mode 100644 index 000000000..8bf3e53f9 --- /dev/null +++ b/src/backend/features/favorites/contract.ts @@ -0,0 +1,24 @@ +import { ParameterValues } from "../configurations/models"; +import { LibraryId } from "../library/library-id"; + +export interface Favorite { + id: string; + insertableId: string; + libraryId: LibraryId; + defaultConfiguration?: ParameterValues; +} + +export interface FavoritesData { + favorites: Record; + favoriteOrder: string[]; +} + +export function getFavoriteForInsertable( + favorites: Record, + insertableId: string +): Favorite | undefined { + for (const fav of Object.values(favorites)) { + if (fav.insertableId === insertableId) return fav; + } + return undefined; +} diff --git a/src/backend/routes/favorites.test.ts b/src/backend/features/favorites/routes.test.ts similarity index 94% rename from src/backend/routes/favorites.test.ts rename to src/backend/features/favorites/routes.test.ts index 706265df5..0e9e97e0e 100644 --- a/src/backend/routes/favorites.test.ts +++ b/src/backend/features/favorites/routes.test.ts @@ -1,7 +1,7 @@ import { asc, eq } from "drizzle-orm"; import { env } from "cloudflare:workers"; import { beforeEach, describe, expect, it } from "vitest"; -import { favorites } from "../../shared/schema"; +import { favorites } from "../../db/schema"; import { TEST_ASSEMBLY_ID, TEST_LIBRARY_ID, @@ -13,8 +13,8 @@ import { seedFavorite, seedPartStudio, seedTestData -} from "../../__test_utils__"; -import { getDb } from "../db"; +} from "../../../__test_utils__"; +import { getDb } from "../../db/client"; const db = getDb(env.DB); const favoritesUrl = `/api/favorites/library/${TEST_LIBRARY_ID}`; @@ -126,14 +126,14 @@ describe("favorites routes", () => { }); }); - describe("DELETE /favorites/:favoriteId", () => { + describe("DELETE /favorite/:favoriteId", () => { it("deletes a favorite owned by the current user", async () => { await seedPartStudio(db); const favoriteId = await seedFavorite(db, TEST_PART_STUDIO_ID); const app = createTestApp(); const res = await app.request( - `/api/favorites/${favoriteId}`, + `/api/favorite/${favoriteId}`, jsonRequest("DELETE"), env ); @@ -153,7 +153,7 @@ describe("favorites routes", () => { const app = createTestApp(); const res = await app.request( - `/api/favorites/${favoriteId}`, + `/api/favorite/${favoriteId}`, jsonRequest("DELETE"), env ); @@ -198,7 +198,7 @@ describe("favorites routes", () => { }); }); - describe("POST /default-configuration/:favoriteId", () => { + describe("POST /default-configuration/favorite/:favoriteId", () => { it("persists the default configuration", async () => { await seedPartStudio(db); const favoriteId = await seedFavorite(db, TEST_PART_STUDIO_ID); @@ -206,7 +206,7 @@ describe("favorites routes", () => { const defaultConfiguration = { "param-id": "value" }; const res = await app.request( - `/api/default-configuration/${favoriteId}`, + `/api/default-configuration/favorite/${favoriteId}`, jsonRequest("POST", { defaultConfiguration }), env ); diff --git a/src/backend/routes/favorites.ts b/src/backend/features/favorites/routes.ts similarity index 58% rename from src/backend/routes/favorites.ts rename to src/backend/features/favorites/routes.ts index cc60f3105..a03e07256 100644 --- a/src/backend/routes/favorites.ts +++ b/src/backend/features/favorites/routes.ts @@ -1,15 +1,33 @@ import { and, asc, eq } from "drizzle-orm"; -import { cacheMiddleware, getApp, getLibraryParam, libraryRoute } from "../app"; -import { type Db, getDb } from "../db"; -import { users, favorites } from "../../shared/schema"; -import { type Favorite, type FavoritesData } from "../../shared/api-models"; -import { type LibraryId } from "../../shared/types"; -import { HttpStatus } from "http-status-ts"; -import { type ParameterValues } from "../../shared/configuration-models"; -import { requireSignInMiddleware } from "../sign-in-utils"; +import { cacheMiddleware } from "../../lib/cache"; +import { getApp } from "../../lib/context"; +import { + favoriteRoute, + getFavoriteParam, + getLibraryParam, + libraryRoute +} from "../../lib/route-params"; +import { type Db, getDb } from "../../db/client"; +import { users, favorites } from "../../db/schema"; +import type { Favorite, FavoritesData } from "./contract"; +import type { LibraryId } from "../library/library-id"; +import { z } from "zod"; +import { validate } from "../../lib/validate"; +import { requireSignInMiddleware } from "../auth/guards"; export const favoriteRoutes = getApp(); +const addFavoriteQuery = z.object({ + insertableId: z.string().min(1), + id: z.string().min(1) +}); + +const favoriteOrderBody = z.object({ favoriteOrder: z.array(z.string()) }); + +const defaultConfigurationBody = z.object({ + defaultConfiguration: z.record(z.string(), z.string()) +}); + async function getFavorites( db: Db, userId: string, @@ -59,18 +77,11 @@ favoriteRoutes.get( favoriteRoutes.post( "/favorites" + libraryRoute(), requireSignInMiddleware, + validate("query", addFavoriteQuery), async (c) => { const libraryId = getLibraryParam(c); const userId = await c.var.getUserId(); - const insertableId = c.req.query("insertableId"); - const favoriteId = c.req.query("id"); - if (!insertableId) - return c.json( - { error: "insertableId required" }, - HttpStatus.BAD_REQUEST - ); - if (!favoriteId) - return c.json({ error: "id required" }, HttpStatus.BAD_REQUEST); + const { insertableId, id: favoriteId } = c.req.valid("query"); const db = getDb(c.env.DB); @@ -102,46 +113,40 @@ favoriteRoutes.post( } ); -/** DELETE /api/favorites/:favoriteId */ -favoriteRoutes.delete( - "/favorites/:favoriteId", - requireSignInMiddleware, - async (c) => { - const favoriteId = c.req.param("favoriteId"); - if (!favoriteId) { - return c.json( - { error: "favoriteId is required" }, - HttpStatus.BAD_REQUEST - ); - } - const userId = await c.var.getUserId(); - const db = getDb(c.env.DB); +/** DELETE /api/favorite/:favoriteId */ +favoriteRoutes.delete(favoriteRoute(), requireSignInMiddleware, async (c) => { + const favoriteId = getFavoriteParam(c); + const userId = await c.var.getUserId(); + const db = getDb(c.env.DB); - // security: Require the user to also match - await db - .delete(favorites) - .where( - and(eq(favorites.id, favoriteId), eq(favorites.userId, userId)) - ); + // Scoped to the owner, so another user's favorite matches nothing. + await db + .delete(favorites) + .where(and(eq(favorites.id, favoriteId), eq(favorites.userId, userId))); - return c.json({ success: true }); - } -); + return c.json({ success: true }); +}); /** POST /api/favorite-order/library/:libraryId */ favoriteRoutes.post( "/favorite-order" + libraryRoute(), requireSignInMiddleware, + validate("json", favoriteOrderBody), async (c) => { - const body = await c.req.json<{ favoriteOrder: string[] }>(); + const { favoriteOrder } = c.req.valid("json"); + const userId = await c.var.getUserId(); const db = getDb(c.env.DB); + // Scoped to the owner rather than checked first: a favorite that is not + // theirs matches nothing, which costs no extra read. await Promise.all( - body.favoriteOrder.map((id, i) => + favoriteOrder.map((id, i) => db .update(favorites) .set({ sortOrder: i }) - .where(eq(favorites.id, id)) + .where( + and(eq(favorites.id, id), eq(favorites.userId, userId)) + ) ) ); @@ -149,21 +154,23 @@ favoriteRoutes.post( } ); -/** POST /api/default-configuration/:favoriteId */ +/** POST /api/default-configuration/favorite/:favoriteId */ favoriteRoutes.post( - "/default-configuration/:favoriteId", + "/default-configuration" + favoriteRoute(), requireSignInMiddleware, + validate("json", defaultConfigurationBody), async (c) => { - const favoriteId = c.req.param("favoriteId"); - const body = await c.req.json<{ - defaultConfiguration: ParameterValues; - }>(); + const favoriteId = getFavoriteParam(c); + const { defaultConfiguration } = c.req.valid("json"); + const userId = await c.var.getUserId(); const db = getDb(c.env.DB); await db .update(favorites) - .set({ defaultConfiguration: body.defaultConfiguration }) - .where(eq(favorites.id, favoriteId)); + .set({ defaultConfiguration }) + .where( + and(eq(favorites.id, favoriteId), eq(favorites.userId, userId)) + ); return c.json({ success: true }); } diff --git a/src/backend/features/library/contract.ts b/src/backend/features/library/contract.ts new file mode 100644 index 000000000..9115e31dd --- /dev/null +++ b/src/backend/features/library/contract.ts @@ -0,0 +1,41 @@ +import { ElementPath, InstancePath } from "../../lib/onshape/path"; +import { ElementType } from "../../lib/onshape/element-type"; +import { Vendor } from "./vendors"; + +export interface InsertableOut { + id: string; + elementId: string; + groupId: string; + documentId: string; + versionId: string; + path: ElementPath; + name: string; + microversionId: string; + isVisible: boolean; + supportsFasten: boolean; + elementType: ElementType; + smallThumbnailUrl?: string; + largeThumbnailUrl?: string; + /** Whether it has configuration parameters; they are fetched by `id`. */ + isConfigurable: boolean; + vendors: Vendor[]; +} + +export interface GroupOut { + id: string; + documentId: string; + path: InstancePath; + name: string; + smallThumbnailUrl?: string; + largeThumbnailUrl?: string; + insertableOrder: string[]; +} + +export type Insertables = Record; +export type Groups = Record; + +export interface LibraryOut { + groupOrder: string[]; + groups: Groups; + insertables: Insertables; +} diff --git a/src/backend/library-data.test.ts b/src/backend/features/library/db.test.ts similarity index 67% rename from src/backend/library-data.test.ts rename to src/backend/features/library/db.test.ts index b35383cec..ae74cd6c9 100644 --- a/src/backend/library-data.test.ts +++ b/src/backend/features/library/db.test.ts @@ -1,16 +1,17 @@ import { env } from "cloudflare:workers"; import { asc } from "drizzle-orm"; import { beforeEach, describe, expect, it } from "vitest"; -import { getDb } from "./db"; -import { group } from "../shared/schema"; +import { getDb } from "../../db/client"; +import { group } from "../../db/schema"; import { resetDb, seedGroup, + seedInsertable, seedLibrary, TEST_GROUP_ID, TEST_LIBRARY_ID -} from "../__test_utils__"; -import { placeNewGroup } from "./library-data"; +} from "../../../__test_utils__"; +import { placeNewGroup, rebuildSearchDb } from "./db"; const db = getDb(env.DB); @@ -68,3 +69,30 @@ describe("placeNewGroup", () => { expect(rows.map((r) => r.sortOrder)).toEqual([0, 2, 3]); }); }); + +describe("rebuildSearchDb", () => { + beforeEach(async () => { + await resetDb(db); + }); + + // An unconfigurable insertable has no configurations row, so its part + // number reaches search only through the insertable's own part data. + it("indexes an unconfigurable insertable's part number", async () => { + await seedGroup(db); + await seedInsertable(db, { + partMetadata: { + partNumber: "WCP-0405", + name: "2x1 Tube", + description: undefined, + material: undefined, + vendor: undefined, + hasMultipleParts: false, + isOpenComposite: false + } + }); + + const searchDb = await rebuildSearchDb(env.BLOB, db, TEST_LIBRARY_ID); + + expect(searchDb).toContain("WCP-0405"); + }); +}); diff --git a/src/backend/library-data.ts b/src/backend/features/library/db.ts similarity index 80% rename from src/backend/library-data.ts rename to src/backend/features/library/db.ts index 5b0950510..28659ac57 100644 --- a/src/backend/library-data.ts +++ b/src/backend/features/library/db.ts @@ -1,20 +1,11 @@ -import { and, asc, eq, sql } from "drizzle-orm"; -import { type Db } from "./db"; -import { - libraries, - group, - insertables, - configurations -} from "../shared/schema"; -import { LibraryId } from "../shared/types"; -import { - InsertableOut, - LibraryOut, - Insertables, - Groups -} from "../shared/api-models"; -import { ConfigurationRecord } from "../shared/configuration-models"; -import { buildSearchDb } from "../shared/search"; +import { asc, eq, sql } from "drizzle-orm"; +import { type Db } from "../../db/client"; +import { libraries, group, insertables, configurations } from "../../db/schema"; +import { LibraryId } from "./library-id"; +import { InsertableOut, LibraryOut, Insertables, Groups } from "./contract"; +import { ConfigurationRecord } from "../configurations/models"; +import { toRecords } from "../configurations/utils"; +import { buildSearchDb } from "../search/search-index"; /** * Assembles the full `LibraryOut` (groups + insertables, in sort order) for a @@ -44,22 +35,17 @@ export async function getLibraryOut( .where(eq(insertables.libraryId, libraryId)) .orderBy(asc(insertables.sortOrder)) .all(), - // A row can exist just to hold records, so "configurable" keys on - // having parameters. Tested in SQL to leave the payload in D1. + // Ids only: a configurations row exists exactly when there are + // parameters, and its payload is fetched when one is opened. db .select({ id: configurations.id }) .from(configurations) .innerJoin(insertables, eq(configurations.id, insertables.id)) - .where( - and( - eq(insertables.libraryId, libraryId), - sql`json_array_length(${configurations.parameters}) > 0` - ) - ) + .where(eq(insertables.libraryId, libraryId)) .all() ]); - const configSet = new Set(allConfigurations.map((c) => c.id)); + const configurableIds = new Set(allConfigurations.map((c) => c.id)); const groupsOut: Groups = {}; for (const group of allGroups) { @@ -106,7 +92,7 @@ export async function getLibraryOut( elementType: ins.elementType, smallThumbnailUrl: ins.smallThumbnailUrl ?? undefined, largeThumbnailUrl: ins.largeThumbnailUrl ?? undefined, - configurationId: configSet.has(ins.id) ? ins.id : undefined, + isConfigurable: configurableIds.has(ins.id), vendors: ins.vendors } satisfies InsertableOut; } @@ -178,7 +164,6 @@ export async function rebuildSearchDb( db: Db, libraryId: LibraryId ): Promise { - const start = Date.now(); const [libraryData, recordsMap] = await Promise.all([ getLibraryOut(db, libraryId), getRecordsMap(db, libraryId) @@ -189,16 +174,12 @@ export async function rebuildSearchDb( await bucket.put(searchIndexKey(libraryId), searchDb, { httpMetadata: { contentType: "application/json" } }); - console.log( - `Rebuilt search index for ${libraryId}: ` + - `${searchDb.length} B, ${Date.now() - start} ms` - ); return searchDb; } /** - * Assembles the per-insertable configuration records `buildSearchDb` dedupes into - * the part-number search map. Only indexed insertables have records. + * The records `buildSearchDb` dedupes: an element's own part data plus one per + * indexed configuration. Left joined — an unconfigurable element has no row. */ async function getRecordsMap( db: Db, @@ -207,17 +188,19 @@ async function getRecordsMap( const rows = await db .select({ id: insertables.id, + partMetadata: insertables.partMetadata, records: configurations.records }) .from(insertables) - .innerJoin(configurations, eq(configurations.id, insertables.id)) + .leftJoin(configurations, eq(configurations.id, insertables.id)) .where(eq(insertables.libraryId, libraryId)) .all(); const recordsMap: Record = {}; for (const row of rows) { - if (row.records.length > 0) { - recordsMap[row.id] = row.records; + const records = toRecords(row.partMetadata, row.records ?? []); + if (records.length > 0) { + recordsMap[row.id] = records; } } return recordsMap; diff --git a/src/backend/routes/groups.test.ts b/src/backend/features/library/groups/routes.test.ts similarity index 95% rename from src/backend/routes/groups.test.ts rename to src/backend/features/library/groups/routes.test.ts index d38a7e5b0..92045855b 100644 --- a/src/backend/routes/groups.test.ts +++ b/src/backend/features/library/groups/routes.test.ts @@ -1,7 +1,7 @@ import { asc, eq } from "drizzle-orm"; import { env } from "cloudflare:workers"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { favorites, group, insertables } from "../../shared/schema"; +import { favorites, group, insertables } from "../../../db/schema"; import { TEST_GROUP_ID, TEST_LIBRARY_ID, @@ -11,14 +11,14 @@ import { resetDb, seedGroup, seedTestData -} from "../../__test_utils__"; +} from "../../../../__test_utils__"; import MiniSearch from "minisearch"; -import { getDb } from "../db"; -import type { JobStatus } from "../../shared/api-models"; -import { searchIndexKey } from "../library-data"; -import { SEARCH_OPTIONS, type SearchDocument } from "../../shared/search"; -import * as DocumentsEndpoint from "../onshape-api/endpoints/documents"; -import * as JobTracker from "../load/job-tracker"; +import { getDb } from "../../../db/client"; +import type { JobStatus } from "../../load/contract"; +import { searchIndexKey } from "../db"; +import { SEARCH_OPTIONS, type SearchDocument } from "../../search/search-index"; +import * as DocumentsEndpoint from "../../../lib/onshape/endpoints/documents"; +import * as JobTracker from "../../load/job-tracker"; const db = getDb(env.DB); diff --git a/src/backend/routes/groups.ts b/src/backend/features/library/groups/routes.ts similarity index 77% rename from src/backend/routes/groups.ts rename to src/backend/features/library/groups/routes.ts index cecf6b7a1..6c44cb158 100644 --- a/src/backend/routes/groups.ts +++ b/src/backend/features/library/groups/routes.ts @@ -1,16 +1,23 @@ import { and, eq, inArray } from "drizzle-orm"; -import { cacheMiddleware, getApp, getLibraryParam, libraryRoute } from "../app"; -import { getDb } from "../db"; -import { getSessionId } from "../auth"; -import { getDocument } from "../onshape-api/endpoints/documents"; -import { requireEditorMiddleware } from "../access-level-utils"; -import { type DocumentPath } from "../../shared/onshape-path"; -import { group, insertables, libraries, favorites } from "../../shared/schema"; -import { bumpLibraryVersion, rebuildSearchDb } from "../library-data"; +import { cacheMiddleware } from "../../../lib/cache"; +import { getApp } from "../../../lib/context"; +import { getLibraryParam, libraryRoute } from "../../../lib/route-params"; +import { getDb } from "../../../db/client"; +import { getSessionId } from "../../auth/session"; +import { getDocument } from "../../../lib/onshape/endpoints/documents"; +import { requireEditorMiddleware } from "../../auth/guards"; +import { type DocumentPath } from "../../../lib/onshape/path"; +import { group, insertables, libraries, favorites } from "../../../db/schema"; +import { bumpLibraryVersion, rebuildSearchDb } from "../db"; import { HttpStatus } from "http-status-ts"; -import { getJobStatus, isReloadRunning, trackJob } from "../load/job-tracker"; +import { handledError } from "../../../lib/api-error"; +import { + getJobStatus, + isReloadRunning, + trackJob +} from "../../load/job-tracker"; import { z } from "zod"; -import { zValidator } from "@hono/zod-validator"; +import { validate } from "../../../lib/validate"; export const groupRoutes = getApp(); @@ -18,11 +25,30 @@ const reloadGroupsQuery = z.object({ forceReload: z.stringbool().default(false) }); +const setVisibilityBody = z.object({ + insertableIds: z.array(z.string()), + isVisible: z.boolean() +}); + +const sortGroupBody = z.object({ + groupId: z.string().min(1), + sortAlphabetically: z.boolean() +}); + +const groupOrderBody = z.object({ groupOrder: z.array(z.string()) }); + +const addGroupBody = z.object({ + newDocumentId: z.string().min(1), + selectedGroupId: z.string().optional() +}); + +const deleteGroupQuery = z.object({ groupId: z.string().min(1) }); + /** POST /api/reload-groups/library/:libraryId?forceReload=true */ groupRoutes.post( "/reload-groups" + libraryRoute(), requireEditorMiddleware, - zValidator("query", reloadGroupsQuery), + validate("query", reloadGroupsQuery), async (c) => { const libraryId = getLibraryParam(c); const { forceReload } = c.req.valid("query"); @@ -65,12 +91,10 @@ groupRoutes.get( groupRoutes.post( "/set-insertable-visibility" + libraryRoute(), requireEditorMiddleware, + validate("json", setVisibilityBody), async (c) => { const libraryId = getLibraryParam(c); - const body = await c.req.json<{ - insertableIds: string[]; - isVisible: boolean; - }>(); + const body = c.req.valid("json"); const db = getDb(c.env.DB); @@ -107,12 +131,10 @@ groupRoutes.post( groupRoutes.post( "/sort-group-alphabetically" + libraryRoute(), requireEditorMiddleware, + validate("json", sortGroupBody), async (c) => { const libraryId = getLibraryParam(c); - const body = await c.req.json<{ - groupId: string; - sortAlphabetically: boolean; - }>(); + const body = c.req.valid("json"); const db = getDb(c.env.DB); await db @@ -131,9 +153,10 @@ groupRoutes.post( groupRoutes.post( "/group-order" + libraryRoute(), requireEditorMiddleware, + validate("json", groupOrderBody), async (c) => { const libraryId = getLibraryParam(c); - const body = await c.req.json<{ groupOrder: string[] }>(); + const body = c.req.valid("json"); const db = getDb(c.env.DB); await Promise.all( @@ -156,13 +179,11 @@ groupRoutes.post( groupRoutes.post( "/group" + libraryRoute(), requireEditorMiddleware, + validate("json", addGroupBody), async (c) => { const onshapeApi = await c.var.getOnshapeApi(); const libraryId = getLibraryParam(c); - const body = await c.req.json<{ - newDocumentId: string; - selectedGroupId?: string; - }>(); + const body = c.req.valid("json"); const sessionId = getSessionId(c); const documentPath: DocumentPath = { documentId: body.newDocumentId }; @@ -171,12 +192,8 @@ groupRoutes.post( try { documentName = (await getDocument(onshapeApi, documentPath)).name; } catch { - return c.json( - { - type: "handled", - message: "Failed to find the specified document.", - isError: true - }, + throw handledError( + "Failed to find the specified document.", HttpStatus.UNPROCESSABLE_ENTITY ); } @@ -195,12 +212,8 @@ groupRoutes.post( .get(); if (existingGroup) { - return c.json( - { - type: "handled", - message: "Document has already been added to library.", - isError: true - }, + throw handledError( + "Document has already been added to library.", HttpStatus.UNPROCESSABLE_ENTITY ); } @@ -226,14 +239,10 @@ groupRoutes.post( groupRoutes.delete( "/group" + libraryRoute(), requireEditorMiddleware, + validate("query", deleteGroupQuery), async (c) => { const libraryId = getLibraryParam(c); - const groupId = c.req.query("groupId"); - if (!groupId) - return c.json( - { error: "groupId required" }, - HttpStatus.BAD_REQUEST - ); + const { groupId } = c.req.valid("query"); const db = getDb(c.env.DB); diff --git a/src/backend/features/library/insertables/fasten-query.test.ts b/src/backend/features/library/insertables/fasten-query.test.ts new file mode 100644 index 000000000..4abc33530 --- /dev/null +++ b/src/backend/features/library/insertables/fasten-query.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { ElementType } from "../../../lib/onshape/element-type"; +import { FastenInfo, MateLocation } from "./fasten"; +import { getFastenQuery } from "./fasten-query"; + +describe("getFastenQuery", () => { + const fasten: FastenInfo = { + mateConnectorId: "mc", + mateLocation: MateLocation.Feature, + path: ["fp"] + }; + + it("builds a part-studio mate connector query for a part studio target", () => { + expect(getFastenQuery(ElementType.PART_STUDIO, ["np"], fasten)).toEqual( + { + btType: "BTMPartStudioMateConnectorQuery-1324", + featureId: "mc", + path: ["np"] + } + ); + }); + + it("uses a part-studio query for a Part mate in an assembly (combined path)", () => { + const partFasten: FastenInfo = { + mateConnectorId: "mc", + mateLocation: MateLocation.Part, + path: ["fp"] + }; + expect( + getFastenQuery(ElementType.ASSEMBLY, ["np"], partFasten) + ).toEqual({ + btType: "BTMPartStudioMateConnectorQuery-1324", + featureId: "mc", + path: ["np", "fp"] + }); + }); + + it("uses a feature-occurrence query for a Feature mate in an assembly", () => { + expect(getFastenQuery(ElementType.ASSEMBLY, ["np"], fasten)).toEqual({ + btType: "BTMFeatureQueryWithOccurrence-157", + path: ["np", "fp"], + queryData: "", + featureId: "mc" + }); + }); +}); diff --git a/src/backend/features/library/insertables/fasten-query.ts b/src/backend/features/library/insertables/fasten-query.ts new file mode 100644 index 000000000..d5993055a --- /dev/null +++ b/src/backend/features/library/insertables/fasten-query.ts @@ -0,0 +1,26 @@ +/** Turns a stored FastenInfo into the Onshape query an insert mates against. */ +import { ElementType } from "../../../lib/onshape/element-type"; +import { + featureOccurrenceQuery, + partStudioMateConnectorQuery +} from "../../../lib/onshape/objects/assembly-features"; +import { FastenInfo, MateLocation } from "./fasten"; + +export function getFastenQuery( + targetElementType: ElementType, + path: string[], + fastenInfo: FastenInfo +): object { + if (targetElementType === ElementType.PART_STUDIO) { + return partStudioMateConnectorQuery(fastenInfo.mateConnectorId, path); + } + + const assemblyPath = [...path, ...fastenInfo.path]; + if (fastenInfo.mateLocation === MateLocation.Part) { + return partStudioMateConnectorQuery( + fastenInfo.mateConnectorId, + assemblyPath + ); + } + return featureOccurrenceQuery(fastenInfo.mateConnectorId, assemblyPath); +} diff --git a/src/backend/features/library/insertables/fasten.ts b/src/backend/features/library/insertables/fasten.ts new file mode 100644 index 000000000..0a91ad16b --- /dev/null +++ b/src/backend/features/library/insertables/fasten.ts @@ -0,0 +1,12 @@ +/** Where an insertable's fasten mate connector lives inside its element. */ +export enum MateLocation { + Feature = "Feature", + Part = "Part", + Subassembly = "Subassembly" +} + +export interface FastenInfo { + mateConnectorId: string; + mateLocation: MateLocation; + path: string[]; +} diff --git a/src/backend/routes/insertables.test.ts b/src/backend/features/library/insertables/routes.test.ts similarity index 88% rename from src/backend/routes/insertables.test.ts rename to src/backend/features/library/insertables/routes.test.ts index e334dfdaa..13f2eaa47 100644 --- a/src/backend/routes/insertables.test.ts +++ b/src/backend/features/library/insertables/routes.test.ts @@ -1,9 +1,10 @@ import { eq } from "drizzle-orm"; import { env } from "cloudflare:workers"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { configurations, insertables } from "../../shared/schema"; -import { ElementType, Vendor } from "../../shared/types"; -import { BuildIssueType } from "../../shared/build-issues"; +import { configurations, insertables } from "../../../db/schema"; +import { ElementType } from "../../../lib/onshape/element-type"; +import { Vendor } from "../vendors"; +import { BuildIssueType } from "../../build-checker/issues"; import { MOCK_ONSHAPE_API, TEST_ASSEMBLY_ID, @@ -16,14 +17,14 @@ import { seedGroup, seedInsertable, seedPartStudio -} from "../../__test_utils__"; -import { getDb } from "../db"; -import * as PartStudioEndpoints from "../onshape-api/endpoints/part-studios"; -import * as AssemblyEndpoints from "../onshape-api/endpoints/assemblies"; -import * as PartsEndpoints from "../onshape-api/endpoints/parts"; -import { OnshapeRateLimitError } from "../onshape-api/onshape-api"; -import { AUTO_INDEX_THRESHOLD } from "../../shared/configuration-combinations"; -import { enumParam } from "../../__test_utils__/configuration-fixtures"; +} from "../../../../__test_utils__"; +import { getDb } from "../../../db/client"; +import * as PartStudioEndpoints from "../../../lib/onshape/endpoints/part-studios"; +import * as AssemblyEndpoints from "../../../lib/onshape/endpoints/assemblies"; +import * as PartsEndpoints from "../../../lib/onshape/endpoints/parts"; +import { OnshapeRateLimitError } from "../../../lib/onshape/client"; +import { AUTO_INDEX_THRESHOLD } from "../../configurations/combinations"; +import { enumParam } from "../../../../__test_utils__/configuration-fixtures"; const db = getDb(env.DB); @@ -181,19 +182,14 @@ describe("insertable routes", () => { const row = await readInsertable(TEST_PART_STUDIO_ID); expect(row?.indexConfigurations).toBe(true); - const config = await readConfig(TEST_PART_STUDIO_ID); - expect(config?.records).toEqual([ - { - configuration: {}, - partNumber: "PN-123", - name: null, - description: null, - material: null, - vendor: null, - hasMultipleParts: false, - isUnstableComposite: false - } - ]); + // Nothing to configure, so the part data lands on the insertable and + // no configurations row is manufactured to hold it. + expect(row?.partMetadata).toEqual({ + partNumber: "PN-123", + hasMultipleParts: false, + isOpenComposite: false + }); + expect(await readConfig(TEST_PART_STUDIO_ID)).toBeUndefined(); }); it("POST /index-configurations leaves the flag off when indexing fails", async () => { @@ -279,10 +275,9 @@ describe("insertable routes", () => { ); expect(res.status).toBe(200); - const config = await readConfig(TEST_PART_STUDIO_ID); - expect(config?.records).toHaveLength(1); - // Nobody sells it, so a missing part number is not worth flagging. const row = await readInsertable(TEST_PART_STUDIO_ID); + expect(row?.partMetadata).not.toBeNull(); + // Nobody sells it, so a missing part number is not worth flagging. expect(row?.buildIssues).toEqual([]); }); @@ -295,7 +290,7 @@ describe("insertable routes", () => { .set({ buildIssues: [ { type: BuildIssueType.NO_VENDORS }, - { type: BuildIssueType.TOO_MANY_CONFIGURATIONS } + { type: BuildIssueType.CONFIGURATION_LIMIT_EXCEEDED } ] }) .where(eq(insertables.id, TEST_PART_STUDIO_ID)); diff --git a/src/backend/routes/insertables.ts b/src/backend/features/library/insertables/routes.ts similarity index 79% rename from src/backend/routes/insertables.ts rename to src/backend/features/library/insertables/routes.ts index 3c083dc94..4eda9937b 100644 --- a/src/backend/routes/insertables.ts +++ b/src/backend/features/library/insertables/routes.ts @@ -1,55 +1,62 @@ import { eq } from "drizzle-orm"; -import { HTTPException } from "hono/http-exception"; -import { zValidator } from "@hono/zod-validator"; +import { internalError } from "../../../lib/api-error"; +import { validate } from "../../../lib/validate"; import { HttpStatus } from "http-status-ts"; import z from "zod"; -import { getApp, getInsertableParam, insertableRoute } from "../app"; -import { getDb, type Db } from "../db"; -import { requireEditorMiddleware } from "../access-level-utils"; -import { requireSignInMiddleware } from "../sign-in-utils"; -import { insertables, configurations } from "../../shared/schema"; -import { bumpLibraryVersion, rebuildSearchDb } from "../library-data"; -import { type ElementPath, INSTANCE_TYPES } from "../../shared/onshape-path"; +import { getApp } from "../../../lib/context"; +import { getInsertableParam, insertableRoute } from "../../../lib/route-params"; +import { getDb, type Db } from "../../../db/client"; +import { requireEditorMiddleware } from "../../auth/guards"; +import { requireSignInMiddleware } from "../../auth/guards"; +import { insertables, configurations } from "../../../db/schema"; +import { bumpLibraryVersion, rebuildSearchDb } from "../db"; +import { type ElementPath, INSTANCE_TYPES } from "../../../lib/onshape/path"; import { type ConfigurationParameter, type ParameterValues -} from "../../shared/configuration-models"; +} from "../../configurations/models"; import { INDEXING_ISSUE_TYPES, NO_RECORDS, decideIndexing, parseConfigurationRecords, type ConfigurationRecordsResult -} from "../parse/parse-configuration-records"; -import { type OnshapeApi } from "../onshape-api/onshape-api"; -import { ElementType } from "../../shared/types"; -import { DerivedFeature } from "../onshape-api/objects/derive-feature"; -import { addPartStudioFeature } from "../onshape-api/endpoints/part-studios"; +} from "../../load/parse-configuration-records"; +import { type OnshapeApi } from "../../../lib/onshape/client"; +import { ElementType } from "../../../lib/onshape/element-type"; +import { DerivedFeature } from "../../../lib/onshape/objects/derive-feature"; +import { addPartStudioFeature } from "../../../lib/onshape/endpoints/part-studios"; import { addElementToAssembly, addAssemblyFeature -} from "../onshape-api/endpoints/assemblies"; +} from "../../../lib/onshape/endpoints/assemblies"; import { PartType, type OnshapeElementType -} from "../onshape-api/endpoints/documents"; -import { encodeConfiguration } from "../onshape-api/endpoints/configurations"; -import { FastenMateBuilder } from "../onshape-api/objects/assembly-features"; -import { getFastenQuery, parseFastenInfo } from "../parse/insert-and-fasten"; -import { addBuildIssue, clearBuildIssue } from "../../shared/build-issues"; -import { checkIndexedPartNumber } from "../parse/build-checks"; +} from "../../../lib/onshape/endpoints/documents"; +import { encodeConfiguration } from "../../../lib/onshape/endpoints/configurations"; +import { FastenMateBuilder } from "../../../lib/onshape/objects/assembly-features"; +import { parseFastenInfo } from "../../load/parse-fasten"; +import { getFastenQuery } from "./fasten-query"; +import { addBuildIssue, clearBuildIssue } from "../../build-checker/issues"; +import { checkIndexedPartNumber } from "../../build-checker/checks"; export const insertableRoutes = getApp(); /** POST /api/toggle-insert-and-fasten/insertable/:insertableId */ +const setFastenBody = z.object({ supportsFasten: z.boolean() }); + +const indexConfigurationsBody = z.object({ indexConfigurations: z.boolean() }); + insertableRoutes.post( "/toggle-insert-and-fasten" + insertableRoute(), requireEditorMiddleware, + validate("json", setFastenBody), async (c) => { const db = getDb(c.env.DB); const insertableId = getInsertableParam(c); - const body = await c.req.json<{ supportsFasten: boolean }>(); + const { supportsFasten } = c.req.valid("json"); const insertableRow = await db .select({ libraryId: insertables.libraryId }) @@ -57,12 +64,10 @@ insertableRoutes.post( .where(eq(insertables.id, insertableId)) .get(); if (!insertableRow) - throw new HTTPException(HttpStatus.NOT_FOUND, { - message: "Insertable not found" - }); + throw internalError("Insertable not found", HttpStatus.NOT_FOUND); let fastenInfo = null; - if (body.supportsFasten) { + if (supportsFasten) { const onshapeApi = await c.var.getOnshapeApi(); const elementPath = await getInsertableElementPath( db, @@ -77,9 +82,10 @@ insertableRoutes.post( .get(); if (!insertable) { - throw new HTTPException(HttpStatus.NOT_FOUND, { - message: "Insertable not found" - }); + throw internalError( + "Insertable not found", + HttpStatus.NOT_FOUND + ); } fastenInfo = await parseFastenInfo( @@ -91,7 +97,7 @@ insertableRoutes.post( await db .update(insertables) - .set({ supportsFasten: body.supportsFasten, fastenInfo }) + .set({ supportsFasten, fastenInfo }) .where(eq(insertables.id, insertableId)); await bumpLibraryVersion(db, insertableRow.libraryId); @@ -103,10 +109,11 @@ insertableRoutes.post( insertableRoutes.post( "/index-configurations" + insertableRoute(), requireEditorMiddleware, + validate("json", indexConfigurationsBody), async (c) => { const db = getDb(c.env.DB); const insertableId = getInsertableParam(c); - const body = await c.req.json<{ indexConfigurations: boolean }>(); + const body = c.req.valid("json"); const row = await db .select({ @@ -123,9 +130,7 @@ insertableRoutes.post( .where(eq(insertables.id, insertableId)) .get(); if (!row) - throw new HTTPException(HttpStatus.NOT_FOUND, { - message: "Insertable not found" - }); + throw internalError("Insertable not found", HttpStatus.NOT_FOUND); const parameters = ( @@ -135,7 +140,11 @@ insertableRoutes.post( .where(eq(configurations.id, insertableId)) .get() )?.parameters ?? []; - const indexing = decideIndexing(parameters, body.indexConfigurations); + const indexing = decideIndexing( + row.elementType, + parameters, + body.indexConfigurations + ); // Index before committing anything: if this throws, nothing is written. // The error reaches the client via the app's onError handler. @@ -158,13 +167,15 @@ insertableRoutes.post( ...indexed.buildIssues, ...indexing.buildIssues, // Vendors are read, not re-derived: the load path wrote them. - ...checkIndexedPartNumber(row.vendors, indexed.records) + ...checkIndexedPartNumber(row.vendors, [ + indexed.partMetadata, + ...indexed.records + ]) ); - // Keep a configurations row while there's parameters or records to hold; - // a non-configurable insertable that stops indexing loses its row. + // A configurations row exists exactly when the insertable is configurable. const configWrite = - parameters.length > 0 || indexed.records.length > 0 + parameters.length > 0 ? db .insert(configurations) .values({ @@ -185,6 +196,7 @@ insertableRoutes.post( .update(insertables) .set({ indexConfigurations: body.indexConfigurations, + partMetadata: indexed.partMetadata, buildIssues }) .where(eq(insertables.id, insertableId)), @@ -263,7 +275,7 @@ const addToAssemblyBody = insertBodySchema.extend({ insertableRoutes.post( "/add-to-part-studio" + insertableRoute(), requireSignInMiddleware, - zValidator("json", addToPartStudioBody), + validate("json", addToPartStudioBody), async (c) => { const onshapeApi = await c.var.getOnshapeApi(); const insertableId = getInsertableParam(c); @@ -283,9 +295,7 @@ insertableRoutes.post( .get(); if (!insertable) { - throw new HTTPException(HttpStatus.NOT_FOUND, { - message: "Insertable not found" - }); + throw internalError("Insertable not found", HttpStatus.NOT_FOUND); } // Look up parsed configuration parameters from D1 if configuration is provided @@ -321,7 +331,7 @@ insertableRoutes.post( insertableRoutes.post( "/add-to-assembly" + insertableRoute(), requireSignInMiddleware, - zValidator("json", addToAssemblyBody), + validate("json", addToAssemblyBody), async (c) => { const onshapeApi = await c.var.getOnshapeApi(); const insertableId = getInsertableParam(c); @@ -346,9 +356,7 @@ insertableRoutes.post( .get(); if (!row) { - throw new HTTPException(HttpStatus.NOT_FOUND, { - message: "Insertable not found" - }); + throw internalError("Insertable not found", HttpStatus.NOT_FOUND); } const sourcePath: ElementPath = { @@ -398,9 +406,10 @@ insertableRoutes.post( const fastenInfo = row.fastenInfo; if (!fastenInfo) { - throw new HTTPException(HttpStatus.BAD_REQUEST, { - message: `${row.name} does not support insert and fasten.` - }); + throw internalError( + `${row.name} does not support insert and fasten.`, + HttpStatus.BAD_REQUEST + ); } const instancePath: string[] = @@ -436,9 +445,7 @@ export async function getInsertableElementPath( .get(); if (!row) { - throw new HTTPException(HttpStatus.NOT_FOUND, { - message: "Insertable not found" - }); + throw internalError("Insertable not found", HttpStatus.NOT_FOUND); } return { diff --git a/src/backend/features/library/library-id.ts b/src/backend/features/library/library-id.ts new file mode 100644 index 000000000..66a48607c --- /dev/null +++ b/src/backend/features/library/library-id.ts @@ -0,0 +1,5 @@ +export enum LibraryId { + FRC_DESIGN_LIB = "frc-design-lib", + FTC_DESIGN_LIB = "ftc-design-lib", + MKCAD = "mkcad" +} diff --git a/src/backend/routes/library.test.ts b/src/backend/features/library/routes.test.ts similarity index 75% rename from src/backend/routes/library.test.ts rename to src/backend/features/library/routes.test.ts index 3c41f655a..6758599e8 100644 --- a/src/backend/routes/library.test.ts +++ b/src/backend/features/library/routes.test.ts @@ -9,12 +9,13 @@ import { resetDb, seedTestData, seedConfiguration, + TEST_PARAMETERS, seedLibrary -} from "../../__test_utils__"; -import { getDb } from "../db"; -import { rebuildSearchDb, searchIndexKey } from "../library-data"; -import { LibraryOut } from "../../shared/api-models"; -import { LibraryId } from "../../shared/types"; +} from "../../../__test_utils__"; +import { getDb } from "../../db/client"; +import { rebuildSearchDb, searchIndexKey } from "./db"; +import { LibraryOut } from "./contract"; +import { LibraryId } from "./library-id"; const db = getDb(env.DB); @@ -41,11 +42,44 @@ describe("library routes", () => { expect(body.groupOrder).toContain(TEST_GROUP_ID); expect(Object.keys(body.insertables)).toContain(TEST_PART_STUDIO_ID); - expect(body.insertables[TEST_PART_STUDIO_ID].configurationId).toBe( - TEST_PART_STUDIO_ID + expect(body.insertables[TEST_PART_STUDIO_ID].isConfigurable).toBe(true); + }); + + it("marks an insertable with no parameters as not configurable", async () => { + await seedTestData(db); + const app = createTestApp(); + + const res = await app.request( + `/api/library-data/library/${TEST_LIBRARY_ID}?v=1`, + jsonRequest("GET"), + env + ); + const body: LibraryOut = await res.json(); + + expect(body.insertables[TEST_PART_STUDIO_ID].isConfigurable).toBe( + false ); }); + it("carries no configuration payload; it is fetched per insertable", async () => { + await seedTestData(db); + await seedConfiguration(db, TEST_PART_STUDIO_ID); + const app = createTestApp(); + + const res = await app.request( + `/api/library-data/library/${TEST_LIBRARY_ID}?v=1`, + jsonRequest("GET"), + env + ); + const body = await res.text(); + + // The seeded parameter's name is distinctive; the payload stays in D1 + // and is fetched from /api/configuration/:insertableId when needed. + expect(body).not.toContain(TEST_PARAMETERS[0].name); + expect(body).not.toContain("parameters"); + expect(body).not.toContain("records"); + }); + it("GET /search-db serves the library's index from R2 as plain JSON", async () => { await seedTestData(db); await seedConfiguration(db, TEST_PART_STUDIO_ID); diff --git a/src/backend/routes/library.ts b/src/backend/features/library/routes.ts similarity index 82% rename from src/backend/routes/library.ts rename to src/backend/features/library/routes.ts index 4493a48e7..134da21a3 100644 --- a/src/backend/routes/library.ts +++ b/src/backend/features/library/routes.ts @@ -1,14 +1,10 @@ import { eq } from "drizzle-orm"; -import { - CachePolicy, - cacheMiddleware, - getApp, - getLibraryParam, - libraryRoute -} from "../app"; -import { getDb } from "../db"; -import { libraries } from "../../shared/schema"; -import { getLibraryOut, searchIndexKey } from "../library-data"; +import { CachePolicy, cacheMiddleware } from "../../lib/cache"; +import { getApp } from "../../lib/context"; +import { getLibraryParam, libraryRoute } from "../../lib/route-params"; +import { getDb } from "../../db/client"; +import { libraries } from "../../db/schema"; +import { getLibraryOut, searchIndexKey } from "./db"; export const libraryRoutes = getApp(); diff --git a/src/backend/features/library/vendors.test.ts b/src/backend/features/library/vendors.test.ts new file mode 100644 index 000000000..6d07e7f4e --- /dev/null +++ b/src/backend/features/library/vendors.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; +import { Vendor, getVendorPartUrl, toVendor } from "./vendors"; + +describe("getVendorPartUrl", () => { + // Each vendor writes its own casing, and only some have a per-part page. + it.each([ + [Vendor.WCP, "WCP-1025", "https://wcproducts.com/products/wcp-1025"], + [Vendor.MCM, "91251A445", "https://www.mcmaster.com/91251A445/"], + [ + Vendor.AM, + "AM-5833", + "https://andymark.com/pages/search-results-page?q=am-5833" + ], + [ + Vendor.REV, + "REV-42-1442", + "https://www.revrobotics.com/search.php?search_query=REV-42-1442§ion=product" + ], + [ + Vendor.TTB, + "TTB-0008", + "https://www.thethriftybot.com/search?type=product&q=TTB-0008" + ], + // Escaped, since a part number can carry url syntax. + [ + Vendor.TTB, + "TTB 1&2", + "https://www.thethriftybot.com/search?type=product&q=TTB%201%262" + ] + ])("builds %s's url for %s", (vendor, partNumber, url) => { + expect(getVendorPartUrl(vendor, partNumber)).toBe(url); + }); + + it.each([Vendor.SDS, Vendor.VEX, Vendor.CUSTOM])( + "has no derivable page for %s", + (vendor) => { + expect(getVendorPartUrl(vendor, "12345")).toBeUndefined(); + } + ); + + it("has nothing to build from without a part number", () => { + expect(getVendorPartUrl(Vendor.WCP, undefined)).toBeUndefined(); + }); +}); + +describe("toVendor", () => { + it.each([ + ["WCP", Vendor.WCP], + ["custom", Vendor.CUSTOM], + ["West Coast Products", Vendor.WCP], + [" mcmaster-carr ", Vendor.MCM], + ["Acme", undefined], + ["", undefined], + [undefined, undefined] + ])("resolves %s", (text, expected) => { + expect(toVendor(text)).toBe(expected); + }); +}); + +describe("Vendor", () => { + it("lists Custom last, since it is the absence of a vendor", () => { + const vendors = Object.values(Vendor); + expect(vendors[vendors.length - 1]).toBe(Vendor.CUSTOM); + }); +}); diff --git a/src/backend/features/library/vendors.ts b/src/backend/features/library/vendors.ts new file mode 100644 index 000000000..29563eed4 --- /dev/null +++ b/src/backend/features/library/vendors.ts @@ -0,0 +1,102 @@ +/** The vendors an insertable can come from, and how they are displayed. */ +export enum Vendor { + AM = "AM", + LAI = "LAI", + MCM = "MCM", + REDUX = "Redux", + REV = "REV", + SDS = "SDS", + SWYFT = "SWYFT", + TTB = "TTB", + VEX = "VEX", + WCP = "WCP", + /** Last, being the absence of a vendor: the team made it, so nobody sells + * it and it has no part number. */ + CUSTOM = "Custom" +} + +/** + * Resolves the free text Onshape carries as a vendor to one we know, written + * either as its code or as its full name. + */ +export function toVendor(vendor: string | undefined): Vendor | undefined { + const text = vendor?.trim().toUpperCase(); + if (!text) { + return undefined; + } + return Object.values(Vendor).find( + (known) => + known.toUpperCase() === text || + getVendorName(known).toUpperCase() === text + ); +} + +/** + * The vendor a part number names itself, e.g. `WCP-1025` — more precise than an + * insertable's tagging, which is generic wherever one part spans vendors. + */ +export function parsePartNumberVendor( + partNumber: string | undefined +): Vendor | undefined { + return toVendor(/^([A-Za-z]+)-/.exec(partNumber?.trim() ?? "")?.[1]); +} + +/** + * The vendor's page for a part, or its search for one where that is all the + * site offers. Most vendors have no url derivable from a part number at all. + */ +export function getVendorPartUrl( + vendor: Vendor | undefined, + partNumber: string | undefined +): string | undefined { + if (!partNumber) { + return undefined; + } + const query = encodeURIComponent(partNumber); + switch (vendor) { + case Vendor.MCM: + return `https://www.mcmaster.com/${query}/`; + case Vendor.WCP: + return `https://wcproducts.com/products/${query.toLowerCase()}`; + case Vendor.AM: + return `https://andymark.com/pages/search-results-page?q=${query.toLowerCase()}`; + case Vendor.REV: + return `https://www.revrobotics.com/search.php?search_query=${query}§ion=product`; + case Vendor.TTB: + return `https://www.thethriftybot.com/search?type=product&q=${query}`; + default: + return undefined; + } +} + +/** Team-made, so it is expected to have no part number. */ +export function isCustomPart(vendors: Vendor[]): boolean { + return vendors.includes(Vendor.CUSTOM); +} + +export function getVendorName(vendor: Vendor) { + switch (vendor) { + case Vendor.AM: + return "AndyMark"; + case Vendor.CUSTOM: + return "Custom"; + case Vendor.LAI: + return "Last Anvil Innovations"; + case Vendor.MCM: + return "McMaster-Carr"; + case Vendor.REDUX: + return "Redux Robotics"; + case Vendor.REV: + return "REV Robotics"; + case Vendor.SDS: + return "Swerve Drive Specialties"; + case Vendor.SWYFT: + return "SWYFT"; + case Vendor.TTB: + return "The Thrifty Bot"; + case Vendor.VEX: + return "VEXpro"; + case Vendor.WCP: + return "West Coast Products"; + } +} diff --git a/src/backend/load/load-common.test.ts b/src/backend/features/load/context.test.ts similarity index 96% rename from src/backend/load/load-common.test.ts rename to src/backend/features/load/context.test.ts index bcd0e10a4..de97b5c46 100644 --- a/src/backend/load/load-common.test.ts +++ b/src/backend/features/load/context.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { createLimiter } from "./load-common"; +import { createLimiter } from "./context"; describe("createLimiter", () => { it("never runs more than `max` tasks at once", async () => { diff --git a/src/backend/load/load-common.ts b/src/backend/features/load/context.ts similarity index 84% rename from src/backend/load/load-common.ts rename to src/backend/features/load/context.ts index 69e39d27f..7c14b5e46 100644 --- a/src/backend/load/load-common.ts +++ b/src/backend/features/load/context.ts @@ -1,9 +1,10 @@ import type { WorkflowStep } from "cloudflare:workers"; -import type { AppBindings } from "../app"; -import { getOnshapeApiFromSessionId } from "../auth"; -import type { OnshapeApi } from "../onshape-api/onshape-api"; -import type { ElementType, LibraryId } from "../../shared/types"; -import type { ElementPath, InstancePath } from "../../shared/onshape-path"; +import type { AppBindings } from "../../lib/context"; +import { getOnshapeApiFromSessionId } from "../auth/caller"; +import type { OnshapeApi } from "../../lib/onshape/client"; +import type { ElementType } from "../../lib/onshape/element-type"; +import type { LibraryId } from "../library/library-id"; +import type { ElementPath, InstancePath } from "../../lib/onshape/path"; /** How many insertables a load reads from Onshape at once. */ export const LOAD_CONCURRENCY = 15; diff --git a/src/backend/features/load/contract.ts b/src/backend/features/load/contract.ts new file mode 100644 index 000000000..b752b231d --- /dev/null +++ b/src/backend/features/load/contract.ts @@ -0,0 +1,7 @@ +/** + * Whether a library-load job is running, and how long it has been going. + * Milliseconds since the oldest running job started paces the client's polling. + */ +export type JobStatus = + | { running: false } + | { running: true; runningForMs: number }; diff --git a/src/backend/load/job-tracker.test.ts b/src/backend/features/load/job-tracker.test.ts similarity index 98% rename from src/backend/load/job-tracker.test.ts rename to src/backend/features/load/job-tracker.test.ts index 676543f29..4faa39ebd 100644 --- a/src/backend/load/job-tracker.test.ts +++ b/src/backend/features/load/job-tracker.test.ts @@ -6,7 +6,7 @@ import { trackJob, untrackJob } from "./job-tracker"; -import { TEST_LIBRARY_ID } from "../../__test_utils__"; +import { TEST_LIBRARY_ID } from "../../../__test_utils__"; interface Job { id: string; diff --git a/src/backend/load/job-tracker.ts b/src/backend/features/load/job-tracker.ts similarity index 95% rename from src/backend/load/job-tracker.ts rename to src/backend/features/load/job-tracker.ts index d48632de6..a40b7786e 100644 --- a/src/backend/load/job-tracker.ts +++ b/src/backend/features/load/job-tracker.ts @@ -1,6 +1,6 @@ -import type { AppBindings } from "../app"; -import type { LibraryId } from "../../shared/types"; -import type { JobStatus } from "../../shared/api-models"; +import type { AppBindings } from "../../lib/context"; +import type { LibraryId } from "../library/library-id"; +import type { JobStatus } from "./contract"; /** * Backstop for a job that crashes before untracking itself; must outlast the diff --git a/src/backend/load/load-group.test.ts b/src/backend/features/load/load-group.test.ts similarity index 94% rename from src/backend/load/load-group.test.ts rename to src/backend/features/load/load-group.test.ts index 08f68e5f6..66ebaa38c 100644 --- a/src/backend/load/load-group.test.ts +++ b/src/backend/features/load/load-group.test.ts @@ -6,22 +6,22 @@ import { type OnshapeElement, OnshapeElementType, OnshapeFolderEntryType -} from "../onshape-api/onshape-types"; -import * as DocumentEndpoints from "../onshape-api/endpoints/documents"; -import * as ConfigurationEndpoints from "../onshape-api/endpoints/configurations"; -import * as PartsEndpoints from "../onshape-api/endpoints/parts"; -import { getDb } from "../db"; -import { group, insertables } from "../../shared/schema"; -import { BuildIssueType } from "../../shared/build-issues"; +} from "../../lib/onshape/types"; +import * as DocumentEndpoints from "../../lib/onshape/endpoints/documents"; +import * as ConfigurationEndpoints from "../../lib/onshape/endpoints/configurations"; +import * as PartsEndpoints from "../../lib/onshape/endpoints/parts"; +import { getDb } from "../../db/client"; +import { group, insertables } from "../../db/schema"; +import { BuildIssueType } from "../build-checker/issues"; import { type StoredInsertable, findRemovedInsertables, loadGroup, selectInsertablesToLoad } from "./load-group"; -import type { GroupTarget, LoadContext } from "./load-common"; -import { LOAD_CONCURRENCY, createLimiter } from "./load-common"; -import * as LoadCommonModule from "./load-common"; +import type { GroupTarget, LoadContext } from "./context"; +import { LOAD_CONCURRENCY, createLimiter } from "./context"; +import * as LoadCommonModule from "./context"; import { FAKE_STEP, MOCK_ONSHAPE_API, @@ -30,7 +30,7 @@ import { resetDb, seedGroup, seedInsertable -} from "../../__test_utils__"; +} from "../../../__test_utils__"; const GROUP: GroupTarget = { libraryId: TEST_LIBRARY_ID, diff --git a/src/backend/load/load-group.ts b/src/backend/features/load/load-group.ts similarity index 92% rename from src/backend/load/load-group.ts rename to src/backend/features/load/load-group.ts index c7a9a7a73..fbcccb057 100644 --- a/src/backend/load/load-group.ts +++ b/src/backend/features/load/load-group.ts @@ -1,28 +1,28 @@ import { eq, inArray } from "drizzle-orm"; import type { BatchItem } from "drizzle-orm/batch"; -import { type Db, getDb } from "../db"; -import { ElementType } from "../../shared/types"; -import type { ThumbnailUrls } from "../../shared/types"; +import { type Db, getDb } from "../../db/client"; +import { ElementType } from "../../lib/onshape/element-type"; +import type { ThumbnailUrls } from "../thumbnails/types"; import { addBuildIssue, type BuildIssue, BuildIssueType -} from "../../shared/build-issues"; -import { group, insertables } from "../../shared/schema"; -import { uploadDocumentThumbnails } from "../routes/thumbnails"; -import { getContents } from "../onshape-api/endpoints/documents"; -import type { OnshapeElement } from "../onshape-api/onshape-types"; -import { checkGroup } from "../parse/build-checks"; -import { parseInsertableTabs } from "../parse/parse-document-contents"; +} from "../build-checker/issues"; +import { group, insertables } from "../../db/schema"; +import { uploadDocumentThumbnails } from "../thumbnails/store"; +import { getContents } from "../../lib/onshape/endpoints/documents"; +import type { OnshapeElement } from "../../lib/onshape/types"; +import { checkGroup } from "../build-checker/checks"; +import { parseInsertableTabs } from "./parse-document-contents"; import { loadInsertable } from "./load-insertable"; import { type GroupTarget, type InsertableTarget, type LoadContext, getOnshapeApiFromContext -} from "./load-common"; -import { uploadThumbnailsStep } from "./load-steps"; -import type { InstancePath } from "../../shared/onshape-path"; +} from "./context"; +import { uploadThumbnailsStep } from "./steps"; +import type { InstancePath } from "../../lib/onshape/path"; export interface GroupLoadResult { loadedElements: number; diff --git a/src/backend/load/load-insertable.test.ts b/src/backend/features/load/load-insertable.test.ts similarity index 71% rename from src/backend/load/load-insertable.test.ts rename to src/backend/features/load/load-insertable.test.ts index 4c916b56d..7d7db0c36 100644 --- a/src/backend/load/load-insertable.test.ts +++ b/src/backend/features/load/load-insertable.test.ts @@ -1,19 +1,20 @@ import { env } from "cloudflare:workers"; import { eq } from "drizzle-orm"; import { beforeEach, describe, expect, it } from "vitest"; -import { getDb } from "../db"; -import { configurations, insertables } from "../../shared/schema"; -import type { ConfigurationRecord } from "../../shared/configuration-models"; +import { getDb } from "../../db/client"; +import { configurations, insertables } from "../../db/schema"; +import type { ParameterValues, PartMetadata } from "../configurations/models"; +import { configurationRecord } from "../../../__test_utils__/configuration-fixtures"; import { TEST_PARAMETERS, TEST_PART_STUDIO_ID, resetDb, seedGroup -} from "../../__test_utils__"; +} from "../../../__test_utils__"; import { insertableTarget, parsedInsertable -} from "../../__test_utils__/insertable-fixtures"; +} from "../../../__test_utils__/insertable-fixtures"; import { saveInsertable } from "./load-insertable"; const db = getDb(env.DB); @@ -26,22 +27,11 @@ function readInsertable() { .get(); } -/** Builds a configuration record with the given part number and defaults. */ -function record( - partNumber: string | null, - configuration: Record = {} -): ConfigurationRecord { - return { - configuration, - partNumber, - name: null, - description: null, - material: null, - vendor: null, - hasMultipleParts: false, - isUnstableComposite: false - }; -} +const partMetadata = (partNumber?: string): PartMetadata => + configurationRecord({ partNumber }); + +const record = (partNumber?: string, configuration: ParameterValues = {}) => + configurationRecord({ partNumber, configuration }); describe("saveInsertable", () => { beforeEach(async () => { @@ -129,32 +119,30 @@ describe("saveInsertable", () => { expect(config?.records).toEqual(records); }); - // An indexed insertable with no varying parameters still needs its records - // stored, so a configurations row is kept even when `parameters` is empty. - it("keeps a configuration row for a non-configurable indexed insertable", async () => { + // The element's own part number is not a configuration of it, so probing an + // unconfigurable element must not manufacture a configurations row. + it("stores part data on the insertable without a configuration row", async () => { await saveInsertable( db, insertableTarget(), parsedInsertable({ - configuration: { - parameters: [], - records: [record("PN-default")] - } + partMetadata: partMetadata("PN-default"), + configuration: { parameters: [], records: [] } }) ); - const config = await db + const insertable = await db .select() - .from(configurations) - .where(eq(configurations.id, TEST_PART_STUDIO_ID)) + .from(insertables) + .where(eq(insertables.id, TEST_PART_STUDIO_ID)) .get(); - expect(config?.parameters).toEqual([]); - expect(config?.records).toEqual([record("PN-default")]); + expect(insertable?.partMetadata).toEqual(partMetadata("PN-default")); + expect(await db.select().from(configurations).all()).toHaveLength(0); }); - // library-data reads `records` without re-checking that the insertable is - // still indexed, so an empty reload must drop the row, not blank it. - it("drops the configuration row when there are no parameters or records", async () => { + // features/library/db.ts treats the row's existence as "configurable", so an + // insertable that stops being configurable must lose the row, not blank it. + it("drops the configuration row when there are no parameters", async () => { await saveInsertable( db, insertableTarget(), diff --git a/src/backend/load/load-insertable.ts b/src/backend/features/load/load-insertable.ts similarity index 76% rename from src/backend/load/load-insertable.ts rename to src/backend/features/load/load-insertable.ts index 19bf1a4d0..31b490eda 100644 --- a/src/backend/load/load-insertable.ts +++ b/src/backend/features/load/load-insertable.ts @@ -1,40 +1,40 @@ import { eq } from "drizzle-orm"; -import { type Db, getDb } from "../db"; +import { type Db, getDb } from "../../db/client"; import type { Configuration, + PartMetadata, ConfigurationParameter -} from "../../shared/configuration-models"; +} from "../configurations/models"; import { addBuildIssue, type BuildIssue, - BuildIssueType -} from "../../shared/build-issues"; -import { - ElementType, - type FastenInfo, - type ThumbnailUrls, - type Vendor -} from "../../shared/types"; -import { configurations, insertables } from "../../shared/schema"; -import { uploadThumbnails } from "../routes/thumbnails"; -import { getConfiguration } from "../onshape-api/endpoints/configurations"; -import { getParts } from "../onshape-api/endpoints/parts"; -import { checkInsertable } from "../parse/build-checks"; -import { parseOnshapeConfiguration } from "../parse/parse-configuration"; -import { parseVendors } from "../parse/parse-vendors"; -import { parseFastenInfo } from "../parse/insert-and-fasten"; + BuildIssueType, + hasBuildIssue +} from "../build-checker/issues"; +import { ElementType } from "../../lib/onshape/element-type"; +import type { FastenInfo } from "../library/insertables/fasten"; +import type { ThumbnailUrls } from "../thumbnails/types"; +import type { Vendor } from "../library/vendors"; +import { configurations, insertables } from "../../db/schema"; +import { uploadThumbnails } from "../thumbnails/store"; +import { getConfiguration } from "../../lib/onshape/endpoints/configurations"; +import { getParts } from "../../lib/onshape/endpoints/parts"; +import { checkInsertable } from "../build-checker/checks"; +import { parseOnshapeConfiguration } from "./parse-configuration"; +import { parseVendors } from "./parse-vendors"; +import { parseFastenInfo } from "./parse-fasten"; import { NO_RECORDS, computeOpenComposite, decideIndexing, loadConfigurationRecords -} from "../parse/parse-configuration-records"; +} from "./parse-configuration-records"; import { type InsertableTarget, type LoadContext, getOnshapeApiFromContext -} from "./load-common"; -import { uploadThumbnailsStep } from "./load-steps"; +} from "./context"; +import { uploadThumbnailsStep } from "./steps"; /** * Exactly the columns a reload overwrites; the rest of the row is identity or @@ -47,6 +47,8 @@ export interface ParsedInsertable { /** Whether the part studio resolves to an open composite. */ isOpenComposite: boolean; buildIssues: BuildIssue[]; + /** The element's own part data; null when nothing was probed. */ + partMetadata: PartMetadata | null; configuration: Configuration; } @@ -73,9 +75,17 @@ export async function loadInsertable( ? await parseFastenInfoStep(ctx, target) : null; - const { isOpenComposite, hasParts } = await readPartsStep(ctx, target); + const parts = await readPartsStep(ctx, target); + const { isOpenComposite } = parts; + // An empty studio renders nothing and probes to nothing, so what it raises + // decides how much of the rest of the load is worth running. + const hasParts = !hasBuildIssue(parts.buildIssues, BuildIssueType.NO_PARTS); - const indexing = decideIndexing(parameters, flags.indexConfigurations); + const indexing = decideIndexing( + target.elementType, + parameters, + flags.indexConfigurations + ); const recordsResult = indexing.shouldIndex ? await loadConfigurationRecords( @@ -110,9 +120,9 @@ export async function loadInsertable( ? checkInsertable({ vendors, thumbnailUrls, - records: recordsResult.records + probes: [recordsResult.partMetadata, ...recordsResult.records] }) - : [{ type: BuildIssueType.NO_PARTS }], + : parts.buildIssues, ...recordsResult.buildIssues, ...indexing.buildIssues ); @@ -123,6 +133,7 @@ export async function loadInsertable( fastenInfo, isOpenComposite, buildIssues, + partMetadata: recordsResult.partMetadata, configuration: { parameters, records: recordsResult.records } }; @@ -168,7 +179,8 @@ function parseConfigurationStep( /** What one look at a part studio's default parts tells the rest of the load. */ interface PartsSummary { isOpenComposite: boolean; - hasParts: boolean; + /** NO_PARTS when the studio is empty; the rest of the load reads it. */ + buildIssues: BuildIssue[]; } /** @@ -180,9 +192,9 @@ function readPartsStep( { insertableId, elementPath, elementType }: InsertableTarget ): Promise { if (elementType !== ElementType.PART_STUDIO) { - return Promise.resolve({ isOpenComposite: false, hasParts: true }); + return Promise.resolve({ isOpenComposite: false, buildIssues: [] }); } - return ctx.step.do(`open-composite-${insertableId}`, async () => { + return ctx.step.do(`parts-${insertableId}`, async () => { const parts = await getParts( await getOnshapeApiFromContext(ctx), elementPath, @@ -190,7 +202,8 @@ function readPartsStep( ); return { isOpenComposite: computeOpenComposite(parts), - hasParts: parts.length > 0 + buildIssues: + parts.length > 0 ? [] : [{ type: BuildIssueType.NO_PARTS }] }; }); } @@ -228,6 +241,7 @@ export async function saveInsertable( largeThumbnailUrl: parsed.thumbnailUrls?.large ?? null, fastenInfo: parsed.fastenInfo, isOpenComposite: parsed.isOpenComposite, + partMetadata: parsed.partMetadata, buildIssues: parsed.buildIssues, lastLoadedAt: Date.now() }; @@ -253,13 +267,9 @@ export async function saveInsertable( set: reloaded }); - // Keep the row while it holds either parameters or records; an insertable - // that is neither configurable nor indexed needs none. + // A configurations row exists exactly when the insertable is configurable. let configurationWrite; - if ( - configuration.parameters.length > 0 || - configuration.records.length > 0 - ) { + if (configuration.parameters.length > 0) { configurationWrite = db .insert(configurations) .values({ id: target.insertableId, ...configuration }) diff --git a/src/backend/parse/parse-configuration-records.test.ts b/src/backend/features/load/parse-configuration-records.test.ts similarity index 67% rename from src/backend/parse/parse-configuration-records.test.ts rename to src/backend/features/load/parse-configuration-records.test.ts index 6f264a2a6..a4b02e473 100644 --- a/src/backend/parse/parse-configuration-records.test.ts +++ b/src/backend/features/load/parse-configuration-records.test.ts @@ -1,20 +1,24 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { countConfigurations } from "../../shared/configuration-combinations"; -import * as PartsEndpoints from "../onshape-api/endpoints/parts"; -import * as MetadataEndpoints from "../onshape-api/endpoints/metadata"; -import { OnshapeApi } from "../onshape-api/onshape-api"; +import { countConfigurations } from "../configurations/combinations"; +import * as PartsEndpoints from "../../lib/onshape/endpoints/parts"; +import * as MetadataEndpoints from "../../lib/onshape/endpoints/metadata"; +import { OnshapeApi } from "../../lib/onshape/client"; import type { OnshapeMetadataObject, OnshapePart -} from "../onshape-api/onshape-types"; -import { ElementPath } from "../../shared/onshape-path"; +} from "../../lib/onshape/types"; +import { ElementPath } from "../../lib/onshape/path"; import { ParameterValues, ConfigurationParameter -} from "../../shared/configuration-models"; -import { enumParam } from "../../__test_utils__/configuration-fixtures"; -import { ElementType } from "../../shared/types"; -import { BuildIssueType } from "../../shared/build-issues"; +} from "../configurations/models"; +import { + enumParam, + paramsWithConfigs +} from "../../../__test_utils__/configuration-fixtures"; +import { ElementType } from "../../lib/onshape/element-type"; +import { BuildIssueType } from "../build-checker/issues"; +import { Vendor } from "../library/vendors"; import { decideIndexing, parseAssemblyRecord, @@ -34,18 +38,8 @@ const CLIENT = {} as OnshapeApi; afterEach(() => vi.restoreAllMocks()); -/** A single enum whose N options make the element enumerate to N configurations. */ -function paramsWithConfigs(count: number): ConfigurationParameter[] { - return [ - enumParam( - "A", - Array.from({ length: count }, (_, i) => `o${i}`) - ) - ]; -} - -const MANY = [{ type: BuildIssueType.MANY_CONFIGURATIONS }]; -const TOO_MANY = [{ type: BuildIssueType.TOO_MANY_CONFIGURATIONS }]; +const MANY = [{ type: BuildIssueType.MANUAL_INDEXING_REQUIRED }]; +const TOO_MANY = [{ type: BuildIssueType.CONFIGURATION_LIMIT_EXCEEDED }]; describe("decideIndexing", () => { // Vendors no longer enter into it: the configuration count is the only gate. @@ -62,6 +56,7 @@ describe("decideIndexing", () => { { configs: 600, force: true, index: false, issues: TOO_MANY } ])("configs=$configs force=$force", ({ configs, force, index, issues }) => { const { shouldIndex, buildIssues } = decideIndexing( + ElementType.PART_STUDIO, paramsWithConfigs(configs), force ); @@ -70,6 +65,14 @@ describe("decideIndexing", () => { buildIssues: issues }); }); + + // No assembly configures its part properties, so the default probe is the + // whole of it however many combinations the count would have enumerated. + it("probes only the default for an assembly", () => { + expect( + decideIndexing(ElementType.ASSEMBLY, paramsWithConfigs(600), false) + ).toEqual({ shouldIndex: true, buildIssues: [], configurations: [] }); + }); }); describe("parsePartStudioRecord", () => { @@ -97,7 +100,7 @@ describe("parsePartStudioRecord", () => { material: "6061 Aluminum", vendor: "AM", hasMultipleParts: false, - isUnstableComposite: false + isOpenComposite: false }); }); @@ -125,7 +128,7 @@ describe("parsePartStudioRecord", () => { ); expect(record.partNumber).toBe("COMP-1"); expect(record.hasMultipleParts).toBe(false); - expect(record.isUnstableComposite).toBe(false); + expect(record.isOpenComposite).toBe(true); }); it("flags an unstable composite when a configuration loses its composite", () => { @@ -137,26 +140,17 @@ describe("parsePartStudioRecord", () => { ) ).toEqual({ configuration: { size: "S" }, - partNumber: null, - name: null, - description: null, - material: null, - vendor: null, hasMultipleParts: false, - isUnstableComposite: true + // The composite it was expected to resolve to is gone. + isOpenComposite: false }); }); it("returns an all-null record for an empty response", () => { expect(parsePartStudioRecord([], { A: "a1" }, false)).toEqual({ configuration: { A: "a1" }, - partNumber: null, - name: null, - description: null, - material: null, - vendor: null, hasMultipleParts: false, - isUnstableComposite: false + isOpenComposite: false }); }); }); @@ -182,7 +176,7 @@ describe("parseAssemblyRecord", () => { material: "Steel", vendor: "AM", hasMultipleParts: false, - isUnstableComposite: false + isOpenComposite: false }); }); }); @@ -198,28 +192,57 @@ function mockParts( ); } +/** Probes an element the way the load does: its own combinations, in full. */ +function probeRecords( + parameters: ConfigurationParameter[], + options: { elementType?: ElementType; isOpenComposite?: boolean } = {} +) { + return parseConfigurationRecords( + CLIENT, + PATH, + options.elementType ?? ElementType.PART_STUDIO, + parameters, + countConfigurations(parameters).configurations, + options.isOpenComposite ?? false + ); +} + describe("parseConfigurationRecords", () => { - it("returns a record per configuration, default first, in enumeration order", async () => { + it("returns the element's part data plus a record per configuration", async () => { mockParts((configuration) => [ { partId: "p", partNumber: `PN-${configuration.A ?? "default"}` } ]); - const result = await parseConfigurationRecords( - CLIENT, - PATH, - ElementType.PART_STUDIO, - [enumParam("A", ["a1", "a2"])], - countConfigurations([enumParam("A", ["a1", "a2"])]).configurations, - false - ); + const result = await probeRecords([enumParam("A", ["a1", "a2"])]); expect(result.buildIssues).toEqual([]); - // "a1" is A's default, so that combination is the default probe under - // another name and is not probed again. - expect(result.records.map((r) => r.partNumber)).toEqual([ - "PN-default", - "PN-a2" + // "a1" is A's default, so that combination is the element's own probe + // under another name and is not probed again. + expect(result.partMetadata?.partNumber).toBe("PN-default"); + expect(result.records.map((r) => r.partNumber)).toEqual(["PN-a2"]); + }); + + it("fills the vendor Onshape leaves unset, per configuration", async () => { + // Onshape reports no vendor; the option each record selected names it. + mockParts((configuration) => [ + { partId: "p", partNumber: `PN-${configuration.Vendor ?? "wcp"}` } ]); + const vendorParam = enumParam("Vendor", ["wcp", "am"]); + + const result = await probeRecords([vendorParam]); + + expect(result.partMetadata?.vendor).toBe(Vendor.WCP); + expect(result.records.map((r) => r.vendor)).toEqual([Vendor.AM]); + }); + + it("keeps the vendor Onshape does report", async () => { + mockParts(() => [ + { partId: "p", partNumber: "PN", vendor: "AndyMark", name: "WCP" } + ]); + + const result = await probeRecords([]); + + expect(result.partMetadata?.vendor).toBe("AndyMark"); }); it("probes every combination when none of them is the default", async () => { @@ -227,21 +250,12 @@ describe("parseConfigurationRecords", () => { { partId: "p", partNumber: `PN-${configuration.A ?? "default"}` } ]); - const result = await parseConfigurationRecords( - CLIENT, - PATH, - ElementType.PART_STUDIO, - [{ ...enumParam("A", ["a1", "a2"]), default: "a2" }], - countConfigurations([ - { ...enumParam("A", ["a1", "a2"]), default: "a2" } - ]).configurations, - false - ); - - expect(result.records.map((r) => r.partNumber)).toEqual([ - "PN-default", - "PN-a1" + const result = await probeRecords([ + { ...enumParam("A", ["a1", "a2"]), default: "a2" } ]); + + expect(result.partMetadata?.partNumber).toBe("PN-default"); + expect(result.records.map((r) => r.partNumber)).toEqual(["PN-a1"]); }); it("flags a studio with more than one part in any configuration", async () => { @@ -282,14 +296,9 @@ describe("parseConfigurationRecords", () => { ] ); - const result = await parseConfigurationRecords( - CLIENT, - PATH, - ElementType.PART_STUDIO, - [enumParam("A", ["a1", "a2"])], - countConfigurations([enumParam("A", ["a1", "a2"])]).configurations, - true - ); + const result = await probeRecords([enumParam("A", ["a1", "a2"])], { + isOpenComposite: true + }); expect(result.buildIssues).toEqual([ { type: BuildIssueType.UNSTABLE_COMPOSITE } @@ -303,18 +312,11 @@ describe("parseConfigurationRecords", () => { { partId: "p", partNumber: "PN-default" } ]); - const result = await parseConfigurationRecords( - CLIENT, - PATH, - ElementType.PART_STUDIO, - paramsWithConfigs(600), - countConfigurations(paramsWithConfigs(600)).configurations, - false - ); + const result = await probeRecords(paramsWithConfigs(600)); expect(result.buildIssues).toEqual([]); - expect(result.records).toHaveLength(1); - expect(result.records[0].partNumber).toBe("PN-default"); + expect(result.records).toHaveLength(0); + expect(result.partMetadata?.partNumber).toBe("PN-default"); expect(spy).toHaveBeenCalledTimes(1); }); @@ -325,27 +327,16 @@ describe("parseConfigurationRecords", () => { properties: [{ name: "Part number", value: "AM-1" }] }); - const result = await parseConfigurationRecords( - CLIENT, - PATH, - ElementType.ASSEMBLY, - [], - countConfigurations([]).configurations, - false - ); + const result = await probeRecords([], { + elementType: ElementType.ASSEMBLY + }); - expect(result.records).toEqual([ - { - configuration: {}, - partNumber: "AM-1", - name: null, - description: null, - material: null, - vendor: null, - hasMultipleParts: false, - isUnstableComposite: false - } - ]); + expect(result.records).toEqual([]); + expect(result.partMetadata).toEqual({ + partNumber: "AM-1", + hasMultipleParts: false, + isOpenComposite: false + }); expect(spy).toHaveBeenCalledWith(CLIENT, PATH, {}); }); }); diff --git a/src/backend/parse/parse-configuration-records.ts b/src/backend/features/load/parse-configuration-records.ts similarity index 76% rename from src/backend/parse/parse-configuration-records.ts rename to src/backend/features/load/parse-configuration-records.ts index 92befcfab..70eed9c83 100644 --- a/src/backend/parse/parse-configuration-records.ts +++ b/src/backend/features/load/parse-configuration-records.ts @@ -2,48 +2,51 @@ * Probes an insertable's configurations for the metadata we store. Every probe * is kept: search dedupes itself, and build checks read the ones it drops. */ -import { OnshapeApi } from "../onshape-api/onshape-api"; -import { ElementPath } from "../../shared/onshape-path"; -import { ElementType } from "../../shared/types"; +import { OnshapeApi } from "../../lib/onshape/client"; +import { parseRecordVendor } from "./parse-vendors"; +import { ElementPath } from "../../lib/onshape/path"; +import { ElementType } from "../../lib/onshape/element-type"; import { ParameterValues, ConfigurationParameter, + PartMetadata, ConfigurationRecord -} from "../../shared/configuration-models"; +} from "../configurations/models"; import { addBuildIssue, type BuildIssue, BuildIssueType -} from "../../shared/build-issues"; +} from "../build-checker/issues"; import { countConfigurations, IndexingBand, isIndexingEnabled -} from "../../shared/configuration-combinations"; -import { canonicalizeConfiguration } from "../../shared/canonical-configuration"; -import { getParts } from "../onshape-api/endpoints/parts"; -import { getElementMetadata } from "../onshape-api/endpoints/metadata"; +} from "../configurations/combinations"; +import { canonicalizeConfiguration } from "../configurations/canonical"; +import { getParts } from "../../lib/onshape/endpoints/parts"; +import { getElementMetadata } from "../../lib/onshape/endpoints/metadata"; import type { OnshapeMetadataObject, OnshapePart -} from "../onshape-api/onshape-types"; -import { - type LoadContext, - getOnshapeApiFromContext -} from "../load/load-common"; -import { ONSHAPE_STEP_RETRIES } from "../load/load-steps"; +} from "../../lib/onshape/types"; +import { type LoadContext, getOnshapeApiFromContext } from "./context"; +import { ONSHAPE_STEP_RETRIES } from "./steps"; /** Configurations fetched per workflow step. */ const BATCH_SIZE = 20; /** An insertable's configuration records, and the issues indexing them raised. */ export interface ConfigurationRecordsResult { + /** The element's own part data; null when nothing was probed. */ + partMetadata: PartMetadata | null; + /** One per indexed configuration; the element's own is `partMetadata`. */ records: ConfigurationRecord[]; buildIssues: BuildIssue[]; } /** The result for an insertable that isn't indexed. */ export const NO_RECORDS: ConfigurationRecordsResult = { + partMetadata: null, records: [], buildIssues: [] }; @@ -53,8 +56,8 @@ export const NO_RECORDS: ConfigurationRecordsResult = { * issues clears these first, so a resolved issue doesn't stick around. */ export const INDEXING_ISSUE_TYPES = [ - BuildIssueType.TOO_MANY_CONFIGURATIONS, - BuildIssueType.MANY_CONFIGURATIONS, + BuildIssueType.CONFIGURATION_LIMIT_EXCEEDED, + BuildIssueType.MANUAL_INDEXING_REQUIRED, BuildIssueType.MULTIPLE_PARTS, BuildIssueType.UNSTABLE_COMPOSITE, BuildIssueType.NO_PART_NUMBER @@ -72,23 +75,32 @@ export interface IndexingDecision { /** Past the hard cap forcing it on cannot help, since enumeration stops there. */ export function decideIndexing( + elementType: ElementType, parameters: ConfigurationParameter[], indexConfigurations: boolean ): IndexingDecision { + // No assembly configures its part properties today, so every combination + // would probe back to what the default already says. + if (elementType === ElementType.ASSEMBLY) { + return { shouldIndex: true, buildIssues: [], configurations: [] }; + } + const { band, configurations } = countConfigurations(parameters); const shouldIndex = isIndexingEnabled(band, indexConfigurations); if (band === IndexingBand.EXCEEDED) { return { shouldIndex, - buildIssues: [{ type: BuildIssueType.TOO_MANY_CONFIGURATIONS }], + buildIssues: [ + { type: BuildIssueType.CONFIGURATION_LIMIT_EXCEEDED } + ], configurations }; } if (band === IndexingBand.MANUAL && !indexConfigurations) { return { shouldIndex, - buildIssues: [{ type: BuildIssueType.MANY_CONFIGURATIONS }], + buildIssues: [{ type: BuildIssueType.MANUAL_INDEXING_REQUIRED }], configurations }; } @@ -96,9 +108,9 @@ export function decideIndexing( } /** Trims a raw metadata value; a missing or blank one becomes `null`. */ -function normalizeText(value: string | undefined | null): string | null { +function normalizeText(value: string | undefined | null): string | undefined { const trimmed = value?.trim(); - return trimmed ? trimmed : null; + return trimmed ? trimmed : undefined; } /** What a part studio's parts resolve to, before build issues are decided. */ @@ -146,16 +158,13 @@ export function parsePartStudioRecord( isOpenComposite: boolean ): ConfigurationRecord { const evaluation = evaluateParts(parts); + // An element that is an open composite everywhere else has no part to read + // in a configuration that loses it; toResult raises the build issue. if (isOpenComposite && !evaluation.isOpenComposite) { return { configuration, - partNumber: null, - name: null, - description: null, - material: null, - vendor: null, hasMultipleParts: false, - isUnstableComposite: true + isOpenComposite: false }; } const part = evaluation.partToUse; @@ -167,7 +176,7 @@ export function parsePartStudioRecord( material: normalizeText(part?.material?.displayName), vendor: normalizeText(part?.vendor), hasMultipleParts: evaluation.hasMultipleParts, - isUnstableComposite: false + isOpenComposite: evaluation.isOpenComposite }; } @@ -181,14 +190,14 @@ const METADATA_FIELDS = { } as const; /** Reads a metadata property value as text; materials arrive as `{displayName}`. */ -function readMetadataValue(value: unknown): string | null { +function readMetadataValue(value: unknown): string | undefined { if (typeof value === "string") { return normalizeText(value); } if (value && typeof value === "object" && "displayName" in value) { return normalizeText((value as { displayName?: string }).displayName); } - return null; + return undefined; } /** Builds a record from an assembly's element metadata for one configuration. */ @@ -196,15 +205,11 @@ export function parseAssemblyRecord( metadata: OnshapeMetadataObject, configuration: ParameterValues ): ConfigurationRecord { + // An assembly is never a composite, so it reads nothing about one. const record: ConfigurationRecord = { configuration, - partNumber: null, - name: null, - description: null, - material: null, - vendor: null, hasMultipleParts: false, - isUnstableComposite: false + isOpenComposite: false }; for (const property of metadata.properties) { const field = @@ -366,16 +371,42 @@ async function fetchBatch( return records; } +/** Onshape's vendor when a part carries one, otherwise the parsed one. */ +function resolveVendor( + record: ConfigurationRecord, + parameters: ConfigurationParameter[] +): string | undefined { + return ( + record.vendor ?? + parseRecordVendor(record.name, record.configuration, parameters) + ); +} + /** Folds the default probe and every batch together, the default first. */ function toResult( defaultRecord: ConfigurationRecord, batches: ConfigurationRecord[][], parameters: ConfigurationParameter[] ): ConfigurationRecordsResult { + // The element's own probe describes the element, not a configuration of it, + // so it sheds the (empty) configuration that produced it. + const partMetadata: PartMetadata = { + partNumber: defaultRecord.partNumber, + name: defaultRecord.name, + description: defaultRecord.description, + material: defaultRecord.material, + vendor: resolveVendor(defaultRecord, parameters), + hasMultipleParts: defaultRecord.hasMultipleParts, + isOpenComposite: defaultRecord.isOpenComposite + }; + // Canonical, so a record addresses the same thumbnail the insert menu does // for the same selection. - const records = [defaultRecord, ...batches.flat()].map((record) => ({ + const records = batches.flat().map((record) => ({ ...record, + // Read before canonicalizing, which drops a selection that is the + // default — including a default vendor option. + vendor: resolveVendor(record, parameters), configuration: canonicalizeConfiguration( record.configuration, parameters @@ -383,18 +414,24 @@ function toResult( })); // A capped insertable never reaches here: decideIndexing turns indexing off - // past the cap, and raises TOO_MANY_CONFIGURATIONS itself. + // past the cap, and raises CONFIGURATION_LIMIT_EXCEEDED itself. + const probes: PartMetadata[] = [partMetadata, ...records]; let buildIssues: BuildIssue[] = []; - if (records.some((record) => record.hasMultipleParts)) { + if (probes.some((probe) => probe.hasMultipleParts)) { buildIssues = addBuildIssue(buildIssues, { type: BuildIssueType.MULTIPLE_PARTS }); } - if (records.some((record) => record.isUnstableComposite)) { + // The element's own probe sets the expectation; losing the composite in any + // configuration is what makes it unstable. + if ( + partMetadata.isOpenComposite && + probes.some((probe) => !probe.isOpenComposite) + ) { buildIssues = addBuildIssue(buildIssues, { type: BuildIssueType.UNSTABLE_COMPOSITE }); } - return { records, buildIssues }; + return { partMetadata, records, buildIssues }; } diff --git a/src/backend/parse/parse-configuration.test.ts b/src/backend/features/load/parse-configuration.test.ts similarity index 59% rename from src/backend/parse/parse-configuration.test.ts rename to src/backend/features/load/parse-configuration.test.ts index 6b75a0ea7..393cfdfb2 100644 --- a/src/backend/parse/parse-configuration.test.ts +++ b/src/backend/features/load/parse-configuration.test.ts @@ -1,24 +1,21 @@ import { describe, expect, it } from "vitest"; import { OptionVisibilityType, - ConfigurationParameter, ParameterType, + ParameterValues, VisibilityCondition, VisibilityType -} from "../../shared/configuration-models"; -import { - LogicalOp, - QuantityType, - Unit -} from "../../shared/configuration-enums"; -import { evaluateCondition } from "../../shared/configuration-utils"; +} from "../configurations/models"; +import { enumParam } from "../../../__test_utils__/configuration-fixtures"; +import { LogicalOp, QuantityType, Unit } from "../configurations/enums"; +import { evaluateCondition } from "../configurations/utils"; import { parseOnshapeConfiguration } from "./parse-configuration"; import { OnshapeConfigurationResponse, OnshapeOptionVisibilityConditionType, OnshapeParameterType, OnshapeVisibilityConditionType -} from "../onshape-api/onshape-types"; +} from "../../lib/onshape/types"; /** No-op visibility condition Onshape attaches to always-visible parameters. */ const NONE = { btType: OnshapeVisibilityConditionType.NONE } as const; @@ -230,169 +227,52 @@ describe("parseOnshapeConfiguration", () => { }); describe("evaluateCondition", () => { - const makeEnumParam = ( - id: string, - options: string[] - ): ConfigurationParameter => ({ - type: ParameterType.ENUM, + const sizes = enumParam("size", ["xs", "sm", "md", "lg", "xl"]); + const equals = (id: string, value: string): VisibilityCondition => ({ + type: VisibilityType.EQUAL, id, - name: id, - isCosmetic: false, - default: options[0], - condition: undefined, - optionConditions: [], - options: options.map((option) => ({ id: option, name: option })) - }); - - it("returns true when condition is undefined", () => { - expect(evaluateCondition(undefined, {}, [])).toBe(true); - }); - - it("ALWAYS_SHOWN: returns true", () => { - const condition: VisibilityCondition = { - type: VisibilityType.ALWAYS_SHOWN - }; - expect(evaluateCondition(condition, {}, [])).toBe(true); - }); - - it("EQUAL: returns true when value matches", () => { - const condition = { - type: VisibilityType.EQUAL as const, - id: "size", - value: "large" - }; - expect(evaluateCondition(condition, { size: "large" }, [])).toBe(true); - }); - - it("EQUAL: returns false when value does not match", () => { - const condition = { - type: VisibilityType.EQUAL as const, - id: "size", - value: "large" - }; - expect(evaluateCondition(condition, { size: "small" }, [])).toBe(false); - }); - - it("RANGE: returns true when value is in range", () => { - const param = makeEnumParam("size", ["xs", "sm", "md", "lg", "xl"]); - const condition: VisibilityCondition = { - type: VisibilityType.RANGE, - id: "size", - start: "sm", - end: "lg" - }; - expect(evaluateCondition(condition, { size: "md" }, [param])).toBe( - true - ); + value }); - - it("RANGE: returns false when value is below start", () => { - const param = makeEnumParam("size", ["xs", "sm", "md", "lg", "xl"]); - const condition: VisibilityCondition = { - type: VisibilityType.RANGE, - id: "size", - start: "sm", - end: "lg" - }; - expect(evaluateCondition(condition, { size: "xs" }, [param])).toBe( - false - ); + /** `a=1` and `b=2`, joined by the operation under test. */ + const bothOf = (operation: LogicalOp): VisibilityCondition => ({ + type: VisibilityType.LOGICAL, + operation, + children: [equals("a", "1"), equals("b", "2")] }); + const smToLg: VisibilityCondition = { + type: VisibilityType.RANGE, + id: "size", + start: "sm", + end: "lg" + }; - it("RANGE: returns false when value is above end", () => { - const param = makeEnumParam("size", ["xs", "sm", "md", "lg", "xl"]); - const condition: VisibilityCondition = { - type: VisibilityType.RANGE, - id: "size", - start: "sm", - end: "lg" - }; - expect(evaluateCondition(condition, { size: "xl" }, [param])).toBe( - false - ); - }); - - it("LOGICAL AND: true when all children match", () => { - const condition: VisibilityCondition = { - type: VisibilityType.LOGICAL, - operation: LogicalOp.AND, - children: [ - { - type: VisibilityType.EQUAL, - id: "a", - value: "1" - }, - { - type: VisibilityType.EQUAL, - id: "b", - value: "2" - } - ] - }; - expect(evaluateCondition(condition, { a: "1", b: "2" }, [])).toBe(true); - }); - - it("LOGICAL AND: false when one child does not match", () => { - const condition: VisibilityCondition = { - type: VisibilityType.LOGICAL, - operation: LogicalOp.AND, - children: [ - { - type: VisibilityType.EQUAL, - id: "a", - value: "1" - }, - { - type: VisibilityType.EQUAL, - id: "b", - value: "2" - } - ] - }; - expect(evaluateCondition(condition, { a: "1", b: "9" }, [])).toBe( + const cases: [ + string, + VisibilityCondition | undefined, + ParameterValues, + boolean + ][] = [ + ["no condition", undefined, {}, true], + ["always shown", { type: VisibilityType.ALWAYS_SHOWN }, {}, true], + ["equal, matching", equals("size", "lg"), { size: "lg" }, true], + ["equal, differing", equals("size", "lg"), { size: "sm" }, false], + ["range, inside", smToLg, { size: "md" }, true], + ["range, below start", smToLg, { size: "xs" }, false], + ["range, above end", smToLg, { size: "xl" }, false], + ["and, both matching", bothOf(LogicalOp.AND), { a: "1", b: "2" }, true], + [ + "and, one differing", + bothOf(LogicalOp.AND), + { a: "1", b: "9" }, false - ); - }); + ], + ["or, one matching", bothOf(LogicalOp.OR), { a: "1", b: "9" }, true], + ["or, none matching", bothOf(LogicalOp.OR), { a: "9", b: "9" }, false] + ]; - it("LOGICAL OR: true when one child matches", () => { - const condition: VisibilityCondition = { - type: VisibilityType.LOGICAL, - operation: LogicalOp.OR, - children: [ - { - type: VisibilityType.EQUAL, - id: "a", - value: "1" - }, - { - type: VisibilityType.EQUAL, - id: "b", - value: "2" - } - ] - }; - expect(evaluateCondition(condition, { a: "1", b: "9" }, [])).toBe(true); - }); - - it("LOGICAL OR: false when no children match", () => { - const condition: VisibilityCondition = { - type: VisibilityType.LOGICAL, - operation: LogicalOp.OR, - children: [ - { - type: VisibilityType.EQUAL, - id: "a", - value: "1" - }, - { - type: VisibilityType.EQUAL, - id: "b", - value: "2" - } - ] - }; - expect(evaluateCondition(condition, { a: "9", b: "9" }, [])).toBe( - false + it.each(cases)("%s", (_name, condition, configuration, expected) => { + expect(evaluateCondition(condition, configuration, [sizes])).toBe( + expected ); }); }); diff --git a/src/backend/parse/parse-configuration.ts b/src/backend/features/load/parse-configuration.ts similarity index 97% rename from src/backend/parse/parse-configuration.ts rename to src/backend/features/load/parse-configuration.ts index 767e0ff73..0efdd08f7 100644 --- a/src/backend/parse/parse-configuration.ts +++ b/src/backend/features/load/parse-configuration.ts @@ -6,8 +6,8 @@ import { ParameterType, type VisibilityCondition, VisibilityType -} from "../../shared/configuration-models"; -import { getUnitDisplayStr } from "../../shared/configuration-enums"; +} from "../configurations/models"; +import { getUnitDisplayStr } from "../configurations/enums"; import { type OnshapeConfigurationResponse, type OnshapeEnumOptionVisibilityConditionList, @@ -15,7 +15,7 @@ import { OnshapeParameterType, type OnshapeVisibilityCondition, OnshapeVisibilityConditionType -} from "../onshape-api/onshape-types"; +} from "../../lib/onshape/types"; function parseVisibilityCondition( onshapeCondition: OnshapeVisibilityCondition | undefined diff --git a/src/backend/parse/parse-document-contents.test.ts b/src/backend/features/load/parse-document-contents.test.ts similarity index 98% rename from src/backend/parse/parse-document-contents.test.ts rename to src/backend/features/load/parse-document-contents.test.ts index 2feb16b30..4ad180515 100644 --- a/src/backend/parse/parse-document-contents.test.ts +++ b/src/backend/features/load/parse-document-contents.test.ts @@ -6,7 +6,7 @@ import { type OnshapeFolderEntry, OnshapeElementType, OnshapeFolderEntryType -} from "../onshape-api/onshape-types"; +} from "../../lib/onshape/types"; import { parseInsertableTabs } from "./parse-document-contents"; function element( diff --git a/src/backend/parse/parse-document-contents.ts b/src/backend/features/load/parse-document-contents.ts similarity index 93% rename from src/backend/parse/parse-document-contents.ts rename to src/backend/features/load/parse-document-contents.ts index 33ef27354..bbb24503d 100644 --- a/src/backend/parse/parse-document-contents.ts +++ b/src/backend/features/load/parse-document-contents.ts @@ -1,13 +1,13 @@ /** * Extracts the insertable tabs from a document's contents listing. */ -import { ElementType } from "../../shared/types"; +import { ElementType } from "../../lib/onshape/element-type"; import { type OnshapeDocumentContents, type OnshapeElement, type OnshapeFolderEntry, OnshapeFolderEntryType -} from "../onshape-api/onshape-types"; +} from "../../lib/onshape/types"; const VALID_ELEMENT_TYPES = new Set([ ElementType.ASSEMBLY, diff --git a/src/backend/parse/insert-and-fasten.test.ts b/src/backend/features/load/parse-fasten.test.ts similarity index 80% rename from src/backend/parse/insert-and-fasten.test.ts rename to src/backend/features/load/parse-fasten.test.ts index 22c90cfb4..c4fd26c2b 100644 --- a/src/backend/parse/insert-and-fasten.test.ts +++ b/src/backend/features/load/parse-fasten.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from "vitest"; -import { ElementType, FastenInfo, MateLocation } from "../../shared/types"; + +import { MateLocation } from "../library/insertables/fasten"; import { - getFastenQuery, parseFastenInfoFromPartStudio, parseFastenInfoFromAssembly -} from "./insert-and-fasten"; +} from "./parse-fasten"; describe("parseFastenInfoFromPartStudio", () => { it("returns Feature location with empty path when mate connector is found", () => { @@ -175,45 +175,3 @@ describe("parseFastenInfoFromAssembly", () => { expect(() => parseFastenInfoFromAssembly(rawAssemblyInfo)).toThrow(); }); }); - -describe("getFastenQuery", () => { - const fasten: FastenInfo = { - mateConnectorId: "mc", - mateLocation: MateLocation.Feature, - path: ["fp"] - }; - - it("builds a part-studio mate connector query for a part studio target", () => { - expect(getFastenQuery(ElementType.PART_STUDIO, ["np"], fasten)).toEqual( - { - btType: "BTMPartStudioMateConnectorQuery-1324", - featureId: "mc", - path: ["np"] - } - ); - }); - - it("uses a part-studio query for a Part mate in an assembly (combined path)", () => { - const partFasten: FastenInfo = { - mateConnectorId: "mc", - mateLocation: MateLocation.Part, - path: ["fp"] - }; - expect( - getFastenQuery(ElementType.ASSEMBLY, ["np"], partFasten) - ).toEqual({ - btType: "BTMPartStudioMateConnectorQuery-1324", - featureId: "mc", - path: ["np", "fp"] - }); - }); - - it("uses a feature-occurrence query for a Feature mate in an assembly", () => { - expect(getFastenQuery(ElementType.ASSEMBLY, ["np"], fasten)).toEqual({ - btType: "BTMFeatureQueryWithOccurrence-157", - path: ["np", "fp"], - queryData: "", - featureId: "mc" - }); - }); -}); diff --git a/src/backend/parse/insert-and-fasten.ts b/src/backend/features/load/parse-fasten.ts similarity index 74% rename from src/backend/parse/insert-and-fasten.ts rename to src/backend/features/load/parse-fasten.ts index fe3abbd45..3061d850a 100644 --- a/src/backend/parse/insert-and-fasten.ts +++ b/src/backend/features/load/parse-fasten.ts @@ -1,17 +1,15 @@ -import { ElementType, FastenInfo, MateLocation } from "../../shared/types"; -import { type ElementPath } from "../../shared/onshape-path"; -import { getAssembly } from "../onshape-api/endpoints/assemblies"; -import { getFeatures } from "../onshape-api/endpoints/part-studios"; -import { - featureOccurrenceQuery, - partStudioMateConnectorQuery -} from "../onshape-api/objects/assembly-features"; -import { OnshapeApi } from "../onshape-api/onshape-api"; +import { ElementType } from "../../lib/onshape/element-type"; +import { FastenInfo, MateLocation } from "../library/insertables/fasten"; +import { type ElementPath } from "../../lib/onshape/path"; +import { getAssembly } from "../../lib/onshape/endpoints/assemblies"; +import { getFeatures } from "../../lib/onshape/endpoints/part-studios"; + +import { OnshapeApi } from "../../lib/onshape/client"; import { OnshapeAssemblyDefinition, OnshapeAssemblyFeature, OnshapeFeatureListResponse -} from "../onshape-api/onshape-types"; +} from "../../lib/onshape/types"; export async function parseFastenInfo( onshapeApi: OnshapeApi, @@ -108,22 +106,3 @@ export function parseFastenInfoFromAssembly( "Failed to find a valid Mate connector feature or instance." ); } - -export function getFastenQuery( - targetElementType: ElementType, - path: string[], - fastenInfo: FastenInfo -): object { - if (targetElementType === ElementType.PART_STUDIO) { - return partStudioMateConnectorQuery(fastenInfo.mateConnectorId, path); - } - - const assemblyPath = [...path, ...fastenInfo.path]; - if (fastenInfo.mateLocation === MateLocation.Part) { - return partStudioMateConnectorQuery( - fastenInfo.mateConnectorId, - assemblyPath - ); - } - return featureOccurrenceQuery(fastenInfo.mateConnectorId, assemblyPath); -} diff --git a/src/backend/parse/parse-vendors.test.ts b/src/backend/features/load/parse-vendors.test.ts similarity index 63% rename from src/backend/parse/parse-vendors.test.ts rename to src/backend/features/load/parse-vendors.test.ts index d7879bb05..a75efa46b 100644 --- a/src/backend/parse/parse-vendors.test.ts +++ b/src/backend/features/load/parse-vendors.test.ts @@ -1,8 +1,12 @@ import { describe, expect, it } from "vitest"; -import { Vendor } from "../../shared/types"; -import { ParameterType } from "../../shared/configuration-models"; -import { QuantityType, Unit } from "../../shared/configuration-enums"; -import { parseNameVendor, parseVendors } from "./parse-vendors"; +import { Vendor } from "../library/vendors"; +import { ParameterType } from "../configurations/models"; +import { QuantityType, Unit } from "../configurations/enums"; +import { + parseNameVendor, + parseRecordVendor, + parseVendors +} from "./parse-vendors"; describe("parseNameVendor", () => { it("detects vendor token in element name", () => { @@ -100,3 +104,60 @@ describe("parseVendors", () => { expect(parseVendors("Customizable Spacer", [])).toEqual([]); }); }); + +const vendorParameter = { + id: "vendor", + name: "Vendor", + default: "wcp", + isCosmetic: false, + type: ParameterType.ENUM as const, + options: [ + { id: "wcp", name: "West Coast Products" }, + { id: "am", name: "AndyMark" } + ], + optionConditions: [] +}; + +describe("parseRecordVendor", () => { + it("reads the selected option, by its full name", () => { + expect( + parseRecordVendor("Bearing", { vendor: "am" }, [vendorParameter]) + ).toBe(Vendor.AM); + }); + + it("reads a default selection, which is still a selection", () => { + expect( + parseRecordVendor("Bearing", { vendor: "wcp" }, [vendorParameter]) + ).toBe(Vendor.WCP); + }); + + it("treats an absent value as the parameter's default", () => { + expect(parseRecordVendor("Bearing", {}, [vendorParameter])).toBe( + Vendor.WCP + ); + }); + + it("prefers the selection over the part's own name", () => { + expect( + parseRecordVendor("REV Bearing", { vendor: "am" }, [ + vendorParameter + ]) + ).toBe(Vendor.AM); + }); + + it("falls back to the part name when nothing is selected", () => { + expect(parseRecordVendor("WCP-1025 Gearbox", {}, [])).toBe(Vendor.WCP); + }); + + it("reads a vendor off a part name that is only a part number", () => { + expect(parseRecordVendor("WCP-1025", {}, [])).toBe(Vendor.WCP); + }); + + it("has none when neither names a vendor", () => { + expect(parseRecordVendor("Generic Bearing", {}, [])).toBeUndefined(); + }); + + it("has none without a part name", () => { + expect(parseRecordVendor(undefined, {}, [])).toBeUndefined(); + }); +}); diff --git a/src/backend/features/load/parse-vendors.ts b/src/backend/features/load/parse-vendors.ts new file mode 100644 index 000000000..5c725deaa --- /dev/null +++ b/src/backend/features/load/parse-vendors.ts @@ -0,0 +1,61 @@ +import { Vendor, toVendor } from "../library/vendors"; +import { + ParameterType, + type ConfigurationParameter, + type ParameterValues +} from "../configurations/models"; + +export function parseNameVendor(name: string): Vendor | undefined { + const words = name.toUpperCase().match(/\b(\w+)\b/g) ?? []; + for (const word of words) { + const vendor = Object.values(Vendor).find( + (v) => (v as string).toUpperCase() === word + ); + if (vendor !== undefined) return vendor; + } + return undefined; +} + +/** A vendor an option names, as a token within its label or as the whole of it. */ +function parseOptionVendor(optionName: string): Vendor | undefined { + return parseNameVendor(optionName) ?? toVendor(optionName); +} + +export function parseVendors( + name: string, + parameters: ConfigurationParameter[] +): Vendor[] { + const nameVendor = parseNameVendor(name); + if (nameVendor) return [nameVendor]; + + const vendors = new Set(); + for (const param of parameters) { + if (param.type !== ParameterType.ENUM) continue; + for (const option of param.options) { + const vendor = parseOptionVendor(option.name); + if (vendor) vendors.add(vendor); + } + } + return [...vendors]; +} + +/** + * The vendor one configuration resolves to. Its selected options name it more + * precisely than the part does, so they are read before the part's own name. + */ +export function parseRecordVendor( + partName: string | undefined, + configuration: ParameterValues, + parameters: ConfigurationParameter[] +): Vendor | undefined { + for (const param of parameters) { + if (param.type !== ParameterType.ENUM) continue; + // An absent value is the parameter's default, which is what the + // element's own probe — configured with nothing — resolves to. + const selected = configuration[param.id] ?? param.default; + const option = param.options.find((o) => o.id === selected); + const vendor = option && parseOptionVendor(option.name); + if (vendor) return vendor; + } + return partName ? parseNameVendor(partName) : undefined; +} diff --git a/src/backend/load/load-steps.test.ts b/src/backend/features/load/steps.test.ts similarity index 78% rename from src/backend/load/load-steps.test.ts rename to src/backend/features/load/steps.test.ts index a58ebb442..e1c820978 100644 --- a/src/backend/load/load-steps.test.ts +++ b/src/backend/features/load/steps.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; -import { OnshapeRateLimitError } from "../onshape-api/onshape-api"; -import { NoSuchConfigurationError } from "../onshape-api/endpoints/thumbnails"; -import { ONSHAPE_STEP_RETRIES, THUMBNAIL_STEP_RETRIES } from "./load-steps"; +import { OnshapeRateLimitError } from "../../lib/onshape/client"; +import { NoSuchConfigurationError } from "../../lib/onshape/endpoints/thumbnails"; +import { ONSHAPE_STEP_RETRIES, THUMBNAIL_STEP_RETRIES } from "./steps"; /** The delay before the retry that follows attempt `attempt`. */ function thumbnailDelay(attempt: number, error = new Error("not rendered")) { @@ -12,28 +12,29 @@ describe("THUMBNAIL_STEP_RETRIES", () => { // Onshape gives no signal when a render lands, so the step polls. Starting // at four seconds keeps a quick render from waiting on a long first delay. it("doubles from four seconds", () => { - expect([1, 2, 3, 4, 5, 6].map((a) => thumbnailDelay(a))).toEqual([ + expect([1, 2, 3, 4, 5].map((a) => thumbnailDelay(a))).toEqual([ "4 seconds", "8 seconds", "16 seconds", "32 seconds", - "64 seconds", - "128 seconds" + "64 seconds" ]); }); - it("keeps doubling rather than settling on a ceiling", () => { - expect(thumbnailDelay(7)).toEqual("256 seconds"); - expect(thumbnailDelay(8)).toEqual("512 seconds"); + // Uncapped, the last waits outgrow the renders themselves, leaving a + // thumbnail that landed early unnoticed for minutes. + it("stops doubling at two minutes", () => { + expect(thumbnailDelay(6)).toEqual("120 seconds"); + expect(thumbnailDelay(12)).toEqual("120 seconds"); }); - it("polls for about seventeen minutes before giving up", () => { + it("polls for about half an hour before giving up", () => { const total = Array.from( { length: THUMBNAIL_STEP_RETRIES.limit - 1 }, (_, i) => Number.parseInt(thumbnailDelay(i + 1), 10) ).reduce((sum, seconds) => sum + seconds, 0); - expect(total).toBe(1020); + expect(total).toBe(1804); }); // A warm request naming a configuration that matches nothing would diff --git a/src/backend/load/load-steps.ts b/src/backend/features/load/steps.ts similarity index 75% rename from src/backend/load/load-steps.ts rename to src/backend/features/load/steps.ts index 1ef2ed18a..b798ea6dc 100644 --- a/src/backend/load/load-steps.ts +++ b/src/backend/features/load/steps.ts @@ -1,7 +1,7 @@ -import { OnshapeRateLimitError } from "../onshape-api/onshape-api"; -import type { ThumbnailUrls } from "../../shared/types"; -import { NoSuchConfigurationError } from "../onshape-api/endpoints/thumbnails"; -import type { LoadContext } from "./load-common"; +import { OnshapeRateLimitError } from "../../lib/onshape/client"; +import type { ThumbnailUrls } from "../thumbnails/types"; +import { NoSuchConfigurationError } from "../../lib/onshape/endpoints/thumbnails"; +import type { LoadContext } from "./context"; /** The retry input a Workflow `delay` callback receives. */ interface RetryDelayInput { @@ -40,6 +40,12 @@ export const ONSHAPE_STEP_RETRIES = { /** The first wait after a render isn't ready; each attempt doubles it. */ const THUMBNAIL_BASE_DELAY_SECONDS = 4; +/** + * Where the doubling stops. Left uncapped, the last waits grow longer than the + * renders themselves, so a thumbnail that landed early sits unnoticed. + */ +const THUMBNAIL_MAX_DELAY_SECONDS = 120; + /** * Onshape gives no signal when a render lands, so the step polls, doubling from * four seconds. A rate limit overrides the curve. @@ -53,14 +59,17 @@ function thumbnailRetryDelay(input: RetryDelayInput): `${number} seconds` { if (input.error instanceof NoSuchConfigurationError) { return "0 seconds"; } - const seconds = THUMBNAIL_BASE_DELAY_SECONDS * 2 ** (input.ctx.attempt - 1); + const seconds = Math.min( + THUMBNAIL_BASE_DELAY_SECONDS * 2 ** (input.ctx.attempt - 1), + THUMBNAIL_MAX_DELAY_SECONDS + ); return `${seconds} seconds`; } export const THUMBNAIL_STEP_RETRIES = { - // 4s, 8s … 512s: a bit over seventeen minutes of polling, which a slow - // Onshape render is worth waiting out. - limit: 9, + // 4s, 8s … 120s and then every two minutes: half an hour of polling, since + // a reload is the only other way to pick a late render up. + limit: 20, delay: thumbnailRetryDelay }; diff --git a/src/backend/load/workflows.ts b/src/backend/features/load/workflows.ts similarity index 70% rename from src/backend/load/workflows.ts rename to src/backend/features/load/workflows.ts index 678065270..3ec635514 100644 --- a/src/backend/load/workflows.ts +++ b/src/backend/features/load/workflows.ts @@ -4,29 +4,28 @@ import { type WorkflowStep } from "cloudflare:workers"; import { eq } from "drizzle-orm"; -import type { AppBindings } from "../app"; -import { getDb } from "../db"; -import type { LibraryId } from "../../shared/types"; +import type { AppBindings } from "../../lib/context"; +import { getDb } from "../../db/client"; +import type { LibraryId } from "../library/library-id"; import { bumpLibraryVersion, placeNewGroup, rebuildSearchDb -} from "../library-data"; -import { getDocument } from "../onshape-api/endpoints/documents"; -import { getLatestVersionId } from "../onshape-api/endpoints/versions"; -import type { InstancePath } from "../../shared/onshape-path"; -import { group, insertables, libraries } from "../../shared/schema"; -import { uploadConfigurationThumbnails } from "../routes/thumbnails"; +} from "../library/db"; +import { getDocument } from "../../lib/onshape/endpoints/documents"; +import { getLatestVersionId } from "../../lib/onshape/endpoints/versions"; +import type { InstancePath } from "../../lib/onshape/path"; +import { group, libraries } from "../../db/schema"; + import { type GroupTarget, type LoadContext, LOAD_CONCURRENCY, createLimiter, getOnshapeApiFromContext -} from "./load-common"; +} from "./context"; import { untrackJob } from "./job-tracker"; import { loadGroup } from "./load-group"; -import { THUMBNAIL_STEP_RETRIES } from "./load-steps"; export interface LoadLibraryParams { libraryId: LibraryId; @@ -233,73 +232,3 @@ async function finalizeLibrary( await rebuildSearchDb(env.BLOB, db, libraryId); await bumpLibraryVersion(db, libraryId); } - -/** The render to run, plus the session whose Onshape tokens it runs under. */ -export interface ThumbnailWorkflowParams { - insertableId: string; - /** Part of the key, so a render lands where the request looked for it. */ - microversionId: string; - /** Never the default, which loads eagerly with the element. */ - canonicalConfiguration: string; - sessionId: string; -} - -/** - * Outside a request, since Onshape can take minutes. Until it finishes, - * requests fall back to the element's default thumbnail. - */ -export class ThumbnailWorkflow extends WorkflowEntrypoint< - AppBindings, - ThumbnailWorkflowParams -> { - async run( - event: WorkflowEvent, - step: WorkflowStep - ): Promise { - const { - insertableId, - microversionId, - canonicalConfiguration, - sessionId - } = event.payload; - - const elementPath = await step.do("resolve-element", async () => { - const row = await getDb(this.env.DB) - .select({ - documentId: insertables.documentId, - versionId: insertables.versionId, - elementId: insertables.elementId - }) - .from(insertables) - .where(eq(insertables.id, insertableId)) - .get(); - if (!row) { - throw new Error(`No insertable ${insertableId}`); - } - return { - documentId: row.documentId, - instanceId: row.versionId, - instanceType: "v" as const, - elementId: row.elementId - }; - }); - - await step.do( - "render-thumbnails", - { retries: THUMBNAIL_STEP_RETRIES }, - async () => - uploadConfigurationThumbnails( - this.env.BLOB, - await getOnshapeApiFromContext({ - env: this.env, - sessionId, - step, - limit: createLimiter(1) - }), - elementPath, - microversionId, - canonicalConfiguration - ) - ); - } -} diff --git a/src/backend/features/search/search-index.test.ts b/src/backend/features/search/search-index.test.ts new file mode 100644 index 000000000..7a82df118 --- /dev/null +++ b/src/backend/features/search/search-index.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { toSearchRecords } from "./search-index"; +import { Vendor } from "../library/vendors"; +import { configurationRecord as record } from "../../../__test_utils__/configuration-fixtures"; + +describe("toSearchRecords", () => { + it("drops a part number that only repeats the name", () => { + const [result] = toSearchRecords([ + record({ partNumber: "Spacer", name: "Spacer" }) + ]); + expect(result.partNumber).toBeUndefined(); + expect(result.name).toBe("Spacer"); + }); + + it("ignores case and surrounding space when comparing the two", () => { + const [result] = toSearchRecords([ + record({ partNumber: " spacer ", name: "Spacer" }) + ]); + expect(result.partNumber).toBeUndefined(); + }); + + it("will not link a repeated part number to a vendor", () => { + const [result] = toSearchRecords( + [record({ partNumber: "Bearing", name: "Bearing" })], + [Vendor.WCP] + ); + expect(result.url).toBeUndefined(); + }); + + it("keeps a part number that says something the name does not", () => { + const [result] = toSearchRecords( + [record({ partNumber: "WCP-1025", name: "Gearbox" })], + [Vendor.WCP] + ); + expect(result.partNumber).toBe("WCP-1025"); + expect(result.url).toBe("https://wcproducts.com/products/wcp-1025"); + }); + + it("keeps a record that is left with only a name", () => { + expect( + toSearchRecords([record({ partNumber: "Spacer", name: "Spacer" })]) + ).toHaveLength(1); + }); + + it("drops a record with neither", () => { + expect(toSearchRecords([record({})])).toHaveLength(0); + }); +}); diff --git a/src/shared/search.ts b/src/backend/features/search/search-index.ts similarity index 67% rename from src/shared/search.ts rename to src/backend/features/search/search-index.ts index 91b7e51ad..3d7593dba 100644 --- a/src/shared/search.ts +++ b/src/backend/features/search/search-index.ts @@ -3,9 +3,10 @@ * deserializes it with the same options. */ import MiniSearch, { Options } from "minisearch"; -import { LibraryOut } from "./api-models"; -import { Vendor } from "./types"; -import { ConfigurationRecord, SearchRecord } from "./configuration-models"; +import { LibraryOut } from "../library/contract"; +import { Vendor } from "../library/vendors"; +import { ConfigurationRecord, SearchRecord } from "../configurations/models"; +import { getPartUrl } from "../configurations/utils"; const deliminator = "^"; @@ -32,9 +33,21 @@ export function processTerm(term: string): string[] { return Array.from(new Set(terms)); } -// A mixed number, simple fraction, or decimal (incl. leading-dot). Alternatives -// are ordered longest-first so `1-1/2` is consumed whole, not as `1` + `1/2`. -const NUMERIC_PATTERN = /(\d+)-(\d+)\/(\d+)|(\d+)\/(\d+)|\d*\.\d+|\d+\.\d*/g; +// A mixed number, simple fraction, decimal (incl. leading-dot), or plain +// integer. Alternatives are ordered longest-first so `1-1/2` is consumed whole, +// not as `1` + `1/2`. +const NUMERIC_PATTERN = + /(\d+)-(\d+)\/(\d+)|(\d+)\/(\d+)|\d*\.\d+|\d+\.\d*|\d+/g; + +/** Leading zeros are spelling, not value: `TTB-0016` and `TTB-16` are one part. */ +function withoutLeadingZeros(digits: string): string { + return digits.replace(/^0+(?=\d)/, ""); +} + +/** One 2-dp decimal, the single form every number is stored and queried as. */ +function toDecimal(value: number): string { + return String(Math.round(value * 100) / 100); +} /** * Rewrites numbers and fractions to one 2-dp decimal, at index and query time @@ -44,10 +57,23 @@ function canonicalizeNumbers(text: string): string { return text.replace( NUMERIC_PATTERN, (match, mixedWhole, mixedNum, mixedDen, fracNum, fracDen) => { + // Left as written, so a long one cannot round-trip through a float. + if (/^\d+$/.test(match)) { + return withoutLeadingZeros(match); + } + let value: number; if (mixedWhole !== undefined) { - value = - Number(mixedWhole) + Number(mixedNum) / Number(mixedDen); + const fraction = Number(mixedNum) / Number(mixedDen); + // A leading zero marks a part number segment rather than a + // quantity, so `TTB-0016-5/32` is part 16 in 5/32", not 16 and + // 5/32. Each half still canonicalizes on its own. + if (mixedWhole.startsWith("0")) { + return Number.isFinite(fraction) + ? `${withoutLeadingZeros(mixedWhole)}-${toDecimal(fraction)}` + : match; + } + value = Number(mixedWhole) + fraction; } else if (fracNum !== undefined) { value = Number(fracNum) / Number(fracDen); } else { @@ -56,7 +82,7 @@ function canonicalizeNumbers(text: string): string { if (!Number.isFinite(value)) { return match; } - return String(Math.round(value * 100) / 100); + return toDecimal(value); } ); } @@ -118,22 +144,37 @@ export const SEARCH_OPTIONS: Options = { }; /** Joins the distinct non-null values with spaces (a searchable field's form). */ -function uniqueJoin(values: (string | null)[]): string { +function uniqueJoin(values: (string | undefined)[]): string { return Array.from( new Set(values.filter((value): value is string => !!value)) ).join(" "); } +/** + * A part number repeating the name identifies nothing — it is what a generic + * part is given for want of a real one — so it is neither shown nor searched. + */ +function withoutRepeatedPartNumber( + record: ConfigurationRecord +): ConfigurationRecord { + const repeated = + record.partNumber?.trim().toLowerCase() === + record.name?.trim().toLowerCase(); + return repeated ? { ...record, partNumber: undefined } : record; +} + /** * Keeps the first of each distinct (part number, name) in enumeration order and * drops records with neither. First-wins is what keeps the latest revision. */ export function toSearchRecords( - records: ConfigurationRecord[] + records: ConfigurationRecord[], + vendors: Vendor[] = [] ): SearchRecord[] { const seen = new Set(); const searchRecords: SearchRecord[] = []; - for (const record of records) { + for (const raw of records) { + const record = withoutRepeatedPartNumber(raw); if (!record.partNumber && !record.name) { continue; } @@ -145,6 +186,7 @@ export function toSearchRecords( searchRecords.push({ partNumber: record.partNumber, name: record.name, + url: getPartUrl(record, vendors), configuration: record.configuration }); } @@ -163,7 +205,10 @@ export function buildSearchDb( .filter((element) => !!element) .map((element) => { const parentGroup = libraryData.groups[element.groupId]; - const records = toSearchRecords(recordsMap[element.id] ?? []); + const records = toSearchRecords( + recordsMap[element.id] ?? [], + element.vendors + ); return { id: element.id, groupId: element.groupId, diff --git a/src/backend/features/settings/routes.test.ts b/src/backend/features/settings/routes.test.ts new file mode 100644 index 000000000..4589bb980 --- /dev/null +++ b/src/backend/features/settings/routes.test.ts @@ -0,0 +1,39 @@ +import { eq } from "drizzle-orm"; +import { env } from "cloudflare:workers"; +import { beforeEach, describe, expect, it } from "vitest"; +import { users } from "../../db/schema"; + +import { Theme } from "./settings"; +import { + TEST_USER_ID, + createTestApp, + jsonRequest, + resetDb +} from "../../../__test_utils__"; +import { getDb } from "../../db/client"; + +const db = getDb(env.DB); + +describe("settings routes", () => { + beforeEach(async () => { + await resetDb(db); + }); + + it("POST /settings updates the caller's settings", async () => { + const app = createTestApp(); + + const res = await app.request( + "/api/settings", + jsonRequest("POST", { theme: Theme.DARK }), + env + ); + expect(res.status).toBe(200); + + const row = await db + .select() + .from(users) + .where(eq(users.id, TEST_USER_ID)) + .get(); + expect(row?.theme).toBe(Theme.DARK); + }); +}); diff --git a/src/backend/features/settings/routes.ts b/src/backend/features/settings/routes.ts new file mode 100644 index 000000000..c003eab4f --- /dev/null +++ b/src/backend/features/settings/routes.ts @@ -0,0 +1,37 @@ +import { eq } from "drizzle-orm"; +import { getApp } from "../../lib/context"; +import { getDb } from "../../db/client"; +import { users } from "../../db/schema"; +import { z } from "zod"; +import { validate } from "../../lib/validate"; +import { requireSignInMiddleware } from "../auth/guards"; +import { LibraryId } from "../library/library-id"; +import { Theme } from "./settings"; + +export const settingsRoutes = getApp(); + +const settingsBody = z.object({ + theme: z.enum(Theme).optional(), + libraryId: z.enum(LibraryId).optional() +}); + +/** POST /api/settings — update the caller's stored settings */ +settingsRoutes.post( + "/settings", + requireSignInMiddleware, + validate("json", settingsBody), + async (c) => { + const userId = await c.var.getUserId(); + const body = c.req.valid("json"); + + const db = getDb(c.env.DB); + + await db.insert(users).values({ id: userId }).onConflictDoNothing(); + + if (Object.keys(body).length > 0) { + await db.update(users).set(body).where(eq(users.id, userId)); + } + + return c.json({ success: true }); + } +); diff --git a/src/backend/features/settings/settings.ts b/src/backend/features/settings/settings.ts new file mode 100644 index 000000000..c30a65ab3 --- /dev/null +++ b/src/backend/features/settings/settings.ts @@ -0,0 +1,21 @@ +import { LibraryId } from "../library/library-id"; + +export enum Theme { + SYSTEM = "system", + LIGHT = "light", + DARK = "dark" +} + +/** User settings, which the entry redirect reads and seeds the app with. */ +export interface Settings { + theme: Theme; + /** The library the caller last opened, and lands in next time. */ + libraryId: LibraryId; +} + +export type SettingsUpdate = Partial; + +export const DEFAULT_SETTINGS: Settings = { + theme: Theme.SYSTEM, + libraryId: LibraryId.FRC_DESIGN_LIB +}; diff --git a/src/shared/thumbnails.ts b/src/backend/features/thumbnails/keys.ts similarity index 94% rename from src/shared/thumbnails.ts rename to src/backend/features/thumbnails/keys.ts index 97c22b11c..6222351f1 100644 --- a/src/shared/thumbnails.ts +++ b/src/backend/features/thumbnails/keys.ts @@ -2,7 +2,7 @@ import { DEFAULT_CANONICAL_CONFIGURATION, DEFAULT_CONFIGURATION_KEY -} from "./canonical-configuration"; +} from "../configurations/canonical"; import { ThumbnailSize } from "./types"; /** Short on purpose: the real render can land at any moment and must take over. */ @@ -53,5 +53,5 @@ export function thumbnailUrl({ query.set("i", insertableId); } } - return `/api/thumbnail/${size}/${elementId}?${query}`; + return `/api/thumbnail/${size}/${elementId}?${query.toString()}`; } diff --git a/src/backend/routes/thumbnails.test.ts b/src/backend/features/thumbnails/routes.test.ts similarity index 97% rename from src/backend/routes/thumbnails.test.ts rename to src/backend/features/thumbnails/routes.test.ts index a70287e4b..8d4c58cea 100644 --- a/src/backend/routes/thumbnails.test.ts +++ b/src/backend/features/thumbnails/routes.test.ts @@ -1,19 +1,19 @@ import { env } from "cloudflare:workers"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { createTestApp, jsonRequest } from "../../__test_utils__"; -import { ThumbnailSize } from "../../shared/types"; +import { createTestApp, jsonRequest } from "../../../__test_utils__"; +import { ThumbnailSize } from "./types"; import { THUMBNAIL_FALLBACK_CACHE_TTL, THUMBNAIL_FALLBACK_HEADER, thumbnailKey, thumbnailUrl -} from "../../shared/thumbnails"; +} from "./keys"; import { DEFAULT_CANONICAL_CONFIGURATION, canonicalConfigurationKey -} from "../../shared/canonical-configuration"; -import { uploadConfigurationThumbnails } from "./thumbnails"; -import type { OnshapeApi } from "../onshape-api/onshape-api"; +} from "../configurations/canonical"; +import { uploadConfigurationThumbnails } from "./store"; +import type { OnshapeApi } from "../../lib/onshape/client"; const SIZE = ThumbnailSize.LARGE; const MICROVERSION = "mv-1"; @@ -240,7 +240,6 @@ describe("warming a configuration's thumbnail", () => { expect.objectContaining({ params: { insertableId: INSERTABLE_ID, - microversionId: MICROVERSION, canonicalConfiguration: CANONICAL_CONFIGURATION, // The render runs later, so it needs a session to authenticate. sessionId: SESSION_ID diff --git a/src/backend/features/thumbnails/routes.ts b/src/backend/features/thumbnails/routes.ts new file mode 100644 index 000000000..727226368 --- /dev/null +++ b/src/backend/features/thumbnails/routes.ts @@ -0,0 +1,119 @@ +import { z } from "zod"; +import { validate } from "../../lib/validate"; +import { CachePolicy, cacheMiddleware, setCacheTtl } from "../../lib/cache"; +import { getApp } from "../../lib/context"; + +import { ThumbnailSize } from "./types"; +import { + THUMBNAIL_FALLBACK_CACHE_TTL, + THUMBNAIL_FALLBACK_HEADER, + thumbnailKey +} from "./keys"; +import { + DEFAULT_CANONICAL_CONFIGURATION, + DEFAULT_CONFIGURATION_KEY, + canonicalConfigurationKey +} from "../configurations/canonical"; + +import type { AppContext } from "../../lib/context"; +import type { ThumbnailWorkflowParams } from "./workflow"; +import { getSessionId } from "../auth/session"; + +export const thumbnailRoutes = getApp(); + +const storedThumbnailParams = z.object({ + size: z.enum(ThumbnailSize), + elementId: z.string().min(1) +}); + +/** Absent means the element default, which is what `""` encodes. */ +const canonicalConfigurationQuery = z + .string() + .default(DEFAULT_CANONICAL_CONFIGURATION); + +const storedThumbnailQuery = z.object({ + /** The microversion, part of the key — which is what makes a hit immutable. */ + v: z.string().min(1), + c: canonicalConfigurationQuery, + warm: z.stringbool().default(false), + /** The insertable to render from; only sent with `warm`. */ + i: z.string().optional() +}); + +/** + * GET /api/thumbnail/:size/:elementId?v=&c=&warm= — an unrendered configuration + * falls back to the element default, and `warm` kicks off the real render. + */ +thumbnailRoutes.get( + "/thumbnail/:size/:elementId", + cacheMiddleware(CachePolicy.PUBLIC_CACHE), + validate("param", storedThumbnailParams), + validate("query", storedThumbnailQuery), + async (c) => { + const { size, elementId } = c.req.valid("param"); + const { + v: microversionId, + c: canonicalConfiguration, + warm, + i: insertableId + } = c.req.valid("query"); + const configurationKey = canonicalConfigurationKey( + canonicalConfiguration + ); + + const object = await c.env.BLOB.get( + thumbnailKey(elementId, microversionId, size, configurationKey) + ); + if (object) { + return thumbnailResponse(object); + } + + if (configurationKey === DEFAULT_CONFIGURATION_KEY) { + return c.notFound(); + } + + if (warm && insertableId) { + await warmConfigurationThumbnail(c, { + insertableId, + canonicalConfiguration + }); + } + + // Stand in with the default configuration until the real render lands. + const fallback = await c.env.BLOB.get( + thumbnailKey(elementId, microversionId, size) + ); + if (!fallback) { + return c.notFound(); + } + // Unlike a hit, this url does not pin these bytes. + setCacheTtl(c, THUMBNAIL_FALLBACK_CACHE_TTL); + const response = thumbnailResponse(fallback); + response.headers.set(THUMBNAIL_FALLBACK_HEADER, "1"); + return response; + } +); + +function thumbnailResponse(object: R2ObjectBody): Response { + const headers = new Headers(); + object.writeHttpMetadata(headers); + return new Response(object.body, { headers }); +} + +/** + * Concurrent requests can each start a run. Rare, and the workflow skips a + * render that is already stored, which a reused id would rule out permanently. + */ +async function warmConfigurationThumbnail( + c: AppContext, + params: Omit +): Promise { + try { + // The render runs later, under this caller's Onshape tokens. + await c.env.THUMBNAIL_WORKFLOW.create({ + params: { ...params, sessionId: getSessionId(c) } + }); + } catch { + // Never fatal: the caller still has the default thumbnail to serve. + } +} diff --git a/src/backend/features/thumbnails/store.ts b/src/backend/features/thumbnails/store.ts new file mode 100644 index 000000000..3afc53a63 --- /dev/null +++ b/src/backend/features/thumbnails/store.ts @@ -0,0 +1,198 @@ +/** + * Renders thumbnails through Onshape and stores them in R2. Kept out of the + * routes so the load workflows can reach it without importing the app. + */ + +import { CachePolicy, immutableCacheControl } from "../../lib/cache"; + +import { + getElementThumbnail, + getThumbnailFromId, + getThumbnailId +} from "../../lib/onshape/endpoints/thumbnails"; +import { + getDocument, + getContents +} from "../../lib/onshape/endpoints/documents"; +import { type ElementPath, type InstancePath } from "../../lib/onshape/path"; + +import { ThumbnailSize, ThumbnailUrls } from "./types"; +import { thumbnailKey, thumbnailUrl } from "./keys"; +import { + DEFAULT_CANONICAL_CONFIGURATION, + canonicalConfigurationKey +} from "../configurations/canonical"; +import { OnshapeApi } from "../../lib/onshape/client"; + +/** + * What produced a stored thumbnail, tagged onto the R2 object. The key already + * addresses it; this is for reading an object back and telling what it is. + */ +export interface ThumbnailMetadata extends Record { + microversionId: string; + /** Empty for an element's own thumbnail, as everywhere else. */ + canonicalConfiguration: string; +} + +/** Stores one rendered thumbnail, tagging it with what produced it. */ +async function putThumbnail( + bucket: R2Bucket, + key: string, + thumbnail: ArrayBuffer, + metadata: ThumbnailMetadata +): Promise { + await bucket.put(key, thumbnail, { + httpMetadata: { + contentType: "image/gif", + cacheControl: immutableCacheControl(CachePolicy.PUBLIC_CACHE) + }, + customMetadata: metadata + }); +} + +/** Throws until Onshape has rendered them, which drives the load step's retries. */ +export async function uploadThumbnails( + bucket: R2Bucket, + onshapeApi: OnshapeApi, + elementPath: ElementPath, + microversionId: string +): Promise { + const [small, large] = await Promise.all([ + getElementThumbnail(onshapeApi, elementPath, ThumbnailSize.SMALL), + getElementThumbnail(onshapeApi, elementPath, ThumbnailSize.LARGE) + ]); + if (!small || !large) { + throw new Error("Failed to find thumbnails. Try again later."); + } + + const { elementId } = elementPath; + await Promise.all([ + putThumbnail( + bucket, + thumbnailKey(elementId, microversionId, ThumbnailSize.SMALL), + small, + { + microversionId, + canonicalConfiguration: DEFAULT_CANONICAL_CONFIGURATION + } + ), + putThumbnail( + bucket, + thumbnailKey(elementId, microversionId, ThumbnailSize.LARGE), + large, + { + microversionId, + canonicalConfiguration: DEFAULT_CANONICAL_CONFIGURATION + } + ) + ]); + + return { + small: thumbnailUrl({ + elementId, + microversionId, + size: ThumbnailSize.SMALL, + canonicalConfiguration: DEFAULT_CANONICAL_CONFIGURATION + }), + large: thumbnailUrl({ + elementId, + microversionId, + size: ThumbnailSize.LARGE, + canonicalConfiguration: DEFAULT_CANONICAL_CONFIGURATION + }) + }; +} + +/** Whether every key is already stored, so the render can be skipped. */ +async function allStored(bucket: R2Bucket, keys: string[]): Promise { + const heads = await Promise.all(keys.map((key) => bucket.head(key))); + return heads.every((head) => head !== null); +} + +/** + * Both sizes, so a row and its hover never disagree. The two-stage id flow is the + * only Onshape path taking a configuration; either call can fail mid-render. + */ +export async function uploadConfigurationThumbnails( + bucket: R2Bucket, + onshapeApi: OnshapeApi, + elementPath: ElementPath, + microversionId: string, + canonicalConfiguration: string +): Promise { + const configurationKey = canonicalConfigurationKey(canonicalConfiguration); + const { elementId } = elementPath; + const targets = [ThumbnailSize.SMALL, ThumbnailSize.LARGE].map((size) => ({ + size, + key: thumbnailKey(elementId, microversionId, size, configurationKey) + })); + const keys = targets.map((target) => target.key); + + // Runs are no longer deduplicated by id, and Onshape is the expensive part. + if (await allStored(bucket, keys)) { + return; + } + + const thumbnailId = await getThumbnailId( + onshapeApi, + elementPath, + canonicalConfiguration + ); + const rendered = await Promise.all( + targets.map(async ({ size, key }) => ({ + key, + thumbnail: await getThumbnailFromId(onshapeApi, thumbnailId, size) + })) + ); + + // The render above takes minutes, long enough to have been beaten to it. + if (await allStored(bucket, keys)) { + return; + } + + await Promise.all( + rendered.map(({ key, thumbnail }) => + putThumbnail(bucket, key, thumbnail, { + microversionId, + canonicalConfiguration + }) + ) + ); +} + +/** Falls back to the first element when the document designates no thumbnail. */ +export async function uploadDocumentThumbnails( + bucket: R2Bucket, + onshapeApi: OnshapeApi, + versionPath: InstancePath +): Promise { + const [onshapeDocument, contents] = await Promise.all([ + getDocument(onshapeApi, versionPath), + getContents(onshapeApi, versionPath) + ]); + + let thumbnailElementId = onshapeDocument.documentThumbnailElementId; + if (!thumbnailElementId) { + if (contents.elements.length < 1) + throw new Error( + `Document ${onshapeDocument.name} has no elements to use as a thumbnail.` + ); + thumbnailElementId = contents.elements[0].id; + } + + const element = contents.elements.find((e) => e.id === thumbnailElementId); + if (!element) { + throw new Error("Unexpectedly failed to find the thumbnail element."); + } + + const thumbnailPath: ElementPath = { + ...versionPath, + elementId: thumbnailElementId + }; + return uploadThumbnails( + bucket, + onshapeApi, + thumbnailPath, + element.microversionId + ); +} diff --git a/src/backend/features/thumbnails/types.ts b/src/backend/features/thumbnails/types.ts new file mode 100644 index 000000000..b6d180185 --- /dev/null +++ b/src/backend/features/thumbnails/types.ts @@ -0,0 +1,14 @@ +/** + * The two thumbnail sizes we generate and store, as the `WxH` Onshape wants. + * SMALL fills list rows; LARGE fills the hover card and the insert preview. + */ +export enum ThumbnailSize { + SMALL = "70x40", + LARGE = "300x300" +} + +/** An element's two stored thumbnail URLs, produced (and stored) as a pair. */ +export interface ThumbnailUrls { + small: string; + large: string; +} diff --git a/src/backend/features/thumbnails/workflow.ts b/src/backend/features/thumbnails/workflow.ts new file mode 100644 index 000000000..5cc322d45 --- /dev/null +++ b/src/backend/features/thumbnails/workflow.ts @@ -0,0 +1,79 @@ +import { + WorkflowEntrypoint, + type WorkflowEvent, + type WorkflowStep +} from "cloudflare:workers"; +import { eq } from "drizzle-orm"; +import type { AppBindings } from "../../lib/context"; +import { getDb } from "../../db/client"; +import { insertables } from "../../db/schema"; +import { createLimiter, getOnshapeApiFromContext } from "../load/context"; +import { THUMBNAIL_STEP_RETRIES } from "../load/steps"; +import { uploadConfigurationThumbnails } from "./store"; + +/** The render to run, plus the session whose Onshape tokens it runs under. */ +export interface ThumbnailWorkflowParams { + insertableId: string; + /** Never the default, which loads eagerly with the element. */ + canonicalConfiguration: string; + sessionId: string; +} + +/** + * Outside a request, since Onshape can take minutes. Until it finishes, + * requests fall back to the element's default thumbnail. + */ +export class ThumbnailWorkflow extends WorkflowEntrypoint< + AppBindings, + ThumbnailWorkflowParams +> { + async run( + event: WorkflowEvent, + step: WorkflowStep + ): Promise { + const { insertableId, canonicalConfiguration, sessionId } = + event.payload; + + // Read rather than passed in: the stored row is what the key has to + // agree with, and a request can carry a microversion it has moved past. + const element = await step.do("resolve-element", async () => { + const row = await getDb(this.env.DB) + .select({ + documentId: insertables.documentId, + versionId: insertables.versionId, + elementId: insertables.elementId, + microversionId: insertables.microversionId + }) + .from(insertables) + .where(eq(insertables.id, insertableId)) + .get(); + if (!row) { + throw new Error(`No insertable ${insertableId}`); + } + return row; + }); + + await step.do( + "render-thumbnails", + { retries: THUMBNAIL_STEP_RETRIES }, + async () => + uploadConfigurationThumbnails( + this.env.BLOB, + await getOnshapeApiFromContext({ + env: this.env, + sessionId, + step, + limit: createLimiter(1) + }), + { + documentId: element.documentId, + instanceId: element.versionId, + instanceType: "v" as const, + elementId: element.elementId + }, + element.microversionId, + canonicalConfiguration + ) + ); + } +} diff --git a/src/backend/index.ts b/src/backend/index.ts index 3f69df78f..4fc3f2b61 100644 --- a/src/backend/index.ts +++ b/src/backend/index.ts @@ -1,9 +1,9 @@ export { AddGroupWorkflow, - LoadLibraryWorkflow, - ThumbnailWorkflow -} from "./load/workflows"; -import { createApp } from "./create-app"; -import { productionServices } from "./services"; + LoadLibraryWorkflow +} from "./features/load/workflows"; +export { ThumbnailWorkflow } from "./features/thumbnails/workflow"; +import { createApp } from "./app"; +import { productionCaller } from "./features/auth/caller"; -export default createApp(productionServices); +export default createApp(productionCaller); diff --git a/src/backend/lib/api-error.ts b/src/backend/lib/api-error.ts new file mode 100644 index 000000000..e70b24e53 --- /dev/null +++ b/src/backend/lib/api-error.ts @@ -0,0 +1,56 @@ +/** + * The one shape every failed /api response takes; `kind` tells the client what + * to do and carries what that kind needs. A leaf module the frontend imports. + */ + +export enum ApiErrorKind { + /** `message` is written for the user; show it. */ + HANDLED = "handled", + /** Onshape is rate limiting us, and said how long to wait. */ + RATE_LIMITED = "rate-limited", + /** `message` is for the logs. The client shows its own wording instead. */ + INTERNAL = "internal" +} + +interface ApiErrorOf { + kind: K; + message: string; +} + +export type ApiErrorBody = + | ApiErrorOf + | ApiErrorOf + | (ApiErrorOf & { retryAfterSeconds: number }); + +/** Thrown by a route; the app's error handler turns it into the response. */ +export class ApiError extends Error { + constructor( + readonly body: ApiErrorBody, + readonly status: number + ) { + super(body.message); + this.name = "ApiError"; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +/** The wording reaches the user, so write it for them. */ +export function handledError(message: string, status: number): ApiError { + return new ApiError({ kind: ApiErrorKind.HANDLED, message }, status); +} + +/** The client will show its own wording; this text is only for the logs. */ +export function internalError(message: string, status: number): ApiError { + return new ApiError({ kind: ApiErrorKind.INTERNAL, message }, status); +} + +export function rateLimitedError( + message: string, + status: number, + retryAfterSeconds: number +): ApiError { + return new ApiError( + { kind: ApiErrorKind.RATE_LIMITED, message, retryAfterSeconds }, + status + ); +} diff --git a/src/backend/lib/cache.ts b/src/backend/lib/cache.ts new file mode 100644 index 000000000..bc7c025e9 --- /dev/null +++ b/src/backend/lib/cache.ts @@ -0,0 +1,65 @@ +import { type MiddlewareHandler } from "hono"; +import { internalError } from "./api-error"; +import { HttpStatus } from "http-status-ts"; +import { type AppContext, type AppContextEnv } from "./context"; + +/** A year — a versioned url's content never changes, only its version does. */ +const IMMUTABLE_CACHE_TTL = 365 * 24 * 3600; + +const NO_STORE = "private, no-store"; + +export enum CachePolicy { + /** Never stored, anywhere. */ + NO_CACHE = "no-cache", + /** Immutable, but kept out of shared caches. */ + PRIVATE_CACHE = "private", + /** Immutable and the same for every caller. */ + PUBLIC_CACHE = "public" +} + +export function immutableCacheControl( + policy: CachePolicy.PRIVATE_CACHE | CachePolicy.PUBLIC_CACHE +): string { + return `${policy}, max-age=${IMMUTABLE_CACHE_TTL}, immutable`; +} + +/** Overrides the route's immutable default for a body its url does not pin. */ +export function setCacheTtl(c: AppContext, maxAge: number): void { + c.set("cacheTtl", maxAge); +} + +/** Declares how a route's response may be cached, and enforces what that takes. */ +export function cacheMiddleware( + policy: CachePolicy = CachePolicy.NO_CACHE +): MiddlewareHandler { + if (policy === CachePolicy.NO_CACHE) { + return async (c, next) => { + await next(); + c.header("Cache-Control", NO_STORE); + }; + } + + const cacheControl = immutableCacheControl(policy); + + return async (c, next) => { + // An immutable response has to be pinned by something, or the next + // version of it is unreachable behind the cache. + if (!c.req.query("v")) { + throw internalError( + "Missing cache version", + HttpStatus.BAD_REQUEST + ); + } + await next(); + // A miss must stay retryable, so only store what succeeded. + if (!c.res.ok) { + c.header("Cache-Control", NO_STORE); + return; + } + const ttl = c.get("cacheTtl"); + c.header( + "Cache-Control", + ttl === undefined ? cacheControl : `${policy}, max-age=${ttl}` + ); + }; +} diff --git a/src/backend/lib/context.ts b/src/backend/lib/context.ts new file mode 100644 index 000000000..025817d67 --- /dev/null +++ b/src/backend/lib/context.ts @@ -0,0 +1,76 @@ +import { type Context, type MiddlewareHandler, Hono } from "hono"; +import type { + AddGroupParams, + LoadLibraryParams +} from "../features/load/workflows"; +import type { ThumbnailWorkflowParams } from "../features/thumbnails/workflow"; +import { type AccessLevel } from "../features/auth/access-level"; +import { type OAuthApi } from "./onshape/client"; + +export interface AppBindings { + DB: D1Database; + KV: KVNamespace; + ASSETS: Fetcher; + /** Thumbnails and search indexes; prefixes keep them apart. */ + BLOB: R2Bucket; + LOAD_LIBRARY_WORKFLOW: Workflow; + ADD_GROUP_WORKFLOW: Workflow; + /** Renders a configuration's thumbnails outside a request; see ThumbnailWorkflow. */ + THUMBNAIL_WORKFLOW: Workflow; + ADMIN_TEAM: string; + ACCESS_LEVEL_OVERRIDE?: string; + /** Testing-only: treat requests as signed in with a fake user. Not for production. */ + FORCE_SIGNED_IN?: string; +} + +interface AppVariables { + /** Internal cache for `getOnshapeApi` in features/auth/caller.ts. */ + onshapeApi?: OAuthApi; + /** Internal cache for `isSignedIn` in features/auth/caller.ts. */ + signedIn?: boolean; + /** Set by `setCacheTtl`; read by `cacheMiddleware`. */ + cacheTtl?: number; + /** Injected by {@link bindCaller}; see {@link Caller}. */ + getOnshapeApi: () => Promise; + getUserId: () => Promise; + getAccessLevel: () => Promise; + isAuthenticated: () => Promise; +} + +export interface AppContextEnv { + Bindings: AppBindings; + Variables: AppVariables; +} + +export type AppContext = Context; + +/** + * Who is making the request, injected per request so tests can substitute a + * caller without an Onshape session. `productionCaller` is the real one. + */ +export interface Caller { + getOnshapeApi: () => Promise; + getUserId: () => Promise; + getAccessLevel: () => Promise; + isAuthenticated: () => Promise; +} + +export type CallerFactory = (c: AppContext) => Caller; + +/** Binds the caller's lookups onto each request, behind `c.var`. */ +export function bindCaller( + makeCaller: CallerFactory +): MiddlewareHandler { + return async (c, next) => { + const caller = makeCaller(c); + c.set("getOnshapeApi", caller.getOnshapeApi); + c.set("getUserId", caller.getUserId); + c.set("getAccessLevel", caller.getAccessLevel); + c.set("isAuthenticated", caller.isAuthenticated); + await next(); + }; +} + +export function getApp() { + return new Hono(); +} diff --git a/src/backend/lib/errors.test.ts b/src/backend/lib/errors.test.ts new file mode 100644 index 000000000..7565f0b01 --- /dev/null +++ b/src/backend/lib/errors.test.ts @@ -0,0 +1,64 @@ +import { env } from "cloudflare:workers"; +import { beforeEach, describe, expect, it } from "vitest"; +import { AccessLevel } from "../features/auth/access-level"; +import { LibraryId } from "../features/library/library-id"; +import { ApiErrorKind } from "./api-error"; +import { createTestApp, jsonRequest, resetDb } from "../../__test_utils__"; +import { getDb } from "../db/client"; + +const db = getDb(env.DB); + +describe("api error responses", () => { + beforeEach(async () => { + await resetDb(db); + }); + + // Written for the user, so the client shows it verbatim. + it("marks a gate's refusal as handled", async () => { + const app = createTestApp({ signedIn: false }); + + const res = await app.request( + `/api/reload-groups/library/${LibraryId.FRC_DESIGN_LIB}`, + jsonRequest("POST"), + env + ); + + expect(res.status).toBe(401); + expect(await res.json()).toMatchObject({ + kind: ApiErrorKind.HANDLED, + message: expect.stringContaining("signed in") + }); + }); + + // A malformed request is our bug, so the client falls back to its own + // wording rather than showing a validator's message. + it("marks a rejected request as internal", async () => { + const app = createTestApp({ accessLevel: AccessLevel.ADMIN }); + + const res = await app.request( + `/api/group-order/library/${LibraryId.FRC_DESIGN_LIB}`, + jsonRequest("POST", { groupOrder: "not-an-array" }), + env + ); + + expect(res.status).toBe(400); + expect(await res.json()).toMatchObject({ + kind: ApiErrorKind.INTERNAL + }); + }); + + it("marks an unknown library as internal rather than explaining it", async () => { + const app = createTestApp(); + + const res = await app.request( + "/api/library-data/library/not-a-library?v=1", + jsonRequest("GET"), + env + ); + + expect(res.status).toBe(400); + expect(await res.json()).toMatchObject({ + kind: ApiErrorKind.INTERNAL + }); + }); +}); diff --git a/src/backend/lib/errors.ts b/src/backend/lib/errors.ts new file mode 100644 index 000000000..cb1f6c2c6 --- /dev/null +++ b/src/backend/lib/errors.ts @@ -0,0 +1,66 @@ +import type { ErrorHandler } from "hono"; +import { HTTPException } from "hono/http-exception"; +import { HttpStatus } from "http-status-ts"; +import { OnshapeApiError, OnshapeRateLimitError } from "./onshape/client"; +import { + ApiError, + ApiErrorKind, + handledError, + internalError, + rateLimitedError +} from "./api-error"; +import type { AppContextEnv } from "./context"; + +/** + * What we are willing to say about an Onshape failure. Anything not named here + * is ours to explain, not Onshape's, so it stays generic. + */ +function fromOnshapeError(error: OnshapeApiError): ApiError { + if (error instanceof OnshapeRateLimitError) { + return rateLimitedError( + "Onshape rate limit reached. Please try again shortly.", + HttpStatus.TOO_MANY_REQUESTS, + error.retryAfterSeconds + ); + } + if ( + error.status === HttpStatus.UNAUTHORIZED || + error.status === HttpStatus.FORBIDDEN + ) { + return handledError( + "Onshape refused the request. Try signing in again.", + error.status + ); + } + return internalError( + `Onshape request failed: ${error.message}`, + HttpStatus.BAD_GATEWAY + ); +} + +export const errorHandler: ErrorHandler = (err, c) => { + if (err instanceof ApiError) { + return c.json(err.body, err.status as never); + } + if (err instanceof OnshapeApiError) { + const apiError = fromOnshapeError(err); + if (apiError.body.kind === ApiErrorKind.INTERNAL) { + console.error(err); + } + return c.json(apiError.body, apiError.status as never); + } + // A raw HTTPException is a validator rejecting a malformed request, which + // is our bug rather than something the user can act on. + if (err instanceof HTTPException) { + console.error(err); + return c.json( + { kind: ApiErrorKind.INTERNAL, message: err.message }, + err.status as never + ); + } + console.error(err); + return c.json( + { kind: ApiErrorKind.INTERNAL, message: "Internal Server Error" }, + HttpStatus.INTERNAL_SERVER_ERROR as never + ); +}; diff --git a/src/backend/onshape-api/api-path.ts b/src/backend/lib/onshape/api-path.ts similarity index 81% rename from src/backend/onshape-api/api-path.ts rename to src/backend/lib/onshape/api-path.ts index 0ae144f97..f2ce936ab 100644 --- a/src/backend/onshape-api/api-path.ts +++ b/src/backend/lib/onshape/api-path.ts @@ -1,13 +1,10 @@ -import { DocumentPath } from "../../shared/onshape-path"; +import { DocumentPath } from "./path"; export interface ApiPathOptions { endRoute?: string; endId?: string; featureId?: string; - /** - * When true and the path is a DocumentPath, emits `/{documentId}` instead of `/d/{documentId}`. - * Used for document-level Onshape endpoints that don't use the `/d/` prefix. - */ + /** For the document-level endpoints that take a bare id, without `/d/`. */ skipDocumentD?: boolean; } diff --git a/src/backend/onshape-api/assertions.ts b/src/backend/lib/onshape/assertions.ts similarity index 87% rename from src/backend/onshape-api/assertions.ts rename to src/backend/lib/onshape/assertions.ts index 2ffeecaf5..2dc55933d 100644 --- a/src/backend/onshape-api/assertions.ts +++ b/src/backend/lib/onshape/assertions.ts @@ -1,4 +1,4 @@ -import { InstancePath, InstanceType } from "../../shared/onshape-path"; +import { InstancePath, InstanceType } from "./path"; export function assertInstanceType( path: InstancePath, diff --git a/src/backend/onshape-api/onshape-api.test.ts b/src/backend/lib/onshape/client.test.ts similarity index 94% rename from src/backend/onshape-api/onshape-api.test.ts rename to src/backend/lib/onshape/client.test.ts index e6b8648c8..d90e01f05 100644 --- a/src/backend/onshape-api/onshape-api.test.ts +++ b/src/backend/lib/onshape/client.test.ts @@ -1,9 +1,5 @@ import { describe, expect, it } from "vitest"; -import { - OnshapeApi, - OnshapeApiError, - OnshapeRateLimitError -} from "./onshape-api"; +import { OnshapeApi, OnshapeApiError, OnshapeRateLimitError } from "./client"; /** Minimal concrete client whose `_request` returns a canned response. */ class TestApi extends OnshapeApi { diff --git a/src/backend/onshape-api/onshape-api.ts b/src/backend/lib/onshape/client.ts similarity index 99% rename from src/backend/onshape-api/onshape-api.ts rename to src/backend/lib/onshape/client.ts index 5780d2594..61bee19e5 100644 --- a/src/backend/onshape-api/onshape-api.ts +++ b/src/backend/lib/onshape/client.ts @@ -3,7 +3,7 @@ import { createSearchParams, type QueryOptions, type PostOptions -} from "../../shared/url-params"; +} from "../query-params"; // Constant across all environments (dev/cert/production), so hardcoded here // rather than duplicated as a per-environment var in wrangler.jsonc. diff --git a/src/backend/lib/onshape/element-type.ts b/src/backend/lib/onshape/element-type.ts new file mode 100644 index 000000000..f1771e544 --- /dev/null +++ b/src/backend/lib/onshape/element-type.ts @@ -0,0 +1,7 @@ +/** + * The type of the Onshape tab the app is open in. + */ +export enum ElementType { + PART_STUDIO = "PARTSTUDIO", + ASSEMBLY = "ASSEMBLY" +} diff --git a/src/backend/onshape-api/endpoints/assemblies.ts b/src/backend/lib/onshape/endpoints/assemblies.ts similarity index 97% rename from src/backend/onshape-api/endpoints/assemblies.ts rename to src/backend/lib/onshape/endpoints/assemblies.ts index 9ba87f574..c65f739ec 100644 --- a/src/backend/onshape-api/endpoints/assemblies.ts +++ b/src/backend/lib/onshape/endpoints/assemblies.ts @@ -1,4 +1,4 @@ -import { OnshapeApi } from "../onshape-api"; +import { OnshapeApi } from "../client"; import { assertWorkspace } from "../assertions"; import { ElementPath, @@ -6,7 +6,7 @@ import { toElementApiObject, toElementApiPath, toInstanceApiPath -} from "../../../shared/onshape-path"; +} from "../path"; import { apiPath } from "../api-path"; import { encodeConfiguration } from "./configurations"; import { OnshapeElementType, PartType } from "./documents"; @@ -15,7 +15,7 @@ import { OnshapeAssemblyDefinition, OnshapeCreatedFeature, OnshapeInsertInstancesResponse -} from "../onshape-types"; +} from "../types"; /** Retrieves information about an assembly. */ export function getAssembly( diff --git a/src/backend/onshape-api/endpoints/configurations.ts b/src/backend/lib/onshape/endpoints/configurations.ts similarity index 92% rename from src/backend/onshape-api/endpoints/configurations.ts rename to src/backend/lib/onshape/endpoints/configurations.ts index c888a0499..1035da911 100644 --- a/src/backend/onshape-api/endpoints/configurations.ts +++ b/src/backend/lib/onshape/endpoints/configurations.ts @@ -1,11 +1,11 @@ -import { OnshapeApi } from "../onshape-api"; -import { ElementPath, toElementApiPath } from "../../../shared/onshape-path"; +import { OnshapeApi } from "../client"; +import { ElementPath, toElementApiPath } from "../path"; import { apiPath } from "../api-path"; import { OnshapeConfigurationInfo, OnshapeConfigurationParameter, OnshapeConfigurationResponse -} from "../onshape-types"; +} from "../types"; export function getConfiguration( client: OnshapeApi, diff --git a/src/backend/onshape-api/endpoints/documents.ts b/src/backend/lib/onshape/endpoints/documents.ts similarity index 96% rename from src/backend/onshape-api/endpoints/documents.ts rename to src/backend/lib/onshape/endpoints/documents.ts index d33d5ab22..0d150bcd3 100644 --- a/src/backend/onshape-api/endpoints/documents.ts +++ b/src/backend/lib/onshape/endpoints/documents.ts @@ -1,4 +1,4 @@ -import { OnshapeApi } from "../onshape-api"; +import { OnshapeApi } from "../client"; import { assertInstanceType, assertWorkspace } from "../assertions"; import { DocumentPath, @@ -9,15 +9,15 @@ import { toElementApiPath, toInstanceApiPath, toInstanceTypeKey -} from "../../../shared/onshape-path"; +} from "../path"; import { apiPath } from "../api-path"; -import { OAuthApi } from "../onshape-api"; +import { OAuthApi } from "../client"; import { getLatestVersion } from "./versions"; import { OnshapeDocumentContents, OnshapeDocumentInfo, OnshapeElementType -} from "../onshape-types"; +} from "../types"; // `OnshapeElementType` is owned by the hand-authored types module; re-export it here so // existing `./documents` importers keep working. @@ -177,11 +177,7 @@ export function getWorkspaceMicroversionId( .then((r: any) => r.microversion); } -/** - * An undocumented OAuth-only endpoint which returns all external references in a document. - * - * Generally speaking, this returns a list of the external workspaces referenced by each tab in the instance. - */ +/** Undocumented and OAuth-only: the external workspaces each tab references. */ export function getExternalReferences( client: OAuthApi, instancePath: InstancePath diff --git a/src/backend/onshape-api/endpoints/feature-studios.ts b/src/backend/lib/onshape/endpoints/feature-studios.ts similarity index 96% rename from src/backend/onshape-api/endpoints/feature-studios.ts rename to src/backend/lib/onshape/endpoints/feature-studios.ts index dcb28914d..1e4c4880b 100644 --- a/src/backend/onshape-api/endpoints/feature-studios.ts +++ b/src/backend/lib/onshape/endpoints/feature-studios.ts @@ -1,11 +1,11 @@ -import { OnshapeApi } from "../onshape-api"; +import { OnshapeApi } from "../client"; import { assertInstanceType, assertWorkspace } from "../assertions"; import { ElementPath, InstancePath, toElementApiPath, toInstanceApiPath -} from "../../../shared/onshape-path"; +} from "../path"; import { apiPath } from "../api-path"; /** diff --git a/src/backend/onshape-api/endpoints/metadata.ts b/src/backend/lib/onshape/endpoints/metadata.ts similarity index 66% rename from src/backend/onshape-api/endpoints/metadata.ts rename to src/backend/lib/onshape/endpoints/metadata.ts index 8d366be93..ac7987084 100644 --- a/src/backend/onshape-api/endpoints/metadata.ts +++ b/src/backend/lib/onshape/endpoints/metadata.ts @@ -1,9 +1,9 @@ -import { OnshapeApi } from "../onshape-api"; -import { ElementPath, toElementApiPath } from "../../../shared/onshape-path"; +import { OnshapeApi } from "../client"; +import { ElementPath, toElementApiPath } from "../path"; import { apiPath } from "../api-path"; -import { encodeConfigurationForQuery } from "../../../shared/configuration-utils"; -import { ParameterValues } from "../../../shared/configuration-models"; -import type { OnshapeMetadataObject } from "../onshape-types"; +import { encodeConfigurationForQuery } from "../../../features/configurations/utils"; +import { ParameterValues } from "../../../features/configurations/models"; +import type { OnshapeMetadataObject } from "../types"; /** Returns an element's metadata properties for a given configuration. */ export function getElementMetadata( diff --git a/src/backend/onshape-api/endpoints/part-studios.ts b/src/backend/lib/onshape/endpoints/part-studios.ts similarity index 91% rename from src/backend/onshape-api/endpoints/part-studios.ts rename to src/backend/lib/onshape/endpoints/part-studios.ts index 227e370f0..54ce47ca5 100644 --- a/src/backend/onshape-api/endpoints/part-studios.ts +++ b/src/backend/lib/onshape/endpoints/part-studios.ts @@ -1,16 +1,13 @@ -import { OnshapeApi } from "../onshape-api"; +import { OnshapeApi } from "../client"; import { assertInstanceType, assertWorkspace } from "../assertions"; import { ElementPath, InstancePath, toElementApiPath, toInstanceApiPath -} from "../../../shared/onshape-path"; +} from "../path"; import { apiPath } from "../api-path"; -import { - OnshapeCreatedFeature, - OnshapeFeatureListResponse -} from "../onshape-types"; +import { OnshapeCreatedFeature, OnshapeFeatureListResponse } from "../types"; export function createPartStudio( client: OnshapeApi, diff --git a/src/backend/onshape-api/endpoints/parts.ts b/src/backend/lib/onshape/endpoints/parts.ts similarity index 80% rename from src/backend/onshape-api/endpoints/parts.ts rename to src/backend/lib/onshape/endpoints/parts.ts index d3e596020..ac6dda7c6 100644 --- a/src/backend/onshape-api/endpoints/parts.ts +++ b/src/backend/lib/onshape/endpoints/parts.ts @@ -1,9 +1,9 @@ -import { OnshapeApi } from "../onshape-api"; -import { ElementPath, toElementApiPath } from "../../../shared/onshape-path"; +import { OnshapeApi } from "../client"; +import { ElementPath, toElementApiPath } from "../path"; import { apiPath } from "../api-path"; -import { encodeConfigurationForQuery } from "../../../shared/configuration-utils"; -import { ParameterValues } from "../../../shared/configuration-models"; -import type { OnshapeAssemblyDefinition, OnshapePart } from "../onshape-types"; +import { encodeConfigurationForQuery } from "../../../features/configurations/utils"; +import { ParameterValues } from "../../../features/configurations/models"; +import type { OnshapeAssemblyDefinition, OnshapePart } from "../types"; /** * Builds the `configuration` query for an element request. The value is the diff --git a/src/backend/onshape-api/endpoints/permissions.ts b/src/backend/lib/onshape/endpoints/permissions.ts similarity index 78% rename from src/backend/onshape-api/endpoints/permissions.ts rename to src/backend/lib/onshape/endpoints/permissions.ts index 95adf70f7..9e55fa871 100644 --- a/src/backend/onshape-api/endpoints/permissions.ts +++ b/src/backend/lib/onshape/endpoints/permissions.ts @@ -1,5 +1,5 @@ -import { OnshapeApi, OnshapeApiError } from "../onshape-api"; -import { DocumentPath, toDocumentApiPath } from "../../../shared/onshape-path"; +import { OnshapeApi, OnshapeApiError } from "../client"; +import { DocumentPath, toDocumentApiPath } from "../path"; import { HttpStatus } from "http-status-ts"; import { apiPath } from "../api-path"; @@ -15,11 +15,7 @@ export enum Permission { OWNER = "OWNER" } -/** - * Returns the permissions the authenticated user has on a document. - * - * Returns an empty array if the document is not shared with the user (Onshape returns 403 in that case). - */ +/** Empty when the document is not shared with the caller, which Onshape 403s. */ export async function getPermissions( client: OnshapeApi, documentPath: DocumentPath diff --git a/src/backend/onshape-api/endpoints/settings.ts b/src/backend/lib/onshape/endpoints/settings.ts similarity index 97% rename from src/backend/onshape-api/endpoints/settings.ts rename to src/backend/lib/onshape/endpoints/settings.ts index 1d02333cd..f87481e33 100644 --- a/src/backend/onshape-api/endpoints/settings.ts +++ b/src/backend/lib/onshape/endpoints/settings.ts @@ -1,4 +1,4 @@ -import { OAuthApi } from "../onshape-api"; +import { OAuthApi } from "../client"; /** Gets a setting with a specific key. Returns `null` if the key is not set. */ export async function getSetting( diff --git a/src/backend/onshape-api/endpoints/std-versions.ts b/src/backend/lib/onshape/endpoints/std-versions.ts similarity index 96% rename from src/backend/onshape-api/endpoints/std-versions.ts rename to src/backend/lib/onshape/endpoints/std-versions.ts index 86febb744..45c6dc006 100644 --- a/src/backend/onshape-api/endpoints/std-versions.ts +++ b/src/backend/lib/onshape/endpoints/std-versions.ts @@ -1,4 +1,4 @@ -import { OnshapeApi } from "../onshape-api"; +import { OnshapeApi } from "../client"; import { getLatestVersion, getVersions } from "./versions"; import { STD_PATH } from "../objects/constants"; diff --git a/src/backend/onshape-api/endpoints/thumbnails.ts b/src/backend/lib/onshape/endpoints/thumbnails.ts similarity index 83% rename from src/backend/onshape-api/endpoints/thumbnails.ts rename to src/backend/lib/onshape/endpoints/thumbnails.ts index 48c21b2a6..00e31ed9c 100644 --- a/src/backend/onshape-api/endpoints/thumbnails.ts +++ b/src/backend/lib/onshape/endpoints/thumbnails.ts @@ -1,13 +1,13 @@ -import { OnshapeApi } from "../onshape-api"; +import { OnshapeApi } from "../client"; import { assertInstanceType, assertWorkspace } from "../assertions"; import { ElementPath, InstancePath, toElementApiPath, toInstanceApiPath -} from "../../../shared/onshape-path"; +} from "../path"; import { apiPath } from "../api-path"; -import { ThumbnailSize } from "../../../shared/types"; +import { ThumbnailSize } from "../../../features/thumbnails/types"; /** Returns the thumbnail of a given document instance. */ export function getInstanceThumbnail( @@ -33,11 +33,7 @@ export function getElementThumbnail( return client.getImage(path); } -/** - * Returns the thumbnail of a given element in a workspace, optionally with a specific configuration. - * - * Compared to `getElementThumbnail`, this endpoint supports configurations but is limited to workspaces only. - */ +/** Unlike `getElementThumbnail` this takes a configuration, but only in a workspace. */ export function getThumbnailFromWorkspace( client: OnshapeApi, elementPath: ElementPath, @@ -85,11 +81,7 @@ export async function getThumbnailId( return thumbnailId; } -/** - * Returns the thumbnail for a given thumbnail ID. - * - * WARNING: This endpoint is very buggy and can fail repeatedly while Onshape generates the thumbnail in the background. - */ +/** Fails repeatedly while Onshape renders the thumbnail in the background. */ export function getThumbnailFromId( client: OnshapeApi, thumbnailId: string, diff --git a/src/backend/onshape-api/endpoints/users.ts b/src/backend/lib/onshape/endpoints/users.ts similarity index 89% rename from src/backend/onshape-api/endpoints/users.ts rename to src/backend/lib/onshape/endpoints/users.ts index 990d64c4d..92728c03e 100644 --- a/src/backend/onshape-api/endpoints/users.ts +++ b/src/backend/lib/onshape/endpoints/users.ts @@ -1,7 +1,7 @@ -import { OnshapeApi } from "../onshape-api"; -import { OAuthApi } from "../onshape-api"; +import { OnshapeApi } from "../client"; +import { OAuthApi } from "../client"; import { apiPath } from "../api-path"; -import { AccessLevel } from "../../../shared/types"; +import { AccessLevel } from "../../../features/auth/access-level"; export interface SessionInfo { id: string; diff --git a/src/backend/onshape-api/endpoints/versions.ts b/src/backend/lib/onshape/endpoints/versions.ts similarity index 93% rename from src/backend/onshape-api/endpoints/versions.ts rename to src/backend/lib/onshape/endpoints/versions.ts index 7933e8cb7..d35ef1b1c 100644 --- a/src/backend/onshape-api/endpoints/versions.ts +++ b/src/backend/lib/onshape/endpoints/versions.ts @@ -1,13 +1,13 @@ -import { OnshapeApi } from "../onshape-api"; +import { OnshapeApi } from "../client"; import { assertVersion } from "../assertions"; import { DocumentPath, InstancePath, toDocumentApiPath, toInstanceApiObject -} from "../../../shared/onshape-path"; +} from "../path"; import { apiPath } from "../api-path"; -import { OnshapeVersionInfo } from "../onshape-types"; +import { OnshapeVersionInfo } from "../types"; /** * Fetches a list of versions of a document. diff --git a/src/backend/onshape-api/objects/assembly-features.ts b/src/backend/lib/onshape/objects/assembly-features.ts similarity index 100% rename from src/backend/onshape-api/objects/assembly-features.ts rename to src/backend/lib/onshape/objects/assembly-features.ts diff --git a/src/backend/onshape-api/objects/constants.ts b/src/backend/lib/onshape/objects/constants.ts similarity index 89% rename from src/backend/onshape-api/objects/constants.ts rename to src/backend/lib/onshape/objects/constants.ts index 3f76696de..dafda2324 100644 --- a/src/backend/onshape-api/objects/constants.ts +++ b/src/backend/lib/onshape/objects/constants.ts @@ -1,4 +1,4 @@ -import { InstancePath } from "../../../shared/onshape-path"; +import { InstancePath } from "../path"; /** The path to the Onshape standard library. */ export const STD_PATH: InstancePath = { diff --git a/src/backend/onshape-api/objects/derive-feature.ts b/src/backend/lib/onshape/objects/derive-feature.ts similarity index 97% rename from src/backend/onshape-api/objects/derive-feature.ts rename to src/backend/lib/onshape/objects/derive-feature.ts index c98a1899f..1646ac37b 100644 --- a/src/backend/onshape-api/objects/derive-feature.ts +++ b/src/backend/lib/onshape/objects/derive-feature.ts @@ -2,8 +2,8 @@ import { ParameterType, type ParameterValues, type ConfigurationParameter -} from "../../../shared/configuration-models"; -import { type ElementPath } from "../../../shared/onshape-path"; +} from "../../../features/configurations/models"; +import { type ElementPath } from "../path"; const PART_STUDIO_QUERY = "query=qUnion(qAllModifiableSolidBodies(), qAllModifiableSolidBodies()->qOwnedByBody(EntityType.BODY)->qBodyType(BodyType.MATE_CONNECTOR), qAllModifiableSolidBodies()->qCompositePartsContaining());"; diff --git a/src/backend/onshape-api/objects/parse-query.ts b/src/backend/lib/onshape/objects/parse-query.ts similarity index 100% rename from src/backend/onshape-api/objects/parse-query.ts rename to src/backend/lib/onshape/objects/parse-query.ts diff --git a/src/shared/onshape-path.ts b/src/backend/lib/onshape/path.ts similarity index 97% rename from src/shared/onshape-path.ts rename to src/backend/lib/onshape/path.ts index fad4b1816..ff2ee8518 100644 --- a/src/shared/onshape-path.ts +++ b/src/backend/lib/onshape/path.ts @@ -1,4 +1,4 @@ -import { ParameterValues } from "./configuration-models"; +import { ParameterValues } from "../../features/configurations/models"; /** The instance kinds an Onshape path can address, as one definition: the type * and the runtime list validators check against both derive from it. */ diff --git a/src/backend/onshape-api/onshape-types.ts b/src/backend/lib/onshape/types.ts similarity index 99% rename from src/backend/onshape-api/onshape-types.ts rename to src/backend/lib/onshape/types.ts index 4ea581106..ba628cd9d 100644 --- a/src/backend/onshape-api/onshape-types.ts +++ b/src/backend/lib/onshape/types.ts @@ -6,7 +6,7 @@ import { LogicalOp, QuantityType, Unit -} from "../../shared/configuration-enums"; +} from "../../features/configurations/enums"; // === configuration (GET .../configuration, GET .../configurationencodings/{cid}) === diff --git a/src/shared/url-params.ts b/src/backend/lib/query-params.ts similarity index 100% rename from src/shared/url-params.ts rename to src/backend/lib/query-params.ts diff --git a/src/backend/lib/route-params.ts b/src/backend/lib/route-params.ts new file mode 100644 index 000000000..3e2ab1ed5 --- /dev/null +++ b/src/backend/lib/route-params.ts @@ -0,0 +1,49 @@ +/** Route patterns and their param readers, so mounts and lookups stay in sync. */ +import { internalError } from "./api-error"; +import { HttpStatus } from "http-status-ts"; +import z from "zod"; +import { LibraryId } from "../features/library/library-id"; +import { type AppContext } from "./context"; + +export function libraryRoute(): string { + return "/library/:libraryId"; +} + +export function getLibraryParam(c: AppContext): LibraryId { + const libraryId = c.req.param("libraryId"); + const parsed = z.enum(LibraryId).safeParse(libraryId); + if (!parsed.success) { + throw internalError("Invalid libraryId", HttpStatus.BAD_REQUEST); + } + return parsed.data; +} + +export function insertableRoute(): string { + return "/insertable/:insertableId"; +} + +export function getInsertableParam(c: AppContext): string { + const id = c.req.param("insertableId"); + if (!id) throw new Error("Missing insertableId route param"); + return id; +} + +export function favoriteRoute(): string { + return "/favorite/:favoriteId"; +} + +export function getFavoriteParam(c: AppContext): string { + const id = c.req.param("favoriteId"); + if (!id) throw new Error("Missing favoriteId route param"); + return id; +} + +export function groupRoute(): string { + return "/group/:groupId"; +} + +export function getGroupParam(c: AppContext): string { + const id = c.req.param("groupId"); + if (!id) throw new Error("Missing groupId route param"); + return id; +} diff --git a/src/backend/lib/validate.ts b/src/backend/lib/validate.ts new file mode 100644 index 000000000..2ef8f34b8 --- /dev/null +++ b/src/backend/lib/validate.ts @@ -0,0 +1,23 @@ +import { zValidator } from "@hono/zod-validator"; +import type { ValidationTargets } from "hono"; +import { HttpStatus } from "http-status-ts"; +import type { ZodType } from "zod"; +import { internalError } from "./api-error"; + +/** + * `zValidator` with our error shape; its own body is the one that would not + * match. A malformed request is our bug, so the detail is for the logs. + */ +export function validate< + T extends ZodType, + Target extends keyof ValidationTargets +>(target: Target, schema: T) { + return zValidator(target, schema, (result) => { + if (!result.success) { + throw internalError( + `Invalid ${target}: ${result.error.message}`, + HttpStatus.BAD_REQUEST + ); + } + }); +} diff --git a/src/backend/parse/parse-vendors.ts b/src/backend/parse/parse-vendors.ts deleted file mode 100644 index 6e4a285db..000000000 --- a/src/backend/parse/parse-vendors.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { Vendor, getVendorName } from "../../shared/types"; -import { - ParameterType, - type ConfigurationParameter -} from "../../shared/configuration-models"; - -export function parseNameVendor(name: string): Vendor | undefined { - const words = name.toUpperCase().match(/\b(\w+)\b/g) ?? []; - for (const word of words) { - const vendor = Object.values(Vendor).find( - (v) => (v as string).toUpperCase() === word - ); - if (vendor !== undefined) return vendor; - } - return undefined; -} - -export function parseVendors( - name: string, - parameters: ConfigurationParameter[] -): Vendor[] { - const nameVendor = parseNameVendor(name); - if (nameVendor) return [nameVendor]; - - const vendors = new Set(); - for (const param of parameters) { - if (param.type !== ParameterType.ENUM) continue; - for (const option of param.options) { - const vendor = parseNameVendor(option.name); - if (vendor) { - vendors.add(vendor); - continue; - } - const byFullName = Object.values(Vendor).find( - (v) => - getVendorName(v).toUpperCase() === option.name.toUpperCase() - ); - if (byFullName) vendors.add(byFullName); - } - } - return [...vendors]; -} diff --git a/src/backend/routes/configurations.ts b/src/backend/routes/configurations.ts deleted file mode 100644 index 971744603..000000000 --- a/src/backend/routes/configurations.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { eq } from "drizzle-orm"; -import { CachePolicy, cacheMiddleware, getApp } from "../app"; -import { getDb } from "../db"; -import { getUnitInfo } from "../onshape-api/endpoints/documents"; -import { configurations } from "../../shared/schema"; -import { - type ConfigurationResult, - type UnitInfo -} from "../../shared/configuration-models"; -import { toSearchRecords } from "../../shared/search"; -import { QuantityType, type Unit } from "../../shared/configuration-enums"; -import { isInstancePath } from "../../shared/onshape-path"; -import { HTTPException } from "hono/http-exception"; -import { HttpStatus } from "http-status-ts"; - -export const configurationRoutes = getApp(); - -/** GET /api/configuration/:configurationId?v=:microversionId */ -configurationRoutes.get( - "/configuration/:configurationId", - cacheMiddleware(CachePolicy.PUBLIC_CACHE), - async (c) => { - const configurationId = c.req.param("configurationId"); - if (!configurationId) { - throw new HTTPException(HttpStatus.BAD_REQUEST, { - message: "configurationId is required" - }); - } - - const db = getDb(c.env.DB); - const config = await db - .select({ - parameters: configurations.parameters, - records: configurations.records - }) - .from(configurations) - .where(eq(configurations.id, configurationId)) - .get(); - - if (!config) { - throw new HTTPException(HttpStatus.NOT_FOUND, { - message: "Failed to find configuration" - }); - } - - const result: ConfigurationResult = { - parameters: config.parameters, - records: toSearchRecords(config.records) - }; - return c.json(result); - } -); - -/** GET /api/unit-info?documentId=X&instanceId=Y&instanceType=v */ -configurationRoutes.get("/unit-info", cacheMiddleware(), async (c) => { - const onshapeApi = await c.var.getOnshapeApi(); - const instancePath = { - documentId: c.req.query("documentId"), - instanceId: c.req.query("instanceId"), - instanceType: c.req.query("instanceType") - }; - if (!isInstancePath(instancePath)) { - throw new HTTPException(HttpStatus.BAD_REQUEST, { - message: "instancePath is required" - }); - } - - const rawUnitInfo = await getUnitInfo(onshapeApi, instancePath); - const units: OnshapeUnit[] = rawUnitInfo.defaultUnits.units; - - const angleUnit = getDefaultUnit(units, QuantityType.ANGLE); - const lengthUnit = getDefaultUnit(units, QuantityType.LENGTH); - - const result: UnitInfo = { - angleUnit, - lengthUnit, - anglePrecision: rawUnitInfo.unitsDisplayPrecision[angleUnit], - lengthPrecision: rawUnitInfo.unitsDisplayPrecision[lengthUnit], - realPrecision: 3 - }; - return c.json(result); -}); - -interface OnshapeUnit { - key: QuantityType; - value: Unit; -} - -function getDefaultUnit( - units: OnshapeUnit[], - quantityType: QuantityType -): Unit { - return units.find((u) => u.key === quantityType)!.value; -} diff --git a/src/backend/routes/thumbnails.ts b/src/backend/routes/thumbnails.ts deleted file mode 100644 index ae5d788ea..000000000 --- a/src/backend/routes/thumbnails.ts +++ /dev/null @@ -1,406 +0,0 @@ -import { eq } from "drizzle-orm"; -import { z } from "zod"; -import { zValidator } from "@hono/zod-validator"; -import { - CachePolicy, - cacheMiddleware, - getApp, - getInsertableParam, - immutableCacheControl, - insertableRoute, - setCacheTtl -} from "../app"; -import { getInsertableElementPath } from "./insertables"; -import { getDb } from "../db"; -import { requireEditorMiddleware } from "../access-level-utils"; -import { bumpLibraryVersion } from "../library-data"; -import { - getElementThumbnail, - getThumbnailFromId, - getThumbnailId -} from "../onshape-api/endpoints/thumbnails"; -import { getDocument, getContents } from "../onshape-api/endpoints/documents"; -import { type ElementPath, type InstancePath } from "../../shared/onshape-path"; -import { group, insertables } from "../../shared/schema"; -import { HTTPException } from "hono/http-exception"; -import { HttpStatus } from "http-status-ts"; -import { ThumbnailSize, ThumbnailUrls } from "../../shared/types"; -import { - THUMBNAIL_FALLBACK_CACHE_TTL, - THUMBNAIL_FALLBACK_HEADER, - thumbnailKey, - thumbnailUrl -} from "../../shared/thumbnails"; -import { - DEFAULT_CANONICAL_CONFIGURATION, - DEFAULT_CONFIGURATION_KEY, - canonicalConfigurationKey -} from "../../shared/canonical-configuration"; -import { OnshapeApi } from "../onshape-api/onshape-api"; -import type { AppContext } from "../app"; -import type { ThumbnailWorkflowParams } from "../load/workflows"; -import { getSessionId } from "../auth"; -import { BuildIssueType, clearBuildIssue } from "../../shared/build-issues"; - -/** Stores one rendered thumbnail, tagging it with what produced it. */ -async function putThumbnail( - bucket: R2Bucket, - key: string, - thumbnail: ArrayBuffer, - metadata: Record -): Promise { - await bucket.put(key, thumbnail, { - httpMetadata: { - contentType: "image/gif", - cacheControl: immutableCacheControl(CachePolicy.PUBLIC_CACHE) - }, - customMetadata: metadata - }); -} - -/** Throws until Onshape has rendered them, which drives the load step's retries. */ -export async function uploadThumbnails( - bucket: R2Bucket, - onshapeApi: OnshapeApi, - elementPath: ElementPath, - microversionId: string -): Promise { - const [small, large] = await Promise.all([ - getElementThumbnail(onshapeApi, elementPath, ThumbnailSize.SMALL), - getElementThumbnail(onshapeApi, elementPath, ThumbnailSize.LARGE) - ]); - if (!small || !large) { - throw new Error("Failed to find thumbnails. Try again later."); - } - - const { elementId } = elementPath; - await Promise.all([ - putThumbnail( - bucket, - thumbnailKey(elementId, microversionId, ThumbnailSize.SMALL), - small, - { microversionId } - ), - putThumbnail( - bucket, - thumbnailKey(elementId, microversionId, ThumbnailSize.LARGE), - large, - { microversionId } - ) - ]); - - return { - small: thumbnailUrl({ - elementId, - microversionId, - size: ThumbnailSize.SMALL, - canonicalConfiguration: DEFAULT_CANONICAL_CONFIGURATION - }), - large: thumbnailUrl({ - elementId, - microversionId, - size: ThumbnailSize.LARGE, - canonicalConfiguration: DEFAULT_CANONICAL_CONFIGURATION - }) - }; -} - -/** Whether every key is already stored, so the render can be skipped. */ -async function allStored(bucket: R2Bucket, keys: string[]): Promise { - const heads = await Promise.all(keys.map((key) => bucket.head(key))); - return heads.every((head) => head !== null); -} - -/** - * Both sizes, so a row and its hover never disagree. The two-stage id flow is the - * only Onshape path taking a configuration; either call can fail mid-render. - */ -export async function uploadConfigurationThumbnails( - bucket: R2Bucket, - onshapeApi: OnshapeApi, - elementPath: ElementPath, - microversionId: string, - canonicalConfiguration: string -): Promise { - const configurationKey = canonicalConfigurationKey(canonicalConfiguration); - const { elementId } = elementPath; - const targets = [ThumbnailSize.SMALL, ThumbnailSize.LARGE].map((size) => ({ - size, - key: thumbnailKey(elementId, microversionId, size, configurationKey) - })); - const keys = targets.map((target) => target.key); - - // Runs are no longer deduplicated by id, and Onshape is the expensive part. - if (await allStored(bucket, keys)) { - return; - } - - const thumbnailId = await getThumbnailId( - onshapeApi, - elementPath, - canonicalConfiguration - ); - const rendered = await Promise.all( - targets.map(async ({ size, key }) => ({ - key, - thumbnail: await getThumbnailFromId(onshapeApi, thumbnailId, size) - })) - ); - - // The render above takes minutes, long enough to have been beaten to it. - if (await allStored(bucket, keys)) { - return; - } - - await Promise.all( - rendered.map(({ key, thumbnail }) => - putThumbnail(bucket, key, thumbnail, { - microversionId, - canonicalConfiguration - }) - ) - ); -} - -/** Falls back to the first element when the document designates no thumbnail. */ -export async function uploadDocumentThumbnails( - bucket: R2Bucket, - onshapeApi: OnshapeApi, - versionPath: InstancePath -): Promise { - const [onshapeDocument, contents] = await Promise.all([ - getDocument(onshapeApi, versionPath), - getContents(onshapeApi, versionPath) - ]); - - let thumbnailElementId = onshapeDocument.documentThumbnailElementId; - if (!thumbnailElementId) { - if (contents.elements.length < 1) - throw new Error( - `Document ${onshapeDocument.name} has no elements to use as a thumbnail.` - ); - thumbnailElementId = contents.elements[0].id; - } - - const element = contents.elements.find((e) => e.id === thumbnailElementId); - if (!element) { - throw new Error("Unexpectedly failed to find the thumbnail element."); - } - - const thumbnailPath: ElementPath = { - ...versionPath, - elementId: thumbnailElementId - }; - return uploadThumbnails( - bucket, - onshapeApi, - thumbnailPath, - element.microversionId - ); -} - -export const thumbnailRoutes = getApp(); - -const storedThumbnailParams = z.object({ - size: z.enum(ThumbnailSize), - elementId: z.string().min(1) -}); - -/** Absent means the element default, which is what `""` encodes. */ -const canonicalConfigurationQuery = z - .string() - .default(DEFAULT_CANONICAL_CONFIGURATION); - -const storedThumbnailQuery = z.object({ - /** The microversion, part of the key — which is what makes a hit immutable. */ - v: z.string().min(1), - c: canonicalConfigurationQuery, - warm: z.stringbool().default(false), - /** The insertable to render from; only sent with `warm`. */ - i: z.string().optional() -}); - -/** - * GET /api/thumbnail/:size/:elementId?v=&c=&warm= — an unrendered configuration - * falls back to the element default, and `warm` kicks off the real render. - */ -thumbnailRoutes.get( - "/thumbnail/:size/:elementId", - cacheMiddleware(CachePolicy.PUBLIC_CACHE), - zValidator("param", storedThumbnailParams), - zValidator("query", storedThumbnailQuery), - async (c) => { - const { size, elementId } = c.req.valid("param"); - const { - v: microversionId, - c: canonicalConfiguration, - warm, - i: insertableId - } = c.req.valid("query"); - const configurationKey = canonicalConfigurationKey( - canonicalConfiguration - ); - - const object = await c.env.BLOB.get( - thumbnailKey(elementId, microversionId, size, configurationKey) - ); - if (object) { - return thumbnailResponse(object); - } - - if (configurationKey === DEFAULT_CONFIGURATION_KEY) { - return c.notFound(); - } - - if (warm && insertableId) { - await warmConfigurationThumbnail(c, { - insertableId, - microversionId, - canonicalConfiguration - }); - } - - // Stand in with the default configuration until the real render lands. - const fallback = await c.env.BLOB.get( - thumbnailKey(elementId, microversionId, size) - ); - if (!fallback) { - return c.notFound(); - } - // Unlike a hit, this url does not pin these bytes. - setCacheTtl(c, THUMBNAIL_FALLBACK_CACHE_TTL); - const response = thumbnailResponse(fallback); - response.headers.set(THUMBNAIL_FALLBACK_HEADER, "1"); - return response; - } -); - -function thumbnailResponse(object: R2ObjectBody): Response { - const headers = new Headers(); - object.writeHttpMetadata(headers); - return new Response(object.body, { headers }); -} - -/** - * Concurrent requests can each start a run. Rare, and the workflow skips a - * render that is already stored, which a reused id would rule out permanently. - */ -async function warmConfigurationThumbnail( - c: AppContext, - params: Omit -): Promise { - try { - // The render runs later, under this caller's Onshape tokens. - await c.env.THUMBNAIL_WORKFLOW.create({ - params: { ...params, sessionId: getSessionId(c) } - }); - } catch { - // Never fatal: the caller still has the default thumbnail to serve. - } -} - -/** POST /api/reload-insertable-thumbnail/insertable/:insertableId */ -thumbnailRoutes.post( - "/reload-insertable-thumbnail" + insertableRoute(), - requireEditorMiddleware, - async (c) => { - const onshapeApi = await c.var.getOnshapeApi(); - const insertableId = getInsertableParam(c); - const db = getDb(c.env.DB); - - const elementPath = await getInsertableElementPath(db, insertableId); - - const row = await db - .select({ - microversionId: insertables.microversionId, - libraryId: insertables.libraryId, - buildIssues: insertables.buildIssues - }) - .from(insertables) - .where(eq(insertables.id, insertableId)) - .get(); - - if (!row) { - throw new HTTPException(HttpStatus.NOT_FOUND, { - message: "Insertable not found" - }); - } - - const thumbnails = await uploadThumbnails( - c.env.BLOB, - onshapeApi, - elementPath, - row.microversionId - ); - - await db - .update(insertables) - .set({ - smallThumbnailUrl: thumbnails.small, - largeThumbnailUrl: thumbnails.large, - buildIssues: clearBuildIssue( - row.buildIssues, - BuildIssueType.THUMBNAIL_FAILED - ) - }) - .where(eq(insertables.id, insertableId)); - - await bumpLibraryVersion(db, row.libraryId); - return c.json({ success: true }); - } -); - -/** POST /api/reload-group-thumbnail/group/:groupId */ -thumbnailRoutes.post( - "/reload-group-thumbnail/group/:groupId", - requireEditorMiddleware, - async (c) => { - const onshapeApi = await c.var.getOnshapeApi(); - const groupId = c.req.param("groupId"); - const db = getDb(c.env.DB); - - const row = await db - .select({ - documentId: group.documentId, - versionId: group.versionId, - libraryId: group.libraryId, - buildIssues: group.buildIssues - }) - .from(group) - .where(eq(group.id, groupId)) - .get(); - - if (!row) { - throw new HTTPException(HttpStatus.NOT_FOUND, { - message: "Group not found" - }); - } - - const instancePath: InstancePath = { - documentId: row.documentId, - instanceId: row.versionId, - instanceType: "v" - }; - - const thumbnails = await uploadDocumentThumbnails( - c.env.BLOB, - onshapeApi, - instancePath - ); - - await db - .update(group) - .set({ - smallThumbnailUrl: thumbnails.small, - largeThumbnailUrl: thumbnails.large, - buildIssues: clearBuildIssue( - row.buildIssues, - BuildIssueType.THUMBNAIL_FAILED - ) - }) - .where(eq(group.id, groupId)); - - await bumpLibraryVersion(db, row.libraryId); - return c.json({ success: true }); - } -); diff --git a/src/backend/routes/user.test.ts b/src/backend/routes/user.test.ts deleted file mode 100644 index b4cc6b06d..000000000 --- a/src/backend/routes/user.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { eq } from "drizzle-orm"; -import { env } from "cloudflare:workers"; -import { beforeEach, describe, expect, it } from "vitest"; -import { users } from "../../shared/schema"; -import { AccessLevel, Theme } from "../../shared/types"; -import { - TEST_USER_ID, - createTestApp, - jsonRequest, - resetDb, - seedLibrary, - seedUser -} from "../../__test_utils__"; -import { getDb } from "../db"; - -const db = getDb(env.DB); - -describe("user routes", () => { - beforeEach(async () => { - await resetDb(db); - }); - - it("GET /access-data returns the caller's access level", async () => { - await seedLibrary(db); - await seedUser(db); - const app = createTestApp({ accessLevel: AccessLevel.ADMIN }); - - const res = await app.request( - "/api/access-data", - jsonRequest("GET"), - env - ); - expect(res.status).toBe(200); - - expect(await res.json()).toEqual({ - maxAccessLevel: AccessLevel.ADMIN, - signedIn: true - }); - // Per-user, and Workers Cache keys ignore cookies. - expect(res.headers.get("Cache-Control")).toBe("private, no-store"); - }); - - it("POST /user-data updates the user's settings", async () => { - const app = createTestApp(); - - const res = await app.request( - "/api/user-data", - jsonRequest("POST", { theme: Theme.DARK }), - env - ); - expect(res.status).toBe(200); - - const row = await db - .select() - .from(users) - .where(eq(users.id, TEST_USER_ID)) - .get(); - expect(row?.theme).toBe(Theme.DARK); - }); -}); diff --git a/src/backend/routes/user.ts b/src/backend/routes/user.ts deleted file mode 100644 index e8336619a..000000000 --- a/src/backend/routes/user.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { eq } from "drizzle-orm"; -import { cacheMiddleware, getApp } from "../app"; -import { getDb } from "../db"; -import { users } from "../../shared/schema"; -import { type AccessData, type SettingsUpdate } from "../../shared/types"; -import { isSignedIn, requireSignInMiddleware } from "../sign-in-utils"; - -export const userRoutes = getApp(); - -/** GET /api/access-data */ -userRoutes.get("/access-data", cacheMiddleware(), async (c) => { - return c.json({ - maxAccessLevel: await c.var.getAccessLevel(), - signedIn: await isSignedIn(c) - } satisfies AccessData); -}); - -/** POST /api/user-data — update settings */ -userRoutes.post("/user-data", requireSignInMiddleware, async (c) => { - const userId = await c.var.getUserId(); - - const body = await c.req.json(); - - const db = getDb(c.env.DB); - - await db.insert(users).values({ id: userId }).onConflictDoNothing(); - - if (Object.keys(body).length > 0) { - await db.update(users).set(body).where(eq(users.id, userId)); - } - - return c.json({ success: true }); -}); diff --git a/src/backend/services.ts b/src/backend/services.ts deleted file mode 100644 index c01090fd1..000000000 --- a/src/backend/services.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { type AppServicesFactory } from "./app"; -import { getCachedUserId, getOnshapeApi, isAuthenticated } from "./auth"; -import { getCachedAccessLevel } from "./access-level-utils"; -import { isForceSignedIn, isSignedIn } from "./sign-in-utils"; -import { AccessLevel } from "../shared/types"; - -/** Stable fake user id used for FORCE_SIGNED_IN testing sessions. */ -export const FORCE_SIGNED_IN_USER_ID = "force-signed-in-user"; - -/** - * Production dependency wiring, memoizing the Onshape lookups in KV by session. - * getUserId only runs behind requireSignInMiddleware; getAccessLevel falls back to USER. - */ -export const productionServices: AppServicesFactory = (c) => ({ - getOnshapeApi: () => getOnshapeApi(c), - getUserId: () => { - // FORCE_SIGNED_IN has no real Onshape session; use a stable fake id. - if (isForceSignedIn(c)) { - return Promise.resolve(FORCE_SIGNED_IN_USER_ID); - } - return getCachedUserId(c); - }, - getAccessLevel: async () => { - const override = c.env.ACCESS_LEVEL_OVERRIDE; - if (override) return override as AccessLevel; - // getCachedAccessLevel needs a real Onshape session, so only call it - // for a genuinely signed-in caller (not FORCE_SIGNED_IN). - if (!isForceSignedIn(c) && (await isSignedIn(c))) { - return getCachedAccessLevel(c); - } - return AccessLevel.USER; - }, - isAuthenticated: () => isAuthenticated(c) -}); diff --git a/src/backend/sign-in-utils.ts b/src/backend/sign-in-utils.ts deleted file mode 100644 index 61e394f0b..000000000 --- a/src/backend/sign-in-utils.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type { MiddlewareHandler } from "hono"; -import { HTTPException } from "hono/http-exception"; -import { HttpStatus } from "http-status-ts"; -import { env } from "process"; -import { type AppContext, type AppContextEnv } from "./app"; - -/** FORCE_SIGNED_IN is a dev-only escape hatch, ignored in production. */ -export function isForceSignedIn(c: AppContext): boolean { - return !!c.env.FORCE_SIGNED_IN && env.NODE_ENV !== "production"; -} - -/** - * Whether the caller has a valid Onshape session, memoized on the request. - * `FORCE_SIGNED_IN` forces it true for testing (see services.ts). - */ -export async function isSignedIn(c: AppContext): Promise { - const cached = c.get("signedIn"); - if (cached !== undefined) return cached; - - let signedIn: boolean; - if (isForceSignedIn(c)) { - signedIn = true; - } else { - try { - await c.var.getOnshapeApi(); - signedIn = true; - } catch { - signedIn = false; - } - } - - c.set("signedIn", signedIn); - return signedIn; -} - -/** - * Middleware which requires the caller to be signed in to Onshape. - */ -export const requireSignInMiddleware: MiddlewareHandler = async ( - c, - next -) => { - if (!(await isSignedIn(c))) { - throw new HTTPException(HttpStatus.UNAUTHORIZED, { - message: - "You must be signed in to Onshape to use this functionality" - }); - } - await next(); -}; diff --git a/src/frontend/api-utils/errors.ts b/src/frontend/api-utils/errors.ts deleted file mode 100644 index eda404896..000000000 --- a/src/frontend/api-utils/errors.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { showErrorToast, showInfoToast } from "../common/notifications"; - -/** - * Errors which are generated and thrown on the client. - * Unlike other errors, the message is displayed directly to the user. - */ -export class HandledError extends Error { - public isError: boolean; - - constructor(message: string, isError = true) { - super(message); - Object.setPrototypeOf(this, new.target.prototype); - this.isError = isError; - } -} - -export function getAppErrorHandler(defaultMessage: string, toastId?: string) { - return (error: Error) => handleAppError(error, defaultMessage, toastId); -} - -export function handleAppError( - error: Error, - defaultMessage: string, - toastKey?: string -) { - if (error instanceof HandledError) { - if (!error.isError) { - showInfoToast(error.message, toastKey); - } else { - showErrorToast(error.message, toastKey); - } - return; - } - showErrorToast(defaultMessage, toastKey); -} diff --git a/src/frontend/api-utils/library.ts b/src/frontend/api-utils/library.ts deleted file mode 100644 index 411385a4e..000000000 --- a/src/frontend/api-utils/library.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { useParams } from "@tanstack/react-router"; -import { DEFAULT_LIBRARY_ID, LibraryId } from "../../shared/types"; - -/** Returns the library being displayed, which the url is the source of truth for. */ -export function useLibraryId(): LibraryId { - // Callers can sit outside the library route — modals mount at the root and - // error components replace the match — so fall back instead of throwing. - const params = useParams({ - from: "/app/library/$libraryId", - shouldThrow: false - }); - return params?.libraryId ?? DEFAULT_LIBRARY_ID; -} - -export function toLibraryPath(libraryId: LibraryId): string { - return `/library/${libraryId}`; -} - -export function toInsertablePath(insertableId: string): string { - return `/insertable/${insertableId}`; -} - -export function toGroupPath(groupId: string): string { - return `/group/${groupId}`; -} - -export function getLibraryName(libraryId: string): string { - switch (libraryId) { - case LibraryId.FRC_DESIGN_LIB: - return "FRCDesignLib"; - case LibraryId.FTC_DESIGN_LIB: - return "FTCDesignLib (Beta)"; - case LibraryId.MKCAD: - return "MKCAD (Deprecated)"; - } - throw new Error("Unknown library: " + libraryId); -} diff --git a/src/frontend/app/app-navbar.tsx b/src/frontend/app/app-navbar.tsx deleted file mode 100644 index fd5928000..000000000 --- a/src/frontend/app/app-navbar.tsx +++ /dev/null @@ -1,220 +0,0 @@ -import { - ActionIcon, - Button, - Group, - Input, - Loader, - Menu, - TextInput, - Tooltip -} from "@mantine/core"; -import { IconChevronDown, IconSearch, IconSettings } from "@tabler/icons-react"; -import { HEADER_CONTROL_COLOR, IconSize } from "../common/style-constants"; -import { ReactNode, RefObject, useRef } from "react"; -import { useNavigate } from "@tanstack/react-router"; - -import frcDesignBook from "/frc-design-book.svg"; -import { openSettingsMenu } from "../settings/settings-menu"; -import { VendorMenu } from "../settings/vendor-filters"; -import { useUiState } from "../api-utils/ui-state"; -import { getLibraryName, useLibraryId } from "../api-utils/library"; -import { RequireAccessLevel } from "../api-utils/access-level"; -import { useSaveSettings } from "../settings/settings"; -import { useIsSignedIn } from "../api-utils/access-level"; -import { startSignIn } from "../api-utils/sign-in"; -import { useJobStatus } from "../api-utils/refresh"; -import { LibraryId } from "../../shared/types"; -import { queryClient } from "../query-client"; -import { getLibraryVersionQuery } from "../queries"; - -/** - * Provides top-level navigation for the app. A single colored control row holds - * the brand, library menu, search, vendor filter menu, and settings. - */ -export function AppNavbar(): ReactNode { - // Create a subgroup that won't wrap and flexes to take up the entire space - const leftGroup = ( - - - - - - - ); - - return ( - - {leftGroup} - - - - - - - ); -} - -/** - * Shown only when not signed in; starts the Onshape OAuth flow and returns to - * the current location, after which access-data reports the caller signed in. - */ -function SignInButton(): ReactNode { - const isSignedIn = useIsSignedIn(); - if (isSignedIn) return null; - - return ( - - ); -} - -/** Editor-only spinner shown while a library-load job is running. */ -function JobIndicator(): ReactNode { - return ( - - - - ); -} - -function RunningJobLoader(): ReactNode { - // Single editor-gated job-status consumer, so it owns refresh-on-finish. - const jobRunning = useJobStatus(); - if (!jobRunning) return null; - return ( - - - - ); -} - -function FrcDesignBookIcon(): ReactNode { - return ( - - FRCDesign.org - - ); -} - -function LibraryMenu(): ReactNode { - const currentLibraryId = useLibraryId(); - const saveSettings = useSaveSettings(); - const navigate = useNavigate(); - - // Warm the versions on open, so picking one has nothing left to wait for. - const prefetchVersions = () => { - for (const libraryId of Object.values(LibraryId)) { - void queryClient.prefetchQuery(getLibraryVersionQuery(libraryId)); - } - }; - - return ( - - - - - - {Object.values(LibraryId).map((libraryId) => ( - { - if (libraryId === currentLibraryId) { - return; - } - // Write-behind: the url displays it, this only - // decides where `/init` lands next time. - saveSettings({ libraryId }); - void navigate({ - to: "/app/library/$libraryId", - params: { libraryId } - }); - }} - > - {getLibraryName(libraryId)} - - ))} - - - ); -} - -export function SettingsButton() { - return ( - openSettingsMenu()} - > - - - ); -} - -function selectAllInputText(ref: RefObject) { - const input = ref.current; - if (!input) { - return; - } - const length = input.value.length; - input.setSelectionRange(0, length); -} - -export function SearchBar() { - const ref = useRef(null); - const [uiState, setUiState] = useUiState(); - - const clearButton = uiState.searchQuery ? ( - { - if (ref.current) { - ref.current.value = ""; - } - setUiState({ searchQuery: undefined }); - }} - /> - ) : undefined; - - return ( - } - placeholder="Search library..." - ref={ref} - value={uiState.searchQuery ?? ""} - onFocus={() => { - selectAllInputText(ref); - }} - onChange={(event) => { - const value = event.currentTarget.value; - const query = value === "" ? undefined : value; - setUiState({ searchQuery: query }); - }} - rightSection={clearButton} - /> - ); -} diff --git a/src/frontend/common/format-time.ts b/src/frontend/common/format-time.ts deleted file mode 100644 index 14b5eb95e..000000000 --- a/src/frontend/common/format-time.ts +++ /dev/null @@ -1,17 +0,0 @@ -/** Shared helpers for rendering timestamps and durations in the UI. */ - -/** - * A short, human relative time like "just now", "5m ago", "3h ago", "2d ago", - * falling back to a locale date for anything older than a week. - */ -export function formatRelativeTime(timestamp: number): string { - const seconds = Math.floor((Date.now() - timestamp) / 1000); - if (seconds < 60) return "just now"; - const minutes = Math.floor(seconds / 60); - if (minutes < 60) return `${minutes}m ago`; - const hours = Math.floor(minutes / 60); - if (hours < 24) return `${hours}h ago`; - const days = Math.floor(hours / 24); - if (days < 7) return `${days}d ago`; - return new Date(timestamp).toLocaleDateString(); -} diff --git a/src/frontend/common/style-constants.ts b/src/frontend/common/style-constants.ts deleted file mode 100644 index 8b8d35f59..000000000 --- a/src/frontend/common/style-constants.ts +++ /dev/null @@ -1,59 +0,0 @@ -/** - * Standard icon sizes to pass to Tabler icons. - */ -export enum IconSize { - /** Icons on badges */ - TINY = 12, - /** Menu options */ - SMALL = 16, - /** Buttons */ - MEDIUM = 18, - /** Input-height controls, which sit next to full-height buttons */ - CONTROL = 24, - /** In-line error states */ - LARGE = 36, - /** Full-page error states */ - HUGE = 48 -} - -/** - * Standard Mantine FontWeights. - */ -export enum FontWeight { - SEMI_BOLD = 500, - BOLD = 700 -} - -export const BORDER = "1px solid var(--mantine-color-default-border)"; - -/** The app's primary color as a filled background. */ -export enum PrimaryColor { - /** - * The current library color, e.g., green for FRCDesign. - */ - FILLED = "var(--mantine-primary-color-filled)", - /** - * The current library contrast color, typically white. - */ - CONTRAST = "var(--mantine-primary-color-contrast)" -} - -/** - * Hex, not a css var or a named color: Mantine parses `color` to derive border - * and hover tints, and anything else silently resolves to black. - */ -export const HEADER_CONTROL_COLOR = "#fff"; - -/** Red used for heart/favorite icons. */ -export const HeartIconColor = "var(--mantine-color-red-6)"; - -/** - * Icon color intents for use with Tabler icons. - * For native mantine components, just use yellow, red, blue, etc. directly. - */ -export enum IconColor { - YELLOW = "var(--mantine-color-yellow-6)", - BLUE = "var(--mantine-color-blue-6)", - RED = HeartIconColor, - GREEN = "var(--mantine-color-green-6)" -} diff --git a/src/frontend/common/utils.ts b/src/frontend/common/utils.ts deleted file mode 100644 index 85730d3c1..000000000 --- a/src/frontend/common/utils.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { useMatch } from "@tanstack/react-router"; -import { produce } from "immer"; -import { Dispatch, SyntheticEvent } from "react"; -import { queryClient } from "../query-client"; -import { QueryKey } from "@tanstack/react-query"; - -export { createSearchParams } from "../../shared/url-params"; -export type { - URLSearchParamsInit, - ParamKeyValuePair, - QueryOptions, - PostOptions -} from "../../shared/url-params"; - -/** - * Capitalizes the first letter of a string and lower cases everything else. - */ -export function capitalize(val: string) { - return val[0].toUpperCase() + val.slice(1).toLowerCase(); -} - -/** Event handler that exposes the target element's value as a boolean. */ -export function handleBooleanChange(handler: Dispatch) { - return (event: SyntheticEvent) => - handler((event.target as HTMLInputElement).checked); -} - -type Updater = (value: T | undefined) => T | undefined; - -/** - * A wrapper around Immer which can be used to update query data. - * Unlike normal updating, you can fully mutate the value without any issues. - */ -export function getQueryUpdater(recipe: (draft: T) => void): Updater { - return (value: T | undefined) => { - if (value === undefined) return undefined; - return produce(value, recipe); - }; -} - -/** - * A helper which can be used to make an optimistic update to a query with the given queryKey. - */ -export async function patchQuery( - queryKey: QueryKey, - recipe: (draft: T) => void -): Promise { - await queryClient.cancelQueries({ queryKey }); - const queryUpdater = getQueryUpdater(recipe); - queryClient.setQueryData(queryKey, queryUpdater); -} - -/** - * Returns true if the current route is the home route, and false if it is a document route. - */ -export function useIsHome(): boolean { - return ( - useMatch({ - from: "/app/library/$libraryId/", - shouldThrow: false - }) !== undefined - ); -} diff --git a/src/frontend/app/alerts.tsx b/src/frontend/components/alerts.tsx similarity index 66% rename from src/frontend/app/alerts.tsx rename to src/frontend/components/alerts.tsx index c1dfa72e8..6f46118d6 100644 --- a/src/frontend/app/alerts.tsx +++ b/src/frontend/components/alerts.tsx @@ -1,5 +1,8 @@ import { modals } from "@mantine/modals"; -import { Text } from "@mantine/core"; +import { Box, Text } from "@mantine/core"; +import { Warning } from "@phosphor-icons/react"; +import { AppTitle } from "./app-title"; +import { IconSize } from "../lib/style-constants"; interface OpenWarningAlertProps { title: string; @@ -8,8 +11,19 @@ interface OpenWarningAlertProps { function openWarningAlert(props: OpenWarningAlertProps): void { modals.openConfirmModal({ - title: props.title, - children: {props.text}, + title: ( + + } + title={props.title} + /> + ), + children: ( + + {props.text} + + ), labels: { confirm: "Close", cancel: null }, centered: true, cancelProps: { display: "none" }, diff --git a/src/frontend/app-common/app-menu.tsx b/src/frontend/components/app-menu.tsx similarity index 92% rename from src/frontend/app-common/app-menu.tsx rename to src/frontend/components/app-menu.tsx index e3795281d..1c7b69d5a 100644 --- a/src/frontend/app-common/app-menu.tsx +++ b/src/frontend/components/app-menu.tsx @@ -1,7 +1,7 @@ import { PropsWithChildren, ReactNode } from "react"; import { FloatingPosition, Menu, ActionIcon } from "@mantine/core"; -import { IconDots } from "@tabler/icons-react"; -import { IconSize } from "../common/style-constants"; +import { DotsThree } from "@phosphor-icons/react"; +import { IconSize } from "../lib/style-constants"; interface AppContextMenuProps { menuItems: ReactNode; @@ -82,7 +82,7 @@ export function MenuButton(props: MenuButtonProps): ReactNode { title="View options" onClick={(e) => e.stopPropagation()} > - + ); diff --git a/src/frontend/components/app-modal.tsx b/src/frontend/components/app-modal.tsx new file mode 100644 index 000000000..f19a9a379 --- /dev/null +++ b/src/frontend/components/app-modal.tsx @@ -0,0 +1,32 @@ +import { Group, type MantineSpacing, Stack } from "@mantine/core"; +import { PropsWithChildren, ReactNode } from "react"; +import { BORDER, CHROME_BACKGROUND } from "../lib/style-constants"; + +interface AppModalBodyProps extends PropsWithChildren { + /** Space between children; content that spaces itself should pass 0. */ + gap?: MantineSpacing; +} + +/** A modal's content, padded away from the chrome framing it. */ +export function AppModalBody(props: AppModalBodyProps): ReactNode { + return ( + + {props.children} + + ); +} + +/** A modal's actions. A lone child sits at the end; two split the row. */ +export function AppModalFooter(props: PropsWithChildren): ReactNode { + return ( + + {props.children} + + ); +} diff --git a/src/frontend/components/app-navbar.tsx b/src/frontend/components/app-navbar.tsx new file mode 100644 index 000000000..56dc80134 --- /dev/null +++ b/src/frontend/components/app-navbar.tsx @@ -0,0 +1,268 @@ +import { + ActionIcon, + Box, + Button, + Group, + Input, + Loader, + Stack, + Tabs, + TextInput, + Tooltip +} from "@mantine/core"; +import { Gear, MagnifyingGlass } from "@phosphor-icons/react"; +import { + BORDER, + CHROME_BACKGROUND, + IconSize, + PrimaryColor +} from "../lib/style-constants"; +import { ReactNode, RefObject, useRef } from "react"; +import { useNavigate } from "@tanstack/react-router"; + +import frcDesignBook from "/frc-design-book.svg"; +import { openSettingsMenu } from "../features/settings/open-settings-menu"; +import { VendorMenu } from "../features/settings/components/vendor-filters"; +import { useUiState } from "../lib/ui-state"; +import { getLibraryName, useLibraryId } from "../features/library/library-path"; +import { RequireAccessLevel } from "../features/auth/access-level"; +import { useSaveSettings } from "../features/settings/settings"; +import { useAccessData } from "../features/auth/access-level"; +import { startSignIn } from "../features/auth/sign-in"; +import { useJobStatus } from "../lib/refresh"; +import { LibraryId } from "@backend/features/library/library-id"; +import { queryClient } from "../lib/query-client"; +import { getLibraryVersionQuery } from "../features/library/queries"; + +/** + * Provides top-level navigation for the app: a row of library tabs with the + * brand and settings alongside, over a row holding search and its filters. + */ +export function AppNavbar(): ReactNode { + return ( + + {/* Stretched so the tabs run the full height and their underline + lands on the row's own border. */} + + + + + + + + + + + + + + + ); +} + +/** + * Shown only when not signed in; starts the Onshape OAuth flow and returns to + * the current location, after which access-data reports the caller signed in. + */ +function SignInButton(): ReactNode { + const { signedIn, isPending } = useAccessData(); + // Waiting rather than assuming signed out: the placeholder would flash the + // button on every load for a caller who is already signed in. + if (isPending || signedIn) return null; + + return ( + + ); +} + +/** Editor-only spinner shown while a library-load job is running. */ +function JobIndicator(): ReactNode { + return ( + + + + ); +} + +function RunningJobLoader(): ReactNode { + // Single editor-gated job-status consumer, so it owns refresh-on-finish. + const jobRunning = useJobStatus(); + if (!jobRunning) return null; + return ( + + + + ); +} + +function FrcDesignBookIcon(): ReactNode { + return ( + + {/* Masked, not drawn, so the book takes the tile's contrast color + rather than the gray in the file. The url needs quoting: Vite + inlines the asset as a data uri containing apostrophes. */} + + + ); +} + +/** Switches libraries; the url is what actually selects one. */ +function LibraryTabs(): ReactNode { + const currentLibraryId = useLibraryId(); + const saveSettings = useSaveSettings(); + const navigate = useNavigate(); + + // Warm the versions on hover, so picking one has nothing left to wait for. + const prefetchVersions = () => { + for (const libraryId of Object.values(LibraryId)) { + void queryClient.prefetchQuery(getLibraryVersionQuery(libraryId)); + } + }; + + return ( + { + if (!value || value === currentLibraryId) { + return; + } + const libraryId = value as LibraryId; + // Write-behind: the url displays it, this only decides where + // `/init` lands next time. + saveSettings({ libraryId }); + void navigate({ + to: "/app/library/$libraryId", + params: { libraryId } + }); + }} + styles={{ + // Hides the line under the tab list alone; the row owns one + // that spans it. The active indicator is colored separately. + root: { "--tab-border-color": "transparent" }, + // Three full names outgrow a narrow panel; scrolling beats + // reflowing the navbar into two rows. + list: { + flexWrap: "nowrap", + overflowX: "auto", + scrollbarWidth: "none" + }, + // Pulled onto that divider, so the active tab's indicator + // replaces it rather than stacking a line above it. + tab: { + marginBottom: -1, + paddingInline: "var(--mantine-spacing-sm)" + } + }} + > + + {Object.values(LibraryId).map((libraryId) => ( + + {getLibraryName(libraryId)} + + ))} + + + ); +} + +export function SettingsButton() { + return ( + openSettingsMenu()} + > + + + ); +} + +function selectAllInputText(ref: RefObject) { + const input = ref.current; + if (!input) { + return; + } + const length = input.value.length; + input.setSelectionRange(0, length); +} + +export function SearchBar() { + const ref = useRef(null); + const [uiState, setUiState] = useUiState(); + const libraryId = useLibraryId(); + + const clearButton = uiState.searchQuery ? ( + { + if (ref.current) { + ref.current.value = ""; + } + setUiState({ searchQuery: undefined }); + }} + /> + ) : undefined; + + return ( + } + placeholder={`Search ${getLibraryName(libraryId)}...`} + ref={ref} + value={uiState.searchQuery ?? ""} + onFocus={() => { + selectAllInputText(ref); + }} + onChange={(event) => { + const value = event.currentTarget.value; + const query = value === "" ? undefined : value; + setUiState({ searchQuery: query }); + }} + rightSection={clearButton} + /> + ); +} diff --git a/src/frontend/app-common/app-select.tsx b/src/frontend/components/app-select.tsx similarity index 93% rename from src/frontend/app-common/app-select.tsx rename to src/frontend/components/app-select.tsx index e338a7b8b..14e9d3981 100644 --- a/src/frontend/app-common/app-select.tsx +++ b/src/frontend/components/app-select.tsx @@ -1,6 +1,6 @@ import { Select } from "@mantine/core"; import { Dispatch, ReactNode } from "react"; -import { SelectOption } from "../settings/select-utils"; +import { SelectOption } from "./select-utils"; interface AppSelectProps { option: SelectOption; diff --git a/src/frontend/components/app-title.tsx b/src/frontend/components/app-title.tsx new file mode 100644 index 000000000..337c7a70f --- /dev/null +++ b/src/frontend/components/app-title.tsx @@ -0,0 +1,155 @@ +import { + ActionIcon, + Anchor, + Center, + CopyButton, + Group, + Stack, + Text, + Tooltip +} from "@mantine/core"; +import { ArrowSquareOut, Check, Copy } from "@phosphor-icons/react"; +import type { ReactNode } from "react"; +import type { SearchRecord } from "@backend/features/configurations/models"; +import { FontWeight, IconSize, TITLE_ICON_NUDGE } from "../lib/style-constants"; +import { displayPartNumber } from "../lib/part-number"; + +interface AppTitleProps { + title: ReactNode; + /** Leading icon, at `IconSize.MEDIUM` to match the title's size. */ + icon?: ReactNode; + /** A quieter second line, laid out as a row so it can hold controls. */ + subtitle?: ReactNode; + /** Trailing content on the title's own line, e.g. a status badge. */ + rightSection?: ReactNode; +} + +/** One weight and size for every icon-and-text heading in the app. */ +export function AppTitle(props: AppTitleProps): ReactNode { + const { title, icon, subtitle, rightSection } = props; + return ( + + {/* Centred, not wrapped in a block, where the icon would go back + to sitting on the text baseline several pixels low. */} + {icon &&
{icon}
} + + + + {title} + + {rightSection} + + {subtitle && ( + // lh, because the title's own is 1: inheriting that + // leaves no leading under the last line, and the block + // reads low against a header padded evenly. + + {subtitle} + + )} + +
+ ); +} + +interface MenuTitleProps { + name: string; + /** The configuration in view, which names the part the element resolves to. */ + record?: SearchRecord; + icon?: ReactNode; +} + +/** A menu's header: the element name is how the part was found, the part + * number is what identifies what gets inserted. */ +export function MenuTitle(props: MenuTitleProps): ReactNode { + const { name, record, icon } = props; + const partNumber = displayPartNumber(record?.partNumber, name); + return ( + + ) + } + /> + ); +} + +/** The xs line box the subtitle row is otherwise sized by, floored. */ +const COPY_BUTTON_SIZE = 16; + +/** The part number, linked to the vendor's page for it when there is one. */ +function PartNumber({ + partNumber, + url +}: { + partNumber: string; + url?: string; +}): ReactNode { + // Nowhere to send them, so offer the number itself to search with. + if (!url) { + return ( + <> + + {partNumber} + + + {({ copied, copy }) => ( + + + {copied ? ( + + ) : ( + + )} + + + )} + + + ); + } + return ( + // inline-flex so the icon centres on the text rather than sitting on + // its baseline, and takes the link's color by being inside it. + event.stopPropagation()} + style={{ + display: "inline-flex", + alignItems: "center", + gap: 2, + minWidth: 0 + }} + > + + {partNumber} + + + + ); +} diff --git a/src/frontend/app-common/app-zero-state.tsx b/src/frontend/components/app-zero-state.tsx similarity index 91% rename from src/frontend/app-common/app-zero-state.tsx rename to src/frontend/components/app-zero-state.tsx index 439393aff..10a7c4419 100644 --- a/src/frontend/app-common/app-zero-state.tsx +++ b/src/frontend/components/app-zero-state.tsx @@ -1,11 +1,9 @@ -import { Center, Loader, EmptyState } from "@mantine/core"; -import { IconX } from "@tabler/icons-react"; -import { HeartIconColor, IconSize } from "../common/style-constants"; +import { Box, Center, EmptyState, Loader } from "@mantine/core"; +import { X } from "@phosphor-icons/react"; +import { IconSize } from "../lib/style-constants"; import { type JSX, ReactNode } from "react"; -const DEFAULT_ERROR_ICON = ( - -); +const DEFAULT_ERROR_ICON = ; interface ZeroStateProps { icon?: ReactNode; diff --git a/src/frontend/common/change-order.tsx b/src/frontend/components/change-order.tsx similarity index 91% rename from src/frontend/common/change-order.tsx rename to src/frontend/components/change-order.tsx index c1bbc4080..4baea7320 100644 --- a/src/frontend/common/change-order.tsx +++ b/src/frontend/components/change-order.tsx @@ -1,11 +1,11 @@ import { Menu } from "@mantine/core"; import { - IconChevronDown, - IconChevronsDown, - IconChevronsUp, - IconChevronUp -} from "@tabler/icons-react"; -import { IconSize } from "./style-constants"; + CaretDoubleDown, + CaretDoubleUp, + CaretDown, + CaretUp +} from "@phosphor-icons/react"; +import { IconSize } from "../lib/style-constants"; import { type ReactNode } from "react"; interface ChangeOrderMenuProps { @@ -32,7 +32,7 @@ export function ChangeOrderItems(props: ChangeOrderMenuProps): ReactNode { <> {operations.includes(MoveOperation.MOVE_UP) && ( } + leftSection={} onClick={() => { onOrderChange( applyMoveOperation(id, order, MoveOperation.MOVE_UP) @@ -44,7 +44,7 @@ export function ChangeOrderItems(props: ChangeOrderMenuProps): ReactNode { )} {operations.includes(MoveOperation.MOVE_DOWN) && ( } + leftSection={} onClick={() => { onOrderChange( applyMoveOperation( @@ -60,7 +60,7 @@ export function ChangeOrderItems(props: ChangeOrderMenuProps): ReactNode { )} {operations.includes(MoveOperation.MOVE_TO_TOP) && ( } + leftSection={} onClick={() => { onOrderChange( applyMoveOperation( @@ -76,7 +76,7 @@ export function ChangeOrderItems(props: ChangeOrderMenuProps): ReactNode { )} {operations.includes(MoveOperation.MOVE_TO_BOTTOM) && ( } + leftSection={} onClick={() => { onOrderChange( applyMoveOperation( diff --git a/src/frontend/components/open-app-modal.tsx b/src/frontend/components/open-app-modal.tsx new file mode 100644 index 000000000..316aa0a3d --- /dev/null +++ b/src/frontend/components/open-app-modal.tsx @@ -0,0 +1,49 @@ +import { modals } from "@mantine/modals"; +import type { ReactNode } from "react"; +import { BORDER, CHROME_BACKGROUND } from "../lib/style-constants"; + +interface OpenAppModalProps { + title: ReactNode; + children: ReactNode; + /** Pass one minted by the caller to update the modal while it is open. */ + modalId?: string; + size?: string | number; + onClose?: () => void; +} + +/** + * Opens a modal wearing the app's chrome. Its body is unpadded, so content + * belongs in an `AppModalBody` and actions in an `AppModalFooter`. + */ +export function openAppModal(props: OpenAppModalProps): void { + const { title, children, modalId, size, onClose } = props; + modals.open({ + modalId, + title, + size, + // Takes the focus the trap would otherwise land on the close button, + // which reads as that button being pre-selected. + children: ( +
+ {children} +
+ ), + onClose, + centered: true, + styles: { + // Drawn, not just shadowed, so the card reads as one panel. + content: { border: BORDER }, + header: { + background: CHROME_BACKGROUND, + borderBottom: BORDER, + padding: "var(--mantine-spacing-sm)", + // Otherwise a Mantine minimum, not the padding, sets the height. + minHeight: 0 + }, + // Shrinkable, so a long title ellipsizes rather than running under + // the close button. + title: { minWidth: 0 }, + body: { padding: 0 } + } + }); +} diff --git a/src/frontend/common/open-url-button.tsx b/src/frontend/components/open-url-button.tsx similarity index 60% rename from src/frontend/common/open-url-button.tsx rename to src/frontend/components/open-url-button.tsx index 9f4a0b954..bc31d142f 100644 --- a/src/frontend/common/open-url-button.tsx +++ b/src/frontend/components/open-url-button.tsx @@ -1,7 +1,7 @@ import { Button } from "@mantine/core"; -import { IconExternalLink } from "@tabler/icons-react"; -import { IconSize } from "./style-constants"; -import { openUrlInNewTab } from "./url"; +import { ArrowSquareOut } from "@phosphor-icons/react"; +import { IconSize } from "../lib/style-constants"; +import { openUrlInNewTab } from "../lib/url"; interface UrlButtonProps { url: string; @@ -11,7 +11,7 @@ interface UrlButtonProps { export function OpenUrlButton(props: UrlButtonProps) { return ( - - - ); -} diff --git a/src/frontend/api-utils/access-level.tsx b/src/frontend/features/auth/access-level.tsx similarity index 77% rename from src/frontend/api-utils/access-level.tsx rename to src/frontend/features/auth/access-level.tsx index 7db66d572..109de45f6 100644 --- a/src/frontend/api-utils/access-level.tsx +++ b/src/frontend/features/auth/access-level.tsx @@ -1,11 +1,15 @@ import { PropsWithChildren, useMemo } from "react"; import { queryOptions, useQuery } from "@tanstack/react-query"; -import { hasEditorAccess } from "../../shared/types"; -import { hasAdminAccess } from "../../shared/types"; -import { isWithinAccessLevel } from "../../shared/types"; -import { AccessLevel, type AccessData } from "../../shared/types"; -import { apiGet } from "./api"; -import { useUiState } from "./ui-state"; +import { + AccessLevel, + type AccessData, + hasAdminAccess, + hasEditorAccess, + isWithinAccessLevel +} from "@backend/features/auth/access-level"; +import { accessDataQueryKey } from "../../lib/query-keys"; +import { apiGet } from "../../lib/api-client"; +import { useUiState } from "../../lib/ui-state"; const DEFAULT_ACCESS_DATA: AccessData = { maxAccessLevel: AccessLevel.USER, @@ -17,10 +21,6 @@ const DEFAULT_ACCESS_LEVEL = (import.meta.env.VITE_DEFAULT_ACCESS_LEVEL as AccessLevel | undefined) ?? AccessLevel.USER; -export function accessDataQueryKey() { - return ["access-data"]; -} - export function getAccessDataQuery() { return queryOptions({ queryKey: accessDataQueryKey(), @@ -31,6 +31,11 @@ export function getAccessDataQuery() { /** Server access plus the level the app is currently viewed as. */ export interface ResolvedAccessData extends AccessData { currentAccessLevel: AccessLevel; + /** + * While set, the rest are the placeholder — so anything rendered for a + * *signed-out* caller must wait or it flashes. Positive gates need not. + */ + isPending: boolean; } /** @@ -38,8 +43,8 @@ export interface ResolvedAccessData extends AccessData { * drop below the granted max), so it survives the query refetching on navigation. */ export function useAccessData(): ResolvedAccessData { - const serverData = - useQuery(getAccessDataQuery()).data ?? DEFAULT_ACCESS_DATA; + const { data, isPending } = useQuery(getAccessDataQuery()); + const serverData = data ?? DEFAULT_ACCESS_DATA; const chosenLevel = useUiState()[0].accessLevel; return useMemo(() => { const desired = chosenLevel ?? DEFAULT_ACCESS_LEVEL; @@ -50,8 +55,8 @@ export function useAccessData(): ResolvedAccessData { ) ? desired : serverData.maxAccessLevel; - return { ...serverData, currentAccessLevel }; - }, [serverData, chosenLevel]); + return { ...serverData, currentAccessLevel, isPending }; + }, [serverData, chosenLevel, isPending]); } /** Whether the caller is signed in to Onshape (from access-data). */ diff --git a/src/frontend/api-utils/sign-in.ts b/src/frontend/features/auth/sign-in.ts similarity index 95% rename from src/frontend/api-utils/sign-in.ts rename to src/frontend/features/auth/sign-in.ts index 1363fe884..e54414a23 100644 --- a/src/frontend/api-utils/sign-in.ts +++ b/src/frontend/features/auth/sign-in.ts @@ -1,6 +1,6 @@ import { useEffect } from "react"; import { useNavigate, useSearch } from "@tanstack/react-router"; -import { showSuccessToast } from "../common/notifications"; +import { showSuccessToast } from "../../lib/notifications"; const SIGNED_IN_PARAM = "justSignedIn"; diff --git a/src/frontend/cards/build-status.tsx b/src/frontend/features/build-status/components/build-status.tsx similarity index 94% rename from src/frontend/cards/build-status.tsx rename to src/frontend/features/build-status/components/build-status.tsx index 99371f00c..9ce4c72bf 100644 --- a/src/frontend/cards/build-status.tsx +++ b/src/frontend/features/build-status/components/build-status.tsx @@ -1,5 +1,6 @@ import { Badge, + Box, Divider, Group, HoverCard, @@ -11,13 +12,13 @@ import { Tooltip } from "@mantine/core"; import { - IconAlertOctagon, - IconAlertTriangle, - IconCheck, - IconClock, - IconInfoCircle, - IconX -} from "@tabler/icons-react"; + Check, + Clock, + Info, + Warning, + WarningOctagon, + X +} from "@phosphor-icons/react"; import { ComponentPropsWithRef, ReactNode, @@ -27,7 +28,7 @@ import { useMemo, useState } from "react"; -import { formatRelativeTime } from "../common/format-time"; +import { formatRelativeTime } from "../../../lib/format-time"; import { addBuildIssue, BuildIssue, @@ -36,16 +37,16 @@ import { getIssueDescription, getIssueSeverity, getMaxSeverity -} from "../../shared/build-issues"; +} from "@backend/features/build-checker/issues"; import { GroupBuildStatus, InsertableBuildStatus -} from "../../shared/api-models"; -import { getVendorName, Vendor } from "../../shared/types"; +} from "@backend/features/build-checker/contract"; +import { getVendorName, Vendor } from "@backend/features/library/vendors"; import { ConfigurationParameter, ParameterType -} from "../../shared/configuration-models"; +} from "@backend/features/configurations/models"; import { AUTO_INDEX_THRESHOLD, type ConfigurationCount, @@ -53,16 +54,17 @@ import { IndexingBand, isIndexedParameter, MAX_PART_NUMBER_CONFIGURATIONS -} from "../../shared/configuration-combinations"; -import { FontWeight, IconColor, IconSize } from "../common/style-constants"; -import { RequireAccessLevel } from "../api-utils/access-level"; -import { useBuildStatusQuery, useJobStatusQuery } from "../queries"; +} from "@backend/features/configurations/combinations"; +import { FontWeight, IconSize } from "../../../lib/style-constants"; +import { RequireAccessLevel } from "../../auth/access-level"; +import { useBuildStatusQuery } from "../queries"; +import { useJobStatusQuery } from "../../library/queries"; import { useSetVisibilityMutation, useToggleInsertAndFastenMutation, useIndexConfigurationsMutation, useToggleSortOrderMutation -} from "./card-hooks"; +} from "../../library/card-hooks"; /** Discriminated so `StateValue` renders each kind its own way. */ export type StateRowValue = @@ -103,7 +105,9 @@ function useGroupBuildIssues( }, [groupStatus, insertableStatuses]); } -interface IssueIconProps extends ComponentPropsWithRef<"svg"> { +interface IssueIconProps + // Rendered through Box, which owns these two as style props. + extends Omit, "color" | "display"> { /** The severity to render, or null if all checks pass. */ severity: BuildIssueSeverity | null; /** @default IconSize.SMALL */ @@ -119,37 +123,41 @@ export function IssueIcon({ switch (severity) { case BuildIssueSeverity.ERROR: return ( - ); case BuildIssueSeverity.WARNING: return ( - ); case BuildIssueSeverity.INFO: return ( - ); case null: return ( - ); @@ -329,7 +337,7 @@ function LastModified({ c="dimmed" style={{ whiteSpace: "nowrap", flexShrink: 0 }} > - + {!lastLoadedAt ? "Unknown" @@ -348,7 +356,7 @@ function SeverityBadges({ issues }: { issues: BuildIssue[] }): ReactNode { size="sm" variant="light" color="green" - leftSection={} + leftSection={} > All checks pass @@ -958,9 +966,9 @@ function ParsedRow({ function StateValue({ value }: { value: StateRowValue }): ReactNode { if (value.kind === "bool") { return value.value ? ( - + ) : ( - + ); } diff --git a/src/frontend/features/build-status/queries.ts b/src/frontend/features/build-status/queries.ts new file mode 100644 index 000000000..b49b85e6e --- /dev/null +++ b/src/frontend/features/build-status/queries.ts @@ -0,0 +1,35 @@ +import { + keepPreviousData, + queryOptions, + useQuery +} from "@tanstack/react-query"; +import { apiGet } from "../../lib/api-client"; +import { type LibraryBuildStatus } from "@backend/features/build-checker/contract"; +import { LibraryId } from "@backend/features/library/library-id"; +import { useLibraryId } from "../library/library-path"; +import { useCacheVersion } from "../library/queries"; +import { buildStatusQueryKey } from "../../lib/query-keys"; + +export function getBuildStatusQuery( + libraryId: LibraryId, + cacheVersion: number +) { + return queryOptions({ + queryKey: buildStatusQueryKey(libraryId, cacheVersion), + queryFn: () => + apiGet("/build-status/library/" + libraryId, { + cacheId: cacheVersion + }), + // A toggle bumps cacheVersion (and thus this key); keep the old data on + // screen while the new version refetches so the hover card doesn't close. + placeholderData: keepPreviousData, + staleTime: Infinity, + gcTime: Infinity + }); +} + +export function useBuildStatusQuery() { + const libraryId = useLibraryId(); + const cacheVersion = useCacheVersion(); + return useQuery(getBuildStatusQuery(libraryId, cacheVersion)); +} diff --git a/src/frontend/favorites/favorite-button.tsx b/src/frontend/features/favorites/components/favorite-button.tsx similarity index 81% rename from src/frontend/favorites/favorite-button.tsx rename to src/frontend/features/favorites/components/favorite-button.tsx index 2ea8fd126..7d380c950 100644 --- a/src/frontend/favorites/favorite-button.tsx +++ b/src/frontend/features/favorites/components/favorite-button.tsx @@ -1,26 +1,26 @@ -import { ActionIcon, Menu } from "@mantine/core"; -import { - IconHeart, - IconHeartBroken, - IconHeartFilled -} from "@tabler/icons-react"; -import { HeartIconColor, IconSize } from "../common/style-constants"; +import { ActionIcon, Box, Menu } from "@mantine/core"; +import { Heart, HeartBreak } from "@phosphor-icons/react"; +import { IconSize } from "../../../lib/style-constants"; import { useMutation } from "@tanstack/react-query"; import { ReactNode, useState } from "react"; -import { apiDelete, apiPost } from "../api-utils/api"; -import { - type Favorite, - type FavoritesData, - type InsertableOut -} from "../../shared/api-models"; -import { LibraryId } from "../../shared/types"; -import { queryClient } from "../query-client"; +import { apiDelete, apiPost } from "../../../lib/api-client"; +import type { + Favorite, + FavoritesData +} from "@backend/features/favorites/contract"; +import type { InsertableOut } from "@backend/features/library/contract"; +import { LibraryId } from "@backend/features/library/library-id"; +import { queryClient } from "../../../lib/query-client"; import { useRouter } from "@tanstack/react-router"; -import { handleAppError, HandledError } from "../api-utils/errors"; -import { getQueryUpdater } from "../common/utils"; -import { toLibraryPath, useLibraryId } from "../api-utils/library"; -import { favoritesQueryKey } from "../queries"; -import { useRefreshFavorites } from "../api-utils/refresh"; +import { appError, handleAppError } from "../../../lib/errors"; +import { getQueryUpdater } from "../../../lib/query-cache"; +import { + toFavoritePath, + toLibraryPath, + useLibraryId +} from "../../library/library-path"; +import { favoritesQueryKey } from "../../../lib/query-keys"; +import { useRefreshFavorites } from "../../../lib/refresh"; enum Operation { ADD, @@ -64,7 +64,7 @@ function useUpdateFavoritesMutation() { mutationFn: async (args) => { if (args.operation === Operation.ADD) { if (!args.insertable.isVisible) { - throw new HandledError( + throw appError( `Cannot favorite hidden element ${args.insertable.name}.` ); } @@ -75,7 +75,7 @@ function useUpdateFavoritesMutation() { } }); } else { - return apiDelete("/favorites/" + args.favoriteId); + return apiDelete(toFavoritePath(args.favoriteId)); } }, onMutate: async (args) => { @@ -197,10 +197,12 @@ interface HeartIconProps { export function HeartIcon(props: HeartIconProps): ReactNode { const { full = true, size = IconSize.SMALL } = props; + // fz, not size: Box builds its own `style`, dropping the font-size that + // Phosphor's `size` sets, which shrank the icon to 1em. return full ? ( - + ) : ( - + ); } @@ -213,5 +215,5 @@ interface HeartBrokenIconProps { export function HeartBrokenIcon(props: HeartBrokenIconProps): ReactNode { const { size = IconSize.SMALL } = props; - return ; + return ; } diff --git a/src/frontend/favorites/favorite-card.tsx b/src/frontend/features/favorites/components/favorite-card.tsx similarity index 79% rename from src/frontend/favorites/favorite-card.tsx rename to src/frontend/features/favorites/components/favorite-card.tsx index 9b14c88dd..f2cb36294 100644 --- a/src/frontend/favorites/favorite-card.tsx +++ b/src/frontend/features/favorites/components/favorite-card.tsx @@ -1,38 +1,40 @@ -import { encodeCanonicalConfiguration } from "../../shared/canonical-configuration"; +import { encodeCanonicalConfiguration } from "@backend/features/configurations/canonical"; import { ReactNode } from "react"; -import { InsertableOut, Favorite } from "../../shared/api-models"; +import { Favorite } from "@backend/features/favorites/contract"; +import { InsertableOut } from "@backend/features/library/contract"; import { useMutation } from "@tanstack/react-query"; -import { apiPost } from "../api-utils/api"; -import { queryClient } from "../query-client"; +import { apiPost } from "../../../lib/api-client"; +import { queryClient } from "../../../lib/query-client"; import { Menu } from "@mantine/core"; -import { IconPencil } from "@tabler/icons-react"; -import { IconSize } from "../common/style-constants"; +import { Pencil } from "@phosphor-icons/react"; +import { IconSize } from "../../../lib/style-constants"; import { useRouter } from "@tanstack/react-router"; -import { openInsertMenu } from "../insert/insert-menu"; -import { openFavoriteMenu } from "./favorite-menu"; +import { openInsertMenu } from "../../insert/open-insert-menu"; +import { openFavoriteMenu } from "../open-favorite-menu"; import { FavoriteButton, FavoriteInsertableItem } from "./favorite-button"; import { CardTitle, ItemRow, OpenDocumentItems, QuickInsertItems -} from "../cards/card-components"; -import { useIsInsertableHidden } from "../cards/card-hooks"; -import { useIsAssemblyInPartStudio } from "../insert/insert-hooks"; -import { ChangeOrderItems } from "../common/change-order"; -import { useUiState } from "../api-utils/ui-state"; -import { useIsConnectedToOnshape } from "../api-utils/onshape-params"; +} from "../../library/components/card-components"; +import { useIsInsertableHidden } from "../../library/card-hooks"; +import { useIsAssemblyInPartStudio } from "../../insert/insert-hooks"; +import { ChangeOrderItems } from "../../../components/change-order"; +import { useUiState } from "../../../lib/ui-state"; +import { useIsConnectedToOnshape } from "../../../lib/onshape-params"; import { openCannotDeriveAssemblyAlert, openCannotEditDefaultConfigurationAlert, openCannotReorderAlert -} from "../app/alerts"; -import { getAppErrorHandler } from "../api-utils/errors"; -import { favoritesQueryKey, useFavoritesQuery } from "../queries"; -import { useRefreshFavorites } from "../api-utils/refresh"; +} from "../../../components/alerts"; +import { getAppErrorHandler } from "../../../lib/errors"; +import { useFavoritesQuery } from "../queries"; +import { favoritesQueryKey } from "../../../lib/query-keys"; +import { useRefreshFavorites } from "../../../lib/refresh"; import { produce } from "immer"; -import { SearchHit } from "../search/search"; -import { toLibraryPath, useLibraryId } from "../api-utils/library"; +import { SearchHit } from "../../search/search"; +import { toLibraryPath, useLibraryId } from "../../library/library-path"; interface FavoriteCardProps { insertable: InsertableOut; @@ -126,9 +128,9 @@ function FavoriteMenuItems(props: FavoriteMenuItemsProps): ReactNode { )} } + leftSection={} onClick={() => { - if (insertable.configurationId === undefined) { + if (!insertable.isConfigurable) { openCannotEditDefaultConfigurationAlert(); return; } diff --git a/src/frontend/features/favorites/components/favorite-menu.tsx b/src/frontend/features/favorites/components/favorite-menu.tsx new file mode 100644 index 000000000..5fea71c45 --- /dev/null +++ b/src/frontend/features/favorites/components/favorite-menu.tsx @@ -0,0 +1,168 @@ +import { modals } from "@mantine/modals"; +import { AppModalBody, AppModalFooter } from "../../../components/app-modal"; +import { MenuTitle } from "../../../components/app-title"; +import { Button } from "@mantine/core"; +import { FloppyDisk } from "@phosphor-icons/react"; +import { IconSize } from "../../../lib/style-constants"; +import { ReactNode, useEffect, useState } from "react"; +import { useRouter } from "@tanstack/react-router"; +import { useMutation } from "@tanstack/react-query"; +import { apiPost } from "../../../lib/api-client"; +import { showErrorToast, showSuccessToast } from "../../../lib/notifications"; +import { PreviewImageCard } from "../../thumbnails/components/thumbnail"; +import { ConfigurationWrapper } from "../../insert/components/configurations"; +import type { FavoritesData } from "@backend/features/favorites/contract"; +import { HeartIcon } from "./favorite-button"; +import { queryClient } from "../../../lib/query-client"; +import { + ParameterValues, + SearchRecord +} from "@backend/features/configurations/models"; +import { encodeCanonicalConfiguration } from "@backend/features/configurations/canonical"; +import { useFavoritesQuery } from "../queries"; +import { useLibraryQuery } from "../../library/queries"; +import { favoritesQueryKey } from "../../../lib/query-keys"; +import { getQueryUpdater } from "../../../lib/query-cache"; +import { toFavoritePath, useLibraryId } from "../../library/library-path"; +import { useRefreshFavorites } from "../../../lib/refresh"; +import { PageError } from "../../../components/app-zero-state"; + +interface FavoriteMenuContentProps { + favoriteId: string; + /** The modal this renders in, so the header can track the selection. */ + modalId: string; + defaultConfiguration?: ParameterValues; +} + +export function FavoriteMenuContent( + props: FavoriteMenuContentProps +): ReactNode { + const { favoriteId, modalId, defaultConfiguration } = props; + + const router = useRouter(); + const libraryId = useLibraryId(); + const insertables = useLibraryQuery().data?.insertables; + const favoritesData = useFavoritesQuery().data; + const refreshFavorites = useRefreshFavorites(); + + const [configuration, setConfiguration] = useState< + ParameterValues | undefined + >(defaultConfiguration); + // Reported by ConfigurationWrapper; addresses this selection's thumbnail. + // Undefined until it reports, which is what gates saving. + const [canonicalConfiguration, setCanonicalConfiguration] = useState< + ParameterValues | undefined + >(undefined); + const [record, setRecord] = useState(undefined); + + const favorite = favoritesData?.favorites[favoriteId]; + const insertable = + favorite && insertables + ? insertables[favorite.insertableId] + : undefined; + + const insertableName = insertable?.name; + useEffect(() => { + if (insertableName === undefined) { + return; + } + modals.updateModal({ + modalId, + title: ( + } + /> + ) + }); + }, [modalId, insertableName, record]); + + const setDefaultConfigurationMutation = useMutation({ + mutationKey: ["set-default-configuration"], + mutationFn: async () => { + // Canonical, so it addresses the thumbnail the favorites row asks + // for; Onshape applies defaults for what it omits. + return apiPost( + "/default-configuration" + toFavoritePath(favoriteId), + { + body: { defaultConfiguration: canonicalConfiguration } + } + ); + }, + + onMutate: async () => { + const queryKey = favoritesQueryKey(libraryId); + await queryClient.cancelQueries({ queryKey }); + queryClient.setQueryData( + queryKey, + getQueryUpdater((data: FavoritesData) => { + const fav = data.favorites[favoriteId]; + if (fav) fav.defaultConfiguration = canonicalConfiguration; + return data; + }) + ); + void router.invalidate(); + }, + onError: () => { + showErrorToast( + "Unexpectedly failed to update default configuration." + ); + }, + onSuccess: () => { + showSuccessToast("Successfully updated default configuration."); + }, + onSettled: refreshFavorites + }); + + if (!insertable) { + return null; + } + if (!insertable.isConfigurable) { + return ( + + ); + } + + return ( + <> + + + + + + + + + ); +} diff --git a/src/frontend/favorites/favorites-list.tsx b/src/frontend/features/favorites/components/favorites-list.tsx similarity index 77% rename from src/frontend/favorites/favorites-list.tsx rename to src/frontend/features/favorites/components/favorites-list.tsx index 0c46e6489..94d50b423 100644 --- a/src/frontend/favorites/favorites-list.tsx +++ b/src/frontend/features/favorites/components/favorites-list.tsx @@ -1,24 +1,27 @@ -import { useAccessData } from "../api-utils/access-level"; -import { IconHeartBroken } from "@tabler/icons-react"; -import { HeartIconColor, IconSize } from "../common/style-constants"; +import { Box } from "@mantine/core"; +import { useAccessData } from "../../auth/access-level"; +import { HeartBreak } from "@phosphor-icons/react"; +import { IconSize } from "../../../lib/style-constants"; import { ReactNode } from "react"; -import { filterInsertables } from "../search/filter"; +import { filterInsertables } from "../../search/filter"; +import { getFavoriteForInsertable } from "@backend/features/favorites/contract"; +import { InsertableOut } from "@backend/features/library/contract"; +import { useUiState } from "../../../lib/ui-state"; import { - getFavoriteForInsertable, - InsertableOut -} from "../../shared/api-models"; -import { useUiState } from "../api-utils/ui-state"; -import { SectionError, SectionLoading } from "../app-common/app-zero-state"; -import { NoSearchResultError, SearchCallout } from "../search/search-errors"; -import { FavoriteCard } from "./favorite-card"; -import { ItemTable } from "../cards/card-components"; + SectionError, + SectionLoading +} from "../../../components/app-zero-state"; import { - useFavoritesQuery, - useLibraryQuery, - useSearchDbQuery -} from "../queries"; -import { doSearch, FilterResult, SearchHit } from "../search/search"; -import { hasEditorAccess } from "../../shared/types"; + NoSearchResultError, + SearchCallout +} from "../../search/components/search-errors"; +import { FavoriteCard } from "./favorite-card"; +import { ItemTable } from "../../library/components/card-components"; +import { useFavoritesQuery } from "../queries"; +import { useLibraryQuery } from "../../library/queries"; +import { useSearchDbQuery } from "../../search/queries"; +import { doSearch, FilterResult, SearchHit } from "../../search/search"; +import { hasEditorAccess } from "@backend/features/auth/access-level"; /** * A list of current favorite cards. @@ -39,9 +42,10 @@ export function FavoritesList(): ReactNode { } /> diff --git a/src/frontend/features/favorites/open-favorite-menu.tsx b/src/frontend/features/favorites/open-favorite-menu.tsx new file mode 100644 index 000000000..13e998e17 --- /dev/null +++ b/src/frontend/features/favorites/open-favorite-menu.tsx @@ -0,0 +1,35 @@ +import { openAppModal } from "../../components/open-app-modal"; +import { type ParameterValues } from "@backend/features/configurations/models"; +import { FavoriteMenuContent } from "./components/favorite-menu"; +import { MenuTitle } from "../../components/app-title"; +import { HeartIcon } from "./components/favorite-button"; +import { IconSize } from "../../lib/style-constants"; + +interface OpenFavoriteMenuProps { + favoriteId: string; + insertableName: string; + defaultConfiguration?: ParameterValues; +} + +export function openFavoriteMenu(props: OpenFavoriteMenuProps) { + const { favoriteId, insertableName, defaultConfiguration } = props; + // Minted here so the content can update the header as the selection changes. + const modalId = crypto.randomUUID(); + openAppModal({ + modalId, + title: ( + } + /> + ), + size: 500, + children: ( + + ) + }); +} diff --git a/src/frontend/features/favorites/queries.ts b/src/frontend/features/favorites/queries.ts new file mode 100644 index 000000000..18a7fede0 --- /dev/null +++ b/src/frontend/features/favorites/queries.ts @@ -0,0 +1,26 @@ +import { queryOptions, useQuery } from "@tanstack/react-query"; +import { apiGet } from "../../lib/api-client"; +import { type FavoritesData } from "@backend/features/favorites/contract"; +import { LibraryId } from "@backend/features/library/library-id"; +import { useAccessData } from "../auth/access-level"; +import { useLibraryId } from "../library/library-path"; +import { favoritesQueryKey } from "../../lib/query-keys"; + +const EMPTY_FAVORITES: FavoritesData = { favorites: {}, favoriteOrder: [] }; + +export function getFavoritesQuery(libraryId: LibraryId, enabled = true) { + return queryOptions({ + queryKey: favoritesQueryKey(libraryId), + queryFn: () => apiGet("/favorites/library/" + libraryId), + enabled, + // Not signed in: the endpoint 401s, so present no favorites. + placeholderData: EMPTY_FAVORITES + }); +} + +export function useFavoritesQuery() { + const libraryId = useLibraryId(); + // Favorites require sign-in; don't fetch (or display) them otherwise. + const signedIn = useAccessData().signedIn; + return useQuery(getFavoritesQuery(libraryId, signedIn)); +} diff --git a/src/frontend/insert/configurations.tsx b/src/frontend/features/insert/components/configurations.tsx similarity index 91% rename from src/frontend/insert/configurations.tsx rename to src/frontend/features/insert/components/configurations.tsx index ceb2e2226..895e6f43d 100644 --- a/src/frontend/insert/configurations.tsx +++ b/src/frontend/features/insert/components/configurations.tsx @@ -4,20 +4,20 @@ import { Group, Loader, Select, + Stack, Text, TextInput } from "@mantine/core"; -import { useQuery } from "@tanstack/react-query"; import { useSearch } from "@tanstack/react-router"; import { - Dispatch, - useEffect, + type Dispatch, ReactNode, + type SyntheticEvent, + useCallback, + useEffect, useRef, - useState, - useCallback + useState } from "react"; -import { apiGet } from "../api-utils/api"; import { ParameterValues, ConfigurationResult, @@ -31,28 +31,27 @@ import { EnumOption, EMPTY_UNIT_INFO, SearchRecord -} from "../../shared/configuration-models"; +} from "@backend/features/configurations/models"; import { evaluateCondition, findRecordForConfiguration, getEvaluateOptions, getOption, getVisibleOptions -} from "../../shared/configuration-utils"; -import { canonicalizeConfiguration } from "../../shared/canonical-configuration"; -import { handleBooleanChange } from "../common/utils"; +} from "@backend/features/configurations/utils"; +import { canonicalizeConfiguration } from "@backend/features/configurations/canonical"; import { formatValueWithUnits, valueWithUnits, evaluateExpression -} from "../../shared/input-parser"; -import { getConfigurationKey, useUnitInfoQuery } from "../queries"; -import { showErrorToast } from "../common/notifications"; -import { SectionError } from "../app-common/app-zero-state"; -import { useIsConnectedToOnshape } from "../api-utils/onshape-params"; +} from "@backend/features/configurations/input-parser"; +import { useConfigurationQuery, useUnitInfoQuery } from "../queries"; +import { showErrorToast } from "../../../lib/notifications"; +import { SectionError } from "../../../components/app-zero-state"; +import { useIsConnectedToOnshape } from "../../../lib/onshape-params"; interface ConfigurationWrapperProps { - configurationId: string; + insertableId: string; microversionId: string; configuration?: ParameterValues; setConfiguration: Dispatch; @@ -67,9 +66,15 @@ interface ConfigurationWrapperProps { onRecord?: (record: SearchRecord | undefined) => void; } +/** Event handler that exposes the target element's value as a boolean. */ +function handleBooleanChange(handler: Dispatch) { + return (event: SyntheticEvent) => + handler((event.target as HTMLInputElement).checked); +} + export function ConfigurationWrapper(props: ConfigurationWrapperProps) { const { - configurationId, + insertableId, microversionId, configuration, setConfiguration, @@ -77,16 +82,7 @@ export function ConfigurationWrapper(props: ConfigurationWrapperProps) { onRecord } = props; - const query = useQuery({ - queryKey: getConfigurationKey(configurationId, microversionId), - queryFn: async () => { - return apiGet("/configuration/" + configurationId, { - cacheId: microversionId - }); - }, - // Don't refetch query automatically so we don't reset user inputs - refetchInterval: false - }); + const query = useConfigurationQuery(insertableId, microversionId); const search = useSearch({ from: "/app" }); // Units come from the current document; empty when not connected to one, in @@ -188,7 +184,9 @@ function ConfigurationParameters(props: ConfigurationParameterProps) { /> ); }); - return
{parameters}
; + // Spaced by the stack, not by a margin on each row, which the first row + // would add to the gap the body already leaves above it. + return {parameters}; } interface ParameterProps { @@ -261,7 +259,7 @@ interface InputLabelProps { function InputLabel(props: InputLabelProps) { const { label, htmlFor, children } = props; return ( - + , - size: 500, - centered: true, - onClose: () => { - if (!didInsert) { - showRestoreToast(insertable, defaultConfiguration); - } - }, - children: ( - { - didInsert = true; - modals.close(id); - }} - /> - ) - }); -} - -/** - * Both are shown: the element name is how the part was found, the part number - * and name are what gets inserted. - */ -function InsertMenuTitle({ - name, - record -}: { - name: string; - record?: SearchRecord; -}): ReactNode { - const details = record - ? [record.partNumber, record.name].filter( - (value): value is string => !!value && value !== name - ) - : []; - return ( - - {name} - {details.length > 0 && ( - - {details.join(" · ")} - - )} - - ); -} +import { RequireSignIn, useIsSignedIn } from "../../auth/access-level"; +import { useIsConnectedToOnshape } from "../../../lib/onshape-params"; +import { startSignIn } from "../../auth/sign-in"; interface InsertMenuContentProps { insertable: InsertableOut; @@ -103,7 +40,7 @@ interface InsertMenuContentProps { onInsert: () => void; } -function InsertMenuContent(props: InsertMenuContentProps): ReactNode { +export function InsertMenuContent(props: InsertMenuContentProps): ReactNode { const { insertable, modalId, onInsert } = props; const favorites = useFavoritesQuery().data?.favorites; const isSignedIn = useIsSignedIn(); @@ -115,16 +52,40 @@ function InsertMenuContent(props: InsertMenuContentProps): ReactNode { // canonical form needs. Empty means the element's default configuration. const [canonicalConfiguration, setCanonicalConfiguration] = useState({}); + // The first report is what the menu opened with, and so what a right-click + // on the card would have inserted. Absent until the parameters load. + const [openedWithConfiguration, setOpenedWithConfiguration] = + useState(); + + const handleCanonicalConfiguration = useCallback( + (canonical: ParameterValues) => { + setCanonicalConfiguration(canonical); + setOpenedWithConfiguration((opened) => opened ?? canonical); + }, + [] + ); const [record, setRecord] = useState(undefined); + // A part with no parameters has one record — the element's own part data — + // which no ConfigurationWrapper is mounted to report, but the title wants. + const soleRecord = useConfigurationQuery( + insertable.id, + insertable.microversionId, + !insertable.isConfigurable + ).data?.records[0]; // The title lives in the modal's chrome, so it's updated rather than // rendered: the header follows the configuration as the user changes it. useEffect(() => { modals.updateModal({ modalId, - title: + title: ( + + ) }); - }, [modalId, insertable.name, record]); + }, [modalId, insertable.name, record, soleRecord]); useEffect(() => { if (!isSignedIn) { @@ -139,14 +100,14 @@ function InsertMenuContent(props: InsertMenuContentProps): ReactNode { const favorite = getFavoriteForInsertable(favorites, insertable.id); let parameters: ReactNode = null; - if (insertable.configurationId) { + if (insertable.isConfigurable) { parameters = ( ); @@ -154,17 +115,19 @@ function InsertMenuContent(props: InsertMenuContentProps): ReactNode { return ( <> - - {parameters} - + + + {parameters} + + - + ); } interface InsertButtonsProps { + /** + * Whether the configuration is still the one the menu opened with, which a + * right-click on the card would have inserted without opening anything. + */ + isUnchanged: boolean; insertable: InsertableOut; configuration?: ParameterValues; isFavorite: boolean; @@ -204,7 +178,8 @@ interface InsertButtonsProps { * The derive/insert button plus the insert and fasten checkbox. */ function InsertButtons(props: InsertButtonsProps): ReactNode { - const { insertable, configuration, isFavorite, onInsert } = props; + const { insertable, configuration, isUnchanged, isFavorite, onInsert } = + props; const search = useSearch({ from: "/app" }); // Inserting targets the current Onshape document; there's nothing to insert @@ -217,7 +192,7 @@ function InsertButtons(props: InsertButtonsProps): ReactNode { const isLoadingConfiguration = useIsFetching({ - queryKey: ["configuration", insertable.configurationId] + queryKey: insertableConfigurationQueryMatchKey(insertable.id) }) > 0; const canFasten = @@ -226,8 +201,11 @@ function InsertButtons(props: InsertButtonsProps): ReactNode { const handleClick = useCallback(() => { insertMutation.mutate(canFasten && uiState.fasten); + if (isUnchanged) { + showQuickInsertTip(); + } onInsert(); - }, [insertMutation, onInsert, canFasten, uiState.fasten]); + }, [insertMutation, onInsert, canFasten, uiState.fasten, isUnchanged]); if (!isConnected) { return null; @@ -243,7 +221,7 @@ function InsertButtons(props: InsertButtonsProps): ReactNode { /> )} - + <> + + setUrl(event.currentTarget.value)} + error={mutation.isError} + /> + + + + + ); } export function AddGroupButton(): ReactNode { return ( @@ -43,14 +46,12 @@ interface ToastConfig { withCloseButton?: boolean; } -/** Shows a toast, replacing any existing toast with the same id. */ +/** Ids currently on screen, so a repeat updates rather than replaces. */ +const liveToasts = new Set(); + +/** Shows a toast, updating any existing toast with the same id. */ function showToast(config: ToastConfig): string { - // Mantine's `show` no-ops when a toast with this id already exists, so hide - // it first to replace it (e.g. a loading toast upgraded to success). - if (config.id) { - notifications.hide(config.id); - } - return notifications.show({ + const props = { id: config.id, color: config.color, icon: config.icon, @@ -58,15 +59,38 @@ function showToast(config: ToastConfig): string { loading: config.loading, autoClose: config.autoClose, withCloseButton: config.withCloseButton + }; + + // Updating keeps the toast in place, so a loading toast becoming a success + // one reads as the same toast rather than one leaving and another arriving. + if (config.id && liveToasts.has(config.id)) { + notifications.update(props); + return config.id; + } + + const id = notifications.show({ + ...props, + onClose: () => liveToasts.delete(id) }); + liveToasts.add(id); + return id; } -export function showInfoToast(message: string, id?: string): string { +interface InfoToastOptions { + /** Repeats with the same id update the toast rather than stacking one up. */ + id?: string; + autoClose?: number | false; +} + +export function showInfoToast( + message: ReactNode, + options: InfoToastOptions = {} +): string { return showToast({ - id, color: "blue", - icon: , - message + icon: , + message, + ...options }); } @@ -85,7 +109,7 @@ export function showSuccessToast(message: string, id?: string): string { return showToast({ id, color: "green", - icon: , + icon: , message }); } @@ -94,7 +118,7 @@ export function showErrorToast(message: string, id?: string): string { return showToast({ id, color: "red", - icon: , + icon: , message }); } diff --git a/src/frontend/api-utils/onshape-params.ts b/src/frontend/lib/onshape-params.ts similarity index 70% rename from src/frontend/api-utils/onshape-params.ts rename to src/frontend/lib/onshape-params.ts index 85638cc2f..ea986e353 100644 --- a/src/frontend/api-utils/onshape-params.ts +++ b/src/frontend/lib/onshape-params.ts @@ -1,7 +1,7 @@ import { useSearch } from "@tanstack/react-router"; -import { ElementType } from "../../shared/types"; -import { Theme } from "../../shared/types"; -import { ElementPath, isElementPath } from "../../shared/onshape-path"; +import { ElementType } from "@backend/lib/onshape/element-type"; +import { Theme } from "@backend/features/settings/settings"; +import { ElementPath, isElementPath } from "@backend/lib/onshape/path"; /** * Documents search parameter values received from Onshape. @@ -24,14 +24,15 @@ export interface OnshapeParams extends ElementPath { */ export type ColorTheme = "light" | "dark"; +/** + * `systemTheme` is Onshape's, forwarded by the entry redirect; standalone there + * is none, so the caller passes the OS preference instead. + */ export function getColorTheme( theme: Theme, - systemTheme: ColorTheme = "light" + systemTheme: ColorTheme ): ColorTheme { - if (theme === Theme.SYSTEM) { - return systemTheme; - } - return theme; + return theme === Theme.SYSTEM ? systemTheme : theme; } /** diff --git a/src/frontend/lib/part-number.test.ts b/src/frontend/lib/part-number.test.ts new file mode 100644 index 000000000..fe629aa9d --- /dev/null +++ b/src/frontend/lib/part-number.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { displayPartNumber } from "./part-number"; + +describe("displayPartNumber", () => { + it.each(["N/A", "n/a", " N/a "])("hides the placeholder %s", (value) => { + expect(displayPartNumber(value)).toBeUndefined(); + }); + + it("hides a number that only repeats the name it sits under", () => { + expect(displayPartNumber(" spacer ", "Spacer")).toBeUndefined(); + }); + + it("keeps a real number, trimmed", () => { + expect(displayPartNumber(" WCP-1025 ", "Gearbox")).toBe("WCP-1025"); + }); + + it("keeps one that merely contains the placeholder", () => { + expect(displayPartNumber("NA-1234")).toBe("NA-1234"); + }); +}); diff --git a/src/frontend/lib/part-number.ts b/src/frontend/lib/part-number.ts new file mode 100644 index 000000000..e0987111d --- /dev/null +++ b/src/frontend/lib/part-number.ts @@ -0,0 +1,17 @@ +/** Admins write this in where a generic part has no real number to give. */ +const PLACEHOLDER_PART_NUMBER = /^n\/a$/i; + +/** + * The part number to show, or nothing when it identifies nothing — a + * placeholder, or a repeat of the name it sits under. + */ +export function displayPartNumber( + partNumber: string | undefined, + name?: string +): string | undefined { + const text = partNumber?.trim(); + if (!text || PLACEHOLDER_PART_NUMBER.test(text)) { + return undefined; + } + return text.toLowerCase() === name?.trim().toLowerCase() ? undefined : text; +} diff --git a/src/frontend/lib/query-cache.ts b/src/frontend/lib/query-cache.ts new file mode 100644 index 000000000..f70cbf6dc --- /dev/null +++ b/src/frontend/lib/query-cache.ts @@ -0,0 +1,28 @@ +import { produce } from "immer"; +import type { QueryKey } from "@tanstack/react-query"; +import { queryClient } from "./query-client"; + +type Updater = (value: T | undefined) => T | undefined; + +/** + * A wrapper around Immer which can be used to update query data. + * Unlike normal updating, you can fully mutate the value without any issues. + */ +export function getQueryUpdater(recipe: (draft: T) => void): Updater { + return (value: T | undefined) => { + if (value === undefined) return undefined; + return produce(value, recipe); + }; +} + +/** + * A helper which can be used to make an optimistic update to a query with the given queryKey. + */ +export async function patchQuery( + queryKey: QueryKey, + recipe: (draft: T) => void +): Promise { + await queryClient.cancelQueries({ queryKey }); + const queryUpdater = getQueryUpdater(recipe); + queryClient.setQueryData(queryKey, queryUpdater); +} diff --git a/src/frontend/query-client.ts b/src/frontend/lib/query-client.ts similarity index 54% rename from src/frontend/query-client.ts rename to src/frontend/lib/query-client.ts index 4af9ff21f..2f55dac31 100644 --- a/src/frontend/query-client.ts +++ b/src/frontend/lib/query-client.ts @@ -1,5 +1,6 @@ import { QueryClient } from "@tanstack/react-query"; -import { HandledError } from "./api-utils/errors"; +import { AppError } from "./errors"; +import { ApiErrorKind } from "@backend/lib/api-error"; export const queryClient = new QueryClient({ defaultOptions: { @@ -8,7 +9,12 @@ export const queryClient = new QueryClient({ // Only retry once if (count >= 2) { return false; - } else if (error instanceof HandledError) { + } + // Retrying will not change an answer the backend meant. + if ( + error instanceof AppError && + error.body.kind !== ApiErrorKind.INTERNAL + ) { return false; } return true; diff --git a/src/frontend/lib/query-keys.ts b/src/frontend/lib/query-keys.ts new file mode 100644 index 000000000..79f7dca8d --- /dev/null +++ b/src/frontend/lib/query-keys.ts @@ -0,0 +1,81 @@ +/** + * Every query key in one place: features read their own keys here, and the + * cross-feature refresh flows invalidate by the match keys. + */ +import { LibraryId } from "@backend/features/library/library-id"; +import { InstancePath } from "@backend/lib/onshape/path"; + +export function accessDataQueryKey() { + return ["access-data"]; +} + +/** Every configuration, whichever insertable and microversion. */ +export function configurationQueryMatchKey() { + return ["configuration"]; +} + +/** One insertable's configuration, whichever microversion is cached. */ +export function insertableConfigurationQueryMatchKey(insertableId: string) { + return ["configuration", insertableId]; +} + +export function configurationQueryKey( + insertableId: string, + microversionId: string +) { + return [ + ...insertableConfigurationQueryMatchKey(insertableId), + microversionId + ]; +} + +export function unitInfoQueryKey(instancePath: InstancePath) { + return ["unit-info", instancePath]; +} + +export function libraryQueryMatchKey() { + return ["library"]; +} + +export function libraryQueryKey(libraryId: LibraryId, cacheVersion: number) { + return ["library", libraryId, cacheVersion]; +} + +export function libraryVersionQueryMatchKey() { + return ["library-version"]; +} + +export function libraryVersionQueryKey(libraryId: LibraryId) { + return ["library-version", libraryId]; +} + +export function searchDbQueryMatchKey() { + return ["search-db"]; +} + +export function searchDbQueryKey(libraryId: LibraryId, cacheVersion: number) { + return ["search-db", libraryId, cacheVersion]; +} + +export function favoritesQueryKey(libraryId: LibraryId) { + return ["favorites", libraryId]; +} + +export function buildStatusQueryMatchKey() { + return ["build-status"]; +} + +export function buildStatusQueryKey( + libraryId: LibraryId, + cacheVersion: number +) { + return ["build-status", libraryId, cacheVersion]; +} + +export function jobStatusQueryMatchKey() { + return ["job-status"]; +} + +export function jobStatusQueryKey(libraryId: LibraryId) { + return ["job-status", libraryId]; +} diff --git a/src/frontend/api-utils/refresh.ts b/src/frontend/lib/refresh.ts similarity index 86% rename from src/frontend/api-utils/refresh.ts rename to src/frontend/lib/refresh.ts index e8d0c46a3..c7953f12c 100644 --- a/src/frontend/api-utils/refresh.ts +++ b/src/frontend/lib/refresh.ts @@ -1,16 +1,16 @@ import { useCallback, useEffect, useRef } from "react"; import { useRouter } from "@tanstack/react-router"; -import { queryClient } from "../query-client"; +import { queryClient } from "./query-client"; +import { useJobStatusQuery } from "../features/library/queries"; import { buildStatusQueryMatchKey, favoritesQueryKey, libraryQueryMatchKey, - libraryVersionQueryMatchKey, - useJobStatusQuery -} from "../queries"; -import { accessDataQueryKey } from "./access-level"; -import { useLibraryId } from "./library"; -import { type LibraryId } from "../../shared/types"; + libraryVersionQueryMatchKey +} from "./query-keys"; +import { accessDataQueryKey } from "./query-keys"; +import { useLibraryId } from "../features/library/library-path"; +import type { LibraryId } from "@backend/features/library/library-id"; /** Refetches the current user's favorites, which aren't version-keyed. */ function refetchFavorites(libraryId: LibraryId): Promise { diff --git a/src/frontend/lib/style-constants.ts b/src/frontend/lib/style-constants.ts new file mode 100644 index 000000000..564eb5e06 --- /dev/null +++ b/src/frontend/lib/style-constants.ts @@ -0,0 +1,50 @@ +/** + * Sizes for Phosphor icons. The first three are general magnitudes for an icon + * in a line of content; the rest each name the one place they are used. + */ +export enum IconSize { + /** Beside xs text: badge labels and metadata rows. */ + TINY = 12, + /** The default, beside a label in a button or menu option. */ + SMALL = 16, + /** Standalone in a row, and the icon of a toast. */ + MEDIUM = 18, + /** Icon-only controls, at input height next to full-height buttons. */ + CONTROL = 24, + /** Section-level empty and error states. */ + SECTION = 36, + /** Full-page error states. */ + PAGE = 48 +} + +/** Standard Mantine font weights. */ +export enum FontWeight { + SEMI_BOLD = 500, + BOLD = 700 +} + +export const BORDER = "1px solid var(--mantine-color-default-border)"; + +/** A step off the page: the navbar's tab row, a modal's header and footer. */ +export const CHROME_BACKGROUND = + "light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-8))"; + +/** + * Text reads as centred on its cap height, a pixel above its line box, so an + * icon centred on that box looks low. `text-box` clips descenders under truncation. + */ +export const TITLE_ICON_NUDGE = { transform: "translateY(-1px)" }; + +/** + * One height for a section header, set rather than left to the content: an + * accordion is sized by its label, a group header by its menu button. + */ +export const SECTION_HEADER_HEIGHT = 48; + +/** The app's primary color as a filled background. */ +export enum PrimaryColor { + /** The current library's color, e.g. green for FRCDesign. */ + FILLED = "var(--mantine-primary-color-filled)", + /** What reads on top of it, typically white. */ + CONTRAST = "var(--mantine-primary-color-contrast)" +} diff --git a/src/frontend/api-utils/ui-state.ts b/src/frontend/lib/ui-state.ts similarity index 91% rename from src/frontend/api-utils/ui-state.ts rename to src/frontend/lib/ui-state.ts index b09057be6..147de9e24 100644 --- a/src/frontend/api-utils/ui-state.ts +++ b/src/frontend/lib/ui-state.ts @@ -1,6 +1,7 @@ import { useSyncExternalStore } from "react"; import * as z from "zod"; -import { AccessLevel, Vendor } from "../../shared/types"; +import { AccessLevel } from "@backend/features/auth/access-level"; +import { Vendor } from "@backend/features/library/vendors"; // Increment this when a breaking change is made to the schema const LATEST_VERSION = 3; @@ -17,7 +18,9 @@ const UiStateSchema = z.object({ openGroupId: z.string().optional(), fasten: z.boolean().default(true), /** The access level to view the app as; absent means the granted default. */ - accessLevel: AccessLevelType.optional() + accessLevel: AccessLevelType.optional(), + /** Whether the quick-insert tip has been shown; it is only worth saying once. */ + hasSeenQuickInsertTip: z.boolean().default(false) }); type UiState = z.infer; diff --git a/src/frontend/common/url.tsx b/src/frontend/lib/url.tsx similarity index 81% rename from src/frontend/common/url.tsx rename to src/frontend/lib/url.tsx index 096bb5276..29acaeb41 100644 --- a/src/frontend/common/url.tsx +++ b/src/frontend/lib/url.tsx @@ -7,10 +7,10 @@ import { InstanceType, ConfigurablePath, isConfigurablePath -} from "../../shared/onshape-path"; -import { encodeConfigurationForQuery } from "../../shared/configuration-utils"; +} from "@backend/lib/onshape/path"; +import { encodeConfigurationForQuery } from "@backend/features/configurations/utils"; import { notifications } from "@mantine/notifications"; -import { IconLink } from "@tabler/icons-react"; +import { Link } from "@phosphor-icons/react"; import { IconSize } from "./style-constants"; export function makeUrl(path: ConfigurablePath): string; @@ -25,8 +25,11 @@ export function makeUrl(path: DocumentPath): string { url += `/e/${path.elementId}`; } if (isConfigurablePath(path)) { + // Encoded here rather than in the shared helper, whose raw output is + // what Onshape's api takes; a url needs its own escaping. url += - "?configuration=" + encodeConfigurationForQuery(path.configuration); + "?configuration=" + + encodeURIComponent(encodeConfigurationForQuery(path.configuration)); } return url; } @@ -64,7 +67,7 @@ export async function copyUrlToClipboard(url: string): Promise { await navigator.clipboard.writeText(url); notifications.show({ message: "Link copied to clipboard.", - icon: , + icon: , color: "blue", autoClose: 3000 }); diff --git a/src/frontend/queries.ts b/src/frontend/queries.ts deleted file mode 100644 index 300b19a99..000000000 --- a/src/frontend/queries.ts +++ /dev/null @@ -1,257 +0,0 @@ -/** - * Queries for getting data from various endpoints on the backend. - */ -import { - keepPreviousData, - queryOptions, - useQuery -} from "@tanstack/react-query"; -import { apiGet, apiGetText } from "./api-utils/api"; -import { - type FavoritesData, - type JobStatus, - type LibraryBuildStatus, - type LibraryOut -} from "../shared/api-models"; -import { hasEditorAccess, LibraryId } from "../shared/types"; -import { useAccessData } from "./api-utils/access-level"; -import { toLibraryPath, useLibraryId } from "./api-utils/library"; -import { EMPTY_UNIT_INFO, type UnitInfo } from "../shared/configuration-models"; -import MiniSearch from "minisearch"; -import { SEARCH_OPTIONS } from "../shared/search"; -import { InstancePath } from "../shared/onshape-path"; - -export function getConfigurationMatchKey() { - return ["configuration"]; -} - -export function getConfigurationKey( - configurationId?: string, - microversionId?: string -) { - return ["configuration", configurationId, microversionId]; -} - -export function libraryQueryKey(libraryId: LibraryId, cacheVersion: number) { - return ["library", libraryId, cacheVersion]; -} - -export function libraryQueryMatchKey() { - return ["library"]; -} - -export function getLibraryQuery(libraryId: LibraryId, cacheVersion: number) { - return queryOptions({ - queryKey: libraryQueryKey(libraryId, cacheVersion), - queryFn: async () => - apiGet("/library-data/library/" + libraryId, { - cacheId: cacheVersion - }), - staleTime: Infinity, - gcTime: Infinity - }); -} - -export function useLibraryQuery() { - const libraryId = useLibraryId(); - const cacheVersion = useCacheVersion(); - return useQuery(getLibraryQuery(libraryId, cacheVersion)); -} - -export function libraryVersionQueryMatchKey() { - return ["library-version"]; -} - -export function libraryVersionQueryKey(libraryId: LibraryId) { - return ["library-version", libraryId]; -} - -/** A library's cache version, which keys the `?v=` on every request for it. */ -export function getLibraryVersionQuery(libraryId: LibraryId) { - return queryOptions({ - queryKey: libraryVersionQueryKey(libraryId), - queryFn: () => - apiGet("/library-version" + toLibraryPath(libraryId)).then( - (result: { version: number }) => result.version - ), - // Bumps arrive through the explicit refresh flows, which refetch this. - staleTime: Infinity - }); -} - -/** The displayed library's cache version, which keys its immutable responses. */ -export function useCacheVersion(): number { - const libraryId = useLibraryId(); - // Loaded by the library route before anything reading this renders. - return useQuery(getLibraryVersionQuery(libraryId)).data ?? 0; -} - -/** - * The current document's units. Disabled when not connected to a document, and - * each quantity then falls back to its own unit. - */ -export function useUnitInfoQuery(instancePath: InstancePath, enabled = true) { - return useQuery({ - queryKey: ["unit-info", instancePath], - queryFn: () => - apiGet("/unit-info", { - query: { - documentId: instancePath.documentId, - instanceId: instancePath.instanceId, - instanceType: instancePath.instanceType - } - }), - enabled, - placeholderData: EMPTY_UNIT_INFO - }); -} - -export function searchDbQueryMatchKey() { - return ["search-db"]; -} - -export function searchDbQueryKey(libraryId: LibraryId, cacheVersion: number) { - return ["search-db", libraryId, cacheVersion]; -} - -export function getSearchDbQuery(libraryId: LibraryId, cacheVersion: number) { - return queryOptions({ - queryKey: searchDbQueryKey(libraryId, cacheVersion), - queryFn: async () => { - const searchDb = await apiGetText( - "/search-db" + toLibraryPath(libraryId), - { - cacheId: cacheVersion - } - ); - if (!searchDb) { - return null; - } - return MiniSearch.loadJSON(searchDb, SEARCH_OPTIONS); - }, - staleTime: Infinity, - gcTime: Infinity - }); -} - -export function useSearchDbQuery() { - const libraryId = useLibraryId(); - const cacheVersion = useCacheVersion(); - return useQuery(getSearchDbQuery(libraryId, cacheVersion)); -} - -export function favoritesQueryKey(libraryId: LibraryId) { - return ["favorites", libraryId]; -} - -const EMPTY_FAVORITES: FavoritesData = { favorites: {}, favoriteOrder: [] }; - -export function getFavoritesQuery(libraryId: LibraryId, enabled = true) { - return queryOptions({ - queryKey: favoritesQueryKey(libraryId), - queryFn: () => apiGet("/favorites/library/" + libraryId), - enabled, - // Not signed in: the endpoint 401s, so present no favorites. - placeholderData: EMPTY_FAVORITES - }); -} - -export function buildStatusQueryMatchKey() { - return ["build-status"]; -} - -export function buildStatusQueryKey( - libraryId: LibraryId, - cacheVersion: number -) { - return ["build-status", libraryId, cacheVersion]; -} - -export function getBuildStatusQuery( - libraryId: LibraryId, - cacheVersion: number -) { - return queryOptions({ - queryKey: buildStatusQueryKey(libraryId, cacheVersion), - queryFn: () => - apiGet("/build-status/library/" + libraryId, { - cacheId: cacheVersion - }), - // A toggle bumps cacheVersion (and thus this key); keep the old data on - // screen while the new version refetches so the hover card doesn't close. - placeholderData: keepPreviousData, - staleTime: Infinity, - gcTime: Infinity - }); -} - -export function useBuildStatusQuery() { - const libraryId = useLibraryId(); - const cacheVersion = useCacheVersion(); - return useQuery(getBuildStatusQuery(libraryId, cacheVersion)); -} - -export function useFavoritesQuery() { - const libraryId = useLibraryId(); - // Favorites require sign-in; don't fetch (or display) them otherwise. - const signedIn = useAccessData().signedIn; - return useQuery(getFavoritesQuery(libraryId, signedIn)); -} - -export function jobStatusQueryMatchKey() { - return ["job-status"]; -} - -export function jobStatusQueryKey(libraryId: LibraryId) { - return ["job-status", libraryId]; -} - -/** Poll a fresh job often, then back off: a full reload runs for hours. */ -const FASTEST_POLL_MS = 3_000; -const POLL_STEPS = [ - { untilMs: 15_000, intervalMs: FASTEST_POLL_MS }, - { untilMs: 75_000, intervalMs: 5_000 } -]; -const SLOWEST_POLL_MS = 10_000; - -function jobPollInterval(runningForMs: number): number { - const step = POLL_STEPS.find(({ untilMs }) => runningForMs < untilMs); - return step?.intervalMs ?? SLOWEST_POLL_MS; -} - -/** - * Checked once on load, then polled while something runs and left alone when a - * check comes back idle. `canPoll` is the caller's gate: the route is editor-only. - */ -export function getJobStatusQuery(libraryId: LibraryId, canPoll: boolean) { - return queryOptions({ - queryKey: jobStatusQueryKey(libraryId), - queryFn: () => apiGet("/job-status/library/" + libraryId), - enabled: canPoll, - // Every status badge observes this, so rows mounting as the user scrolls - // would each trigger a fetch. Only the poll should set the pace. - staleTime: FASTEST_POLL_MS, - refetchInterval: (query) => { - const status = query.state.data; - if (!status?.running) { - return false; - } - return jobPollInterval(status.runningForMs); - } - }); -} - -/** - * Job status for the current library. The endpoint is editor-only and needs an - * Onshape session, so callers who have neither don't poll it at all. - */ -export function useJobStatusQuery() { - const libraryId = useLibraryId(); - const { signedIn, currentAccessLevel } = useAccessData(); - return useQuery( - getJobStatusQuery( - libraryId, - signedIn && hasEditorAccess(currentAccessLevel) - ) - ); -} diff --git a/src/frontend/routeTree.gen.ts b/src/frontend/routeTree.gen.ts index dea2f3a0d..a51efd206 100644 --- a/src/frontend/routeTree.gen.ts +++ b/src/frontend/routeTree.gen.ts @@ -15,7 +15,6 @@ import { Route as PagesSafariErrorRouteImport } from './routes/_pages/safari-err import { Route as PagesLicenseRouteImport } from './routes/_pages/license' import { Route as PagesGrantDeniedRouteImport } from './routes/_pages/grant-denied' import { Route as PagesCookieErrorRouteImport } from './routes/_pages/cookie-error' -import { Route as PagesBetaCompleteRouteImport } from './routes/_pages/beta-complete' import { Route as AppLibraryLibraryIdRouteRouteImport } from './routes/app/library/$libraryId/route' import { Route as AppLibraryLibraryIdIndexRouteImport } from './routes/app/library/$libraryId/index' import { Route as AppLibraryLibraryIdGroupsGroupIdRouteImport } from './routes/app/library/$libraryId/groups/$groupId' @@ -50,11 +49,6 @@ const PagesCookieErrorRoute = PagesCookieErrorRouteImport.update({ path: '/cookie-error', getParentRoute: () => rootRouteImport, } as any) -const PagesBetaCompleteRoute = PagesBetaCompleteRouteImport.update({ - id: '/_pages/beta-complete', - path: '/beta-complete', - getParentRoute: () => rootRouteImport, -} as any) const AppLibraryLibraryIdRouteRoute = AppLibraryLibraryIdRouteRouteImport.update({ id: '/library/$libraryId', @@ -77,7 +71,6 @@ const AppLibraryLibraryIdGroupsGroupIdRoute = export interface FileRoutesByFullPath { '/': typeof IndexRoute '/app': typeof AppRouteRouteWithChildren - '/beta-complete': typeof PagesBetaCompleteRoute '/cookie-error': typeof PagesCookieErrorRoute '/grant-denied': typeof PagesGrantDeniedRoute '/license': typeof PagesLicenseRoute @@ -89,7 +82,6 @@ export interface FileRoutesByFullPath { export interface FileRoutesByTo { '/': typeof IndexRoute '/app': typeof AppRouteRouteWithChildren - '/beta-complete': typeof PagesBetaCompleteRoute '/cookie-error': typeof PagesCookieErrorRoute '/grant-denied': typeof PagesGrantDeniedRoute '/license': typeof PagesLicenseRoute @@ -101,7 +93,6 @@ export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute '/app': typeof AppRouteRouteWithChildren - '/_pages/beta-complete': typeof PagesBetaCompleteRoute '/_pages/cookie-error': typeof PagesCookieErrorRoute '/_pages/grant-denied': typeof PagesGrantDeniedRoute '/_pages/license': typeof PagesLicenseRoute @@ -115,7 +106,6 @@ export interface FileRouteTypes { fullPaths: | '/' | '/app' - | '/beta-complete' | '/cookie-error' | '/grant-denied' | '/license' @@ -127,7 +117,6 @@ export interface FileRouteTypes { to: | '/' | '/app' - | '/beta-complete' | '/cookie-error' | '/grant-denied' | '/license' @@ -138,7 +127,6 @@ export interface FileRouteTypes { | '__root__' | '/' | '/app' - | '/_pages/beta-complete' | '/_pages/cookie-error' | '/_pages/grant-denied' | '/_pages/license' @@ -151,7 +139,6 @@ export interface FileRouteTypes { export interface RootRouteChildren { IndexRoute: typeof IndexRoute AppRouteRoute: typeof AppRouteRouteWithChildren - PagesBetaCompleteRoute: typeof PagesBetaCompleteRoute PagesCookieErrorRoute: typeof PagesCookieErrorRoute PagesGrantDeniedRoute: typeof PagesGrantDeniedRoute PagesLicenseRoute: typeof PagesLicenseRoute @@ -202,13 +189,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof PagesCookieErrorRouteImport parentRoute: typeof rootRouteImport } - '/_pages/beta-complete': { - id: '/_pages/beta-complete' - path: '/beta-complete' - fullPath: '/beta-complete' - preLoaderRoute: typeof PagesBetaCompleteRouteImport - parentRoute: typeof rootRouteImport - } '/app/library/$libraryId': { id: '/app/library/$libraryId' path: '/library/$libraryId' @@ -265,7 +245,6 @@ const AppRouteRouteWithChildren = AppRouteRoute._addFileChildren( const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, AppRouteRoute: AppRouteRouteWithChildren, - PagesBetaCompleteRoute: PagesBetaCompleteRoute, PagesCookieErrorRoute: PagesCookieErrorRoute, PagesGrantDeniedRoute: PagesGrantDeniedRoute, PagesLicenseRoute: PagesLicenseRoute, diff --git a/src/frontend/router.ts b/src/frontend/router.ts index 5c2fc60e5..74b57d6c7 100644 --- a/src/frontend/router.ts +++ b/src/frontend/router.ts @@ -1,6 +1,6 @@ import { createRouter } from "@tanstack/react-router"; import { routeTree } from "./routeTree.gen"; -import { RootAppSpinner } from "./app/root-spinner"; +import { RootAppSpinner } from "./components/root-spinner"; export const router = createRouter({ routeTree, diff --git a/src/frontend/routes/__root.tsx b/src/frontend/routes/__root.tsx index 33ca68e6a..110c7d8d2 100644 --- a/src/frontend/routes/__root.tsx +++ b/src/frontend/routes/__root.tsx @@ -9,11 +9,12 @@ import { MantineProvider } from "@mantine/core"; import { ModalsProvider } from "@mantine/modals"; import { Notifications } from "@mantine/notifications"; import { ReactNode, useMemo } from "react"; -import { queryClient } from "../query-client"; +import { useColorScheme } from "@mantine/hooks"; +import { queryClient } from "../lib/query-client"; import { createAppTheme } from "../theme"; -import { getColorTheme } from "../api-utils/onshape-params"; -import { DEFAULT_LIBRARY_ID, DEFAULT_SETTINGS } from "../../shared/types"; -import { NotFoundError, RootCrash } from "../app/root-error"; +import { getColorTheme } from "../lib/onshape-params"; +import { DEFAULT_SETTINGS } from "@backend/features/settings/settings"; +import { NotFoundError, RootCrash } from "../components/root-error"; export const Route = createRootRoute({ component: RootComponent, @@ -25,16 +26,22 @@ export const Route = createRootRoute({ }); function RootComponent(): ReactNode { - const search = useSearch({ strict: false }); // Both come off the url — the entry redirect seeds them and a switch // rewrites them — so the first paint is already the right colors. + const search = useSearch({ strict: false }); const params = useParams({ strict: false }); - const libraryId = params.libraryId ?? DEFAULT_LIBRARY_ID; - const theme = useMemo(() => createAppTheme(libraryId), [libraryId]); + const theme = useMemo( + () => createAppTheme(params.libraryId ?? DEFAULT_SETTINGS.libraryId), + [params.libraryId] + ); + + // Onshape puts its own scheme on the url when it launches us; standalone + // there is none, and the OS is what "system" means. + const osColorScheme = useColorScheme(); const colorTheme = getColorTheme( search.theme ?? DEFAULT_SETTINGS.theme, - search.systemTheme + search.systemTheme ?? osColorScheme ); return ( @@ -47,6 +54,9 @@ function RootComponent(): ReactNode { position="bottom-center" limit={3} autoClose={4000} + // Otherwise pinned at 440px, wrapping a message with + // an action button. Still clamped to a narrow viewport. + containerWidth="max-content" /> diff --git a/src/frontend/routes/_pages/beta-complete.tsx b/src/frontend/routes/_pages/beta-complete.tsx deleted file mode 100644 index 086e70b57..000000000 --- a/src/frontend/routes/_pages/beta-complete.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import type { JSX } from "react"; -import { createFileRoute } from "@tanstack/react-router"; -import { OpenUrlButton } from "../../common/open-url-button"; -import { PageError } from "../../app-common/app-zero-state"; - -export const Route = createFileRoute("/_pages/beta-complete")({ - component: BetaComplete -}); - -const URL = - "https://cad.onshape.com/appstore/apps/Manufacturers%20Models/6004ec5e83c40b107c183347"; - -function BetaComplete(): JSX.Element { - const frcDesignAppButton = ; - - return ( - - ); -} diff --git a/src/frontend/routes/_pages/cookie-error.tsx b/src/frontend/routes/_pages/cookie-error.tsx index 0c65cee7a..53c956f33 100644 --- a/src/frontend/routes/_pages/cookie-error.tsx +++ b/src/frontend/routes/_pages/cookie-error.tsx @@ -1,6 +1,6 @@ import type { JSX } from "react"; import { createFileRoute } from "@tanstack/react-router"; -import { PageError } from "../../app-common/app-zero-state"; +import { PageError } from "../../components/app-zero-state"; export const Route = createFileRoute("/_pages/cookie-error")({ component: CookieError diff --git a/src/frontend/routes/_pages/grant-denied.tsx b/src/frontend/routes/_pages/grant-denied.tsx index 2939bc2fc..86db3c520 100644 --- a/src/frontend/routes/_pages/grant-denied.tsx +++ b/src/frontend/routes/_pages/grant-denied.tsx @@ -1,7 +1,7 @@ import type { JSX } from "react"; import { createFileRoute } from "@tanstack/react-router"; -import { OpenUrlButton } from "../../common/open-url-button"; -import { PageError } from "../../app-common/app-zero-state"; +import { OpenUrlButton } from "../../components/open-url-button"; +import { PageError } from "../../components/app-zero-state"; export const Route = createFileRoute("/_pages/grant-denied")({ component: GrantDenied diff --git a/src/frontend/routes/_pages/safari-error.tsx b/src/frontend/routes/_pages/safari-error.tsx index a38eb93e4..c863e7170 100644 --- a/src/frontend/routes/_pages/safari-error.tsx +++ b/src/frontend/routes/_pages/safari-error.tsx @@ -1,7 +1,7 @@ import type { JSX } from "react"; import { createFileRoute } from "@tanstack/react-router"; -import { OpenUrlButton } from "../../common/open-url-button"; -import { PageError } from "../../app-common/app-zero-state"; +import { OpenUrlButton } from "../../components/open-url-button"; +import { PageError } from "../../components/app-zero-state"; export const Route = createFileRoute("/_pages/safari-error")({ component: SafariError diff --git a/src/frontend/routes/app/library/$libraryId/groups/$groupId.tsx b/src/frontend/routes/app/library/$libraryId/groups/$groupId.tsx index 9386c2cf8..e778309c0 100644 --- a/src/frontend/routes/app/library/$libraryId/groups/$groupId.tsx +++ b/src/frontend/routes/app/library/$libraryId/groups/$groupId.tsx @@ -1,41 +1,37 @@ -import { useAccessData } from "../../../../../api-utils/access-level"; +import { useAccessData } from "../../../../../features/auth/access-level"; +import { AppTitle } from "../../../../../components/app-title"; import { createFileRoute, Outlet, useNavigate, useParams } from "@tanstack/react-router"; -import { Box, Button, Group, Text } from "@mantine/core"; -import { - IconAlertTriangle, - IconArrowBackUp, - IconArrowLeft -} from "@tabler/icons-react"; +import { Box, Button, Group } from "@mantine/core"; +import { ArrowLeft, ArrowUUpLeft, Warning } from "@phosphor-icons/react"; import { BORDER, - FontWeight, - IconColor, - IconSize -} from "../../../../../common/style-constants"; + IconSize, + SECTION_HEADER_HEIGHT +} from "../../../../../lib/style-constants"; import { ReactNode } from "react"; -import { SearchResults } from "../../../../../search/search-results"; -import { GroupOut, Insertables } from "../../../../../../shared/api-models"; -import { hasEditorAccess } from "../../../../../../shared/types"; -import { filterInsertables } from "../../../../../search/filter"; -import { GroupMenuItems } from "../../../../../groups/group-card"; -import { InsertableCard } from "../../../../../cards/insertable-card"; -import { ItemTable } from "../../../../../cards/card-components"; -import { AppContextMenu, MenuButton } from "../../../../../app-common/app-menu"; -import { SearchCallout } from "../../../../../search/search-errors"; +import { SearchResults } from "../../../../../features/search/components/search-results"; +import { GroupOut, Insertables } from "@backend/features/library/contract"; +import { hasEditorAccess } from "@backend/features/auth/access-level"; +import { filterInsertables } from "../../../../../features/search/filter"; +import { GroupMenuItems } from "../../../../../features/library/components/group-card"; +import { InsertableCard } from "../../../../../features/library/components/insertable-card"; +import { ItemTable } from "../../../../../features/library/components/card-components"; +import { AppContextMenu, MenuButton } from "../../../../../components/app-menu"; +import { SearchCallout } from "../../../../../features/search/components/search-errors"; import { PageError, SectionError, SectionLoading -} from "../../../../../app-common/app-zero-state"; -import { ClearFiltersButton } from "../../../../../settings/vendor-filters"; -import { useLibraryQuery } from "../../../../../queries"; -import { useLibraryId } from "../../../../../api-utils/library"; -import { useUiState, updateUiState } from "../../../../../api-utils/ui-state"; +} from "../../../../../components/app-zero-state"; +import { ClearFiltersButton } from "../../../../../features/settings/components/vendor-filters"; +import { useLibraryQuery } from "../../../../../features/library/queries"; +import { useLibraryId } from "../../../../../features/library/library-path"; +import { useUiState, updateUiState } from "../../../../../lib/ui-state"; export const Route = createFileRoute("/app/library/$libraryId/groups/$groupId")( { @@ -73,7 +69,7 @@ function GroupList(): ReactNode { justifyUp action={