diff --git a/.claude/settings.json b/.claude/settings.json index 26115c9..0293c1a 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,4 +1,14 @@ { + "permissions": { + "allow": [ + "Bash(DATABASE_URL=\"postgresql://wheretf:wheretf@localhost:5432/wheretf_test\" npx vitest run)", + "WebSearch", + "WebFetch(domain:forum.figma.com)" + ], + "additionalDirectories": [ + "/home/nick/projects/nickydoes/wheretf/web/app/api/modules" + ] + }, "hooks": { "PreToolUse": [ { @@ -11,14 +21,5 @@ ] } ] - }, - "permissions": { - "allow": [ - "Bash(DATABASE_URL=\"postgresql://wheretf:wheretf@localhost:5432/wheretf_test\" npx vitest run)", - "WebSearch" - ], - "additionalDirectories": [ - "/home/nick/projects/nickydoes/wheretf/web/app/api/modules" - ] } } diff --git a/.claude/skills/committing.md b/.claude/skills/committing.md deleted file mode 100644 index 5a08d28..0000000 --- a/.claude/skills/committing.md +++ /dev/null @@ -1,4 +0,0 @@ -# Commit Guidelines - -- Do not include Co-Authored-By lines or any AI/Claude attribution in commit messages. -- Keep commit messages concise and focused on the "why" of the change. diff --git a/specification/auth-roadmap.md b/specification/auth-roadmap.md new file mode 100644 index 0000000..aabd80e --- /dev/null +++ b/specification/auth-roadmap.md @@ -0,0 +1,55 @@ +# Auth & Multi-Tenancy Roadmap + +Ordered sequence for landing authentication, authorization, and +multi-tenancy in WhereTF. Each phase blocks the next. Detailed scope +lives in [deployment.md](deployment.md#future-work--todos-so-the-deploy-system-knows-whats-coming) +and in the approved implementation plan +(`/home/nick/.claude/plans/linear-crafting-feigenbaum.md`); this file +is the index and the agreed ordering. + +## External dependency — IdP + +The homelab team deploys and operates the OIDC identity provider +(Authentik / Keycloak / Zitadel, TBD). This is **out of scope for +WhereTF**. WhereTF consumes the IdP as an OIDC client via +`AUTH_HOMELAB_ISSUER` and credentials. Until the IdP is live, the +homelab OIDC provider is optional (only registered when env vars are +set); customer credentials and dev impersonate work without it. + +## Priority order (agreed) + +1. **Authentication** — Auth.js v5, hybrid providers (homelab IdP OIDC + for staff/admin, email+password credentials for customers, dev + impersonate gated on non-production). JWT sessions (Auth.js v5 + constraint when Credentials provider is in play). CSRF helper for + mutation routes. +2. **Authorization** — `orgs`, `user_orgs(role)` with roles + `owner | admin | member | viewer`. Per-request context hydrated by + middleware from session + active-org cookie. Enforcement + app-layer; Postgres RLS deferred until threat model justifies it. +3. **Multi-tenancy migration** — three-mode isolation model + (isolated / additive / open). Add `ownerOrgId` nullable → backfill + isolated tables to a default org → flip NOT NULL on isolated. + Additive tables keep nullable. Repo refactor so every method takes + `{ userId, orgId }`. Two-org isolation integration test per repo. +4. **Org switcher + signup UI** — signup creates user + default free + org. Org switcher in app shell. `/settings/org` for members / + invites / rename / transfer / delete. +5. **Plan gating** — `orgs.plan: 'free' | 'paid'` drives seat limit + and private-items gate. Billing integration and external API keys + are follow-on phases, not this roadmap. + +## Why this order + +- **1 → 2**: authorization needs an authenticated `userId` to scope by. +- **2 → 3**: the migration applies the authz model to existing data; + the model has to exist first. +- **3 → 4**: UI needs org context it can read and switch. +- **4 → 5**: plan gates check org plan at create/invite time; orgs + must exist and be switchable first. + +## Gate between phases + +Each phase produces an artifact reviewed before the next phase +starts. No combining phases, no parallel work across phases unless +explicitly agreed. diff --git a/specification/deployment.md b/specification/deployment.md index 00aa1e5..dbf999e 100644 --- a/specification/deployment.md +++ b/specification/deployment.md @@ -201,85 +201,123 @@ These are **planned, not implemented.** Deployment as described above works without them. When any one of them lands, this doc and the deployment system both get revisited. -### Identity provider on the homelab (TODO) +### Identity provider (external dependency) -The homelab currently has **no IdP deployed**. Before authentication -can ship, one has to be stood up and maintained. +The homelab team deploys and operates the OIDC IdP (Authentik / +Keycloak / Zitadel, TBD) — **out of scope for WhereTF**. WhereTF +consumes it as an OIDC client. The homelab OIDC provider is +registered only when `AUTH_HOMELAB_ISSUER`, `AUTH_HOMELAB_CLIENT_ID`, +and `AUTH_HOMELAB_CLIENT_SECRET` are set; customer credentials and +dev impersonate work without it. -Scope for that effort (separate project, separate plan cycle): -- Evaluate Authentik, Keycloak, Zitadel. Pick one. -- Deploy (Proxmox LXC or container), put behind Caddy, back with the - homelab Postgres VM or its own DB. -- Automate lifecycle with Ansible so it's reproducible. -- Add monitoring + backups alongside existing services. +Until the IdP is live, WhereTF runs with customer credentials only +and must not be internet-reachable. -Until that lands, WhereTF runs without auth and must not be internet- -reachable. +### Authentication -### Authentication (TODO) +Auth.js v5 (next-auth) in `web/`. Hybrid providers: -Depends on the IdP decision above. Plan: +- **Homelab IdP OIDC** — env-gated, for staff/admin federation. +- **Credentials** — email + password (bcryptjs), for customer + accounts. Signup endpoint creates a `users` row and a default owner + `orgs` row with `plan = 'free'`. +- **Dev impersonate** — registered only when + `NODE_ENV !== "production"`, lets any email through for local dev. -- **App side**: Auth.js (next-auth v5) with an OIDC provider. DB-backed - sessions — new `users`, `sessions` tables, users identified by - `(auth_provider, auth_subject)` for provider portability. -- **Dev**: local "impersonate" login gated on - `NODE_ENV !== "production"` so dev doesn't require the IdP to be - running. -- **CSRF**: Auth.js covers `/api/auth/*`; our mutation routes get a - shared helper. +**Session strategy: JWT.** Auth.js v5 requires JWT sessions when a +Credentials provider is in play. The JWT carries `sub` (user id); +per-request authz hydrates orgs and active role from the database. -### Authorization (TODO) +**CSRF**: Auth.js covers `/api/auth/*`; mutation routes call +`requireCsrf()` from `web/lib/auth/csrf.ts`. -Model: **org-scoped, role per user-org pair.** +### Authorization -- New tables: `orgs`, `user_orgs (user_id, org_id, role)`. - Roles: `owner | admin | member | viewer`. +Model: **org-scoped, role per user-org pair, three-mode data +isolation.** + +- New tables: + - `users`, `accounts`, `sessions`, `verification_tokens` (Auth.js) + - `orgs (id, name, slug, plan: 'free' | 'paid', ...)` + - `user_orgs (user_id, org_id, role)`, roles + `owner | admin | member | viewer`, PK `(user_id, org_id)` - Every authenticated request carries - `{ userId, currentOrgId, role }`, derived from session + an - "active org" cookie. -- **Per-org** tables: `modules`, `locations`, `inserts`, `assignments`, - `templates`, `template_versions`, `co_storability`. -- **Global** tables: `items`, `item_aspects`, `item_parameter_values`, - `aspects`, `parameters`, `standards`, `designations`, `categories`. - Items are deliberately shared — see project memory on the global - catalog vision. -- Enforcement starts application-layer (every repo method takes - `{ orgId }`), moves to Postgres RLS when the threat model justifies - the complexity. - -### API access + rate limiting (TODO) + `{ userId, activeOrgId, role }`, hydrated in `web/middleware.ts` + from the session + `wtf-active-org` cookie (fallback = user's + first org). +- Isolation mode per table: + - **isolated** — `ownerOrgId NOT NULL`, strict filter + `ownerOrgId = :orgId`. + Tables: `modules`, `inserts`, `locations`, `assignments`, + `location_interfaces_accepted`, `transactions`. + - **additive** — `ownerOrgId` nullable. Read union + `(ownerOrgId IS NULL OR ownerOrgId = :orgId)`. Writes default to + the active org; writing a global row (`ownerOrgId = NULL`) + requires elevated permission. + Tables: `templates`, `template_versions`, + `template_version_interfaces_provided`, + `template_version_interfaces_accepted`, `items`, `item_aspects`, + `item_parameter_values`, `item_categories`, `item_standards`, + `co_storability`, `categories`, `parameter_definitions`, + `aspects`, `aspect_parameters`, `standards`, + `standard_parameters`, `standard_designations`, + `aspect_standards`, `interface_types`. + - **open** — no `ownerOrgId`, global only. + Tables: none at launch. +- **Private items** are additive `items` rows with + `ownerOrgId IS NOT NULL`. Free tier rejects private inserts; paid + tier allows. No separate table, no visibility enum. +- **Global catalog edit policy**: any signed-in user may create or + edit global rows. Every write lands in `transactions` with actor + `userId`. No moderation UI at launch. +- Enforcement app-layer in repositories. Postgres RLS is a future + refinement, not scheduled. + +### Plan gating + +`orgs.plan` drives two gates at launch: + +1. **Seat limit** — free orgs reject a 2nd `user_orgs` insert. +2. **Private items** — free orgs reject `items` inserts with + `ownerOrgId != null`. + +Parametric metadata is in-app readable for any signed-in user, free +or paid. External API access (API keys) is paid-only; enforcement +ships with the API keys phase. + +Billing integration is a separate phase (product TBD). Until it +lands, `orgs.plan` is toggled by a platform admin (`users.is_admin`) +via an admin-only endpoint. + +### API access + rate limiting (future phase) Two surfaces: -1. **Internal** — session-cookie auth, CSRF for mutations. - Generous per-user limits (e.g. 60 rps burst, 300 rpm sustained). -2. **External** — API keys. New table `api_keys` with hashed keys, - scopes, per-key rate limits tied to the org's plan (subscription - hook). - -Limiter: token bucket, sliding window. In-memory in dev; Redis (or -similar) in prod. +1. **Internal** — session auth + CSRF for mutations. Generous + per-user limits (e.g. 60 rps burst, 300 rpm sustained). +2. **External** — `api_keys` table with hashed keys, scopes, per-key + rate limits tied to `orgs.plan`. Paid-only. -Middleware lives in `web/middleware.ts`, intercepts `/api/*`, runs -auth + rate-limit + org hydration before the route handler. Exempts -`/api/health*`. +Limiter: token bucket, sliding window. In-memory in dev; Redis in +prod. -### Multi-tenancy migration (TODO, depends on auth + authz) +Middleware in `web/middleware.ts` intercepts `/api/*`, runs auth + +rate-limit + org hydration before the route handler. Exempts +`/api/health*` and `/api/auth/*`. -Execution order when picked up: +### Multi-tenancy migration (execution order) -1. Migration adds `users`, `orgs`, `user_orgs`, `sessions`. Adds - nullable `org_id` on every per-org table; backfills existing rows - to a "default" org; follow-up migration flips NOT NULL. -2. Repo refactor: every org-scoped method takes `{ orgId }`. - Integration test with two orgs enforces isolation per repo. -3. API middleware populates request-local org context. -4. Org switcher UI. -5. Items stay global. Write-heavy catalog paths audit into the - existing `transactions` table. -6. `orgs.plan` → rate limits + feature flags. Stripe (or whatever) - webhook updates it. +1. Add `ownerOrgId uuid` nullable to every additive + isolated table + (no FK yet). +2. Create `users`, `accounts`, `sessions`, `verification_tokens`, + `orgs`, `user_orgs`. Insert a default org and assign the current + single user as `owner`. +3. Backfill isolated tables to the default org. Additive tables stay + `NULL` — they become the global catalog. +4. Add FK `ownerOrgId → orgs.id` on every touched table. Flip + `NOT NULL` on isolated tables. +5. Repo refactor: every method takes `{ userId, orgId, ... }`. +6. Two-org isolation integration test per repo. None of the above blocks the deploy work. Ship the image now; layer -auth + tenancy in when the IdP is ready. +auth + tenancy in when the org model lands. diff --git a/specification/project-intent.md b/specification/project-intent.md index a25ffed..c0a9d44 100644 --- a/specification/project-intent.md +++ b/specification/project-intent.md @@ -15,7 +15,7 @@ GUI and AI each own different concerns: ## Domain Concepts -- **Item** — what a thing *is*, independent of where it is. A type/category, not an instance or count. Thorough, structured item characterization is core to WhereTF — finding an item requires describing it well. Items are described by name and typed parameters organized by aspects (reusable parameter groups). See [item-taxonomy.md](item-taxonomy.md) for the classification system. Equivalent to a product in ERP. Items belong to WhereTF globally — as items are refined and improved, they benefit all users. Storage and assignments are per-org; items are shared. Future: private items as a paid feature. +- **Item** — what a thing *is*, independent of where it is. A type/category, not an instance or count. Thorough, structured item characterization is core to WhereTF — finding an item requires describing it well. Items are described by name and typed parameters organized by aspects (reusable parameter groups). See [item-taxonomy.md](item-taxonomy.md) for the classification system. Equivalent to a product in ERP. Items belong to WhereTF globally — as items are refined and improved, they benefit all users. Storage and assignments are per-org; items are shared. Private items (paid tier): an item row with `ownerOrgId` set is private to that org; free tier only creates global items. - **Assignment** — connects an item to a location. Own entity, not a field on item or location. Either *placed* (specific leaf location, one per location unless co-storable) or *provisional* (at a location, position undetermined). Many assignments per item. Unassigned items and empty locations are both valid states. - **Module** — a top-level, independent physical storage unit (cabinet, shelf, drawer unit). Never nested. Defines valid location path structures. - **Template** — versioned blueprint for a storage product's layout (e.g. Plano Stowaway 3600 = 4×6 grid). Applied via inserts (receptacle locations) or directly (fixed locations). Instances pin to an applied version. diff --git a/web/app/api/aspects/[id]/items/route.ts b/web/app/api/aspects/[id]/items/route.ts index 55033cd..ae346fa 100644 --- a/web/app/api/aspects/[id]/items/route.ts +++ b/web/app/api/aspects/[id]/items/route.ts @@ -1,24 +1,24 @@ import { NextRequest, NextResponse } from "next/server"; import { aspectRepository } from "@/repositories/aspectRepository"; +import { requireContext, errorResponse } from "@/lib/auth/route"; export async function GET( request: NextRequest, { params }: { params: Promise<{ id: string }> } ) { try { + const ctx = await requireContext(); const { id } = await params; const { searchParams } = new URL(request.url); const limitStr = searchParams.get("limit"); const limit = limitStr ? parseInt(limitStr, 10) : 50; const items = await aspectRepository.listItemsUsing({ + orgId: ctx.activeOrgId, aspectId: id, limit: Number.isFinite(limit) && limit > 0 ? limit : 50, }); return NextResponse.json({ items }); } catch (err) { - return NextResponse.json( - { error: err instanceof Error ? err.message : "Unexpected error" }, - { status: 500 } - ); + return errorResponse(err); } } diff --git a/web/app/api/aspects/[id]/parameters/route.ts b/web/app/api/aspects/[id]/parameters/route.ts index 2770749..7568430 100644 --- a/web/app/api/aspects/[id]/parameters/route.ts +++ b/web/app/api/aspects/[id]/parameters/route.ts @@ -1,19 +1,21 @@ import { NextRequest, NextResponse } from "next/server"; import { aspectRepository } from "@/repositories/aspectRepository"; +import { requireContext, errorResponse } from "@/lib/auth/route"; export async function GET( _request: NextRequest, { params }: { params: Promise<{ id: string }> }, ) { try { + const ctx = await requireContext(); const { id } = await params; - const parameters = await aspectRepository.getParameters({ aspectId: id }); + const parameters = await aspectRepository.getParameters({ + orgId: ctx.activeOrgId, + aspectId: id, + }); return NextResponse.json({ parameters }); } catch (err) { - return NextResponse.json( - { error: err instanceof Error ? err.message : "Unexpected error" }, - { status: 500 }, - ); + return errorResponse(err); } } @@ -22,19 +24,18 @@ export async function POST( { params }: { params: Promise<{ id: string }> }, ) { try { + const ctx = await requireContext(); const { id } = await params; const body = await request.json(); const parameter = await aspectRepository.addParameter({ + userId: ctx.userId, + orgId: ctx.activeOrgId, aspectId: id, ...body, }); return NextResponse.json({ parameter }, { status: 201 }); } catch (err) { - const message = err instanceof Error ? err.message : "Unexpected error"; - if (message.includes("not found")) { - return NextResponse.json({ error: message }, { status: 404 }); - } - return NextResponse.json({ error: message }, { status: 400 }); + return errorResponse(err); } } @@ -43,18 +44,16 @@ export async function DELETE( { params }: { params: Promise<{ id: string }> }, ) { try { + const ctx = await requireContext(); const { id } = await params; const { parameterDefinitionId } = await request.json(); await aspectRepository.removeParameter({ + orgId: ctx.activeOrgId, aspectId: id, parameterDefinitionId, }); return NextResponse.json({ success: true }); } catch (err) { - const message = err instanceof Error ? err.message : "Unexpected error"; - if (message.includes("not found")) { - return NextResponse.json({ error: message }, { status: 404 }); - } - return NextResponse.json({ error: message }, { status: 400 }); + return errorResponse(err); } } diff --git a/web/app/api/aspects/[id]/route.ts b/web/app/api/aspects/[id]/route.ts index 2683af0..74a43b5 100644 --- a/web/app/api/aspects/[id]/route.ts +++ b/web/app/api/aspects/[id]/route.ts @@ -1,19 +1,24 @@ import { NextRequest, NextResponse } from "next/server"; import { aspectRepository } from "@/repositories/aspectRepository"; +import { requireContext, errorResponse } from "@/lib/auth/route"; export async function GET( _request: NextRequest, { params }: { params: Promise<{ id: string }> }, ) { try { + const ctx = await requireContext(); const { id } = await params; - const aspect = await aspectRepository.findById({ id }); + const aspect = await aspectRepository.findById({ + orgId: ctx.activeOrgId, + id, + }); if (!aspect) { return NextResponse.json({ error: "Aspect not found" }, { status: 404 }); } const [usage, parameters] = await Promise.all([ - aspectRepository.getUsage({ aspectId: id }), - aspectRepository.getParameters({ aspectId: id }), + aspectRepository.getUsage({ orgId: ctx.activeOrgId, aspectId: id }), + aspectRepository.getParameters({ orgId: ctx.activeOrgId, aspectId: id }), ]); return NextResponse.json({ aspect, @@ -23,10 +28,7 @@ export async function GET( standardCount: usage.standardCount, }); } catch (err) { - return NextResponse.json( - { error: err instanceof Error ? err.message : "Unexpected error" }, - { status: 500 }, - ); + return errorResponse(err); } } @@ -35,16 +37,18 @@ export async function PATCH( { params }: { params: Promise<{ id: string }> }, ) { try { + const ctx = await requireContext(); const { id } = await params; const body = await request.json(); - const aspect = await aspectRepository.update({ id, ...body }); + const aspect = await aspectRepository.update({ + userId: ctx.userId, + orgId: ctx.activeOrgId, + id, + ...body, + }); return NextResponse.json({ aspect }); } catch (err) { - const message = err instanceof Error ? err.message : "Unexpected error"; - if (message.includes("not found")) { - return NextResponse.json({ error: message }, { status: 404 }); - } - return NextResponse.json({ error: message }, { status: 400 }); + return errorResponse(err); } } @@ -53,14 +57,15 @@ export async function DELETE( { params }: { params: Promise<{ id: string }> }, ) { try { + const ctx = await requireContext(); const { id } = await params; - await aspectRepository.remove({ id }); + await aspectRepository.remove({ + userId: ctx.userId, + orgId: ctx.activeOrgId, + id, + }); return NextResponse.json({ success: true }); } catch (err) { - const message = err instanceof Error ? err.message : "Unexpected error"; - if (message.includes("not found")) { - return NextResponse.json({ error: message }, { status: 404 }); - } - return NextResponse.json({ error: message }, { status: 400 }); + return errorResponse(err); } } diff --git a/web/app/api/aspects/[id]/suggested-parameters/route.ts b/web/app/api/aspects/[id]/suggested-parameters/route.ts index 04ef05b..2c093f1 100644 --- a/web/app/api/aspects/[id]/suggested-parameters/route.ts +++ b/web/app/api/aspects/[id]/suggested-parameters/route.ts @@ -1,24 +1,24 @@ import { NextRequest, NextResponse } from "next/server"; import { aspectRepository } from "@/repositories/aspectRepository"; +import { requireContext, errorResponse } from "@/lib/auth/route"; export async function GET( request: NextRequest, { params }: { params: Promise<{ id: string }> } ) { try { + const ctx = await requireContext(); const { id } = await params; const { searchParams } = new URL(request.url); const limitStr = searchParams.get("limit"); const limit = limitStr ? parseInt(limitStr, 10) : 5; const suggestions = await aspectRepository.suggestCoOccurringParameters({ + orgId: ctx.activeOrgId, aspectId: id, limit: Number.isFinite(limit) && limit > 0 ? limit : 5, }); return NextResponse.json({ suggestions }); } catch (err) { - return NextResponse.json( - { error: err instanceof Error ? err.message : "Unexpected error" }, - { status: 500 } - ); + return errorResponse(err); } } diff --git a/web/app/api/aspects/route.ts b/web/app/api/aspects/route.ts index 353b1d9..a5ff93b 100644 --- a/web/app/api/aspects/route.ts +++ b/web/app/api/aspects/route.ts @@ -1,27 +1,32 @@ import { NextRequest, NextResponse } from "next/server"; import { aspectRepository } from "@/repositories/aspectRepository"; +import { requireContext, errorResponse } from "@/lib/auth/route"; export async function GET() { try { - const aspects = await aspectRepository.listWithUsage(); + const ctx = await requireContext(); + const aspects = await aspectRepository.listWithUsage({ + orgId: ctx.activeOrgId, + }); return NextResponse.json({ aspects }); } catch (err) { - return NextResponse.json( - { error: err instanceof Error ? err.message : "Unexpected error" }, - { status: 500 }, - ); + return errorResponse(err); } } export async function POST(request: NextRequest) { try { + const ctx = await requireContext(); const body = await request.json(); - const aspect = await aspectRepository.create(body); + const { asGlobal, ...rest } = body; + const aspect = await aspectRepository.create({ + userId: ctx.userId, + orgId: ctx.activeOrgId, + asGlobal: Boolean(asGlobal), + ...rest, + }); return NextResponse.json({ aspect }, { status: 201 }); } catch (err) { - return NextResponse.json( - { error: err instanceof Error ? err.message : "Unexpected error" }, - { status: 400 }, - ); + return errorResponse(err); } } diff --git a/web/app/api/assignments/[id]/convert/route.ts b/web/app/api/assignments/[id]/convert/route.ts index 051f56f..5f47a93 100644 --- a/web/app/api/assignments/[id]/convert/route.ts +++ b/web/app/api/assignments/[id]/convert/route.ts @@ -1,26 +1,27 @@ import { NextRequest, NextResponse } from "next/server"; import { assignmentRepository } from "@/repositories/assignmentRepository"; +import { requireContext, errorResponse } from "@/lib/auth/route"; export async function POST( request: NextRequest, { params }: { params: Promise<{ id: string }> }, ) { try { + const ctx = await requireContext(); const { id } = await params; const body = await request.json(); const assignment = await assignmentRepository.convertToPlaced({ + userId: ctx.userId, + orgId: ctx.activeOrgId, id, locationId: body.locationId, }); return NextResponse.json({ assignment }); } catch (err) { const message = err instanceof Error ? err.message : "Unexpected error"; - if (message.includes("not found")) { - return NextResponse.json({ error: message }, { status: 404 }); - } if (message.includes("not co-storable")) { return NextResponse.json({ error: message }, { status: 409 }); } - return NextResponse.json({ error: message }, { status: 400 }); + return errorResponse(err); } } diff --git a/web/app/api/assignments/[id]/move/route.ts b/web/app/api/assignments/[id]/move/route.ts index 88390eb..0376493 100644 --- a/web/app/api/assignments/[id]/move/route.ts +++ b/web/app/api/assignments/[id]/move/route.ts @@ -1,26 +1,27 @@ import { NextRequest, NextResponse } from "next/server"; import { assignmentRepository } from "@/repositories/assignmentRepository"; +import { requireContext, errorResponse } from "@/lib/auth/route"; export async function POST( request: NextRequest, { params }: { params: Promise<{ id: string }> }, ) { try { + const ctx = await requireContext(); const { id } = await params; const body = await request.json(); const assignment = await assignmentRepository.move({ + userId: ctx.userId, + orgId: ctx.activeOrgId, id, newLocationId: body.locationId, }); return NextResponse.json({ assignment }); } catch (err) { const message = err instanceof Error ? err.message : "Unexpected error"; - if (message.includes("not found")) { - return NextResponse.json({ error: message }, { status: 404 }); - } if (message.includes("not co-storable")) { return NextResponse.json({ error: message }, { status: 409 }); } - return NextResponse.json({ error: message }, { status: 400 }); + return errorResponse(err); } } diff --git a/web/app/api/assignments/[id]/route.ts b/web/app/api/assignments/[id]/route.ts index d1554f1..335b030 100644 --- a/web/app/api/assignments/[id]/route.ts +++ b/web/app/api/assignments/[id]/route.ts @@ -1,13 +1,18 @@ import { NextRequest, NextResponse } from "next/server"; import { assignmentRepository } from "@/repositories/assignmentRepository"; +import { requireContext, errorResponse } from "@/lib/auth/route"; export async function GET( _request: NextRequest, { params }: { params: Promise<{ id: string }> }, ) { try { + const ctx = await requireContext(); const { id } = await params; - const assignment = await assignmentRepository.findById({ id }); + const assignment = await assignmentRepository.findById({ + orgId: ctx.activeOrgId, + id, + }); if (!assignment) { return NextResponse.json( { error: "Assignment not found" }, @@ -16,10 +21,7 @@ export async function GET( } return NextResponse.json({ assignment }); } catch (err) { - return NextResponse.json( - { error: err instanceof Error ? err.message : "Unexpected error" }, - { status: 500 }, - ); + return errorResponse(err); } } @@ -28,14 +30,15 @@ export async function DELETE( { params }: { params: Promise<{ id: string }> }, ) { try { + const ctx = await requireContext(); const { id } = await params; - await assignmentRepository.remove({ id }); + await assignmentRepository.remove({ + userId: ctx.userId, + orgId: ctx.activeOrgId, + id, + }); return NextResponse.json({ success: true }); } catch (err) { - const message = err instanceof Error ? err.message : "Unexpected error"; - if (message.includes("not found")) { - return NextResponse.json({ error: message }, { status: 404 }); - } - return NextResponse.json({ error: message }, { status: 400 }); + return errorResponse(err); } } diff --git a/web/app/api/assignments/route.ts b/web/app/api/assignments/route.ts index ddf3150..ff6d32e 100644 --- a/web/app/api/assignments/route.ts +++ b/web/app/api/assignments/route.ts @@ -1,24 +1,32 @@ import { NextRequest, NextResponse } from "next/server"; import { assignmentRepository } from "@/repositories/assignmentRepository"; +import { requireContext, errorResponse } from "@/lib/auth/route"; export async function GET(request: NextRequest) { try { + const ctx = await requireContext(); const itemId = request.nextUrl.searchParams.get("itemId"); const locationId = request.nextUrl.searchParams.get("locationId"); const provisional = request.nextUrl.searchParams.get("provisional"); if (itemId) { - const assignments = await assignmentRepository.findByItemId({ itemId }); + const assignments = await assignmentRepository.findByItemId({ + orgId: ctx.activeOrgId, + itemId, + }); return NextResponse.json({ assignments }); } if (locationId) { const assignments = await assignmentRepository.findByLocationId({ + orgId: ctx.activeOrgId, locationId, }); return NextResponse.json({ assignments }); } if (provisional === "true") { - const assignments = await assignmentRepository.listProvisional(); + const assignments = await assignmentRepository.listProvisional({ + orgId: ctx.activeOrgId, + }); return NextResponse.json({ assignments }); } @@ -30,23 +38,28 @@ export async function GET(request: NextRequest) { { status: 400 }, ); } catch (err) { - return NextResponse.json( - { error: err instanceof Error ? err.message : "Unexpected error" }, - { status: 500 }, - ); + return errorResponse(err); } } export async function POST(request: NextRequest) { try { + const ctx = await requireContext(); const body = await request.json(); - const assignment = await assignmentRepository.create(body); + const assignment = await assignmentRepository.create({ + userId: ctx.userId, + orgId: ctx.activeOrgId, + itemId: body.itemId, + locationId: body.locationId, + assignmentType: body.assignmentType, + metadata: body.metadata, + }); return NextResponse.json({ assignment }, { status: 201 }); } catch (err) { const message = err instanceof Error ? err.message : "Unexpected error"; if (message.includes("not co-storable")) { return NextResponse.json({ error: message }, { status: 409 }); } - return NextResponse.json({ error: message }, { status: 400 }); + return errorResponse(err); } } diff --git a/web/app/api/auth/[...nextauth]/route.ts b/web/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..737ffc0 --- /dev/null +++ b/web/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,3 @@ +import { handlers } from "@/lib/auth/config"; + +export const { GET, POST } = handlers; diff --git a/web/app/api/categories/[id]/items/route.ts b/web/app/api/categories/[id]/items/route.ts index 1174585..9b8c58f 100644 --- a/web/app/api/categories/[id]/items/route.ts +++ b/web/app/api/categories/[id]/items/route.ts @@ -1,24 +1,24 @@ import { NextRequest, NextResponse } from "next/server"; import { categoryRepository } from "@/repositories/categoryRepository"; +import { requireContext, errorResponse } from "@/lib/auth/route"; export async function GET( request: NextRequest, { params }: { params: Promise<{ id: string }> } ) { try { + const ctx = await requireContext(); const { id } = await params; const { searchParams } = new URL(request.url); const limitStr = searchParams.get("limit"); const limit = limitStr ? parseInt(limitStr, 10) : 50; const items = await categoryRepository.listItems({ + orgId: ctx.activeOrgId, categoryId: id, limit: Number.isFinite(limit) && limit > 0 ? limit : 50, }); return NextResponse.json({ items }); } catch (err) { - return NextResponse.json( - { error: err instanceof Error ? err.message : "Unexpected error" }, - { status: 500 } - ); + return errorResponse(err); } } diff --git a/web/app/api/categories/[id]/route.ts b/web/app/api/categories/[id]/route.ts index 005ddda..05e9306 100644 --- a/web/app/api/categories/[id]/route.ts +++ b/web/app/api/categories/[id]/route.ts @@ -1,22 +1,24 @@ import { NextRequest, NextResponse } from "next/server"; import { categoryRepository } from "@/repositories/categoryRepository"; +import { requireContext, errorResponse } from "@/lib/auth/route"; export async function GET( _request: NextRequest, { params }: { params: Promise<{ id: string }> }, ) { try { + const ctx = await requireContext(); const { id } = await params; - const category = await categoryRepository.findById({ id }); + const category = await categoryRepository.findById({ + orgId: ctx.activeOrgId, + id, + }); if (!category) { return NextResponse.json({ error: "Category not found" }, { status: 404 }); } return NextResponse.json({ category }); } catch (err) { - return NextResponse.json( - { error: err instanceof Error ? err.message : "Unexpected error" }, - { status: 500 }, - ); + return errorResponse(err); } } @@ -25,16 +27,18 @@ export async function PATCH( { params }: { params: Promise<{ id: string }> }, ) { try { + const ctx = await requireContext(); const { id } = await params; const body = await request.json(); - const category = await categoryRepository.update({ id, ...body }); + const category = await categoryRepository.update({ + userId: ctx.userId, + orgId: ctx.activeOrgId, + id, + ...body, + }); return NextResponse.json({ category }); } catch (err) { - const message = err instanceof Error ? err.message : "Unexpected error"; - if (message.includes("not found")) { - return NextResponse.json({ error: message }, { status: 404 }); - } - return NextResponse.json({ error: message }, { status: 400 }); + return errorResponse(err); } } @@ -43,14 +47,15 @@ export async function DELETE( { params }: { params: Promise<{ id: string }> }, ) { try { + const ctx = await requireContext(); const { id } = await params; - await categoryRepository.remove({ id }); + await categoryRepository.remove({ + userId: ctx.userId, + orgId: ctx.activeOrgId, + id, + }); return NextResponse.json({ success: true }); } catch (err) { - const message = err instanceof Error ? err.message : "Unexpected error"; - if (message.includes("not found")) { - return NextResponse.json({ error: message }, { status: 404 }); - } - return NextResponse.json({ error: message }, { status: 400 }); + return errorResponse(err); } } diff --git a/web/app/api/categories/counts/route.ts b/web/app/api/categories/counts/route.ts index 0b0c58b..5ab064b 100644 --- a/web/app/api/categories/counts/route.ts +++ b/web/app/api/categories/counts/route.ts @@ -1,8 +1,10 @@ import { NextRequest, NextResponse } from "next/server"; import { itemRepository } from "@/repositories/itemRepository"; +import { requireContext, errorResponse } from "@/lib/auth/route"; export async function GET(request: NextRequest) { try { + const ctx = await requireContext(); const params = request.nextUrl.searchParams; const q = params.get("q") || undefined; @@ -27,15 +29,13 @@ export async function GET(request: NextRequest) { } const categories = await itemRepository.getCategoryCounts({ + orgId: ctx.activeOrgId, query: q, filters, }); return NextResponse.json({ categories }); } catch (err) { - return NextResponse.json( - { error: err instanceof Error ? err.message : "Unexpected error" }, - { status: 500 }, - ); + return errorResponse(err); } } diff --git a/web/app/api/categories/route.ts b/web/app/api/categories/route.ts index 4ec7e17..edc8992 100644 --- a/web/app/api/categories/route.ts +++ b/web/app/api/categories/route.ts @@ -1,27 +1,32 @@ import { NextRequest, NextResponse } from "next/server"; import { categoryRepository } from "@/repositories/categoryRepository"; +import { requireContext, errorResponse } from "@/lib/auth/route"; export async function GET() { try { - const categories = await categoryRepository.listWithUsage(); + const ctx = await requireContext(); + const categories = await categoryRepository.listWithUsage({ + orgId: ctx.activeOrgId, + }); return NextResponse.json({ categories }); } catch (err) { - return NextResponse.json( - { error: err instanceof Error ? err.message : "Unexpected error" }, - { status: 500 }, - ); + return errorResponse(err); } } export async function POST(request: NextRequest) { try { + const ctx = await requireContext(); const body = await request.json(); - const category = await categoryRepository.create(body); + const { asGlobal, ...rest } = body; + const category = await categoryRepository.create({ + userId: ctx.userId, + orgId: ctx.activeOrgId, + asGlobal: Boolean(asGlobal), + ...rest, + }); return NextResponse.json({ category }, { status: 201 }); } catch (err) { - return NextResponse.json( - { error: err instanceof Error ? err.message : "Unexpected error" }, - { status: 400 }, - ); + return errorResponse(err); } } diff --git a/web/app/api/inserts/[id]/compatible-receptacles/route.ts b/web/app/api/inserts/[id]/compatible-receptacles/route.ts index 95772aa..d7fe301 100644 --- a/web/app/api/inserts/[id]/compatible-receptacles/route.ts +++ b/web/app/api/inserts/[id]/compatible-receptacles/route.ts @@ -1,18 +1,19 @@ import { NextRequest, NextResponse } from "next/server"; import { insertRepository } from "@/repositories/insertRepository"; +import { requireContext, errorResponse } from "@/lib/auth/route"; type RouteParams = { params: Promise<{ id: string }> }; export async function GET(_request: NextRequest, { params }: RouteParams) { try { + const ctx = await requireContext(); const { id } = await params; - const receptacles = await insertRepository.listCompatibleReceptacles({ id }); + const receptacles = await insertRepository.listCompatibleReceptacles({ + orgId: ctx.activeOrgId, + id, + }); return NextResponse.json({ receptacles }); } catch (err) { - const message = err instanceof Error ? err.message : "Unexpected error"; - if (message.includes("not found")) { - return NextResponse.json({ error: message }, { status: 404 }); - } - return NextResponse.json({ error: message }, { status: 500 }); + return errorResponse(err); } } diff --git a/web/app/api/inserts/[id]/place/route.ts b/web/app/api/inserts/[id]/place/route.ts index 066c63f..fa48ec1 100644 --- a/web/app/api/inserts/[id]/place/route.ts +++ b/web/app/api/inserts/[id]/place/route.ts @@ -1,27 +1,24 @@ import { NextRequest, NextResponse } from "next/server"; import { insertRepository } from "@/repositories/insertRepository"; +import { requireContext, errorResponse } from "@/lib/auth/route"; export async function POST( request: NextRequest, { params }: { params: Promise<{ id: string }> }, ) { try { + const ctx = await requireContext(); const { id } = await params; const body = await request.json(); const insert = await insertRepository.place({ + userId: ctx.userId, + orgId: ctx.activeOrgId, id, locationId: body.locationId, }); return NextResponse.json({ insert }); } catch (err) { - const message = err instanceof Error ? err.message : "Unexpected error"; - if (message.includes("not found")) { - return NextResponse.json({ error: message }, { status: 404 }); - } - if (message.includes("mismatch") || message.includes("not a receptacle")) { - return NextResponse.json({ error: message }, { status: 409 }); - } - return NextResponse.json({ error: message }, { status: 400 }); + return errorResponse(err); } } @@ -30,14 +27,15 @@ export async function DELETE( { params }: { params: Promise<{ id: string }> }, ) { try { + const ctx = await requireContext(); const { id } = await params; - const insert = await insertRepository.removeFromLocation({ id }); + const insert = await insertRepository.removeFromLocation({ + userId: ctx.userId, + orgId: ctx.activeOrgId, + id, + }); return NextResponse.json({ insert }); } catch (err) { - const message = err instanceof Error ? err.message : "Unexpected error"; - if (message.includes("not found")) { - return NextResponse.json({ error: message }, { status: 404 }); - } - return NextResponse.json({ error: message }, { status: 400 }); + return errorResponse(err); } } diff --git a/web/app/api/inserts/[id]/route.ts b/web/app/api/inserts/[id]/route.ts index 4a36510..343e157 100644 --- a/web/app/api/inserts/[id]/route.ts +++ b/web/app/api/inserts/[id]/route.ts @@ -1,13 +1,18 @@ import { NextRequest, NextResponse } from "next/server"; import { insertRepository } from "@/repositories/insertRepository"; +import { requireContext, errorResponse } from "@/lib/auth/route"; export async function GET( _request: NextRequest, { params }: { params: Promise<{ id: string }> }, ) { try { + const ctx = await requireContext(); const { id } = await params; - const insert = await insertRepository.findById({ id }); + const insert = await insertRepository.findById({ + orgId: ctx.activeOrgId, + id, + }); if (!insert) { return NextResponse.json( { error: "Insert not found" }, @@ -16,10 +21,7 @@ export async function GET( } return NextResponse.json({ insert }); } catch (err) { - return NextResponse.json( - { error: err instanceof Error ? err.message : "Unexpected error" }, - { status: 500 }, - ); + return errorResponse(err); } } @@ -28,16 +30,18 @@ export async function PATCH( { params }: { params: Promise<{ id: string }> }, ) { try { + const ctx = await requireContext(); const { id } = await params; const body = await request.json(); - const insert = await insertRepository.update({ id, ...body }); + const insert = await insertRepository.update({ + userId: ctx.userId, + orgId: ctx.activeOrgId, + id, + ...body, + }); return NextResponse.json({ insert }); } catch (err) { - const message = err instanceof Error ? err.message : "Unexpected error"; - if (message.includes("not found")) { - return NextResponse.json({ error: message }, { status: 404 }); - } - return NextResponse.json({ error: message }, { status: 400 }); + return errorResponse(err); } } @@ -46,14 +50,15 @@ export async function DELETE( { params }: { params: Promise<{ id: string }> }, ) { try { + const ctx = await requireContext(); const { id } = await params; - await insertRepository.remove({ id }); + await insertRepository.remove({ + userId: ctx.userId, + orgId: ctx.activeOrgId, + id, + }); return NextResponse.json({ success: true }); } catch (err) { - const message = err instanceof Error ? err.message : "Unexpected error"; - if (message.includes("not found")) { - return NextResponse.json({ error: message }, { status: 404 }); - } - return NextResponse.json({ error: message }, { status: 400 }); + return errorResponse(err); } } diff --git a/web/app/api/inserts/place-with-children/route.ts b/web/app/api/inserts/place-with-children/route.ts index f70cd49..dbe5334 100644 --- a/web/app/api/inserts/place-with-children/route.ts +++ b/web/app/api/inserts/place-with-children/route.ts @@ -1,9 +1,11 @@ import { NextRequest, NextResponse } from "next/server"; import { insertRepository } from "@/repositories/insertRepository"; import { locationRepository } from "@/repositories/locationRepository"; +import { requireContext, errorResponse } from "@/lib/auth/route"; import { db } from "@/db/connection"; import { locations } from "@/db/schema"; -import { eq } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; +import { isolatedOrgFilter } from "@/lib/auth/scope"; /** * Compound operation retained for API compatibility: @@ -13,6 +15,7 @@ import { eq } from "drizzle-orm"; */ export async function POST(request: NextRequest) { try { + const ctx = await requireContext(); const body = await request.json(); const { templateId, templateVersionId, locationId, name, rows, columns } = body; @@ -24,7 +27,10 @@ export async function POST(request: NextRequest) { ); } - const parent = await locationRepository.findById({ id: locationId }); + const parent = await locationRepository.findById({ + orgId: ctx.activeOrgId, + id: locationId, + }); if (!parent) { return NextResponse.json( { error: "Location not found" }, @@ -33,6 +39,8 @@ export async function POST(request: NextRequest) { } const insert = await insertRepository.create({ + userId: ctx.userId, + orgId: ctx.activeOrgId, name, templateId, templateVersionId, @@ -40,19 +48,28 @@ export async function POST(request: NextRequest) { columns, }); - await insertRepository.place({ id: insert.id, locationId }); + await insertRepository.place({ + userId: ctx.userId, + orgId: ctx.activeOrgId, + id: insert.id, + locationId, + }); const cells = await db .select() .from(locations) - .where(eq(locations.insertId, insert.id)); + .where( + and( + isolatedOrgFilter(locations.ownerOrgId, ctx.activeOrgId), + eq(locations.insertId, insert.id), + ), + ); return NextResponse.json( { insert, locations: cells }, { status: 201 } ); } catch (err) { - const message = err instanceof Error ? err.message : "Unknown error"; - return NextResponse.json({ error: message }, { status: 400 }); + return errorResponse(err); } } diff --git a/web/app/api/inserts/route.ts b/web/app/api/inserts/route.ts index f955751..74463e8 100644 --- a/web/app/api/inserts/route.ts +++ b/web/app/api/inserts/route.ts @@ -1,13 +1,17 @@ import { NextRequest, NextResponse } from "next/server"; import { insertRepository } from "@/repositories/insertRepository"; +import { requireContext, errorResponse } from "@/lib/auth/route"; export async function GET(request: NextRequest) { try { + const ctx = await requireContext(); const params = request.nextUrl.searchParams; // Legacy: ?unplaced=true returns raw list without joins. if (params.get("unplaced") === "true") { - const inserts = await insertRepository.listUnplaced(); + const inserts = await insertRepository.listUnplaced({ + orgId: ctx.activeOrgId, + }); return NextResponse.json({ inserts }); } @@ -18,6 +22,7 @@ export async function GET(request: NextRequest) { : "all"; const inserts = await insertRepository.listWithDetails({ + orgId: ctx.activeOrgId, templateId: params.get("templateId") ?? undefined, interfaceTypeId: params.get("interfaceTypeId") ?? undefined, moduleId: params.get("moduleId") ?? undefined, @@ -25,22 +30,21 @@ export async function GET(request: NextRequest) { }); return NextResponse.json({ inserts }); } catch (err) { - return NextResponse.json( - { error: err instanceof Error ? err.message : "Unexpected error" }, - { status: 500 }, - ); + return errorResponse(err); } } export async function POST(request: NextRequest) { try { + const ctx = await requireContext(); const body = await request.json(); - const insert = await insertRepository.create(body); + const insert = await insertRepository.create({ + userId: ctx.userId, + orgId: ctx.activeOrgId, + ...body, + }); return NextResponse.json({ insert }, { status: 201 }); } catch (err) { - return NextResponse.json( - { error: err instanceof Error ? err.message : "Unexpected error" }, - { status: 400 }, - ); + return errorResponse(err); } } diff --git a/web/app/api/interface-types/[id]/archive/route.ts b/web/app/api/interface-types/[id]/archive/route.ts index a860fc0..a9d38d0 100644 --- a/web/app/api/interface-types/[id]/archive/route.ts +++ b/web/app/api/interface-types/[id]/archive/route.ts @@ -1,19 +1,21 @@ import { NextRequest, NextResponse } from "next/server"; import { interfaceTypeRepository } from "@/repositories/interfaceTypeRepository"; +import { requireContext, errorResponse } from "@/lib/auth/route"; export async function POST( _request: NextRequest, { params }: { params: Promise<{ id: string }> }, ) { try { + const ctx = await requireContext(); const { id } = await params; - const interfaceType = await interfaceTypeRepository.archive({ id }); + const interfaceType = await interfaceTypeRepository.archive({ + userId: ctx.userId, + orgId: ctx.activeOrgId, + id, + }); return NextResponse.json({ interfaceType }); } catch (err) { - const message = err instanceof Error ? err.message : "Unexpected error"; - if (message.includes("not found")) { - return NextResponse.json({ error: message }, { status: 404 }); - } - return NextResponse.json({ error: message }, { status: 400 }); + return errorResponse(err); } } diff --git a/web/app/api/interface-types/[id]/route.ts b/web/app/api/interface-types/[id]/route.ts index 7a557ab..80f1218 100644 --- a/web/app/api/interface-types/[id]/route.ts +++ b/web/app/api/interface-types/[id]/route.ts @@ -1,26 +1,31 @@ import { NextRequest, NextResponse } from "next/server"; import { interfaceTypeRepository } from "@/repositories/interfaceTypeRepository"; +import { requireContext, errorResponse } from "@/lib/auth/route"; export async function GET( _request: NextRequest, { params }: { params: Promise<{ id: string }> }, ) { try { + const ctx = await requireContext(); const { id } = await params; - const interfaceType = await interfaceTypeRepository.findById({ id }); + const interfaceType = await interfaceTypeRepository.findById({ + orgId: ctx.activeOrgId, + id, + }); if (!interfaceType) { return NextResponse.json( { error: "Interface type not found" }, { status: 404 }, ); } - const usage = await interfaceTypeRepository.usageCount({ id }); + const usage = await interfaceTypeRepository.usageCount({ + orgId: ctx.activeOrgId, + id, + }); return NextResponse.json({ interfaceType, usage }); } catch (err) { - return NextResponse.json( - { error: err instanceof Error ? err.message : "Unexpected error" }, - { status: 500 }, - ); + return errorResponse(err); } } @@ -29,24 +34,22 @@ export async function PATCH( { params }: { params: Promise<{ id: string }> }, ) { try { + const ctx = await requireContext(); const { id } = await params; const body = await request.json(); const interfaceType = await interfaceTypeRepository.update({ + userId: ctx.userId, + orgId: ctx.activeOrgId, id, ...body, }); return NextResponse.json({ interfaceType }); } catch (err) { const message = err instanceof Error ? err.message : "Unexpected error"; - if (message.includes("not found")) { - return NextResponse.json({ error: message }, { status: 404 }); - } - // Demotion stable→draft + other invariants return 409 conflict so - // clients can distinguish rule violations from bad input. if (message.includes("terminal") || message.includes("one-way")) { return NextResponse.json({ error: message }, { status: 409 }); } - return NextResponse.json({ error: message }, { status: 400 }); + return errorResponse(err); } } @@ -55,18 +58,19 @@ export async function DELETE( { params }: { params: Promise<{ id: string }> }, ) { try { + const ctx = await requireContext(); const { id } = await params; - await interfaceTypeRepository.remove({ id }); + await interfaceTypeRepository.remove({ + userId: ctx.userId, + orgId: ctx.activeOrgId, + id, + }); return NextResponse.json({ success: true }); } catch (err) { const message = err instanceof Error ? err.message : "Unexpected error"; - if (message.includes("not found")) { - return NextResponse.json({ error: message }, { status: 404 }); - } - // Archive-gate + usage-gate return 409. if (message.includes("not archived") || message.includes("in use")) { return NextResponse.json({ error: message }, { status: 409 }); } - return NextResponse.json({ error: message }, { status: 400 }); + return errorResponse(err); } } diff --git a/web/app/api/interface-types/[id]/unarchive/route.ts b/web/app/api/interface-types/[id]/unarchive/route.ts index f404ac6..afea807 100644 --- a/web/app/api/interface-types/[id]/unarchive/route.ts +++ b/web/app/api/interface-types/[id]/unarchive/route.ts @@ -1,19 +1,21 @@ import { NextRequest, NextResponse } from "next/server"; import { interfaceTypeRepository } from "@/repositories/interfaceTypeRepository"; +import { requireContext, errorResponse } from "@/lib/auth/route"; export async function POST( _request: NextRequest, { params }: { params: Promise<{ id: string }> }, ) { try { + const ctx = await requireContext(); const { id } = await params; - const interfaceType = await interfaceTypeRepository.unarchive({ id }); + const interfaceType = await interfaceTypeRepository.unarchive({ + userId: ctx.userId, + orgId: ctx.activeOrgId, + id, + }); return NextResponse.json({ interfaceType }); } catch (err) { - const message = err instanceof Error ? err.message : "Unexpected error"; - if (message.includes("not found")) { - return NextResponse.json({ error: message }, { status: 404 }); - } - return NextResponse.json({ error: message }, { status: 400 }); + return errorResponse(err); } } diff --git a/web/app/api/interface-types/merge/route.ts b/web/app/api/interface-types/merge/route.ts index fb1b585..5ccafcc 100644 --- a/web/app/api/interface-types/merge/route.ts +++ b/web/app/api/interface-types/merge/route.ts @@ -1,8 +1,10 @@ import { NextRequest, NextResponse } from "next/server"; import { interfaceTypeRepository } from "@/repositories/interfaceTypeRepository"; +import { requireContext, errorResponse } from "@/lib/auth/route"; export async function POST(request: NextRequest) { try { + const ctx = await requireContext(); const body = await request.json(); const { sourceIds, targetId } = body ?? {}; if (!Array.isArray(sourceIds) || typeof targetId !== "string") { @@ -12,18 +14,17 @@ export async function POST(request: NextRequest) { ); } const result = await interfaceTypeRepository.merge({ + userId: ctx.userId, + orgId: ctx.activeOrgId, sourceIds, targetId, }); return NextResponse.json(result); } catch (err) { const message = err instanceof Error ? err.message : "Unexpected error"; - if (/target.*not found|source not found/i.test(message)) { - return NextResponse.json({ error: message }, { status: 404 }); - } if (/target.*source|at least one sourceId/i.test(message)) { return NextResponse.json({ error: message }, { status: 409 }); } - return NextResponse.json({ error: message }, { status: 400 }); + return errorResponse(err); } } diff --git a/web/app/api/interface-types/route.ts b/web/app/api/interface-types/route.ts index 4b46393..4c99539 100644 --- a/web/app/api/interface-types/route.ts +++ b/web/app/api/interface-types/route.ts @@ -1,34 +1,38 @@ import { NextRequest, NextResponse } from "next/server"; import { interfaceTypeRepository } from "@/repositories/interfaceTypeRepository"; +import { requireContext, errorResponse } from "@/lib/auth/route"; export async function GET(request: NextRequest) { try { + const ctx = await requireContext(); const status = request.nextUrl.searchParams.get("status"); const validStatuses = new Set(["active", "archived", "all"]); const filterStatus = validStatuses.has(status ?? "") ? (status as "active" | "archived" | "all") : "all"; const interfaceTypes = await interfaceTypeRepository.list({ + orgId: ctx.activeOrgId, status: filterStatus, }); return NextResponse.json({ interfaceTypes }); } catch (err) { - return NextResponse.json( - { error: err instanceof Error ? err.message : "Unexpected error" }, - { status: 500 }, - ); + return errorResponse(err); } } export async function POST(request: NextRequest) { try { + const ctx = await requireContext(); const body = await request.json(); - const interfaceType = await interfaceTypeRepository.create(body); + const { asGlobal, ...rest } = body ?? {}; + const interfaceType = await interfaceTypeRepository.create({ + userId: ctx.userId, + orgId: ctx.activeOrgId, + asGlobal: Boolean(asGlobal), + ...rest, + }); return NextResponse.json({ interfaceType }, { status: 201 }); } catch (err) { - return NextResponse.json( - { error: err instanceof Error ? err.message : "Unexpected error" }, - { status: 400 }, - ); + return errorResponse(err); } } diff --git a/web/app/api/items/[id]/aspects/route.ts b/web/app/api/items/[id]/aspects/route.ts index e8a439f..5e9b90b 100644 --- a/web/app/api/items/[id]/aspects/route.ts +++ b/web/app/api/items/[id]/aspects/route.ts @@ -1,19 +1,21 @@ import { NextRequest, NextResponse } from "next/server"; import { itemRepository } from "@/repositories/itemRepository"; +import { requireContext, errorResponse } from "@/lib/auth/route"; export async function GET( _request: NextRequest, { params }: { params: Promise<{ id: string }> }, ) { try { + const ctx = await requireContext(); const { id } = await params; - const aspects = await itemRepository.getAspects({ itemId: id }); + const aspects = await itemRepository.getAspects({ + orgId: ctx.activeOrgId, + itemId: id, + }); return NextResponse.json({ aspects }); } catch (err) { - return NextResponse.json( - { error: err instanceof Error ? err.message : "Unexpected error" }, - { status: 500 }, - ); + return errorResponse(err); } } @@ -22,19 +24,17 @@ export async function POST( { params }: { params: Promise<{ id: string }> }, ) { try { + const ctx = await requireContext(); const { id } = await params; const { aspectId } = await request.json(); const itemAspect = await itemRepository.applyAspect({ + orgId: ctx.activeOrgId, itemId: id, aspectId, }); return NextResponse.json({ itemAspect }, { status: 201 }); } catch (err) { - const message = err instanceof Error ? err.message : "Unexpected error"; - if (message.includes("not found")) { - return NextResponse.json({ error: message }, { status: 404 }); - } - return NextResponse.json({ error: message }, { status: 400 }); + return errorResponse(err); } } @@ -43,15 +43,16 @@ export async function DELETE( { params }: { params: Promise<{ id: string }> }, ) { try { + const ctx = await requireContext(); const { id } = await params; const { aspectId } = await request.json(); - await itemRepository.removeAspect({ itemId: id, aspectId }); + await itemRepository.removeAspect({ + orgId: ctx.activeOrgId, + itemId: id, + aspectId, + }); return NextResponse.json({ success: true }); } catch (err) { - const message = err instanceof Error ? err.message : "Unexpected error"; - if (message.includes("not applied")) { - return NextResponse.json({ error: message }, { status: 404 }); - } - return NextResponse.json({ error: message }, { status: 400 }); + return errorResponse(err); } } diff --git a/web/app/api/items/[id]/categories/route.ts b/web/app/api/items/[id]/categories/route.ts index caafbaf..dae52d6 100644 --- a/web/app/api/items/[id]/categories/route.ts +++ b/web/app/api/items/[id]/categories/route.ts @@ -1,19 +1,21 @@ import { NextRequest, NextResponse } from "next/server"; import { itemRepository } from "@/repositories/itemRepository"; +import { requireContext, errorResponse } from "@/lib/auth/route"; export async function GET( _request: NextRequest, { params }: { params: Promise<{ id: string }> }, ) { try { + const ctx = await requireContext(); const { id } = await params; - const categories = await itemRepository.getCategories({ itemId: id }); + const categories = await itemRepository.getCategories({ + orgId: ctx.activeOrgId, + itemId: id, + }); return NextResponse.json({ categories }); } catch (err) { - return NextResponse.json( - { error: err instanceof Error ? err.message : "Unexpected error" }, - { status: 500 }, - ); + return errorResponse(err); } } @@ -22,19 +24,18 @@ export async function POST( { params }: { params: Promise<{ id: string }> }, ) { try { + const ctx = await requireContext(); const { id } = await params; const body = await request.json(); const itemCategory = await itemRepository.addCategory({ + orgId: ctx.activeOrgId, itemId: id, - ...body, + categoryId: body.categoryId, + isPrimary: body.isPrimary, }); return NextResponse.json({ itemCategory }, { status: 201 }); } catch (err) { - const message = err instanceof Error ? err.message : "Unexpected error"; - if (message.includes("not found")) { - return NextResponse.json({ error: message }, { status: 404 }); - } - return NextResponse.json({ error: message }, { status: 400 }); + return errorResponse(err); } } @@ -43,15 +44,16 @@ export async function DELETE( { params }: { params: Promise<{ id: string }> }, ) { try { + const ctx = await requireContext(); const { id } = await params; const { categoryId } = await request.json(); - await itemRepository.removeCategory({ itemId: id, categoryId }); + await itemRepository.removeCategory({ + orgId: ctx.activeOrgId, + itemId: id, + categoryId, + }); return NextResponse.json({ success: true }); } catch (err) { - const message = err instanceof Error ? err.message : "Unexpected error"; - if (message.includes("not on item")) { - return NextResponse.json({ error: message }, { status: 404 }); - } - return NextResponse.json({ error: message }, { status: 400 }); + return errorResponse(err); } } diff --git a/web/app/api/items/[id]/co-storable/route.ts b/web/app/api/items/[id]/co-storable/route.ts index 2cd2397..8639487 100644 --- a/web/app/api/items/[id]/co-storable/route.ts +++ b/web/app/api/items/[id]/co-storable/route.ts @@ -1,19 +1,21 @@ import { NextRequest, NextResponse } from "next/server"; import { itemRepository } from "@/repositories/itemRepository"; +import { requireContext, errorResponse } from "@/lib/auth/route"; export async function GET( _request: NextRequest, { params }: { params: Promise<{ id: string }> }, ) { try { + const ctx = await requireContext(); const { id } = await params; - const items = await itemRepository.getCoStorableItems({ itemId: id }); + const items = await itemRepository.getCoStorableItems({ + orgId: ctx.activeOrgId, + itemId: id, + }); return NextResponse.json({ items }); } catch (err) { - return NextResponse.json( - { error: err instanceof Error ? err.message : "Unexpected error" }, - { status: 500 }, - ); + return errorResponse(err); } } @@ -22,19 +24,19 @@ export async function POST( { params }: { params: Promise<{ id: string }> }, ) { try { + const ctx = await requireContext(); const { id } = await params; const body = await request.json(); await itemRepository.addCoStorability({ + userId: ctx.userId, + orgId: ctx.activeOrgId, itemAId: id, itemBId: body.itemId, reason: body.reason, }); return NextResponse.json({ success: true }, { status: 201 }); } catch (err) { - return NextResponse.json( - { error: err instanceof Error ? err.message : "Unexpected error" }, - { status: 400 }, - ); + return errorResponse(err); } } @@ -43,18 +45,17 @@ export async function DELETE( { params }: { params: Promise<{ id: string }> }, ) { try { + const ctx = await requireContext(); const { id } = await params; const body = await request.json(); await itemRepository.removeCoStorability({ + userId: ctx.userId, + orgId: ctx.activeOrgId, itemAId: id, itemBId: body.itemId, }); return NextResponse.json({ success: true }); } catch (err) { - const message = err instanceof Error ? err.message : "Unexpected error"; - if (message.includes("not found")) { - return NextResponse.json({ error: message }, { status: 404 }); - } - return NextResponse.json({ error: message }, { status: 400 }); + return errorResponse(err); } } diff --git a/web/app/api/items/[id]/parameters/route.ts b/web/app/api/items/[id]/parameters/route.ts index 9b0e342..c79ac0b 100644 --- a/web/app/api/items/[id]/parameters/route.ts +++ b/web/app/api/items/[id]/parameters/route.ts @@ -1,19 +1,21 @@ import { NextRequest, NextResponse } from "next/server"; import { itemRepository } from "@/repositories/itemRepository"; +import { requireContext, errorResponse } from "@/lib/auth/route"; export async function GET( _request: NextRequest, { params }: { params: Promise<{ id: string }> }, ) { try { + const ctx = await requireContext(); const { id } = await params; - const parameters = await itemRepository.getParameterValues({ itemId: id }); + const parameters = await itemRepository.getParameterValues({ + orgId: ctx.activeOrgId, + itemId: id, + }); return NextResponse.json({ parameters }); } catch (err) { - return NextResponse.json( - { error: err instanceof Error ? err.message : "Unexpected error" }, - { status: 500 }, - ); + return errorResponse(err); } } @@ -22,17 +24,18 @@ export async function POST( { params }: { params: Promise<{ id: string }> }, ) { try { + const ctx = await requireContext(); const { id } = await params; const body = await request.json(); const parameter = await itemRepository.setParameterValue({ + orgId: ctx.activeOrgId, itemId: id, - ...body, + parameterDefinitionId: body.parameterDefinitionId, + itemAspectId: body.itemAspectId, + value: body.value, }); return NextResponse.json({ parameter }, { status: 201 }); } catch (err) { - return NextResponse.json( - { error: err instanceof Error ? err.message : "Unexpected error" }, - { status: 400 }, - ); + return errorResponse(err); } } diff --git a/web/app/api/items/[id]/route.ts b/web/app/api/items/[id]/route.ts index 27f72d8..7323d36 100644 --- a/web/app/api/items/[id]/route.ts +++ b/web/app/api/items/[id]/route.ts @@ -1,22 +1,24 @@ import { NextRequest, NextResponse } from "next/server"; import { itemRepository } from "@/repositories/itemRepository"; +import { requireContext, errorResponse } from "@/lib/auth/route"; export async function GET( _request: NextRequest, { params }: { params: Promise<{ id: string }> }, ) { try { + const ctx = await requireContext(); const { id } = await params; - const item = await itemRepository.findById({ id }); + const item = await itemRepository.findById({ + orgId: ctx.activeOrgId, + id, + }); if (!item) { return NextResponse.json({ error: "Item not found" }, { status: 404 }); } return NextResponse.json({ item }); } catch (err) { - return NextResponse.json( - { error: err instanceof Error ? err.message : "Unexpected error" }, - { status: 500 }, - ); + return errorResponse(err); } } @@ -25,16 +27,18 @@ export async function PATCH( { params }: { params: Promise<{ id: string }> }, ) { try { + const ctx = await requireContext(); const { id } = await params; const body = await request.json(); - const item = await itemRepository.update({ id, ...body }); + const item = await itemRepository.update({ + userId: ctx.userId, + orgId: ctx.activeOrgId, + id, + ...body, + }); return NextResponse.json({ item }); } catch (err) { - const message = err instanceof Error ? err.message : "Unexpected error"; - if (message.includes("not found")) { - return NextResponse.json({ error: message }, { status: 404 }); - } - return NextResponse.json({ error: message }, { status: 400 }); + return errorResponse(err); } } @@ -43,14 +47,15 @@ export async function DELETE( { params }: { params: Promise<{ id: string }> }, ) { try { + const ctx = await requireContext(); const { id } = await params; - await itemRepository.remove({ id }); + await itemRepository.remove({ + userId: ctx.userId, + orgId: ctx.activeOrgId, + id, + }); return NextResponse.json({ success: true }); } catch (err) { - const message = err instanceof Error ? err.message : "Unexpected error"; - if (message.includes("not found")) { - return NextResponse.json({ error: message }, { status: 404 }); - } - return NextResponse.json({ error: message }, { status: 400 }); + return errorResponse(err); } } diff --git a/web/app/api/items/[id]/standards/route.ts b/web/app/api/items/[id]/standards/route.ts index 4b75830..521d3b6 100644 --- a/web/app/api/items/[id]/standards/route.ts +++ b/web/app/api/items/[id]/standards/route.ts @@ -1,19 +1,21 @@ import { NextRequest, NextResponse } from "next/server"; import { standardRepository } from "@/repositories/standardRepository"; +import { requireContext, errorResponse } from "@/lib/auth/route"; export async function GET( _request: NextRequest, { params }: { params: Promise<{ id: string }> } ) { try { + const ctx = await requireContext(); const { id } = await params; - const standards = await standardRepository.getItemStandards({ itemId: id }); + const standards = await standardRepository.getItemStandards({ + orgId: ctx.activeOrgId, + itemId: id, + }); return NextResponse.json({ standards }); - } catch (error: unknown) { - return NextResponse.json( - { error: error instanceof Error ? error.message : "Unknown error" }, - { status: 500 } - ); + } catch (err) { + return errorResponse(err); } } @@ -22,6 +24,7 @@ export async function POST( { params }: { params: Promise<{ id: string }> } ) { try { + const ctx = await requireContext(); const { id } = await params; const body = await request.json(); const { standardId, designationId } = body; @@ -34,17 +37,16 @@ export async function POST( } const itemStandard = await standardRepository.applyToItem({ + userId: ctx.userId, + orgId: ctx.activeOrgId, itemId: id, standardId, designationId, }); return NextResponse.json({ itemStandard }, { status: 201 }); - } catch (error: unknown) { - return NextResponse.json( - { error: error instanceof Error ? error.message : "Unknown error" }, - { status: 500 } - ); + } catch (err) { + return errorResponse(err); } } @@ -53,6 +55,7 @@ export async function PATCH( { params }: { params: Promise<{ id: string }> } ) { try { + const ctx = await requireContext(); const { id } = await params; const body = await request.json(); const { standardId, designationId } = body; @@ -65,17 +68,15 @@ export async function PATCH( } const updated = await standardRepository.setDesignation({ + orgId: ctx.activeOrgId, itemId: id, standardId, designationId, }); return NextResponse.json({ itemStandard: updated }); - } catch (error: unknown) { - return NextResponse.json( - { error: error instanceof Error ? error.message : "Unknown error" }, - { status: 500 } - ); + } catch (err) { + return errorResponse(err); } } @@ -84,6 +85,7 @@ export async function DELETE( { params }: { params: Promise<{ id: string }> } ) { try { + const ctx = await requireContext(); const { id } = await params; const { searchParams } = new URL(request.url); const standardId = searchParams.get("standardId"); @@ -95,12 +97,14 @@ export async function DELETE( ); } - await standardRepository.removeFromItem({ itemId: id, standardId }); + await standardRepository.removeFromItem({ + userId: ctx.userId, + orgId: ctx.activeOrgId, + itemId: id, + standardId, + }); return NextResponse.json({ success: true }); - } catch (error: unknown) { - return NextResponse.json( - { error: error instanceof Error ? error.message : "Unknown error" }, - { status: 500 } - ); + } catch (err) { + return errorResponse(err); } } diff --git a/web/app/api/items/find-similar/route.ts b/web/app/api/items/find-similar/route.ts index e7d9435..117c4a9 100644 --- a/web/app/api/items/find-similar/route.ts +++ b/web/app/api/items/find-similar/route.ts @@ -1,26 +1,26 @@ import { NextRequest, NextResponse } from "next/server"; import { itemRepository } from "@/repositories/itemRepository"; +import { requireContext, errorResponse } from "@/lib/auth/route"; export async function GET(request: NextRequest) { try { + const ctx = await requireContext(); const { searchParams } = new URL(request.url); const standardId = searchParams.get("standardId"); const designationId = searchParams.get("designationId"); if (!standardId || !designationId) { return NextResponse.json( { error: "standardId and designationId are required" }, - { status: 400 } + { status: 400 }, ); } const candidates = await itemRepository.findSimilar({ + orgId: ctx.activeOrgId, standardId, designationId, }); return NextResponse.json({ candidates }); } catch (err) { - return NextResponse.json( - { error: err instanceof Error ? err.message : "Unknown error" }, - { status: 500 } - ); + return errorResponse(err); } } diff --git a/web/app/api/items/route.ts b/web/app/api/items/route.ts index a566a4b..8ec141a 100644 --- a/web/app/api/items/route.ts +++ b/web/app/api/items/route.ts @@ -1,8 +1,10 @@ import { NextRequest, NextResponse } from "next/server"; import { itemRepository } from "@/repositories/itemRepository"; +import { requireContext, errorResponse } from "@/lib/auth/route"; export async function GET(request: NextRequest) { try { + const ctx = await requireContext(); const params = request.nextUrl.searchParams; const q = params.get("q") || undefined; const categoryId = params.get("category") || undefined; @@ -29,6 +31,7 @@ export async function GET(request: NextRequest) { } const result = await itemRepository.listRich({ + orgId: ctx.activeOrgId, query: q, filters, categoryId, @@ -38,22 +41,30 @@ export async function GET(request: NextRequest) { return NextResponse.json(result); } catch (err) { - return NextResponse.json( - { error: err instanceof Error ? err.message : "Unexpected error" }, - { status: 500 }, - ); + return errorResponse(err); } } export async function POST(request: NextRequest) { try { + const ctx = await requireContext(); const body = await request.json(); - const item = await itemRepository.create(body); + const { name, description, metadata, asGlobal } = body; + + if (!name) { + return NextResponse.json({ error: "name is required" }, { status: 400 }); + } + + const item = await itemRepository.create({ + userId: ctx.userId, + orgId: ctx.activeOrgId, + asGlobal: Boolean(asGlobal), + name, + description, + metadata, + }); return NextResponse.json({ item }, { status: 201 }); } catch (err) { - return NextResponse.json( - { error: err instanceof Error ? err.message : "Unexpected error" }, - { status: 400 }, - ); + return errorResponse(err); } } diff --git a/web/app/api/items/suggest-categories/route.ts b/web/app/api/items/suggest-categories/route.ts index 754e02d..d5141be 100644 --- a/web/app/api/items/suggest-categories/route.ts +++ b/web/app/api/items/suggest-categories/route.ts @@ -1,8 +1,10 @@ import { NextRequest, NextResponse } from "next/server"; import { itemRepository } from "@/repositories/itemRepository"; +import { requireContext, errorResponse } from "@/lib/auth/route"; export async function GET(request: NextRequest) { try { + const ctx = await requireContext(); const { searchParams } = new URL(request.url); const aspectIds = searchParams.getAll("aspectId"); const standardIds = searchParams.getAll("standardId"); @@ -10,6 +12,7 @@ export async function GET(request: NextRequest) { const limit = limitStr ? parseInt(limitStr, 10) : 3; const suggestions = await itemRepository.suggestCategories({ + orgId: ctx.activeOrgId, aspectIds, standardIds, limit: Number.isFinite(limit) && limit > 0 ? limit : 3, @@ -17,9 +20,6 @@ export async function GET(request: NextRequest) { return NextResponse.json({ suggestions }); } catch (err) { - return NextResponse.json( - { error: err instanceof Error ? err.message : "Unknown error" }, - { status: 500 } - ); + return errorResponse(err); } } diff --git a/web/app/api/locations/[id]/disable/route.ts b/web/app/api/locations/[id]/disable/route.ts index 05a69d1..1a2102d 100644 --- a/web/app/api/locations/[id]/disable/route.ts +++ b/web/app/api/locations/[id]/disable/route.ts @@ -1,14 +1,18 @@ import { NextRequest, NextResponse } from "next/server"; import { locationRepository } from "@/repositories/locationRepository"; +import { requireContext } from "@/lib/auth/route"; export async function POST( request: NextRequest, { params }: { params: Promise<{ id: string }> }, ) { try { + const ctx = await requireContext(); const { id } = await params; const body = await request.json().catch(() => ({})); const location = await locationRepository.disable({ + userId: ctx.userId, + orgId: ctx.activeOrgId, id, reason: body.reason, }); @@ -30,8 +34,13 @@ export async function DELETE( { params }: { params: Promise<{ id: string }> }, ) { try { + const ctx = await requireContext(); const { id } = await params; - const location = await locationRepository.enable({ id }); + const location = await locationRepository.enable({ + userId: ctx.userId, + orgId: ctx.activeOrgId, + id, + }); return NextResponse.json({ location }); } catch (err) { const message = err instanceof Error ? err.message : "Unexpected error"; diff --git a/web/app/api/locations/[id]/divide/route.ts b/web/app/api/locations/[id]/divide/route.ts index cbc13c8..6c5716a 100644 --- a/web/app/api/locations/[id]/divide/route.ts +++ b/web/app/api/locations/[id]/divide/route.ts @@ -1,10 +1,12 @@ import { NextRequest, NextResponse } from "next/server"; import { locationRepository } from "@/repositories/locationRepository"; +import { requireContext } from "@/lib/auth/route"; type RouteParams = { params: Promise<{ id: string }> }; export async function POST(request: NextRequest, { params }: RouteParams) { try { + const ctx = await requireContext(); const { id } = await params; const body = await request.json(); const { labels, source } = body ?? {}; @@ -15,6 +17,8 @@ export async function POST(request: NextRequest, { params }: RouteParams) { ); } const children = await locationRepository.divide({ + userId: ctx.userId, + orgId: ctx.activeOrgId, parentId: id, labels, source, @@ -40,8 +44,13 @@ export async function POST(request: NextRequest, { params }: RouteParams) { export async function DELETE(_request: NextRequest, { params }: RouteParams) { try { + const ctx = await requireContext(); const { id } = await params; - const result = await locationRepository.undivide({ parentId: id }); + const result = await locationRepository.undivide({ + userId: ctx.userId, + orgId: ctx.activeOrgId, + parentId: id, + }); return NextResponse.json({ undivide: result }); } catch (err) { const message = err instanceof Error ? err.message : "Unexpected error"; diff --git a/web/app/api/locations/[id]/restrict/route.ts b/web/app/api/locations/[id]/restrict/route.ts index 26746cd..0af777e 100644 --- a/web/app/api/locations/[id]/restrict/route.ts +++ b/web/app/api/locations/[id]/restrict/route.ts @@ -1,14 +1,18 @@ import { NextRequest, NextResponse } from "next/server"; import { locationRepository } from "@/repositories/locationRepository"; +import { requireContext } from "@/lib/auth/route"; type RouteParams = { params: Promise<{ id: string }> }; export async function PATCH(request: NextRequest, { params }: RouteParams) { try { + const ctx = await requireContext(); const { id } = await params; const body = await request.json().catch(() => ({})); const { maxWidthMm, maxHeightMm, maxDepthMm, reason } = body ?? {}; const location = await locationRepository.restrict({ + userId: ctx.userId, + orgId: ctx.activeOrgId, id, maxWidthMm: normalizeNum(maxWidthMm), maxHeightMm: normalizeNum(maxHeightMm), @@ -30,8 +34,13 @@ export async function DELETE( { params }: RouteParams ) { try { + const ctx = await requireContext(); const { id } = await params; - const location = await locationRepository.clearRestrict({ id }); + const location = await locationRepository.clearRestrict({ + userId: ctx.userId, + orgId: ctx.activeOrgId, + id, + }); return NextResponse.json({ location }); } catch (err) { const message = err instanceof Error ? err.message : "Unexpected error"; diff --git a/web/app/api/locations/[id]/route.ts b/web/app/api/locations/[id]/route.ts index 6f968f1..8c8e7d5 100644 --- a/web/app/api/locations/[id]/route.ts +++ b/web/app/api/locations/[id]/route.ts @@ -1,13 +1,18 @@ import { NextRequest, NextResponse } from "next/server"; import { locationRepository } from "@/repositories/locationRepository"; +import { requireContext, errorResponse } from "@/lib/auth/route"; export async function GET( _request: NextRequest, { params }: { params: Promise<{ id: string }> }, ) { try { + const ctx = await requireContext(); const { id } = await params; - const location = await locationRepository.findById({ id }); + const location = await locationRepository.findById({ + orgId: ctx.activeOrgId, + id, + }); if (!location) { return NextResponse.json( { error: "Location not found" }, @@ -15,16 +20,14 @@ export async function GET( ); } const interfacesAccepted = await locationRepository.getAcceptedInterfaces({ + orgId: ctx.activeOrgId, locationId: id, }); return NextResponse.json({ location: { ...location, interfacesAccepted }, }); } catch (err) { - return NextResponse.json( - { error: err instanceof Error ? err.message : "Unexpected error" }, - { status: 500 }, - ); + return errorResponse(err); } } @@ -33,13 +36,19 @@ export async function PATCH( { params }: { params: Promise<{ id: string }> }, ) { try { + const ctx = await requireContext(); const { id } = await params; const body = await request.json(); const { interfacesAcceptedIds, ...rest } = body ?? {}; const hasCoreUpdates = Object.keys(rest).length > 0; const location = hasCoreUpdates - ? await locationRepository.update({ id, ...rest }) - : await locationRepository.findById({ id }); + ? await locationRepository.update({ + userId: ctx.userId, + orgId: ctx.activeOrgId, + id, + ...rest, + }) + : await locationRepository.findById({ orgId: ctx.activeOrgId, id }); if (!location) { return NextResponse.json( { error: "Location not found" }, @@ -48,11 +57,14 @@ export async function PATCH( } if (Array.isArray(interfacesAcceptedIds)) { await locationRepository.setAcceptedInterfaces({ + userId: ctx.userId, + orgId: ctx.activeOrgId, locationId: id, interfaceTypeIds: interfacesAcceptedIds, }); } const interfacesAccepted = await locationRepository.getAcceptedInterfaces({ + orgId: ctx.activeOrgId, locationId: id, }); return NextResponse.json({ @@ -72,8 +84,13 @@ export async function DELETE( { params }: { params: Promise<{ id: string }> }, ) { try { + const ctx = await requireContext(); const { id } = await params; - await locationRepository.remove({ id }); + await locationRepository.remove({ + userId: ctx.userId, + orgId: ctx.activeOrgId, + id, + }); return NextResponse.json({ success: true }); } catch (err) { const message = err instanceof Error ? err.message : "Unexpected error"; diff --git a/web/app/api/locations/[id]/unmerge/route.ts b/web/app/api/locations/[id]/unmerge/route.ts index 1373df6..ee02cf3 100644 --- a/web/app/api/locations/[id]/unmerge/route.ts +++ b/web/app/api/locations/[id]/unmerge/route.ts @@ -1,12 +1,18 @@ import { NextRequest, NextResponse } from "next/server"; import { locationRepository } from "@/repositories/locationRepository"; +import { requireContext } from "@/lib/auth/route"; type RouteParams = { params: Promise<{ id: string }> }; export async function POST(_request: NextRequest, { params }: RouteParams) { try { + const ctx = await requireContext(); const { id } = await params; - const result = await locationRepository.unmerge({ originId: id }); + const result = await locationRepository.unmerge({ + userId: ctx.userId, + orgId: ctx.activeOrgId, + originId: id, + }); return NextResponse.json({ unmerge: result }); } catch (err) { const message = err instanceof Error ? err.message : "Unexpected error"; diff --git a/web/app/api/locations/merge/route.ts b/web/app/api/locations/merge/route.ts index 847237b..41bb012 100644 --- a/web/app/api/locations/merge/route.ts +++ b/web/app/api/locations/merge/route.ts @@ -1,8 +1,10 @@ import { NextRequest, NextResponse } from "next/server"; import { locationRepository } from "@/repositories/locationRepository"; +import { requireContext } from "@/lib/auth/route"; export async function POST(request: NextRequest) { try { + const ctx = await requireContext(); const body = await request.json(); const { originId, aliasIds } = body ?? {}; if (!originId || !Array.isArray(aliasIds)) { @@ -11,7 +13,12 @@ export async function POST(request: NextRequest) { { status: 400 } ); } - const result = await locationRepository.merge({ originId, aliasIds }); + const result = await locationRepository.merge({ + userId: ctx.userId, + orgId: ctx.activeOrgId, + originId, + aliasIds, + }); return NextResponse.json({ merge: result }); } catch (err) { const message = err instanceof Error ? err.message : "Unexpected error"; diff --git a/web/app/api/locations/route.ts b/web/app/api/locations/route.ts index 8ba7fe6..2560407 100644 --- a/web/app/api/locations/route.ts +++ b/web/app/api/locations/route.ts @@ -1,15 +1,23 @@ import { NextRequest, NextResponse } from "next/server"; import { locationRepository } from "@/repositories/locationRepository"; +import { requireContext, errorResponse } from "@/lib/auth/route"; export async function GET(request: NextRequest) { try { + const ctx = await requireContext(); const moduleId = request.nextUrl.searchParams.get("moduleId"); const insertId = request.nextUrl.searchParams.get("insertId"); let locations; if (insertId) { - locations = await locationRepository.findByInsertId({ insertId }); + locations = await locationRepository.findByInsertId({ + orgId: ctx.activeOrgId, + insertId, + }); } else if (moduleId) { - locations = await locationRepository.findByModuleId({ moduleId }); + locations = await locationRepository.findByModuleId({ + orgId: ctx.activeOrgId, + moduleId, + }); } else { return NextResponse.json( { error: "moduleId or insertId query parameter is required" }, @@ -19,7 +27,7 @@ export async function GET(request: NextRequest) { // Attach accepted interfaces per location (batched). const ids = locations.map((l) => l.id); const ifaceMap = await locationRepository.getAcceptedInterfacesByLocationIds( - { locationIds: ids }, + { orgId: ctx.activeOrgId, locationIds: ids }, ); return NextResponse.json({ locations: locations.map((l) => ({ @@ -28,22 +36,21 @@ export async function GET(request: NextRequest) { })), }); } catch (err) { - return NextResponse.json( - { error: err instanceof Error ? err.message : "Unexpected error" }, - { status: 500 }, - ); + return errorResponse(err); } } export async function POST(request: NextRequest) { try { + const ctx = await requireContext(); const body = await request.json(); - const location = await locationRepository.create(body); + const location = await locationRepository.create({ + userId: ctx.userId, + orgId: ctx.activeOrgId, + ...body, + }); return NextResponse.json({ location }, { status: 201 }); } catch (err) { - return NextResponse.json( - { error: err instanceof Error ? err.message : "Unexpected error" }, - { status: 400 }, - ); + return errorResponse(err); } } diff --git a/web/app/api/modules/[id]/route.ts b/web/app/api/modules/[id]/route.ts index 40fa27f..b20ebaa 100644 --- a/web/app/api/modules/[id]/route.ts +++ b/web/app/api/modules/[id]/route.ts @@ -1,29 +1,33 @@ import { NextRequest, NextResponse } from "next/server"; import { moduleRepository } from "@/repositories/moduleRepository"; +import { requireContext, errorResponse } from "@/lib/auth/route"; type RouteParams = { params: Promise<{ id: string }> }; export async function GET(_request: NextRequest, { params }: RouteParams) { try { + const ctx = await requireContext(); const { id } = await params; - const mod = await moduleRepository.findById({ id }); + const mod = await moduleRepository.findById({ orgId: ctx.activeOrgId, id }); if (!mod) { return NextResponse.json({ error: "Module not found" }, { status: 404 }); } return NextResponse.json({ module: mod }); } catch (err) { - const message = err instanceof Error ? err.message : "Unknown error"; - return NextResponse.json({ error: message }, { status: 500 }); + return errorResponse(err); } } export async function PATCH(request: NextRequest, { params }: RouteParams) { try { + const ctx = await requireContext(); const { id } = await params; const body = await request.json(); const { name, description, primaryDimensionLabel, primaryDimensionCount, metadata } = body; const mod = await moduleRepository.update({ + userId: ctx.userId, + orgId: ctx.activeOrgId, id, name, description, @@ -34,29 +38,30 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) { return NextResponse.json({ module: mod }); } catch (err) { - const message = err instanceof Error ? err.message : "Unknown error"; - if (message.includes("not found")) { - return NextResponse.json({ error: message }, { status: 404 }); - } - return NextResponse.json({ error: message }, { status: 500 }); + return errorResponse(err); } } export async function DELETE(request: NextRequest, { params }: RouteParams) { try { + const ctx = await requireContext(); const { id } = await params; const cascade = request.nextUrl.searchParams.get("cascade") === "true"; if (cascade) { - const stats = await moduleRepository.removeWithCascade({ id }); + const stats = await moduleRepository.removeWithCascade({ + userId: ctx.userId, + orgId: ctx.activeOrgId, + id, + }); return NextResponse.json({ success: true, stats }); } - await moduleRepository.remove({ id }); + await moduleRepository.remove({ + userId: ctx.userId, + orgId: ctx.activeOrgId, + id, + }); return NextResponse.json({ success: true }); } catch (err) { - const message = err instanceof Error ? err.message : "Unknown error"; - if (message.includes("not found")) { - return NextResponse.json({ error: message }, { status: 404 }); - } - return NextResponse.json({ error: message }, { status: 500 }); + return errorResponse(err); } } diff --git a/web/app/api/modules/[id]/stats/route.ts b/web/app/api/modules/[id]/stats/route.ts index 6328a35..e034c38 100644 --- a/web/app/api/modules/[id]/stats/route.ts +++ b/web/app/api/modules/[id]/stats/route.ts @@ -1,18 +1,19 @@ import { NextRequest, NextResponse } from "next/server"; import { moduleRepository } from "@/repositories/moduleRepository"; +import { requireContext, errorResponse } from "@/lib/auth/route"; type RouteParams = { params: Promise<{ id: string }> }; export async function GET(_request: NextRequest, { params }: RouteParams) { try { + const ctx = await requireContext(); const { id } = await params; - const stats = await moduleRepository.getStats({ id }); + const stats = await moduleRepository.getStats({ + orgId: ctx.activeOrgId, + id, + }); return NextResponse.json({ stats }); } catch (err) { - const message = err instanceof Error ? err.message : "Unknown error"; - if (message.includes("not found")) { - return NextResponse.json({ error: message }, { status: 404 }); - } - return NextResponse.json({ error: message }, { status: 500 }); + return errorResponse(err); } } diff --git a/web/app/api/modules/route.ts b/web/app/api/modules/route.ts index f3a1be9..c4a8b8a 100644 --- a/web/app/api/modules/route.ts +++ b/web/app/api/modules/route.ts @@ -1,18 +1,20 @@ import { NextRequest, NextResponse } from "next/server"; import { moduleRepository } from "@/repositories/moduleRepository"; +import { requireContext, errorResponse } from "@/lib/auth/route"; export async function GET() { try { - const modules = await moduleRepository.list(); + const ctx = await requireContext(); + const modules = await moduleRepository.list({ orgId: ctx.activeOrgId }); return NextResponse.json({ modules }); } catch (err) { - const message = err instanceof Error ? err.message : "Unknown error"; - return NextResponse.json({ error: message }, { status: 500 }); + return errorResponse(err); } } export async function POST(request: NextRequest) { try { + const ctx = await requireContext(); const body = await request.json(); const { name, description, primaryDimensionLabel, primaryDimensionCount, metadata } = body; @@ -24,6 +26,8 @@ export async function POST(request: NextRequest) { } const mod = await moduleRepository.create({ + userId: ctx.userId, + orgId: ctx.activeOrgId, name, description, primaryDimensionLabel, @@ -33,7 +37,6 @@ export async function POST(request: NextRequest) { return NextResponse.json({ module: mod }, { status: 201 }); } catch (err) { - const message = err instanceof Error ? err.message : "Unknown error"; - return NextResponse.json({ error: message }, { status: 500 }); + return errorResponse(err); } } diff --git a/web/app/api/parameter-definitions/[id]/route.ts b/web/app/api/parameter-definitions/[id]/route.ts index 6d5afbd..4328324 100644 --- a/web/app/api/parameter-definitions/[id]/route.ts +++ b/web/app/api/parameter-definitions/[id]/route.ts @@ -1,22 +1,27 @@ import { NextRequest, NextResponse } from "next/server"; import { parameterDefinitionRepository } from "@/repositories/parameterDefinitionRepository"; +import { requireContext, errorResponse } from "@/lib/auth/route"; export async function GET( _request: NextRequest, { params }: { params: Promise<{ id: string }> }, ) { try { + const ctx = await requireContext(); const { id } = await params; - const parameterDefinition = await parameterDefinitionRepository.findById({ id }); + const parameterDefinition = await parameterDefinitionRepository.findById({ + orgId: ctx.activeOrgId, + id, + }); if (!parameterDefinition) { - return NextResponse.json({ error: "Parameter definition not found" }, { status: 404 }); + return NextResponse.json( + { error: "Parameter definition not found" }, + { status: 404 }, + ); } return NextResponse.json({ parameterDefinition }); } catch (err) { - return NextResponse.json( - { error: err instanceof Error ? err.message : "Unexpected error" }, - { status: 500 }, - ); + return errorResponse(err); } } @@ -25,16 +30,18 @@ export async function PATCH( { params }: { params: Promise<{ id: string }> }, ) { try { + const ctx = await requireContext(); const { id } = await params; const body = await request.json(); - const parameterDefinition = await parameterDefinitionRepository.update({ id, ...body }); + const parameterDefinition = await parameterDefinitionRepository.update({ + userId: ctx.userId, + orgId: ctx.activeOrgId, + id, + ...body, + }); return NextResponse.json({ parameterDefinition }); } catch (err) { - const message = err instanceof Error ? err.message : "Unexpected error"; - if (message.includes("not found")) { - return NextResponse.json({ error: message }, { status: 404 }); - } - return NextResponse.json({ error: message }, { status: 400 }); + return errorResponse(err); } } @@ -43,14 +50,15 @@ export async function DELETE( { params }: { params: Promise<{ id: string }> }, ) { try { + const ctx = await requireContext(); const { id } = await params; - await parameterDefinitionRepository.remove({ id }); + await parameterDefinitionRepository.remove({ + userId: ctx.userId, + orgId: ctx.activeOrgId, + id, + }); return NextResponse.json({ success: true }); } catch (err) { - const message = err instanceof Error ? err.message : "Unexpected error"; - if (message.includes("not found")) { - return NextResponse.json({ error: message }, { status: 404 }); - } - return NextResponse.json({ error: message }, { status: 400 }); + return errorResponse(err); } } diff --git a/web/app/api/parameter-definitions/[id]/usage/route.ts b/web/app/api/parameter-definitions/[id]/usage/route.ts index d4639fd..a04f2cb 100644 --- a/web/app/api/parameter-definitions/[id]/usage/route.ts +++ b/web/app/api/parameter-definitions/[id]/usage/route.ts @@ -1,20 +1,20 @@ import { NextRequest, NextResponse } from "next/server"; import { parameterDefinitionRepository } from "@/repositories/parameterDefinitionRepository"; +import { requireContext, errorResponse } from "@/lib/auth/route"; export async function GET( _request: NextRequest, { params }: { params: Promise<{ id: string }> } ) { try { + const ctx = await requireContext(); const { id } = await params; const usage = await parameterDefinitionRepository.getUsage({ + orgId: ctx.activeOrgId, parameterDefinitionId: id, }); return NextResponse.json(usage); } catch (err) { - return NextResponse.json( - { error: err instanceof Error ? err.message : "Unexpected error" }, - { status: 500 } - ); + return errorResponse(err); } } diff --git a/web/app/api/parameter-definitions/route.ts b/web/app/api/parameter-definitions/route.ts index 9f3a313..9651fa7 100644 --- a/web/app/api/parameter-definitions/route.ts +++ b/web/app/api/parameter-definitions/route.ts @@ -1,28 +1,33 @@ import { NextRequest, NextResponse } from "next/server"; import { parameterDefinitionRepository } from "@/repositories/parameterDefinitionRepository"; +import { requireContext, errorResponse } from "@/lib/auth/route"; export async function GET() { try { + const ctx = await requireContext(); const parameterDefinitions = - await parameterDefinitionRepository.listWithUsage(); + await parameterDefinitionRepository.listWithUsage({ + orgId: ctx.activeOrgId, + }); return NextResponse.json({ parameterDefinitions }); } catch (err) { - return NextResponse.json( - { error: err instanceof Error ? err.message : "Unexpected error" }, - { status: 500 }, - ); + return errorResponse(err); } } export async function POST(request: NextRequest) { try { + const ctx = await requireContext(); const body = await request.json(); - const parameterDefinition = await parameterDefinitionRepository.create(body); + const { asGlobal, ...rest } = body; + const parameterDefinition = await parameterDefinitionRepository.create({ + userId: ctx.userId, + orgId: ctx.activeOrgId, + asGlobal: Boolean(asGlobal), + ...rest, + }); return NextResponse.json({ parameterDefinition }, { status: 201 }); } catch (err) { - return NextResponse.json( - { error: err instanceof Error ? err.message : "Unexpected error" }, - { status: 400 }, - ); + return errorResponse(err); } } diff --git a/web/app/api/standards/[id]/aspects/route.ts b/web/app/api/standards/[id]/aspects/route.ts index 158dd97..d660af3 100644 --- a/web/app/api/standards/[id]/aspects/route.ts +++ b/web/app/api/standards/[id]/aspects/route.ts @@ -1,11 +1,13 @@ import { NextRequest, NextResponse } from "next/server"; import { standardRepository } from "@/repositories/standardRepository"; +import { requireContext, errorResponse } from "@/lib/auth/route"; export async function POST( request: NextRequest, { params }: { params: Promise<{ id: string }> } ) { try { + const ctx = await requireContext(); const { id: standardId } = await params; const body = await request.json(); const { aspectId } = body; @@ -17,13 +19,15 @@ export async function POST( ); } - const link = await standardRepository.addAspect({ standardId, aspectId }); + const link = await standardRepository.addAspect({ + userId: ctx.userId, + orgId: ctx.activeOrgId, + standardId, + aspectId, + }); return NextResponse.json({ link }, { status: 201 }); - } catch (error: unknown) { - return NextResponse.json( - { error: error instanceof Error ? error.message : "Unknown error" }, - { status: 500 } - ); + } catch (err) { + return errorResponse(err); } } @@ -32,6 +36,7 @@ export async function DELETE( { params }: { params: Promise<{ id: string }> } ) { try { + const ctx = await requireContext(); const { id: standardId } = await params; const { searchParams } = new URL(request.url); const aspectId = searchParams.get("aspectId"); @@ -43,12 +48,14 @@ export async function DELETE( ); } - await standardRepository.removeAspect({ standardId, aspectId }); + await standardRepository.removeAspect({ + userId: ctx.userId, + orgId: ctx.activeOrgId, + standardId, + aspectId, + }); return NextResponse.json({ success: true }); - } catch (error: unknown) { - return NextResponse.json( - { error: error instanceof Error ? error.message : "Unknown error" }, - { status: 500 } - ); + } catch (err) { + return errorResponse(err); } } diff --git a/web/app/api/standards/[id]/designations/route.ts b/web/app/api/standards/[id]/designations/route.ts index ac0328c..ce28149 100644 --- a/web/app/api/standards/[id]/designations/route.ts +++ b/web/app/api/standards/[id]/designations/route.ts @@ -1,11 +1,13 @@ import { NextRequest, NextResponse } from "next/server"; import { standardRepository } from "@/repositories/standardRepository"; +import { requireContext, errorResponse } from "@/lib/auth/route"; export async function GET( request: NextRequest, { params }: { params: Promise<{ id: string }> } ) { try { + const ctx = await requireContext(); const { id } = await params; const { searchParams } = new URL(request.url); const limit = searchParams.get("limit") @@ -17,16 +19,22 @@ export async function GET( const q = searchParams.get("q") ?? undefined; const [designations, total] = await Promise.all([ - standardRepository.listDesignations({ standardId: id, q, limit, offset }), - standardRepository.countDesignations({ standardId: id }), + standardRepository.listDesignations({ + orgId: ctx.activeOrgId, + standardId: id, + q, + limit, + offset, + }), + standardRepository.countDesignations({ + orgId: ctx.activeOrgId, + standardId: id, + }), ]); return NextResponse.json({ designations, total, limit, offset }); - } catch (error: unknown) { - return NextResponse.json( - { error: error instanceof Error ? error.message : "Unknown error" }, - { status: 500 } - ); + } catch (err) { + return errorResponse(err); } } @@ -35,6 +43,7 @@ export async function POST( { params }: { params: Promise<{ id: string }> } ) { try { + const ctx = await requireContext(); const { id } = await params; const body = await request.json(); const { designation, values, metadata } = body; @@ -47,6 +56,7 @@ export async function POST( } const entry = await standardRepository.createDesignation({ + orgId: ctx.activeOrgId, standardId: id, designation, values, @@ -54,16 +64,14 @@ export async function POST( }); return NextResponse.json({ designation: entry }, { status: 201 }); - } catch (error: unknown) { - return NextResponse.json( - { error: error instanceof Error ? error.message : "Unknown error" }, - { status: 500 } - ); + } catch (err) { + return errorResponse(err); } } export async function DELETE(request: NextRequest) { try { + const ctx = await requireContext(); const designationId = new URL(request.url).searchParams.get("id"); if (!designationId) { return NextResponse.json( @@ -71,11 +79,12 @@ export async function DELETE(request: NextRequest) { { status: 400 } ); } - await standardRepository.removeDesignation({ id: designationId }); + await standardRepository.removeDesignation({ + orgId: ctx.activeOrgId, + id: designationId, + }); return NextResponse.json({ success: true }); - } catch (error: unknown) { - const message = error instanceof Error ? error.message : "Unknown error"; - const status = message.includes("not found") ? 404 : 500; - return NextResponse.json({ error: message }, { status }); + } catch (err) { + return errorResponse(err); } } diff --git a/web/app/api/standards/[id]/parameters/route.ts b/web/app/api/standards/[id]/parameters/route.ts index 128f17d..cab04fb 100644 --- a/web/app/api/standards/[id]/parameters/route.ts +++ b/web/app/api/standards/[id]/parameters/route.ts @@ -1,19 +1,21 @@ import { NextRequest, NextResponse } from "next/server"; import { standardRepository } from "@/repositories/standardRepository"; +import { requireContext, errorResponse } from "@/lib/auth/route"; export async function GET( _request: NextRequest, { params }: { params: Promise<{ id: string }> } ) { try { + const ctx = await requireContext(); const { id } = await params; - const parameters = await standardRepository.getParameters({ standardId: id }); + const parameters = await standardRepository.getParameters({ + orgId: ctx.activeOrgId, + standardId: id, + }); return NextResponse.json({ parameters }); - } catch (error: unknown) { - return NextResponse.json( - { error: error instanceof Error ? error.message : "Unknown error" }, - { status: 500 } - ); + } catch (err) { + return errorResponse(err); } } @@ -22,6 +24,7 @@ export async function POST( { params }: { params: Promise<{ id: string }> } ) { try { + const ctx = await requireContext(); const { id } = await params; const body = await request.json(); const { parameterDefinitionId, role, sortOrder } = body; @@ -34,6 +37,7 @@ export async function POST( } const parameter = await standardRepository.addParameter({ + orgId: ctx.activeOrgId, standardId: id, parameterDefinitionId, role, @@ -41,11 +45,8 @@ export async function POST( }); return NextResponse.json({ parameter }, { status: 201 }); - } catch (error: unknown) { - return NextResponse.json( - { error: error instanceof Error ? error.message : "Unknown error" }, - { status: 500 } - ); + } catch (err) { + return errorResponse(err); } } @@ -54,6 +55,7 @@ export async function DELETE( { params }: { params: Promise<{ id: string }> } ) { try { + const ctx = await requireContext(); const { id } = await params; const { searchParams } = new URL(request.url); const parameterDefinitionId = searchParams.get("parameterDefinitionId"); @@ -66,15 +68,13 @@ export async function DELETE( } await standardRepository.removeParameter({ + orgId: ctx.activeOrgId, standardId: id, parameterDefinitionId, }); return NextResponse.json({ success: true }); - } catch (error: unknown) { - return NextResponse.json( - { error: error instanceof Error ? error.message : "Unknown error" }, - { status: 500 } - ); + } catch (err) { + return errorResponse(err); } } diff --git a/web/app/api/standards/[id]/route.ts b/web/app/api/standards/[id]/route.ts index c37d581..d460b39 100644 --- a/web/app/api/standards/[id]/route.ts +++ b/web/app/api/standards/[id]/route.ts @@ -1,25 +1,49 @@ import { NextRequest, NextResponse } from "next/server"; import { standardRepository } from "@/repositories/standardRepository"; +import { requireContext, errorResponse } from "@/lib/auth/route"; export async function GET( _request: NextRequest, { params }: { params: Promise<{ id: string }> } ) { try { + const ctx = await requireContext(); const { id } = await params; - const standard = await standardRepository.findById({ id }); + const standard = await standardRepository.findById({ + orgId: ctx.activeOrgId, + id, + }); if (!standard) { return NextResponse.json({ error: "Standard not found" }, { status: 404 }); } const [parameters, aspects, itemCount, designationCount, items, designationUsage] = await Promise.all([ - standardRepository.getParameters({ standardId: id }), - standardRepository.listAspectsForStandard({ standardId: id }), - standardRepository.countItemsUsing({ standardId: id }), - standardRepository.countDesignations({ standardId: id }), - standardRepository.listItemsUsing({ standardId: id, limit: 50 }), - standardRepository.designationUsage({ standardId: id }), + standardRepository.getParameters({ + orgId: ctx.activeOrgId, + standardId: id, + }), + standardRepository.listAspectsForStandard({ + orgId: ctx.activeOrgId, + standardId: id, + }), + standardRepository.countItemsUsing({ + orgId: ctx.activeOrgId, + standardId: id, + }), + standardRepository.countDesignations({ + orgId: ctx.activeOrgId, + standardId: id, + }), + standardRepository.listItemsUsing({ + orgId: ctx.activeOrgId, + standardId: id, + limit: 50, + }), + standardRepository.designationUsage({ + orgId: ctx.activeOrgId, + standardId: id, + }), ]); return NextResponse.json({ @@ -31,11 +55,8 @@ export async function GET( items, designationUsage, }); - } catch (error: unknown) { - return NextResponse.json( - { error: error instanceof Error ? error.message : "Unknown error" }, - { status: 500 } - ); + } catch (err) { + return errorResponse(err); } } @@ -44,15 +65,18 @@ export async function PATCH( { params }: { params: Promise<{ id: string }> } ) { try { + const ctx = await requireContext(); const { id } = await params; const body = await request.json(); - const standard = await standardRepository.update({ id, ...body }); + const standard = await standardRepository.update({ + userId: ctx.userId, + orgId: ctx.activeOrgId, + id, + ...body, + }); return NextResponse.json({ standard }); - } catch (error: unknown) { - return NextResponse.json( - { error: error instanceof Error ? error.message : "Unknown error" }, - { status: 500 } - ); + } catch (err) { + return errorResponse(err); } } @@ -61,13 +85,15 @@ export async function DELETE( { params }: { params: Promise<{ id: string }> } ) { try { + const ctx = await requireContext(); const { id } = await params; - await standardRepository.remove({ id }); + await standardRepository.remove({ + userId: ctx.userId, + orgId: ctx.activeOrgId, + id, + }); return NextResponse.json({ success: true }); - } catch (error: unknown) { - return NextResponse.json( - { error: error instanceof Error ? error.message : "Unknown error" }, - { status: 500 } - ); + } catch (err) { + return errorResponse(err); } } diff --git a/web/app/api/standards/route.ts b/web/app/api/standards/route.ts index b8c5daf..76c652d 100644 --- a/web/app/api/standards/route.ts +++ b/web/app/api/standards/route.ts @@ -1,22 +1,22 @@ import { NextRequest, NextResponse } from "next/server"; import { standardRepository } from "@/repositories/standardRepository"; +import { requireContext, errorResponse } from "@/lib/auth/route"; export async function GET() { try { - const items = await standardRepository.list(); + const ctx = await requireContext(); + const items = await standardRepository.list({ orgId: ctx.activeOrgId }); return NextResponse.json({ standards: items }); - } catch (error: unknown) { - return NextResponse.json( - { error: error instanceof Error ? error.message : "Unknown error" }, - { status: 500 } - ); + } catch (err) { + return errorResponse(err); } } export async function POST(request: NextRequest) { try { + const ctx = await requireContext(); const body = await request.json(); - const { name, description, domainTag, aspectIds } = body; + const { name, description, domainTag, asGlobal, aspectIds } = body; if (!name) { return NextResponse.json( @@ -26,6 +26,9 @@ export async function POST(request: NextRequest) { } const standard = await standardRepository.create({ + userId: ctx.userId, + orgId: ctx.activeOrgId, + asGlobal: Boolean(asGlobal), name, description, domainTag, @@ -34,6 +37,8 @@ export async function POST(request: NextRequest) { if (Array.isArray(aspectIds) && aspectIds.length > 0) { for (const aspectId of aspectIds) { await standardRepository.addAspect({ + userId: ctx.userId, + orgId: ctx.activeOrgId, standardId: standard.id, aspectId, }); @@ -41,10 +46,7 @@ export async function POST(request: NextRequest) { } return NextResponse.json({ standard }, { status: 201 }); - } catch (error: unknown) { - return NextResponse.json( - { error: error instanceof Error ? error.message : "Unknown error" }, - { status: 500 } - ); + } catch (err) { + return errorResponse(err); } } diff --git a/web/app/api/taxonomy/audit/route.ts b/web/app/api/taxonomy/audit/route.ts index b0fda11..f73fa17 100644 --- a/web/app/api/taxonomy/audit/route.ts +++ b/web/app/api/taxonomy/audit/route.ts @@ -2,13 +2,15 @@ import { NextResponse } from "next/server"; import { aspectRepository } from "@/repositories/aspectRepository"; import { parameterDefinitionRepository } from "@/repositories/parameterDefinitionRepository"; import { itemRepository } from "@/repositories/itemRepository"; +import { requireContext, errorResponse } from "@/lib/auth/route"; export async function GET() { try { + const ctx = await requireContext(); const [paramChecks, aspectChecks, valueChecks] = await Promise.all([ - parameterDefinitionRepository.audit(), - aspectRepository.audit(), - itemRepository.auditParameterValues(), + parameterDefinitionRepository.audit({ orgId: ctx.activeOrgId }), + aspectRepository.audit({ orgId: ctx.activeOrgId }), + itemRepository.auditParameterValues({ orgId: ctx.activeOrgId }), ]); const checks = [...paramChecks, ...aspectChecks, ...valueChecks]; return NextResponse.json({ @@ -16,9 +18,6 @@ export async function GET() { runAt: new Date().toISOString(), }); } catch (err) { - return NextResponse.json( - { error: err instanceof Error ? err.message : "Unexpected error" }, - { status: 500 } - ); + return errorResponse(err); } } diff --git a/web/app/api/templates/[id]/hide/route.ts b/web/app/api/templates/[id]/hide/route.ts index e6a9969..ca2dc0a 100644 --- a/web/app/api/templates/[id]/hide/route.ts +++ b/web/app/api/templates/[id]/hide/route.ts @@ -1,32 +1,35 @@ import { NextRequest, NextResponse } from "next/server"; import { templateRepository } from "@/repositories/templateRepository"; +import { requireContext, errorResponse } from "@/lib/auth/route"; type RouteParams = { params: Promise<{ id: string }> }; export async function POST(_request: NextRequest, { params }: RouteParams) { try { + const ctx = await requireContext(); const { id } = await params; - const template = await templateRepository.hide({ id }); + const template = await templateRepository.hide({ + userId: ctx.userId, + orgId: ctx.activeOrgId, + id, + }); return NextResponse.json({ template }); } catch (err) { - const message = err instanceof Error ? err.message : "Unknown error"; - if (message.includes("not found")) { - return NextResponse.json({ error: message }, { status: 404 }); - } - return NextResponse.json({ error: message }, { status: 500 }); + return errorResponse(err); } } export async function DELETE(_request: NextRequest, { params }: RouteParams) { try { + const ctx = await requireContext(); const { id } = await params; - const template = await templateRepository.unhide({ id }); + const template = await templateRepository.unhide({ + userId: ctx.userId, + orgId: ctx.activeOrgId, + id, + }); return NextResponse.json({ template }); } catch (err) { - const message = err instanceof Error ? err.message : "Unknown error"; - if (message.includes("not found")) { - return NextResponse.json({ error: message }, { status: 404 }); - } - return NextResponse.json({ error: message }, { status: 500 }); + return errorResponse(err); } } diff --git a/web/app/api/templates/[id]/route.ts b/web/app/api/templates/[id]/route.ts index 214faac..de62cdc 100644 --- a/web/app/api/templates/[id]/route.ts +++ b/web/app/api/templates/[id]/route.ts @@ -1,37 +1,45 @@ import { NextRequest, NextResponse } from "next/server"; import { templateRepository } from "@/repositories/templateRepository"; +import { requireContext, errorResponse } from "@/lib/auth/route"; type RouteParams = { params: Promise<{ id: string }> }; export async function GET(_request: NextRequest, { params }: RouteParams) { try { + const ctx = await requireContext(); const { id } = await params; - const template = await templateRepository.findById({ id }); + const template = await templateRepository.findById({ + orgId: ctx.activeOrgId, + id, + }); if (!template) { return NextResponse.json({ error: "Template not found" }, { status: 404 }); } return NextResponse.json({ template }); } catch (err) { - const message = err instanceof Error ? err.message : "Unknown error"; - return NextResponse.json({ error: message }, { status: 500 }); + return errorResponse(err); } } export async function PATCH(request: NextRequest, { params }: RouteParams) { try { + const ctx = await requireContext(); const { id } = await params; const body = await request.json(); const { name, description, metadata, activeVersion } = body; - // Set active version if requested if (activeVersion !== undefined) { await templateRepository.setActiveVersion({ + userId: ctx.userId, + orgId: ctx.activeOrgId, templateId: id, version: activeVersion, }); } const template = await templateRepository.update({ + userId: ctx.userId, + orgId: ctx.activeOrgId, id, ...(name !== undefined && { name }), ...(description !== undefined && { description }), @@ -40,38 +48,36 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) { return NextResponse.json({ template }); } catch (err) { - const message = err instanceof Error ? err.message : "Unknown error"; - if (message.includes("not found")) { - return NextResponse.json({ error: message }, { status: 404 }); - } - return NextResponse.json({ error: message }, { status: 500 }); + return errorResponse(err); } } export async function DELETE(_request: NextRequest, { params }: RouteParams) { try { + const ctx = await requireContext(); const { id } = await params; - // If the template is referenced anywhere, reject hard delete and - // tell the caller to hide instead. - const refs = await templateRepository.getReferenceCount({ id }); + const refs = await templateRepository.getReferenceCount({ + orgId: ctx.activeOrgId, + id, + }); if (refs.insertCount > 0 || refs.locationCount > 0) { return NextResponse.json( { error: "Template is referenced and cannot be deleted. Hide it instead.", references: refs, }, - { status: 409 } + { status: 409 }, ); } - await templateRepository.remove({ id }); + await templateRepository.remove({ + userId: ctx.userId, + orgId: ctx.activeOrgId, + id, + }); return NextResponse.json({ success: true }); } catch (err) { - const message = err instanceof Error ? err.message : "Unknown error"; - if (message.includes("not found")) { - return NextResponse.json({ error: message }, { status: 404 }); - } - return NextResponse.json({ error: message }, { status: 500 }); + return errorResponse(err); } } diff --git a/web/app/api/templates/[id]/stats/route.ts b/web/app/api/templates/[id]/stats/route.ts index d9828f6..9106287 100644 --- a/web/app/api/templates/[id]/stats/route.ts +++ b/web/app/api/templates/[id]/stats/route.ts @@ -1,16 +1,24 @@ import { NextRequest, NextResponse } from "next/server"; import { templateRepository } from "@/repositories/templateRepository"; +import { requireContext, errorResponse } from "@/lib/auth/route"; type RouteParams = { params: Promise<{ id: string }> }; export async function GET(_request: NextRequest, { params }: RouteParams) { try { + const ctx = await requireContext(); const { id } = await params; - const template = await templateRepository.findById({ id }); + const template = await templateRepository.findById({ + orgId: ctx.activeOrgId, + id, + }); if (!template) { return NextResponse.json({ error: "Template not found" }, { status: 404 }); } - const refs = await templateRepository.getReferenceCount({ id }); + const refs = await templateRepository.getReferenceCount({ + orgId: ctx.activeOrgId, + id, + }); return NextResponse.json({ stats: { ...refs, @@ -18,7 +26,6 @@ export async function GET(_request: NextRequest, { params }: RouteParams) { }, }); } catch (err) { - const message = err instanceof Error ? err.message : "Unknown error"; - return NextResponse.json({ error: message }, { status: 500 }); + return errorResponse(err); } } diff --git a/web/app/api/templates/[id]/versions/route.ts b/web/app/api/templates/[id]/versions/route.ts index e38d9c1..aec465f 100644 --- a/web/app/api/templates/[id]/versions/route.ts +++ b/web/app/api/templates/[id]/versions/route.ts @@ -1,41 +1,47 @@ import { NextRequest, NextResponse } from "next/server"; import { templateRepository } from "@/repositories/templateRepository"; +import { requireContext, errorResponse } from "@/lib/auth/route"; type RouteParams = { params: Promise<{ id: string }> }; export async function GET(_request: NextRequest, { params }: RouteParams) { try { + const ctx = await requireContext(); const { id } = await params; - const template = await templateRepository.findById({ id }); + const template = await templateRepository.findById({ + orgId: ctx.activeOrgId, + id, + }); if (!template) { return NextResponse.json({ error: "Template not found" }, { status: 404 }); } - const versions = await templateRepository.listVersions({ templateId: id }); + const versions = await templateRepository.listVersions({ + orgId: ctx.activeOrgId, + templateId: id, + }); return NextResponse.json({ versions }); } catch (err) { - const message = err instanceof Error ? err.message : "Unknown error"; - return NextResponse.json({ error: message }, { status: 500 }); + return errorResponse(err); } } export async function POST(request: NextRequest, { params }: RouteParams) { try { + const ctx = await requireContext(); const { id } = await params; const body = await request.json(); const version = await templateRepository.publishVersion({ + userId: ctx.userId, + orgId: ctx.activeOrgId, templateId: id, ...body, }); return NextResponse.json({ version }, { status: 201 }); } catch (err) { - const message = err instanceof Error ? err.message : "Unknown error"; - if (message.includes("not found")) { - return NextResponse.json({ error: message }, { status: 404 }); - } - return NextResponse.json({ error: message }, { status: 500 }); + return errorResponse(err); } } diff --git a/web/app/api/templates/route.ts b/web/app/api/templates/route.ts index 18ef0bf..44fb661 100644 --- a/web/app/api/templates/route.ts +++ b/web/app/api/templates/route.ts @@ -1,30 +1,36 @@ import { NextRequest, NextResponse } from "next/server"; import { templateRepository } from "@/repositories/templateRepository"; +import { requireContext, errorResponse } from "@/lib/auth/route"; export async function GET(request: NextRequest) { try { + const ctx = await requireContext(); const includeHidden = request.nextUrl.searchParams.get("includeHidden") === "true"; const templates = await templateRepository.listWithCurrentVersion({ + orgId: ctx.activeOrgId, includeHidden, }); return NextResponse.json({ templates }); } catch (err) { - const message = err instanceof Error ? err.message : "Unknown error"; - return NextResponse.json({ error: message }, { status: 500 }); + return errorResponse(err); } } export async function POST(request: NextRequest) { try { + const ctx = await requireContext(); const body = await request.json(); - const { name, description, metadata, ...versionFields } = body; + const { name, description, metadata, asGlobal, ...versionFields } = body; if (!name) { return NextResponse.json({ error: "name is required" }, { status: 400 }); } const template = await templateRepository.create({ + userId: ctx.userId, + orgId: ctx.activeOrgId, + asGlobal: Boolean(asGlobal), name, description, metadata, @@ -33,7 +39,6 @@ export async function POST(request: NextRequest) { return NextResponse.json({ template }, { status: 201 }); } catch (err) { - const message = err instanceof Error ? err.message : "Unknown error"; - return NextResponse.json({ error: message }, { status: 500 }); + return errorResponse(err); } } diff --git a/web/app/api/transactions/route.ts b/web/app/api/transactions/route.ts index 3db9138..a1c7775 100644 --- a/web/app/api/transactions/route.ts +++ b/web/app/api/transactions/route.ts @@ -1,16 +1,18 @@ import { NextRequest, NextResponse } from "next/server"; import { transactionRepository } from "@/repositories/transactionRepository"; +import { requireContext, errorResponse } from "@/lib/auth/route"; export async function GET(request: NextRequest) { try { + const ctx = await requireContext(); const limitParam = request.nextUrl.searchParams.get("limit"); const limit = limitParam ? parseInt(limitParam, 10) : 50; - const transactions = await transactionRepository.listRecent({ limit }); + const transactions = await transactionRepository.listRecent({ + orgId: ctx.activeOrgId, + limit, + }); return NextResponse.json({ transactions }); } catch (err) { - return NextResponse.json( - { error: err instanceof Error ? err.message : "Unexpected error" }, - { status: 500 }, - ); + return errorResponse(err); } } diff --git a/web/app/login/page.tsx b/web/app/login/page.tsx new file mode 100644 index 0000000..7e81169 --- /dev/null +++ b/web/app/login/page.tsx @@ -0,0 +1,74 @@ +import { signIn } from "@/lib/auth/config"; + +// Minimal stub — Phase D will replace this with a proper UI. +// Email+password hits the credentials provider; the dev-impersonate +// form is only rendered in non-production and trusts any email. + +async function credentialsSignIn(formData: FormData) { + "use server"; + const email = String(formData.get("email") ?? ""); + const password = String(formData.get("password") ?? ""); + await signIn("credentials", { email, password, redirectTo: "/" }); +} + +async function devImpersonate(formData: FormData) { + "use server"; + const email = String(formData.get("email") ?? ""); + await signIn("dev-impersonate", { email, redirectTo: "/" }); +} + +export default function LoginPage() { + const isProd = process.env.NODE_ENV === "production"; + + return ( +
+

Sign in

+ +
+ + + +
+ + {!isProd && ( +
+

+ Dev impersonate (non-prod only) +

+ + +
+ )} +
+ ); +} diff --git a/web/db/migrations/0015_auth_and_orgs.sql b/web/db/migrations/0015_auth_and_orgs.sql new file mode 100644 index 0000000..49897e7 --- /dev/null +++ b/web/db/migrations/0015_auth_and_orgs.sql @@ -0,0 +1,73 @@ +-- Auth.js adapter tables + org model. +-- Phase A (auth) + Phase B (orgs) from the auth roadmap. The +-- per-table `owner_org_id` columns and backfill land in 0016. + +CREATE TABLE IF NOT EXISTS "users" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "name" text, + "email" text NOT NULL UNIQUE, + "email_verified" timestamp, + "image" text, + "password_hash" text, + "is_admin" boolean DEFAULT false NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint + +CREATE TABLE IF NOT EXISTS "accounts" ( + "user_id" uuid NOT NULL, + "type" text NOT NULL, + "provider" text NOT NULL, + "provider_account_id" text NOT NULL, + "refresh_token" text, + "access_token" text, + "expires_at" integer, + "token_type" text, + "scope" text, + "id_token" text, + "session_state" text, + CONSTRAINT "accounts_pk" PRIMARY KEY ("provider", "provider_account_id"), + CONSTRAINT "accounts_user_id_users_id_fk" + FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE +); +--> statement-breakpoint + +CREATE TABLE IF NOT EXISTS "sessions" ( + "session_token" text PRIMARY KEY NOT NULL, + "user_id" uuid NOT NULL, + "expires" timestamp NOT NULL, + CONSTRAINT "sessions_user_id_users_id_fk" + FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE +); +--> statement-breakpoint + +CREATE TABLE IF NOT EXISTS "verification_tokens" ( + "identifier" text NOT NULL, + "token" text NOT NULL, + "expires" timestamp NOT NULL, + CONSTRAINT "verification_tokens_pk" PRIMARY KEY ("identifier", "token") +); +--> statement-breakpoint + +CREATE TABLE IF NOT EXISTS "orgs" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "name" text NOT NULL, + "slug" text NOT NULL UNIQUE, + "plan" text DEFAULT 'free' NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint + +CREATE TABLE IF NOT EXISTS "user_orgs" ( + "user_id" uuid NOT NULL, + "org_id" uuid NOT NULL, + "role" text NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "user_orgs_pk" PRIMARY KEY ("user_id", "org_id"), + CONSTRAINT "user_orgs_user_id_users_id_fk" + FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE, + CONSTRAINT "user_orgs_org_id_orgs_id_fk" + FOREIGN KEY ("org_id") REFERENCES "orgs"("id") ON DELETE CASCADE +); diff --git a/web/db/migrations/0016_owner_org_id_and_backfill.sql b/web/db/migrations/0016_owner_org_id_and_backfill.sql new file mode 100644 index 0000000..17aeb38 --- /dev/null +++ b/web/db/migrations/0016_owner_org_id_and_backfill.sql @@ -0,0 +1,113 @@ +-- Phase C.1: add owner_org_id to every additive + isolated table, +-- seed a default org, backfill isolated rows, add FK constraints. +-- NOT NULL on isolated tables is deferred to 0017, after repos always +-- populate owner_org_id on new writes. +-- +-- Isolated tables (backfilled to default org): +-- modules, inserts, locations, assignments, +-- location_interfaces_accepted, transactions +-- Additive tables (left NULL = global catalog): +-- templates, template_versions, +-- template_version_interfaces_provided/accepted, +-- items, item_aspects, item_parameter_values, item_categories, +-- item_standards, co_storability, categories, +-- parameter_definitions, aspects, aspect_parameters, standards, +-- standard_parameters, standard_designations, aspect_standards, +-- interface_types + +-- Isolated +ALTER TABLE "modules" ADD COLUMN IF NOT EXISTS "owner_org_id" uuid; +ALTER TABLE "inserts" ADD COLUMN IF NOT EXISTS "owner_org_id" uuid; +ALTER TABLE "locations" ADD COLUMN IF NOT EXISTS "owner_org_id" uuid; +ALTER TABLE "assignments" ADD COLUMN IF NOT EXISTS "owner_org_id" uuid; +ALTER TABLE "location_interfaces_accepted" ADD COLUMN IF NOT EXISTS "owner_org_id" uuid; +ALTER TABLE "transactions" ADD COLUMN IF NOT EXISTS "owner_org_id" uuid; +ALTER TABLE "transactions" ADD COLUMN IF NOT EXISTS "actor_user_id" uuid; +--> statement-breakpoint + +-- Additive +ALTER TABLE "templates" ADD COLUMN IF NOT EXISTS "owner_org_id" uuid; +ALTER TABLE "template_versions" ADD COLUMN IF NOT EXISTS "owner_org_id" uuid; +ALTER TABLE "template_version_interfaces_provided" ADD COLUMN IF NOT EXISTS "owner_org_id" uuid; +ALTER TABLE "template_version_interfaces_accepted" ADD COLUMN IF NOT EXISTS "owner_org_id" uuid; +ALTER TABLE "items" ADD COLUMN IF NOT EXISTS "owner_org_id" uuid; +ALTER TABLE "item_aspects" ADD COLUMN IF NOT EXISTS "owner_org_id" uuid; +ALTER TABLE "item_parameter_values" ADD COLUMN IF NOT EXISTS "owner_org_id" uuid; +ALTER TABLE "item_categories" ADD COLUMN IF NOT EXISTS "owner_org_id" uuid; +ALTER TABLE "item_standards" ADD COLUMN IF NOT EXISTS "owner_org_id" uuid; +ALTER TABLE "co_storability" ADD COLUMN IF NOT EXISTS "owner_org_id" uuid; +ALTER TABLE "categories" ADD COLUMN IF NOT EXISTS "owner_org_id" uuid; +ALTER TABLE "parameter_definitions" ADD COLUMN IF NOT EXISTS "owner_org_id" uuid; +ALTER TABLE "aspects" ADD COLUMN IF NOT EXISTS "owner_org_id" uuid; +ALTER TABLE "aspect_parameters" ADD COLUMN IF NOT EXISTS "owner_org_id" uuid; +ALTER TABLE "standards" ADD COLUMN IF NOT EXISTS "owner_org_id" uuid; +ALTER TABLE "standard_parameters" ADD COLUMN IF NOT EXISTS "owner_org_id" uuid; +ALTER TABLE "standard_designations" ADD COLUMN IF NOT EXISTS "owner_org_id" uuid; +ALTER TABLE "aspect_standards" ADD COLUMN IF NOT EXISTS "owner_org_id" uuid; +ALTER TABLE "interface_types" ADD COLUMN IF NOT EXISTS "owner_org_id" uuid; +--> statement-breakpoint + +-- Seed default user + org, backfill isolated rows. Idempotent. +DO $$ +DECLARE + seed_user_id uuid; + seed_org_id uuid; +BEGIN + SELECT id INTO seed_user_id FROM "users" WHERE email = 'default@wheretf.local'; + IF seed_user_id IS NULL THEN + INSERT INTO "users" (email, name, is_admin) + VALUES ('default@wheretf.local', 'default', true) + RETURNING id INTO seed_user_id; + END IF; + + SELECT id INTO seed_org_id FROM "orgs" WHERE slug = 'default'; + IF seed_org_id IS NULL THEN + INSERT INTO "orgs" (name, slug, plan) + VALUES ('Default', 'default', 'paid') + RETURNING id INTO seed_org_id; + END IF; + + INSERT INTO "user_orgs" (user_id, org_id, role) + VALUES (seed_user_id, seed_org_id, 'owner') + ON CONFLICT DO NOTHING; + + UPDATE "modules" SET owner_org_id = seed_org_id WHERE owner_org_id IS NULL; + UPDATE "inserts" SET owner_org_id = seed_org_id WHERE owner_org_id IS NULL; + UPDATE "locations" SET owner_org_id = seed_org_id WHERE owner_org_id IS NULL; + UPDATE "assignments" SET owner_org_id = seed_org_id WHERE owner_org_id IS NULL; + UPDATE "location_interfaces_accepted" SET owner_org_id = seed_org_id WHERE owner_org_id IS NULL; + UPDATE "transactions" + SET owner_org_id = seed_org_id, + actor_user_id = seed_user_id + WHERE owner_org_id IS NULL; +END $$; +--> statement-breakpoint + +-- FK constraints. Isolated + additive alike; no NOT NULL yet. +ALTER TABLE "modules" ADD CONSTRAINT "modules_owner_org_id_fk" FOREIGN KEY ("owner_org_id") REFERENCES "orgs"("id") ON DELETE CASCADE; +ALTER TABLE "inserts" ADD CONSTRAINT "inserts_owner_org_id_fk" FOREIGN KEY ("owner_org_id") REFERENCES "orgs"("id") ON DELETE CASCADE; +ALTER TABLE "locations" ADD CONSTRAINT "locations_owner_org_id_fk" FOREIGN KEY ("owner_org_id") REFERENCES "orgs"("id") ON DELETE CASCADE; +ALTER TABLE "assignments" ADD CONSTRAINT "assignments_owner_org_id_fk" FOREIGN KEY ("owner_org_id") REFERENCES "orgs"("id") ON DELETE CASCADE; +ALTER TABLE "location_interfaces_accepted" ADD CONSTRAINT "location_interfaces_accepted_owner_org_id_fk" FOREIGN KEY ("owner_org_id") REFERENCES "orgs"("id") ON DELETE CASCADE; +ALTER TABLE "transactions" ADD CONSTRAINT "transactions_owner_org_id_fk" FOREIGN KEY ("owner_org_id") REFERENCES "orgs"("id") ON DELETE CASCADE; +ALTER TABLE "transactions" ADD CONSTRAINT "transactions_actor_user_id_fk" FOREIGN KEY ("actor_user_id") REFERENCES "users"("id") ON DELETE SET NULL; + +ALTER TABLE "templates" ADD CONSTRAINT "templates_owner_org_id_fk" FOREIGN KEY ("owner_org_id") REFERENCES "orgs"("id") ON DELETE CASCADE; +ALTER TABLE "template_versions" ADD CONSTRAINT "template_versions_owner_org_id_fk" FOREIGN KEY ("owner_org_id") REFERENCES "orgs"("id") ON DELETE CASCADE; +ALTER TABLE "template_version_interfaces_provided" ADD CONSTRAINT "template_version_interfaces_provided_owner_org_id_fk" FOREIGN KEY ("owner_org_id") REFERENCES "orgs"("id") ON DELETE CASCADE; +ALTER TABLE "template_version_interfaces_accepted" ADD CONSTRAINT "template_version_interfaces_accepted_owner_org_id_fk" FOREIGN KEY ("owner_org_id") REFERENCES "orgs"("id") ON DELETE CASCADE; +ALTER TABLE "items" ADD CONSTRAINT "items_owner_org_id_fk" FOREIGN KEY ("owner_org_id") REFERENCES "orgs"("id") ON DELETE CASCADE; +ALTER TABLE "item_aspects" ADD CONSTRAINT "item_aspects_owner_org_id_fk" FOREIGN KEY ("owner_org_id") REFERENCES "orgs"("id") ON DELETE CASCADE; +ALTER TABLE "item_parameter_values" ADD CONSTRAINT "item_parameter_values_owner_org_id_fk" FOREIGN KEY ("owner_org_id") REFERENCES "orgs"("id") ON DELETE CASCADE; +ALTER TABLE "item_categories" ADD CONSTRAINT "item_categories_owner_org_id_fk" FOREIGN KEY ("owner_org_id") REFERENCES "orgs"("id") ON DELETE CASCADE; +ALTER TABLE "item_standards" ADD CONSTRAINT "item_standards_owner_org_id_fk" FOREIGN KEY ("owner_org_id") REFERENCES "orgs"("id") ON DELETE CASCADE; +ALTER TABLE "co_storability" ADD CONSTRAINT "co_storability_owner_org_id_fk" FOREIGN KEY ("owner_org_id") REFERENCES "orgs"("id") ON DELETE CASCADE; +ALTER TABLE "categories" ADD CONSTRAINT "categories_owner_org_id_fk" FOREIGN KEY ("owner_org_id") REFERENCES "orgs"("id") ON DELETE CASCADE; +ALTER TABLE "parameter_definitions" ADD CONSTRAINT "parameter_definitions_owner_org_id_fk" FOREIGN KEY ("owner_org_id") REFERENCES "orgs"("id") ON DELETE CASCADE; +ALTER TABLE "aspects" ADD CONSTRAINT "aspects_owner_org_id_fk" FOREIGN KEY ("owner_org_id") REFERENCES "orgs"("id") ON DELETE CASCADE; +ALTER TABLE "aspect_parameters" ADD CONSTRAINT "aspect_parameters_owner_org_id_fk" FOREIGN KEY ("owner_org_id") REFERENCES "orgs"("id") ON DELETE CASCADE; +ALTER TABLE "standards" ADD CONSTRAINT "standards_owner_org_id_fk" FOREIGN KEY ("owner_org_id") REFERENCES "orgs"("id") ON DELETE CASCADE; +ALTER TABLE "standard_parameters" ADD CONSTRAINT "standard_parameters_owner_org_id_fk" FOREIGN KEY ("owner_org_id") REFERENCES "orgs"("id") ON DELETE CASCADE; +ALTER TABLE "standard_designations" ADD CONSTRAINT "standard_designations_owner_org_id_fk" FOREIGN KEY ("owner_org_id") REFERENCES "orgs"("id") ON DELETE CASCADE; +ALTER TABLE "aspect_standards" ADD CONSTRAINT "aspect_standards_owner_org_id_fk" FOREIGN KEY ("owner_org_id") REFERENCES "orgs"("id") ON DELETE CASCADE; +ALTER TABLE "interface_types" ADD CONSTRAINT "interface_types_owner_org_id_fk" FOREIGN KEY ("owner_org_id") REFERENCES "orgs"("id") ON DELETE CASCADE; diff --git a/web/db/migrations/0017_isolated_owner_org_id_not_null.sql b/web/db/migrations/0017_isolated_owner_org_id_not_null.sql new file mode 100644 index 0000000..4f871b8 --- /dev/null +++ b/web/db/migrations/0017_isolated_owner_org_id_not_null.sql @@ -0,0 +1,36 @@ +-- Phase C.4: flip owner_org_id to NOT NULL on every isolated table. +-- All repos now populate it on new writes (Phase C.2) so any lingering +-- NULLs would only be stray rows from the nullable window between 0016 +-- and this migration. Backfill is idempotent — if nothing's NULL, the +-- UPDATEs are no-ops. +-- +-- Isolated tables: modules, inserts, locations, assignments, +-- location_interfaces_accepted, transactions. + +DO $$ +DECLARE + default_org_id uuid; +BEGIN + SELECT id INTO default_org_id FROM "orgs" WHERE slug = 'default'; + IF default_org_id IS NULL THEN + -- Migration 0016 seeded the default org. If it's gone, the system + -- has been re-seeded somehow; skip the backfill (0017 will still + -- succeed only if no isolated NULLs remain). + RAISE NOTICE 'default org not found; skipping backfill'; + ELSE + UPDATE "modules" SET owner_org_id = default_org_id WHERE owner_org_id IS NULL; + UPDATE "inserts" SET owner_org_id = default_org_id WHERE owner_org_id IS NULL; + UPDATE "locations" SET owner_org_id = default_org_id WHERE owner_org_id IS NULL; + UPDATE "assignments" SET owner_org_id = default_org_id WHERE owner_org_id IS NULL; + UPDATE "location_interfaces_accepted" SET owner_org_id = default_org_id WHERE owner_org_id IS NULL; + UPDATE "transactions" SET owner_org_id = default_org_id WHERE owner_org_id IS NULL; + END IF; +END $$; +--> statement-breakpoint + +ALTER TABLE "modules" ALTER COLUMN "owner_org_id" SET NOT NULL; +ALTER TABLE "inserts" ALTER COLUMN "owner_org_id" SET NOT NULL; +ALTER TABLE "locations" ALTER COLUMN "owner_org_id" SET NOT NULL; +ALTER TABLE "assignments" ALTER COLUMN "owner_org_id" SET NOT NULL; +ALTER TABLE "location_interfaces_accepted" ALTER COLUMN "owner_org_id" SET NOT NULL; +ALTER TABLE "transactions" ALTER COLUMN "owner_org_id" SET NOT NULL; diff --git a/web/db/migrations/meta/_journal.json b/web/db/migrations/meta/_journal.json index 2707530..7c7d117 100644 --- a/web/db/migrations/meta/_journal.json +++ b/web/db/migrations/meta/_journal.json @@ -106,6 +106,27 @@ "when": 1776605680000, "tag": "0014_interface_type_junctions_migrate", "breakpoints": true + }, + { + "idx": 15, + "version": "7", + "when": 1776692000000, + "tag": "0015_auth_and_orgs", + "breakpoints": true + }, + { + "idx": 16, + "version": "7", + "when": 1776692100000, + "tag": "0016_owner_org_id_and_backfill", + "breakpoints": true + }, + { + "idx": 17, + "version": "7", + "when": 1776692200000, + "tag": "0017_isolated_owner_org_id_not_null", + "breakpoints": true } ] } diff --git a/web/db/schema/auth.ts b/web/db/schema/auth.ts new file mode 100644 index 0000000..f35279f --- /dev/null +++ b/web/db/schema/auth.ts @@ -0,0 +1,63 @@ +import { + pgTable, + text, + timestamp, + primaryKey, + integer, + boolean, + uuid, +} from "drizzle-orm/pg-core"; + +// Auth.js Drizzle adapter tables plus WhereTF-specific fields: +// - `passwordHash` on users — nullable so IdP-only users have no local password +// - `isAdmin` — platform-level admin (catalog seeding, plan manual toggle); +// unrelated to per-org roles in user_orgs +export const users = pgTable("users", { + id: uuid("id").primaryKey().defaultRandom(), + name: text("name"), + email: text("email").notNull().unique(), + emailVerified: timestamp("email_verified", { mode: "date" }), + image: text("image"), + passwordHash: text("password_hash"), + isAdmin: boolean("is_admin").notNull().default(false), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().notNull(), +}); + +export const accounts = pgTable( + "accounts", + { + userId: uuid("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + type: text("type").notNull(), + provider: text("provider").notNull(), + providerAccountId: text("provider_account_id").notNull(), + refresh_token: text("refresh_token"), + access_token: text("access_token"), + expires_at: integer("expires_at"), + token_type: text("token_type"), + scope: text("scope"), + id_token: text("id_token"), + session_state: text("session_state"), + }, + (t) => [primaryKey({ columns: [t.provider, t.providerAccountId] })], +); + +export const sessions = pgTable("sessions", { + sessionToken: text("session_token").primaryKey(), + userId: uuid("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + expires: timestamp("expires", { mode: "date" }).notNull(), +}); + +export const verificationTokens = pgTable( + "verification_tokens", + { + identifier: text("identifier").notNull(), + token: text("token").notNull(), + expires: timestamp("expires", { mode: "date" }).notNull(), + }, + (t) => [primaryKey({ columns: [t.identifier, t.token] })], +); diff --git a/web/db/schema/index.ts b/web/db/schema/index.ts index 6c78476..b5160f2 100644 --- a/web/db/schema/index.ts +++ b/web/db/schema/index.ts @@ -1,3 +1,5 @@ +export * from "./auth"; +export * from "./orgs"; export * from "./modules"; export * from "./templates"; export * from "./locations"; diff --git a/web/db/schema/items.ts b/web/db/schema/items.ts index a845a42..63793e5 100644 --- a/web/db/schema/items.ts +++ b/web/db/schema/items.ts @@ -7,9 +7,15 @@ import { boolean, } from "drizzle-orm/pg-core"; import { locations } from "./locations"; +import { orgs } from "./orgs"; export const items = pgTable("items", { id: uuid("id").primaryKey().defaultRandom(), + // Isolation: additive. NULL = global catalog row (anyone may read/edit); + // set = private item owned by that org (paid-tier feature). + ownerOrgId: uuid("owner_org_id").references(() => orgs.id, { + onDelete: "cascade", + }), name: text("name").notNull(), description: text("description"), metadata: jsonb("metadata"), // images, datasheets, notes — no prescribed shape @@ -20,6 +26,10 @@ export const items = pgTable("items", { // Co-storability: item-level relationship declaring which items can share a location export const coStorability = pgTable("co_storability", { id: uuid("id").primaryKey().defaultRandom(), + // Isolation: additive. + ownerOrgId: uuid("owner_org_id").references(() => orgs.id, { + onDelete: "cascade", + }), itemAId: uuid("item_a_id") .notNull() .references(() => items.id), @@ -32,6 +42,10 @@ export const coStorability = pgTable("co_storability", { export const assignments = pgTable("assignments", { id: uuid("id").primaryKey().defaultRandom(), + // Isolation: isolated. NOT NULL since migration 0017. + ownerOrgId: uuid("owner_org_id") + .notNull() + .references(() => orgs.id, { onDelete: "cascade" }), itemId: uuid("item_id") .notNull() .references(() => items.id), diff --git a/web/db/schema/locations.ts b/web/db/schema/locations.ts index 76b1e95..68449ed 100644 --- a/web/db/schema/locations.ts +++ b/web/db/schema/locations.ts @@ -10,6 +10,7 @@ import { type AnyPgColumn, } from "drizzle-orm/pg-core"; import { modules } from "./modules"; +import { orgs } from "./orgs"; import { templates, templateVersions } from "./templates"; // Forward reference: inserts ↔ locations is intentionally asymmetric. // inserts.locationId → locations.id (insert lives in a receptacle) @@ -17,6 +18,10 @@ import { templates, templateVersions } from "./templates"; export const locations = pgTable("locations", { id: uuid("id").primaryKey().defaultRandom(), + // Isolation: isolated. NOT NULL since migration 0017. + ownerOrgId: uuid("owner_org_id") + .notNull() + .references(() => orgs.id, { onDelete: "cascade" }), // Nullable: unplaced insert cells have no host module. Set on placement. moduleId: uuid("module_id").references(() => modules.id), parentId: uuid("parent_id").references((): AnyPgColumn => locations.id), @@ -66,6 +71,10 @@ export const locations = pgTable("locations", { export const inserts = pgTable("inserts", { id: uuid("id").primaryKey().defaultRandom(), + // Isolation: isolated. NOT NULL since migration 0017. + ownerOrgId: uuid("owner_org_id") + .notNull() + .references(() => orgs.id, { onDelete: "cascade" }), uid: text("uid").unique(), // short alphanumeric identifier, writable to RFID tags name: text("name"), // optional user-given name for this specific insert templateId: uuid("template_id").references(() => templates.id), diff --git a/web/db/schema/modules.ts b/web/db/schema/modules.ts index d0406b7..987390e 100644 --- a/web/db/schema/modules.ts +++ b/web/db/schema/modules.ts @@ -6,9 +6,14 @@ import { jsonb, integer, } from "drizzle-orm/pg-core"; +import { orgs } from "./orgs"; export const modules = pgTable("modules", { id: uuid("id").primaryKey().defaultRandom(), + // Isolation: isolated. NOT NULL enforced at DB level since migration 0017. + ownerOrgId: uuid("owner_org_id") + .notNull() + .references(() => orgs.id, { onDelete: "cascade" }), name: text("name").notNull(), description: text("description"), primaryDimensionLabel: text("primary_dimension_label").notNull(), // e.g., "level", "drawer" diff --git a/web/db/schema/orgs.ts b/web/db/schema/orgs.ts new file mode 100644 index 0000000..e3e60de --- /dev/null +++ b/web/db/schema/orgs.ts @@ -0,0 +1,38 @@ +import { + pgTable, + uuid, + text, + timestamp, + primaryKey, +} from "drizzle-orm/pg-core"; +import { users } from "./auth"; + +export const orgPlans = ["free", "paid"] as const; +export type OrgPlan = (typeof orgPlans)[number]; + +export const orgRoles = ["owner", "admin", "member", "viewer"] as const; +export type OrgRole = (typeof orgRoles)[number]; + +export const orgs = pgTable("orgs", { + id: uuid("id").primaryKey().defaultRandom(), + name: text("name").notNull(), + slug: text("slug").notNull().unique(), + plan: text("plan").notNull().default("free"), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().notNull(), +}); + +export const userOrgs = pgTable( + "user_orgs", + { + userId: uuid("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + orgId: uuid("org_id") + .notNull() + .references(() => orgs.id, { onDelete: "cascade" }), + role: text("role").notNull(), + createdAt: timestamp("created_at").defaultNow().notNull(), + }, + (t) => [primaryKey({ columns: [t.userId, t.orgId] })], +); diff --git a/web/db/schema/taxonomy.ts b/web/db/schema/taxonomy.ts index aa5b6b1..0a654d0 100644 --- a/web/db/schema/taxonomy.ts +++ b/web/db/schema/taxonomy.ts @@ -9,10 +9,17 @@ import { unique, } from "drizzle-orm/pg-core"; import { items } from "./items"; +import { orgs } from "./orgs"; + +// Isolation for every table in this file: additive. NULL = global +// taxonomy (seeded, shared). Set = org-private addition layered on top. // System-managed visual labels for items export const categories = pgTable("categories", { id: uuid("id").primaryKey().defaultRandom(), + ownerOrgId: uuid("owner_org_id").references(() => orgs.id, { + onDelete: "cascade", + }), name: text("name").notNull().unique(), icon: text("icon"), // short icon key / emoji; fallback when `svg` is null svg: text("svg"), // inline SVG markup — preferred over `icon` when set @@ -25,6 +32,9 @@ export const categories = pgTable("categories", { // Reusable parameter specs — atomic unit of item description export const parameterDefinitions = pgTable("parameter_definitions", { id: uuid("id").primaryKey().defaultRandom(), + ownerOrgId: uuid("owner_org_id").references(() => orgs.id, { + onDelete: "cascade", + }), name: text("name").notNull().unique(), dataType: text("data_type").notNull(), // "numeric" | "text" | "boolean" | "enum" unit: text("unit"), // mm, inches, V, ohms — null if dimensionless @@ -39,6 +49,9 @@ export const parameterDefinitions = pgTable("parameter_definitions", { // Reusable parameter groups describing one facet of an item export const aspects = pgTable("aspects", { id: uuid("id").primaryKey().defaultRandom(), + ownerOrgId: uuid("owner_org_id").references(() => orgs.id, { + onDelete: "cascade", + }), name: text("name").notNull().unique(), description: text("description"), createdAt: timestamp("created_at").defaultNow().notNull(), @@ -50,6 +63,9 @@ export const aspectParameters = pgTable( "aspect_parameters", { id: uuid("id").primaryKey().defaultRandom(), + ownerOrgId: uuid("owner_org_id").references(() => orgs.id, { + onDelete: "cascade", + }), aspectId: uuid("aspect_id") .notNull() .references(() => aspects.id, { onDelete: "cascade" }), @@ -68,6 +84,9 @@ export const itemCategories = pgTable( "item_categories", { id: uuid("id").primaryKey().defaultRandom(), + ownerOrgId: uuid("owner_org_id").references(() => orgs.id, { + onDelete: "cascade", + }), itemId: uuid("item_id") .notNull() .references(() => items.id, { onDelete: "cascade" }), @@ -85,6 +104,9 @@ export const itemAspects = pgTable( "item_aspects", { id: uuid("id").primaryKey().defaultRandom(), + ownerOrgId: uuid("owner_org_id").references(() => orgs.id, { + onDelete: "cascade", + }), itemId: uuid("item_id") .notNull() .references(() => items.id, { onDelete: "cascade" }), @@ -99,6 +121,9 @@ export const itemAspects = pgTable( // Named classification system — carries lookup tables, linked to aspects via aspect_standards export const standards = pgTable("standards", { id: uuid("id").primaryKey().defaultRandom(), + ownerOrgId: uuid("owner_org_id").references(() => orgs.id, { + onDelete: "cascade", + }), name: text("name").notNull().unique(), description: text("description"), domainTag: text("domain_tag"), // e.g., "Unified Thread Standard" for UNC/UNF grouping @@ -111,6 +136,9 @@ export const aspectStandards = pgTable( "aspect_standards", { id: uuid("id").primaryKey().defaultRandom(), + ownerOrgId: uuid("owner_org_id").references(() => orgs.id, { + onDelete: "cascade", + }), aspectId: uuid("aspect_id") .notNull() .references(() => aspects.id, { onDelete: "cascade" }), @@ -127,6 +155,9 @@ export const standardParameters = pgTable( "standard_parameters", { id: uuid("id").primaryKey().defaultRandom(), + ownerOrgId: uuid("owner_org_id").references(() => orgs.id, { + onDelete: "cascade", + }), standardId: uuid("standard_id") .notNull() .references(() => standards.id, { onDelete: "cascade" }), @@ -144,6 +175,9 @@ export const standardDesignations = pgTable( "standard_designations", { id: uuid("id").primaryKey().defaultRandom(), + ownerOrgId: uuid("owner_org_id").references(() => orgs.id, { + onDelete: "cascade", + }), standardId: uuid("standard_id") .notNull() .references(() => standards.id, { onDelete: "cascade" }), @@ -160,6 +194,9 @@ export const itemStandards = pgTable( "item_standards", { id: uuid("id").primaryKey().defaultRandom(), + ownerOrgId: uuid("owner_org_id").references(() => orgs.id, { + onDelete: "cascade", + }), itemId: uuid("item_id") .notNull() .references(() => items.id, { onDelete: "cascade" }), @@ -181,6 +218,9 @@ export const itemParameterValues = pgTable( "item_parameter_values", { id: uuid("id").primaryKey().defaultRandom(), + ownerOrgId: uuid("owner_org_id").references(() => orgs.id, { + onDelete: "cascade", + }), itemId: uuid("item_id") .notNull() .references(() => items.id, { onDelete: "cascade" }), diff --git a/web/db/schema/templates.ts b/web/db/schema/templates.ts index 06d0016..4f09385 100644 --- a/web/db/schema/templates.ts +++ b/web/db/schema/templates.ts @@ -9,9 +9,15 @@ import { numeric, unique, } from "drizzle-orm/pg-core"; +import { orgs } from "./orgs"; export const templates = pgTable("templates", { id: uuid("id").primaryKey().defaultRandom(), + // Isolation: additive. NULL = global template catalog (e.g. Plano 3600); + // set = custom template owned by that org. + ownerOrgId: uuid("owner_org_id").references(() => orgs.id, { + onDelete: "cascade", + }), name: text("name").notNull(), description: text("description"), currentVersion: integer("current_version").notNull().default(1), @@ -25,6 +31,10 @@ export const templates = pgTable("templates", { export const templateVersions = pgTable("template_versions", { id: uuid("id").primaryKey().defaultRandom(), + // Isolation: additive, follows parent template. + ownerOrgId: uuid("owner_org_id").references(() => orgs.id, { + onDelete: "cascade", + }), templateId: uuid("template_id") .notNull() .references(() => templates.id), @@ -71,6 +81,11 @@ export const templateVersions = pgTable("template_versions", { export const interfaceTypes = pgTable("interface_types", { id: uuid("id").primaryKey().defaultRandom(), + // Isolation: additive. NULL = global interface contract (admin-seeded); + // set = org-private contract. + ownerOrgId: uuid("owner_org_id").references(() => orgs.id, { + onDelete: "cascade", + }), // IMPORTANT: identifier is a mutable display slug. All references to // interface types MUST be by `id` (UUID). Never join on `identifier`. // See specification/interface-type-management.md — load-bearing invariant. @@ -90,6 +105,10 @@ export const templateVersionInterfacesProvided = pgTable( "template_version_interfaces_provided", { id: uuid("id").primaryKey().defaultRandom(), + // Isolation: additive, follows parent template version. + ownerOrgId: uuid("owner_org_id").references(() => orgs.id, { + onDelete: "cascade", + }), templateVersionId: uuid("template_version_id") .notNull() .references(() => templateVersions.id, { onDelete: "cascade" }), @@ -104,6 +123,10 @@ export const templateVersionInterfacesAccepted = pgTable( "template_version_interfaces_accepted", { id: uuid("id").primaryKey().defaultRandom(), + // Isolation: additive, follows parent template version. + ownerOrgId: uuid("owner_org_id").references(() => orgs.id, { + onDelete: "cascade", + }), templateVersionId: uuid("template_version_id") .notNull() .references(() => templateVersions.id, { onDelete: "cascade" }), @@ -118,6 +141,10 @@ export const locationInterfacesAccepted = pgTable( "location_interfaces_accepted", { id: uuid("id").primaryKey().defaultRandom(), + // Isolation: isolated (follows locations). NOT NULL since migration 0017. + ownerOrgId: uuid("owner_org_id") + .notNull() + .references(() => orgs.id, { onDelete: "cascade" }), // FK to locations added in the locations schema to avoid circular import; // logically this is locations.id with ON DELETE CASCADE. Constraint added // manually in the migration. diff --git a/web/db/schema/transactions.ts b/web/db/schema/transactions.ts index 4be385f..32c1110 100644 --- a/web/db/schema/transactions.ts +++ b/web/db/schema/transactions.ts @@ -7,9 +7,22 @@ import { boolean, type AnyPgColumn, } from "drizzle-orm/pg-core"; +import { orgs } from "./orgs"; +import { users } from "./auth"; export const transactions = pgTable("transactions", { id: uuid("id").primaryKey().defaultRandom(), + // Isolation: isolated. Audit events are scoped to the org whose data + // changed. Global-catalog edits (ownerOrgId = NULL on target row) + // still log under the acting user's active org. NOT NULL since 0017. + ownerOrgId: uuid("owner_org_id") + .notNull() + .references(() => orgs.id, { onDelete: "cascade" }), + // Actor who made the change. Nullable for historical rows predating + // auth; always set on new writes. + actorUserId: uuid("actor_user_id").references(() => users.id, { + onDelete: "set null", + }), parentId: uuid("parent_id").references( (): AnyPgColumn => transactions.id ), // for compound transactions diff --git a/web/db/seed.ts b/web/db/seed.ts index f2bebfd..4e9c824 100644 --- a/web/db/seed.ts +++ b/web/db/seed.ts @@ -2,6 +2,7 @@ * Seed script: creates sample taxonomy data and items for UI development. * Run: cd web && npx tsx db/seed.ts */ +import { eq } from "drizzle-orm"; import { db } from "./connection"; import { categoryRepository } from "../repositories/categoryRepository"; import { parameterDefinitionRepository } from "../repositories/parameterDefinitionRepository"; @@ -11,11 +12,32 @@ import { templateRepository } from "../repositories/templateRepository"; import { moduleRepository } from "../repositories/moduleRepository"; import { locationRepository } from "../repositories/locationRepository"; import { interfaceTypeRepository } from "../repositories/interfaceTypeRepository"; -import { categories, parameterDefinitions, aspects, templates, modules } from "./schema"; +import { + categories, + parameterDefinitions, + aspects, + templates, + modules, + orgs, + users, +} from "./schema"; + +async function getSeedCtx() { + const [org] = await db.select().from(orgs).where(eq(orgs.slug, "default")); + if (!org) throw new Error("default org not seeded — run migrations first"); + const [user] = await db + .select() + .from(users) + .where(eq(users.email, "default@wheretf.local")); + if (!user) throw new Error("default user not seeded — run migrations first"); + return { userId: user.id, orgId: org.id }; +} async function seed() { console.log("Seeding..."); + const ctx = await getSeedCtx(); + const existingCats = await db.select().from(categories); const hasTaxonomy = existingCats.length > 0; @@ -41,36 +63,48 @@ async function seed() { console.log("Taxonomy/items already seeded, skipping."); } else { const catFastener = await categoryRepository.create({ + ...ctx, + asGlobal: true, name: "Fasteners", icon: "🔩", color: "#6366f1", sortOrder: 1, }); const catElectronic = await categoryRepository.create({ + ...ctx, + asGlobal: true, name: "Electronics", icon: "⚡", color: "#f59e0b", sortOrder: 2, }); const catTool = await categoryRepository.create({ + ...ctx, + asGlobal: true, name: "Tools", icon: "🔧", color: "#10b981", sortOrder: 3, }); const catAdhesive = await categoryRepository.create({ + ...ctx, + asGlobal: true, name: "Adhesives", icon: "🧴", color: "#ec4899", sortOrder: 4, }); const catWire = await categoryRepository.create({ + ...ctx, + asGlobal: true, name: "Wire & Cable", icon: "🔌", color: "#8b5cf6", sortOrder: 5, }); const catMeasure = await categoryRepository.create({ + ...ctx, + asGlobal: true, name: "Measurement", icon: "📏", color: "#06b6d4", @@ -79,15 +113,21 @@ async function seed() { // --- Parameter Definitions --- const pdThreadSize = await parameterDefinitionRepository.create({ + ...ctx, + asGlobal: true, name: "thread_size", dataType: "text", }); const pdLength = await parameterDefinitionRepository.create({ + ...ctx, + asGlobal: true, name: "length", dataType: "numeric", unit: "mm", }); const pdMaterial = await parameterDefinitionRepository.create({ + ...ctx, + asGlobal: true, name: "material", dataType: "enum", constraints: { @@ -102,6 +142,8 @@ async function seed() { }, }); const pdHeadType = await parameterDefinitionRepository.create({ + ...ctx, + asGlobal: true, name: "head_type", dataType: "enum", constraints: { @@ -109,6 +151,8 @@ async function seed() { }, }); const pdDriveType = await parameterDefinitionRepository.create({ + ...ctx, + asGlobal: true, name: "drive_type", dataType: "enum", constraints: { @@ -116,25 +160,35 @@ async function seed() { }, }); const pdVoltage = await parameterDefinitionRepository.create({ + ...ctx, + asGlobal: true, name: "voltage_rating", dataType: "numeric", unit: "V", }); const pdCapacitance = await parameterDefinitionRepository.create({ + ...ctx, + asGlobal: true, name: "capacitance", dataType: "numeric", unit: "µF", }); const pdResistance = await parameterDefinitionRepository.create({ + ...ctx, + asGlobal: true, name: "resistance", dataType: "numeric", unit: "Ω", }); const pdTolerance = await parameterDefinitionRepository.create({ + ...ctx, + asGlobal: true, name: "tolerance", dataType: "text", }); const pdPackage = await parameterDefinitionRepository.create({ + ...ctx, + asGlobal: true, name: "package", dataType: "enum", constraints: { @@ -142,24 +196,34 @@ async function seed() { }, }); const pdWeight = await parameterDefinitionRepository.create({ + ...ctx, + asGlobal: true, name: "weight", dataType: "numeric", unit: "g", }); const pdColor = await parameterDefinitionRepository.create({ + ...ctx, + asGlobal: true, name: "color", dataType: "text", }); const pdGauge = await parameterDefinitionRepository.create({ + ...ctx, + asGlobal: true, name: "gauge", dataType: "text", }); const pdCureTime = await parameterDefinitionRepository.create({ + ...ctx, + asGlobal: true, name: "cure_time", dataType: "numeric", unit: "min", }); const pdTempRating = await parameterDefinitionRepository.create({ + ...ctx, + asGlobal: true, name: "temp_rating", dataType: "numeric", unit: "°C", @@ -167,241 +231,289 @@ async function seed() { // --- Aspects --- const aspectThread = await aspectRepository.create({ + ...ctx, + asGlobal: true, name: "Threading", description: "Thread specifications for fasteners", }); await aspectRepository.addParameter({ + ...ctx, aspectId: aspectThread.id, parameterDefinitionId: pdThreadSize.id, sortOrder: 0, }); await aspectRepository.addParameter({ + ...ctx, aspectId: aspectThread.id, parameterDefinitionId: pdLength.id, sortOrder: 1, }); const aspectScrew = await aspectRepository.create({ + ...ctx, + asGlobal: true, name: "Screw Head", description: "Head and drive type for screws", }); await aspectRepository.addParameter({ + ...ctx, aspectId: aspectScrew.id, parameterDefinitionId: pdHeadType.id, sortOrder: 0, }); await aspectRepository.addParameter({ + ...ctx, aspectId: aspectScrew.id, parameterDefinitionId: pdDriveType.id, sortOrder: 1, }); const aspectPhysical = await aspectRepository.create({ + ...ctx, + asGlobal: true, name: "Physical Properties", description: "Weight, material, color", }); await aspectRepository.addParameter({ + ...ctx, aspectId: aspectPhysical.id, parameterDefinitionId: pdMaterial.id, sortOrder: 0, }); await aspectRepository.addParameter({ + ...ctx, aspectId: aspectPhysical.id, parameterDefinitionId: pdWeight.id, sortOrder: 1, }); await aspectRepository.addParameter({ + ...ctx, aspectId: aspectPhysical.id, parameterDefinitionId: pdColor.id, sortOrder: 2, }); const aspectElectrical = await aspectRepository.create({ + ...ctx, + asGlobal: true, name: "Electrical Ratings", description: "Voltage, tolerance, package", }); await aspectRepository.addParameter({ + ...ctx, aspectId: aspectElectrical.id, parameterDefinitionId: pdVoltage.id, sortOrder: 0, }); await aspectRepository.addParameter({ + ...ctx, aspectId: aspectElectrical.id, parameterDefinitionId: pdTolerance.id, sortOrder: 1, }); await aspectRepository.addParameter({ + ...ctx, aspectId: aspectElectrical.id, parameterDefinitionId: pdPackage.id, sortOrder: 2, }); - // --- Items --- + // --- Items (global catalog contributions) --- // Machine Screws const screw1 = await itemRepository.create({ + ...ctx, + asGlobal: true, name: "M3x10 Socket Head Cap Screw", description: "Stainless steel socket head cap screw", }); - await itemRepository.addCategory({ itemId: screw1.id, categoryId: catFastener.id, isPrimary: true }); - await itemRepository.applyAspect({ itemId: screw1.id, aspectId: aspectThread.id }); - await itemRepository.applyAspect({ itemId: screw1.id, aspectId: aspectScrew.id }); - await itemRepository.applyAspect({ itemId: screw1.id, aspectId: aspectPhysical.id }); - await itemRepository.setParameterValue({ itemId: screw1.id, parameterDefinitionId: pdThreadSize.id, value: "M3" }); - await itemRepository.setParameterValue({ itemId: screw1.id, parameterDefinitionId: pdLength.id, value: 10 }); - await itemRepository.setParameterValue({ itemId: screw1.id, parameterDefinitionId: pdHeadType.id, value: "socket" }); - await itemRepository.setParameterValue({ itemId: screw1.id, parameterDefinitionId: pdDriveType.id, value: "hex" }); - await itemRepository.setParameterValue({ itemId: screw1.id, parameterDefinitionId: pdMaterial.id, value: "stainless_steel" }); + await itemRepository.addCategory({ ...ctx, itemId: screw1.id, categoryId: catFastener.id, isPrimary: true }); + await itemRepository.applyAspect({ ...ctx, itemId: screw1.id, aspectId: aspectThread.id }); + await itemRepository.applyAspect({ ...ctx, itemId: screw1.id, aspectId: aspectScrew.id }); + await itemRepository.applyAspect({ ...ctx, itemId: screw1.id, aspectId: aspectPhysical.id }); + await itemRepository.setParameterValue({ ...ctx, itemId: screw1.id, parameterDefinitionId: pdThreadSize.id, value: "M3" }); + await itemRepository.setParameterValue({ ...ctx, itemId: screw1.id, parameterDefinitionId: pdLength.id, value: 10 }); + await itemRepository.setParameterValue({ ...ctx, itemId: screw1.id, parameterDefinitionId: pdHeadType.id, value: "socket" }); + await itemRepository.setParameterValue({ ...ctx, itemId: screw1.id, parameterDefinitionId: pdDriveType.id, value: "hex" }); + await itemRepository.setParameterValue({ ...ctx, itemId: screw1.id, parameterDefinitionId: pdMaterial.id, value: "stainless_steel" }); const screw2 = await itemRepository.create({ + ...ctx, + asGlobal: true, name: "M4x20 Pan Head Machine Screw", description: "Zinc-plated steel pan head screw", }); - await itemRepository.addCategory({ itemId: screw2.id, categoryId: catFastener.id, isPrimary: true }); - await itemRepository.applyAspect({ itemId: screw2.id, aspectId: aspectThread.id }); - await itemRepository.applyAspect({ itemId: screw2.id, aspectId: aspectScrew.id }); - await itemRepository.applyAspect({ itemId: screw2.id, aspectId: aspectPhysical.id }); - await itemRepository.setParameterValue({ itemId: screw2.id, parameterDefinitionId: pdThreadSize.id, value: "M4" }); - await itemRepository.setParameterValue({ itemId: screw2.id, parameterDefinitionId: pdLength.id, value: 20 }); - await itemRepository.setParameterValue({ itemId: screw2.id, parameterDefinitionId: pdHeadType.id, value: "pan" }); - await itemRepository.setParameterValue({ itemId: screw2.id, parameterDefinitionId: pdDriveType.id, value: "phillips" }); - await itemRepository.setParameterValue({ itemId: screw2.id, parameterDefinitionId: pdMaterial.id, value: "steel" }); + await itemRepository.addCategory({ ...ctx, itemId: screw2.id, categoryId: catFastener.id, isPrimary: true }); + await itemRepository.applyAspect({ ...ctx, itemId: screw2.id, aspectId: aspectThread.id }); + await itemRepository.applyAspect({ ...ctx, itemId: screw2.id, aspectId: aspectScrew.id }); + await itemRepository.applyAspect({ ...ctx, itemId: screw2.id, aspectId: aspectPhysical.id }); + await itemRepository.setParameterValue({ ...ctx, itemId: screw2.id, parameterDefinitionId: pdThreadSize.id, value: "M4" }); + await itemRepository.setParameterValue({ ...ctx, itemId: screw2.id, parameterDefinitionId: pdLength.id, value: 20 }); + await itemRepository.setParameterValue({ ...ctx, itemId: screw2.id, parameterDefinitionId: pdHeadType.id, value: "pan" }); + await itemRepository.setParameterValue({ ...ctx, itemId: screw2.id, parameterDefinitionId: pdDriveType.id, value: "phillips" }); + await itemRepository.setParameterValue({ ...ctx, itemId: screw2.id, parameterDefinitionId: pdMaterial.id, value: "steel" }); const screw3 = await itemRepository.create({ + ...ctx, + asGlobal: true, name: "M5x16 Flat Head Screw", }); - await itemRepository.addCategory({ itemId: screw3.id, categoryId: catFastener.id, isPrimary: true }); - await itemRepository.applyAspect({ itemId: screw3.id, aspectId: aspectThread.id }); - await itemRepository.applyAspect({ itemId: screw3.id, aspectId: aspectScrew.id }); - await itemRepository.setParameterValue({ itemId: screw3.id, parameterDefinitionId: pdThreadSize.id, value: "M5" }); - await itemRepository.setParameterValue({ itemId: screw3.id, parameterDefinitionId: pdLength.id, value: 16 }); - await itemRepository.setParameterValue({ itemId: screw3.id, parameterDefinitionId: pdHeadType.id, value: "flat" }); - await itemRepository.setParameterValue({ itemId: screw3.id, parameterDefinitionId: pdDriveType.id, value: "torx" }); + await itemRepository.addCategory({ ...ctx, itemId: screw3.id, categoryId: catFastener.id, isPrimary: true }); + await itemRepository.applyAspect({ ...ctx, itemId: screw3.id, aspectId: aspectThread.id }); + await itemRepository.applyAspect({ ...ctx, itemId: screw3.id, aspectId: aspectScrew.id }); + await itemRepository.setParameterValue({ ...ctx, itemId: screw3.id, parameterDefinitionId: pdThreadSize.id, value: "M5" }); + await itemRepository.setParameterValue({ ...ctx, itemId: screw3.id, parameterDefinitionId: pdLength.id, value: 16 }); + await itemRepository.setParameterValue({ ...ctx, itemId: screw3.id, parameterDefinitionId: pdHeadType.id, value: "flat" }); + await itemRepository.setParameterValue({ ...ctx, itemId: screw3.id, parameterDefinitionId: pdDriveType.id, value: "torx" }); // Nuts & Washers const nut1 = await itemRepository.create({ + ...ctx, + asGlobal: true, name: "M3 Hex Nut", description: "Stainless steel hex nut", }); - await itemRepository.addCategory({ itemId: nut1.id, categoryId: catFastener.id, isPrimary: true }); - await itemRepository.applyAspect({ itemId: nut1.id, aspectId: aspectThread.id }); - await itemRepository.applyAspect({ itemId: nut1.id, aspectId: aspectPhysical.id }); - await itemRepository.setParameterValue({ itemId: nut1.id, parameterDefinitionId: pdThreadSize.id, value: "M3" }); - await itemRepository.setParameterValue({ itemId: nut1.id, parameterDefinitionId: pdMaterial.id, value: "stainless_steel" }); + await itemRepository.addCategory({ ...ctx, itemId: nut1.id, categoryId: catFastener.id, isPrimary: true }); + await itemRepository.applyAspect({ ...ctx, itemId: nut1.id, aspectId: aspectThread.id }); + await itemRepository.applyAspect({ ...ctx, itemId: nut1.id, aspectId: aspectPhysical.id }); + await itemRepository.setParameterValue({ ...ctx, itemId: nut1.id, parameterDefinitionId: pdThreadSize.id, value: "M3" }); + await itemRepository.setParameterValue({ ...ctx, itemId: nut1.id, parameterDefinitionId: pdMaterial.id, value: "stainless_steel" }); - const nut2 = await itemRepository.create({ name: "M4 Nylon Lock Nut" }); - await itemRepository.addCategory({ itemId: nut2.id, categoryId: catFastener.id, isPrimary: true }); - await itemRepository.applyAspect({ itemId: nut2.id, aspectId: aspectThread.id }); - await itemRepository.setParameterValue({ itemId: nut2.id, parameterDefinitionId: pdThreadSize.id, value: "M4" }); + const nut2 = await itemRepository.create({ ...ctx, asGlobal: true, name: "M4 Nylon Lock Nut" }); + await itemRepository.addCategory({ ...ctx, itemId: nut2.id, categoryId: catFastener.id, isPrimary: true }); + await itemRepository.applyAspect({ ...ctx, itemId: nut2.id, aspectId: aspectThread.id }); + await itemRepository.setParameterValue({ ...ctx, itemId: nut2.id, parameterDefinitionId: pdThreadSize.id, value: "M4" }); // Electronics const cap1 = await itemRepository.create({ + ...ctx, + asGlobal: true, name: "100µF Electrolytic Capacitor", description: "25V aluminum electrolytic capacitor", }); - await itemRepository.addCategory({ itemId: cap1.id, categoryId: catElectronic.id, isPrimary: true }); - await itemRepository.applyAspect({ itemId: cap1.id, aspectId: aspectElectrical.id }); - await itemRepository.setParameterValue({ itemId: cap1.id, parameterDefinitionId: pdCapacitance.id, value: 100 }); - await itemRepository.setParameterValue({ itemId: cap1.id, parameterDefinitionId: pdVoltage.id, value: 25 }); - await itemRepository.setParameterValue({ itemId: cap1.id, parameterDefinitionId: pdPackage.id, value: "through-hole" }); - await itemRepository.setParameterValue({ itemId: cap1.id, parameterDefinitionId: pdTolerance.id, value: "±20%" }); + await itemRepository.addCategory({ ...ctx, itemId: cap1.id, categoryId: catElectronic.id, isPrimary: true }); + await itemRepository.applyAspect({ ...ctx, itemId: cap1.id, aspectId: aspectElectrical.id }); + await itemRepository.setParameterValue({ ...ctx, itemId: cap1.id, parameterDefinitionId: pdCapacitance.id, value: 100 }); + await itemRepository.setParameterValue({ ...ctx, itemId: cap1.id, parameterDefinitionId: pdVoltage.id, value: 25 }); + await itemRepository.setParameterValue({ ...ctx, itemId: cap1.id, parameterDefinitionId: pdPackage.id, value: "through-hole" }); + await itemRepository.setParameterValue({ ...ctx, itemId: cap1.id, parameterDefinitionId: pdTolerance.id, value: "±20%" }); const cap2 = await itemRepository.create({ + ...ctx, + asGlobal: true, name: "10µF Ceramic Capacitor", description: "50V X7R ceramic capacitor", }); - await itemRepository.addCategory({ itemId: cap2.id, categoryId: catElectronic.id, isPrimary: true }); - await itemRepository.applyAspect({ itemId: cap2.id, aspectId: aspectElectrical.id }); - await itemRepository.setParameterValue({ itemId: cap2.id, parameterDefinitionId: pdCapacitance.id, value: 10 }); - await itemRepository.setParameterValue({ itemId: cap2.id, parameterDefinitionId: pdVoltage.id, value: 50 }); - await itemRepository.setParameterValue({ itemId: cap2.id, parameterDefinitionId: pdPackage.id, value: "0805" }); - await itemRepository.setParameterValue({ itemId: cap2.id, parameterDefinitionId: pdTolerance.id, value: "±10%" }); + await itemRepository.addCategory({ ...ctx, itemId: cap2.id, categoryId: catElectronic.id, isPrimary: true }); + await itemRepository.applyAspect({ ...ctx, itemId: cap2.id, aspectId: aspectElectrical.id }); + await itemRepository.setParameterValue({ ...ctx, itemId: cap2.id, parameterDefinitionId: pdCapacitance.id, value: 10 }); + await itemRepository.setParameterValue({ ...ctx, itemId: cap2.id, parameterDefinitionId: pdVoltage.id, value: 50 }); + await itemRepository.setParameterValue({ ...ctx, itemId: cap2.id, parameterDefinitionId: pdPackage.id, value: "0805" }); + await itemRepository.setParameterValue({ ...ctx, itemId: cap2.id, parameterDefinitionId: pdTolerance.id, value: "±10%" }); const resistor1 = await itemRepository.create({ + ...ctx, + asGlobal: true, name: "10kΩ Resistor", description: "1/4W metal film resistor", }); - await itemRepository.addCategory({ itemId: resistor1.id, categoryId: catElectronic.id, isPrimary: true }); - await itemRepository.applyAspect({ itemId: resistor1.id, aspectId: aspectElectrical.id }); - await itemRepository.setParameterValue({ itemId: resistor1.id, parameterDefinitionId: pdResistance.id, value: 10000 }); - await itemRepository.setParameterValue({ itemId: resistor1.id, parameterDefinitionId: pdTolerance.id, value: "±1%" }); - await itemRepository.setParameterValue({ itemId: resistor1.id, parameterDefinitionId: pdPackage.id, value: "through-hole" }); + await itemRepository.addCategory({ ...ctx, itemId: resistor1.id, categoryId: catElectronic.id, isPrimary: true }); + await itemRepository.applyAspect({ ...ctx, itemId: resistor1.id, aspectId: aspectElectrical.id }); + await itemRepository.setParameterValue({ ...ctx, itemId: resistor1.id, parameterDefinitionId: pdResistance.id, value: 10000 }); + await itemRepository.setParameterValue({ ...ctx, itemId: resistor1.id, parameterDefinitionId: pdTolerance.id, value: "±1%" }); + await itemRepository.setParameterValue({ ...ctx, itemId: resistor1.id, parameterDefinitionId: pdPackage.id, value: "through-hole" }); const resistor2 = await itemRepository.create({ + ...ctx, + asGlobal: true, name: "470Ω SMD Resistor", }); - await itemRepository.addCategory({ itemId: resistor2.id, categoryId: catElectronic.id, isPrimary: true }); - await itemRepository.applyAspect({ itemId: resistor2.id, aspectId: aspectElectrical.id }); - await itemRepository.setParameterValue({ itemId: resistor2.id, parameterDefinitionId: pdResistance.id, value: 470 }); - await itemRepository.setParameterValue({ itemId: resistor2.id, parameterDefinitionId: pdPackage.id, value: "0603" }); + await itemRepository.addCategory({ ...ctx, itemId: resistor2.id, categoryId: catElectronic.id, isPrimary: true }); + await itemRepository.applyAspect({ ...ctx, itemId: resistor2.id, aspectId: aspectElectrical.id }); + await itemRepository.setParameterValue({ ...ctx, itemId: resistor2.id, parameterDefinitionId: pdResistance.id, value: 470 }); + await itemRepository.setParameterValue({ ...ctx, itemId: resistor2.id, parameterDefinitionId: pdPackage.id, value: "0603" }); const led1 = await itemRepository.create({ + ...ctx, + asGlobal: true, name: "5mm Red LED", description: "Standard through-hole LED, 2.0V forward voltage", }); - await itemRepository.addCategory({ itemId: led1.id, categoryId: catElectronic.id, isPrimary: true }); - await itemRepository.applyAspect({ itemId: led1.id, aspectId: aspectElectrical.id }); - await itemRepository.applyAspect({ itemId: led1.id, aspectId: aspectPhysical.id }); - await itemRepository.setParameterValue({ itemId: led1.id, parameterDefinitionId: pdVoltage.id, value: 2.0 }); - await itemRepository.setParameterValue({ itemId: led1.id, parameterDefinitionId: pdColor.id, value: "red" }); - await itemRepository.setParameterValue({ itemId: led1.id, parameterDefinitionId: pdPackage.id, value: "through-hole" }); + await itemRepository.addCategory({ ...ctx, itemId: led1.id, categoryId: catElectronic.id, isPrimary: true }); + await itemRepository.applyAspect({ ...ctx, itemId: led1.id, aspectId: aspectElectrical.id }); + await itemRepository.applyAspect({ ...ctx, itemId: led1.id, aspectId: aspectPhysical.id }); + await itemRepository.setParameterValue({ ...ctx, itemId: led1.id, parameterDefinitionId: pdVoltage.id, value: 2.0 }); + await itemRepository.setParameterValue({ ...ctx, itemId: led1.id, parameterDefinitionId: pdColor.id, value: "red" }); + await itemRepository.setParameterValue({ ...ctx, itemId: led1.id, parameterDefinitionId: pdPackage.id, value: "through-hole" }); // Tools const tool1 = await itemRepository.create({ + ...ctx, + asGlobal: true, name: "Weller WE1010 Soldering Station", description: "70W digital soldering station", }); - await itemRepository.addCategory({ itemId: tool1.id, categoryId: catTool.id, isPrimary: true }); - await itemRepository.addCategory({ itemId: tool1.id, categoryId: catElectronic.id }); - await itemRepository.applyAspect({ itemId: tool1.id, aspectId: aspectElectrical.id }); - await itemRepository.setParameterValue({ itemId: tool1.id, parameterDefinitionId: pdVoltage.id, value: 120 }); - await itemRepository.setParameterValue({ itemId: tool1.id, parameterDefinitionId: pdTempRating.id, value: 450 }); + await itemRepository.addCategory({ ...ctx, itemId: tool1.id, categoryId: catTool.id, isPrimary: true }); + await itemRepository.addCategory({ ...ctx, itemId: tool1.id, categoryId: catElectronic.id }); + await itemRepository.applyAspect({ ...ctx, itemId: tool1.id, aspectId: aspectElectrical.id }); + await itemRepository.setParameterValue({ ...ctx, itemId: tool1.id, parameterDefinitionId: pdVoltage.id, value: 120 }); + await itemRepository.setParameterValue({ ...ctx, itemId: tool1.id, parameterDefinitionId: pdTempRating.id, value: 450 }); const tool2 = await itemRepository.create({ + ...ctx, + asGlobal: true, name: "Digital Calipers", description: "6-inch stainless steel digital calipers", }); - await itemRepository.addCategory({ itemId: tool2.id, categoryId: catTool.id, isPrimary: true }); - await itemRepository.addCategory({ itemId: tool2.id, categoryId: catMeasure.id }); + await itemRepository.addCategory({ ...ctx, itemId: tool2.id, categoryId: catTool.id, isPrimary: true }); + await itemRepository.addCategory({ ...ctx, itemId: tool2.id, categoryId: catMeasure.id }); // Adhesives const glue1 = await itemRepository.create({ + ...ctx, + asGlobal: true, name: "Loctite Super Glue", description: "Instant cyanoacrylate adhesive", }); - await itemRepository.addCategory({ itemId: glue1.id, categoryId: catAdhesive.id, isPrimary: true }); - await itemRepository.setParameterValue({ itemId: glue1.id, parameterDefinitionId: pdCureTime.id, value: 1 }); + await itemRepository.addCategory({ ...ctx, itemId: glue1.id, categoryId: catAdhesive.id, isPrimary: true }); + await itemRepository.setParameterValue({ ...ctx, itemId: glue1.id, parameterDefinitionId: pdCureTime.id, value: 1 }); const glue2 = await itemRepository.create({ + ...ctx, + asGlobal: true, name: "JB Weld Original", description: "Two-part epoxy, steel-reinforced", }); - await itemRepository.addCategory({ itemId: glue2.id, categoryId: catAdhesive.id, isPrimary: true }); - await itemRepository.setParameterValue({ itemId: glue2.id, parameterDefinitionId: pdCureTime.id, value: 960 }); - await itemRepository.setParameterValue({ itemId: glue2.id, parameterDefinitionId: pdTempRating.id, value: 288 }); + await itemRepository.addCategory({ ...ctx, itemId: glue2.id, categoryId: catAdhesive.id, isPrimary: true }); + await itemRepository.setParameterValue({ ...ctx, itemId: glue2.id, parameterDefinitionId: pdCureTime.id, value: 960 }); + await itemRepository.setParameterValue({ ...ctx, itemId: glue2.id, parameterDefinitionId: pdTempRating.id, value: 288 }); // Wire const wire1 = await itemRepository.create({ + ...ctx, + asGlobal: true, name: "22 AWG Hookup Wire (Red)", description: "Solid core hookup wire, 25ft spool", }); - await itemRepository.addCategory({ itemId: wire1.id, categoryId: catWire.id, isPrimary: true }); - await itemRepository.addCategory({ itemId: wire1.id, categoryId: catElectronic.id }); - await itemRepository.applyAspect({ itemId: wire1.id, aspectId: aspectPhysical.id }); - await itemRepository.setParameterValue({ itemId: wire1.id, parameterDefinitionId: pdGauge.id, value: "22 AWG" }); - await itemRepository.setParameterValue({ itemId: wire1.id, parameterDefinitionId: pdColor.id, value: "red" }); - await itemRepository.setParameterValue({ itemId: wire1.id, parameterDefinitionId: pdVoltage.id, value: 300 }); + await itemRepository.addCategory({ ...ctx, itemId: wire1.id, categoryId: catWire.id, isPrimary: true }); + await itemRepository.addCategory({ ...ctx, itemId: wire1.id, categoryId: catElectronic.id }); + await itemRepository.applyAspect({ ...ctx, itemId: wire1.id, aspectId: aspectPhysical.id }); + await itemRepository.setParameterValue({ ...ctx, itemId: wire1.id, parameterDefinitionId: pdGauge.id, value: "22 AWG" }); + await itemRepository.setParameterValue({ ...ctx, itemId: wire1.id, parameterDefinitionId: pdColor.id, value: "red" }); + await itemRepository.setParameterValue({ ...ctx, itemId: wire1.id, parameterDefinitionId: pdVoltage.id, value: 300 }); const wire2 = await itemRepository.create({ + ...ctx, + asGlobal: true, name: "22 AWG Hookup Wire (Black)", description: "Solid core hookup wire, 25ft spool", }); - await itemRepository.addCategory({ itemId: wire2.id, categoryId: catWire.id, isPrimary: true }); - await itemRepository.addCategory({ itemId: wire2.id, categoryId: catElectronic.id }); - await itemRepository.applyAspect({ itemId: wire2.id, aspectId: aspectPhysical.id }); - await itemRepository.setParameterValue({ itemId: wire2.id, parameterDefinitionId: pdGauge.id, value: "22 AWG" }); - await itemRepository.setParameterValue({ itemId: wire2.id, parameterDefinitionId: pdColor.id, value: "black" }); - await itemRepository.setParameterValue({ itemId: wire2.id, parameterDefinitionId: pdVoltage.id, value: 300 }); + await itemRepository.addCategory({ ...ctx, itemId: wire2.id, categoryId: catWire.id, isPrimary: true }); + await itemRepository.addCategory({ ...ctx, itemId: wire2.id, categoryId: catElectronic.id }); + await itemRepository.applyAspect({ ...ctx, itemId: wire2.id, aspectId: aspectPhysical.id }); + await itemRepository.setParameterValue({ ...ctx, itemId: wire2.id, parameterDefinitionId: pdGauge.id, value: "22 AWG" }); + await itemRepository.setParameterValue({ ...ctx, itemId: wire2.id, parameterDefinitionId: pdColor.id, value: "black" }); + await itemRepository.setParameterValue({ ...ctx, itemId: wire2.id, parameterDefinitionId: pdVoltage.id, value: 300 }); console.log("Seeded 16 items across 6 categories with aspects and parameters."); } // end taxonomy block @@ -433,16 +545,23 @@ async function seed() { const ifaceIds: Record = {}; for (const s of ifaceSeeds) { let existing = await interfaceTypeRepository.findByIdentifier({ + orgId: ctx.orgId, identifier: s.identifier, }); if (!existing) { - existing = await interfaceTypeRepository.create(s); + existing = await interfaceTypeRepository.create({ + ...ctx, + asGlobal: true, + ...s, + }); } ifaceIds[s.identifier] = existing.id; } // Plano 3600 Stowaway — classic tackle box tray const tplPlano3600 = await templateRepository.create({ + ...ctx, + asGlobal: true, name: "Plano 3600 Stowaway", description: "4-row adjustable compartment box, removable column dividers", rows: 4, @@ -458,6 +577,7 @@ async function seed() { // Publish a v2 with different column count await templateRepository.publishVersion({ + ...ctx, templateId: tplPlano3600.id, rows: 4, columns: 4, @@ -471,6 +591,8 @@ async function seed() { // Plano 3700 Stowaway — deeper, fewer rows await templateRepository.create({ + ...ctx, + asGlobal: true, name: "Plano 3700 Stowaway", description: "3-row deep compartment box, removable dividers", rows: 3, @@ -486,6 +608,8 @@ async function seed() { // Gridfinity baseplate — parametric await templateRepository.create({ + ...ctx, + asGlobal: true, name: "Gridfinity Baseplate", description: "42mm modular grid system, parametric sizing", isParametric: true, @@ -506,6 +630,8 @@ async function seed() { // Small parts drawer — simple 2x3 await templateRepository.create({ + ...ctx, + asGlobal: true, name: "Small Parts Drawer", description: "Simple 2×3 compartment drawer insert", rows: 2, @@ -521,6 +647,8 @@ async function seed() { // ALEX drawer divider — IKEA drawer organizer await templateRepository.create({ + ...ctx, + asGlobal: true, name: "ALEX Drawer Divider", description: "IKEA ALEX drawer compartment layout", rows: 3, @@ -544,13 +672,17 @@ async function seed() { } else { // MUSE — 11-shelf cabinet const muse = await moduleRepository.create({ + ...ctx, name: "MUSE", description: "Red metal cabinet, 11 shelf levels, under workbench", primaryDimensionLabel: "level", primaryDimensionCount: 11, }); const ifaceByIdentifier = async (identifier: string) => { - const row = await interfaceTypeRepository.findByIdentifier({ identifier }); + const row = await interfaceTypeRepository.findByIdentifier({ + orgId: ctx.orgId, + identifier, + }); if (!row) throw new Error(`Interface type ${identifier} not seeded`); return row.id; }; @@ -560,6 +692,7 @@ async function seed() { for (let i = 1; i <= 11; i++) { await locationRepository.create({ + ...ctx, moduleId: muse.id, label: String(i), pathSegments: ["MUSE", String(i)], @@ -570,6 +703,7 @@ async function seed() { // ALEX — 5-drawer IKEA unit const alex = await moduleRepository.create({ + ...ctx, name: "ALEX", description: "IKEA ALEX 5-drawer unit, white, right side of desk", primaryDimensionLabel: "drawer", @@ -577,6 +711,7 @@ async function seed() { }); for (let i = 1; i <= 5; i++) { await locationRepository.create({ + ...ctx, moduleId: alex.id, label: String(i), pathSegments: ["ALEX", String(i)], @@ -587,6 +722,7 @@ async function seed() { // BENCH — workbench with 3 bays const bench = await moduleRepository.create({ + ...ctx, name: "BENCH", description: "Main workbench, 3 open bays underneath", primaryDimensionLabel: "bay", @@ -594,6 +730,7 @@ async function seed() { }); for (let i = 1; i <= 3; i++) { await locationRepository.create({ + ...ctx, moduleId: bench.id, label: String(i), pathSegments: ["BENCH", String(i)], diff --git a/web/lib/auth/config.ts b/web/lib/auth/config.ts new file mode 100644 index 0000000..cc0d4f1 --- /dev/null +++ b/web/lib/auth/config.ts @@ -0,0 +1,102 @@ +import NextAuth, { type NextAuthConfig } from "next-auth"; +import Credentials from "next-auth/providers/credentials"; +import { DrizzleAdapter } from "@auth/drizzle-adapter"; +import { eq } from "drizzle-orm"; +import bcrypt from "bcryptjs"; +import { db } from "@/db/connection"; +import { users, accounts, sessions, verificationTokens } from "@/db/schema"; + +const isProd = process.env.NODE_ENV === "production"; + +const homelabIssuer = process.env.AUTH_HOMELAB_ISSUER; +const homelabClientId = process.env.AUTH_HOMELAB_CLIENT_ID; +const homelabClientSecret = process.env.AUTH_HOMELAB_CLIENT_SECRET; + +const providers: NextAuthConfig["providers"] = [ + Credentials({ + id: "credentials", + name: "Email & password", + credentials: { + email: { label: "Email", type: "email" }, + password: { label: "Password", type: "password" }, + }, + authorize: async (raw) => { + const email = typeof raw?.email === "string" ? raw.email.toLowerCase() : null; + const password = typeof raw?.password === "string" ? raw.password : null; + if (!email || !password) return null; + const [user] = await db.select().from(users).where(eq(users.email, email)); + if (!user?.passwordHash) return null; + const ok = await bcrypt.compare(password, user.passwordHash); + if (!ok) return null; + return { id: user.id, email: user.email, name: user.name, image: user.image }; + }, + }), +]; + +if (homelabIssuer && homelabClientId && homelabClientSecret) { + providers.push({ + id: "homelab", + name: "Homelab IdP", + type: "oidc", + issuer: homelabIssuer, + clientId: homelabClientId, + clientSecret: homelabClientSecret, + }); +} + +if (!isProd) { + providers.push( + Credentials({ + id: "dev-impersonate", + name: "Dev impersonate", + credentials: { email: { label: "Email", type: "email" } }, + authorize: async (raw) => { + const email = typeof raw?.email === "string" ? raw.email.toLowerCase() : null; + if (!email) return null; + let [user] = await db.select().from(users).where(eq(users.email, email)); + if (!user) { + [user] = await db + .insert(users) + .values({ email, name: email.split("@")[0] }) + .returning(); + } + return { id: user.id, email: user.email, name: user.name, image: user.image }; + }, + }), + ); +} + +export const { handlers, auth, signIn, signOut } = NextAuth({ + adapter: DrizzleAdapter(db, { + usersTable: users, + accountsTable: accounts, + sessionsTable: sessions, + verificationTokensTable: verificationTokens, + }), + session: { strategy: "jwt" }, + providers, + pages: { + signIn: "/login", + }, + callbacks: { + async jwt({ token, user }) { + if (user?.id) token.sub = user.id; + return token; + }, + async session({ session, token }) { + if (token.sub) session.user.id = token.sub; + return session; + }, + }, +}); + +declare module "next-auth" { + interface Session { + user: { + id: string; + email?: string | null; + name?: string | null; + image?: string | null; + }; + } +} diff --git a/web/lib/auth/context.ts b/web/lib/auth/context.ts new file mode 100644 index 0000000..fd6ee62 --- /dev/null +++ b/web/lib/auth/context.ts @@ -0,0 +1,55 @@ +import { cookies } from "next/headers"; +import { and, eq } from "drizzle-orm"; +import { db } from "@/db/connection"; +import { userOrgs, orgs } from "@/db/schema"; +import { auth } from "./config"; +import { isOrgRole, type AuthzError } from "./roles"; +import type { OrgRole } from "@/db/schema/orgs"; + +export const ACTIVE_ORG_COOKIE = "wtf-active-org"; + +export type RequestContext = { + userId: string; + activeOrgId: string; + role: OrgRole; + plan: "free" | "paid"; +}; + +// Returns null when the request is unauthenticated or the user has no orgs. +// Throws only on DB errors. +export async function getRequestContext(): Promise { + const session = await auth(); + const userId = session?.user?.id; + if (!userId) return null; + + const cookieStore = await cookies(); + const preferredOrgId = cookieStore.get(ACTIVE_ORG_COOKIE)?.value; + + const memberships = await db + .select({ + orgId: userOrgs.orgId, + role: userOrgs.role, + plan: orgs.plan, + }) + .from(userOrgs) + .innerJoin(orgs, eq(orgs.id, userOrgs.orgId)) + .where(eq(userOrgs.userId, userId)); + + if (memberships.length === 0) return null; + + const preferred = + (preferredOrgId && memberships.find((m) => m.orgId === preferredOrgId)) || + memberships[0]; + + if (!isOrgRole(preferred.role)) return null; + const plan = preferred.plan === "paid" ? "paid" : "free"; + + return { + userId, + activeOrgId: preferred.orgId, + role: preferred.role, + plan, + }; +} + +export type { AuthzError }; diff --git a/web/lib/auth/csrf.ts b/web/lib/auth/csrf.ts new file mode 100644 index 0000000..1189ce5 --- /dev/null +++ b/web/lib/auth/csrf.ts @@ -0,0 +1,25 @@ +import { cookies, headers } from "next/headers"; + +// Auth.js writes a double-submit CSRF cookie at /api/auth/csrf. Mutation +// routes assert the header matches the cookie before proceeding. GET-shaped +// requests and Auth.js's own endpoints are exempt; call this from any +// non-/api/auth POST/PATCH/PUT/DELETE handler. +export async function requireCsrf(): Promise { + const cookieStore = await cookies(); + const headerStore = await headers(); + + const cookie = + cookieStore.get("__Host-authjs.csrf-token") ?? + cookieStore.get("authjs.csrf-token"); + const token = headerStore.get("x-csrf-token"); + + if (!cookie || !token) throw new CsrfError("missing csrf token"); + + // Auth.js stores `${token}|${hash}` — compare the token half. + const cookieToken = cookie.value.split("|")[0]; + if (cookieToken !== token) throw new CsrfError("csrf token mismatch"); +} + +export class CsrfError extends Error { + readonly status = 403; +} diff --git a/web/lib/auth/roles.ts b/web/lib/auth/roles.ts new file mode 100644 index 0000000..827e547 --- /dev/null +++ b/web/lib/auth/roles.ts @@ -0,0 +1,30 @@ +import { orgRoles, type OrgRole } from "@/db/schema/orgs"; + +const rank: Record = { + viewer: 0, + member: 1, + admin: 2, + owner: 3, +}; + +export function isOrgRole(value: string): value is OrgRole { + return (orgRoles as readonly string[]).includes(value); +} + +export function roleAtLeast(role: OrgRole, min: OrgRole): boolean { + return rank[role] >= rank[min]; +} + +export class AuthzError extends Error { + readonly status = 403; +} + +export function assertRole( + role: OrgRole | null | undefined, + min: OrgRole, +): asserts role is OrgRole { + if (!role) throw new AuthzError("not a member of this org"); + if (!roleAtLeast(role, min)) { + throw new AuthzError(`requires ${min}, have ${role}`); + } +} diff --git a/web/lib/auth/route.ts b/web/lib/auth/route.ts new file mode 100644 index 0000000..4a8692e --- /dev/null +++ b/web/lib/auth/route.ts @@ -0,0 +1,33 @@ +import { NextResponse } from "next/server"; +import { getRequestContext, type RequestContext } from "./context"; + +export class UnauthenticatedError extends Error { + readonly status = 401; + constructor(message = "unauthenticated") { + super(message); + } +} + +// Throws UnauthenticatedError if the caller has no session or no org +// membership. Route handlers call this at the top and let the outer +// try/catch turn it into a 401 via `errorResponse`. +export async function requireContext(): Promise { + const ctx = await getRequestContext(); + if (!ctx) throw new UnauthenticatedError(); + return ctx; +} + +export function errorResponse(err: unknown): NextResponse { + const message = err instanceof Error ? err.message : "Unknown error"; + const rawStatus = + err && typeof err === "object" && "status" in err + ? (err as { status: unknown }).status + : undefined; + const status = + typeof rawStatus === "number" + ? rawStatus + : message.includes("not found") + ? 404 + : 500; + return NextResponse.json({ error: message }, { status }); +} diff --git a/web/lib/auth/scope.ts b/web/lib/auth/scope.ts new file mode 100644 index 0000000..c658bc2 --- /dev/null +++ b/web/lib/auth/scope.ts @@ -0,0 +1,14 @@ +import { isNull, or, eq, type SQL } from "drizzle-orm"; +import type { AnyPgColumn } from "drizzle-orm/pg-core"; + +// Build the SQL filter for an isolated table: `owner_org_id = :orgId`. +export function isolatedOrgFilter(col: AnyPgColumn, orgId: string): SQL { + return eq(col, orgId); +} + +// Build the SQL filter for an additive table: +// `(owner_org_id IS NULL OR owner_org_id = :orgId)`. NULL = global +// catalog row visible to every org; matching org = private row. +export function additiveOrgFilter(col: AnyPgColumn, orgId: string): SQL { + return or(isNull(col), eq(col, orgId)) as SQL; +} diff --git a/web/middleware.ts b/web/middleware.ts new file mode 100644 index 0000000..4d7c785 --- /dev/null +++ b/web/middleware.ts @@ -0,0 +1,40 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { auth } from "@/lib/auth/config"; + +// Gate signed-in-only pages and API routes. Identity comes from the JWT +// session; org/role hydration happens inside each route via +// `getRequestContext()` so middleware stays edge-safe (no DB calls). +const PUBLIC_API = [/^\/api\/health(\/|$)/, /^\/api\/auth(\/|$)/]; +const PUBLIC_PAGE = [/^\/login(\/|$)/, /^\/signup(\/|$)/]; + +export async function middleware(req: NextRequest) { + const { pathname } = req.nextUrl; + + if (pathname.startsWith("/api/")) { + if (PUBLIC_API.some((re) => re.test(pathname))) return NextResponse.next(); + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "unauthenticated" }, { status: 401 }); + } + return NextResponse.next(); + } + + if (PUBLIC_PAGE.some((re) => re.test(pathname))) return NextResponse.next(); + + const session = await auth(); + if (!session?.user?.id) { + const url = req.nextUrl.clone(); + url.pathname = "/login"; + url.searchParams.set("next", pathname); + return NextResponse.redirect(url); + } + + return NextResponse.next(); +} + +export const config = { + matcher: [ + // Run on everything except Next internals and static assets + "/((?!_next/static|_next/image|favicon.ico|logo.svg|.*\\..*).*)", + ], +}; diff --git a/web/package-lock.json b/web/package-lock.json index 38baec9..4e4d575 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -8,10 +8,13 @@ "name": "web", "version": "0.2.0", "dependencies": { + "@auth/drizzle-adapter": "^1.11.2", + "bcryptjs": "^3.0.3", "date-fns": "^4.1.0", "drizzle-orm": "^0.44.0", "lucide-react": "^0.562.0", "next": "^16.1.6", + "next-auth": "^5.0.0-beta.31", "postgres": "^3.4.7", "react": "19.2.1", "react-dom": "19.2.1" @@ -94,6 +97,42 @@ "dev": true, "license": "MIT" }, + "node_modules/@auth/core": { + "version": "0.41.2", + "resolved": "https://registry.npmjs.org/@auth/core/-/core-0.41.2.tgz", + "integrity": "sha512-Hx5MNBxN2fJTbJKGUKAA0wca43D0Akl3TvufY54Gn8lop7F+34vU1zA1pn0vQfIoVuLIrpfc2nkyjwIaPJMW7w==", + "dependencies": { + "@panva/hkdf": "^1.2.1", + "jose": "^6.0.6", + "oauth4webapi": "^3.3.0", + "preact": "10.24.3", + "preact-render-to-string": "6.5.11" + }, + "peerDependencies": { + "@simplewebauthn/browser": "^9.0.1", + "@simplewebauthn/server": "^9.0.2", + "nodemailer": "^7.0.7" + }, + "peerDependenciesMeta": { + "@simplewebauthn/browser": { + "optional": true + }, + "@simplewebauthn/server": { + "optional": true + }, + "nodemailer": { + "optional": true + } + } + }, + "node_modules/@auth/drizzle-adapter": { + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@auth/drizzle-adapter/-/drizzle-adapter-1.11.2.tgz", + "integrity": "sha512-VOuj7REI8jfJjpSbsYwDM/Zrn55T6lS9Yc+29V+EXMcel8eqG7x+7LodNfd1WHjakfkLYi+qsbYUHB3E6aDA4w==", + "dependencies": { + "@auth/core": "0.41.2" + } + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -2293,6 +2332,14 @@ "node": ">=12.4.0" } }, + "node_modules/@panva/hkdf": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz", + "integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", @@ -4085,6 +4132,14 @@ "node": ">=6.0.0" } }, + "node_modules/bcryptjs": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", + "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==", + "bin": { + "bcrypt": "bin/bcrypt" + } + }, "node_modules/bidi-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", @@ -6834,6 +6889,14 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/jose": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz", + "integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -7528,6 +7591,32 @@ } } }, + "node_modules/next-auth": { + "version": "5.0.0-beta.31", + "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-5.0.0-beta.31.tgz", + "integrity": "sha512-1OBgCKPzo+S7UWWMp3xgvGvIJ0OpV7B3vR4ZDRqD9a4Ch+OT6dakLXG9ivhtmIWVa71nTSXattOHyCg8sNi8/Q==", + "dependencies": { + "@auth/core": "0.41.2" + }, + "peerDependencies": { + "@simplewebauthn/browser": "^9.0.1", + "@simplewebauthn/server": "^9.0.2", + "next": "^14.0.0-0 || ^15.0.0 || ^16.0.0", + "nodemailer": "^7.0.7", + "react": "^18.2.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@simplewebauthn/browser": { + "optional": true + }, + "@simplewebauthn/server": { + "optional": true + }, + "nodemailer": { + "optional": true + } + } + }, "node_modules/next/node_modules/postcss": { "version": "8.4.31", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", @@ -7561,6 +7650,14 @@ "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", "dev": true }, + "node_modules/oauth4webapi": { + "version": "3.8.5", + "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.5.tgz", + "integrity": "sha512-A8jmyUckVhRJj5lspguklcl90Ydqk61H3dcU0oLhH3Yv13KpAliKTt5hknpGGPZSSfOwGyraNEFmofDYH+1kSg==", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -7874,6 +7971,23 @@ "url": "https://github.com/sponsors/porsager" } }, + "node_modules/preact": { + "version": "10.24.3", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz", + "integrity": "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, + "node_modules/preact-render-to-string": { + "version": "6.5.11", + "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-6.5.11.tgz", + "integrity": "sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw==", + "peerDependencies": { + "preact": ">=10" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", diff --git a/web/package.json b/web/package.json index b46f904..cc3fcc5 100644 --- a/web/package.json +++ b/web/package.json @@ -17,10 +17,13 @@ "db:seed": "tsx db/seed.ts" }, "dependencies": { + "@auth/drizzle-adapter": "^1.11.2", + "bcryptjs": "^3.0.3", "date-fns": "^4.1.0", "drizzle-orm": "^0.44.0", "lucide-react": "^0.562.0", "next": "^16.1.6", + "next-auth": "^5.0.0-beta.31", "postgres": "^3.4.7", "react": "19.2.1", "react-dom": "19.2.1" diff --git a/web/repositories/aspectRepository.ts b/web/repositories/aspectRepository.ts index f4a0052..633c0af 100644 --- a/web/repositories/aspectRepository.ts +++ b/web/repositories/aspectRepository.ts @@ -8,23 +8,36 @@ import { itemAspects, items, } from "@/db/schema"; +import { additiveOrgFilter } from "@/lib/auth/scope"; import { transactionRepository } from "./transactionRepository"; import { type AuditCheck, jaccardTokens } from "@/lib/audit"; +// aspects + related junctions are additive. NULL = global taxonomy; +// set = org-private. Junction inserts inherit ownerOrgId from the +// parent aspect. export const aspectRepository = { async create({ + userId, + orgId, + asGlobal, name, description, }: { + userId: string; + orgId: string; + asGlobal?: boolean; name: string; description?: string; }) { + const ownerOrgId = asGlobal ? null : orgId; const [aspect] = await db .insert(aspects) - .values({ name, description }) + .values({ ownerOrgId, name, description }) .returning(); await transactionRepository.log({ + userId, + orgId, actionType: "aspect.create", entityType: "aspect", entityId: aspect.id, @@ -35,34 +48,35 @@ export const aspectRepository = { return aspect; }, - async findById({ id }: { id: string }) { + async findById({ orgId, id }: { orgId: string; id: string }) { const [aspect] = await db .select() .from(aspects) - .where(eq(aspects.id, id)); + .where(and(additiveOrgFilter(aspects.ownerOrgId, orgId), eq(aspects.id, id))); return aspect ?? null; }, - async findByName({ name }: { name: string }) { + async findByName({ orgId, name }: { orgId: string; name: string }) { const [aspect] = await db .select() .from(aspects) - .where(eq(aspects.name, name)); + .where(and(additiveOrgFilter(aspects.ownerOrgId, orgId), eq(aspects.name, name))); return aspect ?? null; }, - async list() { - return db.select().from(aspects).orderBy(aspects.name); + async list({ orgId }: { orgId: string }) { + return db + .select() + .from(aspects) + .where(additiveOrgFilter(aspects.ownerOrgId, orgId)) + .orderBy(aspects.name); }, - /** - * Like list() but each row includes usage counts computed via correlated - * subqueries so the list page can show context per aspect. - */ - async listWithUsage() { + async listWithUsage({ orgId }: { orgId: string }) { const rows = await db .select({ id: aspects.id, + ownerOrgId: aspects.ownerOrgId, name: aspects.name, description: aspects.description, createdAt: aspects.createdAt, @@ -70,17 +84,21 @@ export const aspectRepository = { parameterCount: sql`( SELECT COUNT(*)::int FROM ${aspectParameters} WHERE ${aspectParameters.aspectId} = ${aspects.id} + AND (${aspectParameters.ownerOrgId} IS NULL OR ${aspectParameters.ownerOrgId} = ${orgId}) )`.as("parameterCount"), itemCount: sql`( SELECT COUNT(*)::int FROM ${itemAspects} WHERE ${itemAspects.aspectId} = ${aspects.id} + AND (${itemAspects.ownerOrgId} IS NULL OR ${itemAspects.ownerOrgId} = ${orgId}) )`.as("itemCount"), standardCount: sql`( SELECT COUNT(*)::int FROM ${aspectStandards} WHERE ${aspectStandards.aspectId} = ${aspects.id} + AND (${aspectStandards.ownerOrgId} IS NULL OR ${aspectStandards.ownerOrgId} = ${orgId}) )`.as("standardCount"), }) .from(aspects) + .where(additiveOrgFilter(aspects.ownerOrgId, orgId)) .orderBy(aspects.name); return rows.map((r) => ({ ...r, @@ -90,14 +108,12 @@ export const aspectRepository = { })); }, - /** - * Items that have this aspect applied. Limited so the list detail - * panel stays snappy — the count lives on getUsage(). - */ async listItemsUsing({ + orgId, aspectId, limit = 50, }: { + orgId: string; aspectId: string; limit?: number; }) { @@ -109,25 +125,46 @@ export const aspectRepository = { }) .from(itemAspects) .innerJoin(items, eq(itemAspects.itemId, items.id)) - .where(eq(itemAspects.aspectId, aspectId)) + .where( + and( + additiveOrgFilter(itemAspects.ownerOrgId, orgId), + additiveOrgFilter(items.ownerOrgId, orgId), + eq(itemAspects.aspectId, aspectId), + ), + ) .orderBy(sql`${itemAspects.createdAt} DESC`) .limit(limit); return rows; }, - async getUsage({ aspectId }: { aspectId: string }) { + async getUsage({ orgId, aspectId }: { orgId: string; aspectId: string }) { const [pc] = await db .select({ n: count() }) .from(aspectParameters) - .where(eq(aspectParameters.aspectId, aspectId)); + .where( + and( + additiveOrgFilter(aspectParameters.ownerOrgId, orgId), + eq(aspectParameters.aspectId, aspectId), + ), + ); const [ic] = await db .select({ n: count() }) .from(itemAspects) - .where(eq(itemAspects.aspectId, aspectId)); + .where( + and( + additiveOrgFilter(itemAspects.ownerOrgId, orgId), + eq(itemAspects.aspectId, aspectId), + ), + ); const [sc] = await db .select({ n: count() }) .from(aspectStandards) - .where(eq(aspectStandards.aspectId, aspectId)); + .where( + and( + additiveOrgFilter(aspectStandards.ownerOrgId, orgId), + eq(aspectStandards.aspectId, aspectId), + ), + ); return { parameterCount: pc?.n ?? 0, itemCount: ic?.n ?? 0, @@ -135,45 +172,43 @@ export const aspectRepository = { }; }, - /** - * Suggest parameters that commonly co-occur with the ones already - * attached to this aspect. Used by the typeahead "commonly paired - * with" strip. - * - * Algorithm: find aspects (other than this one) that share ≥1 of - * this aspect's parameters. From each such aspect collect its other - * parameters. Rank the candidates by frequency — parameters that - * appear across many such aspects are more likely to belong here. - * Exclude parameters already attached to this aspect. - */ async suggestCoOccurringParameters({ + orgId, aspectId, limit = 5, }: { + orgId: string; aspectId: string; limit?: number; }) { - // Current attached params. const attachedRows = await db .select({ id: aspectParameters.parameterDefinitionId }) .from(aspectParameters) - .where(eq(aspectParameters.aspectId, aspectId)); + .where( + and( + additiveOrgFilter(aspectParameters.ownerOrgId, orgId), + eq(aspectParameters.aspectId, aspectId), + ), + ); const attached = new Set(attachedRows.map((r) => r.id)); if (attached.size === 0) return []; - // Aspects that share any of those params. const sharingRows = await db .selectDistinctOn([aspectParameters.aspectId], { aspectId: aspectParameters.aspectId, }) .from(aspectParameters) - .where(sql`${aspectParameters.parameterDefinitionId} IN (${sql.join(Array.from(attached).map((id) => sql`${id}`), sql`, `)})`); + .where( + and( + additiveOrgFilter(aspectParameters.ownerOrgId, orgId), + sql`${aspectParameters.parameterDefinitionId} IN (${sql.join(Array.from(attached).map((id) => sql`${id}`), sql`, `)})`, + ), + ); const sharingAspectIds = sharingRows .map((r) => r.aspectId) .filter((id) => id !== aspectId); if (sharingAspectIds.length === 0) return []; - // All parameters on those aspects. const candidateRows = await db .select({ aspectId: aspectParameters.aspectId, @@ -187,11 +222,17 @@ export const aspectRepository = { .innerJoin(aspects, eq(aspects.id, aspectParameters.aspectId)) .innerJoin( parameterDefinitions, - eq(parameterDefinitions.id, aspectParameters.parameterDefinitionId) + eq(parameterDefinitions.id, aspectParameters.parameterDefinitionId), ) - .where(sql`${aspectParameters.aspectId} IN (${sql.join(sharingAspectIds.map((id) => sql`${id}`), sql`, `)})`); + .where( + and( + additiveOrgFilter(aspectParameters.ownerOrgId, orgId), + additiveOrgFilter(aspects.ownerOrgId, orgId), + additiveOrgFilter(parameterDefinitions.ownerOrgId, orgId), + sql`${aspectParameters.aspectId} IN (${sql.join(sharingAspectIds.map((id) => sql`${id}`), sql`, `)})`, + ), + ); - // Tally freq; track source aspects. const tally = new Map< string, { @@ -204,7 +245,7 @@ export const aspectRepository = { } >(); for (const r of candidateRows) { - if (attached.has(r.parameterDefinitionId)) continue; // already attached + if (attached.has(r.parameterDefinitionId)) continue; const existing = tally.get(r.parameterDefinitionId); if (existing) { existing.frequency += 1; @@ -234,19 +275,15 @@ export const aspectRepository = { .slice(0, limit); }, - /** - * Taxonomy-level audit of aspects: empty, unused, duplicate or - * overlapping parameter sets, similar names. Complements - * parameterDefinitionRepository.audit(). - */ - async audit(): Promise { - const withUsage = await aspectRepository.listWithUsage(); + async audit({ orgId }: { orgId: string }): Promise { + const withUsage = await aspectRepository.listWithUsage({ orgId }); const paramRows = await db .select({ aspectId: aspectParameters.aspectId, parameterDefinitionId: aspectParameters.parameterDefinitionId, }) - .from(aspectParameters); + .from(aspectParameters) + .where(additiveOrgFilter(aspectParameters.ownerOrgId, orgId)); const paramsByAspect = new Map>(); for (const r of paramRows) { const bag = paramsByAspect.get(r.aspectId) ?? new Set(); @@ -354,23 +391,29 @@ export const aspectRepository = { }, async update({ + userId, + orgId, id, ...updates }: { + userId: string; + orgId: string; id: string; name?: string; description?: string; }) { - const before = await aspectRepository.findById({ id }); + const before = await aspectRepository.findById({ orgId, id }); if (!before) throw new Error(`Aspect ${id} not found`); const [updated] = await db .update(aspects) .set({ ...updates, updatedAt: new Date() }) - .where(eq(aspects.id, id)) + .where(and(additiveOrgFilter(aspects.ownerOrgId, orgId), eq(aspects.id, id))) .returning(); await transactionRepository.log({ + userId, + orgId, actionType: "aspect.update", entityType: "aspect", entityId: id, @@ -381,13 +424,25 @@ export const aspectRepository = { return updated; }, - async remove({ id }: { id: string }) { - const before = await aspectRepository.findById({ id }); + async remove({ + userId, + orgId, + id, + }: { + userId: string; + orgId: string; + id: string; + }) { + const before = await aspectRepository.findById({ orgId, id }); if (!before) throw new Error(`Aspect ${id} not found`); - await db.delete(aspects).where(eq(aspects.id, id)); + await db + .delete(aspects) + .where(and(additiveOrgFilter(aspects.ownerOrgId, orgId), eq(aspects.id, id))); await transactionRepository.log({ + userId, + orgId, actionType: "aspect.delete", entityType: "aspect", entityId: id, @@ -396,35 +451,51 @@ export const aspectRepository = { }); }, - async countItemsUsing({ aspectId }: { aspectId: string }) { + async countItemsUsing({ + orgId, + aspectId, + }: { + orgId: string; + aspectId: string; + }) { const [result] = await db .select({ count: count() }) .from(itemAspects) - .where(eq(itemAspects.aspectId, aspectId)); + .where( + and( + additiveOrgFilter(itemAspects.ownerOrgId, orgId), + eq(itemAspects.aspectId, aspectId), + ), + ); return result?.count ?? 0; }, // --- Aspect parameter management --- async addParameter({ + userId, + orgId, aspectId, parameterDefinitionId, required, defaultValue, sortOrder, }: { + userId: string; + orgId: string; aspectId: string; parameterDefinitionId: string; required?: boolean; defaultValue?: unknown; sortOrder?: number; }) { - const aspect = await aspectRepository.findById({ id: aspectId }); + const aspect = await aspectRepository.findById({ orgId, id: aspectId }); if (!aspect) throw new Error(`Aspect ${aspectId} not found`); const [ap] = await db .insert(aspectParameters) .values({ + ownerOrgId: aspect.ownerOrgId, aspectId, parameterDefinitionId, required: required ?? false, @@ -433,13 +504,16 @@ export const aspectRepository = { }) .returning(); + void userId; return ap; }, async removeParameter({ + orgId, aspectId, parameterDefinitionId, }: { + orgId: string; aspectId: string; parameterDefinitionId: string; }) { @@ -447,20 +521,27 @@ export const aspectRepository = { .delete(aspectParameters) .where( and( + additiveOrgFilter(aspectParameters.ownerOrgId, orgId), eq(aspectParameters.aspectId, aspectId), - eq(aspectParameters.parameterDefinitionId, parameterDefinitionId) - ) + eq(aspectParameters.parameterDefinitionId, parameterDefinitionId), + ), ) .returning(); if (!deleted) { throw new Error( - `Parameter ${parameterDefinitionId} not found on aspect ${aspectId}` + `Parameter ${parameterDefinitionId} not found on aspect ${aspectId}`, ); } }, - async getParameters({ aspectId }: { aspectId: string }) { + async getParameters({ + orgId, + aspectId, + }: { + orgId: string; + aspectId: string; + }) { return db .select({ id: aspectParameters.id, @@ -477,9 +558,15 @@ export const aspectRepository = { .from(aspectParameters) .innerJoin( parameterDefinitions, - eq(aspectParameters.parameterDefinitionId, parameterDefinitions.id) + eq(aspectParameters.parameterDefinitionId, parameterDefinitions.id), + ) + .where( + and( + additiveOrgFilter(aspectParameters.ownerOrgId, orgId), + additiveOrgFilter(parameterDefinitions.ownerOrgId, orgId), + eq(aspectParameters.aspectId, aspectId), + ), ) - .where(eq(aspectParameters.aspectId, aspectId)) .orderBy(aspectParameters.sortOrder); }, }; diff --git a/web/repositories/assignmentRepository.ts b/web/repositories/assignmentRepository.ts index 228cacf..5f85cdd 100644 --- a/web/repositories/assignmentRepository.ts +++ b/web/repositories/assignmentRepository.ts @@ -1,21 +1,30 @@ import { eq, and, or } from "drizzle-orm"; import { db } from "@/db/connection"; import { assignments, coStorability } from "@/db/schema"; +import { isolatedOrgFilter } from "@/lib/auth/scope"; import { transactionRepository } from "./transactionRepository"; +// assignments is an isolated table. Every method is org-scoped. +// Co-storability is additive (global catalog entries visible to every +// org), but when we look up co-storability we key by item IDs only — +// the lookup doesn't need an org filter because any org that can see +// both items should benefit from a global pairing. async function checkPlacedCoStorability({ + orgId, locationId, itemId, }: { + orgId: string; locationId: string; itemId: string; }) { - // Find existing placed assignments at this location + // Find existing placed assignments at this location (scoped to org). const existing = await db .select() .from(assignments) .where( and( + isolatedOrgFilter(assignments.ownerOrgId, orgId), eq(assignments.locationId, locationId), eq(assignments.assignmentType, "placed"), ), @@ -53,23 +62,28 @@ async function checkPlacedCoStorability({ export const assignmentRepository = { async create({ + userId, + orgId, itemId, locationId, assignmentType, metadata, }: { + userId: string; + orgId: string; itemId: string; locationId: string; assignmentType: "placed" | "provisional"; metadata?: Record; }) { if (assignmentType === "placed") { - await checkPlacedCoStorability({ locationId, itemId }); + await checkPlacedCoStorability({ orgId, locationId, itemId }); } const [assignment] = await db .insert(assignments) .values({ + ownerOrgId: orgId, itemId, locationId, assignmentType, @@ -78,6 +92,8 @@ export const assignmentRepository = { .returning(); await transactionRepository.log({ + userId, + orgId, actionType: "assignment.create", entityType: "assignment", entityId: assignment.id, @@ -88,39 +104,64 @@ export const assignmentRepository = { return assignment; }, - async findById({ id }: { id: string }) { + async findById({ orgId, id }: { orgId: string; id: string }) { const [assignment] = await db .select() .from(assignments) - .where(eq(assignments.id, id)); + .where( + and( + isolatedOrgFilter(assignments.ownerOrgId, orgId), + eq(assignments.id, id), + ), + ); return assignment ?? null; }, - async findByItemId({ itemId }: { itemId: string }) { + async findByItemId({ orgId, itemId }: { orgId: string; itemId: string }) { return db .select() .from(assignments) - .where(eq(assignments.itemId, itemId)); + .where( + and( + isolatedOrgFilter(assignments.ownerOrgId, orgId), + eq(assignments.itemId, itemId), + ), + ); }, - async findByLocationId({ locationId }: { locationId: string }) { + async findByLocationId({ + orgId, + locationId, + }: { + orgId: string; + locationId: string; + }) { return db .select() .from(assignments) - .where(eq(assignments.locationId, locationId)); + .where( + and( + isolatedOrgFilter(assignments.ownerOrgId, orgId), + eq(assignments.locationId, locationId), + ), + ); }, async convertToPlaced({ + userId, + orgId, id, locationId, }: { + userId: string; + orgId: string; id: string; locationId: string; }) { - const before = await assignmentRepository.findById({ id }); + const before = await assignmentRepository.findById({ orgId, id }); if (!before) throw new Error(`Assignment ${id} not found`); - await checkPlacedCoStorability({ locationId, itemId: before.itemId }); + await checkPlacedCoStorability({ orgId, locationId, itemId: before.itemId }); const [updated] = await db .update(assignments) @@ -129,10 +170,17 @@ export const assignmentRepository = { locationId, updatedAt: new Date(), }) - .where(eq(assignments.id, id)) + .where( + and( + isolatedOrgFilter(assignments.ownerOrgId, orgId), + eq(assignments.id, id), + ), + ) .returning(); await transactionRepository.log({ + userId, + orgId, actionType: "assignment.convertToPlaced", entityType: "assignment", entityId: id, @@ -143,11 +191,22 @@ export const assignmentRepository = { return updated; }, - async move({ id, newLocationId }: { id: string; newLocationId: string }) { - const before = await assignmentRepository.findById({ id }); + async move({ + userId, + orgId, + id, + newLocationId, + }: { + userId: string; + orgId: string; + id: string; + newLocationId: string; + }) { + const before = await assignmentRepository.findById({ orgId, id }); if (!before) throw new Error(`Assignment ${id} not found`); await checkPlacedCoStorability({ + orgId, locationId: newLocationId, itemId: before.itemId, }); @@ -158,10 +217,17 @@ export const assignmentRepository = { locationId: newLocationId, updatedAt: new Date(), }) - .where(eq(assignments.id, id)) + .where( + and( + isolatedOrgFilter(assignments.ownerOrgId, orgId), + eq(assignments.id, id), + ), + ) .returning(); await transactionRepository.log({ + userId, + orgId, actionType: "assignment.move", entityType: "assignment", entityId: id, @@ -172,13 +238,30 @@ export const assignmentRepository = { return updated; }, - async remove({ id }: { id: string }) { - const before = await assignmentRepository.findById({ id }); + async remove({ + userId, + orgId, + id, + }: { + userId: string; + orgId: string; + id: string; + }) { + const before = await assignmentRepository.findById({ orgId, id }); if (!before) throw new Error(`Assignment ${id} not found`); - await db.delete(assignments).where(eq(assignments.id, id)); + await db + .delete(assignments) + .where( + and( + isolatedOrgFilter(assignments.ownerOrgId, orgId), + eq(assignments.id, id), + ), + ); await transactionRepository.log({ + userId, + orgId, actionType: "assignment.delete", entityType: "assignment", entityId: id, @@ -187,10 +270,15 @@ export const assignmentRepository = { }); }, - async listProvisional() { + async listProvisional({ orgId }: { orgId: string }) { return db .select() .from(assignments) - .where(eq(assignments.assignmentType, "provisional")); + .where( + and( + isolatedOrgFilter(assignments.ownerOrgId, orgId), + eq(assignments.assignmentType, "provisional"), + ), + ); }, }; diff --git a/web/repositories/categoryRepository.ts b/web/repositories/categoryRepository.ts index 37c97b9..0973269 100644 --- a/web/repositories/categoryRepository.ts +++ b/web/repositories/categoryRepository.ts @@ -1,25 +1,35 @@ -import { eq, sql } from "drizzle-orm"; +import { eq, and, sql } from "drizzle-orm"; import { db } from "@/db/connection"; import { categories, itemCategories, items } from "@/db/schema"; +import { additiveOrgFilter } from "@/lib/auth/scope"; import { transactionRepository } from "./transactionRepository"; +// categories is additive. NULL = global taxonomy; set = org-private. export const categoryRepository = { async create({ + userId, + orgId, + asGlobal, name, icon, svg, color, sortOrder, }: { + userId: string; + orgId: string; + asGlobal?: boolean; name: string; icon?: string | null; svg?: string | null; color?: string | null; sortOrder?: number; }) { + const ownerOrgId = asGlobal ? null : orgId; const [category] = await db .insert(categories) .values({ + ownerOrgId, name, icon: icon ?? null, svg: svg ?? null, @@ -29,6 +39,8 @@ export const categoryRepository = { .returning(); await transactionRepository.log({ + userId, + orgId, actionType: "category.create", entityType: "category", entityId: category.id, @@ -39,30 +51,35 @@ export const categoryRepository = { return category; }, - async findById({ id }: { id: string }) { + async findById({ orgId, id }: { orgId: string; id: string }) { const [category] = await db .select() .from(categories) - .where(eq(categories.id, id)); + .where(and(additiveOrgFilter(categories.ownerOrgId, orgId), eq(categories.id, id))); return category ?? null; }, - async findByName({ name }: { name: string }) { + async findByName({ orgId, name }: { orgId: string; name: string }) { const [category] = await db .select() .from(categories) - .where(eq(categories.name, name)); + .where(and(additiveOrgFilter(categories.ownerOrgId, orgId), eq(categories.name, name))); return category ?? null; }, - async list() { - return db.select().from(categories).orderBy(categories.sortOrder); + async list({ orgId }: { orgId: string }) { + return db + .select() + .from(categories) + .where(additiveOrgFilter(categories.ownerOrgId, orgId)) + .orderBy(categories.sortOrder); }, - async listWithUsage() { + async listWithUsage({ orgId }: { orgId: string }) { const rows = await db .select({ id: categories.id, + ownerOrgId: categories.ownerOrgId, name: categories.name, icon: categories.icon, svg: categories.svg, @@ -74,17 +91,21 @@ export const categoryRepository = { SELECT COUNT(DISTINCT ${itemCategories.itemId})::int FROM ${itemCategories} WHERE ${itemCategories.categoryId} = ${categories.id} + AND (${itemCategories.ownerOrgId} IS NULL OR ${itemCategories.ownerOrgId} = ${orgId}) )`.as("itemCount"), }) .from(categories) + .where(additiveOrgFilter(categories.ownerOrgId, orgId)) .orderBy(categories.sortOrder); return rows.map((r) => ({ ...r, itemCount: Number(r.itemCount ?? 0) })); }, async listItems({ + orgId, categoryId, limit = 50, }: { + orgId: string; categoryId: string; limit?: number; }) { @@ -92,15 +113,25 @@ export const categoryRepository = { .select({ id: items.id, name: items.name }) .from(itemCategories) .innerJoin(items, eq(itemCategories.itemId, items.id)) - .where(eq(itemCategories.categoryId, categoryId)) + .where( + and( + additiveOrgFilter(itemCategories.ownerOrgId, orgId), + additiveOrgFilter(items.ownerOrgId, orgId), + eq(itemCategories.categoryId, categoryId), + ), + ) .orderBy(items.name) .limit(limit); }, async update({ + userId, + orgId, id, ...updates }: { + userId: string; + orgId: string; id: string; name?: string; icon?: string | null; @@ -108,16 +139,18 @@ export const categoryRepository = { color?: string | null; sortOrder?: number; }) { - const before = await categoryRepository.findById({ id }); + const before = await categoryRepository.findById({ orgId, id }); if (!before) throw new Error(`Category ${id} not found`); const [updated] = await db .update(categories) .set({ ...updates, updatedAt: new Date() }) - .where(eq(categories.id, id)) + .where(and(additiveOrgFilter(categories.ownerOrgId, orgId), eq(categories.id, id))) .returning(); await transactionRepository.log({ + userId, + orgId, actionType: "category.update", entityType: "category", entityId: id, @@ -128,13 +161,25 @@ export const categoryRepository = { return updated; }, - async remove({ id }: { id: string }) { - const before = await categoryRepository.findById({ id }); + async remove({ + userId, + orgId, + id, + }: { + userId: string; + orgId: string; + id: string; + }) { + const before = await categoryRepository.findById({ orgId, id }); if (!before) throw new Error(`Category ${id} not found`); - await db.delete(categories).where(eq(categories.id, id)); + await db + .delete(categories) + .where(and(additiveOrgFilter(categories.ownerOrgId, orgId), eq(categories.id, id))); await transactionRepository.log({ + userId, + orgId, actionType: "category.delete", entityType: "category", entityId: id, diff --git a/web/repositories/insertRepository.ts b/web/repositories/insertRepository.ts index 3949721..f7809a7 100644 --- a/web/repositories/insertRepository.ts +++ b/web/repositories/insertRepository.ts @@ -11,6 +11,7 @@ import { modules, assignments, } from "@/db/schema"; +import { isolatedOrgFilter } from "@/lib/auth/scope"; import { transactionRepository } from "./transactionRepository"; import { getGridLabel } from "@/lib/gridLabels"; @@ -41,13 +42,19 @@ type InsertParentLocation = { */ async function reparentInsertCells( tx: Parameters[0]>[0], + orgId: string, insertId: string, receptacle: InsertParentLocation ) { const cells = await tx .select() .from(locations) - .where(eq(locations.insertId, insertId)); + .where( + and( + isolatedOrgFilter(locations.ownerOrgId, orgId), + eq(locations.insertId, insertId), + ), + ); const receptaclePath = (receptacle.pathSegments as string[]) ?? []; const byId = new Map(); @@ -78,12 +85,20 @@ async function reparentInsertCells( pathSegments: newSegments, updatedAt: new Date(), }) - .where(eq(locations.id, cell.id)); + .where( + and( + isolatedOrgFilter(locations.ownerOrgId, orgId), + eq(locations.id, cell.id), + ), + ); } } +// inserts is an isolated table. Every method is org-scoped. export const insertRepository = { async create({ + userId, + orgId, name, templateId, templateVersionId, @@ -92,6 +107,8 @@ export const insertRepository = { overrides, metadata, }: { + userId: string; + orgId: string; name?: string; templateId?: string; templateVersionId?: string; @@ -106,6 +123,7 @@ export const insertRepository = { const [ins] = await tx .insert(inserts) .values({ + ownerOrgId: orgId, uid, name, templateId, @@ -150,6 +168,7 @@ export const insertRepository = { ); const cellLabel = `${rowLabel}${colLabel}`; await tx.insert(locations).values({ + ownerOrgId: orgId, moduleId: null, parentId: null, label: cellLabel, @@ -170,6 +189,8 @@ export const insertRepository = { }); await transactionRepository.log({ + userId, + orgId, actionType: "insert.create", entityType: "insert", entityId: insert.id, @@ -180,24 +201,45 @@ export const insertRepository = { return insert; }, - async findById({ id }: { id: string }) { + async findById({ orgId, id }: { orgId: string; id: string }) { const [insert] = await db .select() .from(inserts) - .where(eq(inserts.id, id)); + .where( + and(isolatedOrgFilter(inserts.ownerOrgId, orgId), eq(inserts.id, id)), + ); return insert ?? null; }, - async findByLocationId({ locationId }: { locationId: string }) { + async findByLocationId({ + orgId, + locationId, + }: { + orgId: string; + locationId: string; + }) { const [insert] = await db .select() .from(inserts) - .where(eq(inserts.locationId, locationId)); + .where( + and( + isolatedOrgFilter(inserts.ownerOrgId, orgId), + eq(inserts.locationId, locationId), + ), + ); return insert ?? null; }, - async listUnplaced() { - return db.select().from(inserts).where(isNull(inserts.locationId)); + async listUnplaced({ orgId }: { orgId: string }) { + return db + .select() + .from(inserts) + .where( + and( + isolatedOrgFilter(inserts.ownerOrgId, orgId), + isNull(inserts.locationId), + ), + ); }, /** @@ -208,8 +250,14 @@ export const insertRepository = { * - interface type compatible (or either side unspecified) * - not the current host (already there) */ - async listCompatibleReceptacles({ id }: { id: string }) { - const insert = await insertRepository.findById({ id }); + async listCompatibleReceptacles({ + orgId, + id, + }: { + orgId: string; + id: string; + }) { + const insert = await insertRepository.findById({ orgId, id }); if (!insert) throw new Error(`Insert ${id} not found`); // Inserts inherit provided interfaces from their template version @@ -238,7 +286,12 @@ export const insertRepository = { }) .from(locations) .leftJoin(modules, eq(locations.moduleId, modules.id)) - .where(eq(locations.locationType, "receptacle")); + .where( + and( + isolatedOrgFilter(locations.ownerOrgId, orgId), + eq(locations.locationType, "receptacle"), + ), + ); // Batch-load accepted interface sets for all candidate receptacles. const recIds = receptacles.map((r) => r.id); @@ -250,7 +303,12 @@ export const insertRepository = { interfaceTypeId: locationInterfacesAccepted.interfaceTypeId, }) .from(locationInterfacesAccepted) - .where(inArray(locationInterfacesAccepted.locationId, recIds)); + .where( + and( + eq(locationInterfacesAccepted.ownerOrgId, orgId), + inArray(locationInterfacesAccepted.locationId, recIds), + ), + ); for (const r of accRows) { let s = acceptedByLoc.get(r.locationId); if (!s) { @@ -265,7 +323,12 @@ export const insertRepository = { const occupants = await db .select({ locationId: inserts.locationId }) .from(inserts) - .where(isNotNull(inserts.locationId)); + .where( + and( + isolatedOrgFilter(inserts.ownerOrgId, orgId), + isNotNull(inserts.locationId), + ), + ); const occupied = new Set( occupants.map((o) => o.locationId).filter(Boolean) as string[] ); @@ -294,17 +357,19 @@ export const insertRepository = { * - placement: 'placed' | 'unplaced' | 'all' (default 'all') */ async listWithDetails({ + orgId, templateId, interfaceTypeId, placement = "all", moduleId, }: { + orgId: string; templateId?: string; interfaceTypeId?: string; placement?: "placed" | "unplaced" | "all"; moduleId?: string; - } = {}) { - const conditions: SQL[] = []; + }) { + const conditions: SQL[] = [isolatedOrgFilter(inserts.ownerOrgId, orgId)]; if (templateId) conditions.push(eq(inserts.templateId, templateId)); if (placement === "placed") conditions.push(isNotNull(inserts.locationId)); @@ -322,9 +387,7 @@ export const insertRepository = { ); } - const where = conditions.length - ? and(...conditions) - : undefined; + const where = and(...conditions); const rows = await db .select({ @@ -416,14 +479,29 @@ export const insertRepository = { })); }, - async place({ id, locationId }: { id: string; locationId: string }) { - const insert = await insertRepository.findById({ id }); + async place({ + userId, + orgId, + id, + locationId, + }: { + userId: string; + orgId: string; + id: string; + locationId: string; + }) { + const insert = await insertRepository.findById({ orgId, id }); if (!insert) throw new Error(`Insert ${id} not found`); const [location] = await db .select() .from(locations) - .where(eq(locations.id, locationId)); + .where( + and( + isolatedOrgFilter(locations.ownerOrgId, orgId), + eq(locations.id, locationId), + ), + ); if (!location) throw new Error(`Location ${locationId} not found`); if (location.locationType !== "receptacle") { @@ -469,7 +547,12 @@ export const insertRepository = { const occupants = await db .select({ id: inserts.id }) .from(inserts) - .where(eq(inserts.locationId, locationId)); + .where( + and( + isolatedOrgFilter(inserts.ownerOrgId, orgId), + eq(inserts.locationId, locationId), + ), + ); if (occupants.find((o) => o.id !== id)) { throw new Error( `Location ${locationId} already holds another insert` @@ -480,15 +563,22 @@ export const insertRepository = { const [ins] = await tx .update(inserts) .set({ locationId, updatedAt: new Date() }) - .where(eq(inserts.id, id)) + .where( + and( + isolatedOrgFilter(inserts.ownerOrgId, orgId), + eq(inserts.id, id), + ), + ) .returning(); - await reparentInsertCells(tx, id, location); + await reparentInsertCells(tx, orgId, id, location); return ins; }); await transactionRepository.log({ + userId, + orgId, actionType: "insert.place", entityType: "insert", entityId: id, @@ -499,15 +589,28 @@ export const insertRepository = { return updated; }, - async removeFromLocation({ id }: { id: string }) { - const before = await insertRepository.findById({ id }); + async removeFromLocation({ + userId, + orgId, + id, + }: { + userId: string; + orgId: string; + id: string; + }) { + const before = await insertRepository.findById({ orgId, id }); if (!before) throw new Error(`Insert ${id} not found`); const updated = await db.transaction(async (tx) => { const [ins] = await tx .update(inserts) .set({ locationId: null, updatedAt: new Date() }) - .where(eq(inserts.id, id)) + .where( + and( + isolatedOrgFilter(inserts.ownerOrgId, orgId), + eq(inserts.id, id), + ), + ) .returning(); // Cells travel with the insert. When unplaced, they stay bound @@ -516,7 +619,12 @@ export const insertRepository = { const cells = await tx .select() .from(locations) - .where(eq(locations.insertId, id)); + .where( + and( + isolatedOrgFilter(locations.ownerOrgId, orgId), + eq(locations.insertId, id), + ), + ); for (const cell of cells) { const segments = [cell.label]; await tx @@ -528,13 +636,20 @@ export const insertRepository = { pathSegments: segments, updatedAt: new Date(), }) - .where(eq(locations.id, cell.id)); + .where( + and( + isolatedOrgFilter(locations.ownerOrgId, orgId), + eq(locations.id, cell.id), + ), + ); } return ins; }); await transactionRepository.log({ + userId, + orgId, actionType: "insert.removeFromLocation", entityType: "insert", entityId: id, @@ -546,9 +661,13 @@ export const insertRepository = { }, async update({ + userId, + orgId, id, ...updates }: { + userId: string; + orgId: string; id: string; name?: string; templateId?: string; @@ -558,16 +677,20 @@ export const insertRepository = { overrides?: Record; metadata?: Record; }) { - const before = await insertRepository.findById({ id }); + const before = await insertRepository.findById({ orgId, id }); if (!before) throw new Error(`Insert ${id} not found`); const [updated] = await db .update(inserts) .set({ ...updates, updatedAt: new Date() }) - .where(eq(inserts.id, id)) + .where( + and(isolatedOrgFilter(inserts.ownerOrgId, orgId), eq(inserts.id, id)), + ) .returning(); await transactionRepository.log({ + userId, + orgId, actionType: "insert.update", entityType: "insert", entityId: id, @@ -578,13 +701,27 @@ export const insertRepository = { return updated; }, - async remove({ id }: { id: string }) { - const before = await insertRepository.findById({ id }); + async remove({ + userId, + orgId, + id, + }: { + userId: string; + orgId: string; + id: string; + }) { + const before = await insertRepository.findById({ orgId, id }); if (!before) throw new Error(`Insert ${id} not found`); - await db.delete(inserts).where(eq(inserts.id, id)); + await db + .delete(inserts) + .where( + and(isolatedOrgFilter(inserts.ownerOrgId, orgId), eq(inserts.id, id)), + ); await transactionRepository.log({ + userId, + orgId, actionType: "insert.delete", entityType: "insert", entityId: id, diff --git a/web/repositories/interfaceTypeRepository.ts b/web/repositories/interfaceTypeRepository.ts index 623f065..750718c 100644 --- a/web/repositories/interfaceTypeRepository.ts +++ b/web/repositories/interfaceTypeRepository.ts @@ -7,29 +7,43 @@ import { templateVersionInterfacesProvided, templateVersionInterfacesAccepted, locationInterfacesAccepted, + locations, } from "@/db/schema"; +import { additiveOrgFilter } from "@/lib/auth/scope"; import { transactionRepository } from "./transactionRepository"; type Maturity = "draft" | "stable"; type ListStatus = "active" | "archived" | "all"; +// interface_types is an additive table. Shared physical contracts +// (e.g. "plano-3600") live as global rows (owner_org_id IS NULL); +// orgs may add private contracts (e.g. a custom 3D-printed receptacle) +// by passing `asGlobal: false` or omitting it on create. export const interfaceTypeRepository = { async create({ + userId, + orgId, + asGlobal, identifier, description, physicalContract, maturity, unitSystem, }: { + userId: string; + orgId: string; + asGlobal?: boolean; identifier: string; description?: string; physicalContract?: Record; maturity?: Maturity; unitSystem?: Record; }) { + const ownerOrgId = asGlobal ? null : orgId; const [interfaceType] = await db .insert(interfaceTypes) .values({ + ownerOrgId, identifier, description, physicalContract, @@ -41,6 +55,8 @@ export const interfaceTypeRepository = { .returning(); await transactionRepository.log({ + userId, + orgId, actionType: "interfaceType.create", entityType: "interfaceType", entityId: interfaceType.id, @@ -51,37 +67,69 @@ export const interfaceTypeRepository = { return interfaceType; }, - async findById({ id }: { id: string }) { + async findById({ orgId, id }: { orgId: string; id: string }) { const [interfaceType] = await db .select() .from(interfaceTypes) - .where(eq(interfaceTypes.id, id)); + .where( + and( + additiveOrgFilter(interfaceTypes.ownerOrgId, orgId), + eq(interfaceTypes.id, id), + ), + ); return interfaceType ?? null; }, - async findByIdentifier({ identifier }: { identifier: string }) { + async findByIdentifier({ + orgId, + identifier, + }: { + orgId: string; + identifier: string; + }) { const [interfaceType] = await db .select() .from(interfaceTypes) - .where(eq(interfaceTypes.identifier, identifier)); + .where( + and( + additiveOrgFilter(interfaceTypes.ownerOrgId, orgId), + eq(interfaceTypes.identifier, identifier), + ), + ); return interfaceType ?? null; }, - async list({ status = "all" }: { status?: ListStatus } = {}) { - const base = db.select().from(interfaceTypes); + async list({ + orgId, + status = "all", + }: { + orgId: string; + status?: ListStatus; + }) { + const scope = additiveOrgFilter(interfaceTypes.ownerOrgId, orgId); if (status === "active") { - return base.where(isNull(interfaceTypes.archivedAt)); + return db + .select() + .from(interfaceTypes) + .where(and(scope, isNull(interfaceTypes.archivedAt))); } if (status === "archived") { - return base.where(isNotNull(interfaceTypes.archivedAt)); + return db + .select() + .from(interfaceTypes) + .where(and(scope, isNotNull(interfaceTypes.archivedAt))); } - return base; + return db.select().from(interfaceTypes).where(scope); }, async update({ + userId, + orgId, id, ...updates }: { + userId: string; + orgId: string; id: string; identifier?: string; description?: string; @@ -89,16 +137,13 @@ export const interfaceTypeRepository = { maturity?: Maturity; unitSystem?: Record | null; }) { - const before = await interfaceTypeRepository.findById({ id }); + const before = await interfaceTypeRepository.findById({ orgId, id }); if (!before) throw new Error(`InterfaceType ${id} not found`); // Maturity guard — stable is terminal. Demotion creates ambiguous // semantics when refs already point at the type. See spec // "Maturity" → state machine one-directional. - if ( - updates.maturity === "draft" && - before.maturity === "stable" - ) { + if (updates.maturity === "draft" && before.maturity === "stable") { throw new Error( "Cannot demote stable → draft. Stable is terminal (one-way state machine).", ); @@ -107,10 +152,17 @@ export const interfaceTypeRepository = { const [updated] = await db .update(interfaceTypes) .set(updates) - .where(eq(interfaceTypes.id, id)) + .where( + and( + additiveOrgFilter(interfaceTypes.ownerOrgId, orgId), + eq(interfaceTypes.id, id), + ), + ) .returning(); await transactionRepository.log({ + userId, + orgId, actionType: "interfaceType.update", entityType: "interfaceType", entityId: id, @@ -121,8 +173,16 @@ export const interfaceTypeRepository = { return updated; }, - async archive({ id }: { id: string }) { - const before = await interfaceTypeRepository.findById({ id }); + async archive({ + userId, + orgId, + id, + }: { + userId: string; + orgId: string; + id: string; + }) { + const before = await interfaceTypeRepository.findById({ orgId, id }); if (!before) throw new Error(`InterfaceType ${id} not found`); if (before.archivedAt) { @@ -132,10 +192,17 @@ export const interfaceTypeRepository = { const [updated] = await db .update(interfaceTypes) .set({ archivedAt: new Date() }) - .where(eq(interfaceTypes.id, id)) + .where( + and( + additiveOrgFilter(interfaceTypes.ownerOrgId, orgId), + eq(interfaceTypes.id, id), + ), + ) .returning(); await transactionRepository.log({ + userId, + orgId, actionType: "interfaceType.archive", entityType: "interfaceType", entityId: id, @@ -146,8 +213,16 @@ export const interfaceTypeRepository = { return updated; }, - async unarchive({ id }: { id: string }) { - const before = await interfaceTypeRepository.findById({ id }); + async unarchive({ + userId, + orgId, + id, + }: { + userId: string; + orgId: string; + id: string; + }) { + const before = await interfaceTypeRepository.findById({ orgId, id }); if (!before) throw new Error(`InterfaceType ${id} not found`); if (!before.archivedAt) { @@ -157,10 +232,17 @@ export const interfaceTypeRepository = { const [updated] = await db .update(interfaceTypes) .set({ archivedAt: null }) - .where(eq(interfaceTypes.id, id)) + .where( + and( + additiveOrgFilter(interfaceTypes.ownerOrgId, orgId), + eq(interfaceTypes.id, id), + ), + ) .returning(); await transactionRepository.log({ + userId, + orgId, actionType: "interfaceType.unarchive", entityType: "interfaceType", entityId: id, @@ -171,21 +253,42 @@ export const interfaceTypeRepository = { return updated; }, - async usageCount({ id }: { id: string }) { + async usageCount({ orgId, id }: { orgId: string; id: string }) { const [providers] = await db .select({ count: sql`count(*)::int` }) .from(templateVersionInterfacesProvided) - .where(eq(templateVersionInterfacesProvided.interfaceTypeId, id)); + .where( + and( + additiveOrgFilter( + templateVersionInterfacesProvided.ownerOrgId, + orgId, + ), + eq(templateVersionInterfacesProvided.interfaceTypeId, id), + ), + ); const [accepters] = await db .select({ count: sql`count(*)::int` }) .from(templateVersionInterfacesAccepted) - .where(eq(templateVersionInterfacesAccepted.interfaceTypeId, id)); + .where( + and( + additiveOrgFilter( + templateVersionInterfacesAccepted.ownerOrgId, + orgId, + ), + eq(templateVersionInterfacesAccepted.interfaceTypeId, id), + ), + ); const [receptacles] = await db .select({ count: sql`count(*)::int` }) .from(locationInterfacesAccepted) - .where(eq(locationInterfacesAccepted.interfaceTypeId, id)); + .where( + and( + eq(locationInterfacesAccepted.ownerOrgId, orgId), + eq(locationInterfacesAccepted.interfaceTypeId, id), + ), + ); return { providers: providers?.count ?? 0, @@ -201,15 +304,20 @@ export const interfaceTypeRepository = { * template version is minted capturing the remapped interface set — * per spec, interfaces-provided/accepted are versioned properties, so * a merge is a versioned event. Source rows are hard-deleted at the - * end, bypassing the archive-gate (merge consolidates; tombstoning is - * part of the operation). + * end. * - * All work happens in a single transaction. + * Scope: only interface_types visible to `orgId` (global ∪ own) and + * template/location junctions owned by (or global-shared with) the + * org are affected. Cross-tenant merges are not supported. */ async merge({ + userId, + orgId, sourceIds, targetId, }: { + userId: string; + orgId: string; sourceIds: string[]; targetId: string; }) { @@ -220,15 +328,16 @@ export const interfaceTypeRepository = { throw new Error("Merge target cannot also be a source."); } - const target = await interfaceTypeRepository.findById({ id: targetId }); + const target = await interfaceTypeRepository.findById({ orgId, id: targetId }); if (!target) { throw new Error(`Merge target ${targetId} not found.`); } + const scope = additiveOrgFilter(interfaceTypes.ownerOrgId, orgId); const sources = await db .select() .from(interfaceTypes) - .where(inArray(interfaceTypes.id, sourceIds)); + .where(and(scope, inArray(interfaceTypes.id, sourceIds))); if (sources.length !== sourceIds.length) { const found = new Set(sources.map((s) => s.id)); const missing = sourceIds.filter((id) => !found.has(id)); @@ -241,24 +350,38 @@ export const interfaceTypeRepository = { .select({ templateVersionId: templateVersionInterfacesProvided.templateVersionId, + ownerOrgId: templateVersionInterfacesProvided.ownerOrgId, }) .from(templateVersionInterfacesProvided) .where( - inArray( - templateVersionInterfacesProvided.interfaceTypeId, - sourceIds, + and( + additiveOrgFilter( + templateVersionInterfacesProvided.ownerOrgId, + orgId, + ), + inArray( + templateVersionInterfacesProvided.interfaceTypeId, + sourceIds, + ), ), ); const affectedAccepted = await tx .select({ templateVersionId: templateVersionInterfacesAccepted.templateVersionId, + ownerOrgId: templateVersionInterfacesAccepted.ownerOrgId, }) .from(templateVersionInterfacesAccepted) .where( - inArray( - templateVersionInterfacesAccepted.interfaceTypeId, - sourceIds, + and( + additiveOrgFilter( + templateVersionInterfacesAccepted.ownerOrgId, + orgId, + ), + inArray( + templateVersionInterfacesAccepted.interfaceTypeId, + sourceIds, + ), ), ); const affectedVersionIds = Array.from( @@ -269,18 +392,17 @@ export const interfaceTypeRepository = { ); // ── 2. Rewrite template_version junctions: source → target ── - // Strategy: SELECT distinct holders of source refs; INSERT target rows - // with ON CONFLICT DO NOTHING to dedupe; DELETE source rows. + // Dedupe holders; preserve each holder's ownerOrgId on the new row. let rewritesProvided = 0; let rewritesAccepted = 0; if (affectedProvided.length > 0) { - const holders = Array.from( - new Set(affectedProvided.map((r) => r.templateVersionId)), - ); + const holderOwner = new Map(); + for (const r of affectedProvided) holderOwner.set(r.templateVersionId, r.ownerOrgId); await tx .insert(templateVersionInterfacesProvided) .values( - holders.map((templateVersionId) => ({ + Array.from(holderOwner.entries()).map(([templateVersionId, ownerOrgId]) => ({ + ownerOrgId, templateVersionId, interfaceTypeId: targetId, })), @@ -298,13 +420,13 @@ export const interfaceTypeRepository = { rewritesProvided = deleted.length; } if (affectedAccepted.length > 0) { - const holders = Array.from( - new Set(affectedAccepted.map((r) => r.templateVersionId)), - ); + const holderOwner = new Map(); + for (const r of affectedAccepted) holderOwner.set(r.templateVersionId, r.ownerOrgId); await tx .insert(templateVersionInterfacesAccepted) .values( - holders.map((templateVersionId) => ({ + Array.from(holderOwner.entries()).map(([templateVersionId, ownerOrgId]) => ({ + ownerOrgId, templateVersionId, interfaceTypeId: targetId, })), @@ -323,21 +445,31 @@ export const interfaceTypeRepository = { } // ── 3. Rewrite location junctions: source → target ── + // location_interfaces_accepted is isolated; only this org's rows. const affectedLocations = await tx - .select({ locationId: locationInterfacesAccepted.locationId }) + .select({ + locationId: locationInterfacesAccepted.locationId, + ownerOrgId: locationInterfacesAccepted.ownerOrgId, + }) .from(locationInterfacesAccepted) .where( - inArray(locationInterfacesAccepted.interfaceTypeId, sourceIds), + and( + eq(locationInterfacesAccepted.ownerOrgId, orgId), + inArray(locationInterfacesAccepted.interfaceTypeId, sourceIds), + ), ); let rewritesLocations = 0; if (affectedLocations.length > 0) { - const holders = Array.from( + // location_interfaces_accepted is isolated + NOT NULL, so ownerOrgId + // for every affected row equals orgId (we filtered on that above). + const uniqueLocationIds = Array.from( new Set(affectedLocations.map((r) => r.locationId)), ); await tx .insert(locationInterfacesAccepted) .values( - holders.map((locationId) => ({ + uniqueLocationIds.map((locationId) => ({ + ownerOrgId: orgId, locationId, interfaceTypeId: targetId, })), @@ -346,20 +478,18 @@ export const interfaceTypeRepository = { const deleted = await tx .delete(locationInterfacesAccepted) .where( - inArray(locationInterfacesAccepted.interfaceTypeId, sourceIds), + and( + eq(locationInterfacesAccepted.ownerOrgId, orgId), + inArray(locationInterfacesAccepted.interfaceTypeId, sourceIds), + ), ) .returning(); rewritesLocations = deleted.length; } // ── 4. Mint a new template version for each affected template ── - // Captures the post-merge interface set as a distinct version so - // consumers see a versioned trail of the merge. Junction rows for - // the new version carry the already-remapped target refs, so we - // snapshot the version's current junctions after step 2. let versionsMinted = 0; if (affectedVersionIds.length > 0) { - // Map version -> template const versionRows = await tx .select() .from(templateVersions) @@ -377,10 +507,10 @@ export const interfaceTypeRepository = { .from(templates) .where(eq(templates.id, templateId)); if (!tmpl) continue; - // Use the most-recent affected version as the structural clone source. - const latest = versions.reduce((best, v) => - v.version > best.version ? v : best, - versions[0]); + const latest = versions.reduce( + (best, v) => (v.version > best.version ? v : best), + versions[0], + ); const newVersionNumber = tmpl.currentVersion + 1; const { @@ -390,18 +520,21 @@ export const interfaceTypeRepository = { templateId: _oldTemplateId, ...clonedFields } = latest; - void _oldId; void _oldVersion; void _oldCreated; void _oldTemplateId; + void _oldId; + void _oldVersion; + void _oldCreated; + void _oldTemplateId; const [newVersion] = await tx .insert(templateVersions) .values({ ...clonedFields, + ownerOrgId: tmpl.ownerOrgId, templateId, version: newVersionNumber, }) .returning(); - // Copy the latest version's junctions (already remapped in step 2). const providedRows = await tx .select() .from(templateVersionInterfacesProvided) @@ -414,6 +547,7 @@ export const interfaceTypeRepository = { if (providedRows.length > 0) { await tx.insert(templateVersionInterfacesProvided).values( providedRows.map((r) => ({ + ownerOrgId: r.ownerOrgId, templateVersionId: newVersion.id, interfaceTypeId: r.interfaceTypeId, })), @@ -431,6 +565,7 @@ export const interfaceTypeRepository = { if (acceptedRows.length > 0) { await tx.insert(templateVersionInterfacesAccepted).values( acceptedRows.map((r) => ({ + ownerOrgId: r.ownerOrgId, templateVersionId: newVersion.id, interfaceTypeId: r.interfaceTypeId, })), @@ -446,8 +581,15 @@ export const interfaceTypeRepository = { } } - // ── 5. Delete source interface_types ── - await tx.delete(interfaceTypes).where(inArray(interfaceTypes.id, sourceIds)); + // ── 5. Delete source interface_types (scope-filtered) ── + await tx + .delete(interfaceTypes) + .where( + and( + additiveOrgFilter(interfaceTypes.ownerOrgId, orgId), + inArray(interfaceTypes.id, sourceIds), + ), + ); return { referencesUpdated: @@ -458,6 +600,8 @@ export const interfaceTypeRepository = { }); await transactionRepository.log({ + userId, + orgId, actionType: "interfaceType.merge", entityType: "interfaceType", entityId: targetId, @@ -476,8 +620,16 @@ export const interfaceTypeRepository = { return result; }, - async remove({ id }: { id: string }) { - const before = await interfaceTypeRepository.findById({ id }); + async remove({ + userId, + orgId, + id, + }: { + userId: string; + orgId: string; + id: string; + }) { + const before = await interfaceTypeRepository.findById({ orgId, id }); if (!before) throw new Error(`InterfaceType ${id} not found`); // Delete-gate: must be archived AND unused. Spec intent — hard @@ -488,7 +640,7 @@ export const interfaceTypeRepository = { ); } - const usage = await interfaceTypeRepository.usageCount({ id }); + const usage = await interfaceTypeRepository.usageCount({ orgId, id }); const total = usage.providers + usage.accepters + usage.receptacles; if (total > 0) { throw new Error( @@ -496,9 +648,18 @@ export const interfaceTypeRepository = { ); } - await db.delete(interfaceTypes).where(eq(interfaceTypes.id, id)); + await db + .delete(interfaceTypes) + .where( + and( + additiveOrgFilter(interfaceTypes.ownerOrgId, orgId), + eq(interfaceTypes.id, id), + ), + ); await transactionRepository.log({ + userId, + orgId, actionType: "interfaceType.delete", entityType: "interfaceType", entityId: id, diff --git a/web/repositories/itemRepository.ts b/web/repositories/itemRepository.ts index bd1c169..390e338 100644 --- a/web/repositories/itemRepository.ts +++ b/web/repositories/itemRepository.ts @@ -1,4 +1,4 @@ -import { eq, or, and, ilike, inArray, sql, asc, desc } from "drizzle-orm"; +import { eq, or, and, ilike, inArray, sql } from "drizzle-orm"; import { db } from "@/db/connection"; import { items, @@ -14,22 +14,43 @@ import { locations, itemStandards, } from "@/db/schema"; +import { additiveOrgFilter, isolatedOrgFilter } from "@/lib/auth/scope"; import { transactionRepository } from "./transactionRepository"; import { type AuditCheck } from "@/lib/audit"; +// items is an additive table. Reads union (global ∪ org). Writes default +// to org-private; pass `asGlobal: true` to contribute to the global +// catalog (any signed-in user may create or edit globals; audited). +// +// Junction / value tables (item_categories, item_aspects, +// item_parameter_values, item_standards, co_storability) are also +// additive. Each row inherits its ownerOrgId from the parent item — a +// global item's junctions are global, an org-private item's are org. +// +// assignments is isolated. Reads here (the listRich join) strictly +// scope by orgId. export const itemRepository = { async create({ + userId, + orgId, + asGlobal, name, description, metadata, }: { + userId: string; + orgId: string; + asGlobal?: boolean; name: string; description?: string; metadata?: Record; }) { + const ownerOrgId = asGlobal ? null : orgId; + const [item] = await db .insert(items) .values({ + ownerOrgId, name, description, metadata, @@ -37,6 +58,8 @@ export const itemRepository = { .returning(); await transactionRepository.log({ + userId, + orgId, actionType: "item.create", entityType: "item", entityId: item.id, @@ -47,54 +70,71 @@ export const itemRepository = { return item; }, - async findById({ id }: { id: string }) { + async findById({ orgId, id }: { orgId: string; id: string }) { const [item] = await db .select() .from(items) - .where(eq(items.id, id)); + .where(and(additiveOrgFilter(items.ownerOrgId, orgId), eq(items.id, id))); return item ?? null; }, - async findByName({ name }: { name: string }) { + async findByName({ orgId, name }: { orgId: string; name: string }) { const [item] = await db .select() .from(items) - .where(eq(items.name, name)); + .where( + and(additiveOrgFilter(items.ownerOrgId, orgId), eq(items.name, name)), + ); return item ?? null; }, - async search({ query }: { query: string }) { + async search({ orgId, query }: { orgId: string; query: string }) { const pattern = `%${query}%`; return db .select() .from(items) .where( - or( - ilike(items.name, pattern), - ilike(items.description, pattern), + and( + additiveOrgFilter(items.ownerOrgId, orgId), + or( + ilike(items.name, pattern), + ilike(items.description, pattern), + ), ), ); }, - async list() { - return db.select().from(items); + async list({ orgId }: { orgId: string }) { + return db + .select() + .from(items) + .where(additiveOrgFilter(items.ownerOrgId, orgId)); }, // --- Rich listing with filters, search, sort --- async listRich({ + orgId, query, filters, categoryId, sortBy, sortDirection, }: { + orgId: string; query?: string; filters?: { parameterDefinitionId: string; value: unknown }[]; categoryId?: string; sortBy?: string; // "name" or a parameter definition ID sortDirection?: "asc" | "desc"; - } = {}) { + }) { + const itemScope = additiveOrgFilter(items.ownerOrgId, orgId); + const ipvScope = additiveOrgFilter(itemParameterValues.ownerOrgId, orgId); + const icScope = additiveOrgFilter(itemCategories.ownerOrgId, orgId); + const iaScope = additiveOrgFilter(itemAspects.ownerOrgId, orgId); + const asScope = isolatedOrgFilter(assignments.ownerOrgId, orgId); + const locScope = isolatedOrgFilter(locations.ownerOrgId, orgId); + // Step 1: Build filtered item ID set let itemIds: string[] | null = null; @@ -106,12 +146,13 @@ export const itemRepository = { .from(itemParameterValues) .where( and( + ipvScope, eq( itemParameterValues.parameterDefinitionId, - filter.parameterDefinitionId + filter.parameterDefinitionId, ), - sql`${itemParameterValues.value} = ${JSON.stringify(filter.value)}::jsonb` - ) + sql`${itemParameterValues.value} = ${JSON.stringify(filter.value)}::jsonb`, + ), ); const matchingIds = new Set(matchingRows.map((r) => r.itemId)); @@ -131,7 +172,9 @@ export const itemRepository = { const catRows = await db .select({ itemId: itemCategories.itemId }) .from(itemCategories) - .where(eq(itemCategories.categoryId, categoryId)); + .where( + and(icScope, eq(itemCategories.categoryId, categoryId)), + ); const catIds = new Set(catRows.map((r) => r.itemId)); @@ -153,14 +196,19 @@ export const itemRepository = { .select({ id: items.id }) .from(items) .where( - or(ilike(items.name, pattern), ilike(items.description, pattern)) + and( + itemScope, + or(ilike(items.name, pattern), ilike(items.description, pattern)), + ), ); // Items matching by parameter value (cast jsonb to text for ilike) const paramMatches = await db .select({ itemId: itemParameterValues.itemId }) .from(itemParameterValues) - .where(sql`${itemParameterValues.value}::text ILIKE ${pattern}`); + .where( + and(ipvScope, sql`${itemParameterValues.value}::text ILIKE ${pattern}`), + ); const searchIds = new Set([ ...nameMatches.map((r) => r.id), @@ -183,9 +231,9 @@ export const itemRepository = { itemRows = await db .select() .from(items) - .where(inArray(items.id, itemIds)); + .where(and(itemScope, inArray(items.id, itemIds))); } else { - itemRows = await db.select().from(items); + itemRows = await db.select().from(items).where(itemScope); } if (itemRows.length === 0) return { items: [], total: 0 }; @@ -208,9 +256,9 @@ export const itemRepository = { .from(itemCategories) .innerJoin( categories, - eq(itemCategories.categoryId, categories.id) + eq(itemCategories.categoryId, categories.id), ) - .where(inArray(itemCategories.itemId, allIds)), + .where(and(icScope, inArray(itemCategories.itemId, allIds))), // Applied aspects db @@ -223,7 +271,7 @@ export const itemRepository = { }) .from(itemAspects) .innerJoin(aspects, eq(itemAspects.aspectId, aspects.id)) - .where(inArray(itemAspects.itemId, allIds)), + .where(and(iaScope, inArray(itemAspects.itemId, allIds))), // Parameter values with definitions db @@ -242,12 +290,12 @@ export const itemRepository = { parameterDefinitions, eq( itemParameterValues.parameterDefinitionId, - parameterDefinitions.id - ) + parameterDefinitions.id, + ), ) - .where(inArray(itemParameterValues.itemId, allIds)), + .where(and(ipvScope, inArray(itemParameterValues.itemId, allIds))), - // Assignments with location paths + // Assignments with location paths — isolated, strictly scoped. db .select({ itemId: assignments.itemId, @@ -257,7 +305,13 @@ export const itemRepository = { }) .from(assignments) .innerJoin(locations, eq(assignments.locationId, locations.id)) - .where(inArray(assignments.itemId, allIds)), + .where( + and( + asScope, + locScope, + inArray(assignments.itemId, allIds), + ), + ), ]); // Step 4: Assemble rich items @@ -276,7 +330,7 @@ export const itemRepository = { parameters: paramValueRows .filter( (pv) => - pv.itemId === item.id && pv.itemAspectId === a.itemAspectId + pv.itemId === item.id && pv.itemAspectId === a.itemAspectId, ) .map(({ itemId, ...rest }) => rest), })), @@ -304,10 +358,10 @@ export const itemRepository = { ...b.standaloneParameters, ]; const valA = allParamsA.find( - (p) => p.parameterDefinitionId === sortBy + (p) => p.parameterDefinitionId === sortBy, )?.value; const valB = allParamsB.find( - (p) => p.parameterDefinitionId === sortBy + (p) => p.parameterDefinitionId === sortBy, )?.value; if (valA == null && valB == null) return 0; @@ -324,27 +378,26 @@ export const itemRepository = { return { items: richItems, total: richItems.length }; }, - // --- Category counts (respects active filters) --- + // --- Cross-item audit --- - /** - * Return items that *might* be duplicates of an item described by the - * given standard+designation (plus their parameter values so the caller - * can refine per-row for set generation). Simple first cut: any item - * that has the same (standardId, designationId) applied. - */ /** * Cross-item audit: value outliers + free-text drift suggesting an - * enum promotion. Pulls all parameter values in one query then - * groups by parameterDefinitionId in Node. + * enum promotion. Pulls parameter values in scope then groups by + * parameterDefinitionId in Node. */ - async auditParameterValues(): Promise { + async auditParameterValues({ + orgId, + }: { + orgId: string; + }): Promise { const rows = await db .select({ itemId: itemParameterValues.itemId, parameterDefinitionId: itemParameterValues.parameterDefinitionId, value: itemParameterValues.value, }) - .from(itemParameterValues); + .from(itemParameterValues) + .where(additiveOrgFilter(itemParameterValues.ownerOrgId, orgId)); // Group values per parameter. const byParam = new Map>(); @@ -355,14 +408,15 @@ export const itemRepository = { } // Need param metadata (name + dataType) so checks can report and - // qualify their work. + // qualify their work. parameterDefinitions is also additive. const paramRows = await db .select({ id: parameterDefinitions.id, name: parameterDefinitions.name, dataType: parameterDefinitions.dataType, }) - .from(parameterDefinitions); + .from(parameterDefinitions) + .where(additiveOrgFilter(parameterDefinitions.ownerOrgId, orgId)); const paramById = new Map(paramRows.map((p) => [p.id, p])); const outliers: Array<{ id: string; name: string }> = []; @@ -390,7 +444,7 @@ export const itemRepository = { const distinct = new Set( values .map((v) => (typeof v.value === "string" ? v.value.trim() : null)) - .filter((s): s is string => !!s) + .filter((s): s is string => !!s), ); if (distinct.size > 0 && distinct.size <= 20 && values.length >= 5) { drift.push({ id: meta.id, name: meta.name }); @@ -420,10 +474,18 @@ export const itemRepository = { return out; }, + /** + * Return items that *might* be duplicates of an item described by the + * given standard+designation (plus their parameter values so the caller + * can refine per-row for set generation). Simple first cut: any item + * that has the same (standardId, designationId) applied. + */ async findSimilar({ + orgId, standardId, designationId, }: { + orgId: string; standardId: string; designationId: string; }) { @@ -436,9 +498,11 @@ export const itemRepository = { .innerJoin(itemStandards, eq(itemStandards.itemId, items.id)) .where( and( + additiveOrgFilter(items.ownerOrgId, orgId), + additiveOrgFilter(itemStandards.ownerOrgId, orgId), eq(itemStandards.standardId, standardId), - eq(itemStandards.designationId, designationId) - ) + eq(itemStandards.designationId, designationId), + ), ); if (matches.length === 0) return []; @@ -451,7 +515,12 @@ export const itemRepository = { value: itemParameterValues.value, }) .from(itemParameterValues) - .where(inArray(itemParameterValues.itemId, ids)); + .where( + and( + additiveOrgFilter(itemParameterValues.ownerOrgId, orgId), + inArray(itemParameterValues.itemId, ids), + ), + ); const byItem = new Map>(); for (const r of pvRows) { @@ -478,10 +547,12 @@ export const itemRepository = { * item fits. */ async suggestCategories({ + orgId, aspectIds = [], standardIds = [], limit = 3, }: { + orgId: string; aspectIds?: string[]; standardIds?: string[]; limit?: number; @@ -494,27 +565,42 @@ export const itemRepository = { const rows = await db .select({ itemId: itemAspects.itemId }) .from(itemAspects) - .where(inArray(itemAspects.aspectId, aspectIds)); + .where( + and( + additiveOrgFilter(itemAspects.ownerOrgId, orgId), + inArray(itemAspects.aspectId, aspectIds), + ), + ); for (const r of rows) matchingItemIds.add(r.itemId); } if (standardIds.length > 0) { const rows = await db .select({ itemId: itemStandards.itemId }) .from(itemStandards) - .where(inArray(itemStandards.standardId, standardIds)); + .where( + and( + additiveOrgFilter(itemStandards.ownerOrgId, orgId), + inArray(itemStandards.standardId, standardIds), + ), + ); for (const r of rows) matchingItemIds.add(r.itemId); } if (matchingItemIds.size === 0) return []; - // For each category, count matching items and total items. + // For each category, count matching items and total items (scoped). const matchingRows = await db .select({ categoryId: itemCategories.categoryId, itemId: itemCategories.itemId, }) .from(itemCategories) - .where(inArray(itemCategories.itemId, Array.from(matchingItemIds))); + .where( + and( + additiveOrgFilter(itemCategories.ownerOrgId, orgId), + inArray(itemCategories.itemId, Array.from(matchingItemIds)), + ), + ); const matchByCat = new Map(); for (const r of matchingRows) { @@ -528,7 +614,12 @@ export const itemRepository = { count: sql`COUNT(*)::int`, }) .from(itemCategories) - .where(inArray(itemCategories.categoryId, Array.from(matchByCat.keys()))) + .where( + and( + additiveOrgFilter(itemCategories.ownerOrgId, orgId), + inArray(itemCategories.categoryId, Array.from(matchByCat.keys())), + ), + ) .groupBy(itemCategories.categoryId); const totalByCat = new Map(); for (const r of totalRows) totalByCat.set(r.categoryId, Number(r.count)); @@ -541,7 +632,12 @@ export const itemRepository = { color: categories.color, }) .from(categories) - .where(inArray(categories.id, Array.from(matchByCat.keys()))); + .where( + and( + additiveOrgFilter(categories.ownerOrgId, orgId), + inArray(categories.id, Array.from(matchByCat.keys())), + ), + ); const byId = new Map(catRows.map((c) => [c.id, c])); const scored = Array.from(matchByCat.entries()).map( @@ -558,7 +654,7 @@ export const itemRepository = { total, score, }; - } + }, ); scored.sort((a, b) => b.score - a.score || b.matched - a.matched); @@ -566,24 +662,29 @@ export const itemRepository = { }, async getCategoryCounts({ + orgId, query, filters, }: { + orgId: string; query?: string; filters?: { parameterDefinitionId: string; value: unknown }[]; - } = {}) { + }) { // Get the filtered item set first (reuse filtering logic) const { items: filteredItems } = await itemRepository.listRich({ + orgId, query, filters, }); const filteredIds = filteredItems.map((i) => i.id); - // Get all categories with counts + // Get all categories with counts (categories is additive — show + // global + org-private categories). const allCategories = await db .select() .from(categories) + .where(additiveOrgFilter(categories.ownerOrgId, orgId)) .orderBy(categories.sortOrder); if (filteredIds.length === 0) { @@ -596,7 +697,12 @@ export const itemRepository = { count: sql`count(*)::int`, }) .from(itemCategories) - .where(inArray(itemCategories.itemId, filteredIds)) + .where( + and( + additiveOrgFilter(itemCategories.ownerOrgId, orgId), + inArray(itemCategories.itemId, filteredIds), + ), + ) .groupBy(itemCategories.categoryId); const countMap = new Map(catCounts.map((c) => [c.categoryId, c.count])); @@ -608,24 +714,32 @@ export const itemRepository = { }, async update({ + userId, + orgId, id, ...updates }: { + userId: string; + orgId: string; id: string; name?: string; description?: string; metadata?: Record; }) { - const before = await itemRepository.findById({ id }); + const before = await itemRepository.findById({ orgId, id }); if (!before) throw new Error(`Item ${id} not found`); const [updated] = await db .update(items) .set({ ...updates, updatedAt: new Date() }) - .where(eq(items.id, id)) + .where( + and(additiveOrgFilter(items.ownerOrgId, orgId), eq(items.id, id)), + ) .returning(); await transactionRepository.log({ + userId, + orgId, actionType: "item.update", entityType: "item", entityId: id, @@ -636,13 +750,27 @@ export const itemRepository = { return updated; }, - async remove({ id }: { id: string }) { - const before = await itemRepository.findById({ id }); + async remove({ + userId, + orgId, + id, + }: { + userId: string; + orgId: string; + id: string; + }) { + const before = await itemRepository.findById({ orgId, id }); if (!before) throw new Error(`Item ${id} not found`); - await db.delete(items).where(eq(items.id, id)); + await db + .delete(items) + .where( + and(additiveOrgFilter(items.ownerOrgId, orgId), eq(items.id, id)), + ); await transactionRepository.log({ + userId, + orgId, actionType: "item.delete", entityType: "item", entityId: id, @@ -652,20 +780,37 @@ export const itemRepository = { }, async addCoStorability({ + userId, + orgId, itemAId, itemBId, reason, }: { + userId: string; + orgId: string; itemAId: string; itemBId: string; reason?: string; }) { + // Junction inherits parent item's scope. Require itemA to be + // visible; use its ownerOrgId so a co-storability of two globals + // is itself global. + const parent = await itemRepository.findById({ orgId, id: itemAId }); + if (!parent) throw new Error(`Item ${itemAId} not found`); + const [record] = await db .insert(coStorability) - .values({ itemAId, itemBId, reason }) + .values({ + ownerOrgId: parent.ownerOrgId, + itemAId, + itemBId, + reason, + }) .returning(); await transactionRepository.log({ + userId, + orgId, actionType: "coStorability.create", entityType: "coStorability", entityId: record.id, @@ -677,25 +822,32 @@ export const itemRepository = { }, async removeCoStorability({ + userId, + orgId, itemAId, itemBId, }: { + userId: string; + orgId: string; itemAId: string; itemBId: string; }) { - // Find the record in either direction + // Find the record in either direction, within scope. const [record] = await db .select() .from(coStorability) .where( - or( - and( - eq(coStorability.itemAId, itemAId), - eq(coStorability.itemBId, itemBId), - ), - and( - eq(coStorability.itemAId, itemBId), - eq(coStorability.itemBId, itemAId), + and( + additiveOrgFilter(coStorability.ownerOrgId, orgId), + or( + and( + eq(coStorability.itemAId, itemAId), + eq(coStorability.itemBId, itemBId), + ), + and( + eq(coStorability.itemAId, itemBId), + eq(coStorability.itemBId, itemAId), + ), ), ), ); @@ -705,6 +857,8 @@ export const itemRepository = { await db.delete(coStorability).where(eq(coStorability.id, record.id)); await transactionRepository.log({ + userId, + orgId, actionType: "coStorability.delete", entityType: "coStorability", entityId: record.id, @@ -713,14 +867,23 @@ export const itemRepository = { }); }, - async getCoStorableItems({ itemId }: { itemId: string }) { + async getCoStorableItems({ + orgId, + itemId, + }: { + orgId: string; + itemId: string; + }) { const records = await db .select() .from(coStorability) .where( - or( - eq(coStorability.itemAId, itemId), - eq(coStorability.itemBId, itemId), + and( + additiveOrgFilter(coStorability.ownerOrgId, orgId), + or( + eq(coStorability.itemAId, itemId), + eq(coStorability.itemBId, itemId), + ), ), ); @@ -731,9 +894,9 @@ export const itemRepository = { if (coStorableIds.length === 0) return []; - // Fetch all co-storable items + // Fetch all co-storable items (scoped). const results = await Promise.all( - coStorableIds.map((id) => itemRepository.findById({ id })), + coStorableIds.map((id) => itemRepository.findById({ orgId, id })), ); return results.filter((item) => item !== null); @@ -742,42 +905,53 @@ export const itemRepository = { // --- Category management --- async addCategory({ + orgId, itemId, categoryId, isPrimary, }: { + orgId: string; itemId: string; categoryId: string; isPrimary?: boolean; }) { - const item = await itemRepository.findById({ id: itemId }); + const item = await itemRepository.findById({ orgId, id: itemId }); if (!item) throw new Error(`Item ${itemId} not found`); - // If setting as primary, unset any existing primary + // If setting as primary, unset any existing primary (within scope). if (isPrimary) { await db .update(itemCategories) .set({ isPrimary: false }) .where( and( + additiveOrgFilter(itemCategories.ownerOrgId, orgId), eq(itemCategories.itemId, itemId), - eq(itemCategories.isPrimary, true) - ) + eq(itemCategories.isPrimary, true), + ), ); } + // Junction inherits the parent item's scope. const [ic] = await db .insert(itemCategories) - .values({ itemId, categoryId, isPrimary: isPrimary ?? false }) + .values({ + ownerOrgId: item.ownerOrgId, + itemId, + categoryId, + isPrimary: isPrimary ?? false, + }) .returning(); return ic; }, async removeCategory({ + orgId, itemId, categoryId, }: { + orgId: string; itemId: string; categoryId: string; }) { @@ -785,9 +959,10 @@ export const itemRepository = { .delete(itemCategories) .where( and( + additiveOrgFilter(itemCategories.ownerOrgId, orgId), eq(itemCategories.itemId, itemId), - eq(itemCategories.categoryId, categoryId) - ) + eq(itemCategories.categoryId, categoryId), + ), ) .returning(); @@ -797,17 +972,21 @@ export const itemRepository = { }, async setPrimaryCategory({ + orgId, itemId, categoryId, }: { + orgId: string; itemId: string; categoryId: string; }) { + const scope = additiveOrgFilter(itemCategories.ownerOrgId, orgId); + // Unset all primaries for this item await db .update(itemCategories) .set({ isPrimary: false }) - .where(eq(itemCategories.itemId, itemId)); + .where(and(scope, eq(itemCategories.itemId, itemId))); // Set the specified one as primary const [updated] = await db @@ -815,9 +994,10 @@ export const itemRepository = { .set({ isPrimary: true }) .where( and( + scope, eq(itemCategories.itemId, itemId), - eq(itemCategories.categoryId, categoryId) - ) + eq(itemCategories.categoryId, categoryId), + ), ) .returning(); @@ -828,7 +1008,7 @@ export const itemRepository = { return updated; }, - async getCategories({ itemId }: { itemId: string }) { + async getCategories({ orgId, itemId }: { orgId: string; itemId: string }) { return db .select({ categoryId: itemCategories.categoryId, @@ -839,44 +1019,64 @@ export const itemRepository = { }) .from(itemCategories) .innerJoin(categories, eq(itemCategories.categoryId, categories.id)) - .where(eq(itemCategories.itemId, itemId)); + .where( + and( + additiveOrgFilter(itemCategories.ownerOrgId, orgId), + eq(itemCategories.itemId, itemId), + ), + ); }, // --- Aspect management --- async applyAspect({ + orgId, itemId, aspectId, }: { + orgId: string; itemId: string; aspectId: string; }) { - const item = await itemRepository.findById({ id: itemId }); + const item = await itemRepository.findById({ orgId, id: itemId }); if (!item) throw new Error(`Item ${itemId} not found`); - // Create the item-aspect link + const junctionOwner = item.ownerOrgId; + + // Create the item-aspect link (inherits item scope). const [ia] = await db .insert(itemAspects) - .values({ itemId, aspectId }) + .values({ ownerOrgId: junctionOwner, itemId, aspectId }) .returning(); - // Get the aspect's parameter definitions and create value slots + // Get the aspect's parameter definitions (additive) and create value slots const aspectParams = await db .select() .from(aspectParameters) - .where(eq(aspectParameters.aspectId, aspectId)); + .where( + and( + additiveOrgFilter(aspectParameters.ownerOrgId, orgId), + eq(aspectParameters.aspectId, aspectId), + ), + ); for (const ap of aspectParams) { - // Get the parameter definition for its global default + // Get the parameter definition for its global default. const [pd] = await db .select() .from(parameterDefinitions) - .where(eq(parameterDefinitions.id, ap.parameterDefinitionId)); + .where( + and( + additiveOrgFilter(parameterDefinitions.ownerOrgId, orgId), + eq(parameterDefinitions.id, ap.parameterDefinitionId), + ), + ); // Aspect-level default wins over parameter-level default const defaultVal = ap.defaultValue ?? pd?.defaultValue ?? null; await db.insert(itemParameterValues).values({ + ownerOrgId: junctionOwner, itemId, parameterDefinitionId: ap.parameterDefinitionId, itemAspectId: ia.id, @@ -888,9 +1088,11 @@ export const itemRepository = { }, async removeAspect({ + orgId, itemId, aspectId, }: { + orgId: string; itemId: string; aspectId: string; }) { @@ -898,7 +1100,11 @@ export const itemRepository = { const [deleted] = await db .delete(itemAspects) .where( - and(eq(itemAspects.itemId, itemId), eq(itemAspects.aspectId, aspectId)) + and( + additiveOrgFilter(itemAspects.ownerOrgId, orgId), + eq(itemAspects.itemId, itemId), + eq(itemAspects.aspectId, aspectId), + ), ) .returning(); @@ -907,42 +1113,54 @@ export const itemRepository = { } }, - async getAspects({ itemId }: { itemId: string }) { + async getAspects({ orgId, itemId }: { orgId: string; itemId: string }) { const rows = await db .select() .from(itemAspects) - .where(eq(itemAspects.itemId, itemId)); + .where( + and( + additiveOrgFilter(itemAspects.ownerOrgId, orgId), + eq(itemAspects.itemId, itemId), + ), + ); return rows; }, // --- Parameter value management --- async setParameterValue({ + orgId, itemId, parameterDefinitionId, itemAspectId, value, }: { + orgId: string; itemId: string; parameterDefinitionId: string; itemAspectId?: string | null; value: unknown; }) { + // Parent item determines junction scope on insert. + const parent = await itemRepository.findById({ orgId, id: itemId }); + if (!parent) throw new Error(`Item ${itemId} not found`); + // Try to update existing const existing = await db .select() .from(itemParameterValues) .where( and( + additiveOrgFilter(itemParameterValues.ownerOrgId, orgId), eq(itemParameterValues.itemId, itemId), eq( itemParameterValues.parameterDefinitionId, - parameterDefinitionId + parameterDefinitionId, ), itemAspectId ? eq(itemParameterValues.itemAspectId, itemAspectId) - : undefined - ) + : undefined, + ), ); if (existing.length > 0) { @@ -954,10 +1172,11 @@ export const itemRepository = { return updated; } - // Create new (standalone parameter or ad-hoc) + // Create new (standalone parameter or ad-hoc); inherits item scope. const [created] = await db .insert(itemParameterValues) .values({ + ownerOrgId: parent.ownerOrgId, itemId, parameterDefinitionId, itemAspectId: itemAspectId ?? null, @@ -968,7 +1187,13 @@ export const itemRepository = { return created; }, - async getParameterValues({ itemId }: { itemId: string }) { + async getParameterValues({ + orgId, + itemId, + }: { + orgId: string; + itemId: string; + }) { return db .select({ id: itemParameterValues.id, @@ -984,9 +1209,14 @@ export const itemRepository = { parameterDefinitions, eq( itemParameterValues.parameterDefinitionId, - parameterDefinitions.id - ) + parameterDefinitions.id, + ), ) - .where(eq(itemParameterValues.itemId, itemId)); + .where( + and( + additiveOrgFilter(itemParameterValues.ownerOrgId, orgId), + eq(itemParameterValues.itemId, itemId), + ), + ); }, }; diff --git a/web/repositories/locationRepository.ts b/web/repositories/locationRepository.ts index 521f822..ce7aad6 100644 --- a/web/repositories/locationRepository.ts +++ b/web/repositories/locationRepository.ts @@ -1,4 +1,4 @@ -import { asc, eq, inArray, sql } from "drizzle-orm"; +import { and, asc, eq, inArray, sql } from "drizzle-orm"; import { db } from "@/db/connection"; import { locations, @@ -8,12 +8,21 @@ import { templateVersions, assignments, } from "@/db/schema"; +import { isolatedOrgFilter } from "@/lib/auth/scope"; import { transactionRepository } from "./transactionRepository"; -async function createSingleInstanceTemplateVersion(pathSegments: string[]) { +// locations and location_interfaces_accepted are isolated tables. +// Every method is org-scoped. The ad-hoc template+version created by +// `create` is written with ownerOrgId = orgId (org-private, not global). + +async function createSingleInstanceTemplateVersion( + orgId: string, + pathSegments: string[], +) { const [template] = await db .insert(templates) .values({ + ownerOrgId: orgId, name: `ad-hoc: ${pathSegments.join(":")}`, scope: "single_instance", currentVersion: 1, @@ -24,6 +33,7 @@ async function createSingleInstanceTemplateVersion(pathSegments: string[]) { const [version] = await db .insert(templateVersions) .values({ + ownerOrgId: orgId, templateId: template.id, version: 1, }) @@ -34,6 +44,8 @@ async function createSingleInstanceTemplateVersion(pathSegments: string[]) { export const locationRepository = { async create({ + userId, + orgId, moduleId, parentId, label, @@ -46,6 +58,8 @@ export const locationRepository = { gridColumn, metadata, }: { + userId: string; + orgId: string; moduleId?: string | null; parentId?: string; label: string; @@ -63,12 +77,13 @@ export const locationRepository = { const resolvedTemplateVersionId = templateVersionId ?? - (await createSingleInstanceTemplateVersion(pathSegments)); + (await createSingleInstanceTemplateVersion(orgId, pathSegments)); const location = await db.transaction(async (tx) => { const [loc] = await tx .insert(locations) .values({ + ownerOrgId: orgId, moduleId, parentId, label, @@ -86,6 +101,7 @@ export const locationRepository = { if (interfacesAcceptedIds?.length) { await tx.insert(locationInterfacesAccepted).values( interfacesAcceptedIds.map((interfaceTypeId) => ({ + ownerOrgId: orgId, locationId: loc.id, interfaceTypeId, })), @@ -96,6 +112,8 @@ export const locationRepository = { }); await transactionRepository.log({ + userId, + orgId, actionType: "location.create", entityType: "location", entityId: location.id, @@ -107,7 +125,13 @@ export const locationRepository = { }, /** Interface types this receptacle accepts. */ - async getAcceptedInterfaces({ locationId }: { locationId: string }) { + async getAcceptedInterfaces({ + orgId, + locationId, + }: { + orgId: string; + locationId: string; + }) { return db .select({ id: interfaceTypes.id, @@ -122,13 +146,20 @@ export const locationRepository = { interfaceTypes, eq(locationInterfacesAccepted.interfaceTypeId, interfaceTypes.id), ) - .where(eq(locationInterfacesAccepted.locationId, locationId)); + .where( + and( + isolatedOrgFilter(locationInterfacesAccepted.ownerOrgId, orgId), + eq(locationInterfacesAccepted.locationId, locationId), + ), + ); }, /** Batched version of getAcceptedInterfaces — returns a map keyed by locationId. */ async getAcceptedInterfacesByLocationIds({ + orgId, locationIds, }: { + orgId: string; locationIds: string[]; }) { const map = new Map< @@ -158,7 +189,12 @@ export const locationRepository = { interfaceTypes, eq(locationInterfacesAccepted.interfaceTypeId, interfaceTypes.id), ) - .where(inArray(locationInterfacesAccepted.locationId, locationIds)); + .where( + and( + isolatedOrgFilter(locationInterfacesAccepted.ownerOrgId, orgId), + inArray(locationInterfacesAccepted.locationId, locationIds), + ), + ); for (const r of rows) { const { locationId, ...iface } = r; let list = map.get(locationId); @@ -173,19 +209,29 @@ export const locationRepository = { /** Replace the accepted-interface set on a receptacle. */ async setAcceptedInterfaces({ + userId, + orgId, locationId, interfaceTypeIds, }: { + userId: string; + orgId: string; locationId: string; interfaceTypeIds: string[]; }) { await db.transaction(async (tx) => { await tx .delete(locationInterfacesAccepted) - .where(eq(locationInterfacesAccepted.locationId, locationId)); + .where( + and( + isolatedOrgFilter(locationInterfacesAccepted.ownerOrgId, orgId), + eq(locationInterfacesAccepted.locationId, locationId), + ), + ); if (interfaceTypeIds.length > 0) { await tx.insert(locationInterfacesAccepted).values( interfaceTypeIds.map((interfaceTypeId) => ({ + ownerOrgId: orgId, locationId, interfaceTypeId, })), @@ -194,6 +240,8 @@ export const locationRepository = { }); await transactionRepository.log({ + userId, + orgId, actionType: "location.setAcceptedInterfaces", entityType: "location", entityId: locationId, @@ -202,49 +250,101 @@ export const locationRepository = { }); }, - async findById({ id }: { id: string }) { + async findById({ orgId, id }: { orgId: string; id: string }) { const [location] = await db .select() .from(locations) - .where(eq(locations.id, id)); + .where( + and(isolatedOrgFilter(locations.ownerOrgId, orgId), eq(locations.id, id)), + ); return location ?? null; }, - async findByPath({ moduleId, path }: { moduleId: string; path: string }) { + async findByPath({ + orgId, + moduleId, + path, + }: { + orgId: string; + moduleId: string; + path: string; + }) { const results = await db .select() .from(locations) - .where(eq(locations.moduleId, moduleId)); + .where( + and( + isolatedOrgFilter(locations.ownerOrgId, orgId), + eq(locations.moduleId, moduleId), + ), + ); const match = results.find((l) => l.path === path); return match ?? null; }, - async findByModuleId({ moduleId }: { moduleId: string }) { + async findByModuleId({ + orgId, + moduleId, + }: { + orgId: string; + moduleId: string; + }) { return db .select() .from(locations) - .where(eq(locations.moduleId, moduleId)) + .where( + and( + isolatedOrgFilter(locations.ownerOrgId, orgId), + eq(locations.moduleId, moduleId), + ), + ) .orderBy(asc(locations.createdAt)); }, - async findByInsertId({ insertId }: { insertId: string }) { + async findByInsertId({ + orgId, + insertId, + }: { + orgId: string; + insertId: string; + }) { return db .select() .from(locations) - .where(eq(locations.insertId, insertId)); + .where( + and( + isolatedOrgFilter(locations.ownerOrgId, orgId), + eq(locations.insertId, insertId), + ), + ); }, - async findChildren({ parentId }: { parentId: string }) { + async findChildren({ + orgId, + parentId, + }: { + orgId: string; + parentId: string; + }) { return db .select() .from(locations) - .where(eq(locations.parentId, parentId)); + .where( + and( + isolatedOrgFilter(locations.ownerOrgId, orgId), + eq(locations.parentId, parentId), + ), + ); }, async update({ + userId, + orgId, id, ...updates }: { + userId: string; + orgId: string; id: string; label?: string; locationType?: string; @@ -253,16 +353,20 @@ export const locationRepository = { gridColumn?: number; metadata?: Record; }) { - const before = await locationRepository.findById({ id }); + const before = await locationRepository.findById({ orgId, id }); if (!before) throw new Error(`Location ${id} not found`); const [updated] = await db .update(locations) .set({ ...updates, updatedAt: new Date() }) - .where(eq(locations.id, id)) + .where( + and(isolatedOrgFilter(locations.ownerOrgId, orgId), eq(locations.id, id)), + ) .returning(); await transactionRepository.log({ + userId, + orgId, actionType: "location.update", entityType: "location", entityId: id, @@ -273,13 +377,27 @@ export const locationRepository = { return updated; }, - async remove({ id }: { id: string }) { - const before = await locationRepository.findById({ id }); + async remove({ + userId, + orgId, + id, + }: { + userId: string; + orgId: string; + id: string; + }) { + const before = await locationRepository.findById({ orgId, id }); if (!before) throw new Error(`Location ${id} not found`); - await db.delete(locations).where(eq(locations.id, id)); + await db + .delete(locations) + .where( + and(isolatedOrgFilter(locations.ownerOrgId, orgId), eq(locations.id, id)), + ); await transactionRepository.log({ + userId, + orgId, actionType: "location.delete", entityType: "location", entityId: id, @@ -288,15 +406,30 @@ export const locationRepository = { }); }, - async disable({ id, reason }: { id: string; reason?: string }) { - const before = await locationRepository.findById({ id }); + async disable({ + userId, + orgId, + id, + reason, + }: { + userId: string; + orgId: string; + id: string; + reason?: string; + }) { + const before = await locationRepository.findById({ orgId, id }); if (!before) throw new Error(`Location ${id} not found`); // Per spec: existing assignments must be resolved before disabling. const [row] = await db .select({ c: sql`COUNT(*)` }) .from(assignments) - .where(eq(assignments.locationId, id)); + .where( + and( + isolatedOrgFilter(assignments.ownerOrgId, orgId), + eq(assignments.locationId, id), + ), + ); if (Number(row?.c ?? 0) > 0) { throw new Error( "Cannot disable a location with active assignments. Unassign items first." @@ -310,10 +443,14 @@ export const locationRepository = { disableReason: reason ?? null, updatedAt: new Date(), }) - .where(eq(locations.id, id)) + .where( + and(isolatedOrgFilter(locations.ownerOrgId, orgId), eq(locations.id, id)), + ) .returning(); await transactionRepository.log({ + userId, + orgId, actionType: "location.disable", entityType: "location", entityId: id, @@ -325,19 +462,23 @@ export const locationRepository = { }, async restrict({ + userId, + orgId, id, maxWidthMm, maxHeightMm, maxDepthMm, reason, }: { + userId: string; + orgId: string; id: string; maxWidthMm?: number | null; maxHeightMm?: number | null; maxDepthMm?: number | null; reason?: string | null; }) { - const before = await locationRepository.findById({ id }); + const before = await locationRepository.findById({ orgId, id }); if (!before) throw new Error(`Location ${id} not found`); // NOTE: spec requires refusing the clamp when existing assignments @@ -354,10 +495,14 @@ export const locationRepository = { restrictReason: reason ?? null, updatedAt: new Date(), }) - .where(eq(locations.id, id)) + .where( + and(isolatedOrgFilter(locations.ownerOrgId, orgId), eq(locations.id, id)), + ) .returning(); await transactionRepository.log({ + userId, + orgId, actionType: "location.restrict", entityType: "location", entityId: id, @@ -368,8 +513,18 @@ export const locationRepository = { return updated; }, - async clearRestrict({ id }: { id: string }) { + async clearRestrict({ + userId, + orgId, + id, + }: { + userId: string; + orgId: string; + id: string; + }) { return locationRepository.restrict({ + userId, + orgId, id, maxWidthMm: null, maxHeightMm: null, @@ -378,8 +533,16 @@ export const locationRepository = { }); }, - async enable({ id }: { id: string }) { - const before = await locationRepository.findById({ id }); + async enable({ + userId, + orgId, + id, + }: { + userId: string; + orgId: string; + id: string; + }) { + const before = await locationRepository.findById({ orgId, id }); if (!before) throw new Error(`Location ${id} not found`); const [updated] = await db @@ -389,10 +552,14 @@ export const locationRepository = { disableReason: null, updatedAt: new Date(), }) - .where(eq(locations.id, id)) + .where( + and(isolatedOrgFilter(locations.ownerOrgId, orgId), eq(locations.id, id)), + ) .returning(); await transactionRepository.log({ + userId, + orgId, actionType: "location.enable", entityType: "location", entityId: id, @@ -414,9 +581,13 @@ export const locationRepository = { * - template's dividersFixed constraints not violated (if applicable) */ async merge({ + userId, + orgId, originId, aliasIds, }: { + userId: string; + orgId: string; originId: string; aliasIds: string[]; }) { @@ -428,7 +599,12 @@ export const locationRepository = { const rows = await db .select() .from(locations) - .where(inArray(locations.id, allIds)); + .where( + and( + isolatedOrgFilter(locations.ownerOrgId, orgId), + inArray(locations.id, allIds), + ), + ); if (rows.length !== allIds.length) { throw new Error("One or more cells not found"); @@ -450,7 +626,12 @@ export const locationRepository = { const [asgRow] = await db .select({ c: sql`COUNT(*)` }) .from(assignments) - .where(inArray(assignments.locationId, allIds)); + .where( + and( + isolatedOrgFilter(assignments.ownerOrgId, orgId), + inArray(assignments.locationId, allIds), + ), + ); if (Number(asgRow?.c ?? 0) > 0) { throw new Error( "Cannot merge cells with active assignments. Unassign items first." @@ -531,11 +712,18 @@ export const locationRepository = { await tx .update(locations) .set({ mergedIntoId: originId, updatedAt: new Date() }) - .where(eq(locations.id, r.id)); + .where( + and( + isolatedOrgFilter(locations.ownerOrgId, orgId), + eq(locations.id, r.id), + ), + ); } }); await transactionRepository.log({ + userId, + orgId, actionType: "location.merge", entityType: "location", entityId: originId, @@ -553,15 +741,19 @@ export const locationRepository = { * Parent becomes non-leaf (no longer valid assignment target). */ async divide({ + userId, + orgId, parentId, labels, source, }: { + userId: string; + orgId: string; parentId: string; labels: string[]; source?: string; // 'ad_hoc' | 'template_option:' | 'insert_template:' }) { - const parent = await locationRepository.findById({ id: parentId }); + const parent = await locationRepository.findById({ orgId, id: parentId }); if (!parent) throw new Error(`Location ${parentId} not found`); if (labels.length < 2) { @@ -579,7 +771,12 @@ export const locationRepository = { const [asgRow] = await db .select({ c: sql`COUNT(*)` }) .from(assignments) - .where(eq(assignments.locationId, parentId)); + .where( + and( + isolatedOrgFilter(assignments.ownerOrgId, orgId), + eq(assignments.locationId, parentId), + ), + ); if (Number(asgRow?.c ?? 0) > 0) { throw new Error( "Cannot divide a location with active assignments. Unassign items first." @@ -590,7 +787,12 @@ export const locationRepository = { const existingChildren = await db .select({ id: locations.id }) .from(locations) - .where(eq(locations.parentId, parentId)); + .where( + and( + isolatedOrgFilter(locations.ownerOrgId, orgId), + eq(locations.parentId, parentId), + ), + ); if (existingChildren.length > 0) { throw new Error("Location is already divided"); } @@ -606,6 +808,7 @@ export const locationRepository = { const [row] = await tx .insert(locations) .values({ + ownerOrgId: orgId, moduleId: parent.moduleId, parentId, label, @@ -626,12 +829,19 @@ export const locationRepository = { subdivisionSource: source ?? "ad_hoc", updatedAt: new Date(), }) - .where(eq(locations.id, parentId)); + .where( + and( + isolatedOrgFilter(locations.ownerOrgId, orgId), + eq(locations.id, parentId), + ), + ); return created; }); await transactionRepository.log({ + userId, + orgId, actionType: "location.divide", entityType: "location", entityId: parentId, @@ -646,14 +856,27 @@ export const locationRepository = { * Collapse a divided parent back to a leaf. Refuses if any child * has assignments or has been divided itself. */ - async undivide({ parentId }: { parentId: string }) { - const parent = await locationRepository.findById({ id: parentId }); + async undivide({ + userId, + orgId, + parentId, + }: { + userId: string; + orgId: string; + parentId: string; + }) { + const parent = await locationRepository.findById({ orgId, id: parentId }); if (!parent) throw new Error(`Location ${parentId} not found`); const children = await db .select() .from(locations) - .where(eq(locations.parentId, parentId)); + .where( + and( + isolatedOrgFilter(locations.ownerOrgId, orgId), + eq(locations.parentId, parentId), + ), + ); if (children.length === 0) { throw new Error("Location is not divided"); } @@ -663,7 +886,12 @@ export const locationRepository = { const [asgRow] = await db .select({ c: sql`COUNT(*)` }) .from(assignments) - .where(inArray(assignments.locationId, childIds)); + .where( + and( + isolatedOrgFilter(assignments.ownerOrgId, orgId), + inArray(assignments.locationId, childIds), + ), + ); if (Number(asgRow?.c ?? 0) > 0) { throw new Error( "Cannot undivide: children have active assignments. Unassign first." @@ -673,7 +901,12 @@ export const locationRepository = { const [grandRow] = await db .select({ c: sql`COUNT(*)` }) .from(locations) - .where(inArray(locations.parentId, childIds)); + .where( + and( + isolatedOrgFilter(locations.ownerOrgId, orgId), + inArray(locations.parentId, childIds), + ), + ); if (Number(grandRow?.c ?? 0) > 0) { throw new Error( "Cannot undivide: children have been subdivided themselves. Undivide them first." @@ -681,7 +914,14 @@ export const locationRepository = { } await db.transaction(async (tx) => { - await tx.delete(locations).where(eq(locations.parentId, parentId)); + await tx + .delete(locations) + .where( + and( + isolatedOrgFilter(locations.ownerOrgId, orgId), + eq(locations.parentId, parentId), + ), + ); await tx .update(locations) .set({ @@ -689,10 +929,17 @@ export const locationRepository = { subdivisionSource: null, updatedAt: new Date(), }) - .where(eq(locations.id, parentId)); + .where( + and( + isolatedOrgFilter(locations.ownerOrgId, orgId), + eq(locations.id, parentId), + ), + ); }); await transactionRepository.log({ + userId, + orgId, actionType: "location.undivide", entityType: "location", entityId: parentId, @@ -703,11 +950,24 @@ export const locationRepository = { return { parentId, removed: childIds.length }; }, - async unmerge({ originId }: { originId: string }) { + async unmerge({ + userId, + orgId, + originId, + }: { + userId: string; + orgId: string; + originId: string; + }) { const aliases = await db .select() .from(locations) - .where(eq(locations.mergedIntoId, originId)); + .where( + and( + isolatedOrgFilter(locations.ownerOrgId, orgId), + eq(locations.mergedIntoId, originId), + ), + ); if (aliases.length === 0) { throw new Error("No merged cells for this origin"); } @@ -715,9 +975,16 @@ export const locationRepository = { await db .update(locations) .set({ mergedIntoId: null, updatedAt: new Date() }) - .where(eq(locations.mergedIntoId, originId)); + .where( + and( + isolatedOrgFilter(locations.ownerOrgId, orgId), + eq(locations.mergedIntoId, originId), + ), + ); await transactionRepository.log({ + userId, + orgId, actionType: "location.unmerge", entityType: "location", entityId: originId, @@ -729,22 +996,30 @@ export const locationRepository = { }, async setMergeAlias({ + userId, + orgId, id, mergedIntoId, }: { + userId: string; + orgId: string; id: string; mergedIntoId: string; }) { - const before = await locationRepository.findById({ id }); + const before = await locationRepository.findById({ orgId, id }); if (!before) throw new Error(`Location ${id} not found`); const [updated] = await db .update(locations) .set({ mergedIntoId, updatedAt: new Date() }) - .where(eq(locations.id, id)) + .where( + and(isolatedOrgFilter(locations.ownerOrgId, orgId), eq(locations.id, id)), + ) .returning(); await transactionRepository.log({ + userId, + orgId, actionType: "location.setMergeAlias", entityType: "location", entityId: id, @@ -755,17 +1030,29 @@ export const locationRepository = { return updated; }, - async clearMergeAlias({ id }: { id: string }) { - const before = await locationRepository.findById({ id }); + async clearMergeAlias({ + userId, + orgId, + id, + }: { + userId: string; + orgId: string; + id: string; + }) { + const before = await locationRepository.findById({ orgId, id }); if (!before) throw new Error(`Location ${id} not found`); const [updated] = await db .update(locations) .set({ mergedIntoId: null, updatedAt: new Date() }) - .where(eq(locations.id, id)) + .where( + and(isolatedOrgFilter(locations.ownerOrgId, orgId), eq(locations.id, id)), + ) .returning(); await transactionRepository.log({ + userId, + orgId, actionType: "location.clearMergeAlias", entityType: "location", entityId: id, diff --git a/web/repositories/moduleRepository.ts b/web/repositories/moduleRepository.ts index e28bf05..654c59e 100644 --- a/web/repositories/moduleRepository.ts +++ b/web/repositories/moduleRepository.ts @@ -1,4 +1,4 @@ -import { eq, inArray, sql } from "drizzle-orm"; +import { and, eq, inArray, sql } from "drizzle-orm"; import { db } from "@/db/connection"; import { modules, @@ -6,16 +6,22 @@ import { assignments, inserts, } from "@/db/schema"; +import { isolatedOrgFilter } from "@/lib/auth/scope"; import { transactionRepository } from "./transactionRepository"; +// modules is an isolated table. Every method is org-scoped. export const moduleRepository = { async create({ + userId, + orgId, name, description, primaryDimensionLabel, primaryDimensionCount, metadata, }: { + userId: string; + orgId: string; name: string; description?: string; primaryDimensionLabel: string; @@ -25,6 +31,7 @@ export const moduleRepository = { const [module] = await db .insert(modules) .values({ + ownerOrgId: orgId, name, description, primaryDimensionLabel, @@ -34,6 +41,8 @@ export const moduleRepository = { .returning(); await transactionRepository.log({ + userId, + orgId, actionType: "module.create", entityType: "module", entityId: module.id, @@ -44,30 +53,37 @@ export const moduleRepository = { return module; }, - async findById({ id }: { id: string }) { + async findById({ orgId, id }: { orgId: string; id: string }) { const [module] = await db .select() .from(modules) - .where(eq(modules.id, id)); + .where(and(isolatedOrgFilter(modules.ownerOrgId, orgId), eq(modules.id, id))); return module ?? null; }, - async findByName({ name }: { name: string }) { + async findByName({ orgId, name }: { orgId: string; name: string }) { const [module] = await db .select() .from(modules) - .where(eq(modules.name, name)); + .where(and(isolatedOrgFilter(modules.ownerOrgId, orgId), eq(modules.name, name))); return module ?? null; }, - async list() { - return db.select().from(modules); + async list({ orgId }: { orgId: string }) { + return db + .select() + .from(modules) + .where(isolatedOrgFilter(modules.ownerOrgId, orgId)); }, async update({ + userId, + orgId, id, ...updates }: { + userId: string; + orgId: string; id: string; name?: string; description?: string; @@ -75,16 +91,18 @@ export const moduleRepository = { primaryDimensionCount?: number; metadata?: Record; }) { - const before = await moduleRepository.findById({ id }); + const before = await moduleRepository.findById({ orgId, id }); if (!before) throw new Error(`Module ${id} not found`); const [updated] = await db .update(modules) .set({ ...updates, updatedAt: new Date() }) - .where(eq(modules.id, id)) + .where(and(isolatedOrgFilter(modules.ownerOrgId, orgId), eq(modules.id, id))) .returning(); await transactionRepository.log({ + userId, + orgId, actionType: "module.update", entityType: "module", entityId: id, @@ -95,13 +113,25 @@ export const moduleRepository = { return updated; }, - async remove({ id }: { id: string }) { - const before = await moduleRepository.findById({ id }); + async remove({ + userId, + orgId, + id, + }: { + userId: string; + orgId: string; + id: string; + }) { + const before = await moduleRepository.findById({ orgId, id }); if (!before) throw new Error(`Module ${id} not found`); - await db.delete(modules).where(eq(modules.id, id)); + await db + .delete(modules) + .where(and(isolatedOrgFilter(modules.ownerOrgId, orgId), eq(modules.id, id))); await transactionRepository.log({ + userId, + orgId, actionType: "module.delete", entityType: "module", entityId: id, @@ -114,14 +144,19 @@ export const moduleRepository = { * Summarize what a module contains prior to deletion. * Used by the GitHub-repo-style deletion dialog. */ - async getStats({ id }: { id: string }) { - const module_ = await moduleRepository.findById({ id }); + async getStats({ orgId, id }: { orgId: string; id: string }) { + const module_ = await moduleRepository.findById({ orgId, id }); if (!module_) throw new Error(`Module ${id} not found`); const locs = await db .select({ id: locations.id, parentId: locations.parentId }) .from(locations) - .where(eq(locations.moduleId, id)); + .where( + and( + isolatedOrgFilter(locations.ownerOrgId, orgId), + eq(locations.moduleId, id), + ), + ); const locationIds = locs.map((l) => l.id); const levelCount = locs.filter((l) => l.parentId === null).length; @@ -132,13 +167,23 @@ export const moduleRepository = { const [asRow] = await db .select({ c: sql`COUNT(*)` }) .from(assignments) - .where(inArray(assignments.locationId, locationIds)); + .where( + and( + isolatedOrgFilter(assignments.ownerOrgId, orgId), + inArray(assignments.locationId, locationIds), + ), + ); assignmentCount = Number(asRow?.c ?? 0); const [inRow] = await db .select({ c: sql`COUNT(*)` }) .from(inserts) - .where(inArray(inserts.locationId, locationIds)); + .where( + and( + isolatedOrgFilter(inserts.ownerOrgId, orgId), + inArray(inserts.locationId, locationIds), + ), + ); insertCount = Number(inRow?.c ?? 0); } @@ -158,36 +203,70 @@ export const moduleRepository = { * - module is deleted * All in one transaction. Logged as a single module.deleteCascade entry. */ - async removeWithCascade({ id }: { id: string }) { - const before = await moduleRepository.findById({ id }); + async removeWithCascade({ + userId, + orgId, + id, + }: { + userId: string; + orgId: string; + id: string; + }) { + const before = await moduleRepository.findById({ orgId, id }); if (!before) throw new Error(`Module ${id} not found`); - const stats = await moduleRepository.getStats({ id }); + const stats = await moduleRepository.getStats({ orgId, id }); await db.transaction(async (tx) => { const locs = await tx .select({ id: locations.id }) .from(locations) - .where(eq(locations.moduleId, id)); + .where( + and( + isolatedOrgFilter(locations.ownerOrgId, orgId), + eq(locations.moduleId, id), + ), + ); const locationIds = locs.map((l) => l.id); if (locationIds.length > 0) { await tx .delete(assignments) - .where(inArray(assignments.locationId, locationIds)); + .where( + and( + isolatedOrgFilter(assignments.ownerOrgId, orgId), + inArray(assignments.locationId, locationIds), + ), + ); await tx .update(inserts) .set({ locationId: null, updatedAt: new Date() }) - .where(inArray(inserts.locationId, locationIds)); + .where( + and( + isolatedOrgFilter(inserts.ownerOrgId, orgId), + inArray(inserts.locationId, locationIds), + ), + ); - await tx.delete(locations).where(eq(locations.moduleId, id)); + await tx + .delete(locations) + .where( + and( + isolatedOrgFilter(locations.ownerOrgId, orgId), + eq(locations.moduleId, id), + ), + ); } - await tx.delete(modules).where(eq(modules.id, id)); + await tx + .delete(modules) + .where(and(isolatedOrgFilter(modules.ownerOrgId, orgId), eq(modules.id, id))); }); await transactionRepository.log({ + userId, + orgId, actionType: "module.deleteCascade", entityType: "module", entityId: id, diff --git a/web/repositories/parameterDefinitionRepository.ts b/web/repositories/parameterDefinitionRepository.ts index 22d57b8..20a124b 100644 --- a/web/repositories/parameterDefinitionRepository.ts +++ b/web/repositories/parameterDefinitionRepository.ts @@ -1,4 +1,4 @@ -import { eq, sql } from "drizzle-orm"; +import { eq, and, sql } from "drizzle-orm"; import { db } from "@/db/connection"; import { parameterDefinitions, @@ -9,6 +9,7 @@ import { items, standards, } from "@/db/schema"; +import { additiveOrgFilter } from "@/lib/auth/scope"; import { transactionRepository } from "./transactionRepository"; import { type AuditCheck, @@ -24,8 +25,12 @@ interface Constraints { max?: number; } +// parameter_definitions is additive. NULL = global taxonomy; set = org-private. export const parameterDefinitionRepository = { async create({ + userId, + orgId, + asGlobal, name, dataType, unit, @@ -34,6 +39,9 @@ export const parameterDefinitionRepository = { defaultValue, constraints, }: { + userId: string; + orgId: string; + asGlobal?: boolean; name: string; dataType: DataType; unit?: string; @@ -48,9 +56,11 @@ export const parameterDefinitionRepository = { } } + const ownerOrgId = asGlobal ? null : orgId; const [paramDef] = await db .insert(parameterDefinitions) .values({ + ownerOrgId, name, dataType, unit, @@ -62,6 +72,8 @@ export const parameterDefinitionRepository = { .returning(); await transactionRepository.log({ + userId, + orgId, actionType: "parameterDefinition.create", entityType: "parameterDefinition", entityId: paramDef.id, @@ -72,38 +84,45 @@ export const parameterDefinitionRepository = { return paramDef; }, - async findById({ id }: { id: string }) { + async findById({ orgId, id }: { orgId: string; id: string }) { const [paramDef] = await db .select() .from(parameterDefinitions) - .where(eq(parameterDefinitions.id, id)); + .where( + and( + additiveOrgFilter(parameterDefinitions.ownerOrgId, orgId), + eq(parameterDefinitions.id, id), + ), + ); return paramDef ?? null; }, - async findByName({ name }: { name: string }) { + async findByName({ orgId, name }: { orgId: string; name: string }) { const [paramDef] = await db .select() .from(parameterDefinitions) - .where(eq(parameterDefinitions.name, name)); + .where( + and( + additiveOrgFilter(parameterDefinitions.ownerOrgId, orgId), + eq(parameterDefinitions.name, name), + ), + ); return paramDef ?? null; }, - async list() { - return db.select().from(parameterDefinitions).orderBy(parameterDefinitions.name); + async list({ orgId }: { orgId: string }) { + return db + .select() + .from(parameterDefinitions) + .where(additiveOrgFilter(parameterDefinitions.ownerOrgId, orgId)) + .orderBy(parameterDefinitions.name); }, - /** - * Like list() but each row includes usage counts so the Parameters - * table can show an at-a-glance context column. - * - * - aspectCount: how many aspects include this parameter. - * - itemCount: how many distinct items have a stored value for it. - * - standardCount: how many standards reference it. - */ - async listWithUsage() { + async listWithUsage({ orgId }: { orgId: string }) { const rows = await db .select({ id: parameterDefinitions.id, + ownerOrgId: parameterDefinitions.ownerOrgId, name: parameterDefinitions.name, dataType: parameterDefinitions.dataType, unit: parameterDefinitions.unit, @@ -116,18 +135,22 @@ export const parameterDefinitionRepository = { aspectCount: sql`( SELECT COUNT(*)::int FROM ${aspectParameters} WHERE ${aspectParameters.parameterDefinitionId} = ${parameterDefinitions.id} + AND (${aspectParameters.ownerOrgId} IS NULL OR ${aspectParameters.ownerOrgId} = ${orgId}) )`.as("aspectCount"), itemCount: sql`( SELECT COUNT(DISTINCT ${itemParameterValues.itemId})::int FROM ${itemParameterValues} WHERE ${itemParameterValues.parameterDefinitionId} = ${parameterDefinitions.id} + AND (${itemParameterValues.ownerOrgId} IS NULL OR ${itemParameterValues.ownerOrgId} = ${orgId}) )`.as("itemCount"), standardCount: sql`( SELECT COUNT(*)::int FROM ${standardParameters} WHERE ${standardParameters.parameterDefinitionId} = ${parameterDefinitions.id} + AND (${standardParameters.ownerOrgId} IS NULL OR ${standardParameters.ownerOrgId} = ${orgId}) )`.as("standardCount"), }) .from(parameterDefinitions) + .where(additiveOrgFilter(parameterDefinitions.ownerOrgId, orgId)) .orderBy(parameterDefinitions.name); return rows.map((r) => ({ ...r, @@ -137,21 +160,29 @@ export const parameterDefinitionRepository = { })); }, - /** - * Detect data-quality and duplication issues in the parameter catalog. - * Runs in Node, no expensive joins — just transforms listWithUsage() output. - */ /** * Where-used drilldown for a single parameter definition. * Returns the aspects that include it, the items that have a stored * value, and the standards that reference it. */ - async getUsage({ parameterDefinitionId }: { parameterDefinitionId: string }) { + async getUsage({ + orgId, + parameterDefinitionId, + }: { + orgId: string; + parameterDefinitionId: string; + }) { const aspectRows = await db .select({ id: aspects.id, name: aspects.name }) .from(aspectParameters) .innerJoin(aspects, eq(aspectParameters.aspectId, aspects.id)) - .where(eq(aspectParameters.parameterDefinitionId, parameterDefinitionId)) + .where( + and( + additiveOrgFilter(aspectParameters.ownerOrgId, orgId), + additiveOrgFilter(aspects.ownerOrgId, orgId), + eq(aspectParameters.parameterDefinitionId, parameterDefinitionId), + ), + ) .orderBy(aspects.name); const itemRows = await db @@ -159,7 +190,11 @@ export const parameterDefinitionRepository = { .from(itemParameterValues) .innerJoin(items, eq(itemParameterValues.itemId, items.id)) .where( - eq(itemParameterValues.parameterDefinitionId, parameterDefinitionId) + and( + additiveOrgFilter(itemParameterValues.ownerOrgId, orgId), + additiveOrgFilter(items.ownerOrgId, orgId), + eq(itemParameterValues.parameterDefinitionId, parameterDefinitionId), + ), ) .orderBy(items.id, items.name); @@ -168,7 +203,11 @@ export const parameterDefinitionRepository = { .from(standardParameters) .innerJoin(standards, eq(standardParameters.standardId, standards.id)) .where( - eq(standardParameters.parameterDefinitionId, parameterDefinitionId) + and( + additiveOrgFilter(standardParameters.ownerOrgId, orgId), + additiveOrgFilter(standards.ownerOrgId, orgId), + eq(standardParameters.parameterDefinitionId, parameterDefinitionId), + ), ) .orderBy(standards.name); @@ -179,11 +218,10 @@ export const parameterDefinitionRepository = { }; }, - async audit(): Promise { - const defs = await parameterDefinitionRepository.listWithUsage(); + async audit({ orgId }: { orgId: string }): Promise { + const defs = await parameterDefinitionRepository.listWithUsage({ orgId }); const out: AuditCheck[] = []; - // no_description const noDesc = defs.filter((d) => !d.description || !d.description.trim()); if (noDesc.length > 0) { out.push({ @@ -194,9 +232,8 @@ export const parameterDefinitionRepository = { }); } - // numeric_no_unit const numericNoUnit = defs.filter( - (d) => d.dataType === "numeric" && !d.unit + (d) => d.dataType === "numeric" && !d.unit, ); if (numericNoUnit.length > 0) { out.push({ @@ -207,7 +244,6 @@ export const parameterDefinitionRepository = { }); } - // enum_no_values const enumNoValues = defs.filter((d) => { if (d.dataType !== "enum") return false; const c = d.constraints as { enumValues?: string[] } | null; @@ -222,7 +258,6 @@ export const parameterDefinitionRepository = { }); } - // orphan (attached to 0 aspects) const orphans = defs.filter((d) => (d.aspectCount ?? 0) === 0); if (orphans.length > 0) { out.push({ @@ -233,7 +268,6 @@ export const parameterDefinitionRepository = { }); } - // name_collision_with_searchterm const byName = new Map(defs.map((d) => [d.name.toLowerCase(), d])); const collisions: Array<{ id: string; name: string }> = []; for (const d of defs) { @@ -253,7 +287,6 @@ export const parameterDefinitionRepository = { }); } - // duplicate_name_ignoring_separators const normalized = new Map>(); for (const d of defs) { const key = d.name.toLowerCase().replace(/[_\s-]+/g, ""); @@ -274,7 +307,6 @@ export const parameterDefinitionRepository = { }); } - // near_duplicate: same dataType + unit, Jaccard ≥ 0.6 on name tokens const nearDup: Array<{ id: string; name: string }> = []; for (let i = 0; i < defs.length; i++) { for (let j = i + 1; j < defs.length; j++) { @@ -301,9 +333,13 @@ export const parameterDefinitionRepository = { }, async update({ + userId, + orgId, id, ...updates }: { + userId: string; + orgId: string; id: string; name?: string; dataType?: DataType; @@ -313,7 +349,7 @@ export const parameterDefinitionRepository = { defaultValue?: unknown; constraints?: Constraints; }) { - const before = await parameterDefinitionRepository.findById({ id }); + const before = await parameterDefinitionRepository.findById({ orgId, id }); if (!before) throw new Error(`ParameterDefinition ${id} not found`); const effectiveDataType = updates.dataType ?? before.dataType; @@ -328,10 +364,17 @@ export const parameterDefinitionRepository = { const [updated] = await db .update(parameterDefinitions) .set({ ...updates, updatedAt: new Date() }) - .where(eq(parameterDefinitions.id, id)) + .where( + and( + additiveOrgFilter(parameterDefinitions.ownerOrgId, orgId), + eq(parameterDefinitions.id, id), + ), + ) .returning(); await transactionRepository.log({ + userId, + orgId, actionType: "parameterDefinition.update", entityType: "parameterDefinition", entityId: id, @@ -342,15 +385,30 @@ export const parameterDefinitionRepository = { return updated; }, - async remove({ id }: { id: string }) { - const before = await parameterDefinitionRepository.findById({ id }); + async remove({ + userId, + orgId, + id, + }: { + userId: string; + orgId: string; + id: string; + }) { + const before = await parameterDefinitionRepository.findById({ orgId, id }); if (!before) throw new Error(`ParameterDefinition ${id} not found`); await db .delete(parameterDefinitions) - .where(eq(parameterDefinitions.id, id)); + .where( + and( + additiveOrgFilter(parameterDefinitions.ownerOrgId, orgId), + eq(parameterDefinitions.id, id), + ), + ); await transactionRepository.log({ + userId, + orgId, actionType: "parameterDefinition.delete", entityType: "parameterDefinition", entityId: id, diff --git a/web/repositories/standardRepository.ts b/web/repositories/standardRepository.ts index f1fcfd9..de7fc5a 100644 --- a/web/repositories/standardRepository.ts +++ b/web/repositories/standardRepository.ts @@ -12,26 +12,39 @@ import { aspects, aspectParameters, } from "@/db/schema"; +import { additiveOrgFilter } from "@/lib/auth/scope"; import { transactionRepository } from "./transactionRepository"; +// standards + related junctions are additive. NULL = global taxonomy; +// set = org-private. Junction inserts inherit ownerOrgId from the +// parent standard (or provided orgId if no parent lookup). export const standardRepository = { // --- Standard CRUD --- async create({ + userId, + orgId, + asGlobal, name, description, domainTag, }: { + userId: string; + orgId: string; + asGlobal?: boolean; name: string; description?: string; domainTag?: string; }) { + const ownerOrgId = asGlobal ? null : orgId; const [standard] = await db .insert(standards) - .values({ name, description, domainTag }) + .values({ ownerOrgId, name, description, domainTag }) .returning(); await transactionRepository.log({ + userId, + orgId, actionType: "standard.create", entityType: "standard", entityId: standard.id, @@ -42,26 +55,27 @@ export const standardRepository = { return standard; }, - async findById({ id }: { id: string }) { + async findById({ orgId, id }: { orgId: string; id: string }) { const [standard] = await db .select() .from(standards) - .where(eq(standards.id, id)); + .where(and(additiveOrgFilter(standards.ownerOrgId, orgId), eq(standards.id, id))); return standard ?? null; }, - async findByName({ name }: { name: string }) { + async findByName({ orgId, name }: { orgId: string; name: string }) { const [standard] = await db .select() .from(standards) - .where(eq(standards.name, name)); + .where(and(additiveOrgFilter(standards.ownerOrgId, orgId), eq(standards.name, name))); return standard ?? null; }, - async list() { + async list({ orgId }: { orgId: string }) { return db .select({ id: standards.id, + ownerOrgId: standards.ownerOrgId, name: standards.name, description: standards.description, domainTag: standards.domainTag, @@ -70,13 +84,21 @@ export const standardRepository = { aspectCount: sql`( SELECT COUNT(*) FROM aspect_standards WHERE aspect_standards.standard_id = standards.id + AND (aspect_standards.owner_org_id IS NULL OR aspect_standards.owner_org_id = ${orgId}) )`.as("aspect_count"), }) .from(standards) + .where(additiveOrgFilter(standards.ownerOrgId, orgId)) .orderBy(standards.name); }, - async listByAspect({ aspectId }: { aspectId: string }) { + async listByAspect({ + orgId, + aspectId, + }: { + orgId: string; + aspectId: string; + }) { return db .select({ id: standards.id, @@ -88,29 +110,41 @@ export const standardRepository = { }) .from(standards) .innerJoin(aspectStandards, eq(aspectStandards.standardId, standards.id)) - .where(eq(aspectStandards.aspectId, aspectId)) + .where( + and( + additiveOrgFilter(standards.ownerOrgId, orgId), + additiveOrgFilter(aspectStandards.ownerOrgId, orgId), + eq(aspectStandards.aspectId, aspectId), + ), + ) .orderBy(standards.name); }, async update({ + userId, + orgId, id, ...updates }: { + userId: string; + orgId: string; id: string; name?: string; description?: string; domainTag?: string; }) { - const before = await standardRepository.findById({ id }); + const before = await standardRepository.findById({ orgId, id }); if (!before) throw new Error(`Standard ${id} not found`); const [updated] = await db .update(standards) .set({ ...updates, updatedAt: new Date() }) - .where(eq(standards.id, id)) + .where(and(additiveOrgFilter(standards.ownerOrgId, orgId), eq(standards.id, id))) .returning(); await transactionRepository.log({ + userId, + orgId, actionType: "standard.update", entityType: "standard", entityId: id, @@ -121,13 +155,25 @@ export const standardRepository = { return updated; }, - async remove({ id }: { id: string }) { - const before = await standardRepository.findById({ id }); + async remove({ + userId, + orgId, + id, + }: { + userId: string; + orgId: string; + id: string; + }) { + const before = await standardRepository.findById({ orgId, id }); if (!before) throw new Error(`Standard ${id} not found`); - await db.delete(standards).where(eq(standards.id, id)); + await db + .delete(standards) + .where(and(additiveOrgFilter(standards.ownerOrgId, orgId), eq(standards.id, id))); await transactionRepository.log({ + userId, + orgId, actionType: "standard.delete", entityType: "standard", entityId: id, @@ -136,23 +182,31 @@ export const standardRepository = { }); }, - async countItemsUsing({ standardId }: { standardId: string }) { + async countItemsUsing({ + orgId, + standardId, + }: { + orgId: string; + standardId: string; + }) { const [result] = await db .select({ count: count() }) .from(itemStandards) - .where(eq(itemStandards.standardId, standardId)); + .where( + and( + additiveOrgFilter(itemStandards.ownerOrgId, orgId), + eq(itemStandards.standardId, standardId), + ), + ); return result?.count ?? 0; }, - /** - * Items that have this standard applied. Includes the chosen - * designation label (if any) so the caller can render - * "M3 screw — M3×0.5" in a usage panel. - */ async listItemsUsing({ + orgId, standardId, limit = 50, }: { + orgId: string; standardId: string; limit?: number; }) { @@ -169,19 +223,27 @@ export const standardRepository = { .innerJoin(items, eq(items.id, itemStandards.itemId)) .leftJoin( standardDesignations, - eq(itemStandards.designationId, standardDesignations.id) + eq(itemStandards.designationId, standardDesignations.id), + ) + .where( + and( + additiveOrgFilter(itemStandards.ownerOrgId, orgId), + additiveOrgFilter(items.ownerOrgId, orgId), + eq(itemStandards.standardId, standardId), + ), ) - .where(eq(itemStandards.standardId, standardId)) .orderBy(sql`${itemStandards.createdAt} DESC`) .limit(limit); return rows; }, - /** - * Histogram of designation usage: which designations under this standard - * are most applied to items, and how many items each covers. - */ - async designationUsage({ standardId }: { standardId: string }) { + async designationUsage({ + orgId, + standardId, + }: { + orgId: string; + standardId: string; + }) { const rows = await db .select({ designationId: itemStandards.designationId, @@ -191,9 +253,14 @@ export const standardRepository = { .from(itemStandards) .leftJoin( standardDesignations, - eq(itemStandards.designationId, standardDesignations.id) + eq(itemStandards.designationId, standardDesignations.id), + ) + .where( + and( + additiveOrgFilter(itemStandards.ownerOrgId, orgId), + eq(itemStandards.standardId, standardId), + ), ) - .where(eq(itemStandards.standardId, standardId)) .groupBy(itemStandards.designationId, standardDesignations.designation) .orderBy(sql`count(${itemStandards.id}) DESC`); return rows; @@ -202,18 +269,27 @@ export const standardRepository = { // --- Aspect-standard associations --- async addAspect({ + userId, + orgId, standardId, aspectId, }: { + userId: string; + orgId: string; standardId: string; aspectId: string; }) { + const parent = await standardRepository.findById({ orgId, id: standardId }); + if (!parent) throw new Error(`Standard ${standardId} not found`); + const [as_] = await db .insert(aspectStandards) - .values({ standardId, aspectId }) + .values({ ownerOrgId: parent.ownerOrgId, standardId, aspectId }) .returning(); await transactionRepository.log({ + userId, + orgId, actionType: "aspect_standard.create", entityType: "aspect_standard", entityId: as_.id, @@ -225,9 +301,13 @@ export const standardRepository = { }, async removeAspect({ + userId, + orgId, standardId, aspectId, }: { + userId: string; + orgId: string; standardId: string; aspectId: string; }) { @@ -235,19 +315,22 @@ export const standardRepository = { .delete(aspectStandards) .where( and( + additiveOrgFilter(aspectStandards.ownerOrgId, orgId), eq(aspectStandards.standardId, standardId), - eq(aspectStandards.aspectId, aspectId) - ) + eq(aspectStandards.aspectId, aspectId), + ), ) .returning(); if (!deleted) { throw new Error( - `Standard ${standardId} not linked to aspect ${aspectId}` + `Standard ${standardId} not linked to aspect ${aspectId}`, ); } await transactionRepository.log({ + userId, + orgId, actionType: "aspect_standard.delete", entityType: "aspect_standard", entityId: deleted.id, @@ -256,7 +339,13 @@ export const standardRepository = { }); }, - async listAspectsForStandard({ standardId }: { standardId: string }) { + async listAspectsForStandard({ + orgId, + standardId, + }: { + orgId: string; + standardId: string; + }) { return db .select({ aspectId: aspects.id, @@ -264,6 +353,7 @@ export const standardRepository = { parameterCount: sql`( SELECT COUNT(*) FROM aspect_parameters WHERE aspect_parameters.aspect_id = ${aspects.id} + AND (aspect_parameters.owner_org_id IS NULL OR aspect_parameters.owner_org_id = ${orgId}) )`.as("parameter_count"), coveredCount: sql`( SELECT COUNT(*) FROM aspect_parameters @@ -272,17 +362,27 @@ export const standardRepository = { = standard_parameters.parameter_definition_id WHERE aspect_parameters.aspect_id = ${aspects.id} AND standard_parameters.standard_id = ${standardId} + AND (aspect_parameters.owner_org_id IS NULL OR aspect_parameters.owner_org_id = ${orgId}) + AND (standard_parameters.owner_org_id IS NULL OR standard_parameters.owner_org_id = ${orgId}) )`.as("covered_count"), }) .from(aspectStandards) .innerJoin(aspects, eq(aspectStandards.aspectId, aspects.id)) - .where(eq(aspectStandards.standardId, standardId)) + .where( + and( + additiveOrgFilter(aspectStandards.ownerOrgId, orgId), + additiveOrgFilter(aspects.ownerOrgId, orgId), + eq(aspectStandards.standardId, standardId), + ), + ) .orderBy(aspects.name); }, async listStandardsForAspectWithCoverage({ + orgId, aspectId, }: { + orgId: string; aspectId: string; }) { return db @@ -293,10 +393,12 @@ export const standardRepository = { designationCount: sql`( SELECT COUNT(*) FROM standard_designations WHERE standard_designations.standard_id = ${standards.id} + AND (standard_designations.owner_org_id IS NULL OR standard_designations.owner_org_id = ${orgId}) )`.as("designation_count"), parameterCount: sql`( SELECT COUNT(*) FROM aspect_parameters WHERE aspect_parameters.aspect_id = ${aspectId} + AND (aspect_parameters.owner_org_id IS NULL OR aspect_parameters.owner_org_id = ${orgId}) )`.as("parameter_count"), coveredCount: sql`( SELECT COUNT(*) FROM aspect_parameters @@ -305,6 +407,8 @@ export const standardRepository = { = standard_parameters.parameter_definition_id WHERE aspect_parameters.aspect_id = ${aspectId} AND standard_parameters.standard_id = ${standards.id} + AND (aspect_parameters.owner_org_id IS NULL OR aspect_parameters.owner_org_id = ${orgId}) + AND (standard_parameters.owner_org_id IS NULL OR standard_parameters.owner_org_id = ${orgId}) )`.as("covered_count"), coveredParamIds: sql`( SELECT array_agg(aspect_parameters.parameter_definition_id) @@ -314,30 +418,44 @@ export const standardRepository = { = standard_parameters.parameter_definition_id WHERE aspect_parameters.aspect_id = ${aspectId} AND standard_parameters.standard_id = ${standards.id} + AND (aspect_parameters.owner_org_id IS NULL OR aspect_parameters.owner_org_id = ${orgId}) + AND (standard_parameters.owner_org_id IS NULL OR standard_parameters.owner_org_id = ${orgId}) )`.as("covered_param_ids"), }) .from(aspectStandards) .innerJoin(standards, eq(aspectStandards.standardId, standards.id)) - .where(eq(aspectStandards.aspectId, aspectId)) + .where( + and( + additiveOrgFilter(aspectStandards.ownerOrgId, orgId), + additiveOrgFilter(standards.ownerOrgId, orgId), + eq(aspectStandards.aspectId, aspectId), + ), + ) .orderBy(standards.name); }, // --- Standard parameters --- async addParameter({ + orgId, standardId, parameterDefinitionId, role, sortOrder, }: { + orgId: string; standardId: string; parameterDefinitionId: string; role: string; sortOrder?: number; }) { + const parent = await standardRepository.findById({ orgId, id: standardId }); + if (!parent) throw new Error(`Standard ${standardId} not found`); + const [sp] = await db .insert(standardParameters) .values({ + ownerOrgId: parent.ownerOrgId, standardId, parameterDefinitionId, role, @@ -349,9 +467,11 @@ export const standardRepository = { }, async removeParameter({ + orgId, standardId, parameterDefinitionId, }: { + orgId: string; standardId: string; parameterDefinitionId: string; }) { @@ -359,20 +479,27 @@ export const standardRepository = { .delete(standardParameters) .where( and( + additiveOrgFilter(standardParameters.ownerOrgId, orgId), eq(standardParameters.standardId, standardId), - eq(standardParameters.parameterDefinitionId, parameterDefinitionId) - ) + eq(standardParameters.parameterDefinitionId, parameterDefinitionId), + ), ) .returning(); if (!deleted) { throw new Error( - `Parameter ${parameterDefinitionId} not found on standard ${standardId}` + `Parameter ${parameterDefinitionId} not found on standard ${standardId}`, ); } }, - async getParameters({ standardId }: { standardId: string }) { + async getParameters({ + orgId, + standardId, + }: { + orgId: string; + standardId: string; + }) { return db .select({ id: standardParameters.id, @@ -386,53 +513,86 @@ export const standardRepository = { .from(standardParameters) .innerJoin( parameterDefinitions, - eq(standardParameters.parameterDefinitionId, parameterDefinitions.id) + eq(standardParameters.parameterDefinitionId, parameterDefinitions.id), + ) + .where( + and( + additiveOrgFilter(standardParameters.ownerOrgId, orgId), + additiveOrgFilter(parameterDefinitions.ownerOrgId, orgId), + eq(standardParameters.standardId, standardId), + ), ) - .where(eq(standardParameters.standardId, standardId)) .orderBy(standardParameters.sortOrder); }, // --- Designations --- async createDesignation({ + orgId, standardId, designation, values, metadata, }: { + orgId: string; standardId: string; designation: string; values: Record; metadata?: unknown; }) { + const parent = await standardRepository.findById({ orgId, id: standardId }); + if (!parent) throw new Error(`Standard ${standardId} not found`); + const [entry] = await db .insert(standardDesignations) - .values({ standardId, designation, values, metadata }) + .values({ + ownerOrgId: parent.ownerOrgId, + standardId, + designation, + values, + metadata, + }) .returning(); return entry; }, - async findDesignationById({ id }: { id: string }) { + async findDesignationById({ + orgId, + id, + }: { + orgId: string; + id: string; + }) { const [entry] = await db .select() .from(standardDesignations) - .where(eq(standardDesignations.id, id)); + .where( + and( + additiveOrgFilter(standardDesignations.ownerOrgId, orgId), + eq(standardDesignations.id, id), + ), + ); return entry ?? null; }, async listDesignations({ + orgId, standardId, q, limit, offset, }: { + orgId: string; standardId: string; q?: string; limit?: number; offset?: number; }) { - const filters = [eq(standardDesignations.standardId, standardId)]; + const filters = [ + additiveOrgFilter(standardDesignations.ownerOrgId, orgId), + eq(standardDesignations.standardId, standardId), + ]; if (q && q.trim().length > 0) { filters.push(ilike(standardDesignations.designation, `%${q.trim()}%`)); } @@ -450,18 +610,31 @@ export const standardRepository = { return query; }, - async countDesignations({ standardId }: { standardId: string }) { + async countDesignations({ + orgId, + standardId, + }: { + orgId: string; + standardId: string; + }) { const [result] = await db .select({ count: count() }) .from(standardDesignations) - .where(eq(standardDesignations.standardId, standardId)); + .where( + and( + additiveOrgFilter(standardDesignations.ownerOrgId, orgId), + eq(standardDesignations.standardId, standardId), + ), + ); return result?.count ?? 0; }, async updateDesignation({ + orgId, id, ...updates }: { + orgId: string; id: string; designation?: string; values?: Record; @@ -470,17 +643,33 @@ export const standardRepository = { const [updated] = await db .update(standardDesignations) .set(updates) - .where(eq(standardDesignations.id, id)) + .where( + and( + additiveOrgFilter(standardDesignations.ownerOrgId, orgId), + eq(standardDesignations.id, id), + ), + ) .returning(); if (!updated) throw new Error(`Designation ${id} not found`); return updated; }, - async removeDesignation({ id }: { id: string }) { + async removeDesignation({ + orgId, + id, + }: { + orgId: string; + id: string; + }) { const [deleted] = await db .delete(standardDesignations) - .where(eq(standardDesignations.id, id)) + .where( + and( + additiveOrgFilter(standardDesignations.ownerOrgId, orgId), + eq(standardDesignations.id, id), + ), + ) .returning(); if (!deleted) throw new Error(`Designation ${id} not found`); @@ -488,44 +677,53 @@ export const standardRepository = { // --- Item-standard associations --- - /** - * Auto-fill item's parameter values from a designation. - * - * Designation values JSONB is keyed by parameter_definition_id. Each entry - * may be a scalar or a compound `{value, source_value?, source_unit?}`. - * For each entry we upsert itemParameterValues — matched on (itemId, - * parameterDefinitionId), ignoring itemAspectId so an existing - * aspect-scoped row gets overwritten rather than duplicated. - */ async applyDesignationValues({ + orgId, itemId, designationId, }: { + orgId: string; itemId: string; designationId: string; }) { const [designation] = await db .select() .from(standardDesignations) - .where(eq(standardDesignations.id, designationId)); + .where( + and( + additiveOrgFilter(standardDesignations.ownerOrgId, orgId), + eq(standardDesignations.id, designationId), + ), + ); if (!designation) return; + const [item] = await db + .select({ ownerOrgId: items.ownerOrgId }) + .from(items) + .where( + and(additiveOrgFilter(items.ownerOrgId, orgId), eq(items.id, itemId)), + ); + if (!item) return; + const valuesOwnerOrgId = item.ownerOrgId; + const values = (designation.values ?? {}) as Record; const allKeys = Object.keys(values); const uuidLike = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; const keys = allKeys.filter((k) => uuidLike.test(k)); if (keys.length === 0) return; - // Skip keys that aren't real parameterDefinition IDs. Older tests / - // fixtures may store human-readable keys ("pitch") that would fail the - // FK; and even well-formed UUIDs may point at defs that don't exist. const validKeys = new Set( ( await db .select({ id: parameterDefinitions.id }) .from(parameterDefinitions) - .where(inArray(parameterDefinitions.id, keys)) - ).map((r) => r.id) + .where( + and( + additiveOrgFilter(parameterDefinitions.ownerOrgId, orgId), + inArray(parameterDefinitions.id, keys), + ), + ) + ).map((r) => r.id), ); for (const [paramDefId, raw] of Object.entries(values)) { @@ -540,9 +738,10 @@ export const standardRepository = { .from(itemParameterValues) .where( and( + additiveOrgFilter(itemParameterValues.ownerOrgId, orgId), eq(itemParameterValues.itemId, itemId), - eq(itemParameterValues.parameterDefinitionId, paramDefId) - ) + eq(itemParameterValues.parameterDefinitionId, paramDefId), + ), ); if (existing.length > 0) { @@ -552,6 +751,7 @@ export const standardRepository = { .where(eq(itemParameterValues.id, existing[0].id)); } else { await db.insert(itemParameterValues).values({ + ownerOrgId: valuesOwnerOrgId, itemId, parameterDefinitionId: paramDefId, itemAspectId: null, @@ -562,17 +762,30 @@ export const standardRepository = { }, async applyToItem({ + userId, + orgId, itemId, standardId, designationId, }: { + userId: string; + orgId: string; itemId: string; standardId: string; designationId?: string; }) { + const [item] = await db + .select({ ownerOrgId: items.ownerOrgId }) + .from(items) + .where( + and(additiveOrgFilter(items.ownerOrgId, orgId), eq(items.id, itemId)), + ); + if (!item) throw new Error(`Item ${itemId} not found`); + const [is] = await db .insert(itemStandards) .values({ + ownerOrgId: item.ownerOrgId, itemId, standardId, designationId: designationId ?? null, @@ -582,12 +795,15 @@ export const standardRepository = { if (designationId) { await standardRepository.applyDesignationValues({ + orgId, itemId, designationId, }); } await transactionRepository.log({ + userId, + orgId, actionType: "item_standard.create", entityType: "item_standard", entityId: is.id, @@ -599,9 +815,13 @@ export const standardRepository = { }, async removeFromItem({ + userId, + orgId, itemId, standardId, }: { + userId: string; + orgId: string; itemId: string; standardId: string; }) { @@ -609,19 +829,20 @@ export const standardRepository = { .delete(itemStandards) .where( and( + additiveOrgFilter(itemStandards.ownerOrgId, orgId), eq(itemStandards.itemId, itemId), - eq(itemStandards.standardId, standardId) - ) + eq(itemStandards.standardId, standardId), + ), ) .returning(); if (!deleted) { - throw new Error( - `Standard ${standardId} not applied to item ${itemId}` - ); + throw new Error(`Standard ${standardId} not applied to item ${itemId}`); } await transactionRepository.log({ + userId, + orgId, actionType: "item_standard.delete", entityType: "item_standard", entityId: deleted.id, @@ -631,10 +852,12 @@ export const standardRepository = { }, async setDesignation({ + orgId, itemId, standardId, designationId, }: { + orgId: string; itemId: string; standardId: string; designationId: string | null; @@ -644,20 +867,20 @@ export const standardRepository = { .set({ designationId, isCustom: false }) .where( and( + additiveOrgFilter(itemStandards.ownerOrgId, orgId), eq(itemStandards.itemId, itemId), - eq(itemStandards.standardId, standardId) - ) + eq(itemStandards.standardId, standardId), + ), ) .returning(); if (!updated) { - throw new Error( - `Standard ${standardId} not applied to item ${itemId}` - ); + throw new Error(`Standard ${standardId} not applied to item ${itemId}`); } if (designationId) { await standardRepository.applyDesignationValues({ + orgId, itemId, designationId, }); @@ -667,9 +890,11 @@ export const standardRepository = { }, async markCustom({ + orgId, itemId, standardId, }: { + orgId: string; itemId: string; standardId: string; }) { @@ -678,22 +903,27 @@ export const standardRepository = { .set({ isCustom: true }) .where( and( + additiveOrgFilter(itemStandards.ownerOrgId, orgId), eq(itemStandards.itemId, itemId), - eq(itemStandards.standardId, standardId) - ) + eq(itemStandards.standardId, standardId), + ), ) .returning(); if (!updated) { - throw new Error( - `Standard ${standardId} not applied to item ${itemId}` - ); + throw new Error(`Standard ${standardId} not applied to item ${itemId}`); } return updated; }, - async getItemStandards({ itemId }: { itemId: string }) { + async getItemStandards({ + orgId, + itemId, + }: { + orgId: string; + itemId: string; + }) { return db .select({ id: itemStandards.id, @@ -709,8 +939,14 @@ export const standardRepository = { .innerJoin(standards, eq(itemStandards.standardId, standards.id)) .leftJoin( standardDesignations, - eq(itemStandards.designationId, standardDesignations.id) + eq(itemStandards.designationId, standardDesignations.id), ) - .where(eq(itemStandards.itemId, itemId)); + .where( + and( + additiveOrgFilter(itemStandards.ownerOrgId, orgId), + additiveOrgFilter(standards.ownerOrgId, orgId), + eq(itemStandards.itemId, itemId), + ), + ); }, }; diff --git a/web/repositories/templateRepository.ts b/web/repositories/templateRepository.ts index 1a0941c..51df020 100644 --- a/web/repositories/templateRepository.ts +++ b/web/repositories/templateRepository.ts @@ -9,10 +9,18 @@ import { inserts, locations, } from "@/db/schema"; +import { additiveOrgFilter } from "@/lib/auth/scope"; import { transactionRepository } from "./transactionRepository"; +// templates is an additive table. Reads union (global ∪ org). +// Writes default to org-private. Pass `asGlobal: true` to contribute +// to the global catalog (per the global-catalog-edit policy: any +// signed-in user may create or edit globals; audited in transactions). export const templateRepository = { async create({ + userId, + orgId, + asGlobal, name, description, metadata, @@ -42,6 +50,9 @@ export const templateRepository = { subdivisionOptions, physicalConstraints, }: { + userId: string; + orgId: string; + asGlobal?: boolean; name: string; description?: string; metadata?: Record; @@ -73,10 +84,13 @@ export const templateRepository = { subdivisionOptions?: Record | null; physicalConstraints?: Record | null; }) { + const ownerOrgId = asGlobal ? null : orgId; + const { template, version } = await db.transaction(async (tx) => { const [t] = await tx .insert(templates) .values({ + ownerOrgId, name, description, metadata, @@ -89,6 +103,7 @@ export const templateRepository = { const [v] = await tx .insert(templateVersions) .values({ + ownerOrgId, templateId: t.id, version: 1, isParametric: isParametric ?? false, @@ -120,6 +135,7 @@ export const templateRepository = { if (interfacesProvidedIds?.length) { await tx.insert(templateVersionInterfacesProvided).values( interfacesProvidedIds.map((interfaceTypeId) => ({ + ownerOrgId, templateVersionId: v.id, interfaceTypeId, })), @@ -128,6 +144,7 @@ export const templateRepository = { if (interfacesAcceptedIds?.length) { await tx.insert(templateVersionInterfacesAccepted).values( interfacesAcceptedIds.map((interfaceTypeId) => ({ + ownerOrgId, templateVersionId: v.id, interfaceTypeId, })), @@ -138,6 +155,8 @@ export const templateRepository = { }); await transactionRepository.log({ + userId, + orgId, actionType: "template.create", entityType: "template", entityId: template.id, @@ -148,42 +167,59 @@ export const templateRepository = { return template; }, - async findById({ id }: { id: string }) { + async findById({ orgId, id }: { orgId: string; id: string }) { const [template] = await db .select() .from(templates) - .where(eq(templates.id, id)); + .where(and(additiveOrgFilter(templates.ownerOrgId, orgId), eq(templates.id, id))); return template ?? null; }, - async findByName({ name }: { name: string }) { + async findByName({ orgId, name }: { orgId: string; name: string }) { const [template] = await db .select() .from(templates) - .where(eq(templates.name, name)); + .where(and(additiveOrgFilter(templates.ownerOrgId, orgId), eq(templates.name, name))); return template ?? null; }, - async list({ includeHidden = false }: { includeHidden?: boolean } = {}) { - if (includeHidden) return db.select().from(templates); + async list({ + orgId, + includeHidden = false, + }: { + orgId: string; + includeHidden?: boolean; + }) { + const scope = additiveOrgFilter(templates.ownerOrgId, orgId); + if (includeHidden) { + return db.select().from(templates).where(scope); + } return db .select() .from(templates) - .where(eq(templates.isHidden, false)); + .where(and(scope, eq(templates.isHidden, false))); }, async listWithCurrentVersion({ + orgId, includeHidden = false, - }: { includeHidden?: boolean } = {}) { + }: { + orgId: string; + includeHidden?: boolean; + }) { + const scope = additiveOrgFilter(templates.ownerOrgId, orgId); const allTemplates = includeHidden - ? await db.select().from(templates) + ? await db.select().from(templates).where(scope) : await db .select() .from(templates) - .where(eq(templates.isHidden, false)); + .where(and(scope, eq(templates.isHidden, false))); if (allTemplates.length === 0) return []; - const allVersions = await db.select().from(templateVersions); + const allVersions = await db + .select() + .from(templateVersions) + .where(additiveOrgFilter(templateVersions.ownerOrgId, orgId)); const versionMap = new Map(); for (const v of allVersions) { const list = versionMap.get(v.templateId) ?? []; @@ -191,8 +227,6 @@ export const templateRepository = { versionMap.set(v.templateId, list); } - // Batch-load interface identifiers for the current versions so callers - // can filter / render chips without a second round-trip. const currentVersionIds = allTemplates .map((t) => versionMap.get(t.id)?.find((v) => v.version === t.currentVersion)?.id) .filter((x): x is string => !!x); @@ -220,9 +254,15 @@ export const templateRepository = { ), ) .where( - inArray( - templateVersionInterfacesProvided.templateVersionId, - currentVersionIds, + and( + additiveOrgFilter( + templateVersionInterfacesProvided.ownerOrgId, + orgId, + ), + inArray( + templateVersionInterfacesProvided.templateVersionId, + currentVersionIds, + ), ), ); for (const r of providedRows) { @@ -245,9 +285,15 @@ export const templateRepository = { ), ) .where( - inArray( - templateVersionInterfacesAccepted.templateVersionId, - currentVersionIds, + and( + additiveOrgFilter( + templateVersionInterfacesAccepted.ownerOrgId, + orgId, + ), + inArray( + templateVersionInterfacesAccepted.templateVersionId, + currentVersionIds, + ), ), ); for (const r of acceptedRows) { @@ -274,24 +320,30 @@ export const templateRepository = { }, async update({ + userId, + orgId, id, ...updates }: { + userId: string; + orgId: string; id: string; name?: string; description?: string; metadata?: Record; }) { - const before = await templateRepository.findById({ id }); + const before = await templateRepository.findById({ orgId, id }); if (!before) throw new Error(`Template ${id} not found`); const [updated] = await db .update(templates) .set({ ...updates, updatedAt: new Date() }) - .where(eq(templates.id, id)) + .where(and(additiveOrgFilter(templates.ownerOrgId, orgId), eq(templates.id, id))) .returning(); await transactionRepository.log({ + userId, + orgId, actionType: "template.update", entityType: "template", entityId: id, @@ -302,19 +354,32 @@ export const templateRepository = { return updated; }, - async remove({ id }: { id: string }) { - const before = await templateRepository.findById({ id }); + async remove({ + userId, + orgId, + id, + }: { + userId: string; + orgId: string; + id: string; + }) { + const before = await templateRepository.findById({ orgId, id }); if (!before) throw new Error(`Template ${id} not found`); - // Delete versions then template within a transaction to avoid deadlocks with test cleanup await db.transaction(async (tx) => { await tx .delete(templateVersions) .where(eq(templateVersions.templateId, id)); - await tx.delete(templates).where(eq(templates.id, id)); + await tx + .delete(templates) + .where( + and(additiveOrgFilter(templates.ownerOrgId, orgId), eq(templates.id, id)), + ); }); await transactionRepository.log({ + userId, + orgId, actionType: "template.delete", entityType: "template", entityId: id, @@ -327,26 +392,42 @@ export const templateRepository = { * Count inserts + locations referencing any version of this template. * Used to decide between hard delete and soft hide. */ - async getReferenceCount({ id }: { id: string }) { + async getReferenceCount({ orgId, id }: { orgId: string; id: string }) { const versions = await db .select({ id: templateVersions.id }) .from(templateVersions) - .where(eq(templateVersions.templateId, id)); + .where( + and( + additiveOrgFilter(templateVersions.ownerOrgId, orgId), + eq(templateVersions.templateId, id), + ), + ); const versionIds = versions.map((v) => v.id); if (versionIds.length === 0) { return { insertCount: 0, locationCount: 0 }; } + // inserts + locations are isolated tables — count within this org only. const [insertRow] = await db .select({ c: sql`COUNT(*)` }) .from(inserts) - .where(inArray(inserts.templateVersionId, versionIds)); + .where( + and( + eq(inserts.ownerOrgId, orgId), + inArray(inserts.templateVersionId, versionIds), + ), + ); const [locationRow] = await db .select({ c: sql`COUNT(*)` }) .from(locations) - .where(inArray(locations.templateVersionId, versionIds)); + .where( + and( + eq(locations.ownerOrgId, orgId), + inArray(locations.templateVersionId, versionIds), + ), + ); return { insertCount: Number(insertRow?.c ?? 0), @@ -354,17 +435,27 @@ export const templateRepository = { }; }, - async hide({ id }: { id: string }) { - const before = await templateRepository.findById({ id }); + async hide({ + userId, + orgId, + id, + }: { + userId: string; + orgId: string; + id: string; + }) { + const before = await templateRepository.findById({ orgId, id }); if (!before) throw new Error(`Template ${id} not found`); const [updated] = await db .update(templates) .set({ isHidden: true, updatedAt: new Date() }) - .where(eq(templates.id, id)) + .where(and(additiveOrgFilter(templates.ownerOrgId, orgId), eq(templates.id, id))) .returning(); await transactionRepository.log({ + userId, + orgId, actionType: "template.hide", entityType: "template", entityId: id, @@ -375,17 +466,27 @@ export const templateRepository = { return updated; }, - async unhide({ id }: { id: string }) { - const before = await templateRepository.findById({ id }); + async unhide({ + userId, + orgId, + id, + }: { + userId: string; + orgId: string; + id: string; + }) { + const before = await templateRepository.findById({ orgId, id }); if (!before) throw new Error(`Template ${id} not found`); const [updated] = await db .update(templates) .set({ isHidden: false, updatedAt: new Date() }) - .where(eq(templates.id, id)) + .where(and(additiveOrgFilter(templates.ownerOrgId, orgId), eq(templates.id, id))) .returning(); await transactionRepository.log({ + userId, + orgId, actionType: "template.unhide", entityType: "template", entityId: id, @@ -397,6 +498,8 @@ export const templateRepository = { }, async publishVersion({ + userId, + orgId, templateId, isParametric, rows, @@ -415,6 +518,8 @@ export const templateRepository = { subdivisionOptions, physicalConstraints, }: { + userId: string; + orgId: string; templateId: string; isParametric?: boolean; rows?: number | null; @@ -433,15 +538,21 @@ export const templateRepository = { subdivisionOptions?: Record | null; physicalConstraints?: Record | null; }) { - const template = await templateRepository.findById({ id: templateId }); + const template = await templateRepository.findById({ + orgId, + id: templateId, + }); if (!template) throw new Error(`Template ${templateId} not found`); const newVersionNumber = template.currentVersion + 1; + // New version inherits the parent template's scope (global or org). + const versionOwnerOrgId = template.ownerOrgId; const { version, updatedTemplate } = await db.transaction(async (tx) => { const [v] = await tx .insert(templateVersions) .values({ + ownerOrgId: versionOwnerOrgId, templateId, version: newVersionNumber, isParametric: isParametric ?? false, @@ -465,6 +576,7 @@ export const templateRepository = { if (interfacesProvidedIds?.length) { await tx.insert(templateVersionInterfacesProvided).values( interfacesProvidedIds.map((interfaceTypeId) => ({ + ownerOrgId: versionOwnerOrgId, templateVersionId: v.id, interfaceTypeId, })), @@ -473,6 +585,7 @@ export const templateRepository = { if (interfacesAcceptedIds?.length) { await tx.insert(templateVersionInterfacesAccepted).values( interfacesAcceptedIds.map((interfaceTypeId) => ({ + ownerOrgId: versionOwnerOrgId, templateVersionId: v.id, interfaceTypeId, })), @@ -489,6 +602,8 @@ export const templateRepository = { }); await transactionRepository.log({ + userId, + orgId, actionType: "template.publishVersion", entityType: "templateVersion", entityId: version.id, @@ -503,7 +618,13 @@ export const templateRepository = { * Read interfaces declared on a specific template version. * Returns full interface_types rows so callers can render chips / labels. */ - async getVersionInterfaces({ versionId }: { versionId: string }) { + async getVersionInterfaces({ + orgId, + versionId, + }: { + orgId: string; + versionId: string; + }) { const provided = await db .select({ id: interfaceTypes.id, @@ -518,7 +639,15 @@ export const templateRepository = { interfaceTypes, eq(templateVersionInterfacesProvided.interfaceTypeId, interfaceTypes.id), ) - .where(eq(templateVersionInterfacesProvided.templateVersionId, versionId)); + .where( + and( + additiveOrgFilter( + templateVersionInterfacesProvided.ownerOrgId, + orgId, + ), + eq(templateVersionInterfacesProvided.templateVersionId, versionId), + ), + ); const accepted = await db .select({ @@ -534,7 +663,15 @@ export const templateRepository = { interfaceTypes, eq(templateVersionInterfacesAccepted.interfaceTypeId, interfaceTypes.id), ) - .where(eq(templateVersionInterfacesAccepted.templateVersionId, versionId)); + .where( + and( + additiveOrgFilter( + templateVersionInterfacesAccepted.ownerOrgId, + orgId, + ), + eq(templateVersionInterfacesAccepted.templateVersionId, versionId), + ), + ); return { provided, accepted }; }, @@ -544,14 +681,31 @@ export const templateRepository = { * Idempotent. Either list can be omitted to leave that side untouched. */ async setVersionInterfaces({ + userId, + orgId, versionId, providedIds, acceptedIds, }: { + userId: string; + orgId: string; versionId: string; providedIds?: string[]; acceptedIds?: string[]; }) { + // Resolve parent version's scope so new junction rows match. + const [parentVersion] = await db + .select({ ownerOrgId: templateVersions.ownerOrgId }) + .from(templateVersions) + .where( + and( + additiveOrgFilter(templateVersions.ownerOrgId, orgId), + eq(templateVersions.id, versionId), + ), + ); + if (!parentVersion) throw new Error(`TemplateVersion ${versionId} not found`); + const junctionOwner = parentVersion.ownerOrgId; + await db.transaction(async (tx) => { if (providedIds !== undefined) { await tx @@ -560,6 +714,7 @@ export const templateRepository = { if (providedIds.length > 0) { await tx.insert(templateVersionInterfacesProvided).values( providedIds.map((interfaceTypeId) => ({ + ownerOrgId: junctionOwner, templateVersionId: versionId, interfaceTypeId, })), @@ -573,6 +728,7 @@ export const templateRepository = { if (acceptedIds.length > 0) { await tx.insert(templateVersionInterfacesAccepted).values( acceptedIds.map((interfaceTypeId) => ({ + ownerOrgId: junctionOwner, templateVersionId: versionId, interfaceTypeId, })), @@ -582,6 +738,8 @@ export const templateRepository = { }); await transactionRepository.log({ + userId, + orgId, actionType: "templateVersion.setInterfaces", entityType: "templateVersion", entityId: versionId, @@ -591,9 +749,11 @@ export const templateRepository = { }, async getVersion({ + orgId, templateId, version, }: { + orgId: string; templateId: string; version: number; }) { @@ -602,40 +762,63 @@ export const templateRepository = { .from(templateVersions) .where( and( + additiveOrgFilter(templateVersions.ownerOrgId, orgId), eq(templateVersions.templateId, templateId), - eq(templateVersions.version, version) - ) + eq(templateVersions.version, version), + ), ); return v ?? null; }, - async listVersions({ templateId }: { templateId: string }) { + async listVersions({ + orgId, + templateId, + }: { + orgId: string; + templateId: string; + }) { return db .select() .from(templateVersions) - .where(eq(templateVersions.templateId, templateId)); + .where( + and( + additiveOrgFilter(templateVersions.ownerOrgId, orgId), + eq(templateVersions.templateId, templateId), + ), + ); }, async setActiveVersion({ + userId, + orgId, templateId, version, }: { + userId: string; + orgId: string; templateId: string; version: number; }) { - const template = await templateRepository.findById({ id: templateId }); + const template = await templateRepository.findById({ orgId, id: templateId }); if (!template) throw new Error(`Template ${templateId} not found`); - const versionRecord = await templateRepository.getVersion({ templateId, version }); - if (!versionRecord) throw new Error(`Version ${version} not found for template ${templateId}`); + const versionRecord = await templateRepository.getVersion({ + orgId, + templateId, + version, + }); + if (!versionRecord) + throw new Error(`Version ${version} not found for template ${templateId}`); const [updated] = await db .update(templates) .set({ activeVersion: version, updatedAt: new Date() }) - .where(eq(templates.id, templateId)) + .where(and(additiveOrgFilter(templates.ownerOrgId, orgId), eq(templates.id, templateId))) .returning(); await transactionRepository.log({ + userId, + orgId, actionType: "template.setActiveVersion", entityType: "template", entityId: templateId, diff --git a/web/repositories/transactionRepository.ts b/web/repositories/transactionRepository.ts index 00fb60d..e676cd0 100644 --- a/web/repositories/transactionRepository.ts +++ b/web/repositories/transactionRepository.ts @@ -1,9 +1,14 @@ -import { eq, desc } from "drizzle-orm"; +import { and, eq, desc } from "drizzle-orm"; import { db } from "@/db/connection"; import { transactions } from "@/db/schema"; +import { isolatedOrgFilter } from "@/lib/auth/scope"; +// transactions is an isolated table. Every log call carries the acting +// user and their active org. Reads are strictly scoped. export const transactionRepository = { async log({ + userId, + orgId, parentId, actionType, entityType, @@ -11,6 +16,8 @@ export const transactionRepository = { beforeState, afterState, }: { + userId: string; + orgId: string; parentId?: string; actionType: string; entityType: string; @@ -21,6 +28,8 @@ export const transactionRepository = { const [tx] = await db .insert(transactions) .values({ + ownerOrgId: orgId, + actorUserId: userId, parentId, actionType, entityType, @@ -33,32 +42,39 @@ export const transactionRepository = { return tx; }, - async findById({ id }: { id: string }) { + async findById({ orgId, id }: { orgId: string; id: string }) { const [tx] = await db .select() .from(transactions) - .where(eq(transactions.id, id)); + .where( + and(isolatedOrgFilter(transactions.ownerOrgId, orgId), eq(transactions.id, id)), + ); return tx ?? null; }, - async listRecent({ limit = 50 }: { limit?: number } = {}) { + async listRecent({ orgId, limit = 50 }: { orgId: string; limit?: number }) { return db .select() .from(transactions) + .where(isolatedOrgFilter(transactions.ownerOrgId, orgId)) .orderBy(desc(transactions.createdAt)) .limit(limit); }, async markUndone({ + orgId, id, undoneByTransactionId, }: { + orgId: string; id: string; undoneByTransactionId: string; }) { await db .update(transactions) .set({ isUndone: true, undoneByTransactionId }) - .where(eq(transactions.id, id)); + .where( + and(isolatedOrgFilter(transactions.ownerOrgId, orgId), eq(transactions.id, id)), + ); }, }; diff --git a/web/tests/repositories/aspectRepository.isolation.test.ts b/web/tests/repositories/aspectRepository.isolation.test.ts new file mode 100644 index 0000000..6e64cc6 --- /dev/null +++ b/web/tests/repositories/aspectRepository.isolation.test.ts @@ -0,0 +1,68 @@ +import { aspectRepository } from "@/repositories/aspectRepository"; +import { testCtx, createTestOrg } from "../setup"; + +describe("aspectRepository isolation (additive)", () => { + it("list returns global + own, not other org's private", async () => { + const b = await createTestOrg({ slug: "other-org" }); + + await aspectRepository.create({ + ...testCtx, + asGlobal: true, + name: "Global Aspect", + }); + await aspectRepository.create({ + ...testCtx, + name: "A Private", + }); + await aspectRepository.create({ + ...b, + name: "B Private", + }); + + const aNames = (await aspectRepository.list({ orgId: testCtx.orgId })) + .map((a) => a.name) + .sort(); + const bNames = (await aspectRepository.list({ orgId: b.orgId })) + .map((a) => a.name) + .sort(); + + expect(aNames).toEqual(["A Private", "Global Aspect"]); + expect(bNames).toEqual(["B Private", "Global Aspect"]); + }); + + it("org A cannot fetch org B's private aspect by id", async () => { + const b = await createTestOrg({ slug: "other-org" }); + const bAspect = await aspectRepository.create({ + ...b, + name: "B Private", + }); + + const fromA = await aspectRepository.findById({ + orgId: testCtx.orgId, + id: bAspect.id, + }); + expect(fromA).toBeNull(); + + const fromB = await aspectRepository.findById({ + orgId: b.orgId, + id: bAspect.id, + }); + expect(fromB?.id).toBe(bAspect.id); + }); + + it("update on another org's private aspect throws not-found", async () => { + const b = await createTestOrg({ slug: "other-org" }); + const bAspect = await aspectRepository.create({ + ...b, + name: "B Private", + }); + + await expect( + aspectRepository.update({ + ...testCtx, + id: bAspect.id, + description: "hijack", + }), + ).rejects.toThrow("not found"); + }); +}); diff --git a/web/tests/repositories/aspectRepository.test.ts b/web/tests/repositories/aspectRepository.test.ts index 4ac71c2..34cc8d2 100644 --- a/web/tests/repositories/aspectRepository.test.ts +++ b/web/tests/repositories/aspectRepository.test.ts @@ -1,11 +1,13 @@ import { aspectRepository } from "@/repositories/aspectRepository"; import { parameterDefinitionRepository } from "@/repositories/parameterDefinitionRepository"; import { transactionRepository } from "@/repositories/transactionRepository"; +import { testCtx } from "../setup"; describe("aspectRepository", () => { describe("create", () => { it("creates an aspect with name and description", async () => { const aspect = await aspectRepository.create({ + ...testCtx, name: "Thread", description: "Threaded fastener properties", }); @@ -17,39 +19,54 @@ describe("aspectRepository", () => { }); it("creates with minimal fields", async () => { - const aspect = await aspectRepository.create({ name: "Material" }); + const aspect = await aspectRepository.create({ + ...testCtx, + name: "Material", + }); expect(aspect.name).toBe("Material"); expect(aspect.description).toBeNull(); }); it("logs a transaction", async () => { - const aspect = await aspectRepository.create({ name: "Thread" }); + const aspect = await aspectRepository.create({ + ...testCtx, + name: "Thread", + }); - const txns = await transactionRepository.listRecent(); + const txns = await transactionRepository.listRecent({ + orgId: testCtx.orgId, + }); expect(txns).toHaveLength(1); expect(txns[0].actionType).toBe("aspect.create"); expect(txns[0].entityId).toBe(aspect.id); }); it("rejects duplicate names", async () => { - await aspectRepository.create({ name: "Thread" }); + await aspectRepository.create({ ...testCtx, name: "Thread" }); await expect( - aspectRepository.create({ name: "Thread" }) + aspectRepository.create({ ...testCtx, name: "Thread" }) ).rejects.toThrow(); }); }); describe("findById", () => { it("returns the aspect by ID", async () => { - const created = await aspectRepository.create({ name: "Thread" }); - const found = await aspectRepository.findById({ id: created.id }); + const created = await aspectRepository.create({ + ...testCtx, + name: "Thread", + }); + const found = await aspectRepository.findById({ + orgId: testCtx.orgId, + id: created.id, + }); expect(found).not.toBeNull(); expect(found!.name).toBe("Thread"); }); it("returns null for nonexistent ID", async () => { const found = await aspectRepository.findById({ + orgId: testCtx.orgId, id: "00000000-0000-0000-0000-000000000000", }); expect(found).toBeNull(); @@ -59,27 +76,34 @@ describe("aspectRepository", () => { describe("findByName", () => { it("returns the aspect by name", async () => { await aspectRepository.create({ + ...testCtx, name: "Drive", description: "Fastener drive type", }); - const found = await aspectRepository.findByName({ name: "Drive" }); + const found = await aspectRepository.findByName({ + orgId: testCtx.orgId, + name: "Drive", + }); expect(found).not.toBeNull(); expect(found!.description).toBe("Fastener drive type"); }); it("returns null for nonexistent name", async () => { - const found = await aspectRepository.findByName({ name: "Nope" }); + const found = await aspectRepository.findByName({ + orgId: testCtx.orgId, + name: "Nope", + }); expect(found).toBeNull(); }); }); describe("list", () => { it("returns all aspects ordered by name", async () => { - await aspectRepository.create({ name: "Thread" }); - await aspectRepository.create({ name: "Drive" }); - await aspectRepository.create({ name: "Material" }); + await aspectRepository.create({ ...testCtx, name: "Thread" }); + await aspectRepository.create({ ...testCtx, name: "Drive" }); + await aspectRepository.create({ ...testCtx, name: "Material" }); - const all = await aspectRepository.list(); + const all = await aspectRepository.list({ orgId: testCtx.orgId }); expect(all).toHaveLength(3); expect(all[0].name).toBe("Drive"); expect(all[1].name).toBe("Material"); @@ -87,7 +111,7 @@ describe("aspectRepository", () => { }); it("returns empty array when none exist", async () => { - const all = await aspectRepository.list(); + const all = await aspectRepository.list({ orgId: testCtx.orgId }); expect(all).toHaveLength(0); }); }); @@ -95,11 +119,13 @@ describe("aspectRepository", () => { describe("update", () => { it("updates fields and returns updated record", async () => { const created = await aspectRepository.create({ + ...testCtx, name: "Thread", description: "Original", }); const updated = await aspectRepository.update({ + ...testCtx, id: created.id, description: "Threaded fastener characteristics", }); @@ -109,10 +135,19 @@ describe("aspectRepository", () => { }); it("logs a transaction", async () => { - const created = await aspectRepository.create({ name: "Thread" }); - await aspectRepository.update({ id: created.id, description: "Updated" }); + const created = await aspectRepository.create({ + ...testCtx, + name: "Thread", + }); + await aspectRepository.update({ + ...testCtx, + id: created.id, + description: "Updated", + }); - const txns = await transactionRepository.listRecent(); + const txns = await transactionRepository.listRecent({ + orgId: testCtx.orgId, + }); const updateTx = txns.find((t) => t.actionType === "aspect.update"); expect(updateTx).toBeDefined(); }); @@ -120,6 +155,7 @@ describe("aspectRepository", () => { it("throws for nonexistent aspect", async () => { await expect( aspectRepository.update({ + ...testCtx, id: "00000000-0000-0000-0000-000000000000", name: "Nope", }) @@ -129,38 +165,55 @@ describe("aspectRepository", () => { describe("remove", () => { it("deletes the aspect", async () => { - const created = await aspectRepository.create({ name: "Thread" }); - await aspectRepository.remove({ id: created.id }); + const created = await aspectRepository.create({ + ...testCtx, + name: "Thread", + }); + await aspectRepository.remove({ ...testCtx, id: created.id }); - const found = await aspectRepository.findById({ id: created.id }); + const found = await aspectRepository.findById({ + orgId: testCtx.orgId, + id: created.id, + }); expect(found).toBeNull(); }); it("cascades to aspect parameters", async () => { - const aspect = await aspectRepository.create({ name: "Thread" }); + const aspect = await aspectRepository.create({ + ...testCtx, + name: "Thread", + }); const pd = await parameterDefinitionRepository.create({ + ...testCtx, name: "Thread diameter", dataType: "numeric", unit: "mm", }); await aspectRepository.addParameter({ + ...testCtx, aspectId: aspect.id, parameterDefinitionId: pd.id, }); - await aspectRepository.remove({ id: aspect.id }); + await aspectRepository.remove({ ...testCtx, id: aspect.id }); const params = await aspectRepository.getParameters({ + orgId: testCtx.orgId, aspectId: aspect.id, }); expect(params).toHaveLength(0); }); it("logs a transaction", async () => { - const created = await aspectRepository.create({ name: "Thread" }); - await aspectRepository.remove({ id: created.id }); + const created = await aspectRepository.create({ + ...testCtx, + name: "Thread", + }); + await aspectRepository.remove({ ...testCtx, id: created.id }); - const txns = await transactionRepository.listRecent(); + const txns = await transactionRepository.listRecent({ + orgId: testCtx.orgId, + }); const deleteTx = txns.find((t) => t.actionType === "aspect.delete"); expect(deleteTx).toBeDefined(); expect(deleteTx!.afterState).toBeNull(); @@ -169,6 +222,7 @@ describe("aspectRepository", () => { it("throws for nonexistent aspect", async () => { await expect( aspectRepository.remove({ + ...testCtx, id: "00000000-0000-0000-0000-000000000000", }) ).rejects.toThrow("not found"); @@ -177,14 +231,19 @@ describe("aspectRepository", () => { describe("addParameter", () => { it("adds a parameter definition to an aspect", async () => { - const aspect = await aspectRepository.create({ name: "Thread" }); + const aspect = await aspectRepository.create({ + ...testCtx, + name: "Thread", + }); const pd = await parameterDefinitionRepository.create({ + ...testCtx, name: "Thread diameter", dataType: "numeric", unit: "mm", }); const ap = await aspectRepository.addParameter({ + ...testCtx, aspectId: aspect.id, parameterDefinitionId: pd.id, required: true, @@ -198,14 +257,19 @@ describe("aspectRepository", () => { }); it("adds with aspect-level default value", async () => { - const aspect = await aspectRepository.create({ name: "Thread" }); + const aspect = await aspectRepository.create({ + ...testCtx, + name: "Thread", + }); const pd = await parameterDefinitionRepository.create({ + ...testCtx, name: "Thread direction", dataType: "text", defaultValue: "right", }); const ap = await aspectRepository.addParameter({ + ...testCtx, aspectId: aspect.id, parameterDefinitionId: pd.id, defaultValue: "left", @@ -216,12 +280,14 @@ describe("aspectRepository", () => { it("throws for nonexistent aspect", async () => { const pd = await parameterDefinitionRepository.create({ + ...testCtx, name: "Length", dataType: "numeric", }); await expect( aspectRepository.addParameter({ + ...testCtx, aspectId: "00000000-0000-0000-0000-000000000000", parameterDefinitionId: pd.id, }) @@ -229,19 +295,25 @@ describe("aspectRepository", () => { }); it("rejects duplicate parameter on same aspect", async () => { - const aspect = await aspectRepository.create({ name: "Thread" }); + const aspect = await aspectRepository.create({ + ...testCtx, + name: "Thread", + }); const pd = await parameterDefinitionRepository.create({ + ...testCtx, name: "Thread diameter", dataType: "numeric", }); await aspectRepository.addParameter({ + ...testCtx, aspectId: aspect.id, parameterDefinitionId: pd.id, }); await expect( aspectRepository.addParameter({ + ...testCtx, aspectId: aspect.id, parameterDefinitionId: pd.id, }) @@ -251,23 +323,30 @@ describe("aspectRepository", () => { describe("removeParameter", () => { it("removes a parameter from an aspect", async () => { - const aspect = await aspectRepository.create({ name: "Thread" }); + const aspect = await aspectRepository.create({ + ...testCtx, + name: "Thread", + }); const pd = await parameterDefinitionRepository.create({ + ...testCtx, name: "Thread diameter", dataType: "numeric", }); await aspectRepository.addParameter({ + ...testCtx, aspectId: aspect.id, parameterDefinitionId: pd.id, }); await aspectRepository.removeParameter({ + orgId: testCtx.orgId, aspectId: aspect.id, parameterDefinitionId: pd.id, }); const params = await aspectRepository.getParameters({ + orgId: testCtx.orgId, aspectId: aspect.id, }); expect(params).toHaveLength(0); @@ -276,6 +355,7 @@ describe("aspectRepository", () => { it("throws when parameter not on aspect", async () => { await expect( aspectRepository.removeParameter({ + orgId: testCtx.orgId, aspectId: "00000000-0000-0000-0000-000000000000", parameterDefinitionId: "00000000-0000-0000-0000-000000000000", }) @@ -285,13 +365,18 @@ describe("aspectRepository", () => { describe("getParameters", () => { it("returns parameters with joined definition data", async () => { - const aspect = await aspectRepository.create({ name: "Thread" }); + const aspect = await aspectRepository.create({ + ...testCtx, + name: "Thread", + }); const pd1 = await parameterDefinitionRepository.create({ + ...testCtx, name: "Thread diameter", dataType: "numeric", unit: "mm", }); const pd2 = await parameterDefinitionRepository.create({ + ...testCtx, name: "Thread pitch", dataType: "numeric", unit: "mm", @@ -299,12 +384,14 @@ describe("aspectRepository", () => { }); await aspectRepository.addParameter({ + ...testCtx, aspectId: aspect.id, parameterDefinitionId: pd1.id, required: true, sortOrder: 1, }); await aspectRepository.addParameter({ + ...testCtx, aspectId: aspect.id, parameterDefinitionId: pd2.id, required: false, @@ -313,6 +400,7 @@ describe("aspectRepository", () => { }); const params = await aspectRepository.getParameters({ + orgId: testCtx.orgId, aspectId: aspect.id, }); @@ -328,28 +416,36 @@ describe("aspectRepository", () => { }); it("returns ordered by sortOrder", async () => { - const aspect = await aspectRepository.create({ name: "Thread" }); + const aspect = await aspectRepository.create({ + ...testCtx, + name: "Thread", + }); const pd1 = await parameterDefinitionRepository.create({ + ...testCtx, name: "Alpha", dataType: "text", }); const pd2 = await parameterDefinitionRepository.create({ + ...testCtx, name: "Beta", dataType: "text", }); await aspectRepository.addParameter({ + ...testCtx, aspectId: aspect.id, parameterDefinitionId: pd2.id, sortOrder: 1, }); await aspectRepository.addParameter({ + ...testCtx, aspectId: aspect.id, parameterDefinitionId: pd1.id, sortOrder: 2, }); const params = await aspectRepository.getParameters({ + orgId: testCtx.orgId, aspectId: aspect.id, }); expect(params[0].parameterName).toBe("Beta"); @@ -357,8 +453,12 @@ describe("aspectRepository", () => { }); it("returns empty array for aspect with no parameters", async () => { - const aspect = await aspectRepository.create({ name: "Empty" }); + const aspect = await aspectRepository.create({ + ...testCtx, + name: "Empty", + }); const params = await aspectRepository.getParameters({ + orgId: testCtx.orgId, aspectId: aspect.id, }); expect(params).toHaveLength(0); diff --git a/web/tests/repositories/assignmentRepository.isolation.test.ts b/web/tests/repositories/assignmentRepository.isolation.test.ts new file mode 100644 index 0000000..4d154d6 --- /dev/null +++ b/web/tests/repositories/assignmentRepository.isolation.test.ts @@ -0,0 +1,175 @@ +import { assignmentRepository } from "@/repositories/assignmentRepository"; +import { itemRepository } from "@/repositories/itemRepository"; +import { locationRepository } from "@/repositories/locationRepository"; +import { moduleRepository } from "@/repositories/moduleRepository"; +import { testCtx, createTestOrg } from "../setup"; + +async function seedLocation(ctx: { userId: string; orgId: string }, label: string) { + const mod = await moduleRepository.create({ + ...ctx, + name: `mod-${ctx.orgId}-${label}`, + primaryDimensionLabel: "level", + primaryDimensionCount: 1, + }); + return locationRepository.create({ + ...ctx, + moduleId: mod.id, + label, + pathSegments: [`mod-${ctx.orgId}`, label], + locationType: "leaf", + }); +} + +describe("assignmentRepository isolation", () => { + it("list-style reads are scoped: org A cannot see org B's assignments", async () => { + const b = await createTestOrg({ slug: "other-org" }); + + // One global item shared across orgs + const item = await itemRepository.create({ + ...testCtx, + asGlobal: true, + name: "Shared Item", + }); + + const aLoc = await seedLocation(testCtx, "A1"); + const bLoc = await seedLocation(b, "B1"); + + await assignmentRepository.create({ + ...testCtx, + itemId: item.id, + locationId: aLoc.id, + assignmentType: "provisional", + }); + await assignmentRepository.create({ + ...b, + itemId: item.id, + locationId: bLoc.id, + assignmentType: "provisional", + }); + + const aList = await assignmentRepository.listProvisional({ + orgId: testCtx.orgId, + }); + const bList = await assignmentRepository.listProvisional({ orgId: b.orgId }); + + expect(aList).toHaveLength(1); + expect(aList[0].locationId).toBe(aLoc.id); + + expect(bList).toHaveLength(1); + expect(bList[0].locationId).toBe(bLoc.id); + }); + + it("findById is scoped: org A cannot fetch org B's assignment", async () => { + const b = await createTestOrg({ slug: "other-org" }); + + const item = await itemRepository.create({ + ...b, + asGlobal: true, + name: "B Item", + }); + const bLoc = await seedLocation(b, "B1"); + + const bAssignment = await assignmentRepository.create({ + ...b, + itemId: item.id, + locationId: bLoc.id, + assignmentType: "placed", + }); + + const fromA = await assignmentRepository.findById({ + orgId: testCtx.orgId, + id: bAssignment.id, + }); + expect(fromA).toBeNull(); + + const fromB = await assignmentRepository.findById({ + orgId: b.orgId, + id: bAssignment.id, + }); + expect(fromB?.id).toBe(bAssignment.id); + }); + + it("update (move) on another org's assignment throws not-found", async () => { + const b = await createTestOrg({ slug: "other-org" }); + + const item = await itemRepository.create({ + ...b, + asGlobal: true, + name: "B Item", + }); + const bLoc1 = await seedLocation(b, "B1"); + const bLoc2 = await seedLocation(b, "B2"); + + const bAssignment = await assignmentRepository.create({ + ...b, + itemId: item.id, + locationId: bLoc1.id, + assignmentType: "placed", + }); + + await expect( + assignmentRepository.move({ + ...testCtx, + id: bAssignment.id, + newLocationId: bLoc2.id, + }), + ).rejects.toThrow("not found"); + + // Remove on another org's assignment also throws not-found. + await expect( + assignmentRepository.remove({ ...testCtx, id: bAssignment.id }), + ).rejects.toThrow("not found"); + + // B can still see it. + const stillThere = await assignmentRepository.findById({ + orgId: b.orgId, + id: bAssignment.id, + }); + expect(stillThere).not.toBeNull(); + }); + + it("a global item can be assigned independently in each org", async () => { + const b = await createTestOrg({ slug: "other-org" }); + + // Single global item + const item = await itemRepository.create({ + ...testCtx, + asGlobal: true, + name: "Globally Visible", + }); + + const aLoc = await seedLocation(testCtx, "A1"); + const bLoc = await seedLocation(b, "B1"); + + const aAssignment = await assignmentRepository.create({ + ...testCtx, + itemId: item.id, + locationId: aLoc.id, + assignmentType: "placed", + }); + const bAssignment = await assignmentRepository.create({ + ...b, + itemId: item.id, + locationId: bLoc.id, + assignmentType: "placed", + }); + + expect(aAssignment.ownerOrgId).toBe(testCtx.orgId); + expect(bAssignment.ownerOrgId).toBe(b.orgId); + + const aByItem = await assignmentRepository.findByItemId({ + orgId: testCtx.orgId, + itemId: item.id, + }); + const bByItem = await assignmentRepository.findByItemId({ + orgId: b.orgId, + itemId: item.id, + }); + + // Each org only sees its own assignment of the shared item. + expect(aByItem).toHaveLength(1); + expect(aByItem[0].id).toBe(aAssignment.id); + expect(bByItem).toHaveLength(1); + expect(bByItem[0].id).toBe(bAssignment.id); + }); +}); diff --git a/web/tests/repositories/assignmentRepository.test.ts b/web/tests/repositories/assignmentRepository.test.ts index 778359f..3e77c6e 100644 --- a/web/tests/repositories/assignmentRepository.test.ts +++ b/web/tests/repositories/assignmentRepository.test.ts @@ -3,10 +3,12 @@ import { itemRepository } from "@/repositories/itemRepository"; import { assignmentRepository } from "@/repositories/assignmentRepository"; import { locationRepository } from "@/repositories/locationRepository"; import { transactionRepository } from "@/repositories/transactionRepository"; +import { testCtx } from "../setup"; async function createTestModule() { return moduleRepository.create({ - name: `MOD-${Date.now()}`, + ...testCtx, + name: `MOD-${Date.now()}-${Math.random()}`, primaryDimensionLabel: "level", primaryDimensionCount: 5, }); @@ -14,6 +16,7 @@ async function createTestModule() { async function createTestLocation(moduleId: string, label: string) { return locationRepository.create({ + ...testCtx, moduleId, label, pathSegments: ["TEST", label], @@ -22,7 +25,9 @@ async function createTestLocation(moduleId: string, label: string) { } async function createTestItem(name: string) { - return itemRepository.create({ name }); + // Items are additive (global catalog). Seed as global so they're + // visible across orgs — matches the catalog model. + return itemRepository.create({ ...testCtx, asGlobal: true, name }); } describe("assignmentRepository", () => { @@ -33,6 +38,7 @@ describe("assignmentRepository", () => { const item = await createTestItem("Resistor Pack"); const assignment = await assignmentRepository.create({ + ...testCtx, itemId: item.id, locationId: loc.id, assignmentType: "placed", @@ -42,6 +48,7 @@ describe("assignmentRepository", () => { expect(assignment.itemId).toBe(item.id); expect(assignment.locationId).toBe(loc.id); expect(assignment.assignmentType).toBe("placed"); + expect(assignment.ownerOrgId).toBe(testCtx.orgId); }); it("creates a provisional assignment", async () => { @@ -50,6 +57,7 @@ describe("assignmentRepository", () => { const item = await createTestItem("Capacitor Pack"); const assignment = await assignmentRepository.create({ + ...testCtx, itemId: item.id, locationId: loc.id, assignmentType: "provisional", @@ -64,12 +72,15 @@ describe("assignmentRepository", () => { const item = await createTestItem("LED Strip"); const assignment = await assignmentRepository.create({ + ...testCtx, itemId: item.id, locationId: loc.id, assignmentType: "placed", }); - const txns = await transactionRepository.listRecent(); + const txns = await transactionRepository.listRecent({ + orgId: testCtx.orgId, + }); const createTx = txns.find( (t) => t.actionType === "assignment.create" && @@ -78,6 +89,8 @@ describe("assignmentRepository", () => { expect(createTx).toBeDefined(); expect(createTx!.entityType).toBe("assignment"); expect(createTx!.beforeState).toBeNull(); + expect(createTx!.actorUserId).toBe(testCtx.userId); + expect(createTx!.ownerOrgId).toBe(testCtx.orgId); }); it("blocks second placed assignment at same location without co-storability", async () => { @@ -87,6 +100,7 @@ describe("assignmentRepository", () => { const item2 = await createTestItem("Item B"); await assignmentRepository.create({ + ...testCtx, itemId: item1.id, locationId: loc.id, assignmentType: "placed", @@ -94,6 +108,7 @@ describe("assignmentRepository", () => { await expect( assignmentRepository.create({ + ...testCtx, itemId: item2.id, locationId: loc.id, assignmentType: "placed", @@ -110,18 +125,21 @@ describe("assignmentRepository", () => { const item2 = await createTestItem("M3 Nut"); await itemRepository.addCoStorability({ + ...testCtx, itemAId: item1.id, itemBId: item2.id, reason: "Same thread size", }); await assignmentRepository.create({ + ...testCtx, itemId: item1.id, locationId: loc.id, assignmentType: "placed", }); const second = await assignmentRepository.create({ + ...testCtx, itemId: item2.id, locationId: loc.id, assignmentType: "placed", @@ -138,12 +156,14 @@ describe("assignmentRepository", () => { const item2 = await createTestItem("Widget B"); const a1 = await assignmentRepository.create({ + ...testCtx, itemId: item1.id, locationId: loc.id, assignmentType: "provisional", }); const a2 = await assignmentRepository.create({ + ...testCtx, itemId: item2.id, locationId: loc.id, assignmentType: "provisional", @@ -161,18 +181,23 @@ describe("assignmentRepository", () => { const item = await createTestItem("Screw"); const created = await assignmentRepository.create({ + ...testCtx, itemId: item.id, locationId: loc.id, assignmentType: "placed", }); - const found = await assignmentRepository.findById({ id: created.id }); + const found = await assignmentRepository.findById({ + orgId: testCtx.orgId, + id: created.id, + }); expect(found).not.toBeNull(); expect(found!.itemId).toBe(item.id); }); it("returns null for nonexistent ID", async () => { const found = await assignmentRepository.findById({ + orgId: testCtx.orgId, id: "00000000-0000-0000-0000-000000000000", }); expect(found).toBeNull(); @@ -187,17 +212,20 @@ describe("assignmentRepository", () => { const item = await createTestItem("Washer"); await assignmentRepository.create({ + ...testCtx, itemId: item.id, locationId: loc1.id, assignmentType: "placed", }); await assignmentRepository.create({ + ...testCtx, itemId: item.id, locationId: loc2.id, assignmentType: "provisional", }); const results = await assignmentRepository.findByItemId({ + orgId: testCtx.orgId, itemId: item.id, }); expect(results).toHaveLength(2); @@ -212,22 +240,26 @@ describe("assignmentRepository", () => { const item2 = await createTestItem("Nut"); await itemRepository.addCoStorability({ + ...testCtx, itemAId: item1.id, itemBId: item2.id, }); await assignmentRepository.create({ + ...testCtx, itemId: item1.id, locationId: loc.id, assignmentType: "placed", }); await assignmentRepository.create({ + ...testCtx, itemId: item2.id, locationId: loc.id, assignmentType: "placed", }); const results = await assignmentRepository.findByLocationId({ + orgId: testCtx.orgId, locationId: loc.id, }); expect(results).toHaveLength(2); @@ -242,12 +274,14 @@ describe("assignmentRepository", () => { const item = await createTestItem("Diode"); const provisional = await assignmentRepository.create({ + ...testCtx, itemId: item.id, locationId: loc1.id, assignmentType: "provisional", }); const placed = await assignmentRepository.convertToPlaced({ + ...testCtx, id: provisional.id, locationId: loc2.id, }); @@ -262,17 +296,21 @@ describe("assignmentRepository", () => { const item = await createTestItem("Transistor"); const provisional = await assignmentRepository.create({ + ...testCtx, itemId: item.id, locationId: loc.id, assignmentType: "provisional", }); await assignmentRepository.convertToPlaced({ + ...testCtx, id: provisional.id, locationId: loc.id, }); - const txns = await transactionRepository.listRecent(); + const txns = await transactionRepository.listRecent({ + orgId: testCtx.orgId, + }); const convertTx = txns.find( (t) => t.actionType === "assignment.convertToPlaced", ); @@ -284,6 +322,7 @@ describe("assignmentRepository", () => { it("throws for nonexistent assignment", async () => { await expect( assignmentRepository.convertToPlaced({ + ...testCtx, id: "00000000-0000-0000-0000-000000000000", locationId: "00000000-0000-0000-0000-000000000000", }), @@ -299,12 +338,14 @@ describe("assignmentRepository", () => { const item = await createTestItem("Relay"); const assignment = await assignmentRepository.create({ + ...testCtx, itemId: item.id, locationId: loc1.id, assignmentType: "placed", }); const moved = await assignmentRepository.move({ + ...testCtx, id: assignment.id, newLocationId: loc2.id, }); @@ -319,17 +360,21 @@ describe("assignmentRepository", () => { const item = await createTestItem("Fuse"); const assignment = await assignmentRepository.create({ + ...testCtx, itemId: item.id, locationId: loc1.id, assignmentType: "placed", }); await assignmentRepository.move({ + ...testCtx, id: assignment.id, newLocationId: loc2.id, }); - const txns = await transactionRepository.listRecent(); + const txns = await transactionRepository.listRecent({ + orgId: testCtx.orgId, + }); const moveTx = txns.find((t) => t.actionType === "assignment.move"); expect(moveTx).toBeDefined(); expect(moveTx!.beforeState).toBeTruthy(); @@ -344,12 +389,14 @@ describe("assignmentRepository", () => { const item2 = await createTestItem("Part Y"); await assignmentRepository.create({ + ...testCtx, itemId: item1.id, locationId: loc2.id, assignmentType: "placed", }); const assignment = await assignmentRepository.create({ + ...testCtx, itemId: item2.id, locationId: loc1.id, assignmentType: "placed", @@ -357,6 +404,7 @@ describe("assignmentRepository", () => { await expect( assignmentRepository.move({ + ...testCtx, id: assignment.id, newLocationId: loc2.id, }), @@ -371,14 +419,19 @@ describe("assignmentRepository", () => { const item = await createTestItem("Connector"); const assignment = await assignmentRepository.create({ + ...testCtx, itemId: item.id, locationId: loc.id, assignmentType: "placed", }); - await assignmentRepository.remove({ id: assignment.id }); + await assignmentRepository.remove({ + ...testCtx, + id: assignment.id, + }); const found = await assignmentRepository.findById({ + orgId: testCtx.orgId, id: assignment.id, }); expect(found).toBeNull(); @@ -390,14 +443,20 @@ describe("assignmentRepository", () => { const item = await createTestItem("Switch"); const assignment = await assignmentRepository.create({ + ...testCtx, itemId: item.id, locationId: loc.id, assignmentType: "placed", }); - await assignmentRepository.remove({ id: assignment.id }); + await assignmentRepository.remove({ + ...testCtx, + id: assignment.id, + }); - const txns = await transactionRepository.listRecent(); + const txns = await transactionRepository.listRecent({ + orgId: testCtx.orgId, + }); const deleteTx = txns.find( (t) => t.actionType === "assignment.delete", ); @@ -408,6 +467,7 @@ describe("assignmentRepository", () => { it("throws for nonexistent assignment", async () => { await expect( assignmentRepository.remove({ + ...testCtx, id: "00000000-0000-0000-0000-000000000000", }), ).rejects.toThrow("not found"); @@ -424,22 +484,27 @@ describe("assignmentRepository", () => { const item3 = await createTestItem("Sensor C"); await assignmentRepository.create({ + ...testCtx, itemId: item1.id, locationId: loc1.id, assignmentType: "provisional", }); await assignmentRepository.create({ + ...testCtx, itemId: item2.id, locationId: loc2.id, assignmentType: "provisional", }); await assignmentRepository.create({ + ...testCtx, itemId: item3.id, locationId: loc1.id, assignmentType: "placed", }); - const provisionals = await assignmentRepository.listProvisional(); + const provisionals = await assignmentRepository.listProvisional({ + orgId: testCtx.orgId, + }); expect(provisionals).toHaveLength(2); expect(provisionals.every((a) => a.assignmentType === "provisional")).toBe( true, @@ -447,7 +512,9 @@ describe("assignmentRepository", () => { }); it("returns empty array when no provisional assignments exist", async () => { - const provisionals = await assignmentRepository.listProvisional(); + const provisionals = await assignmentRepository.listProvisional({ + orgId: testCtx.orgId, + }); expect(provisionals).toHaveLength(0); }); }); diff --git a/web/tests/repositories/categoryRepository.isolation.test.ts b/web/tests/repositories/categoryRepository.isolation.test.ts new file mode 100644 index 0000000..5098254 --- /dev/null +++ b/web/tests/repositories/categoryRepository.isolation.test.ts @@ -0,0 +1,68 @@ +import { categoryRepository } from "@/repositories/categoryRepository"; +import { testCtx, createTestOrg } from "../setup"; + +describe("categoryRepository isolation (additive)", () => { + it("list returns global + own, not other org's private", async () => { + const b = await createTestOrg({ slug: "other-org" }); + + await categoryRepository.create({ + ...testCtx, + asGlobal: true, + name: "Global Cat", + }); + await categoryRepository.create({ + ...testCtx, + name: "A Private", + }); + await categoryRepository.create({ + ...b, + name: "B Private", + }); + + const aNames = (await categoryRepository.list({ orgId: testCtx.orgId })) + .map((c) => c.name) + .sort(); + const bNames = (await categoryRepository.list({ orgId: b.orgId })) + .map((c) => c.name) + .sort(); + + expect(aNames).toEqual(["A Private", "Global Cat"]); + expect(bNames).toEqual(["B Private", "Global Cat"]); + }); + + it("org A cannot fetch org B's private category by id", async () => { + const b = await createTestOrg({ slug: "other-org" }); + const bCat = await categoryRepository.create({ + ...b, + name: "B Private", + }); + + const fromA = await categoryRepository.findById({ + orgId: testCtx.orgId, + id: bCat.id, + }); + expect(fromA).toBeNull(); + + const fromB = await categoryRepository.findById({ + orgId: b.orgId, + id: bCat.id, + }); + expect(fromB?.id).toBe(bCat.id); + }); + + it("update on another org's private category throws not-found", async () => { + const b = await createTestOrg({ slug: "other-org" }); + const bCat = await categoryRepository.create({ + ...b, + name: "B Private", + }); + + await expect( + categoryRepository.update({ + ...testCtx, + id: bCat.id, + icon: "hijack", + }), + ).rejects.toThrow("not found"); + }); +}); diff --git a/web/tests/repositories/categoryRepository.test.ts b/web/tests/repositories/categoryRepository.test.ts index 6449024..72f4fa6 100644 --- a/web/tests/repositories/categoryRepository.test.ts +++ b/web/tests/repositories/categoryRepository.test.ts @@ -1,10 +1,12 @@ import { categoryRepository } from "@/repositories/categoryRepository"; import { transactionRepository } from "@/repositories/transactionRepository"; +import { testCtx } from "../setup"; describe("categoryRepository", () => { describe("create", () => { it("creates a category with all fields", async () => { const cat = await categoryRepository.create({ + ...testCtx, name: "Fasteners", icon: "screw", color: "#4488cc", @@ -20,7 +22,10 @@ describe("categoryRepository", () => { }); it("creates with minimal fields", async () => { - const cat = await categoryRepository.create({ name: "Electronics" }); + const cat = await categoryRepository.create({ + ...testCtx, + name: "Electronics", + }); expect(cat.name).toBe("Electronics"); expect(cat.icon).toBeNull(); @@ -29,32 +34,44 @@ describe("categoryRepository", () => { }); it("logs a transaction", async () => { - const cat = await categoryRepository.create({ name: "Fasteners" }); + const cat = await categoryRepository.create({ + ...testCtx, + name: "Fasteners", + }); - const txns = await transactionRepository.listRecent(); + const txns = await transactionRepository.listRecent({ + orgId: testCtx.orgId, + }); expect(txns).toHaveLength(1); expect(txns[0].actionType).toBe("category.create"); expect(txns[0].entityId).toBe(cat.id); }); it("rejects duplicate names", async () => { - await categoryRepository.create({ name: "Fasteners" }); + await categoryRepository.create({ ...testCtx, name: "Fasteners" }); await expect( - categoryRepository.create({ name: "Fasteners" }) + categoryRepository.create({ ...testCtx, name: "Fasteners" }) ).rejects.toThrow(); }); }); describe("findById", () => { it("returns the category by ID", async () => { - const created = await categoryRepository.create({ name: "Fasteners" }); - const found = await categoryRepository.findById({ id: created.id }); + const created = await categoryRepository.create({ + ...testCtx, + name: "Fasteners", + }); + const found = await categoryRepository.findById({ + orgId: testCtx.orgId, + id: created.id, + }); expect(found).not.toBeNull(); expect(found!.name).toBe("Fasteners"); }); it("returns null for nonexistent ID", async () => { const found = await categoryRepository.findById({ + orgId: testCtx.orgId, id: "00000000-0000-0000-0000-000000000000", }); expect(found).toBeNull(); @@ -63,25 +80,43 @@ describe("categoryRepository", () => { describe("findByName", () => { it("returns the category by name", async () => { - await categoryRepository.create({ name: "Electronics" }); - const found = await categoryRepository.findByName({ name: "Electronics" }); + await categoryRepository.create({ ...testCtx, name: "Electronics" }); + const found = await categoryRepository.findByName({ + orgId: testCtx.orgId, + name: "Electronics", + }); expect(found).not.toBeNull(); expect(found!.name).toBe("Electronics"); }); it("returns null for nonexistent name", async () => { - const found = await categoryRepository.findByName({ name: "Nope" }); + const found = await categoryRepository.findByName({ + orgId: testCtx.orgId, + name: "Nope", + }); expect(found).toBeNull(); }); }); describe("list", () => { it("returns all categories ordered by sortOrder", async () => { - await categoryRepository.create({ name: "Zippers", sortOrder: 2 }); - await categoryRepository.create({ name: "Adhesives", sortOrder: 0 }); - await categoryRepository.create({ name: "Fasteners", sortOrder: 1 }); + await categoryRepository.create({ + ...testCtx, + name: "Zippers", + sortOrder: 2, + }); + await categoryRepository.create({ + ...testCtx, + name: "Adhesives", + sortOrder: 0, + }); + await categoryRepository.create({ + ...testCtx, + name: "Fasteners", + sortOrder: 1, + }); - const all = await categoryRepository.list(); + const all = await categoryRepository.list({ orgId: testCtx.orgId }); expect(all).toHaveLength(3); expect(all[0].name).toBe("Adhesives"); expect(all[1].name).toBe("Fasteners"); @@ -89,7 +124,7 @@ describe("categoryRepository", () => { }); it("returns empty array when none exist", async () => { - const all = await categoryRepository.list(); + const all = await categoryRepository.list({ orgId: testCtx.orgId }); expect(all).toHaveLength(0); }); }); @@ -97,11 +132,13 @@ describe("categoryRepository", () => { describe("update", () => { it("updates fields and returns updated record", async () => { const created = await categoryRepository.create({ + ...testCtx, name: "Fasteners", icon: "bolt", }); const updated = await categoryRepository.update({ + ...testCtx, id: created.id, icon: "screw", color: "#ff0000", @@ -113,10 +150,19 @@ describe("categoryRepository", () => { }); it("logs a transaction with before and after", async () => { - const created = await categoryRepository.create({ name: "Fasteners" }); - await categoryRepository.update({ id: created.id, icon: "screw" }); + const created = await categoryRepository.create({ + ...testCtx, + name: "Fasteners", + }); + await categoryRepository.update({ + ...testCtx, + id: created.id, + icon: "screw", + }); - const txns = await transactionRepository.listRecent(); + const txns = await transactionRepository.listRecent({ + orgId: testCtx.orgId, + }); const updateTx = txns.find((t) => t.actionType === "category.update"); expect(updateTx).toBeDefined(); expect(updateTx!.beforeState).toBeTruthy(); @@ -126,6 +172,7 @@ describe("categoryRepository", () => { it("throws for nonexistent category", async () => { await expect( categoryRepository.update({ + ...testCtx, id: "00000000-0000-0000-0000-000000000000", name: "Nope", }) @@ -135,18 +182,29 @@ describe("categoryRepository", () => { describe("remove", () => { it("deletes the category", async () => { - const created = await categoryRepository.create({ name: "Fasteners" }); - await categoryRepository.remove({ id: created.id }); + const created = await categoryRepository.create({ + ...testCtx, + name: "Fasteners", + }); + await categoryRepository.remove({ ...testCtx, id: created.id }); - const found = await categoryRepository.findById({ id: created.id }); + const found = await categoryRepository.findById({ + orgId: testCtx.orgId, + id: created.id, + }); expect(found).toBeNull(); }); it("logs a transaction", async () => { - const created = await categoryRepository.create({ name: "Fasteners" }); - await categoryRepository.remove({ id: created.id }); + const created = await categoryRepository.create({ + ...testCtx, + name: "Fasteners", + }); + await categoryRepository.remove({ ...testCtx, id: created.id }); - const txns = await transactionRepository.listRecent(); + const txns = await transactionRepository.listRecent({ + orgId: testCtx.orgId, + }); const deleteTx = txns.find((t) => t.actionType === "category.delete"); expect(deleteTx).toBeDefined(); expect(deleteTx!.afterState).toBeNull(); @@ -155,6 +213,7 @@ describe("categoryRepository", () => { it("throws for nonexistent category", async () => { await expect( categoryRepository.remove({ + ...testCtx, id: "00000000-0000-0000-0000-000000000000", }) ).rejects.toThrow("not found"); diff --git a/web/tests/repositories/insertRepository.isolation.test.ts b/web/tests/repositories/insertRepository.isolation.test.ts new file mode 100644 index 0000000..0287102 --- /dev/null +++ b/web/tests/repositories/insertRepository.isolation.test.ts @@ -0,0 +1,106 @@ +import { insertRepository } from "@/repositories/insertRepository"; +import { locationRepository } from "@/repositories/locationRepository"; +import { moduleRepository } from "@/repositories/moduleRepository"; +import { testCtx, createTestOrg } from "../setup"; + +describe("insertRepository isolation", () => { + it("org A cannot see org B's unplaced inserts", async () => { + const b = await createTestOrg({ slug: "other-org" }); + + await insertRepository.create({ ...testCtx, name: "A-tray" }); + await insertRepository.create({ ...b, name: "B-tray" }); + + const aList = await insertRepository.listUnplaced({ + orgId: testCtx.orgId, + }); + const bList = await insertRepository.listUnplaced({ orgId: b.orgId }); + + expect(aList.map((i) => i.name)).toEqual(["A-tray"]); + expect(bList.map((i) => i.name)).toEqual(["B-tray"]); + }); + + it("findById is scoped: org A cannot fetch org B's insert", async () => { + const b = await createTestOrg({ slug: "other-org" }); + + const bInsert = await insertRepository.create({ ...b, name: "B-tray" }); + + const fromA = await insertRepository.findById({ + orgId: testCtx.orgId, + id: bInsert.id, + }); + expect(fromA).toBeNull(); + + const fromB = await insertRepository.findById({ + orgId: b.orgId, + id: bInsert.id, + }); + expect(fromB?.id).toBe(bInsert.id); + }); + + it("update on another org's insert throws not-found", async () => { + const b = await createTestOrg({ slug: "other-org" }); + + const bInsert = await insertRepository.create({ ...b, name: "B-tray" }); + + await expect( + insertRepository.update({ + ...testCtx, + id: bInsert.id, + name: "hijack", + }), + ).rejects.toThrow("not found"); + }); + + it("remove on another org's insert throws not-found", async () => { + const b = await createTestOrg({ slug: "other-org" }); + + const bInsert = await insertRepository.create({ ...b, name: "B-tray" }); + + await expect( + insertRepository.remove({ ...testCtx, id: bInsert.id }), + ).rejects.toThrow("not found"); + + // B can still see it. + const stillThere = await insertRepository.findById({ + orgId: b.orgId, + id: bInsert.id, + }); + expect(stillThere).not.toBeNull(); + }); + + it("listWithDetails is scoped: only own-org inserts are returned", async () => { + const b = await createTestOrg({ slug: "other-org" }); + + // Org A: one unplaced insert + await insertRepository.create({ ...testCtx, name: "A-only" }); + + // Org B: one placed insert + const bModule = await moduleRepository.create({ + ...b, + name: "B-mod", + primaryDimensionLabel: "level", + primaryDimensionCount: 1, + }); + const bLoc = await locationRepository.create({ + ...b, + moduleId: bModule.id, + label: "1", + pathSegments: ["B-mod", "1"], + locationType: "receptacle", + }); + const bInsert = await insertRepository.create({ ...b, name: "B-only" }); + await insertRepository.place({ + ...b, + id: bInsert.id, + locationId: bLoc.id, + }); + + const aRows = await insertRepository.listWithDetails({ + orgId: testCtx.orgId, + }); + const bRows = await insertRepository.listWithDetails({ orgId: b.orgId }); + + expect(aRows.map((r) => r.name)).toEqual(["A-only"]); + expect(bRows.map((r) => r.name)).toEqual(["B-only"]); + }); +}); diff --git a/web/tests/repositories/insertRepository.test.ts b/web/tests/repositories/insertRepository.test.ts index d59060a..10cd13f 100644 --- a/web/tests/repositories/insertRepository.test.ts +++ b/web/tests/repositories/insertRepository.test.ts @@ -4,9 +4,11 @@ import { locationRepository } from "@/repositories/locationRepository"; import { moduleRepository } from "@/repositories/moduleRepository"; import { templateRepository } from "@/repositories/templateRepository"; import { transactionRepository } from "@/repositories/transactionRepository"; +import { testCtx } from "../setup"; async function createTestModule() { return moduleRepository.create({ + ...testCtx, name: "MUSE", primaryDimensionLabel: "level", primaryDimensionCount: 11, @@ -18,6 +20,7 @@ async function createReceptacleLocation( interfacesAcceptedIds?: string[] ) { return locationRepository.create({ + ...testCtx, moduleId, label: "A1", pathSegments: ["MUSE", "3", "A1"], @@ -29,14 +32,16 @@ async function createReceptacleLocation( async function createTemplateProviding(identifiers: string[]) { const ifaces = await Promise.all( identifiers.map((id) => - interfaceTypeRepository.create({ identifier: id }), + interfaceTypeRepository.create({ ...testCtx, identifier: id }), ), ); const template = await templateRepository.create({ + ...testCtx, name: `tmpl-${identifiers.join("-")}-${Math.random()}`, interfacesProvidedIds: ifaces.map((i) => i.id), }); const version = await templateRepository.getVersion({ + orgId: testCtx.orgId, templateId: template.id, version: 1, }); @@ -47,16 +52,19 @@ describe("insertRepository", () => { describe("create", () => { it("creates an unplaced insert", async () => { const insert = await insertRepository.create({ + ...testCtx, name: "Resistor tray", }); expect(insert.id).toBeDefined(); expect(insert.name).toBe("Resistor tray"); expect(insert.locationId).toBeNull(); + expect(insert.ownerOrgId).toBe(testCtx.orgId); }); it("creates an insert with parametric dimensions", async () => { const insert = await insertRepository.create({ + ...testCtx, name: "Grid bin", rows: 2, columns: 3, @@ -68,43 +76,57 @@ describe("insertRepository", () => { it("stores metadata and overrides as JSON", async () => { const insert = await insertRepository.create({ + ...testCtx, name: "Custom tray", overrides: { removedDividers: [1, 3] }, metadata: { color: "blue" }, }); - const found = await insertRepository.findById({ id: insert.id }); + const found = await insertRepository.findById({ + orgId: testCtx.orgId, + id: insert.id, + }); expect(found?.overrides).toEqual({ removedDividers: [1, 3] }); expect(found?.metadata).toEqual({ color: "blue" }); }); it("logs a transaction", async () => { const insert = await insertRepository.create({ + ...testCtx, name: "Test insert", }); - const txns = await transactionRepository.listRecent(); + const txns = await transactionRepository.listRecent({ + orgId: testCtx.orgId, + }); const createTx = txns.find((t) => t.actionType === "insert.create"); expect(createTx).toBeDefined(); expect(createTx!.entityType).toBe("insert"); expect(createTx!.entityId).toBe(insert.id); expect(createTx!.beforeState).toBeNull(); + expect(createTx!.actorUserId).toBe(testCtx.userId); + expect(createTx!.ownerOrgId).toBe(testCtx.orgId); }); }); describe("findById", () => { it("returns the insert by ID", async () => { const created = await insertRepository.create({ + ...testCtx, name: "Test insert", }); - const found = await insertRepository.findById({ id: created.id }); + const found = await insertRepository.findById({ + orgId: testCtx.orgId, + id: created.id, + }); expect(found).not.toBeNull(); expect(found!.name).toBe("Test insert"); }); it("returns null for nonexistent ID", async () => { const found = await insertRepository.findById({ + orgId: testCtx.orgId, id: "00000000-0000-0000-0000-000000000000", }); expect(found).toBeNull(); @@ -115,9 +137,13 @@ describe("insertRepository", () => { it("places an insert into a receptacle location", async () => { const module = await createTestModule(); const location = await createReceptacleLocation(module.id); - const insert = await insertRepository.create({ name: "Tray" }); + const insert = await insertRepository.create({ + ...testCtx, + name: "Tray", + }); const placed = await insertRepository.place({ + ...testCtx, id: insert.id, locationId: location.id, }); @@ -132,12 +158,14 @@ describe("insertRepository", () => { ]); const location = await createReceptacleLocation(module.id, [ifaces[0].id]); const insert = await insertRepository.create({ + ...testCtx, name: "Tray", templateId: template.id, templateVersionId: version.id, }); const placed = await insertRepository.place({ + ...testCtx, id: insert.id, locationId: location.id, }); @@ -148,12 +176,17 @@ describe("insertRepository", () => { it("succeeds when insert has no interface type (template provides none)", async () => { const module = await createTestModule(); const plano = await interfaceTypeRepository.create({ + ...testCtx, identifier: "plano-3600", }); const location = await createReceptacleLocation(module.id, [plano.id]); - const insert = await insertRepository.create({ name: "Generic tray" }); + const insert = await insertRepository.create({ + ...testCtx, + name: "Generic tray", + }); const placed = await insertRepository.place({ + ...testCtx, id: insert.id, locationId: location.id, }); @@ -168,12 +201,14 @@ describe("insertRepository", () => { ]); const location = await createReceptacleLocation(module.id); const insert = await insertRepository.create({ + ...testCtx, name: "Tray", templateId: template.id, templateVersionId: version.id, }); const placed = await insertRepository.place({ + ...testCtx, id: insert.id, locationId: location.id, }); @@ -184,6 +219,7 @@ describe("insertRepository", () => { it("fails when interface types do not match", async () => { const module = await createTestModule(); const plano = await interfaceTypeRepository.create({ + ...testCtx, identifier: "plano-3600", }); const { template, version } = await createTemplateProviding([ @@ -191,6 +227,7 @@ describe("insertRepository", () => { ]); const location = await createReceptacleLocation(module.id, [plano.id]); const insert = await insertRepository.create({ + ...testCtx, name: "Tray", templateId: template.id, templateVersionId: version.id, @@ -198,6 +235,7 @@ describe("insertRepository", () => { await expect( insertRepository.place({ + ...testCtx, id: insert.id, locationId: location.id, }) @@ -207,15 +245,20 @@ describe("insertRepository", () => { it("fails when location is not a receptacle", async () => { const module = await createTestModule(); const location = await locationRepository.create({ + ...testCtx, moduleId: module.id, label: "3", pathSegments: ["MUSE", "3"], locationType: "fixed", }); - const insert = await insertRepository.create({ name: "Tray" }); + const insert = await insertRepository.create({ + ...testCtx, + name: "Tray", + }); await expect( insertRepository.place({ + ...testCtx, id: insert.id, locationId: location.id, }) @@ -223,10 +266,14 @@ describe("insertRepository", () => { }); it("fails when location does not exist", async () => { - const insert = await insertRepository.create({ name: "Tray" }); + const insert = await insertRepository.create({ + ...testCtx, + name: "Tray", + }); await expect( insertRepository.place({ + ...testCtx, id: insert.id, locationId: "00000000-0000-0000-0000-000000000000", }) @@ -239,6 +286,7 @@ describe("insertRepository", () => { await expect( insertRepository.place({ + ...testCtx, id: "00000000-0000-0000-0000-000000000000", locationId: location.id, }) @@ -248,14 +296,20 @@ describe("insertRepository", () => { it("logs a transaction", async () => { const module = await createTestModule(); const location = await createReceptacleLocation(module.id); - const insert = await insertRepository.create({ name: "Tray" }); + const insert = await insertRepository.create({ + ...testCtx, + name: "Tray", + }); await insertRepository.place({ + ...testCtx, id: insert.id, locationId: location.id, }); - const txns = await transactionRepository.listRecent(); + const txns = await transactionRepository.listRecent({ + orgId: testCtx.orgId, + }); const placeTx = txns.find((t) => t.actionType === "insert.place"); expect(placeTx).toBeDefined(); expect(placeTx!.entityId).toBe(insert.id); @@ -266,13 +320,18 @@ describe("insertRepository", () => { it("unplaces an insert", async () => { const module = await createTestModule(); const location = await createReceptacleLocation(module.id); - const insert = await insertRepository.create({ name: "Tray" }); + const insert = await insertRepository.create({ + ...testCtx, + name: "Tray", + }); await insertRepository.place({ + ...testCtx, id: insert.id, locationId: location.id, }); const unplaced = await insertRepository.removeFromLocation({ + ...testCtx, id: insert.id, }); @@ -282,15 +341,24 @@ describe("insertRepository", () => { it("logs a transaction", async () => { const module = await createTestModule(); const location = await createReceptacleLocation(module.id); - const insert = await insertRepository.create({ name: "Tray" }); + const insert = await insertRepository.create({ + ...testCtx, + name: "Tray", + }); await insertRepository.place({ + ...testCtx, id: insert.id, locationId: location.id, }); - await insertRepository.removeFromLocation({ id: insert.id }); + await insertRepository.removeFromLocation({ + ...testCtx, + id: insert.id, + }); - const txns = await transactionRepository.listRecent(); + const txns = await transactionRepository.listRecent({ + orgId: testCtx.orgId, + }); const removeTx = txns.find( (t) => t.actionType === "insert.removeFromLocation" ); @@ -302,13 +370,18 @@ describe("insertRepository", () => { it("returns the insert at a location", async () => { const module = await createTestModule(); const location = await createReceptacleLocation(module.id); - const insert = await insertRepository.create({ name: "Tray" }); + const insert = await insertRepository.create({ + ...testCtx, + name: "Tray", + }); await insertRepository.place({ + ...testCtx, id: insert.id, locationId: location.id, }); const found = await insertRepository.findByLocationId({ + orgId: testCtx.orgId, locationId: location.id, }); expect(found).not.toBeNull(); @@ -320,6 +393,7 @@ describe("insertRepository", () => { const location = await createReceptacleLocation(module.id); const found = await insertRepository.findByLocationId({ + orgId: testCtx.orgId, locationId: location.id, }); expect(found).toBeNull(); @@ -331,15 +405,27 @@ describe("insertRepository", () => { const module = await createTestModule(); const location = await createReceptacleLocation(module.id); - const placed = await insertRepository.create({ name: "Placed tray" }); + const placed = await insertRepository.create({ + ...testCtx, + name: "Placed tray", + }); await insertRepository.place({ + ...testCtx, id: placed.id, locationId: location.id, }); - await insertRepository.create({ name: "Unplaced tray 1" }); - await insertRepository.create({ name: "Unplaced tray 2" }); + await insertRepository.create({ + ...testCtx, + name: "Unplaced tray 1", + }); + await insertRepository.create({ + ...testCtx, + name: "Unplaced tray 2", + }); - const unplaced = await insertRepository.listUnplaced(); + const unplaced = await insertRepository.listUnplaced({ + orgId: testCtx.orgId, + }); expect(unplaced).toHaveLength(2); expect(unplaced.every((i) => i.locationId === null)).toBe(true); }); @@ -347,13 +433,19 @@ describe("insertRepository", () => { it("returns empty array when all inserts are placed", async () => { const module = await createTestModule(); const location = await createReceptacleLocation(module.id); - const insert = await insertRepository.create({ name: "Tray" }); + const insert = await insertRepository.create({ + ...testCtx, + name: "Tray", + }); await insertRepository.place({ + ...testCtx, id: insert.id, locationId: location.id, }); - const unplaced = await insertRepository.listUnplaced(); + const unplaced = await insertRepository.listUnplaced({ + orgId: testCtx.orgId, + }); expect(unplaced).toHaveLength(0); }); }); @@ -361,10 +453,12 @@ describe("insertRepository", () => { describe("update", () => { it("updates fields and returns the updated insert", async () => { const created = await insertRepository.create({ + ...testCtx, name: "Old name", }); const updated = await insertRepository.update({ + ...testCtx, id: created.id, name: "New name", }); @@ -374,15 +468,19 @@ describe("insertRepository", () => { it("logs a transaction with before and after state", async () => { const created = await insertRepository.create({ + ...testCtx, name: "Test insert", }); await insertRepository.update({ + ...testCtx, id: created.id, name: "Updated", }); - const txns = await transactionRepository.listRecent(); + const txns = await transactionRepository.listRecent({ + orgId: testCtx.orgId, + }); const updateTx = txns.find((t) => t.actionType === "insert.update"); expect(updateTx).toBeDefined(); expect(updateTx!.beforeState).toBeTruthy(); @@ -392,6 +490,7 @@ describe("insertRepository", () => { it("throws for nonexistent insert", async () => { await expect( insertRepository.update({ + ...testCtx, id: "00000000-0000-0000-0000-000000000000", name: "GHOST", }) @@ -402,23 +501,30 @@ describe("insertRepository", () => { describe("remove", () => { it("deletes the insert", async () => { const created = await insertRepository.create({ + ...testCtx, name: "Test insert", }); - await insertRepository.remove({ id: created.id }); + await insertRepository.remove({ ...testCtx, id: created.id }); - const found = await insertRepository.findById({ id: created.id }); + const found = await insertRepository.findById({ + orgId: testCtx.orgId, + id: created.id, + }); expect(found).toBeNull(); }); it("logs a transaction", async () => { const created = await insertRepository.create({ + ...testCtx, name: "Test insert", }); - await insertRepository.remove({ id: created.id }); + await insertRepository.remove({ ...testCtx, id: created.id }); - const txns = await transactionRepository.listRecent(); + const txns = await transactionRepository.listRecent({ + orgId: testCtx.orgId, + }); const deleteTx = txns.find((t) => t.actionType === "insert.delete"); expect(deleteTx).toBeDefined(); expect(deleteTx!.afterState).toBeNull(); @@ -427,6 +533,7 @@ describe("insertRepository", () => { it("throws for nonexistent insert", async () => { await expect( insertRepository.remove({ + ...testCtx, id: "00000000-0000-0000-0000-000000000000", }) ).rejects.toThrow("not found"); @@ -436,28 +543,39 @@ describe("insertRepository", () => { describe("cells travel with insert (IN-3 structural correctness)", () => { it("re-parents cells when insert moves; overrides persist", async () => { const mod = await moduleRepository.create({ + ...testCtx, name: "MUSE", primaryDimensionLabel: "level", primaryDimensionCount: 11, }); const levelA = await locationRepository.create({ + ...testCtx, moduleId: mod.id, label: "1", pathSegments: ["MUSE", "1"], locationType: "receptacle", }); const levelB = await locationRepository.create({ + ...testCtx, moduleId: mod.id, label: "2", pathSegments: ["MUSE", "2"], locationType: "receptacle", }); - const insert = await insertRepository.create({ name: "Construction screws" }); - await insertRepository.place({ id: insert.id, locationId: levelA.id }); + const insert = await insertRepository.create({ + ...testCtx, + name: "Construction screws", + }); + await insertRepository.place({ + ...testCtx, + id: insert.id, + locationId: levelA.id, + }); // Create a cell "A3" inside the insert at level 1 const cell = await locationRepository.create({ + ...testCtx, moduleId: mod.id, parentId: levelA.id, label: "A3", @@ -469,13 +587,27 @@ describe("insertRepository", () => { }); // Disable the cell — this is an override on the cell row - await locationRepository.disable({ id: cell.id, reason: "cracked" }); + await locationRepository.disable({ + ...testCtx, + id: cell.id, + reason: "cracked", + }); // Move insert from level 1 to level 2 - await insertRepository.removeFromLocation({ id: insert.id }); - await insertRepository.place({ id: insert.id, locationId: levelB.id }); + await insertRepository.removeFromLocation({ + ...testCtx, + id: insert.id, + }); + await insertRepository.place({ + ...testCtx, + id: insert.id, + locationId: levelB.id, + }); - const moved = await locationRepository.findById({ id: cell.id }); + const moved = await locationRepository.findById({ + orgId: testCtx.orgId, + id: cell.id, + }); expect(moved).not.toBeNull(); expect(moved!.insertId).toBe(insert.id); expect(moved!.parentId).toBe(levelB.id); @@ -487,42 +619,62 @@ describe("insertRepository", () => { it("refuses to place into a receptacle that already holds another insert", async () => { const mod = await moduleRepository.create({ + ...testCtx, name: "MUSE", primaryDimensionLabel: "level", primaryDimensionCount: 11, }); const level = await locationRepository.create({ + ...testCtx, moduleId: mod.id, label: "1", pathSegments: ["MUSE", "1"], locationType: "receptacle", }); - const a = await insertRepository.create({ name: "first" }); - const b = await insertRepository.create({ name: "second" }); - await insertRepository.place({ id: a.id, locationId: level.id }); + const a = await insertRepository.create({ ...testCtx, name: "first" }); + const b = await insertRepository.create({ ...testCtx, name: "second" }); + await insertRepository.place({ + ...testCtx, + id: a.id, + locationId: level.id, + }); await expect( - insertRepository.place({ id: b.id, locationId: level.id }) + insertRepository.place({ + ...testCtx, + id: b.id, + locationId: level.id, + }) ).rejects.toThrow(/already holds/); }); it("unplace leaves cells with insert_id set, parent_id null, path = cell label", async () => { const mod = await moduleRepository.create({ + ...testCtx, name: "MUSE", primaryDimensionLabel: "level", primaryDimensionCount: 11, }); const level = await locationRepository.create({ + ...testCtx, moduleId: mod.id, label: "1", pathSegments: ["MUSE", "1"], locationType: "receptacle", }); - const insert = await insertRepository.create({ name: "orphan" }); - await insertRepository.place({ id: insert.id, locationId: level.id }); + const insert = await insertRepository.create({ + ...testCtx, + name: "orphan", + }); + await insertRepository.place({ + ...testCtx, + id: insert.id, + locationId: level.id, + }); const cell = await locationRepository.create({ + ...testCtx, moduleId: mod.id, parentId: level.id, label: "A1", @@ -531,9 +683,15 @@ describe("insertRepository", () => { insertId: insert.id, }); - await insertRepository.removeFromLocation({ id: insert.id }); + await insertRepository.removeFromLocation({ + ...testCtx, + id: insert.id, + }); - const unplaced = await locationRepository.findById({ id: cell.id }); + const unplaced = await locationRepository.findById({ + orgId: testCtx.orgId, + id: cell.id, + }); expect(unplaced!.insertId).toBe(insert.id); expect(unplaced!.parentId).toBeNull(); expect(unplaced!.path).toBe("A1"); @@ -541,14 +699,17 @@ describe("insertRepository", () => { it("materializes cells at creation time (unplaced, module null, path = label)", async () => { const template = await templateRepository.create({ + ...testCtx, name: "Plano 3600 test", }); const version = await templateRepository.getVersion({ + orgId: testCtx.orgId, templateId: template.id, version: 1, }); const insert = await insertRepository.create({ + ...testCtx, name: "fresh", templateId: template.id, templateVersionId: version!.id, @@ -569,6 +730,7 @@ describe("insertRepository", () => { expect(cell.insertId).toBe(insert.id); expect(cell.moduleId).toBeNull(); expect(cell.parentId).toBeNull(); + expect(cell.ownerOrgId).toBe(testCtx.orgId); // Path is just the cell label pre-placement expect(cell.path).toBe(cell.label); } @@ -576,18 +738,22 @@ describe("insertRepository", () => { it("place fills in moduleId + parentId + path on an unplaced insert", async () => { const template = await templateRepository.create({ + ...testCtx, name: "Plano 3600 test", }); const version = await templateRepository.getVersion({ + orgId: testCtx.orgId, templateId: template.id, version: 1, }); const mod = await moduleRepository.create({ + ...testCtx, name: "MUSE", primaryDimensionLabel: "level", primaryDimensionCount: 11, }); const level = await locationRepository.create({ + ...testCtx, moduleId: mod.id, label: "1", pathSegments: ["MUSE", "1"], @@ -595,15 +761,23 @@ describe("insertRepository", () => { }); const insert = await insertRepository.create({ + ...testCtx, name: "fresh", templateId: template.id, templateVersionId: version!.id, rows: 2, columns: 3, }); - await insertRepository.place({ id: insert.id, locationId: level.id }); + await insertRepository.place({ + ...testCtx, + id: insert.id, + locationId: level.id, + }); - const cells = await locationRepository.findChildren({ parentId: level.id }); + const cells = await locationRepository.findChildren({ + orgId: testCtx.orgId, + parentId: level.id, + }); expect(cells).toHaveLength(6); for (const cell of cells) { expect(cell.insertId).toBe(insert.id); @@ -615,20 +789,30 @@ describe("insertRepository", () => { it("deleting the insert cascades its cells", async () => { const mod = await moduleRepository.create({ + ...testCtx, name: "MUSE", primaryDimensionLabel: "level", primaryDimensionCount: 11, }); const level = await locationRepository.create({ + ...testCtx, moduleId: mod.id, label: "1", pathSegments: ["MUSE", "1"], locationType: "receptacle", }); - const insert = await insertRepository.create({ name: "to delete" }); - await insertRepository.place({ id: insert.id, locationId: level.id }); + const insert = await insertRepository.create({ + ...testCtx, + name: "to delete", + }); + await insertRepository.place({ + ...testCtx, + id: insert.id, + locationId: level.id, + }); const cell = await locationRepository.create({ + ...testCtx, moduleId: mod.id, parentId: level.id, label: "A1", @@ -637,9 +821,12 @@ describe("insertRepository", () => { insertId: insert.id, }); - await insertRepository.remove({ id: insert.id }); + await insertRepository.remove({ ...testCtx, id: insert.id }); - const gone = await locationRepository.findById({ id: cell.id }); + const gone = await locationRepository.findById({ + orgId: testCtx.orgId, + id: cell.id, + }); expect(gone).toBeNull(); }); }); @@ -647,37 +834,49 @@ describe("insertRepository", () => { describe("listWithDetails", () => { it("returns inserts with template + location + module joined", async () => { const template = await templateRepository.create({ + ...testCtx, name: "Plano 3600", }); const version = await templateRepository.getVersion({ + orgId: testCtx.orgId, templateId: template.id, version: 1, }); const mod = await moduleRepository.create({ + ...testCtx, name: "MUSE", primaryDimensionLabel: "level", primaryDimensionCount: 3, }); const level = await locationRepository.create({ + ...testCtx, moduleId: mod.id, label: "1", pathSegments: ["MUSE", "1"], locationType: "receptacle", }); const placed = await insertRepository.create({ + ...testCtx, name: "Plano #1", templateId: template.id, templateVersionId: version!.id, }); - await insertRepository.place({ id: placed.id, locationId: level.id }); + await insertRepository.place({ + ...testCtx, + id: placed.id, + locationId: level.id, + }); await insertRepository.create({ + ...testCtx, name: "Plano #2 (on shelf)", templateId: template.id, templateVersionId: version!.id, }); - const all = await insertRepository.listWithDetails(); + const all = await insertRepository.listWithDetails({ + orgId: testCtx.orgId, + }); expect(all).toHaveLength(2); const p1 = all.find((i) => i.name === "Plano #1")!; expect(p1.templateName).toBe("Plano 3600"); @@ -685,18 +884,21 @@ describe("insertRepository", () => { expect(p1.moduleName).toBe("MUSE"); const unplacedList = await insertRepository.listWithDetails({ + orgId: testCtx.orgId, placement: "unplaced", }); expect(unplacedList).toHaveLength(1); expect(unplacedList[0].name).toBe("Plano #2 (on shelf)"); const placedList = await insertRepository.listWithDetails({ + orgId: testCtx.orgId, placement: "placed", }); expect(placedList).toHaveLength(1); expect(placedList[0].name).toBe("Plano #1"); const filteredByTemplate = await insertRepository.listWithDetails({ + orgId: testCtx.orgId, templateId: template.id, }); expect(filteredByTemplate).toHaveLength(2); diff --git a/web/tests/repositories/interfaceTypeRepository.isolation.test.ts b/web/tests/repositories/interfaceTypeRepository.isolation.test.ts new file mode 100644 index 0000000..13b0930 --- /dev/null +++ b/web/tests/repositories/interfaceTypeRepository.isolation.test.ts @@ -0,0 +1,99 @@ +import { interfaceTypeRepository } from "@/repositories/interfaceTypeRepository"; +import { testCtx, createTestOrg } from "../setup"; + +describe("interfaceTypeRepository isolation (additive)", () => { + it("list returns global + own, not other org's private", async () => { + const b = await createTestOrg({ slug: "other-org" }); + + await interfaceTypeRepository.create({ + ...testCtx, + asGlobal: true, + identifier: "global-plano", + description: "Global", + }); + await interfaceTypeRepository.create({ + ...testCtx, + identifier: "a-custom", + description: "A private", + }); + await interfaceTypeRepository.create({ + ...b, + identifier: "b-custom", + description: "B private", + }); + + const aIdents = (await interfaceTypeRepository.list({ orgId: testCtx.orgId })) + .map((i) => i.identifier) + .sort(); + const bIdents = (await interfaceTypeRepository.list({ orgId: b.orgId })) + .map((i) => i.identifier) + .sort(); + + expect(aIdents).toEqual(["a-custom", "global-plano"]); + expect(bIdents).toEqual(["b-custom", "global-plano"]); + }); + + it("org A cannot fetch org B's private interface by id", async () => { + const b = await createTestOrg({ slug: "other-org" }); + const bIface = await interfaceTypeRepository.create({ + ...b, + identifier: "b-only", + }); + + const fromA = await interfaceTypeRepository.findById({ + orgId: testCtx.orgId, + id: bIface.id, + }); + expect(fromA).toBeNull(); + }); + + it("findByIdentifier is scoped to org + globals", async () => { + const b = await createTestOrg({ slug: "other-org" }); + await interfaceTypeRepository.create({ + ...b, + identifier: "b-only", + }); + + const fromA = await interfaceTypeRepository.findByIdentifier({ + orgId: testCtx.orgId, + identifier: "b-only", + }); + expect(fromA).toBeNull(); + }); + + it("update on another org's private interface throws not-found", async () => { + const b = await createTestOrg({ slug: "other-org" }); + const bIface = await interfaceTypeRepository.create({ + ...b, + identifier: "b-only", + }); + + await expect( + interfaceTypeRepository.update({ + ...testCtx, + id: bIface.id, + description: "hijack", + }), + ).rejects.toThrow("not found"); + }); + + it("merge cannot reach another org's private interfaces", async () => { + const b = await createTestOrg({ slug: "other-org" }); + const bSource = await interfaceTypeRepository.create({ + ...b, + identifier: "b-source", + }); + const aTarget = await interfaceTypeRepository.create({ + ...testCtx, + identifier: "a-target", + }); + + await expect( + interfaceTypeRepository.merge({ + ...testCtx, + sourceIds: [bSource.id], + targetId: aTarget.id, + }), + ).rejects.toThrow("not found"); + }); +}); diff --git a/web/tests/repositories/interfaceTypeRepository.test.ts b/web/tests/repositories/interfaceTypeRepository.test.ts index 5eb6b7d..761270c 100644 --- a/web/tests/repositories/interfaceTypeRepository.test.ts +++ b/web/tests/repositories/interfaceTypeRepository.test.ts @@ -11,11 +11,13 @@ import { locationInterfacesAccepted, modules, } from "@/db/schema"; +import { testCtx } from "../setup"; describe("interfaceTypeRepository", () => { describe("create", () => { it("creates an interface type and returns it", async () => { const it = await interfaceTypeRepository.create({ + ...testCtx, identifier: "plano-3600", description: "Plano Stowaway 3600 series tray slot", }); @@ -28,6 +30,7 @@ describe("interfaceTypeRepository", () => { it("creates with physicalContract as JSON", async () => { const it = await interfaceTypeRepository.create({ + ...testCtx, identifier: "gridfinity-42mm", description: "Gridfinity 42mm baseplate cell", physicalContract: { @@ -37,7 +40,10 @@ describe("interfaceTypeRepository", () => { }, }); - const found = await interfaceTypeRepository.findById({ id: it.id }); + const found = await interfaceTypeRepository.findById({ + orgId: testCtx.orgId, + id: it.id, + }); expect(found?.physicalContract).toEqual({ cellSize: "42mm", height: "7mm", @@ -47,6 +53,7 @@ describe("interfaceTypeRepository", () => { it("creates with minimal fields", async () => { const it = await interfaceTypeRepository.create({ + ...testCtx, identifier: "custom-slot", }); @@ -57,10 +64,11 @@ describe("interfaceTypeRepository", () => { it("logs a transaction", async () => { const it = await interfaceTypeRepository.create({ + ...testCtx, identifier: "plano-3600", }); - const txns = await transactionRepository.listRecent(); + const txns = await transactionRepository.listRecent({ orgId: testCtx.orgId }); expect(txns).toHaveLength(1); expect(txns[0].actionType).toBe("interfaceType.create"); expect(txns[0].entityType).toBe("interfaceType"); @@ -70,11 +78,13 @@ describe("interfaceTypeRepository", () => { it("rejects duplicate identifiers", async () => { await interfaceTypeRepository.create({ + ...testCtx, identifier: "plano-3600", }); await expect( interfaceTypeRepository.create({ + ...testCtx, identifier: "plano-3600", }) ).rejects.toThrow(); @@ -84,17 +94,22 @@ describe("interfaceTypeRepository", () => { describe("findById", () => { it("returns the interface type by ID", async () => { const created = await interfaceTypeRepository.create({ + ...testCtx, identifier: "plano-3600", description: "Plano slot", }); - const found = await interfaceTypeRepository.findById({ id: created.id }); + const found = await interfaceTypeRepository.findById({ + orgId: testCtx.orgId, + id: created.id, + }); expect(found).not.toBeNull(); expect(found!.identifier).toBe("plano-3600"); }); it("returns null for nonexistent ID", async () => { const found = await interfaceTypeRepository.findById({ + orgId: testCtx.orgId, id: "00000000-0000-0000-0000-000000000000", }); expect(found).toBeNull(); @@ -104,11 +119,13 @@ describe("interfaceTypeRepository", () => { describe("findByIdentifier", () => { it("returns the interface type by identifier", async () => { await interfaceTypeRepository.create({ + ...testCtx, identifier: "gridfinity-42mm", description: "Gridfinity cell", }); const found = await interfaceTypeRepository.findByIdentifier({ + orgId: testCtx.orgId, identifier: "gridfinity-42mm", }); expect(found).not.toBeNull(); @@ -117,6 +134,7 @@ describe("interfaceTypeRepository", () => { it("returns null for nonexistent identifier", async () => { const found = await interfaceTypeRepository.findByIdentifier({ + orgId: testCtx.orgId, identifier: "does-not-exist", }); expect(found).toBeNull(); @@ -125,15 +143,21 @@ describe("interfaceTypeRepository", () => { describe("list", () => { it("returns all interface types", async () => { - await interfaceTypeRepository.create({ identifier: "plano-3600" }); - await interfaceTypeRepository.create({ identifier: "gridfinity-42mm" }); + await interfaceTypeRepository.create({ + ...testCtx, + identifier: "plano-3600", + }); + await interfaceTypeRepository.create({ + ...testCtx, + identifier: "gridfinity-42mm", + }); - const all = await interfaceTypeRepository.list(); + const all = await interfaceTypeRepository.list({ orgId: testCtx.orgId }); expect(all).toHaveLength(2); }); it("returns empty array when none exist", async () => { - const all = await interfaceTypeRepository.list(); + const all = await interfaceTypeRepository.list({ orgId: testCtx.orgId }); expect(all).toHaveLength(0); }); }); @@ -141,11 +165,13 @@ describe("interfaceTypeRepository", () => { describe("update", () => { it("updates description and returns the updated record", async () => { const created = await interfaceTypeRepository.create({ + ...testCtx, identifier: "plano-3600", description: "Original", }); const updated = await interfaceTypeRepository.update({ + ...testCtx, id: created.id, description: "Updated description", }); @@ -156,10 +182,12 @@ describe("interfaceTypeRepository", () => { it("updates physicalContract", async () => { const created = await interfaceTypeRepository.create({ + ...testCtx, identifier: "gridfinity-42mm", }); const updated = await interfaceTypeRepository.update({ + ...testCtx, id: created.id, physicalContract: { cellSize: "42mm", depth: "50mm" }, }); @@ -172,15 +200,17 @@ describe("interfaceTypeRepository", () => { it("logs a transaction with before and after state", async () => { const created = await interfaceTypeRepository.create({ + ...testCtx, identifier: "plano-3600", }); await interfaceTypeRepository.update({ + ...testCtx, id: created.id, description: "Updated", }); - const txns = await transactionRepository.listRecent(); + const txns = await transactionRepository.listRecent({ orgId: testCtx.orgId }); const updateTx = txns.find( (t) => t.actionType === "interfaceType.update" ); @@ -192,6 +222,7 @@ describe("interfaceTypeRepository", () => { it("throws for nonexistent interface type", async () => { await expect( interfaceTypeRepository.update({ + ...testCtx, id: "00000000-0000-0000-0000-000000000000", description: "Nope", }) @@ -202,25 +233,30 @@ describe("interfaceTypeRepository", () => { describe("remove", () => { it("deletes an archived, unused interface type", async () => { const created = await interfaceTypeRepository.create({ + ...testCtx, identifier: "plano-3600", }); - await interfaceTypeRepository.archive({ id: created.id }); + await interfaceTypeRepository.archive({ ...testCtx, id: created.id }); - await interfaceTypeRepository.remove({ id: created.id }); + await interfaceTypeRepository.remove({ ...testCtx, id: created.id }); - const found = await interfaceTypeRepository.findById({ id: created.id }); + const found = await interfaceTypeRepository.findById({ + orgId: testCtx.orgId, + id: created.id, + }); expect(found).toBeNull(); }); it("logs a transaction", async () => { const created = await interfaceTypeRepository.create({ + ...testCtx, identifier: "plano-3600", }); - await interfaceTypeRepository.archive({ id: created.id }); + await interfaceTypeRepository.archive({ ...testCtx, id: created.id }); - await interfaceTypeRepository.remove({ id: created.id }); + await interfaceTypeRepository.remove({ ...testCtx, id: created.id }); - const txns = await transactionRepository.listRecent(); + const txns = await transactionRepository.listRecent({ orgId: testCtx.orgId }); const deleteTx = txns.find( (t) => t.actionType === "interfaceType.delete" ); @@ -231,6 +267,7 @@ describe("interfaceTypeRepository", () => { it("throws for nonexistent interface type", async () => { await expect( interfaceTypeRepository.remove({ + ...testCtx, id: "00000000-0000-0000-0000-000000000000", }) ).rejects.toThrow("not found"); @@ -238,25 +275,27 @@ describe("interfaceTypeRepository", () => { it("refuses to delete an active (non-archived) type", async () => { const created = await interfaceTypeRepository.create({ + ...testCtx, identifier: "plano-3600", }); await expect( - interfaceTypeRepository.remove({ id: created.id }) + interfaceTypeRepository.remove({ ...testCtx, id: created.id }) ).rejects.toThrow(/archive/i); }); it("refuses to delete an archived type with usage", async () => { const created = await interfaceTypeRepository.create({ + ...testCtx, identifier: "gridfinity-42mm", }); const { templateVersionId } = await seedTemplateWithProvidedInterface( created.id ); expect(templateVersionId).toBeDefined(); - await interfaceTypeRepository.archive({ id: created.id }); + await interfaceTypeRepository.archive({ ...testCtx, id: created.id }); await expect( - interfaceTypeRepository.remove({ id: created.id }) + interfaceTypeRepository.remove({ ...testCtx, id: created.id }) ).rejects.toThrow(/usage|in use/i); }); }); @@ -265,6 +304,7 @@ describe("interfaceTypeRepository", () => { describe("create — maturity", () => { it("defaults maturity to 'stable'", async () => { const it = await interfaceTypeRepository.create({ + ...testCtx, identifier: "plano-3600", }); expect(it.maturity).toBe("stable"); @@ -272,6 +312,7 @@ describe("interfaceTypeRepository", () => { it("accepts maturity='draft'", async () => { const it = await interfaceTypeRepository.create({ + ...testCtx, identifier: "gridfinity-48mm", maturity: "draft", }); @@ -281,6 +322,7 @@ describe("interfaceTypeRepository", () => { it("rejects invalid maturity via DB check", async () => { await expect( interfaceTypeRepository.create({ + ...testCtx, identifier: "weird", // @ts-expect-error invalid value on purpose maturity: "invalid", @@ -290,6 +332,7 @@ describe("interfaceTypeRepository", () => { it("persists unitSystem as JSON", async () => { const it = await interfaceTypeRepository.create({ + ...testCtx, identifier: "gridfinity-42mm", unitSystem: { width: { label: "u", mm: 42 }, @@ -297,7 +340,10 @@ describe("interfaceTypeRepository", () => { height: { label: "h", mm: 7 }, }, }); - const found = await interfaceTypeRepository.findById({ id: it.id }); + const found = await interfaceTypeRepository.findById({ + orgId: testCtx.orgId, + id: it.id, + }); expect(found?.unitSystem).toEqual({ width: { label: "u", mm: 42 }, depth: { label: "u", mm: 42 }, @@ -309,30 +355,43 @@ describe("interfaceTypeRepository", () => { // ───────────── list with filter ───────────── describe("list — filter", () => { it("defaults to all (active + archived)", async () => { - const a = await interfaceTypeRepository.create({ identifier: "a" }); - await interfaceTypeRepository.create({ identifier: "b" }); - await interfaceTypeRepository.archive({ id: a.id }); + const a = await interfaceTypeRepository.create({ + ...testCtx, + identifier: "a", + }); + await interfaceTypeRepository.create({ ...testCtx, identifier: "b" }); + await interfaceTypeRepository.archive({ ...testCtx, id: a.id }); - const all = await interfaceTypeRepository.list(); + const all = await interfaceTypeRepository.list({ orgId: testCtx.orgId }); expect(all).toHaveLength(2); }); it("filters to active only", async () => { - const a = await interfaceTypeRepository.create({ identifier: "a" }); - await interfaceTypeRepository.create({ identifier: "b" }); - await interfaceTypeRepository.archive({ id: a.id }); + const a = await interfaceTypeRepository.create({ + ...testCtx, + identifier: "a", + }); + await interfaceTypeRepository.create({ ...testCtx, identifier: "b" }); + await interfaceTypeRepository.archive({ ...testCtx, id: a.id }); - const active = await interfaceTypeRepository.list({ status: "active" }); + const active = await interfaceTypeRepository.list({ + orgId: testCtx.orgId, + status: "active", + }); expect(active).toHaveLength(1); expect(active[0].identifier).toBe("b"); }); it("filters to archived only", async () => { - const a = await interfaceTypeRepository.create({ identifier: "a" }); - await interfaceTypeRepository.create({ identifier: "b" }); - await interfaceTypeRepository.archive({ id: a.id }); + const a = await interfaceTypeRepository.create({ + ...testCtx, + identifier: "a", + }); + await interfaceTypeRepository.create({ ...testCtx, identifier: "b" }); + await interfaceTypeRepository.archive({ ...testCtx, id: a.id }); const archived = await interfaceTypeRepository.list({ + orgId: testCtx.orgId, status: "archived", }); expect(archived).toHaveLength(1); @@ -344,11 +403,13 @@ describe("interfaceTypeRepository", () => { describe("archive / unarchive", () => { it("archive sets archivedAt", async () => { const created = await interfaceTypeRepository.create({ + ...testCtx, identifier: "plano-3600", }); expect(created.archivedAt).toBeNull(); const archived = await interfaceTypeRepository.archive({ + ...testCtx, id: created.id, }); expect(archived.archivedAt).toBeInstanceOf(Date); @@ -356,11 +417,13 @@ describe("interfaceTypeRepository", () => { it("unarchive clears archivedAt", async () => { const created = await interfaceTypeRepository.create({ + ...testCtx, identifier: "plano-3600", }); - await interfaceTypeRepository.archive({ id: created.id }); + await interfaceTypeRepository.archive({ ...testCtx, id: created.id }); const restored = await interfaceTypeRepository.unarchive({ + ...testCtx, id: created.id, }); expect(restored.archivedAt).toBeNull(); @@ -368,31 +431,40 @@ describe("interfaceTypeRepository", () => { it("archive logs a transaction", async () => { const created = await interfaceTypeRepository.create({ + ...testCtx, identifier: "plano-3600", }); - await interfaceTypeRepository.archive({ id: created.id }); - const txns = await transactionRepository.listRecent(); + await interfaceTypeRepository.archive({ ...testCtx, id: created.id }); + const txns = await transactionRepository.listRecent({ orgId: testCtx.orgId }); const tx = txns.find((t) => t.actionType === "interfaceType.archive"); expect(tx).toBeDefined(); }); it("unarchive logs a transaction", async () => { const created = await interfaceTypeRepository.create({ + ...testCtx, identifier: "plano-3600", }); - await interfaceTypeRepository.archive({ id: created.id }); - await interfaceTypeRepository.unarchive({ id: created.id }); - const txns = await transactionRepository.listRecent(); + await interfaceTypeRepository.archive({ ...testCtx, id: created.id }); + await interfaceTypeRepository.unarchive({ ...testCtx, id: created.id }); + const txns = await transactionRepository.listRecent({ orgId: testCtx.orgId }); const tx = txns.find((t) => t.actionType === "interfaceType.unarchive"); expect(tx).toBeDefined(); }); it("archive is idempotent on an already-archived type", async () => { const created = await interfaceTypeRepository.create({ + ...testCtx, identifier: "plano-3600", }); - const first = await interfaceTypeRepository.archive({ id: created.id }); - const again = await interfaceTypeRepository.archive({ id: created.id }); + const first = await interfaceTypeRepository.archive({ + ...testCtx, + id: created.id, + }); + const again = await interfaceTypeRepository.archive({ + ...testCtx, + id: created.id, + }); expect(again.archivedAt).toEqual(first.archivedAt); }); }); @@ -401,10 +473,12 @@ describe("interfaceTypeRepository", () => { describe("update — maturity guard", () => { it("promotes draft → stable", async () => { const created = await interfaceTypeRepository.create({ + ...testCtx, identifier: "gridfinity-48mm", maturity: "draft", }); const updated = await interfaceTypeRepository.update({ + ...testCtx, id: created.id, maturity: "stable", }); @@ -413,11 +487,13 @@ describe("interfaceTypeRepository", () => { it("refuses to demote stable → draft", async () => { const created = await interfaceTypeRepository.create({ + ...testCtx, identifier: "plano-3600", maturity: "stable", }); await expect( interfaceTypeRepository.update({ + ...testCtx, id: created.id, maturity: "draft", }) @@ -426,9 +502,11 @@ describe("interfaceTypeRepository", () => { it("allows updating identifier (slug rename)", async () => { const created = await interfaceTypeRepository.create({ + ...testCtx, identifier: "plano-3600", }); const updated = await interfaceTypeRepository.update({ + ...testCtx, id: created.id, identifier: "plano-3600-renamed", }); @@ -437,9 +515,11 @@ describe("interfaceTypeRepository", () => { it("allows updating unitSystem", async () => { const created = await interfaceTypeRepository.create({ + ...testCtx, identifier: "gridfinity-42mm", }); const updated = await interfaceTypeRepository.update({ + ...testCtx, id: created.id, unitSystem: { width: { label: "u", mm: 42 }, @@ -457,9 +537,11 @@ describe("interfaceTypeRepository", () => { describe("merge", () => { it("rewrites template-version provided junctions from source to target", async () => { const source = await interfaceTypeRepository.create({ + ...testCtx, identifier: "src-iface", }); const target = await interfaceTypeRepository.create({ + ...testCtx, identifier: "tgt-iface", }); const { templateVersionId } = await seedTemplateWithProvidedInterface( @@ -467,6 +549,7 @@ describe("interfaceTypeRepository", () => { ); const result = await interfaceTypeRepository.merge({ + ...testCtx, sourceIds: [source.id], targetId: target.id, }); @@ -495,14 +578,17 @@ describe("interfaceTypeRepository", () => { it("rewrites template-version accepted junctions from source to target", async () => { const source = await interfaceTypeRepository.create({ + ...testCtx, identifier: "src-iface", }); const target = await interfaceTypeRepository.create({ + ...testCtx, identifier: "tgt-iface", }); await seedTemplateWithAcceptedInterface(source.id); await interfaceTypeRepository.merge({ + ...testCtx, sourceIds: [source.id], targetId: target.id, }); @@ -518,15 +604,18 @@ describe("interfaceTypeRepository", () => { it("rewrites location junctions from source to target", async () => { const source = await interfaceTypeRepository.create({ + ...testCtx, identifier: "src-iface", }); const target = await interfaceTypeRepository.create({ + ...testCtx, identifier: "tgt-iface", }); await seedLocationAcceptingInterface(source.id); await seedLocationAcceptingInterface(source.id); await interfaceTypeRepository.merge({ + ...testCtx, sourceIds: [source.id], targetId: target.id, }); @@ -546,27 +635,35 @@ describe("interfaceTypeRepository", () => { it("deletes source interface types after merge", async () => { const source = await interfaceTypeRepository.create({ + ...testCtx, identifier: "src-iface", }); const target = await interfaceTypeRepository.create({ + ...testCtx, identifier: "tgt-iface", }); await seedTemplateWithProvidedInterface(source.id); await interfaceTypeRepository.merge({ + ...testCtx, sourceIds: [source.id], targetId: target.id, }); - const gone = await interfaceTypeRepository.findById({ id: source.id }); + const gone = await interfaceTypeRepository.findById({ + orgId: testCtx.orgId, + id: source.id, + }); expect(gone).toBeNull(); }); it("mints a new template version for every affected template", async () => { const source = await interfaceTypeRepository.create({ + ...testCtx, identifier: "src-iface", }); const target = await interfaceTypeRepository.create({ + ...testCtx, identifier: "tgt-iface", }); const seedA = await seedTemplateWithProvidedInterface(source.id); @@ -574,6 +671,7 @@ describe("interfaceTypeRepository", () => { const before = await db.select().from(templateVersions); await interfaceTypeRepository.merge({ + ...testCtx, sourceIds: [source.id], targetId: target.id, }); @@ -601,9 +699,11 @@ describe("interfaceTypeRepository", () => { it("dedups when a template version already provides both source and target", async () => { const source = await interfaceTypeRepository.create({ + ...testCtx, identifier: "src-iface", }); const target = await interfaceTypeRepository.create({ + ...testCtx, identifier: "tgt-iface", }); const { templateVersionId } = await seedTemplateWithProvidedInterface( @@ -616,6 +716,7 @@ describe("interfaceTypeRepository", () => { }); await interfaceTypeRepository.merge({ + ...testCtx, sourceIds: [source.id], targetId: target.id, }); @@ -632,9 +733,13 @@ describe("interfaceTypeRepository", () => { }); it("rejects when target is in sources", async () => { - const a = await interfaceTypeRepository.create({ identifier: "a" }); + const a = await interfaceTypeRepository.create({ + ...testCtx, + identifier: "a", + }); await expect( interfaceTypeRepository.merge({ + ...testCtx, sourceIds: [a.id], targetId: a.id, }) @@ -642,9 +747,13 @@ describe("interfaceTypeRepository", () => { }); it("rejects when target does not exist", async () => { - const a = await interfaceTypeRepository.create({ identifier: "a" }); + const a = await interfaceTypeRepository.create({ + ...testCtx, + identifier: "a", + }); await expect( interfaceTypeRepository.merge({ + ...testCtx, sourceIds: [a.id], targetId: "00000000-0000-0000-0000-000000000000", }) @@ -652,9 +761,13 @@ describe("interfaceTypeRepository", () => { }); it("rejects empty sources", async () => { - const a = await interfaceTypeRepository.create({ identifier: "a" }); + const a = await interfaceTypeRepository.create({ + ...testCtx, + identifier: "a", + }); await expect( interfaceTypeRepository.merge({ + ...testCtx, sourceIds: [], targetId: a.id, }) @@ -663,19 +776,22 @@ describe("interfaceTypeRepository", () => { it("logs a merge transaction", async () => { const source = await interfaceTypeRepository.create({ + ...testCtx, identifier: "src-iface", }); const target = await interfaceTypeRepository.create({ + ...testCtx, identifier: "tgt-iface", }); await seedTemplateWithProvidedInterface(source.id); await interfaceTypeRepository.merge({ + ...testCtx, sourceIds: [source.id], targetId: target.id, }); - const txns = await transactionRepository.listRecent(); + const txns = await transactionRepository.listRecent({ orgId: testCtx.orgId }); const tx = txns.find((t) => t.actionType === "interfaceType.merge"); expect(tx).toBeDefined(); expect(tx!.entityId).toBe(target.id); @@ -686,20 +802,28 @@ describe("interfaceTypeRepository", () => { describe("usageCount", () => { it("returns zeros when nothing references the type", async () => { const created = await interfaceTypeRepository.create({ + ...testCtx, identifier: "unused", }); - const u = await interfaceTypeRepository.usageCount({ id: created.id }); + const u = await interfaceTypeRepository.usageCount({ + orgId: testCtx.orgId, + id: created.id, + }); expect(u).toEqual({ providers: 0, accepters: 0, receptacles: 0 }); }); it("counts template versions providing the type", async () => { const ifc = await interfaceTypeRepository.create({ + ...testCtx, identifier: "gridfinity-42mm", }); await seedTemplateWithProvidedInterface(ifc.id); await seedTemplateWithProvidedInterface(ifc.id); - const u = await interfaceTypeRepository.usageCount({ id: ifc.id }); + const u = await interfaceTypeRepository.usageCount({ + orgId: testCtx.orgId, + id: ifc.id, + }); expect(u.providers).toBe(2); expect(u.accepters).toBe(0); expect(u.receptacles).toBe(0); @@ -707,23 +831,31 @@ describe("interfaceTypeRepository", () => { it("counts template versions accepting the type", async () => { const ifc = await interfaceTypeRepository.create({ + ...testCtx, identifier: "gridfinity-42mm", }); await seedTemplateWithAcceptedInterface(ifc.id); - const u = await interfaceTypeRepository.usageCount({ id: ifc.id }); + const u = await interfaceTypeRepository.usageCount({ + orgId: testCtx.orgId, + id: ifc.id, + }); expect(u.accepters).toBe(1); }); it("counts receptacle locations accepting the type", async () => { const ifc = await interfaceTypeRepository.create({ + ...testCtx, identifier: "plano-3600", }); await seedLocationAcceptingInterface(ifc.id); await seedLocationAcceptingInterface(ifc.id); await seedLocationAcceptingInterface(ifc.id); - const u = await interfaceTypeRepository.usageCount({ id: ifc.id }); + const u = await interfaceTypeRepository.usageCount({ + orgId: testCtx.orgId, + id: ifc.id, + }); expect(u.receptacles).toBe(3); }); }); @@ -734,15 +866,20 @@ describe("interfaceTypeRepository", () => { async function seedTemplateWithProvidedInterface(interfaceTypeId: string) { const [template] = await db .insert(templates) - .values({ name: `template-${Math.random()}` }) + .values({ name: `template-${Math.random()}`, ownerOrgId: testCtx.orgId }) .returning(); const [version] = await db .insert(templateVersions) - .values({ templateId: template.id, version: 1 }) + .values({ + templateId: template.id, + version: 1, + ownerOrgId: testCtx.orgId, + }) .returning(); await db.insert(templateVersionInterfacesProvided).values({ templateVersionId: version.id, interfaceTypeId, + ownerOrgId: testCtx.orgId, }); return { templateId: template.id, templateVersionId: version.id }; } @@ -750,15 +887,20 @@ async function seedTemplateWithProvidedInterface(interfaceTypeId: string) { async function seedTemplateWithAcceptedInterface(interfaceTypeId: string) { const [template] = await db .insert(templates) - .values({ name: `template-${Math.random()}` }) + .values({ name: `template-${Math.random()}`, ownerOrgId: testCtx.orgId }) .returning(); const [version] = await db .insert(templateVersions) - .values({ templateId: template.id, version: 1 }) + .values({ + templateId: template.id, + version: 1, + ownerOrgId: testCtx.orgId, + }) .returning(); await db.insert(templateVersionInterfacesAccepted).values({ templateVersionId: version.id, interfaceTypeId, + ownerOrgId: testCtx.orgId, }); return { templateId: template.id, templateVersionId: version.id }; } @@ -770,15 +912,20 @@ async function seedLocationAcceptingInterface(interfaceTypeId: string) { name: `mod-${Math.random()}`, primaryDimensionLabel: "level", primaryDimensionCount: 1, + ownerOrgId: testCtx.orgId, }) .returning(); const [template] = await db .insert(templates) - .values({ name: `template-${Math.random()}` }) + .values({ name: `template-${Math.random()}`, ownerOrgId: testCtx.orgId }) .returning(); const [version] = await db .insert(templateVersions) - .values({ templateId: template.id, version: 1 }) + .values({ + templateId: template.id, + version: 1, + ownerOrgId: testCtx.orgId, + }) .returning(); const [loc] = await db .insert(locations) @@ -789,11 +936,13 @@ async function seedLocationAcceptingInterface(interfaceTypeId: string) { pathSegments: [mod.name, "1"], locationType: "receptacle", templateVersionId: version.id, + ownerOrgId: testCtx.orgId, }) .returning(); await db.insert(locationInterfacesAccepted).values({ locationId: loc.id, interfaceTypeId, + ownerOrgId: testCtx.orgId, }); return { locationId: loc.id }; } diff --git a/web/tests/repositories/itemListRich.test.ts b/web/tests/repositories/itemListRich.test.ts index aa02755..0513497 100644 --- a/web/tests/repositories/itemListRich.test.ts +++ b/web/tests/repositories/itemListRich.test.ts @@ -5,6 +5,7 @@ import { aspectRepository } from "@/repositories/aspectRepository"; import { moduleRepository } from "@/repositories/moduleRepository"; import { locationRepository } from "@/repositories/locationRepository"; import { assignmentRepository } from "@/repositories/assignmentRepository"; +import { testCtx } from "../setup"; // Helper: create a full machine screw item with taxonomy async function seedScrew({ @@ -30,33 +31,39 @@ async function seedScrew({ lengthDefId: string; fastenersId: string; }) { - const item = await itemRepository.create({ name }); + const item = await itemRepository.create({ ...testCtx, name }); await itemRepository.addCategory({ + orgId: testCtx.orgId, itemId: item.id, categoryId: fastenersId, isPrimary: true, }); const threadIa = await itemRepository.applyAspect({ + orgId: testCtx.orgId, itemId: item.id, aspectId: threadAspectId, }); const headIa = await itemRepository.applyAspect({ + orgId: testCtx.orgId, itemId: item.id, aspectId: headAspectId, }); await itemRepository.setParameterValue({ + orgId: testCtx.orgId, itemId: item.id, parameterDefinitionId: threadDiameterDefId, itemAspectId: threadIa.id, value: threadDiameter, }); await itemRepository.setParameterValue({ + orgId: testCtx.orgId, itemId: item.id, parameterDefinitionId: headTypeDefId, itemAspectId: headIa.id, value: headType, }); await itemRepository.setParameterValue({ + orgId: testCtx.orgId, itemId: item.id, parameterDefinitionId: lengthDefId, value: length, @@ -80,6 +87,7 @@ describe("itemRepository.listRich", () => { beforeEach(async () => { // Categories const fasteners = await categoryRepository.create({ + ...testCtx, name: "Fasteners", icon: "screw", color: "#4488cc", @@ -87,6 +95,7 @@ describe("itemRepository.listRich", () => { fastenersId = fasteners.id; const electronics = await categoryRepository.create({ + ...testCtx, name: "Electronics", icon: "chip", color: "#44cc88", @@ -95,18 +104,21 @@ describe("itemRepository.listRich", () => { // Parameter definitions const threadDiameterDef = await parameterDefinitionRepository.create({ + ...testCtx, name: "Thread diameter", dataType: "text", }); threadDiameterDefId = threadDiameterDef.id; const headTypeDef = await parameterDefinitionRepository.create({ + ...testCtx, name: "Head type", dataType: "text", }); headTypeDefId = headTypeDef.id; const lengthDef = await parameterDefinitionRepository.create({ + ...testCtx, name: "Length", dataType: "numeric", unit: "mm", @@ -114,42 +126,55 @@ describe("itemRepository.listRich", () => { lengthDefId = lengthDef.id; const resistanceDef = await parameterDefinitionRepository.create({ + ...testCtx, name: "Resistance", dataType: "text", }); resistanceDefId = resistanceDef.id; const packageCodeDef = await parameterDefinitionRepository.create({ + ...testCtx, name: "Package code", dataType: "text", }); packageCodeDefId = packageCodeDef.id; // Aspects - const threadAspect = await aspectRepository.create({ name: "Thread" }); + const threadAspect = await aspectRepository.create({ + ...testCtx, + name: "Thread", + }); threadAspectId = threadAspect.id; await aspectRepository.addParameter({ + ...testCtx, aspectId: threadAspectId, parameterDefinitionId: threadDiameterDefId, required: true, }); - const headAspect = await aspectRepository.create({ name: "Head" }); + const headAspect = await aspectRepository.create({ + ...testCtx, + name: "Head", + }); headAspectId = headAspect.id; await aspectRepository.addParameter({ + ...testCtx, aspectId: headAspectId, parameterDefinitionId: headTypeDefId, }); const electricalAspect = await aspectRepository.create({ + ...testCtx, name: "Electrical", }); electricalAspectId = electricalAspect.id; await aspectRepository.addParameter({ + ...testCtx, aspectId: electricalAspectId, parameterDefinitionId: resistanceDefId, }); await aspectRepository.addParameter({ + ...testCtx, aspectId: electricalAspectId, parameterDefinitionId: packageCodeDefId, }); @@ -170,7 +195,7 @@ describe("itemRepository.listRich", () => { fastenersId, }); - const result = await itemRepository.listRich(); + const result = await itemRepository.listRich({ orgId: testCtx.orgId }); expect(result.items).toHaveLength(1); expect(result.total).toBe(1); @@ -189,7 +214,7 @@ describe("itemRepository.listRich", () => { expect(threadAspect).toBeDefined(); expect(threadAspect!.parameters).toHaveLength(1); expect(threadAspect!.parameters[0].parameterName).toBe( - "Thread diameter" + "Thread diameter", ); expect(threadAspect!.parameters[0].value).toBe("M3"); @@ -200,7 +225,7 @@ describe("itemRepository.listRich", () => { }); it("returns empty result when no items exist", async () => { - const result = await itemRepository.listRich(); + const result = await itemRepository.listRich({ orgId: testCtx.orgId }); expect(result.items).toHaveLength(0); expect(result.total).toBe(0); }); @@ -234,6 +259,7 @@ describe("itemRepository.listRich", () => { }); const result = await itemRepository.listRich({ + orgId: testCtx.orgId, filters: [ { parameterDefinitionId: threadDiameterDefId, value: "M3" }, ], @@ -270,6 +296,7 @@ describe("itemRepository.listRich", () => { }); const result = await itemRepository.listRich({ + orgId: testCtx.orgId, filters: [ { parameterDefinitionId: threadDiameterDefId, value: "M3" }, { parameterDefinitionId: headTypeDefId, value: "SHCS" }, @@ -295,6 +322,7 @@ describe("itemRepository.listRich", () => { }); const result = await itemRepository.listRich({ + orgId: testCtx.orgId, filters: [ { parameterDefinitionId: threadDiameterDefId, value: "M5" }, ], @@ -320,14 +348,19 @@ describe("itemRepository.listRich", () => { }); // Create a resistor with Electronics category - const resistor = await itemRepository.create({ name: "10kΩ Resistor" }); + const resistor = await itemRepository.create({ + ...testCtx, + name: "10kΩ Resistor", + }); await itemRepository.addCategory({ + orgId: testCtx.orgId, itemId: resistor.id, categoryId: electronicsId, isPrimary: true, }); const result = await itemRepository.listRich({ + orgId: testCtx.orgId, categoryId: fastenersId, }); @@ -363,7 +396,10 @@ describe("itemRepository.listRich", () => { fastenersId, }); - const result = await itemRepository.listRich({ query: "M3" }); + const result = await itemRepository.listRich({ + orgId: testCtx.orgId, + query: "M3", + }); expect(result.items).toHaveLength(1); expect(result.items[0].name).toBe("M3x10 SHCS"); @@ -371,11 +407,15 @@ describe("itemRepository.listRich", () => { it("searches by description", async () => { const item = await itemRepository.create({ + ...testCtx, name: "Screw", description: "Stainless steel fastener", }); - const result = await itemRepository.listRich({ query: "stainless" }); + const result = await itemRepository.listRich({ + orgId: testCtx.orgId, + query: "stainless", + }); expect(result.items).toHaveLength(1); expect(result.items[0].name).toBe("Screw"); @@ -397,20 +437,26 @@ describe("itemRepository.listRich", () => { // Create a resistor with "10k" parameter value const resistor = await itemRepository.create({ + ...testCtx, name: "SMD Resistor", }); const ia = await itemRepository.applyAspect({ + orgId: testCtx.orgId, itemId: resistor.id, aspectId: electricalAspectId, }); await itemRepository.setParameterValue({ + orgId: testCtx.orgId, itemId: resistor.id, parameterDefinitionId: resistanceDefId, itemAspectId: ia.id, value: "10kΩ", }); - const result = await itemRepository.listRich({ query: "10k" }); + const result = await itemRepository.listRich({ + orgId: testCtx.orgId, + query: "10k", + }); expect(result.items).toHaveLength(1); expect(result.items[0].name).toBe("SMD Resistor"); @@ -443,6 +489,7 @@ describe("itemRepository.listRich", () => { }); const result = await itemRepository.listRich({ + orgId: testCtx.orgId, query: "x16", filters: [ { parameterDefinitionId: threadDiameterDefId, value: "M3" }, @@ -481,7 +528,10 @@ describe("itemRepository.listRich", () => { fastenersId, }); - const result = await itemRepository.listRich({ sortBy: "name" }); + const result = await itemRepository.listRich({ + orgId: testCtx.orgId, + sortBy: "name", + }); expect(result.items[0].name).toBe("M3x10 SHCS"); expect(result.items[1].name).toBe("M4x12 SHCS"); @@ -514,6 +564,7 @@ describe("itemRepository.listRich", () => { }); const result = await itemRepository.listRich({ + orgId: testCtx.orgId, sortBy: "name", sortDirection: "desc", }); @@ -549,6 +600,7 @@ describe("itemRepository.listRich", () => { }); const result = await itemRepository.listRich({ + orgId: testCtx.orgId, sortBy: lengthDefId, sortDirection: "asc", }); @@ -572,9 +624,13 @@ describe("itemRepository.listRich", () => { }); // Item without Length parameter - const bare = await itemRepository.create({ name: "Bare Item" }); + const bare = await itemRepository.create({ + ...testCtx, + name: "Bare Item", + }); const result = await itemRepository.listRich({ + orgId: testCtx.orgId, sortBy: lengthDefId, }); @@ -598,24 +654,31 @@ describe("itemRepository.listRich", () => { fastenersId, }); - const mod = await moduleRepository.create({ name: "MUSE", primaryDimensionLabel: "Level", primaryDimensionCount: 4 }); + const mod = await moduleRepository.create({ + ...testCtx, + name: "MUSE", + primaryDimensionLabel: "Level", + primaryDimensionCount: 4, + }); const loc = await locationRepository.create({ + ...testCtx, moduleId: mod.id, label: "A3", pathSegments: ["MUSE", "Level 2", "A3"], locationType: "fixed", }); await assignmentRepository.create({ + ...testCtx, itemId: screw.id, locationId: loc.id, assignmentType: "placed", }); - const result = await itemRepository.listRich(); + const result = await itemRepository.listRich({ orgId: testCtx.orgId }); expect(result.items[0].assignments).toHaveLength(1); expect(result.items[0].assignments[0].locationPath).toBe( - "MUSE:Level 2:A3" + "MUSE:Level 2:A3", ); expect(result.items[0].assignments[0].assignmentType).toBe("placed"); }); @@ -625,32 +688,39 @@ describe("itemRepository.listRich", () => { describe("itemRepository.getCategoryCounts", () => { it("returns category counts", async () => { const fasteners = await categoryRepository.create({ + ...testCtx, name: "Fasteners", sortOrder: 0, }); const electronics = await categoryRepository.create({ + ...testCtx, name: "Electronics", sortOrder: 1, }); - const item1 = await itemRepository.create({ name: "Screw" }); - const item2 = await itemRepository.create({ name: "Bolt" }); - const item3 = await itemRepository.create({ name: "Resistor" }); + const item1 = await itemRepository.create({ ...testCtx, name: "Screw" }); + const item2 = await itemRepository.create({ ...testCtx, name: "Bolt" }); + const item3 = await itemRepository.create({ ...testCtx, name: "Resistor" }); await itemRepository.addCategory({ + orgId: testCtx.orgId, itemId: item1.id, categoryId: fasteners.id, }); await itemRepository.addCategory({ + orgId: testCtx.orgId, itemId: item2.id, categoryId: fasteners.id, }); await itemRepository.addCategory({ + orgId: testCtx.orgId, itemId: item3.id, categoryId: electronics.id, }); - const counts = await itemRepository.getCategoryCounts(); + const counts = await itemRepository.getCategoryCounts({ + orgId: testCtx.orgId, + }); expect(counts).toHaveLength(2); expect(counts[0].name).toBe("Fasteners"); @@ -660,20 +730,26 @@ describe("itemRepository.getCategoryCounts", () => { }); it("returns zero counts when no items match filters", async () => { - const fasteners = await categoryRepository.create({ name: "Fasteners" }); - const item = await itemRepository.create({ name: "Screw" }); + const fasteners = await categoryRepository.create({ + ...testCtx, + name: "Fasteners", + }); + const item = await itemRepository.create({ ...testCtx, name: "Screw" }); await itemRepository.addCategory({ + orgId: testCtx.orgId, itemId: item.id, categoryId: fasteners.id, }); const threadDef = await parameterDefinitionRepository.create({ + ...testCtx, name: "Thread diameter", dataType: "text", }); // Filter by a value no item has const counts = await itemRepository.getCategoryCounts({ + orgId: testCtx.orgId, filters: [{ parameterDefinitionId: threadDef.id, value: "M99" }], }); diff --git a/web/tests/repositories/itemRepository.isolation.test.ts b/web/tests/repositories/itemRepository.isolation.test.ts new file mode 100644 index 0000000..098cc65 --- /dev/null +++ b/web/tests/repositories/itemRepository.isolation.test.ts @@ -0,0 +1,134 @@ +import { itemRepository } from "@/repositories/itemRepository"; +import { testCtx, createTestOrg } from "../setup"; + +describe("itemRepository isolation (additive)", () => { + it("list returns global + own, not other org's private", async () => { + const b = await createTestOrg({ slug: "other-org" }); + + await itemRepository.create({ + ...testCtx, + asGlobal: true, + name: "Global Widget", + }); + await itemRepository.create({ + ...testCtx, + name: "A Private", + }); + await itemRepository.create({ + ...b, + name: "B Private", + }); + + const aNames = (await itemRepository.list({ orgId: testCtx.orgId })) + .map((i) => i.name) + .sort(); + const bNames = (await itemRepository.list({ orgId: b.orgId })) + .map((i) => i.name) + .sort(); + + expect(aNames).toEqual(["A Private", "Global Widget"]); + expect(bNames).toEqual(["B Private", "Global Widget"]); + }); + + it("org A cannot fetch org B's private item by id", async () => { + const b = await createTestOrg({ slug: "other-org" }); + const bItem = await itemRepository.create({ + ...b, + name: "B Private", + }); + + const fromA = await itemRepository.findById({ + orgId: testCtx.orgId, + id: bItem.id, + }); + expect(fromA).toBeNull(); + }); + + it("org A can fetch a global item", async () => { + const globalItem = await itemRepository.create({ + ...testCtx, + asGlobal: true, + name: "Global", + }); + const b = await createTestOrg({ slug: "other-org" }); + + const fromB = await itemRepository.findById({ + orgId: b.orgId, + id: globalItem.id, + }); + expect(fromB?.id).toBe(globalItem.id); + }); + + it("update on another org's private item throws not-found", async () => { + const b = await createTestOrg({ slug: "other-org" }); + const bItem = await itemRepository.create({ + ...b, + name: "B Private", + }); + + await expect( + itemRepository.update({ + ...testCtx, + id: bItem.id, + description: "hijack", + }), + ).rejects.toThrow("not found"); + }); + + it("junction scoping: item_categories on B's private item is invisible to A", async () => { + const b = await createTestOrg({ slug: "other-org" }); + + // Category is global so both orgs see it via the additive filter. + const { categoryRepository } = await import( + "@/repositories/categoryRepository" + ); + const cat = await categoryRepository.create({ + ...testCtx, + asGlobal: true, + name: "Shared", + }); + + // Org-private item + category junction owned by B. + const bItem = await itemRepository.create({ ...b, name: "B Item" }); + await itemRepository.addCategory({ + orgId: b.orgId, + itemId: bItem.id, + categoryId: cat.id, + }); + + // Org A cannot see the junction row (nor the item). + const fromA = await itemRepository.getCategories({ + orgId: testCtx.orgId, + itemId: bItem.id, + }); + expect(fromA).toHaveLength(0); + + // Org B sees its own junction. + const fromB = await itemRepository.getCategories({ + orgId: b.orgId, + itemId: bItem.id, + }); + expect(fromB).toHaveLength(1); + expect(fromB[0].name).toBe("Shared"); + }); + + it("search is scoped: org A sees globals + own, not org B's private", async () => { + const b = await createTestOrg({ slug: "other-org" }); + + await itemRepository.create({ + ...testCtx, + asGlobal: true, + name: "Global Screw", + }); + await itemRepository.create({ + ...b, + name: "B Screw", + }); + + const fromA = await itemRepository.search({ + orgId: testCtx.orgId, + query: "Screw", + }); + expect(fromA.map((i) => i.name).sort()).toEqual(["Global Screw"]); + }); +}); diff --git a/web/tests/repositories/itemRepository.test.ts b/web/tests/repositories/itemRepository.test.ts index 50b9c11..535dd42 100644 --- a/web/tests/repositories/itemRepository.test.ts +++ b/web/tests/repositories/itemRepository.test.ts @@ -1,10 +1,12 @@ import { itemRepository } from "@/repositories/itemRepository"; import { transactionRepository } from "@/repositories/transactionRepository"; +import { testCtx } from "../setup"; describe("itemRepository", () => { describe("create", () => { it("creates an item and returns it", async () => { const item = await itemRepository.create({ + ...testCtx, name: "M3 x 10mm Socket Head Cap Screw", description: "Stainless steel, DIN 912", }); @@ -16,11 +18,15 @@ describe("itemRepository", () => { it("stores metadata as JSON", async () => { const item = await itemRepository.create({ + ...testCtx, name: "LED", metadata: { color: "red", datasheet: "https://example.com/led.pdf" }, }); - const found = await itemRepository.findById({ id: item.id }); + const found = await itemRepository.findById({ + orgId: testCtx.orgId, + id: item.id, + }); expect(found?.metadata).toEqual({ color: "red", datasheet: "https://example.com/led.pdf", @@ -29,10 +35,13 @@ describe("itemRepository", () => { it("logs a transaction", async () => { const item = await itemRepository.create({ + ...testCtx, name: "Washer", }); - const txns = await transactionRepository.listRecent(); + const txns = await transactionRepository.listRecent({ + orgId: testCtx.orgId, + }); expect(txns).toHaveLength(1); expect(txns[0].actionType).toBe("item.create"); expect(txns[0].entityType).toBe("item"); @@ -44,17 +53,22 @@ describe("itemRepository", () => { describe("findById", () => { it("returns the item by ID", async () => { const created = await itemRepository.create({ + ...testCtx, name: "Bolt", description: "Hex head bolt", }); - const found = await itemRepository.findById({ id: created.id }); + const found = await itemRepository.findById({ + orgId: testCtx.orgId, + id: created.id, + }); expect(found).not.toBeNull(); expect(found!.name).toBe("Bolt"); }); it("returns null for nonexistent ID", async () => { const found = await itemRepository.findById({ + orgId: testCtx.orgId, id: "00000000-0000-0000-0000-000000000000", }); expect(found).toBeNull(); @@ -64,17 +78,24 @@ describe("itemRepository", () => { describe("findByName", () => { it("returns the item by name", async () => { await itemRepository.create({ + ...testCtx, name: "Nut", description: "M3 hex nut", }); - const found = await itemRepository.findByName({ name: "Nut" }); + const found = await itemRepository.findByName({ + orgId: testCtx.orgId, + name: "Nut", + }); expect(found).not.toBeNull(); expect(found!.description).toBe("M3 hex nut"); }); it("returns null for nonexistent name", async () => { - const found = await itemRepository.findByName({ name: "Nonexistent" }); + const found = await itemRepository.findByName({ + orgId: testCtx.orgId, + name: "Nonexistent", + }); expect(found).toBeNull(); }); }); @@ -82,52 +103,67 @@ describe("itemRepository", () => { describe("search", () => { beforeEach(async () => { await itemRepository.create({ + ...testCtx, name: "M3 Socket Head Cap Screw", description: "Stainless steel fastener", }); await itemRepository.create({ + ...testCtx, name: "M4 Hex Bolt", description: "Grade 8.8 steel bolt", }); await itemRepository.create({ + ...testCtx, name: "10k Resistor", description: "SMD 0805 package", }); }); it("finds items by name (case-insensitive)", async () => { - const results = await itemRepository.search({ query: "screw" }); + const results = await itemRepository.search({ + orgId: testCtx.orgId, + query: "screw", + }); expect(results).toHaveLength(1); expect(results[0].name).toBe("M3 Socket Head Cap Screw"); }); it("finds items by description (case-insensitive)", async () => { - const results = await itemRepository.search({ query: "steel" }); + const results = await itemRepository.search({ + orgId: testCtx.orgId, + query: "steel", + }); expect(results).toHaveLength(2); }); it("finds items by partial match", async () => { - const results = await itemRepository.search({ query: "M3" }); + const results = await itemRepository.search({ + orgId: testCtx.orgId, + query: "M3", + }); expect(results).toHaveLength(1); }); it("returns empty array for no matches", async () => { - const results = await itemRepository.search({ query: "capacitor" }); + const results = await itemRepository.search({ + orgId: testCtx.orgId, + query: "capacitor", + }); expect(results).toHaveLength(0); }); }); describe("list", () => { it("returns all items", async () => { - await itemRepository.create({ name: "Item A" }); - await itemRepository.create({ name: "Item B" }); + await itemRepository.create({ ...testCtx, name: "Item A" }); + await itemRepository.create({ ...testCtx, name: "Item B" }); - const all = await itemRepository.list(); + const all = await itemRepository.list({ orgId: testCtx.orgId }); expect(all).toHaveLength(2); }); it("returns empty array when no items exist", async () => { - const all = await itemRepository.list(); + const all = await itemRepository.list({ orgId: testCtx.orgId }); expect(all).toHaveLength(0); }); }); @@ -135,10 +171,12 @@ describe("itemRepository", () => { describe("update", () => { it("updates fields and returns the updated item", async () => { const created = await itemRepository.create({ + ...testCtx, name: "Screw", }); const updated = await itemRepository.update({ + ...testCtx, id: created.id, description: "Updated description", }); @@ -149,15 +187,19 @@ describe("itemRepository", () => { it("logs a transaction with before and after state", async () => { const created = await itemRepository.create({ + ...testCtx, name: "Bolt", }); await itemRepository.update({ + ...testCtx, id: created.id, description: "Updated", }); - const txns = await transactionRepository.listRecent(); + const txns = await transactionRepository.listRecent({ + orgId: testCtx.orgId, + }); const updateTx = txns.find((t) => t.actionType === "item.update"); expect(updateTx).toBeDefined(); expect(updateTx!.beforeState).toBeTruthy(); @@ -167,6 +209,7 @@ describe("itemRepository", () => { it("throws for nonexistent item", async () => { await expect( itemRepository.update({ + ...testCtx, id: "00000000-0000-0000-0000-000000000000", name: "Ghost", }), @@ -177,23 +220,30 @@ describe("itemRepository", () => { describe("remove", () => { it("deletes the item", async () => { const created = await itemRepository.create({ + ...testCtx, name: "Washer", }); - await itemRepository.remove({ id: created.id }); + await itemRepository.remove({ ...testCtx, id: created.id }); - const found = await itemRepository.findById({ id: created.id }); + const found = await itemRepository.findById({ + orgId: testCtx.orgId, + id: created.id, + }); expect(found).toBeNull(); }); it("logs a transaction", async () => { const created = await itemRepository.create({ + ...testCtx, name: "Nut", }); - await itemRepository.remove({ id: created.id }); + await itemRepository.remove({ ...testCtx, id: created.id }); - const txns = await transactionRepository.listRecent(); + const txns = await transactionRepository.listRecent({ + orgId: testCtx.orgId, + }); const deleteTx = txns.find((t) => t.actionType === "item.delete"); expect(deleteTx).toBeDefined(); expect(deleteTx!.afterState).toBeNull(); @@ -202,6 +252,7 @@ describe("itemRepository", () => { it("throws for nonexistent item", async () => { await expect( itemRepository.remove({ + ...testCtx, id: "00000000-0000-0000-0000-000000000000", }), ).rejects.toThrow("not found"); @@ -214,13 +265,14 @@ describe("itemRepository", () => { let itemC: Awaited>; beforeEach(async () => { - itemA = await itemRepository.create({ name: "M3 Screw" }); - itemB = await itemRepository.create({ name: "M3 Nut" }); - itemC = await itemRepository.create({ name: "M3 Washer" }); + itemA = await itemRepository.create({ ...testCtx, name: "M3 Screw" }); + itemB = await itemRepository.create({ ...testCtx, name: "M3 Nut" }); + itemC = await itemRepository.create({ ...testCtx, name: "M3 Washer" }); }); it("adds a co-storability relationship", async () => { const record = await itemRepository.addCoStorability({ + ...testCtx, itemAId: itemA.id, itemBId: itemB.id, reason: "Same thread size", @@ -234,11 +286,14 @@ describe("itemRepository", () => { it("logs a transaction for addCoStorability", async () => { await itemRepository.addCoStorability({ + ...testCtx, itemAId: itemA.id, itemBId: itemB.id, }); - const txns = await transactionRepository.listRecent(); + const txns = await transactionRepository.listRecent({ + orgId: testCtx.orgId, + }); const coStorTx = txns.find( (t) => t.actionType === "coStorability.create", ); @@ -249,17 +304,20 @@ describe("itemRepository", () => { it("is bidirectional — querying from either side works", async () => { await itemRepository.addCoStorability({ + ...testCtx, itemAId: itemA.id, itemBId: itemB.id, }); const fromA = await itemRepository.getCoStorableItems({ + orgId: testCtx.orgId, itemId: itemA.id, }); expect(fromA).toHaveLength(1); expect(fromA[0].id).toBe(itemB.id); const fromB = await itemRepository.getCoStorableItems({ + orgId: testCtx.orgId, itemId: itemB.id, }); expect(fromB).toHaveLength(1); @@ -268,15 +326,18 @@ describe("itemRepository", () => { it("returns multiple co-storable items", async () => { await itemRepository.addCoStorability({ + ...testCtx, itemAId: itemA.id, itemBId: itemB.id, }); await itemRepository.addCoStorability({ + ...testCtx, itemAId: itemA.id, itemBId: itemC.id, }); const coStorable = await itemRepository.getCoStorableItems({ + orgId: testCtx.orgId, itemId: itemA.id, }); expect(coStorable).toHaveLength(2); @@ -286,6 +347,7 @@ describe("itemRepository", () => { it("returns empty array when no co-storability exists", async () => { const result = await itemRepository.getCoStorableItems({ + orgId: testCtx.orgId, itemId: itemA.id, }); expect(result).toHaveLength(0); @@ -293,16 +355,19 @@ describe("itemRepository", () => { it("removes a co-storability relationship", async () => { await itemRepository.addCoStorability({ + ...testCtx, itemAId: itemA.id, itemBId: itemB.id, }); await itemRepository.removeCoStorability({ + ...testCtx, itemAId: itemA.id, itemBId: itemB.id, }); const coStorable = await itemRepository.getCoStorableItems({ + orgId: testCtx.orgId, itemId: itemA.id, }); expect(coStorable).toHaveLength(0); @@ -310,17 +375,20 @@ describe("itemRepository", () => { it("removes co-storability regardless of argument order", async () => { await itemRepository.addCoStorability({ + ...testCtx, itemAId: itemA.id, itemBId: itemB.id, }); // Remove with reversed order await itemRepository.removeCoStorability({ + ...testCtx, itemAId: itemB.id, itemBId: itemA.id, }); const coStorable = await itemRepository.getCoStorableItems({ + orgId: testCtx.orgId, itemId: itemA.id, }); expect(coStorable).toHaveLength(0); @@ -328,16 +396,20 @@ describe("itemRepository", () => { it("logs a transaction for removeCoStorability", async () => { await itemRepository.addCoStorability({ + ...testCtx, itemAId: itemA.id, itemBId: itemB.id, }); await itemRepository.removeCoStorability({ + ...testCtx, itemAId: itemA.id, itemBId: itemB.id, }); - const txns = await transactionRepository.listRecent(); + const txns = await transactionRepository.listRecent({ + orgId: testCtx.orgId, + }); const deleteTx = txns.find( (t) => t.actionType === "coStorability.delete", ); @@ -348,6 +420,7 @@ describe("itemRepository", () => { it("throws when removing nonexistent co-storability", async () => { await expect( itemRepository.removeCoStorability({ + ...testCtx, itemAId: itemA.id, itemBId: itemB.id, }), diff --git a/web/tests/repositories/itemTaxonomy.test.ts b/web/tests/repositories/itemTaxonomy.test.ts index e967ac2..02ac8e1 100644 --- a/web/tests/repositories/itemTaxonomy.test.ts +++ b/web/tests/repositories/itemTaxonomy.test.ts @@ -2,51 +2,64 @@ import { itemRepository } from "@/repositories/itemRepository"; import { categoryRepository } from "@/repositories/categoryRepository"; import { parameterDefinitionRepository } from "@/repositories/parameterDefinitionRepository"; import { aspectRepository } from "@/repositories/aspectRepository"; +import { testCtx } from "../setup"; describe("item taxonomy", () => { // Helpers async function createItem(name = "M3x10 SHCS") { - return itemRepository.create({ name }); + return itemRepository.create({ ...testCtx, name }); } async function createCategory(name = "Fasteners") { - return categoryRepository.create({ name, icon: "screw", color: "#4488cc" }); + return categoryRepository.create({ + ...testCtx, + name, + icon: "screw", + color: "#4488cc", + }); } async function createThreadAspect() { const aspect = await aspectRepository.create({ + ...testCtx, name: "Thread", description: "Threaded fastener properties", }); const diameter = await parameterDefinitionRepository.create({ + ...testCtx, name: "Thread diameter", dataType: "text", }); const pitch = await parameterDefinitionRepository.create({ + ...testCtx, name: "Thread pitch", dataType: "numeric", unit: "mm", defaultValue: 0.5, }); const direction = await parameterDefinitionRepository.create({ + ...testCtx, name: "Thread direction", dataType: "text", defaultValue: "right", }); await aspectRepository.addParameter({ + ...testCtx, aspectId: aspect.id, parameterDefinitionId: diameter.id, required: true, sortOrder: 1, }); await aspectRepository.addParameter({ + ...testCtx, aspectId: aspect.id, parameterDefinitionId: pitch.id, required: false, sortOrder: 2, }); await aspectRepository.addParameter({ + ...testCtx, aspectId: aspect.id, parameterDefinitionId: direction.id, required: false, @@ -62,6 +75,7 @@ describe("item taxonomy", () => { const cat = await createCategory(); const ic = await itemRepository.addCategory({ + orgId: testCtx.orgId, itemId: item.id, categoryId: cat.id, }); @@ -76,6 +90,7 @@ describe("item taxonomy", () => { const cat = await createCategory(); const ic = await itemRepository.addCategory({ + orgId: testCtx.orgId, itemId: item.id, categoryId: cat.id, isPrimary: true, @@ -87,32 +102,54 @@ describe("item taxonomy", () => { it("supports multiple categories on one item", async () => { const item = await createItem(); const cat1 = await createCategory("Fasteners"); - const cat2 = await categoryRepository.create({ name: "Hardware" }); + const cat2 = await categoryRepository.create({ + ...testCtx, + name: "Hardware", + }); - await itemRepository.addCategory({ itemId: item.id, categoryId: cat1.id }); - await itemRepository.addCategory({ itemId: item.id, categoryId: cat2.id }); + await itemRepository.addCategory({ + orgId: testCtx.orgId, + itemId: item.id, + categoryId: cat1.id, + }); + await itemRepository.addCategory({ + orgId: testCtx.orgId, + itemId: item.id, + categoryId: cat2.id, + }); - const cats = await itemRepository.getCategories({ itemId: item.id }); + const cats = await itemRepository.getCategories({ + orgId: testCtx.orgId, + itemId: item.id, + }); expect(cats).toHaveLength(2); }); it("enforces single primary — setting new primary unsets old", async () => { const item = await createItem(); const cat1 = await createCategory("Fasteners"); - const cat2 = await categoryRepository.create({ name: "Hardware" }); + const cat2 = await categoryRepository.create({ + ...testCtx, + name: "Hardware", + }); await itemRepository.addCategory({ + orgId: testCtx.orgId, itemId: item.id, categoryId: cat1.id, isPrimary: true, }); await itemRepository.addCategory({ + orgId: testCtx.orgId, itemId: item.id, categoryId: cat2.id, isPrimary: true, }); - const cats = await itemRepository.getCategories({ itemId: item.id }); + const cats = await itemRepository.getCategories({ + orgId: testCtx.orgId, + itemId: item.id, + }); const primaries = cats.filter((c) => c.isPrimary); expect(primaries).toHaveLength(1); expect(primaries[0].name).toBe("Hardware"); @@ -122,10 +159,21 @@ describe("item taxonomy", () => { const item = await createItem(); const cat = await createCategory(); - await itemRepository.addCategory({ itemId: item.id, categoryId: cat.id }); - await itemRepository.removeCategory({ itemId: item.id, categoryId: cat.id }); + await itemRepository.addCategory({ + orgId: testCtx.orgId, + itemId: item.id, + categoryId: cat.id, + }); + await itemRepository.removeCategory({ + orgId: testCtx.orgId, + itemId: item.id, + categoryId: cat.id, + }); - const cats = await itemRepository.getCategories({ itemId: item.id }); + const cats = await itemRepository.getCategories({ + orgId: testCtx.orgId, + itemId: item.id, + }); expect(cats).toHaveLength(0); }); @@ -133,26 +181,43 @@ describe("item taxonomy", () => { const item = await createItem(); await expect( itemRepository.removeCategory({ + orgId: testCtx.orgId, itemId: item.id, categoryId: "00000000-0000-0000-0000-000000000000", - }) + }), ).rejects.toThrow("not on item"); }); it("sets primary category", async () => { const item = await createItem(); const cat1 = await createCategory("Fasteners"); - const cat2 = await categoryRepository.create({ name: "Hardware" }); + const cat2 = await categoryRepository.create({ + ...testCtx, + name: "Hardware", + }); - await itemRepository.addCategory({ itemId: item.id, categoryId: cat1.id, isPrimary: true }); - await itemRepository.addCategory({ itemId: item.id, categoryId: cat2.id }); + await itemRepository.addCategory({ + orgId: testCtx.orgId, + itemId: item.id, + categoryId: cat1.id, + isPrimary: true, + }); + await itemRepository.addCategory({ + orgId: testCtx.orgId, + itemId: item.id, + categoryId: cat2.id, + }); await itemRepository.setPrimaryCategory({ + orgId: testCtx.orgId, itemId: item.id, categoryId: cat2.id, }); - const cats = await itemRepository.getCategories({ itemId: item.id }); + const cats = await itemRepository.getCategories({ + orgId: testCtx.orgId, + itemId: item.id, + }); const primary = cats.find((c) => c.isPrimary); expect(primary!.name).toBe("Hardware"); const nonPrimary = cats.find((c) => !c.isPrimary); @@ -164,12 +229,16 @@ describe("item taxonomy", () => { const cat = await createCategory(); await itemRepository.addCategory({ + orgId: testCtx.orgId, itemId: item.id, categoryId: cat.id, isPrimary: true, }); - const cats = await itemRepository.getCategories({ itemId: item.id }); + const cats = await itemRepository.getCategories({ + orgId: testCtx.orgId, + itemId: item.id, + }); expect(cats[0].name).toBe("Fasteners"); expect(cats[0].icon).toBe("screw"); expect(cats[0].color).toBe("#4488cc"); @@ -180,9 +249,17 @@ describe("item taxonomy", () => { const item = await createItem(); const cat = await createCategory(); - await itemRepository.addCategory({ itemId: item.id, categoryId: cat.id }); + await itemRepository.addCategory({ + orgId: testCtx.orgId, + itemId: item.id, + categoryId: cat.id, + }); await expect( - itemRepository.addCategory({ itemId: item.id, categoryId: cat.id }) + itemRepository.addCategory({ + orgId: testCtx.orgId, + itemId: item.id, + categoryId: cat.id, + }), ).rejects.toThrow(); }); @@ -190,9 +267,10 @@ describe("item taxonomy", () => { const cat = await createCategory(); await expect( itemRepository.addCategory({ + orgId: testCtx.orgId, itemId: "00000000-0000-0000-0000-000000000000", categoryId: cat.id, - }) + }), ).rejects.toThrow("not found"); }); }); @@ -203,6 +281,7 @@ describe("item taxonomy", () => { const { aspect } = await createThreadAspect(); const ia = await itemRepository.applyAspect({ + orgId: testCtx.orgId, itemId: item.id, aspectId: aspect.id, }); @@ -210,7 +289,10 @@ describe("item taxonomy", () => { expect(ia.itemId).toBe(item.id); expect(ia.aspectId).toBe(aspect.id); - const values = await itemRepository.getParameterValues({ itemId: item.id }); + const values = await itemRepository.getParameterValues({ + orgId: testCtx.orgId, + itemId: item.id, + }); expect(values).toHaveLength(3); }); @@ -218,9 +300,16 @@ describe("item taxonomy", () => { const item = await createItem(); const { aspect } = await createThreadAspect(); - await itemRepository.applyAspect({ itemId: item.id, aspectId: aspect.id }); + await itemRepository.applyAspect({ + orgId: testCtx.orgId, + itemId: item.id, + aspectId: aspect.id, + }); - const values = await itemRepository.getParameterValues({ itemId: item.id }); + const values = await itemRepository.getParameterValues({ + orgId: testCtx.orgId, + itemId: item.id, + }); const pitchVal = values.find((v) => v.parameterName === "Thread pitch"); expect(pitchVal!.value).toBe(0.5); @@ -230,21 +319,33 @@ describe("item taxonomy", () => { it("pre-fills aspect-level default over param-level default", async () => { const item = await createItem(); - const aspect = await aspectRepository.create({ name: "Left Thread" }); + const aspect = await aspectRepository.create({ + ...testCtx, + name: "Left Thread", + }); const direction = await parameterDefinitionRepository.create({ + ...testCtx, name: "Direction", dataType: "text", defaultValue: "right", }); await aspectRepository.addParameter({ + ...testCtx, aspectId: aspect.id, parameterDefinitionId: direction.id, defaultValue: "left", }); - await itemRepository.applyAspect({ itemId: item.id, aspectId: aspect.id }); + await itemRepository.applyAspect({ + orgId: testCtx.orgId, + itemId: item.id, + aspectId: aspect.id, + }); - const values = await itemRepository.getParameterValues({ itemId: item.id }); + const values = await itemRepository.getParameterValues({ + orgId: testCtx.orgId, + itemId: item.id, + }); expect(values[0].value).toBe("left"); }); @@ -252,10 +353,19 @@ describe("item taxonomy", () => { const item = await createItem(); const { aspect } = await createThreadAspect(); - await itemRepository.applyAspect({ itemId: item.id, aspectId: aspect.id }); + await itemRepository.applyAspect({ + orgId: testCtx.orgId, + itemId: item.id, + aspectId: aspect.id, + }); - const values = await itemRepository.getParameterValues({ itemId: item.id }); - const diameterVal = values.find((v) => v.parameterName === "Thread diameter"); + const values = await itemRepository.getParameterValues({ + orgId: testCtx.orgId, + itemId: item.id, + }); + const diameterVal = values.find( + (v) => v.parameterName === "Thread diameter", + ); expect(diameterVal!.value).toBeNull(); }); @@ -263,9 +373,17 @@ describe("item taxonomy", () => { const item = await createItem(); const { aspect } = await createThreadAspect(); - await itemRepository.applyAspect({ itemId: item.id, aspectId: aspect.id }); + await itemRepository.applyAspect({ + orgId: testCtx.orgId, + itemId: item.id, + aspectId: aspect.id, + }); await expect( - itemRepository.applyAspect({ itemId: item.id, aspectId: aspect.id }) + itemRepository.applyAspect({ + orgId: testCtx.orgId, + itemId: item.id, + aspectId: aspect.id, + }), ).rejects.toThrow(); }); @@ -273,9 +391,10 @@ describe("item taxonomy", () => { const { aspect } = await createThreadAspect(); await expect( itemRepository.applyAspect({ + orgId: testCtx.orgId, itemId: "00000000-0000-0000-0000-000000000000", aspectId: aspect.id, - }) + }), ).rejects.toThrow("not found"); }); }); @@ -285,13 +404,27 @@ describe("item taxonomy", () => { const item = await createItem(); const { aspect } = await createThreadAspect(); - await itemRepository.applyAspect({ itemId: item.id, aspectId: aspect.id }); - await itemRepository.removeAspect({ itemId: item.id, aspectId: aspect.id }); + await itemRepository.applyAspect({ + orgId: testCtx.orgId, + itemId: item.id, + aspectId: aspect.id, + }); + await itemRepository.removeAspect({ + orgId: testCtx.orgId, + itemId: item.id, + aspectId: aspect.id, + }); - const aspects = await itemRepository.getAspects({ itemId: item.id }); + const aspects = await itemRepository.getAspects({ + orgId: testCtx.orgId, + itemId: item.id, + }); expect(aspects).toHaveLength(0); - const values = await itemRepository.getParameterValues({ itemId: item.id }); + const values = await itemRepository.getParameterValues({ + orgId: testCtx.orgId, + itemId: item.id, + }); expect(values).toHaveLength(0); }); @@ -299,9 +432,10 @@ describe("item taxonomy", () => { const item = await createItem(); await expect( itemRepository.removeAspect({ + orgId: testCtx.orgId, itemId: item.id, aspectId: "00000000-0000-0000-0000-000000000000", - }) + }), ).rejects.toThrow("not applied"); }); }); @@ -312,11 +446,13 @@ describe("item taxonomy", () => { const { aspect, diameter } = await createThreadAspect(); const ia = await itemRepository.applyAspect({ + orgId: testCtx.orgId, itemId: item.id, aspectId: aspect.id, }); const updated = await itemRepository.setParameterValue({ + orgId: testCtx.orgId, itemId: item.id, parameterDefinitionId: diameter.id, itemAspectId: ia.id, @@ -329,12 +465,14 @@ describe("item taxonomy", () => { it("creates a standalone parameter value", async () => { const item = await createItem(); const length = await parameterDefinitionRepository.create({ + ...testCtx, name: "Length", dataType: "numeric", unit: "mm", }); const pv = await itemRepository.setParameterValue({ + orgId: testCtx.orgId, itemId: item.id, parameterDefinitionId: length.id, value: 10, @@ -347,18 +485,23 @@ describe("item taxonomy", () => { it("getParameterValues returns joined definition data", async () => { const item = await createItem(); const length = await parameterDefinitionRepository.create({ + ...testCtx, name: "Length", dataType: "numeric", unit: "mm", }); await itemRepository.setParameterValue({ + orgId: testCtx.orgId, itemId: item.id, parameterDefinitionId: length.id, value: 10, }); - const values = await itemRepository.getParameterValues({ itemId: item.id }); + const values = await itemRepository.getParameterValues({ + orgId: testCtx.orgId, + itemId: item.id, + }); expect(values).toHaveLength(1); expect(values[0].parameterName).toBe("Length"); expect(values[0].dataType).toBe("numeric"); @@ -371,42 +514,53 @@ describe("item taxonomy", () => { it("models a machine screw with aspects, categories, and standalone params", async () => { // Create the item const item = await itemRepository.create({ + ...testCtx, name: "M3x10 Socket Head Cap Screw", description: "18-8 stainless, black oxide", }); // Add categories const fasteners = await categoryRepository.create({ + ...testCtx, name: "Fasteners", icon: "screw", }); - const hardware = await categoryRepository.create({ name: "Hardware" }); + const hardware = await categoryRepository.create({ + ...testCtx, + name: "Hardware", + }); await itemRepository.addCategory({ + orgId: testCtx.orgId, itemId: item.id, categoryId: fasteners.id, isPrimary: true, }); await itemRepository.addCategory({ + orgId: testCtx.orgId, itemId: item.id, categoryId: hardware.id, }); // Create and apply Thread aspect - const { aspect: threadAspect, diameter, pitch } = await createThreadAspect(); + const { aspect: threadAspect, diameter, pitch } = + await createThreadAspect(); const ia = await itemRepository.applyAspect({ + orgId: testCtx.orgId, itemId: item.id, aspectId: threadAspect.id, }); // Fill in values await itemRepository.setParameterValue({ + orgId: testCtx.orgId, itemId: item.id, parameterDefinitionId: diameter.id, itemAspectId: ia.id, value: "M3", }); await itemRepository.setParameterValue({ + orgId: testCtx.orgId, itemId: item.id, parameterDefinitionId: pitch.id, itemAspectId: ia.id, @@ -415,32 +569,45 @@ describe("item taxonomy", () => { // Add standalone Length parameter const lengthDef = await parameterDefinitionRepository.create({ + ...testCtx, name: "Length", dataType: "numeric", unit: "mm", }); await itemRepository.setParameterValue({ + orgId: testCtx.orgId, itemId: item.id, parameterDefinitionId: lengthDef.id, value: 10, }); // Verify full state - const cats = await itemRepository.getCategories({ itemId: item.id }); + const cats = await itemRepository.getCategories({ + orgId: testCtx.orgId, + itemId: item.id, + }); expect(cats).toHaveLength(2); expect(cats.find((c) => c.isPrimary)!.name).toBe("Fasteners"); - const aspects = await itemRepository.getAspects({ itemId: item.id }); + const aspects = await itemRepository.getAspects({ + orgId: testCtx.orgId, + itemId: item.id, + }); expect(aspects).toHaveLength(1); - const values = await itemRepository.getParameterValues({ itemId: item.id }); + const values = await itemRepository.getParameterValues({ + orgId: testCtx.orgId, + itemId: item.id, + }); expect(values).toHaveLength(4); // 3 from Thread aspect + 1 standalone const lengthVal = values.find((v) => v.parameterName === "Length"); expect(lengthVal!.value).toBe(10); expect(lengthVal!.itemAspectId).toBeNull(); - const diameterVal = values.find((v) => v.parameterName === "Thread diameter"); + const diameterVal = values.find( + (v) => v.parameterName === "Thread diameter", + ); expect(diameterVal!.value).toBe("M3"); expect(diameterVal!.itemAspectId).toBe(ia.id); }); diff --git a/web/tests/repositories/locationRepository.isolation.test.ts b/web/tests/repositories/locationRepository.isolation.test.ts new file mode 100644 index 0000000..3eb0fb3 --- /dev/null +++ b/web/tests/repositories/locationRepository.isolation.test.ts @@ -0,0 +1,164 @@ +import { locationRepository } from "@/repositories/locationRepository"; +import { moduleRepository } from "@/repositories/moduleRepository"; +import { testCtx, createTestOrg } from "../setup"; + +async function seedModule(ctx: { userId: string; orgId: string }, name: string) { + return moduleRepository.create({ + ...ctx, + name, + primaryDimensionLabel: "level", + primaryDimensionCount: 1, + }); +} + +describe("locationRepository isolation", () => { + it("findByModuleId is scoped: org A cannot see org B's locations", async () => { + const b = await createTestOrg({ slug: "other-org" }); + + const modA = await seedModule(testCtx, "MUSE-A"); + const modB = await seedModule(b, "MUSE-B"); + + await locationRepository.create({ + ...testCtx, + moduleId: modA.id, + label: "1", + pathSegments: ["MUSE-A", "1"], + locationType: "fixed", + }); + await locationRepository.create({ + ...b, + moduleId: modB.id, + label: "1", + pathSegments: ["MUSE-B", "1"], + locationType: "fixed", + }); + + const aList = await locationRepository.findByModuleId({ + orgId: testCtx.orgId, + moduleId: modA.id, + }); + const bList = await locationRepository.findByModuleId({ + orgId: b.orgId, + moduleId: modB.id, + }); + + expect(aList).toHaveLength(1); + expect(bList).toHaveLength(1); + expect(aList[0].ownerOrgId).toBe(testCtx.orgId); + expect(bList[0].ownerOrgId).toBe(b.orgId); + + // Cross-org query must not leak rows even if a caller supplies the + // other org's moduleId. + const leak = await locationRepository.findByModuleId({ + orgId: testCtx.orgId, + moduleId: modB.id, + }); + expect(leak).toHaveLength(0); + }); + + it("findById is scoped: org A cannot fetch org B's location", async () => { + const b = await createTestOrg({ slug: "other-org" }); + + const modB = await seedModule(b, "MUSE-B"); + const bLoc = await locationRepository.create({ + ...b, + moduleId: modB.id, + label: "1", + pathSegments: ["MUSE-B", "1"], + locationType: "fixed", + }); + + const fromA = await locationRepository.findById({ + orgId: testCtx.orgId, + id: bLoc.id, + }); + expect(fromA).toBeNull(); + + const fromB = await locationRepository.findById({ + orgId: b.orgId, + id: bLoc.id, + }); + expect(fromB?.id).toBe(bLoc.id); + }); + + it("update on another org's location throws not-found", async () => { + const b = await createTestOrg({ slug: "other-org" }); + + const modB = await seedModule(b, "MUSE-B"); + const bLoc = await locationRepository.create({ + ...b, + moduleId: modB.id, + label: "1", + pathSegments: ["MUSE-B", "1"], + locationType: "fixed", + }); + + await expect( + locationRepository.update({ + ...testCtx, + id: bLoc.id, + label: "hijack", + }), + ).rejects.toThrow("not found"); + }); + + it("remove on another org's location throws not-found", async () => { + const b = await createTestOrg({ slug: "other-org" }); + + const modB = await seedModule(b, "MUSE-B"); + const bLoc = await locationRepository.create({ + ...b, + moduleId: modB.id, + label: "1", + pathSegments: ["MUSE-B", "1"], + locationType: "fixed", + }); + + await expect( + locationRepository.remove({ + ...testCtx, + id: bLoc.id, + }), + ).rejects.toThrow("not found"); + + // B can still see it + const stillThere = await locationRepository.findById({ + orgId: b.orgId, + id: bLoc.id, + }); + expect(stillThere).not.toBeNull(); + }); + + it("findChildren is scoped: children of an org B parent invisible to org A", async () => { + const b = await createTestOrg({ slug: "other-org" }); + + const modB = await seedModule(b, "MUSE-B"); + const bParent = await locationRepository.create({ + ...b, + moduleId: modB.id, + label: "1", + pathSegments: ["MUSE-B", "1"], + locationType: "fixed", + }); + await locationRepository.create({ + ...b, + moduleId: modB.id, + parentId: bParent.id, + label: "A1", + pathSegments: ["MUSE-B", "1", "A1"], + locationType: "leaf", + }); + + const aViewsB = await locationRepository.findChildren({ + orgId: testCtx.orgId, + parentId: bParent.id, + }); + expect(aViewsB).toHaveLength(0); + + const bViewsB = await locationRepository.findChildren({ + orgId: b.orgId, + parentId: bParent.id, + }); + expect(bViewsB).toHaveLength(1); + }); +}); diff --git a/web/tests/repositories/locationRepository.test.ts b/web/tests/repositories/locationRepository.test.ts index 2af1bcb..3efae53 100644 --- a/web/tests/repositories/locationRepository.test.ts +++ b/web/tests/repositories/locationRepository.test.ts @@ -1,13 +1,43 @@ import { db } from "@/db/connection"; -import { templates, templateVersions } from "@/db/schema"; +import { templates, templateVersions, assignments, items } from "@/db/schema"; import { eq } from "drizzle-orm"; + +// itemRepository and assignmentRepository haven't been migrated to org +// scope yet (Phase C.2d). These helpers seed scoped rows directly so +// the locationRepository checks (which DO filter by org) can see them. +async function seedScopedAssignment({ + locationId, + itemName = "test item", + assignmentType = "placed", +}: { + locationId: string; + itemName?: string; + assignmentType?: string; +}) { + const [item] = await db + .insert(items) + .values({ name: itemName, ownerOrgId: testCtx.orgId }) + .returning(); + const [asg] = await db + .insert(assignments) + .values({ + ownerOrgId: testCtx.orgId, + itemId: item.id, + locationId, + assignmentType, + }) + .returning(); + return { item, assignment: asg }; +} import { interfaceTypeRepository } from "@/repositories/interfaceTypeRepository"; import { locationRepository } from "@/repositories/locationRepository"; import { moduleRepository } from "@/repositories/moduleRepository"; import { transactionRepository } from "@/repositories/transactionRepository"; +import { testCtx } from "../setup"; async function createTestModule() { return moduleRepository.create({ + ...testCtx, name: "MUSE", primaryDimensionLabel: "level", primaryDimensionCount: 11, @@ -20,6 +50,7 @@ describe("locationRepository", () => { const module = await createTestModule(); const location = await locationRepository.create({ + ...testCtx, moduleId: module.id, label: "3", pathSegments: ["MUSE", "3"], @@ -32,12 +63,14 @@ describe("locationRepository", () => { expect(location.path).toBe("MUSE:3"); expect(location.pathSegments).toEqual(["MUSE", "3"]); expect(location.locationType).toBe("fixed"); + expect(location.ownerOrgId).toBe(testCtx.orgId); }); it("auto-creates a single_instance template when templateVersionId is omitted", async () => { const module = await createTestModule(); const location = await locationRepository.create({ + ...testCtx, moduleId: module.id, label: "shelf", pathSegments: ["AD-HOC", "shelf"], @@ -58,6 +91,9 @@ describe("locationRepository", () => { expect(template.scope).toBe("single_instance"); expect(template.name).toBe("ad-hoc: AD-HOC:shelf"); + // Ad-hoc template + version must be org-private, not global. + expect(template.ownerOrgId).toBe(testCtx.orgId); + expect(version.ownerOrgId).toBe(testCtx.orgId); }); it("uses provided templateVersionId when given", async () => { @@ -74,6 +110,7 @@ describe("locationRepository", () => { .returning(); const location = await locationRepository.create({ + ...testCtx, moduleId: module.id, label: "A1", pathSegments: ["MUSE", "3", "A1"], @@ -87,10 +124,12 @@ describe("locationRepository", () => { it("creates a receptacle with accepted interface types", async () => { const module = await createTestModule(); const plano = await interfaceTypeRepository.create({ + ...testCtx, identifier: "plano-3600", }); const location = await locationRepository.create({ + ...testCtx, moduleId: module.id, label: "A1", pathSegments: ["MUSE", "3", "A1"], @@ -101,6 +140,7 @@ describe("locationRepository", () => { expect(location.locationType).toBe("receptacle"); const accepted = await locationRepository.getAcceptedInterfaces({ + orgId: testCtx.orgId, locationId: location.id, }); expect(accepted).toHaveLength(1); @@ -111,6 +151,7 @@ describe("locationRepository", () => { const module = await createTestModule(); const parent = await locationRepository.create({ + ...testCtx, moduleId: module.id, label: "3", pathSegments: ["MUSE", "3"], @@ -118,6 +159,7 @@ describe("locationRepository", () => { }); const child = await locationRepository.create({ + ...testCtx, moduleId: module.id, parentId: parent.id, label: "A1", @@ -133,6 +175,7 @@ describe("locationRepository", () => { const module = await createTestModule(); const location = await locationRepository.create({ + ...testCtx, moduleId: module.id, label: "B2", pathSegments: ["MUSE", "3", "B2"], @@ -149,6 +192,7 @@ describe("locationRepository", () => { const module = await createTestModule(); const location = await locationRepository.create({ + ...testCtx, moduleId: module.id, label: "3", pathSegments: ["MUSE", "3"], @@ -156,7 +200,10 @@ describe("locationRepository", () => { metadata: { notes: "top shelf" }, }); - const found = await locationRepository.findById({ id: location.id }); + const found = await locationRepository.findById({ + orgId: testCtx.orgId, + id: location.id, + }); expect(found?.metadata).toEqual({ notes: "top shelf" }); }); @@ -164,18 +211,23 @@ describe("locationRepository", () => { const module = await createTestModule(); const location = await locationRepository.create({ + ...testCtx, moduleId: module.id, label: "3", pathSegments: ["MUSE", "3"], locationType: "fixed", }); - const txns = await transactionRepository.listRecent(); + const txns = await transactionRepository.listRecent({ + orgId: testCtx.orgId, + }); const createTx = txns.find((t) => t.actionType === "location.create"); expect(createTx).toBeDefined(); expect(createTx!.entityType).toBe("location"); expect(createTx!.entityId).toBe(location.id); expect(createTx!.beforeState).toBeNull(); + expect(createTx!.actorUserId).toBe(testCtx.userId); + expect(createTx!.ownerOrgId).toBe(testCtx.orgId); }); }); @@ -183,19 +235,24 @@ describe("locationRepository", () => { it("returns the location by ID", async () => { const module = await createTestModule(); const created = await locationRepository.create({ + ...testCtx, moduleId: module.id, label: "3", pathSegments: ["MUSE", "3"], locationType: "fixed", }); - const found = await locationRepository.findById({ id: created.id }); + const found = await locationRepository.findById({ + orgId: testCtx.orgId, + id: created.id, + }); expect(found).not.toBeNull(); expect(found!.label).toBe("3"); }); it("returns null for nonexistent ID", async () => { const found = await locationRepository.findById({ + orgId: testCtx.orgId, id: "00000000-0000-0000-0000-000000000000", }); expect(found).toBeNull(); @@ -206,6 +263,7 @@ describe("locationRepository", () => { it("finds a location by module and path", async () => { const module = await createTestModule(); await locationRepository.create({ + ...testCtx, moduleId: module.id, label: "3", pathSegments: ["MUSE", "3"], @@ -213,6 +271,7 @@ describe("locationRepository", () => { }); const found = await locationRepository.findByPath({ + orgId: testCtx.orgId, moduleId: module.id, path: "MUSE:3", }); @@ -223,6 +282,7 @@ describe("locationRepository", () => { it("returns null for nonexistent path", async () => { const module = await createTestModule(); const found = await locationRepository.findByPath({ + orgId: testCtx.orgId, moduleId: module.id, path: "MUSE:99", }); @@ -234,12 +294,14 @@ describe("locationRepository", () => { it("returns all locations in a module", async () => { const module = await createTestModule(); await locationRepository.create({ + ...testCtx, moduleId: module.id, label: "3", pathSegments: ["MUSE", "3"], locationType: "fixed", }); await locationRepository.create({ + ...testCtx, moduleId: module.id, label: "4", pathSegments: ["MUSE", "4"], @@ -247,6 +309,7 @@ describe("locationRepository", () => { }); const results = await locationRepository.findByModuleId({ + orgId: testCtx.orgId, moduleId: module.id, }); expect(results).toHaveLength(2); @@ -257,6 +320,7 @@ describe("locationRepository", () => { it("returns direct children of a location", async () => { const module = await createTestModule(); const parent = await locationRepository.create({ + ...testCtx, moduleId: module.id, label: "3", pathSegments: ["MUSE", "3"], @@ -264,6 +328,7 @@ describe("locationRepository", () => { }); await locationRepository.create({ + ...testCtx, moduleId: module.id, parentId: parent.id, label: "A1", @@ -271,6 +336,7 @@ describe("locationRepository", () => { locationType: "receptacle", }); await locationRepository.create({ + ...testCtx, moduleId: module.id, parentId: parent.id, label: "A2", @@ -279,6 +345,7 @@ describe("locationRepository", () => { }); const children = await locationRepository.findChildren({ + orgId: testCtx.orgId, parentId: parent.id, }); expect(children).toHaveLength(2); @@ -287,6 +354,7 @@ describe("locationRepository", () => { it("returns empty array when no children exist", async () => { const module = await createTestModule(); const location = await locationRepository.create({ + ...testCtx, moduleId: module.id, label: "3", pathSegments: ["MUSE", "3"], @@ -294,6 +362,7 @@ describe("locationRepository", () => { }); const children = await locationRepository.findChildren({ + orgId: testCtx.orgId, parentId: location.id, }); expect(children).toHaveLength(0); @@ -304,6 +373,7 @@ describe("locationRepository", () => { it("updates fields and returns the updated location", async () => { const module = await createTestModule(); const created = await locationRepository.create({ + ...testCtx, moduleId: module.id, label: "3", pathSegments: ["MUSE", "3"], @@ -311,6 +381,7 @@ describe("locationRepository", () => { }); const updated = await locationRepository.update({ + ...testCtx, id: created.id, locationType: "receptacle", }); @@ -318,13 +389,16 @@ describe("locationRepository", () => { expect(updated.locationType).toBe("receptacle"); const plano = await interfaceTypeRepository.create({ + ...testCtx, identifier: "plano-3600", }); await locationRepository.setAcceptedInterfaces({ + ...testCtx, locationId: created.id, interfaceTypeIds: [plano.id], }); const accepted = await locationRepository.getAcceptedInterfaces({ + orgId: testCtx.orgId, locationId: created.id, }); expect(accepted.map((a) => a.identifier)).toEqual(["plano-3600"]); @@ -333,6 +407,7 @@ describe("locationRepository", () => { it("logs a transaction with before and after state", async () => { const module = await createTestModule(); const created = await locationRepository.create({ + ...testCtx, moduleId: module.id, label: "3", pathSegments: ["MUSE", "3"], @@ -340,11 +415,14 @@ describe("locationRepository", () => { }); await locationRepository.update({ + ...testCtx, id: created.id, label: "3-updated", }); - const txns = await transactionRepository.listRecent(); + const txns = await transactionRepository.listRecent({ + orgId: testCtx.orgId, + }); const updateTx = txns.find((t) => t.actionType === "location.update"); expect(updateTx).toBeDefined(); expect(updateTx!.beforeState).toBeTruthy(); @@ -354,6 +432,7 @@ describe("locationRepository", () => { it("throws for nonexistent location", async () => { await expect( locationRepository.update({ + ...testCtx, id: "00000000-0000-0000-0000-000000000000", label: "GHOST", }) @@ -365,30 +444,37 @@ describe("locationRepository", () => { it("deletes the location", async () => { const module = await createTestModule(); const created = await locationRepository.create({ + ...testCtx, moduleId: module.id, label: "3", pathSegments: ["MUSE", "3"], locationType: "fixed", }); - await locationRepository.remove({ id: created.id }); + await locationRepository.remove({ ...testCtx, id: created.id }); - const found = await locationRepository.findById({ id: created.id }); + const found = await locationRepository.findById({ + orgId: testCtx.orgId, + id: created.id, + }); expect(found).toBeNull(); }); it("logs a transaction", async () => { const module = await createTestModule(); const created = await locationRepository.create({ + ...testCtx, moduleId: module.id, label: "3", pathSegments: ["MUSE", "3"], locationType: "fixed", }); - await locationRepository.remove({ id: created.id }); + await locationRepository.remove({ ...testCtx, id: created.id }); - const txns = await transactionRepository.listRecent(); + const txns = await transactionRepository.listRecent({ + orgId: testCtx.orgId, + }); const deleteTx = txns.find((t) => t.actionType === "location.delete"); expect(deleteTx).toBeDefined(); expect(deleteTx!.afterState).toBeNull(); @@ -397,6 +483,7 @@ describe("locationRepository", () => { it("throws for nonexistent location", async () => { await expect( locationRepository.remove({ + ...testCtx, id: "00000000-0000-0000-0000-000000000000", }) ).rejects.toThrow("not found"); @@ -407,6 +494,7 @@ describe("locationRepository", () => { it("disables a location with reason", async () => { const module = await createTestModule(); const created = await locationRepository.create({ + ...testCtx, moduleId: module.id, label: "3", pathSegments: ["MUSE", "3"], @@ -414,6 +502,7 @@ describe("locationRepository", () => { }); const disabled = await locationRepository.disable({ + ...testCtx, id: created.id, reason: "Broken shelf", }); @@ -423,37 +512,31 @@ describe("locationRepository", () => { }); it("refuses to disable a location with active assignments", async () => { - const { assignmentRepository } = await import( - "@/repositories/assignmentRepository" - ); - const { itemRepository } = await import( - "@/repositories/itemRepository" - ); const module = await createTestModule(); const loc = await locationRepository.create({ + ...testCtx, moduleId: module.id, label: "A1", pathSegments: ["MUSE", "3", "A1"], locationType: "leaf", }); - const item = await itemRepository.create({ name: "Resistor" }); - await assignmentRepository.create({ - itemId: item.id, - locationId: loc.id, - assignmentType: "placed", - }); + await seedScopedAssignment({ locationId: loc.id, itemName: "Resistor" }); await expect( - locationRepository.disable({ id: loc.id }) + locationRepository.disable({ ...testCtx, id: loc.id }) ).rejects.toThrow(/active assignments/); - const after = await locationRepository.findById({ id: loc.id }); + const after = await locationRepository.findById({ + orgId: testCtx.orgId, + id: loc.id, + }); expect(after?.isDisabled).toBe(false); }); it("enables a previously disabled location", async () => { const module = await createTestModule(); const created = await locationRepository.create({ + ...testCtx, moduleId: module.id, label: "3", pathSegments: ["MUSE", "3"], @@ -461,11 +544,15 @@ describe("locationRepository", () => { }); await locationRepository.disable({ + ...testCtx, id: created.id, reason: "Broken shelf", }); - const enabled = await locationRepository.enable({ id: created.id }); + const enabled = await locationRepository.enable({ + ...testCtx, + id: created.id, + }); expect(enabled.isDisabled).toBe(false); expect(enabled.disableReason).toBeNull(); }); @@ -473,16 +560,19 @@ describe("locationRepository", () => { it("logs transactions for disable and enable", async () => { const module = await createTestModule(); const created = await locationRepository.create({ + ...testCtx, moduleId: module.id, label: "3", pathSegments: ["MUSE", "3"], locationType: "fixed", }); - await locationRepository.disable({ id: created.id }); - await locationRepository.enable({ id: created.id }); + await locationRepository.disable({ ...testCtx, id: created.id }); + await locationRepository.enable({ ...testCtx, id: created.id }); - const txns = await transactionRepository.listRecent(); + const txns = await transactionRepository.listRecent({ + orgId: testCtx.orgId, + }); expect(txns.find((t) => t.actionType === "location.disable")).toBeDefined(); expect(txns.find((t) => t.actionType === "location.enable")).toBeDefined(); }); @@ -492,6 +582,7 @@ describe("locationRepository", () => { it("sets capacity clamps and reason", async () => { const module = await createTestModule(); const loc = await locationRepository.create({ + ...testCtx, moduleId: module.id, label: "A1", pathSegments: ["MUSE", "3", "A1"], @@ -499,6 +590,7 @@ describe("locationRepository", () => { }); const restricted = await locationRepository.restrict({ + ...testCtx, id: loc.id, maxHeightMm: 60, reason: "Must slide under shelf above", @@ -513,18 +605,23 @@ describe("locationRepository", () => { it("clearRestrict removes all clamps", async () => { const module = await createTestModule(); const loc = await locationRepository.create({ + ...testCtx, moduleId: module.id, label: "A1", pathSegments: ["MUSE", "3", "A1"], locationType: "leaf", }); await locationRepository.restrict({ + ...testCtx, id: loc.id, maxWidthMm: 100, maxHeightMm: 50, reason: "tight", }); - const cleared = await locationRepository.clearRestrict({ id: loc.id }); + const cleared = await locationRepository.clearRestrict({ + ...testCtx, + id: loc.id, + }); expect(cleared.maxWidthMm).toBeNull(); expect(cleared.maxHeightMm).toBeNull(); expect(cleared.maxDepthMm).toBeNull(); @@ -534,13 +631,20 @@ describe("locationRepository", () => { it("logs a transaction", async () => { const module = await createTestModule(); const loc = await locationRepository.create({ + ...testCtx, moduleId: module.id, label: "A1", pathSegments: ["MUSE", "3", "A1"], locationType: "leaf", }); - await locationRepository.restrict({ id: loc.id, maxHeightMm: 45 }); - const txns = await transactionRepository.listRecent(); + await locationRepository.restrict({ + ...testCtx, + id: loc.id, + maxHeightMm: 45, + }); + const txns = await transactionRepository.listRecent({ + orgId: testCtx.orgId, + }); expect( txns.find((t) => t.actionType === "location.restrict") ).toBeDefined(); @@ -558,6 +662,7 @@ describe("locationRepository", () => { for (const [r, c, label] of rc) { out.push( await locationRepository.create({ + ...testCtx, moduleId, parentId, label, @@ -575,6 +680,7 @@ describe("locationRepository", () => { it("merges 2 adjacent cells under same parent", async () => { const module = await createTestModule(); const parent = await locationRepository.create({ + ...testCtx, moduleId: module.id, label: "1", pathSegments: ["MUSE", "1"], @@ -586,17 +692,22 @@ describe("locationRepository", () => { ]); await locationRepository.merge({ + ...testCtx, originId: a.id, aliasIds: [b.id], }); - const after = await locationRepository.findById({ id: b.id }); + const after = await locationRepository.findById({ + orgId: testCtx.orgId, + id: b.id, + }); expect(after?.mergedIntoId).toBe(a.id); }); it("refuses non-adjacent merge", async () => { const module = await createTestModule(); const parent = await locationRepository.create({ + ...testCtx, moduleId: module.id, label: "1", pathSegments: ["MUSE", "1"], @@ -607,19 +718,25 @@ describe("locationRepository", () => { [0, 2, "A3"], ]); await expect( - locationRepository.merge({ originId: a.id, aliasIds: [b.id] }) + locationRepository.merge({ + ...testCtx, + originId: a.id, + aliasIds: [b.id], + }) ).rejects.toThrow(/contiguous/); }); it("refuses merge across different parents", async () => { const module = await createTestModule(); const p1 = await locationRepository.create({ + ...testCtx, moduleId: module.id, label: "1", pathSegments: ["MUSE", "1"], locationType: "receptacle", }); const p2 = await locationRepository.create({ + ...testCtx, moduleId: module.id, label: "2", pathSegments: ["MUSE", "2"], @@ -628,13 +745,18 @@ describe("locationRepository", () => { const [a] = await createGridCells(module.id, p1.id, [[0, 0, "A1"]]); const [b] = await createGridCells(module.id, p2.id, [[0, 0, "A1"]]); await expect( - locationRepository.merge({ originId: a.id, aliasIds: [b.id] }) + locationRepository.merge({ + ...testCtx, + originId: a.id, + aliasIds: [b.id], + }) ).rejects.toThrow(/same parent/); }); it("refuses merge when any cell is disabled", async () => { const module = await createTestModule(); const parent = await locationRepository.create({ + ...testCtx, moduleId: module.id, label: "1", pathSegments: ["MUSE", "1"], @@ -644,21 +766,24 @@ describe("locationRepository", () => { [0, 0, "A1"], [0, 1, "A2"], ]); - await locationRepository.disable({ id: b.id, reason: "cracked" }); + await locationRepository.disable({ + ...testCtx, + id: b.id, + reason: "cracked", + }); await expect( - locationRepository.merge({ originId: a.id, aliasIds: [b.id] }) + locationRepository.merge({ + ...testCtx, + originId: a.id, + aliasIds: [b.id], + }) ).rejects.toThrow(/disabled/); }); it("refuses merge with active assignments", async () => { - const { assignmentRepository } = await import( - "@/repositories/assignmentRepository" - ); - const { itemRepository } = await import( - "@/repositories/itemRepository" - ); const module = await createTestModule(); const parent = await locationRepository.create({ + ...testCtx, moduleId: module.id, label: "1", pathSegments: ["MUSE", "1"], @@ -668,20 +793,20 @@ describe("locationRepository", () => { [0, 0, "A1"], [0, 1, "A2"], ]); - const item = await itemRepository.create({ name: "x" }); - await assignmentRepository.create({ - itemId: item.id, - locationId: b.id, - assignmentType: "placed", - }); + await seedScopedAssignment({ locationId: b.id }); await expect( - locationRepository.merge({ originId: a.id, aliasIds: [b.id] }) + locationRepository.merge({ + ...testCtx, + originId: a.id, + aliasIds: [b.id], + }) ).rejects.toThrow(/assignments/); }); it("unmerge clears all aliases", async () => { const module = await createTestModule(); const parent = await locationRepository.create({ + ...testCtx, moduleId: module.id, label: "1", pathSegments: ["MUSE", "1"], @@ -693,12 +818,19 @@ describe("locationRepository", () => { [0, 2, "A3"], ]); await locationRepository.merge({ + ...testCtx, originId: a.id, aliasIds: [b.id, c.id], }); - const res = await locationRepository.unmerge({ originId: a.id }); + const res = await locationRepository.unmerge({ + ...testCtx, + originId: a.id, + }); expect(res.aliasCount).toBe(2); - const after = await locationRepository.findById({ id: b.id }); + const after = await locationRepository.findById({ + orgId: testCtx.orgId, + id: b.id, + }); expect(after?.mergedIntoId).toBeNull(); }); }); @@ -707,18 +839,23 @@ describe("locationRepository", () => { it("splits a leaf into named children", async () => { const module = await createTestModule(); const parent = await locationRepository.create({ + ...testCtx, moduleId: module.id, label: "drawer 1", pathSegments: ["MUSE", "drawer 1"], locationType: "leaf", }); const children = await locationRepository.divide({ + ...testCtx, parentId: parent.id, labels: ["front", "rear"], }); expect(children).toHaveLength(2); expect(children.map((c) => c.label).sort()).toEqual(["front", "rear"]); - const after = await locationRepository.findById({ id: parent.id }); + const after = await locationRepository.findById({ + orgId: testCtx.orgId, + id: parent.id, + }); expect(after?.locationType).toBe("fixed"); expect(after?.subdivisionSource).toBe("ad_hoc"); }); @@ -726,19 +863,25 @@ describe("locationRepository", () => { it("refuses divide with fewer than 2 labels", async () => { const module = await createTestModule(); const parent = await locationRepository.create({ + ...testCtx, moduleId: module.id, label: "d", pathSegments: ["MUSE", "d"], locationType: "leaf", }); await expect( - locationRepository.divide({ parentId: parent.id, labels: ["only"] }) + locationRepository.divide({ + ...testCtx, + parentId: parent.id, + labels: ["only"], + }) ).rejects.toThrow(/at least two/); }); it("refuses divide with duplicate labels", async () => { const module = await createTestModule(); const parent = await locationRepository.create({ + ...testCtx, moduleId: module.id, label: "d", pathSegments: ["MUSE", "d"], @@ -746,6 +889,7 @@ describe("locationRepository", () => { }); await expect( locationRepository.divide({ + ...testCtx, parentId: parent.id, labels: ["a", "a"], }) @@ -753,27 +897,18 @@ describe("locationRepository", () => { }); it("refuses divide when parent has active assignments", async () => { - const { assignmentRepository } = await import( - "@/repositories/assignmentRepository" - ); - const { itemRepository } = await import( - "@/repositories/itemRepository" - ); const module = await createTestModule(); const parent = await locationRepository.create({ + ...testCtx, moduleId: module.id, label: "d", pathSegments: ["MUSE", "d"], locationType: "leaf", }); - const item = await itemRepository.create({ name: "x" }); - await assignmentRepository.create({ - itemId: item.id, - locationId: parent.id, - assignmentType: "placed", - }); + await seedScopedAssignment({ locationId: parent.id }); await expect( locationRepository.divide({ + ...testCtx, parentId: parent.id, labels: ["a", "b"], }) @@ -783,18 +918,26 @@ describe("locationRepository", () => { it("undivide removes children and restores parent to leaf", async () => { const module = await createTestModule(); const parent = await locationRepository.create({ + ...testCtx, moduleId: module.id, label: "d", pathSegments: ["MUSE", "d"], locationType: "leaf", }); await locationRepository.divide({ + ...testCtx, parentId: parent.id, labels: ["a", "b"], }); - const res = await locationRepository.undivide({ parentId: parent.id }); + const res = await locationRepository.undivide({ + ...testCtx, + parentId: parent.id, + }); expect(res.removed).toBe(2); - const after = await locationRepository.findById({ id: parent.id }); + const after = await locationRepository.findById({ + orgId: testCtx.orgId, + id: parent.id, + }); expect(after?.locationType).toBe("leaf"); expect(after?.subdivisionSource).toBeNull(); }); @@ -804,12 +947,14 @@ describe("locationRepository", () => { it("sets a merge alias", async () => { const module = await createTestModule(); const loc1 = await locationRepository.create({ + ...testCtx, moduleId: module.id, label: "A1", pathSegments: ["MUSE", "3", "A1"], locationType: "receptacle", }); const loc2 = await locationRepository.create({ + ...testCtx, moduleId: module.id, label: "A2", pathSegments: ["MUSE", "3", "A2"], @@ -817,6 +962,7 @@ describe("locationRepository", () => { }); const merged = await locationRepository.setMergeAlias({ + ...testCtx, id: loc1.id, mergedIntoId: loc2.id, }); @@ -827,12 +973,14 @@ describe("locationRepository", () => { it("clears a merge alias", async () => { const module = await createTestModule(); const loc1 = await locationRepository.create({ + ...testCtx, moduleId: module.id, label: "A1", pathSegments: ["MUSE", "3", "A1"], locationType: "receptacle", }); const loc2 = await locationRepository.create({ + ...testCtx, moduleId: module.id, label: "A2", pathSegments: ["MUSE", "3", "A2"], @@ -840,23 +988,29 @@ describe("locationRepository", () => { }); await locationRepository.setMergeAlias({ + ...testCtx, id: loc1.id, mergedIntoId: loc2.id, }); - const cleared = await locationRepository.clearMergeAlias({ id: loc1.id }); + const cleared = await locationRepository.clearMergeAlias({ + ...testCtx, + id: loc1.id, + }); expect(cleared.mergedIntoId).toBeNull(); }); it("logs transactions for set and clear", async () => { const module = await createTestModule(); const loc1 = await locationRepository.create({ + ...testCtx, moduleId: module.id, label: "A1", pathSegments: ["MUSE", "3", "A1"], locationType: "receptacle", }); const loc2 = await locationRepository.create({ + ...testCtx, moduleId: module.id, label: "A2", pathSegments: ["MUSE", "3", "A2"], @@ -864,12 +1018,18 @@ describe("locationRepository", () => { }); await locationRepository.setMergeAlias({ + ...testCtx, id: loc1.id, mergedIntoId: loc2.id, }); - await locationRepository.clearMergeAlias({ id: loc1.id }); + await locationRepository.clearMergeAlias({ + ...testCtx, + id: loc1.id, + }); - const txns = await transactionRepository.listRecent(); + const txns = await transactionRepository.listRecent({ + orgId: testCtx.orgId, + }); expect( txns.find((t) => t.actionType === "location.setMergeAlias") ).toBeDefined(); diff --git a/web/tests/repositories/moduleRepository.isolation.test.ts b/web/tests/repositories/moduleRepository.isolation.test.ts new file mode 100644 index 0000000..6edd45e --- /dev/null +++ b/web/tests/repositories/moduleRepository.isolation.test.ts @@ -0,0 +1,69 @@ +import { moduleRepository } from "@/repositories/moduleRepository"; +import { testCtx, createTestOrg } from "../setup"; + +describe("moduleRepository isolation", () => { + it("org A cannot see org B's modules", async () => { + const b = await createTestOrg({ slug: "other-org" }); + + await moduleRepository.create({ + ...testCtx, + name: "A-only", + primaryDimensionLabel: "level", + primaryDimensionCount: 1, + }); + await moduleRepository.create({ + ...b, + name: "B-only", + primaryDimensionLabel: "level", + primaryDimensionCount: 1, + }); + + const aList = await moduleRepository.list({ orgId: testCtx.orgId }); + const bList = await moduleRepository.list({ orgId: b.orgId }); + + expect(aList.map((m) => m.name)).toEqual(["A-only"]); + expect(bList.map((m) => m.name)).toEqual(["B-only"]); + }); + + it("findById is scoped: org A cannot fetch org B's module", async () => { + const b = await createTestOrg({ slug: "other-org" }); + + const bModule = await moduleRepository.create({ + ...b, + name: "B-only", + primaryDimensionLabel: "level", + primaryDimensionCount: 1, + }); + + const fromA = await moduleRepository.findById({ + orgId: testCtx.orgId, + id: bModule.id, + }); + expect(fromA).toBeNull(); + + const fromB = await moduleRepository.findById({ + orgId: b.orgId, + id: bModule.id, + }); + expect(fromB?.id).toBe(bModule.id); + }); + + it("update on another org's module throws not-found", async () => { + const b = await createTestOrg({ slug: "other-org" }); + + const bModule = await moduleRepository.create({ + ...b, + name: "B-only", + primaryDimensionLabel: "level", + primaryDimensionCount: 1, + }); + + await expect( + moduleRepository.update({ + ...testCtx, + id: bModule.id, + description: "hijack", + }), + ).rejects.toThrow("not found"); + }); +}); diff --git a/web/tests/repositories/moduleRepository.test.ts b/web/tests/repositories/moduleRepository.test.ts index e32fa18..efcb925 100644 --- a/web/tests/repositories/moduleRepository.test.ts +++ b/web/tests/repositories/moduleRepository.test.ts @@ -1,10 +1,12 @@ import { moduleRepository } from "@/repositories/moduleRepository"; import { transactionRepository } from "@/repositories/transactionRepository"; +import { testCtx } from "../setup"; describe("moduleRepository", () => { describe("create", () => { it("creates a module and returns it", async () => { const module = await moduleRepository.create({ + ...testCtx, name: "MUSE", description: "Red cabinet with shelf levels", primaryDimensionLabel: "level", @@ -16,32 +18,40 @@ describe("moduleRepository", () => { expect(module.description).toBe("Red cabinet with shelf levels"); expect(module.primaryDimensionLabel).toBe("level"); expect(module.primaryDimensionCount).toBe(11); + expect(module.ownerOrgId).toBe(testCtx.orgId); }); it("logs a transaction", async () => { const module = await moduleRepository.create({ + ...testCtx, name: "MUSE", primaryDimensionLabel: "level", primaryDimensionCount: 11, }); - const txns = await transactionRepository.listRecent(); + const txns = await transactionRepository.listRecent({ orgId: testCtx.orgId }); expect(txns).toHaveLength(1); expect(txns[0].actionType).toBe("module.create"); expect(txns[0].entityType).toBe("module"); expect(txns[0].entityId).toBe(module.id); expect(txns[0].beforeState).toBeNull(); + expect(txns[0].actorUserId).toBe(testCtx.userId); + expect(txns[0].ownerOrgId).toBe(testCtx.orgId); }); it("stores metadata as JSON", async () => { const module = await moduleRepository.create({ + ...testCtx, name: "ALEX", primaryDimensionLabel: "drawer", primaryDimensionCount: 9, metadata: { manufacturer: "IKEA", color: "white" }, }); - const found = await moduleRepository.findById({ id: module.id }); + const found = await moduleRepository.findById({ + orgId: testCtx.orgId, + id: module.id, + }); expect(found?.metadata).toEqual({ manufacturer: "IKEA", color: "white" }); }); }); @@ -49,18 +59,23 @@ describe("moduleRepository", () => { describe("findById", () => { it("returns the module by ID", async () => { const created = await moduleRepository.create({ + ...testCtx, name: "MUSE", primaryDimensionLabel: "level", primaryDimensionCount: 11, }); - const found = await moduleRepository.findById({ id: created.id }); + const found = await moduleRepository.findById({ + orgId: testCtx.orgId, + id: created.id, + }); expect(found).not.toBeNull(); expect(found!.name).toBe("MUSE"); }); it("returns null for nonexistent ID", async () => { const found = await moduleRepository.findById({ + orgId: testCtx.orgId, id: "00000000-0000-0000-0000-000000000000", }); expect(found).toBeNull(); @@ -70,41 +85,50 @@ describe("moduleRepository", () => { describe("findByName", () => { it("returns the module by name", async () => { await moduleRepository.create({ + ...testCtx, name: "NEON", primaryDimensionLabel: "drawer", primaryDimensionCount: 10, }); - const found = await moduleRepository.findByName({ name: "NEON" }); + const found = await moduleRepository.findByName({ + orgId: testCtx.orgId, + name: "NEON", + }); expect(found).not.toBeNull(); expect(found!.primaryDimensionCount).toBe(10); }); it("returns null for nonexistent name", async () => { - const found = await moduleRepository.findByName({ name: "GHOST" }); + const found = await moduleRepository.findByName({ + orgId: testCtx.orgId, + name: "GHOST", + }); expect(found).toBeNull(); }); }); describe("list", () => { - it("returns all modules", async () => { + it("returns all modules in the org", async () => { await moduleRepository.create({ + ...testCtx, name: "MUSE", primaryDimensionLabel: "level", primaryDimensionCount: 11, }); await moduleRepository.create({ + ...testCtx, name: "ALEX", primaryDimensionLabel: "drawer", primaryDimensionCount: 9, }); - const all = await moduleRepository.list(); + const all = await moduleRepository.list({ orgId: testCtx.orgId }); expect(all).toHaveLength(2); }); it("returns empty array when no modules exist", async () => { - const all = await moduleRepository.list(); + const all = await moduleRepository.list({ orgId: testCtx.orgId }); expect(all).toHaveLength(0); }); }); @@ -112,12 +136,14 @@ describe("moduleRepository", () => { describe("update", () => { it("updates fields and returns the updated module", async () => { const created = await moduleRepository.create({ + ...testCtx, name: "MUSE", primaryDimensionLabel: "level", primaryDimensionCount: 11, }); const updated = await moduleRepository.update({ + ...testCtx, id: created.id, description: "Updated description", }); @@ -128,17 +154,19 @@ describe("moduleRepository", () => { it("logs a transaction with before and after state", async () => { const created = await moduleRepository.create({ + ...testCtx, name: "MUSE", primaryDimensionLabel: "level", primaryDimensionCount: 11, }); await moduleRepository.update({ + ...testCtx, id: created.id, description: "Updated", }); - const txns = await transactionRepository.listRecent(); + const txns = await transactionRepository.listRecent({ orgId: testCtx.orgId }); const updateTx = txns.find((t) => t.actionType === "module.update"); expect(updateTx).toBeDefined(); expect(updateTx!.beforeState).toBeTruthy(); @@ -148,6 +176,7 @@ describe("moduleRepository", () => { it("throws for nonexistent module", async () => { await expect( moduleRepository.update({ + ...testCtx, id: "00000000-0000-0000-0000-000000000000", name: "GHOST", }) @@ -158,27 +187,32 @@ describe("moduleRepository", () => { describe("remove", () => { it("deletes the module", async () => { const created = await moduleRepository.create({ + ...testCtx, name: "MUSE", primaryDimensionLabel: "level", primaryDimensionCount: 11, }); - await moduleRepository.remove({ id: created.id }); + await moduleRepository.remove({ ...testCtx, id: created.id }); - const found = await moduleRepository.findById({ id: created.id }); + const found = await moduleRepository.findById({ + orgId: testCtx.orgId, + id: created.id, + }); expect(found).toBeNull(); }); it("logs a transaction", async () => { const created = await moduleRepository.create({ + ...testCtx, name: "MUSE", primaryDimensionLabel: "level", primaryDimensionCount: 11, }); - await moduleRepository.remove({ id: created.id }); + await moduleRepository.remove({ ...testCtx, id: created.id }); - const txns = await transactionRepository.listRecent(); + const txns = await transactionRepository.listRecent({ orgId: testCtx.orgId }); const deleteTx = txns.find((t) => t.actionType === "module.delete"); expect(deleteTx).toBeDefined(); expect(deleteTx!.afterState).toBeNull(); @@ -187,6 +221,7 @@ describe("moduleRepository", () => { it("throws for nonexistent module", async () => { await expect( moduleRepository.remove({ + ...testCtx, id: "00000000-0000-0000-0000-000000000000", }) ).rejects.toThrow("not found"); diff --git a/web/tests/repositories/parameterDefinitionRepository.isolation.test.ts b/web/tests/repositories/parameterDefinitionRepository.isolation.test.ts new file mode 100644 index 0000000..ba2da68 --- /dev/null +++ b/web/tests/repositories/parameterDefinitionRepository.isolation.test.ts @@ -0,0 +1,77 @@ +import { parameterDefinitionRepository } from "@/repositories/parameterDefinitionRepository"; +import { testCtx, createTestOrg } from "../setup"; + +describe("parameterDefinitionRepository isolation (additive)", () => { + it("list returns global + own, not other org's private", async () => { + const b = await createTestOrg({ slug: "other-org" }); + + await parameterDefinitionRepository.create({ + ...testCtx, + asGlobal: true, + name: "Global Param", + dataType: "numeric", + }); + await parameterDefinitionRepository.create({ + ...testCtx, + name: "A Private", + dataType: "text", + }); + await parameterDefinitionRepository.create({ + ...b, + name: "B Private", + dataType: "text", + }); + + const aNames = ( + await parameterDefinitionRepository.list({ orgId: testCtx.orgId }) + ) + .map((p) => p.name) + .sort(); + const bNames = ( + await parameterDefinitionRepository.list({ orgId: b.orgId }) + ) + .map((p) => p.name) + .sort(); + + expect(aNames).toEqual(["A Private", "Global Param"]); + expect(bNames).toEqual(["B Private", "Global Param"]); + }); + + it("org A cannot fetch org B's private parameter by id", async () => { + const b = await createTestOrg({ slug: "other-org" }); + const bParam = await parameterDefinitionRepository.create({ + ...b, + name: "B Private", + dataType: "numeric", + }); + + const fromA = await parameterDefinitionRepository.findById({ + orgId: testCtx.orgId, + id: bParam.id, + }); + expect(fromA).toBeNull(); + + const fromB = await parameterDefinitionRepository.findById({ + orgId: b.orgId, + id: bParam.id, + }); + expect(fromB?.id).toBe(bParam.id); + }); + + it("update on another org's private parameter throws not-found", async () => { + const b = await createTestOrg({ slug: "other-org" }); + const bParam = await parameterDefinitionRepository.create({ + ...b, + name: "B Private", + dataType: "numeric", + }); + + await expect( + parameterDefinitionRepository.update({ + ...testCtx, + id: bParam.id, + unit: "hijack", + }), + ).rejects.toThrow("not found"); + }); +}); diff --git a/web/tests/repositories/parameterDefinitionRepository.test.ts b/web/tests/repositories/parameterDefinitionRepository.test.ts index 72c25a3..325b0a4 100644 --- a/web/tests/repositories/parameterDefinitionRepository.test.ts +++ b/web/tests/repositories/parameterDefinitionRepository.test.ts @@ -1,10 +1,12 @@ import { parameterDefinitionRepository } from "@/repositories/parameterDefinitionRepository"; import { transactionRepository } from "@/repositories/transactionRepository"; +import { testCtx } from "../setup"; describe("parameterDefinitionRepository", () => { describe("create", () => { it("creates a numeric parameter with unit", async () => { const pd = await parameterDefinitionRepository.create({ + ...testCtx, name: "Thread diameter", dataType: "numeric", unit: "mm", @@ -20,6 +22,7 @@ describe("parameterDefinitionRepository", () => { it("creates a boolean parameter", async () => { const pd = await parameterDefinitionRepository.create({ + ...testCtx, name: "RoHS compliant", dataType: "boolean", defaultValue: true, @@ -31,6 +34,7 @@ describe("parameterDefinitionRepository", () => { it("creates an enum parameter with enumValues", async () => { const pd = await parameterDefinitionRepository.create({ + ...testCtx, name: "Drive style", dataType: "enum", constraints: { @@ -47,6 +51,7 @@ describe("parameterDefinitionRepository", () => { it("rejects enum without enumValues", async () => { await expect( parameterDefinitionRepository.create({ + ...testCtx, name: "Bad enum", dataType: "enum", }) @@ -55,6 +60,7 @@ describe("parameterDefinitionRepository", () => { it("creates numeric with range constraints", async () => { const pd = await parameterDefinitionRepository.create({ + ...testCtx, name: "Thread pitch", dataType: "numeric", unit: "mm", @@ -66,6 +72,7 @@ describe("parameterDefinitionRepository", () => { it("creates with default value", async () => { const pd = await parameterDefinitionRepository.create({ + ...testCtx, name: "Thread direction", dataType: "text", defaultValue: "right", @@ -76,12 +83,15 @@ describe("parameterDefinitionRepository", () => { it("logs a transaction", async () => { const pd = await parameterDefinitionRepository.create({ + ...testCtx, name: "Length", dataType: "numeric", unit: "mm", }); - const txns = await transactionRepository.listRecent(); + const txns = await transactionRepository.listRecent({ + orgId: testCtx.orgId, + }); expect(txns).toHaveLength(1); expect(txns[0].actionType).toBe("parameterDefinition.create"); expect(txns[0].entityId).toBe(pd.id); @@ -89,11 +99,13 @@ describe("parameterDefinitionRepository", () => { it("rejects duplicate names", async () => { await parameterDefinitionRepository.create({ + ...testCtx, name: "Length", dataType: "numeric", }); await expect( parameterDefinitionRepository.create({ + ...testCtx, name: "Length", dataType: "numeric", }) @@ -104,17 +116,22 @@ describe("parameterDefinitionRepository", () => { describe("findById", () => { it("returns the parameter definition by ID", async () => { const created = await parameterDefinitionRepository.create({ + ...testCtx, name: "Length", dataType: "numeric", unit: "mm", }); - const found = await parameterDefinitionRepository.findById({ id: created.id }); + const found = await parameterDefinitionRepository.findById({ + orgId: testCtx.orgId, + id: created.id, + }); expect(found).not.toBeNull(); expect(found!.name).toBe("Length"); }); it("returns null for nonexistent ID", async () => { const found = await parameterDefinitionRepository.findById({ + orgId: testCtx.orgId, id: "00000000-0000-0000-0000-000000000000", }); expect(found).toBeNull(); @@ -124,11 +141,13 @@ describe("parameterDefinitionRepository", () => { describe("findByName", () => { it("returns the parameter definition by name", async () => { await parameterDefinitionRepository.create({ + ...testCtx, name: "Voltage rating", dataType: "numeric", unit: "V", }); const found = await parameterDefinitionRepository.findByName({ + orgId: testCtx.orgId, name: "Voltage rating", }); expect(found).not.toBeNull(); @@ -136,18 +155,35 @@ describe("parameterDefinitionRepository", () => { }); it("returns null for nonexistent name", async () => { - const found = await parameterDefinitionRepository.findByName({ name: "Nope" }); + const found = await parameterDefinitionRepository.findByName({ + orgId: testCtx.orgId, + name: "Nope", + }); expect(found).toBeNull(); }); }); describe("list", () => { it("returns all parameter definitions ordered by name", async () => { - await parameterDefinitionRepository.create({ name: "Voltage", dataType: "numeric" }); - await parameterDefinitionRepository.create({ name: "Color", dataType: "text" }); - await parameterDefinitionRepository.create({ name: "Length", dataType: "numeric" }); + await parameterDefinitionRepository.create({ + ...testCtx, + name: "Voltage", + dataType: "numeric", + }); + await parameterDefinitionRepository.create({ + ...testCtx, + name: "Color", + dataType: "text", + }); + await parameterDefinitionRepository.create({ + ...testCtx, + name: "Length", + dataType: "numeric", + }); - const all = await parameterDefinitionRepository.list(); + const all = await parameterDefinitionRepository.list({ + orgId: testCtx.orgId, + }); expect(all).toHaveLength(3); expect(all[0].name).toBe("Color"); expect(all[1].name).toBe("Length"); @@ -155,7 +191,9 @@ describe("parameterDefinitionRepository", () => { }); it("returns empty array when none exist", async () => { - const all = await parameterDefinitionRepository.list(); + const all = await parameterDefinitionRepository.list({ + orgId: testCtx.orgId, + }); expect(all).toHaveLength(0); }); }); @@ -163,12 +201,14 @@ describe("parameterDefinitionRepository", () => { describe("update", () => { it("updates fields and returns updated record", async () => { const created = await parameterDefinitionRepository.create({ + ...testCtx, name: "Length", dataType: "numeric", unit: "mm", }); const updated = await parameterDefinitionRepository.update({ + ...testCtx, id: created.id, unit: "inches", defaultValue: 1.0, @@ -181,6 +221,7 @@ describe("parameterDefinitionRepository", () => { it("validates enum constraints on update", async () => { const created = await parameterDefinitionRepository.create({ + ...testCtx, name: "Drive style", dataType: "enum", constraints: { enumValues: ["Phillips", "Torx"] }, @@ -188,6 +229,7 @@ describe("parameterDefinitionRepository", () => { await expect( parameterDefinitionRepository.update({ + ...testCtx, id: created.id, constraints: {}, }) @@ -196,15 +238,19 @@ describe("parameterDefinitionRepository", () => { it("logs a transaction", async () => { const created = await parameterDefinitionRepository.create({ + ...testCtx, name: "Length", dataType: "numeric", }); await parameterDefinitionRepository.update({ + ...testCtx, id: created.id, unit: "mm", }); - const txns = await transactionRepository.listRecent(); + const txns = await transactionRepository.listRecent({ + orgId: testCtx.orgId, + }); const updateTx = txns.find( (t) => t.actionType === "parameterDefinition.update" ); @@ -214,6 +260,7 @@ describe("parameterDefinitionRepository", () => { it("throws for nonexistent parameter definition", async () => { await expect( parameterDefinitionRepository.update({ + ...testCtx, id: "00000000-0000-0000-0000-000000000000", unit: "mm", }) @@ -224,23 +271,36 @@ describe("parameterDefinitionRepository", () => { describe("remove", () => { it("deletes the parameter definition", async () => { const created = await parameterDefinitionRepository.create({ + ...testCtx, name: "Length", dataType: "numeric", }); - await parameterDefinitionRepository.remove({ id: created.id }); + await parameterDefinitionRepository.remove({ + ...testCtx, + id: created.id, + }); - const found = await parameterDefinitionRepository.findById({ id: created.id }); + const found = await parameterDefinitionRepository.findById({ + orgId: testCtx.orgId, + id: created.id, + }); expect(found).toBeNull(); }); it("logs a transaction", async () => { const created = await parameterDefinitionRepository.create({ + ...testCtx, name: "Length", dataType: "numeric", }); - await parameterDefinitionRepository.remove({ id: created.id }); + await parameterDefinitionRepository.remove({ + ...testCtx, + id: created.id, + }); - const txns = await transactionRepository.listRecent(); + const txns = await transactionRepository.listRecent({ + orgId: testCtx.orgId, + }); const deleteTx = txns.find( (t) => t.actionType === "parameterDefinition.delete" ); @@ -251,6 +311,7 @@ describe("parameterDefinitionRepository", () => { it("throws for nonexistent parameter definition", async () => { await expect( parameterDefinitionRepository.remove({ + ...testCtx, id: "00000000-0000-0000-0000-000000000000", }) ).rejects.toThrow("not found"); diff --git a/web/tests/repositories/standardRepository.isolation.test.ts b/web/tests/repositories/standardRepository.isolation.test.ts new file mode 100644 index 0000000..9829a0b --- /dev/null +++ b/web/tests/repositories/standardRepository.isolation.test.ts @@ -0,0 +1,68 @@ +import { standardRepository } from "@/repositories/standardRepository"; +import { testCtx, createTestOrg } from "../setup"; + +describe("standardRepository isolation (additive)", () => { + it("list returns global + own, not other org's private", async () => { + const b = await createTestOrg({ slug: "other-org" }); + + await standardRepository.create({ + ...testCtx, + asGlobal: true, + name: "Global Std", + }); + await standardRepository.create({ + ...testCtx, + name: "A Private", + }); + await standardRepository.create({ + ...b, + name: "B Private", + }); + + const aNames = (await standardRepository.list({ orgId: testCtx.orgId })) + .map((s) => s.name) + .sort(); + const bNames = (await standardRepository.list({ orgId: b.orgId })) + .map((s) => s.name) + .sort(); + + expect(aNames).toEqual(["A Private", "Global Std"]); + expect(bNames).toEqual(["B Private", "Global Std"]); + }); + + it("org A cannot fetch org B's private standard by id", async () => { + const b = await createTestOrg({ slug: "other-org" }); + const bStd = await standardRepository.create({ + ...b, + name: "B Private", + }); + + const fromA = await standardRepository.findById({ + orgId: testCtx.orgId, + id: bStd.id, + }); + expect(fromA).toBeNull(); + + const fromB = await standardRepository.findById({ + orgId: b.orgId, + id: bStd.id, + }); + expect(fromB?.id).toBe(bStd.id); + }); + + it("update on another org's private standard throws not-found", async () => { + const b = await createTestOrg({ slug: "other-org" }); + const bStd = await standardRepository.create({ + ...b, + name: "B Private", + }); + + await expect( + standardRepository.update({ + ...testCtx, + id: bStd.id, + description: "hijack", + }), + ).rejects.toThrow("not found"); + }); +}); diff --git a/web/tests/repositories/standardRepository.test.ts b/web/tests/repositories/standardRepository.test.ts index 45ea759..548be54 100644 --- a/web/tests/repositories/standardRepository.test.ts +++ b/web/tests/repositories/standardRepository.test.ts @@ -3,10 +3,11 @@ import { aspectRepository } from "@/repositories/aspectRepository"; import { parameterDefinitionRepository } from "@/repositories/parameterDefinitionRepository"; import { itemRepository } from "@/repositories/itemRepository"; import { transactionRepository } from "@/repositories/transactionRepository"; +import { testCtx } from "../setup"; // Helper: create an aspect async function createAspect(name = "Machine Screw Threading") { - return aspectRepository.create({ name }); + return aspectRepository.create({ ...testCtx, name }); } // Helper: create a parameter definition @@ -15,12 +16,18 @@ async function createParam( dataType: "numeric" | "text" | "boolean" | "enum" = "numeric", unit?: string ) { - return parameterDefinitionRepository.create({ name, dataType, unit }); + return parameterDefinitionRepository.create({ + ...testCtx, + name, + dataType, + unit, + }); } // Helper: create a standard (no aspect — caller links via addAspect) async function createStandard(name = "UNC", domainTag?: string) { return standardRepository.create({ + ...testCtx, name, description: `${name} standard`, domainTag, @@ -34,7 +41,11 @@ async function createStandardForAspect( domainTag?: string ) { const standard = await createStandard(name, domainTag); - await standardRepository.addAspect({ standardId: standard.id, aspectId }); + await standardRepository.addAspect({ + ...testCtx, + standardId: standard.id, + aspectId, + }); return standard; } @@ -57,7 +68,9 @@ describe("standardRepository", () => { it("logs a transaction", async () => { const standard = await createStandard(); - const txns = await transactionRepository.listRecent(); + const txns = await transactionRepository.listRecent({ + orgId: testCtx.orgId, + }); const stdTxn = txns.find((t) => t.actionType === "standard.create"); expect(stdTxn).toBeDefined(); expect(stdTxn!.entityId).toBe(standard.id); @@ -72,7 +85,10 @@ describe("standardRepository", () => { describe("findById", () => { it("returns the standard by ID", async () => { const created = await createStandard(); - const found = await standardRepository.findById({ id: created.id }); + const found = await standardRepository.findById({ + orgId: testCtx.orgId, + id: created.id, + }); expect(found).not.toBeNull(); expect(found!.name).toBe("UNC"); @@ -80,6 +96,7 @@ describe("standardRepository", () => { it("returns null for nonexistent ID", async () => { const found = await standardRepository.findById({ + orgId: testCtx.orgId, id: "00000000-0000-0000-0000-000000000000", }); expect(found).toBeNull(); @@ -89,7 +106,10 @@ describe("standardRepository", () => { describe("findByName", () => { it("returns the standard by name", async () => { await createStandard(); - const found = await standardRepository.findByName({ name: "UNC" }); + const found = await standardRepository.findByName({ + orgId: testCtx.orgId, + name: "UNC", + }); expect(found).not.toBeNull(); expect(found!.name).toBe("UNC"); @@ -102,7 +122,7 @@ describe("standardRepository", () => { const unc = await createStandardForAspect(aspect.id, "UNC"); await createStandardForAspect(aspect.id, "UNF"); - const list = await standardRepository.list(); + const list = await standardRepository.list({ orgId: testCtx.orgId }); expect(list).toHaveLength(2); const uncEntry = list.find((s) => s.id === unc.id); expect(Number(uncEntry!.aspectCount)).toBe(1); @@ -110,12 +130,12 @@ describe("standardRepository", () => { it("returns 0 aspectCount for unlinked standard", async () => { await createStandard("Orphan"); - const list = await standardRepository.list(); + const list = await standardRepository.list({ orgId: testCtx.orgId }); expect(Number(list[0].aspectCount)).toBe(0); }); it("returns empty array when none exist", async () => { - const list = await standardRepository.list(); + const list = await standardRepository.list({ orgId: testCtx.orgId }); expect(list).toHaveLength(0); }); }); @@ -129,6 +149,7 @@ describe("standardRepository", () => { await createStandardForAspect(drive.id, "Phillips"); const list = await standardRepository.listByAspect({ + orgId: testCtx.orgId, aspectId: threading.id, }); expect(list).toHaveLength(2); @@ -140,18 +161,22 @@ describe("standardRepository", () => { const drive = await createAspect("Fastener Drive"); const crossAspect = await createStandard("ISO 4762"); await standardRepository.addAspect({ + ...testCtx, standardId: crossAspect.id, aspectId: threading.id, }); await standardRepository.addAspect({ + ...testCtx, standardId: crossAspect.id, aspectId: drive.id, }); const forThreading = await standardRepository.listByAspect({ + orgId: testCtx.orgId, aspectId: threading.id, }); const forDrive = await standardRepository.listByAspect({ + orgId: testCtx.orgId, aspectId: drive.id, }); @@ -166,6 +191,7 @@ describe("standardRepository", () => { const standard = await createStandard(); const link = await standardRepository.addAspect({ + ...testCtx, standardId: standard.id, aspectId: aspect.id, }); @@ -178,11 +204,14 @@ describe("standardRepository", () => { const aspect = await createAspect(); const standard = await createStandard(); await standardRepository.addAspect({ + ...testCtx, standardId: standard.id, aspectId: aspect.id, }); - const txns = await transactionRepository.listRecent(); + const txns = await transactionRepository.listRecent({ + orgId: testCtx.orgId, + }); const txn = txns.find((t) => t.actionType === "aspect_standard.create"); expect(txn).toBeDefined(); }); @@ -191,11 +220,13 @@ describe("standardRepository", () => { const aspect = await createAspect(); const standard = await createStandard(); await standardRepository.addAspect({ + ...testCtx, standardId: standard.id, aspectId: aspect.id, }); await expect( standardRepository.addAspect({ + ...testCtx, standardId: standard.id, aspectId: aspect.id, }) @@ -207,11 +238,13 @@ describe("standardRepository", () => { const standard = await createStandardForAspect(aspect.id); await standardRepository.removeAspect({ + ...testCtx, standardId: standard.id, aspectId: aspect.id, }); const list = await standardRepository.listByAspect({ + orgId: testCtx.orgId, aspectId: aspect.id, }); expect(list).toHaveLength(0); @@ -222,6 +255,7 @@ describe("standardRepository", () => { const standard = await createStandard(); await expect( standardRepository.removeAspect({ + ...testCtx, standardId: standard.id, aspectId: aspect.id, }) @@ -233,11 +267,14 @@ describe("standardRepository", () => { const standard = await createStandardForAspect(aspect.id); await standardRepository.removeAspect({ + ...testCtx, standardId: standard.id, aspectId: aspect.id, }); - const txns = await transactionRepository.listRecent(); + const txns = await transactionRepository.listRecent({ + orgId: testCtx.orgId, + }); const txn = txns.find((t) => t.actionType === "aspect_standard.delete"); expect(txn).toBeDefined(); }); @@ -247,22 +284,26 @@ describe("standardRepository", () => { const pitch = await createParam("pitch", "numeric", "mm"); const majorDia = await createParam("major_dia", "numeric", "mm"); await aspectRepository.addParameter({ + ...testCtx, aspectId: aspect.id, parameterDefinitionId: pitch.id, }); await aspectRepository.addParameter({ + ...testCtx, aspectId: aspect.id, parameterDefinitionId: majorDia.id, }); const standard = await createStandardForAspect(aspect.id, "UNC"); await standardRepository.addParameter({ + orgId: testCtx.orgId, standardId: standard.id, parameterDefinitionId: pitch.id, role: "key", }); const aspects_ = await standardRepository.listAspectsForStandard({ + orgId: testCtx.orgId, standardId: standard.id, }); @@ -277,15 +318,18 @@ describe("standardRepository", () => { const drive = await createAspect("Fastener Drive"); const standard = await createStandard("ISO 4762"); await standardRepository.addAspect({ + ...testCtx, standardId: standard.id, aspectId: threading.id, }); await standardRepository.addAspect({ + ...testCtx, standardId: standard.id, aspectId: drive.id, }); const aspectList = await standardRepository.listAspectsForStandard({ + orgId: testCtx.orgId, standardId: standard.id, }); @@ -303,28 +347,32 @@ describe("standardRepository", () => { const pitch = await createParam("pitch", "numeric", "mm"); const majorDia = await createParam("major_dia", "numeric", "mm"); await aspectRepository.addParameter({ + ...testCtx, aspectId: aspect.id, parameterDefinitionId: pitch.id, }); await aspectRepository.addParameter({ + ...testCtx, aspectId: aspect.id, parameterDefinitionId: majorDia.id, }); const standard = await createStandardForAspect(aspect.id, "UNC"); await standardRepository.addParameter({ + orgId: testCtx.orgId, standardId: standard.id, parameterDefinitionId: pitch.id, role: "key", }); await standardRepository.createDesignation({ + orgId: testCtx.orgId, standardId: standard.id, designation: "#8-32", values: {}, }); const result = await standardRepository.listStandardsForAspectWithCoverage( - { aspectId: aspect.id } + { orgId: testCtx.orgId, aspectId: aspect.id } ); expect(result).toHaveLength(1); @@ -339,7 +387,7 @@ describe("standardRepository", () => { it("returns empty array for aspect with no linked standards", async () => { const aspect = await createAspect(); const result = await standardRepository.listStandardsForAspectWithCoverage( - { aspectId: aspect.id } + { orgId: testCtx.orgId, aspectId: aspect.id } ); expect(result).toHaveLength(0); }); @@ -350,6 +398,7 @@ describe("standardRepository", () => { const standard = await createStandard(); const updated = await standardRepository.update({ + ...testCtx, id: standard.id, description: "Unified National Coarse", domainTag: "Unified Thread Standard", @@ -362,11 +411,14 @@ describe("standardRepository", () => { it("logs a transaction", async () => { const standard = await createStandard(); await standardRepository.update({ + ...testCtx, id: standard.id, description: "updated", }); - const txns = await transactionRepository.listRecent(); + const txns = await transactionRepository.listRecent({ + orgId: testCtx.orgId, + }); const updateTxn = txns.find((t) => t.actionType === "standard.update"); expect(updateTxn).toBeDefined(); }); @@ -374,6 +426,7 @@ describe("standardRepository", () => { it("throws for nonexistent standard", async () => { await expect( standardRepository.update({ + ...testCtx, id: "00000000-0000-0000-0000-000000000000", name: "nope", }) @@ -384,17 +437,22 @@ describe("standardRepository", () => { describe("remove", () => { it("deletes the standard", async () => { const standard = await createStandard(); - await standardRepository.remove({ id: standard.id }); + await standardRepository.remove({ ...testCtx, id: standard.id }); - const found = await standardRepository.findById({ id: standard.id }); + const found = await standardRepository.findById({ + orgId: testCtx.orgId, + id: standard.id, + }); expect(found).toBeNull(); }); it("logs a transaction", async () => { const standard = await createStandard(); - await standardRepository.remove({ id: standard.id }); + await standardRepository.remove({ ...testCtx, id: standard.id }); - const txns = await transactionRepository.listRecent(); + const txns = await transactionRepository.listRecent({ + orgId: testCtx.orgId, + }); const deleteTxn = txns.find((t) => t.actionType === "standard.delete"); expect(deleteTxn).toBeDefined(); expect(deleteTxn!.entityId).toBe(standard.id); @@ -403,6 +461,7 @@ describe("standardRepository", () => { it("throws for nonexistent standard", async () => { await expect( standardRepository.remove({ + ...testCtx, id: "00000000-0000-0000-0000-000000000000", }) ).rejects.toThrow(); @@ -413,6 +472,7 @@ describe("standardRepository", () => { it("returns 0 when no items use the standard", async () => { const standard = await createStandard(); const count = await standardRepository.countItemsUsing({ + orgId: testCtx.orgId, standardId: standard.id, }); expect(count).toBe(0); @@ -421,19 +481,22 @@ describe("standardRepository", () => { it("counts items with the standard applied", async () => { const aspect = await createAspect(); const standard = await createStandardForAspect(aspect.id); - const item1 = await itemRepository.create({ name: "Screw A" }); - const item2 = await itemRepository.create({ name: "Screw B" }); + const item1 = await itemRepository.create({ ...testCtx, name: "Screw A" }); + const item2 = await itemRepository.create({ ...testCtx, name: "Screw B" }); await standardRepository.applyToItem({ + ...testCtx, itemId: item1.id, standardId: standard.id, }); await standardRepository.applyToItem({ + ...testCtx, itemId: item2.id, standardId: standard.id, }); const count = await standardRepository.countItemsUsing({ + orgId: testCtx.orgId, standardId: standard.id, }); expect(count).toBe(2); @@ -448,6 +511,7 @@ describe("standardRepository", () => { const pitch = await createParam("pitch", "numeric", "mm"); const sp = await standardRepository.addParameter({ + orgId: testCtx.orgId, standardId: standard.id, parameterDefinitionId: pitch.id, role: "key", @@ -463,12 +527,14 @@ describe("standardRepository", () => { const majorDia = await createParam("major_dia", "numeric", "mm"); await standardRepository.addParameter({ + orgId: testCtx.orgId, standardId: standard.id, parameterDefinitionId: pitch.id, role: "key", sortOrder: 0, }); await standardRepository.addParameter({ + orgId: testCtx.orgId, standardId: standard.id, parameterDefinitionId: majorDia.id, role: "derived", @@ -476,6 +542,7 @@ describe("standardRepository", () => { }); const params = await standardRepository.getParameters({ + orgId: testCtx.orgId, standardId: standard.id, }); expect(params).toHaveLength(2); @@ -491,17 +558,20 @@ describe("standardRepository", () => { const pitch = await createParam("pitch", "numeric", "mm"); await standardRepository.addParameter({ + orgId: testCtx.orgId, standardId: standard.id, parameterDefinitionId: pitch.id, role: "key", }); await standardRepository.removeParameter({ + orgId: testCtx.orgId, standardId: standard.id, parameterDefinitionId: pitch.id, }); const params = await standardRepository.getParameters({ + orgId: testCtx.orgId, standardId: standard.id, }); expect(params).toHaveLength(0); @@ -512,6 +582,7 @@ describe("standardRepository", () => { const pitch = await createParam("pitch", "numeric", "mm"); await standardRepository.addParameter({ + orgId: testCtx.orgId, standardId: standard.id, parameterDefinitionId: pitch.id, role: "key", @@ -519,6 +590,7 @@ describe("standardRepository", () => { await expect( standardRepository.addParameter({ + orgId: testCtx.orgId, standardId: standard.id, parameterDefinitionId: pitch.id, role: "derived", @@ -535,6 +607,7 @@ describe("standardRepository", () => { const pitch = await createParam("pitch", "numeric", "mm"); const designation = await standardRepository.createDesignation({ + orgId: testCtx.orgId, standardId: standard.id, designation: "#8-32", values: { @@ -557,6 +630,7 @@ describe("standardRepository", () => { for (const d of ["#4-40", "#6-32", "#8-32", "#10-24", "#10-32"]) { await standardRepository.createDesignation({ + orgId: testCtx.orgId, standardId: standard.id, designation: d, values: {}, @@ -564,6 +638,7 @@ describe("standardRepository", () => { } const page1 = await standardRepository.listDesignations({ + orgId: testCtx.orgId, standardId: standard.id, limit: 3, offset: 0, @@ -571,6 +646,7 @@ describe("standardRepository", () => { expect(page1).toHaveLength(3); const page2 = await standardRepository.listDesignations({ + orgId: testCtx.orgId, standardId: standard.id, limit: 3, offset: 3, @@ -582,17 +658,20 @@ describe("standardRepository", () => { const standard = await createStandard(); await standardRepository.createDesignation({ + orgId: testCtx.orgId, standardId: standard.id, designation: "#8-32", values: {}, }); await standardRepository.createDesignation({ + orgId: testCtx.orgId, standardId: standard.id, designation: "#10-24", values: {}, }); const count = await standardRepository.countDesignations({ + orgId: testCtx.orgId, standardId: standard.id, }); expect(count).toBe(2); @@ -601,12 +680,14 @@ describe("standardRepository", () => { it("updates designation values", async () => { const standard = await createStandard(); const designation = await standardRepository.createDesignation({ + orgId: testCtx.orgId, standardId: standard.id, designation: "#8-32", values: { old: true }, }); const updated = await standardRepository.updateDesignation({ + orgId: testCtx.orgId, id: designation.id, values: { new: true }, }); @@ -617,14 +698,19 @@ describe("standardRepository", () => { it("deletes a designation", async () => { const standard = await createStandard(); const designation = await standardRepository.createDesignation({ + orgId: testCtx.orgId, standardId: standard.id, designation: "#8-32", values: {}, }); - await standardRepository.removeDesignation({ id: designation.id }); + await standardRepository.removeDesignation({ + orgId: testCtx.orgId, + id: designation.id, + }); const found = await standardRepository.findDesignationById({ + orgId: testCtx.orgId, id: designation.id, }); expect(found).toBeNull(); @@ -634,6 +720,7 @@ describe("standardRepository", () => { const standard = await createStandard(); await standardRepository.createDesignation({ + orgId: testCtx.orgId, standardId: standard.id, designation: "#8-32", values: {}, @@ -641,6 +728,7 @@ describe("standardRepository", () => { await expect( standardRepository.createDesignation({ + orgId: testCtx.orgId, standardId: standard.id, designation: "#8-32", values: {}, @@ -653,11 +741,13 @@ describe("standardRepository", () => { const std2 = await createStandard("Pozidriv"); const d1 = await standardRepository.createDesignation({ + orgId: testCtx.orgId, standardId: std1.id, designation: "#2", values: { system: "Phillips" }, }); const d2 = await standardRepository.createDesignation({ + orgId: testCtx.orgId, standardId: std2.id, designation: "#2", values: { system: "Pozidriv" }, @@ -675,9 +765,10 @@ describe("standardRepository", () => { it("applies a standard to an item", async () => { const aspect = await createAspect(); const standard = await createStandardForAspect(aspect.id); - const item = await itemRepository.create({ name: "M3 Screw" }); + const item = await itemRepository.create({ ...testCtx, name: "M3 Screw" }); const is = await standardRepository.applyToItem({ + ...testCtx, itemId: item.id, standardId: standard.id, }); @@ -692,13 +783,18 @@ describe("standardRepository", () => { const aspect = await createAspect(); const standard = await createStandardForAspect(aspect.id); const designation = await standardRepository.createDesignation({ + orgId: testCtx.orgId, standardId: standard.id, designation: "#8-32", values: { pitch: 0.794 }, }); - const item = await itemRepository.create({ name: "#8-32 Screw" }); + const item = await itemRepository.create({ + ...testCtx, + name: "#8-32 Screw", + }); const is = await standardRepository.applyToItem({ + ...testCtx, itemId: item.id, standardId: standard.id, designationId: designation.id, @@ -710,14 +806,17 @@ describe("standardRepository", () => { it("logs a transaction", async () => { const aspect = await createAspect(); const standard = await createStandardForAspect(aspect.id); - const item = await itemRepository.create({ name: "Screw" }); + const item = await itemRepository.create({ ...testCtx, name: "Screw" }); await standardRepository.applyToItem({ + ...testCtx, itemId: item.id, standardId: standard.id, }); - const txns = await transactionRepository.listRecent(); + const txns = await transactionRepository.listRecent({ + orgId: testCtx.orgId, + }); const isTxn = txns.find( (t) => t.actionType === "item_standard.create" ); @@ -727,15 +826,17 @@ describe("standardRepository", () => { it("rejects duplicate standard on same item", async () => { const aspect = await createAspect(); const standard = await createStandardForAspect(aspect.id); - const item = await itemRepository.create({ name: "Screw" }); + const item = await itemRepository.create({ ...testCtx, name: "Screw" }); await standardRepository.applyToItem({ + ...testCtx, itemId: item.id, standardId: standard.id, }); await expect( standardRepository.applyToItem({ + ...testCtx, itemId: item.id, standardId: standard.id, }) @@ -746,24 +847,28 @@ describe("standardRepository", () => { const aspect = await createAspect(); const standard = await createStandardForAspect(aspect.id); const d1 = await standardRepository.createDesignation({ + orgId: testCtx.orgId, standardId: standard.id, designation: "#8-32", values: {}, }); const d2 = await standardRepository.createDesignation({ + orgId: testCtx.orgId, standardId: standard.id, designation: "#10-24", values: {}, }); - const item = await itemRepository.create({ name: "Screw" }); + const item = await itemRepository.create({ ...testCtx, name: "Screw" }); await standardRepository.applyToItem({ + ...testCtx, itemId: item.id, standardId: standard.id, designationId: d1.id, }); const updated = await standardRepository.setDesignation({ + orgId: testCtx.orgId, itemId: item.id, standardId: standard.id, designationId: d2.id, @@ -776,14 +881,16 @@ describe("standardRepository", () => { it("marks an item-standard as custom", async () => { const aspect = await createAspect(); const standard = await createStandardForAspect(aspect.id); - const item = await itemRepository.create({ name: "Screw" }); + const item = await itemRepository.create({ ...testCtx, name: "Screw" }); await standardRepository.applyToItem({ + ...testCtx, itemId: item.id, standardId: standard.id, }); const updated = await standardRepository.markCustom({ + orgId: testCtx.orgId, itemId: item.id, standardId: standard.id, }); @@ -796,21 +903,27 @@ describe("standardRepository", () => { const standard = await createStandardForAspect(aspect.id); const pitch = await createParam("pitch", "numeric", "mm"); const designation = await standardRepository.createDesignation({ + orgId: testCtx.orgId, standardId: standard.id, designation: "#8-32", values: { [pitch.id]: { value: 0.794, source_value: "32", source_unit: "TPI" }, }, }); - const item = await itemRepository.create({ name: "#8-32 Screw" }); + const item = await itemRepository.create({ + ...testCtx, + name: "#8-32 Screw", + }); await standardRepository.applyToItem({ + ...testCtx, itemId: item.id, standardId: standard.id, designationId: designation.id, }); const itemStds = await standardRepository.getItemStandards({ + orgId: testCtx.orgId, itemId: item.id, }); @@ -828,19 +941,22 @@ describe("standardRepository", () => { it("removes a standard from an item", async () => { const aspect = await createAspect(); const standard = await createStandardForAspect(aspect.id); - const item = await itemRepository.create({ name: "Screw" }); + const item = await itemRepository.create({ ...testCtx, name: "Screw" }); await standardRepository.applyToItem({ + ...testCtx, itemId: item.id, standardId: standard.id, }); await standardRepository.removeFromItem({ + ...testCtx, itemId: item.id, standardId: standard.id, }); const itemStds = await standardRepository.getItemStandards({ + orgId: testCtx.orgId, itemId: item.id, }); expect(itemStds).toHaveLength(0); @@ -852,6 +968,7 @@ describe("standardRepository", () => { const pitch = await createParam("pitch", "numeric", "mm"); const majorDia = await createParam("major_diameter", "numeric", "mm"); const designation = await standardRepository.createDesignation({ + orgId: testCtx.orgId, standardId: standard.id, designation: "M3x0.5", values: { @@ -859,15 +976,20 @@ describe("standardRepository", () => { [majorDia.id]: 3, }, }); - const item = await itemRepository.create({ name: "M3x0.5 screw" }); + const item = await itemRepository.create({ + ...testCtx, + name: "M3x0.5 screw", + }); await standardRepository.applyToItem({ + ...testCtx, itemId: item.id, standardId: standard.id, designationId: designation.id, }); const paramValues = await itemRepository.getParameterValues({ + orgId: testCtx.orgId, itemId: item.id, }); const byParam = new Map( @@ -882,29 +1004,34 @@ describe("standardRepository", () => { const standard = await createStandardForAspect(aspect.id); const pitch = await createParam("pitch", "numeric", "mm"); const d1 = await standardRepository.createDesignation({ + orgId: testCtx.orgId, standardId: standard.id, designation: "M3x0.5", values: { [pitch.id]: { value: 0.5 } }, }); const d2 = await standardRepository.createDesignation({ + orgId: testCtx.orgId, standardId: standard.id, designation: "M4x0.7", values: { [pitch.id]: { value: 0.7 } }, }); - const item = await itemRepository.create({ name: "Screw" }); + const item = await itemRepository.create({ ...testCtx, name: "Screw" }); await standardRepository.applyToItem({ + ...testCtx, itemId: item.id, standardId: standard.id, designationId: d1.id, }); await standardRepository.setDesignation({ + orgId: testCtx.orgId, itemId: item.id, standardId: standard.id, designationId: d2.id, }); const values = await itemRepository.getParameterValues({ + orgId: testCtx.orgId, itemId: item.id, }); const pitchRow = values.find((v) => v.parameterDefinitionId === pitch.id); @@ -915,19 +1042,22 @@ describe("standardRepository", () => { const aspect = await createAspect(); const standard = await createStandardForAspect(aspect.id); const designation = await standardRepository.createDesignation({ + orgId: testCtx.orgId, standardId: standard.id, designation: "legacy", values: { pitch: 0.5, "not-a-uuid": "x" }, }); - const item = await itemRepository.create({ name: "Legacy" }); + const item = await itemRepository.create({ ...testCtx, name: "Legacy" }); await standardRepository.applyToItem({ + ...testCtx, itemId: item.id, standardId: standard.id, designationId: designation.id, }); const values = await itemRepository.getParameterValues({ + orgId: testCtx.orgId, itemId: item.id, }); expect(values).toHaveLength(0); @@ -936,22 +1066,26 @@ describe("standardRepository", () => { it("filters designations by q substring (case-insensitive)", async () => { const standard = await createStandard(); await standardRepository.createDesignation({ + orgId: testCtx.orgId, standardId: standard.id, designation: "M3x0.5", values: {}, }); await standardRepository.createDesignation({ + orgId: testCtx.orgId, standardId: standard.id, designation: "M4x0.7", values: {}, }); await standardRepository.createDesignation({ + orgId: testCtx.orgId, standardId: standard.id, designation: "#8-32", values: {}, }); const hits = await standardRepository.listDesignations({ + orgId: testCtx.orgId, standardId: standard.id, q: "m3", }); @@ -962,18 +1096,22 @@ describe("standardRepository", () => { it("logs a transaction on removal", async () => { const aspect = await createAspect(); const standard = await createStandardForAspect(aspect.id); - const item = await itemRepository.create({ name: "Screw" }); + const item = await itemRepository.create({ ...testCtx, name: "Screw" }); await standardRepository.applyToItem({ + ...testCtx, itemId: item.id, standardId: standard.id, }); await standardRepository.removeFromItem({ + ...testCtx, itemId: item.id, standardId: standard.id, }); - const txns = await transactionRepository.listRecent(); + const txns = await transactionRepository.listRecent({ + orgId: testCtx.orgId, + }); const deleteTxn = txns.find( (t) => t.actionType === "item_standard.delete" ); @@ -987,14 +1125,16 @@ describe("standardRepository", () => { it("deleting a standard cascades to its designations", async () => { const standard = await createStandard(); const designation = await standardRepository.createDesignation({ + orgId: testCtx.orgId, standardId: standard.id, designation: "#8-32", values: {}, }); - await standardRepository.remove({ id: standard.id }); + await standardRepository.remove({ ...testCtx, id: standard.id }); const found = await standardRepository.findDesignationById({ + orgId: testCtx.orgId, id: designation.id, }); expect(found).toBeNull(); @@ -1003,16 +1143,18 @@ describe("standardRepository", () => { it("deleting a standard cascades to item_standards", async () => { const aspect = await createAspect(); const standard = await createStandardForAspect(aspect.id); - const item = await itemRepository.create({ name: "Screw" }); + const item = await itemRepository.create({ ...testCtx, name: "Screw" }); await standardRepository.applyToItem({ + ...testCtx, itemId: item.id, standardId: standard.id, }); - await standardRepository.remove({ id: standard.id }); + await standardRepository.remove({ ...testCtx, id: standard.id }); const itemStds = await standardRepository.getItemStandards({ + orgId: testCtx.orgId, itemId: item.id, }); expect(itemStds).toHaveLength(0); @@ -1022,21 +1164,27 @@ describe("standardRepository", () => { const aspect = await createAspect(); const standard = await createStandardForAspect(aspect.id); const designation = await standardRepository.createDesignation({ + orgId: testCtx.orgId, standardId: standard.id, designation: "#8-32", values: {}, }); - const item = await itemRepository.create({ name: "Screw" }); + const item = await itemRepository.create({ ...testCtx, name: "Screw" }); await standardRepository.applyToItem({ + ...testCtx, itemId: item.id, standardId: standard.id, designationId: designation.id, }); - await standardRepository.removeDesignation({ id: designation.id }); + await standardRepository.removeDesignation({ + orgId: testCtx.orgId, + id: designation.id, + }); const itemStds = await standardRepository.getItemStandards({ + orgId: testCtx.orgId, itemId: item.id, }); expect(itemStds).toHaveLength(1); @@ -1048,14 +1196,18 @@ describe("standardRepository", () => { const aspect = await createAspect(); const standard = await createStandardForAspect(aspect.id); - await aspectRepository.remove({ id: aspect.id }); + await aspectRepository.remove({ ...testCtx, id: aspect.id }); // Standard still exists - const found = await standardRepository.findById({ id: standard.id }); + const found = await standardRepository.findById({ + orgId: testCtx.orgId, + id: standard.id, + }); expect(found).not.toBeNull(); // But it's no longer linked to the aspect const list = await standardRepository.listByAspect({ + orgId: testCtx.orgId, aspectId: aspect.id, }); expect(list).toHaveLength(0); @@ -1065,9 +1217,10 @@ describe("standardRepository", () => { const aspect = await createAspect(); const standard = await createStandardForAspect(aspect.id); - await standardRepository.remove({ id: standard.id }); + await standardRepository.remove({ ...testCtx, id: standard.id }); const list = await standardRepository.listByAspect({ + orgId: testCtx.orgId, aspectId: aspect.id, }); expect(list).toHaveLength(0); diff --git a/web/tests/repositories/templateRepository.isolation.test.ts b/web/tests/repositories/templateRepository.isolation.test.ts new file mode 100644 index 0000000..5eff9f7 --- /dev/null +++ b/web/tests/repositories/templateRepository.isolation.test.ts @@ -0,0 +1,97 @@ +import { templateRepository } from "@/repositories/templateRepository"; +import { testCtx, createTestOrg } from "../setup"; + +describe("templateRepository isolation (additive)", () => { + it("list returns global + own, not other org's private", async () => { + const b = await createTestOrg({ slug: "other-org" }); + + await templateRepository.create({ + ...testCtx, + asGlobal: true, + name: "Global Tray", + }); + await templateRepository.create({ + ...testCtx, + name: "A Private", + }); + await templateRepository.create({ + ...b, + name: "B Private", + }); + + const aNames = (await templateRepository.list({ orgId: testCtx.orgId })) + .map((t) => t.name) + .sort(); + const bNames = (await templateRepository.list({ orgId: b.orgId })) + .map((t) => t.name) + .sort(); + + expect(aNames).toEqual(["A Private", "Global Tray"]); + expect(bNames).toEqual(["B Private", "Global Tray"]); + }); + + it("org A cannot fetch org B's private template by id", async () => { + const b = await createTestOrg({ slug: "other-org" }); + const bTemplate = await templateRepository.create({ + ...b, + name: "B Private", + }); + + const fromA = await templateRepository.findById({ + orgId: testCtx.orgId, + id: bTemplate.id, + }); + expect(fromA).toBeNull(); + }); + + it("org A can fetch a global template", async () => { + const globalT = await templateRepository.create({ + ...testCtx, + asGlobal: true, + name: "Global", + }); + const b = await createTestOrg({ slug: "other-org" }); + + const fromB = await templateRepository.findById({ + orgId: b.orgId, + id: globalT.id, + }); + expect(fromB?.id).toBe(globalT.id); + }); + + it("update on another org's private template throws not-found", async () => { + const b = await createTestOrg({ slug: "other-org" }); + const bTemplate = await templateRepository.create({ + ...b, + name: "B Private", + }); + + await expect( + templateRepository.update({ + ...testCtx, + id: bTemplate.id, + description: "hijack", + }), + ).rejects.toThrow("not found"); + }); + + it("listVersions returns only versions in scope", async () => { + const b = await createTestOrg({ slug: "other-org" }); + const bTemplate = await templateRepository.create({ + ...b, + name: "B Private", + }); + + const fromA = await templateRepository.listVersions({ + orgId: testCtx.orgId, + templateId: bTemplate.id, + }); + expect(fromA).toEqual([]); + + const fromB = await templateRepository.listVersions({ + orgId: b.orgId, + templateId: bTemplate.id, + }); + expect(fromB.length).toBeGreaterThan(0); + }); +}); diff --git a/web/tests/repositories/templateRepository.test.ts b/web/tests/repositories/templateRepository.test.ts index f719172..9ef0e21 100644 --- a/web/tests/repositories/templateRepository.test.ts +++ b/web/tests/repositories/templateRepository.test.ts @@ -2,11 +2,13 @@ import { templateRepository } from "@/repositories/templateRepository"; import { transactionRepository } from "@/repositories/transactionRepository"; import { moduleRepository } from "@/repositories/moduleRepository"; import { locationRepository } from "@/repositories/locationRepository"; +import { testCtx } from "../setup"; describe("templateRepository", () => { describe("create", () => { it("creates a template and returns it", async () => { const template = await templateRepository.create({ + ...testCtx, name: "Plano 3600", description: "Standard tackle box", }); @@ -19,10 +21,12 @@ describe("templateRepository", () => { it("auto-creates version 1", async () => { const template = await templateRepository.create({ + ...testCtx, name: "Plano 3600", }); const version = await templateRepository.getVersion({ + orgId: testCtx.orgId, templateId: template.id, version: 1, }); @@ -40,10 +44,11 @@ describe("templateRepository", () => { it("logs a transaction", async () => { const template = await templateRepository.create({ + ...testCtx, name: "Plano 3600", }); - const txns = await transactionRepository.listRecent(); + const txns = await transactionRepository.listRecent({ orgId: testCtx.orgId }); expect(txns).toHaveLength(1); expect(txns[0].actionType).toBe("template.create"); expect(txns[0].entityType).toBe("template"); @@ -53,21 +58,29 @@ describe("templateRepository", () => { it("stores metadata as JSON", async () => { const template = await templateRepository.create({ + ...testCtx, name: "Plano 3600", metadata: { manufacturer: "Plano", sku: "3600" }, }); - const found = await templateRepository.findById({ id: template.id }); + const found = await templateRepository.findById({ + orgId: testCtx.orgId, + id: template.id, + }); expect(found?.metadata).toEqual({ manufacturer: "Plano", sku: "3600" }); }); it("defaults scope to shared", async () => { - const template = await templateRepository.create({ name: "Plano 3600" }); + const template = await templateRepository.create({ + ...testCtx, + name: "Plano 3600", + }); expect(template.scope).toBe("shared"); }); it("accepts scope=single_instance for ad-hoc templates", async () => { const template = await templateRepository.create({ + ...testCtx, name: "ad-hoc shelf", scope: "single_instance", }); @@ -76,6 +89,7 @@ describe("templateRepository", () => { it("stores continuous-dimension capacity on the version", async () => { const template = await templateRepository.create({ + ...testCtx, name: "Akro-Mils 30636", isContinuous: true, widthMm: 914.4, // 36 in @@ -85,6 +99,7 @@ describe("templateRepository", () => { }); const version = await templateRepository.getVersion({ + orgId: testCtx.orgId, templateId: template.id, version: 1, }); @@ -98,6 +113,7 @@ describe("templateRepository", () => { it("stores bufferMm on insert templates", async () => { const template = await templateRepository.create({ + ...testCtx, name: "Akro-Mils 30220", isContinuous: true, widthMm: 104.775, // 4⅛ in @@ -105,6 +121,7 @@ describe("templateRepository", () => { }); const version = await templateRepository.getVersion({ + orgId: testCtx.orgId, templateId: template.id, version: 1, }); @@ -116,16 +133,21 @@ describe("templateRepository", () => { describe("findById", () => { it("returns the template by ID", async () => { const created = await templateRepository.create({ + ...testCtx, name: "Plano 3600", }); - const found = await templateRepository.findById({ id: created.id }); + const found = await templateRepository.findById({ + orgId: testCtx.orgId, + id: created.id, + }); expect(found).not.toBeNull(); expect(found!.name).toBe("Plano 3600"); }); it("returns null for nonexistent ID", async () => { const found = await templateRepository.findById({ + orgId: testCtx.orgId, id: "00000000-0000-0000-0000-000000000000", }); expect(found).toBeNull(); @@ -135,11 +157,13 @@ describe("templateRepository", () => { describe("findByName", () => { it("returns the template by name", async () => { await templateRepository.create({ + ...testCtx, name: "Gridfinity 42mm", description: "Standard gridfinity baseplate", }); const found = await templateRepository.findByName({ + orgId: testCtx.orgId, name: "Gridfinity 42mm", }); expect(found).not.toBeNull(); @@ -147,76 +171,97 @@ describe("templateRepository", () => { }); it("returns null for nonexistent name", async () => { - const found = await templateRepository.findByName({ name: "GHOST" }); + const found = await templateRepository.findByName({ + orgId: testCtx.orgId, + name: "GHOST", + }); expect(found).toBeNull(); }); }); describe("list", () => { it("returns all templates", async () => { - await templateRepository.create({ name: "Plano 3600" }); - await templateRepository.create({ name: "Gridfinity 42mm" }); + await templateRepository.create({ ...testCtx, name: "Plano 3600" }); + await templateRepository.create({ ...testCtx, name: "Gridfinity 42mm" }); - const all = await templateRepository.list(); + const all = await templateRepository.list({ orgId: testCtx.orgId }); expect(all).toHaveLength(2); }); it("returns empty array when no templates exist", async () => { - const all = await templateRepository.list(); + const all = await templateRepository.list({ orgId: testCtx.orgId }); expect(all).toHaveLength(0); }); it("excludes hidden templates by default", async () => { - const a = await templateRepository.create({ name: "Shown" }); - await templateRepository.create({ name: "Hidden" }).then((t) => - templateRepository.hide({ id: t.id }) - ); - const all = await templateRepository.list(); + const a = await templateRepository.create({ ...testCtx, name: "Shown" }); + await templateRepository + .create({ ...testCtx, name: "Hidden" }) + .then((t) => templateRepository.hide({ ...testCtx, id: t.id })); + const all = await templateRepository.list({ orgId: testCtx.orgId }); expect(all).toHaveLength(1); expect(all[0].id).toBe(a.id); }); it("includes hidden when includeHidden=true", async () => { - await templateRepository.create({ name: "Shown" }); - await templateRepository.create({ name: "Hidden" }).then((t) => - templateRepository.hide({ id: t.id }) - ); - const all = await templateRepository.list({ includeHidden: true }); + await templateRepository.create({ ...testCtx, name: "Shown" }); + await templateRepository + .create({ ...testCtx, name: "Hidden" }) + .then((t) => templateRepository.hide({ ...testCtx, id: t.id })); + const all = await templateRepository.list({ + orgId: testCtx.orgId, + includeHidden: true, + }); expect(all).toHaveLength(2); }); }); describe("hide / unhide / getReferenceCount", () => { it("hides and unhides a template", async () => { - const t = await templateRepository.create({ name: "Plano 3600" }); + const t = await templateRepository.create({ + ...testCtx, + name: "Plano 3600", + }); expect(t.isHidden).toBe(false); - const hidden = await templateRepository.hide({ id: t.id }); + const hidden = await templateRepository.hide({ ...testCtx, id: t.id }); expect(hidden.isHidden).toBe(true); - const shown = await templateRepository.unhide({ id: t.id }); + const shown = await templateRepository.unhide({ ...testCtx, id: t.id }); expect(shown.isHidden).toBe(false); }); it("counts zero references for unused templates", async () => { - const t = await templateRepository.create({ name: "Plano 3600" }); - const refs = await templateRepository.getReferenceCount({ id: t.id }); + const t = await templateRepository.create({ + ...testCtx, + name: "Plano 3600", + }); + const refs = await templateRepository.getReferenceCount({ + orgId: testCtx.orgId, + id: t.id, + }); expect(refs.insertCount).toBe(0); expect(refs.locationCount).toBe(0); }); it("counts location references through any version", async () => { - const t = await templateRepository.create({ name: "Plano 3600" }); + const t = await templateRepository.create({ + ...testCtx, + name: "Plano 3600", + }); const mod = await moduleRepository.create({ + ...testCtx, name: "MUSE", primaryDimensionLabel: "level", primaryDimensionCount: 1, }); const v1 = await templateRepository.getVersion({ + orgId: testCtx.orgId, templateId: t.id, version: 1, }); await locationRepository.create({ + ...testCtx, moduleId: mod.id, label: "1", pathSegments: ["MUSE", "1"], @@ -224,7 +269,10 @@ describe("templateRepository", () => { templateVersionId: v1!.id, }); - const refs = await templateRepository.getReferenceCount({ id: t.id }); + const refs = await templateRepository.getReferenceCount({ + orgId: testCtx.orgId, + id: t.id, + }); expect(refs.locationCount).toBe(1); expect(refs.insertCount).toBe(0); }); @@ -233,10 +281,12 @@ describe("templateRepository", () => { describe("update", () => { it("updates fields and returns the updated template", async () => { const created = await templateRepository.create({ + ...testCtx, name: "Plano 3600", }); const updated = await templateRepository.update({ + ...testCtx, id: created.id, description: "Updated description", }); @@ -247,15 +297,17 @@ describe("templateRepository", () => { it("logs a transaction with before and after state", async () => { const created = await templateRepository.create({ + ...testCtx, name: "Plano 3600", }); await templateRepository.update({ + ...testCtx, id: created.id, description: "Updated", }); - const txns = await transactionRepository.listRecent(); + const txns = await transactionRepository.listRecent({ orgId: testCtx.orgId }); const updateTx = txns.find((t) => t.actionType === "template.update"); expect(updateTx).toBeDefined(); expect(updateTx!.beforeState).toBeTruthy(); @@ -265,6 +317,7 @@ describe("templateRepository", () => { it("throws for nonexistent template", async () => { await expect( templateRepository.update({ + ...testCtx, id: "00000000-0000-0000-0000-000000000000", name: "GHOST", }) @@ -275,29 +328,36 @@ describe("templateRepository", () => { describe("remove", () => { it("deletes the template", async () => { const created = await templateRepository.create({ + ...testCtx, name: "Plano 3600", }); - await templateRepository.remove({ id: created.id }); + await templateRepository.remove({ ...testCtx, id: created.id }); - const found = await templateRepository.findById({ id: created.id }); + const found = await templateRepository.findById({ + orgId: testCtx.orgId, + id: created.id, + }); expect(found).toBeNull(); }); it("deletes associated versions", async () => { const created = await templateRepository.create({ + ...testCtx, name: "Plano 3600", }); await templateRepository.publishVersion({ + ...testCtx, templateId: created.id, rows: 4, columns: 6, }); - await templateRepository.remove({ id: created.id }); + await templateRepository.remove({ ...testCtx, id: created.id }); const versions = await templateRepository.listVersions({ + orgId: testCtx.orgId, templateId: created.id, }); expect(versions).toHaveLength(0); @@ -305,12 +365,13 @@ describe("templateRepository", () => { it("logs a transaction", async () => { const created = await templateRepository.create({ + ...testCtx, name: "Plano 3600", }); - await templateRepository.remove({ id: created.id }); + await templateRepository.remove({ ...testCtx, id: created.id }); - const txns = await transactionRepository.listRecent(); + const txns = await transactionRepository.listRecent({ orgId: testCtx.orgId }); const deleteTx = txns.find((t) => t.actionType === "template.delete"); expect(deleteTx).toBeDefined(); expect(deleteTx!.afterState).toBeNull(); @@ -319,6 +380,7 @@ describe("templateRepository", () => { it("throws for nonexistent template", async () => { await expect( templateRepository.remove({ + ...testCtx, id: "00000000-0000-0000-0000-000000000000", }) ).rejects.toThrow("not found"); @@ -328,10 +390,12 @@ describe("templateRepository", () => { describe("publishVersion", () => { it("creates a new version with incremented version number", async () => { const template = await templateRepository.create({ + ...testCtx, name: "Plano 3600", }); const v2 = await templateRepository.publishVersion({ + ...testCtx, templateId: template.id, rows: 4, columns: 6, @@ -345,31 +409,39 @@ describe("templateRepository", () => { it("updates template currentVersion", async () => { const template = await templateRepository.create({ + ...testCtx, name: "Plano 3600", }); await templateRepository.publishVersion({ + ...testCtx, templateId: template.id, rows: 4, columns: 6, }); - const updated = await templateRepository.findById({ id: template.id }); + const updated = await templateRepository.findById({ + orgId: testCtx.orgId, + id: template.id, + }); expect(updated!.currentVersion).toBe(2); }); it("preserves old version when new version is published", async () => { const template = await templateRepository.create({ + ...testCtx, name: "Plano 3600", }); await templateRepository.publishVersion({ + ...testCtx, templateId: template.id, rows: 4, columns: 6, }); const v1 = await templateRepository.getVersion({ + orgId: testCtx.orgId, templateId: template.id, version: 1, }); @@ -379,10 +451,12 @@ describe("templateRepository", () => { it("supports parametric templates", async () => { const template = await templateRepository.create({ + ...testCtx, name: "Gridfinity Base", }); const v2 = await templateRepository.publishVersion({ + ...testCtx, templateId: template.id, isParametric: true, minRows: 1, @@ -398,16 +472,18 @@ describe("templateRepository", () => { it("logs a transaction", async () => { const template = await templateRepository.create({ + ...testCtx, name: "Plano 3600", }); await templateRepository.publishVersion({ + ...testCtx, templateId: template.id, rows: 4, columns: 6, }); - const txns = await transactionRepository.listRecent(); + const txns = await transactionRepository.listRecent({ orgId: testCtx.orgId }); const pubTx = txns.find( (t) => t.actionType === "template.publishVersion" ); @@ -418,6 +494,7 @@ describe("templateRepository", () => { it("throws for nonexistent template", async () => { await expect( templateRepository.publishVersion({ + ...testCtx, templateId: "00000000-0000-0000-0000-000000000000", rows: 4, columns: 6, @@ -429,10 +506,12 @@ describe("templateRepository", () => { describe("getVersion", () => { it("returns a specific version", async () => { const template = await templateRepository.create({ + ...testCtx, name: "Plano 3600", }); const v1 = await templateRepository.getVersion({ + orgId: testCtx.orgId, templateId: template.id, version: 1, }); @@ -444,10 +523,12 @@ describe("templateRepository", () => { it("returns null for nonexistent version", async () => { const template = await templateRepository.create({ + ...testCtx, name: "Plano 3600", }); const v99 = await templateRepository.getVersion({ + orgId: testCtx.orgId, templateId: template.id, version: 99, }); @@ -459,22 +540,26 @@ describe("templateRepository", () => { describe("listVersions", () => { it("lists all versions for a template", async () => { const template = await templateRepository.create({ + ...testCtx, name: "Plano 3600", }); await templateRepository.publishVersion({ + ...testCtx, templateId: template.id, rows: 4, columns: 6, }); await templateRepository.publishVersion({ + ...testCtx, templateId: template.id, rows: 5, columns: 7, }); const versions = await templateRepository.listVersions({ + orgId: testCtx.orgId, templateId: template.id, }); expect(versions).toHaveLength(3); // v1 (auto) + v2 + v3 @@ -482,6 +567,7 @@ describe("templateRepository", () => { it("returns empty array for template with no versions (nonexistent template)", async () => { const versions = await templateRepository.listVersions({ + orgId: testCtx.orgId, templateId: "00000000-0000-0000-0000-000000000000", }); expect(versions).toHaveLength(0); diff --git a/web/tests/setup.ts b/web/tests/setup.ts index b860330..03e03cd 100644 --- a/web/tests/setup.ts +++ b/web/tests/setup.ts @@ -1,5 +1,6 @@ import { sql } from "drizzle-orm"; import { db } from "@/db/connection"; +import { users, orgs, userOrgs, type OrgRole } from "@/db/schema"; // Safety guard: refuse to TRUNCATE anything that doesn't look like a // test database. Historical footgun: the default connection string @@ -34,6 +35,50 @@ async function cleanDatabase() { `); } +// Default test scope — seeded fresh before every test so repo calls +// have a user + org to scope by. Tests spread `testCtx` into repo +// args: `moduleRepository.create({ ...testCtx, name: "MUSE" })`. +export const testCtx: { userId: string; orgId: string } = { + userId: "", + orgId: "", +}; + +// Mint an extra org (with its own owner user) for isolation tests. +export async function createTestOrg({ + slug, + plan = "paid", +}: { + slug: string; + plan?: "free" | "paid"; +}): Promise<{ userId: string; orgId: string }> { + const [u] = await db + .insert(users) + .values({ email: `${slug}@wheretf.local`, name: slug }) + .returning(); + const [o] = await db + .insert(orgs) + .values({ name: slug, slug, plan }) + .returning(); + await db.insert(userOrgs).values({ + userId: u.id, + orgId: o.id, + role: "owner" satisfies OrgRole, + }); + return { userId: u.id, orgId: o.id }; +} + +// Use a unique slug per test so any cleanup hiccup doesn't produce a +// duplicate-key error on users.email / orgs.slug on the next beforeEach. +let testSeq = 0; + +beforeEach(async () => { + testSeq += 1; + const slug = `tctx-${testSeq}-${Math.random().toString(36).slice(2, 8)}`; + const seeded = await createTestOrg({ slug }); + testCtx.userId = seeded.userId; + testCtx.orgId = seeded.orgId; +}); + afterEach(async () => { await cleanDatabase(); });