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 && (
+
+ )}
+
+ );
+}
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();
});