diff --git a/apps/web-next/package.json b/apps/web-next/package.json index 38508d3..c6fdc50 100644 --- a/apps/web-next/package.json +++ b/apps/web-next/package.json @@ -19,6 +19,7 @@ "@repo/core-cms": "workspace:*", "@repo/core-shared": "workspace:*", "@repo/core-trpc": "workspace:^", + "@repo/workspaces": "workspace:*", "@sentry/nextjs": "^10.51.0", "@tailwindcss/postcss": "^4.3.0", "@tanstack/react-query": "^5.96.2", diff --git a/apps/web-next/src/server/bind-production.smoke.test.ts b/apps/web-next/src/server/bind-production.smoke.test.ts index 1d39d8a..bb28764 100644 --- a/apps/web-next/src/server/bind-production.smoke.test.ts +++ b/apps/web-next/src/server/bind-production.smoke.test.ts @@ -1,6 +1,6 @@ // Dev-seed boot smoke (platform-retrofit story 04): prove `bindAll()` boots -// the app with exactly the auth feature bound — no dangling DI symbols from -// the deleted demo features. +// the app with exactly the expected features bound — no dangling DI symbols +// from the deleted demo features. // // Unlike bind-production.test.ts (which mocks the per-feature binders to test // dispatcher routing), this file runs the REAL auth dev-seed binder, so the @@ -19,7 +19,7 @@ vi.mock("payload", () => ({ getPayload: vi.fn(async () => ({ jobs: { queue: vi.fn() } })), })); -describe("dev-seed boot smoke — bindAll() binds exactly the auth feature", () => { +describe("dev-seed boot smoke — bindAll() binds exactly the expected features", () => { beforeEach(() => { vi.resetModules(); vi.unstubAllEnvs(); @@ -82,7 +82,7 @@ describe("dev-seed boot smoke — bindAll() binds exactly the auth feature", () expect(cookie.value).not.toBe(""); }); - it("wires auth and nothing else — the dispatcher imports exactly one feature's binders", () => { + it("wires auth + workspaces and nothing else — the dispatcher imports exactly the expected features' binders", () => { // vitest runs with cwd at the package root (jsdom rewrites // import.meta.url to an http: URL, so resolve from cwd instead). const source = readFileSync( @@ -94,6 +94,6 @@ describe("dev-seed boot smoke — bindAll() binds exactly the auth feature", () ); expect(features.length).toBeGreaterThan(0); - expect([...new Set(features)].sort()).toEqual(["auth"]); + expect([...new Set(features)].sort()).toEqual(["auth", "workspaces"]); }); }); diff --git a/apps/web-next/src/server/bind-production.ts b/apps/web-next/src/server/bind-production.ts index b32a512..2fd3e25 100644 --- a/apps/web-next/src/server/bind-production.ts +++ b/apps/web-next/src/server/bind-production.ts @@ -19,6 +19,8 @@ import { import { NoopRateLimit } from "@repo/core-shared/rate-limit"; import { bindProductionAuth } from "@repo/auth/di/bind-production"; import { bindDevSeedAuth } from "@repo/auth/di/bind-dev-seed"; +import { bindProductionWorkspaces } from "@repo/workspaces/di/bind-production"; +import { bindDevSeedWorkspaces } from "@repo/workspaces/di/bind-dev-seed"; let bindPromise: Promise | null = null; @@ -92,6 +94,7 @@ export async function bindAllProduction(): Promise { }; bindProductionAuth(ctx); + bindProductionWorkspaces(ctx); } /** @@ -111,6 +114,7 @@ export async function bindAllDevSeed(): Promise { }; await bindDevSeedAuth(ctx); + await bindDevSeedWorkspaces(ctx); } /** diff --git a/packages/core-api/package.json b/packages/core-api/package.json index f4b48e4..e1c1f6a 100644 --- a/packages/core-api/package.json +++ b/packages/core-api/package.json @@ -17,6 +17,7 @@ "@repo/core-consent": "workspace:*", "@repo/core-dsr": "workspace:*", "@repo/core-shared": "workspace:*", + "@repo/workspaces": "workspace:*", "@trpc/server": "^11.0.0" }, "devDependencies": { diff --git a/packages/core-api/src/root.ts b/packages/core-api/src/root.ts index 1ffc133..89e1703 100644 --- a/packages/core-api/src/root.ts +++ b/packages/core-api/src/root.ts @@ -1,10 +1,12 @@ import { router } from "@repo/core-shared/trpc/init"; import { authRouter } from "@repo/auth/api"; +import { workspacesRouter } from "@repo/workspaces/api"; import { dsrRouter } from "@repo/core-dsr"; import { consentRouter } from "@repo/core-consent"; export const appRouter = router({ auth: authRouter, + workspaces: workspacesRouter, // gen:routers — optional-core routers composed below dsr: dsrRouter, consent: consentRouter, diff --git a/packages/workspaces/AGENTS.md b/packages/workspaces/AGENTS.md new file mode 100644 index 0000000..79bcbd0 --- /dev/null +++ b/packages/workspaces/AGENTS.md @@ -0,0 +1,42 @@ +# AGENTS.md — workspaces + +Feature package scaffolded by `turbo gen feature`. Provides domain logic, repositories, use cases, controllers and the tRPC router for `Workspace`. + +## Overview + +`@repo/workspaces` owns: the `Workspace` domain model, feature-scoped errors, the `IWorkspaceRepository` interface, one use case (`getWorkspaceUseCase`), one controller, a real Payload-backed repository (stub body — fill in once the Payload collection is added), an in-memory mock repository, and the tRPC `workspacesRouter`. + +## Layer responsibilities + +| Layer | Key files | +| ---------------------------------- | ------------------------------------------------------------------------------------------------------ | +| **entities/models** | `workspace.ts` — `Workspace` Zod schema + type | +| **entities/errors** | `workspace.ts` (WorkspaceNotFoundError), `common.ts` (InputParseError) | +| **application/use-cases** | `get-workspace.use-case.ts` — factory function + exported schemas | +| **application/repositories** | `workspace.repository.interface.ts` — `IWorkspaceRepository` | +| **infrastructure/repositories** | `workspace.repository.ts` (real Payload-backed; stub body), `workspace.repository.mock.ts` (in-memory) | +| **interface-adapters/controllers** | `get-workspace.controller.ts` — one file per use case | +| **di** | `symbols.ts`, `module.ts`, `container.ts`, `bind-production.ts`, `bind-dev-seed.ts` | +| **integrations/api** | `procedures.ts` (workspacesProcedure), `router.ts` (workspacesRouter) | + +## Public exports + +| Subpath | Contents | +| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `.` | `Workspace` type; `WorkspaceNotFoundError`, `InputParseError`; `getWorkspaceInputSchema`, `getWorkspaceOutputSchema`, `GetWorkspaceInput`, `GetWorkspaceOutput`, `IGetWorkspaceUseCase`; `IGetWorkspaceController` type alias; `WorkspacesRouter` type | +| `./api` | `workspacesRouter` (tRPC router) | +| `./di/bind-production` | `bindProductionWorkspaces(config, tracer, logger)` | +| `./di/bind-dev-seed` | `bindDevSeedWorkspaces(tracer, logger)` | +| `./ui` | (reserved — empty barrel) | + +## Tests + +```bash +pnpm test --filter @repo/workspaces +``` + +## What it must NOT import + +- Any other feature package +- Any app package +- `@repo/core-api`, `@repo/core-cms`, `@repo/core-trpc`, `@repo/core-ui` directly; only `@repo/core-shared` diff --git a/packages/workspaces/CHANGELOG.md b/packages/workspaces/CHANGELOG.md new file mode 100644 index 0000000..a3bab4f --- /dev/null +++ b/packages/workspaces/CHANGELOG.md @@ -0,0 +1,9 @@ +# Changelog — @repo/workspaces + +All notable changes to the `workspaces` feature package. Maintained by [release-please](https://github.com/googleapis/release-please) on merges to `main`. See [ADR-021](../../docs/decisions/adr-021-versioning-and-changelog.md) and [`docs/guides/releasing.md`](../../docs/guides/releasing.md). + +## 0.1.0 (Initial) + +### Initial baseline + +The `workspaces` feature scaffolded via `pnpm turbo gen feature workspaces`. Tracked at v0.1.0 in the release-please manifest; future entries appear above this section as release-please assembles them from conventional commits scoped to `packages/workspaces/**`. diff --git a/packages/workspaces/eslint.config.js b/packages/workspaces/eslint.config.js new file mode 100644 index 0000000..7440d8f --- /dev/null +++ b/packages/workspaces/eslint.config.js @@ -0,0 +1,3 @@ +import baseConfig from "@repo/core-eslint/base"; + +export default baseConfig; diff --git a/packages/workspaces/package.json b/packages/workspaces/package.json new file mode 100644 index 0000000..3f678db --- /dev/null +++ b/packages/workspaces/package.json @@ -0,0 +1,36 @@ +{ + "name": "@repo/workspaces", + "private": true, + "version": "0.1.0", + "type": "module", + "exports": { + ".": "./src/index.ts", + "./ui": "./src/ui/index.ts", + "./cms": "./src/integrations/cms/index.ts", + "./api": "./src/integrations/api/router.ts", + "./di/bind-production": "./src/di/bind-production.ts", + "./di/bind-dev-seed": "./src/di/bind-dev-seed.ts" + }, + "scripts": { + "build": "tsc --noEmit", + "lint": "eslint .", + "test": "vitest run --passWithNoTests", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@repo/core-shared": "workspace:*", + "@trpc/server": "^11.0.0", + "inversify": "^6.2.0", + "payload": "^3.14.0", + "reflect-metadata": "^0.2.2", + "zod": "^3.24.0" + }, + "devDependencies": { + "@repo/core-eslint": "workspace:*", + "@repo/core-testing": "workspace:*", + "@repo/core-typescript": "workspace:*", + "@types/node": "^22.0.0", + "@vitest/coverage-v8": "^3.2.4", + "vitest": "^3.1.0" + } +} diff --git a/packages/workspaces/src/__contracts__/workspace-repository.contract.ts b/packages/workspaces/src/__contracts__/workspace-repository.contract.ts new file mode 100644 index 0000000..737a8b6 --- /dev/null +++ b/packages/workspaces/src/__contracts__/workspace-repository.contract.ts @@ -0,0 +1,58 @@ +import { it, expect, beforeEach, describe } from "vitest"; +import { defineContractSuite } from "@repo/core-testing/contract"; +import type { IWorkspaceRepository } from "../application/repositories/workspace.repository.interface"; +import type { Workspace } from "../entities/models/workspace"; + +/** + * Known fixtures every implementation's `buildSubject` must pre-seed. + * Exported so test files can pass them to `MockWorkspaceRepository` or the + * Payload stub without duplicating definitions. + */ +export const CONTRACT_WORKSPACE_SEED: ReadonlyArray< + readonly [string, Workspace] +> = [ + ["seed-1", { id: "seed-1", name: "Seed One" }], + ["seed-2", { id: "seed-2", name: "Seed Two" }], +]; + +/** + * Contract for IWorkspaceRepository. + * + * The interface exposes only `getWorkspace(id)`. The contract verifies + * found vs missing behaviour and span emission. + */ +export const workspaceRepositoryContract = + defineContractSuite( + "IWorkspaceRepository", + ({ buildSubject, getTracer }) => { + let repo: IWorkspaceRepository; + + beforeEach(async () => { + repo = await buildSubject(); + }); + + it("getWorkspace returns the seeded workspace when id exists", async () => { + const result = await repo.getWorkspace("seed-1"); + expect(result).not.toBeNull(); + expect(result?.id).toBe("seed-1"); + expect(typeof result?.name).toBe("string"); + }); + + it("getWorkspace returns null for an unknown id", async () => { + const result = await repo.getWorkspace("does-not-exist"); + expect(result).toBeNull(); + }); + + describe("span emission", () => { + it("getWorkspace emits span 'workspace.getWorkspace' with op=repository", async () => { + if (!getTracer) return; + const tracer = getTracer(); + tracer.reset(); + await repo.getWorkspace("seed-1"); + const span = tracer.findSpan("workspace.getWorkspace"); + expect(span).toBeDefined(); + expect(span!.op).toBe("repository"); + }); + }); + }, + ); diff --git a/packages/workspaces/src/__factories__/index.ts b/packages/workspaces/src/__factories__/index.ts new file mode 100644 index 0000000..db178e6 --- /dev/null +++ b/packages/workspaces/src/__factories__/index.ts @@ -0,0 +1,3 @@ +// Phase-1 placeholder. Add faker-driven factories here once the entity +// shape stabilises. See packages/blog/src/__factories__/ for the pattern. +export {}; diff --git a/packages/workspaces/src/__factories__/workspace.factory.ts b/packages/workspaces/src/__factories__/workspace.factory.ts new file mode 100644 index 0000000..ac4c389 --- /dev/null +++ b/packages/workspaces/src/__factories__/workspace.factory.ts @@ -0,0 +1,4 @@ +// Phase-1 stub. Replace with `defineFactory` once the +// entity shape stabilises. See packages/auth/src/__factories__/ for an +// example. +export {}; diff --git a/packages/workspaces/src/__seeds__/dev.ts b/packages/workspaces/src/__seeds__/dev.ts new file mode 100644 index 0000000..cd04ec3 --- /dev/null +++ b/packages/workspaces/src/__seeds__/dev.ts @@ -0,0 +1,16 @@ +import type { Workspace } from "../entities/models/workspace"; + +/** + * Realistic dev seed for `bindDevSeedWorkspaces`. + * + * Phase-1: returns a small hand-rolled Map. Replace with a faker-driven + * `defineFactory` (see `packages/blog/src/__factories__/`) once the entity + * shape stabilises. + */ +export function buildDevWorkspaceMap(): Map { + return new Map([ + ["dev-1", { id: "dev-1", name: "Dev One" }], + ["dev-2", { id: "dev-2", name: "Dev Two" }], + ["dev-3", { id: "dev-3", name: "Dev Three" }], + ]); +} diff --git a/packages/workspaces/src/application/repositories/workspace.repository.interface.ts b/packages/workspaces/src/application/repositories/workspace.repository.interface.ts new file mode 100644 index 0000000..460b6ac --- /dev/null +++ b/packages/workspaces/src/application/repositories/workspace.repository.interface.ts @@ -0,0 +1,5 @@ +import type { Workspace } from "../../entities/models/workspace"; + +export interface IWorkspaceRepository { + getWorkspace(id: string): Promise; +} diff --git a/packages/workspaces/src/application/use-cases/get-workspace.use-case.test.ts b/packages/workspaces/src/application/use-cases/get-workspace.use-case.test.ts new file mode 100644 index 0000000..983e18b --- /dev/null +++ b/packages/workspaces/src/application/use-cases/get-workspace.use-case.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { ZodError } from "zod"; +import { getWorkspaceUseCase } from "@/application/use-cases/get-workspace.use-case"; +import { MockWorkspaceRepository } from "@/infrastructure/repositories/workspace.repository.mock"; +import { WorkspaceNotFoundError } from "@/entities/errors/workspace"; + +describe("getWorkspaceUseCase", () => { + it("returns the seeded workspace by id", async () => { + const repo = new MockWorkspaceRepository(); + const useCase = getWorkspaceUseCase(repo); + const result = await useCase({ id: "seed-1" }); + expect(result.id).toBe("seed-1"); + expect(result.name).toBeTypeOf("string"); + }); + + it("throws WorkspaceNotFoundError when repository returns null", async () => { + const repo = new MockWorkspaceRepository(new Map()); + const useCase = getWorkspaceUseCase(repo); + await expect(useCase({ id: "missing" })).rejects.toBeInstanceOf( + WorkspaceNotFoundError, + ); + }); + + it("throws ZodError when repository returns malformed data", async () => { + const malformedRepo = { + getWorkspace: async () => ({ id: "", name: "x" }) as never, + }; + const useCase = getWorkspaceUseCase(malformedRepo); + await expect(useCase({ id: "anything" })).rejects.toBeInstanceOf(ZodError); + }); +}); diff --git a/packages/workspaces/src/application/use-cases/get-workspace.use-case.ts b/packages/workspaces/src/application/use-cases/get-workspace.use-case.ts new file mode 100644 index 0000000..d4c32aa --- /dev/null +++ b/packages/workspaces/src/application/use-cases/get-workspace.use-case.ts @@ -0,0 +1,30 @@ +import { z } from "zod"; + +import { WorkspaceNotFoundError } from "../../entities/errors/workspace"; +import { workspaceSchema } from "../../entities/models/workspace"; +import type { IWorkspaceRepository } from "../repositories/workspace.repository.interface"; + +// ── Input ──────────────────────────────────────────────────────────────── +export const getWorkspaceInputSchema = z + .object({ + id: z.string().min(1), + }) + .strict(); +export type GetWorkspaceInput = z.infer; + +// ── Output ─────────────────────────────────────────────────────────────── +export const getWorkspaceOutputSchema = workspaceSchema; +export type GetWorkspaceOutput = z.infer; + +// ── Use case ───────────────────────────────────────────────────────────── +export type IGetWorkspaceUseCase = ReturnType; + +export const getWorkspaceUseCase = + (workspaceRepository: IWorkspaceRepository) => + async (input: GetWorkspaceInput): Promise => { + const result = await workspaceRepository.getWorkspace(input.id); + if (!result) { + throw new WorkspaceNotFoundError(`Workspace not found: ${input.id}`); + } + return getWorkspaceOutputSchema.parse(result); + }; diff --git a/packages/workspaces/src/di/bind-dev-seed.test.ts b/packages/workspaces/src/di/bind-dev-seed.test.ts new file mode 100644 index 0000000..2bbbeca --- /dev/null +++ b/packages/workspaces/src/di/bind-dev-seed.test.ts @@ -0,0 +1,56 @@ +import "reflect-metadata"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { NoopTracer, NoopLogger } from "@repo/core-shared/instrumentation"; +import { bindDevSeedWorkspaces } from "@/di/bind-dev-seed"; +import { workspacesContainer } from "@/di/container"; +import { WORKSPACES_SYMBOLS } from "@/di/symbols"; +import { MockWorkspaceRepository } from "@/infrastructure/repositories/workspace.repository.mock"; +import type { IWorkspaceRepository } from "@/application/repositories/workspace.repository.interface"; + +const noop = { tracer: new NoopTracer(), logger: new NoopLogger() }; + +describe("bindDevSeedWorkspaces", () => { + beforeEach(() => { + if (workspacesContainer.isBound(WORKSPACES_SYMBOLS.IWorkspaceRepository)) { + workspacesContainer.unbind(WORKSPACES_SYMBOLS.IWorkspaceRepository); + } + workspacesContainer + .bind(WORKSPACES_SYMBOLS.IWorkspaceRepository) + .to(MockWorkspaceRepository); + }); + + afterEach(() => { + if (workspacesContainer.isBound(WORKSPACES_SYMBOLS.IWorkspaceRepository)) { + workspacesContainer.unbind(WORKSPACES_SYMBOLS.IWorkspaceRepository); + } + workspacesContainer + .bind(WORKSPACES_SYMBOLS.IWorkspaceRepository) + .to(MockWorkspaceRepository); + }); + + it("populates the repository with the dev seed", async () => { + await bindDevSeedWorkspaces(noop); + + const repo = workspacesContainer.get( + WORKSPACES_SYMBOLS.IWorkspaceRepository, + ); + const found = await repo.getWorkspace("dev-1"); + + expect(found).not.toBeNull(); + expect(found?.id).toBe("dev-1"); + }); + + it("is idempotent — calling twice rebuilds a fresh populated repo", async () => { + await bindDevSeedWorkspaces(noop); + const before = workspacesContainer.get( + WORKSPACES_SYMBOLS.IWorkspaceRepository, + ); + + await bindDevSeedWorkspaces(noop); + const after = workspacesContainer.get( + WORKSPACES_SYMBOLS.IWorkspaceRepository, + ); + + expect(after).not.toBe(before); + }); +}); diff --git a/packages/workspaces/src/di/bind-dev-seed.ts b/packages/workspaces/src/di/bind-dev-seed.ts new file mode 100644 index 0000000..0cefd11 --- /dev/null +++ b/packages/workspaces/src/di/bind-dev-seed.ts @@ -0,0 +1,112 @@ +import { + withSpan, + withCapture, + INSTRUMENTATION_SYMBOLS, + type ITracer, + type ILogger, +} from "@repo/core-shared/instrumentation"; +import type { BindContext } from "@repo/core-shared/di"; +import { + assertFeatureConformance, + wireUseCase, +} from "@repo/core-shared/conformance"; +import { workspacesManifest } from "../feature.manifest"; +import { workspacesContainer } from "./container"; +import { WORKSPACES_SYMBOLS } from "./symbols"; +import { MockWorkspaceRepository } from "../infrastructure/repositories/workspace.repository.mock"; +import { buildDevWorkspaceMap } from "../__seeds__/dev"; +import { getWorkspaceUseCase } from "../application/use-cases/get-workspace.use-case"; +import { getWorkspaceController } from "../interface-adapters/controllers/get-workspace.controller"; +import type { IWorkspaceRepository } from "../application/repositories/workspace.repository.interface"; + +/** + * Replace the default mock with a populated one for dev mode + storybook. + * + * Mutually exclusive with `bindProductionWorkspaces(ctx)`. + * Tests must NOT call this — they construct `new MockWorkspaceRepository()` + * directly and seed via factories per-test. + * + * Idempotent: safe to call multiple times; each call rebuilds a fresh + * populated repo and rebinds the symbol. + */ +export async function bindDevSeedWorkspaces(ctx: BindContext): Promise { + const { tracer, logger, bus, queue, realtime, realtimeRegistry } = ctx; + + // Bind shared instrumentation into feature container + if (workspacesContainer.isBound(INSTRUMENTATION_SYMBOLS.TRACER)) { + workspacesContainer.unbind(INSTRUMENTATION_SYMBOLS.TRACER); + } + if (workspacesContainer.isBound(INSTRUMENTATION_SYMBOLS.LOGGER)) { + workspacesContainer.unbind(INSTRUMENTATION_SYMBOLS.LOGGER); + } + workspacesContainer + .bind(INSTRUMENTATION_SYMBOLS.TRACER) + .toConstantValue(tracer); + workspacesContainer + .bind(INSTRUMENTATION_SYMBOLS.LOGGER) + .toConstantValue(logger); + + if (workspacesContainer.isBound(WORKSPACES_SYMBOLS.IWorkspaceRepository)) { + workspacesContainer.unbind(WORKSPACES_SYMBOLS.IWorkspaceRepository); + } + const repo = new MockWorkspaceRepository( + buildDevWorkspaceMap(), + tracer, + logger, + ); + workspacesContainer + .bind(WORKSPACES_SYMBOLS.IWorkspaceRepository) + .toConstantValue(repo); + + // Use case + const wrappedGetWorkspace = wireUseCase({ + container: workspacesContainer, + symbol: WORKSPACES_SYMBOLS.IGetWorkspaceUseCase, + factory: getWorkspaceUseCase, + deps: [repo], + feature: "workspaces", + layer: "use-case", + name: "getWorkspace", + tracer, + logger, + }); + + if (workspacesContainer.isBound(WORKSPACES_SYMBOLS.IGetWorkspaceController)) { + workspacesContainer.unbind(WORKSPACES_SYMBOLS.IGetWorkspaceController); + } + workspacesContainer + .bind(WORKSPACES_SYMBOLS.IGetWorkspaceController) + .toConstantValue( + withSpan( + tracer, + { name: "workspaces.getWorkspace", op: "controller" }, + withCapture( + logger, + { + feature: "workspaces", + layer: "controller", + name: "workspaces.getWorkspace", + }, + getWorkspaceController(wrappedGetWorkspace), + ), + ), + ); + // bus + queue are passed through; generated handlers consume them at the anchors below. + void bus; + void queue; + void realtime; + void realtimeRegistry; + // + // + // + + // Boot-time conformance check (dev-seed mode). + assertFeatureConformance( + workspacesContainer, + workspacesManifest, + { + getWorkspace: WORKSPACES_SYMBOLS.IGetWorkspaceUseCase, + }, + ctx, + ); +} diff --git a/packages/workspaces/src/di/bind-production.ts b/packages/workspaces/src/di/bind-production.ts new file mode 100644 index 0000000..25eeb08 --- /dev/null +++ b/packages/workspaces/src/di/bind-production.ts @@ -0,0 +1,100 @@ +import { + withSpan, + withCapture, + INSTRUMENTATION_SYMBOLS, + type ITracer, + type ILogger, +} from "@repo/core-shared/instrumentation"; +import type { BindProductionContext } from "@repo/core-shared/di"; +import { + assertFeatureConformance, + wireUseCase, +} from "@repo/core-shared/conformance"; +import { workspacesContainer } from "./container"; +import { WORKSPACES_SYMBOLS } from "./symbols"; +import { workspacesManifest } from "../feature.manifest"; +import { WorkspaceRepository } from "../infrastructure/repositories/workspace.repository"; +import { getWorkspaceUseCase } from "../application/use-cases/get-workspace.use-case"; +import { getWorkspaceController } from "../interface-adapters/controllers/get-workspace.controller"; + +export function bindProductionWorkspaces(ctx: BindProductionContext): void { + const { config, tracer, logger, bus, queue, realtime, realtimeRegistry } = + ctx; + + // Bind shared instrumentation into feature container + if (workspacesContainer.isBound(INSTRUMENTATION_SYMBOLS.TRACER)) { + workspacesContainer.unbind(INSTRUMENTATION_SYMBOLS.TRACER); + } + if (workspacesContainer.isBound(INSTRUMENTATION_SYMBOLS.LOGGER)) { + workspacesContainer.unbind(INSTRUMENTATION_SYMBOLS.LOGGER); + } + workspacesContainer + .bind(INSTRUMENTATION_SYMBOLS.TRACER) + .toConstantValue(tracer); + workspacesContainer + .bind(INSTRUMENTATION_SYMBOLS.LOGGER) + .toConstantValue(logger); + + // Real repository + if (workspacesContainer.isBound(WORKSPACES_SYMBOLS.IWorkspaceRepository)) { + workspacesContainer.unbind(WORKSPACES_SYMBOLS.IWorkspaceRepository); + } + const repo = new WorkspaceRepository(config, tracer, logger); + workspacesContainer + .bind(WORKSPACES_SYMBOLS.IWorkspaceRepository) + .toConstantValue(repo); + + // Use case + const wrappedGetWorkspace = wireUseCase({ + container: workspacesContainer, + symbol: WORKSPACES_SYMBOLS.IGetWorkspaceUseCase, + factory: getWorkspaceUseCase, + deps: [repo], + feature: "workspaces", + layer: "use-case", + name: "getWorkspace", + tracer, + logger, + }); + + // Controller — wrapped with span at bind time + if (workspacesContainer.isBound(WORKSPACES_SYMBOLS.IGetWorkspaceController)) { + workspacesContainer.unbind(WORKSPACES_SYMBOLS.IGetWorkspaceController); + } + workspacesContainer + .bind(WORKSPACES_SYMBOLS.IGetWorkspaceController) + .toConstantValue( + withSpan( + tracer, + { name: "workspaces.getWorkspace", op: "controller" }, + withCapture( + logger, + { + feature: "workspaces", + layer: "controller", + name: "workspaces.getWorkspace", + }, + getWorkspaceController(wrappedGetWorkspace), + ), + ), + ); + // bus + queue are passed through; generated handlers consume them at the anchors below. + void bus; + void queue; + void realtime; + void realtimeRegistry; + // + // + // + + // Boot-time conformance check: refuses to start if any use-case binding + // is missing a required brand (withSpan / withCapture / withAudit). + assertFeatureConformance( + workspacesContainer, + workspacesManifest, + { + getWorkspace: WORKSPACES_SYMBOLS.IGetWorkspaceUseCase, + }, + ctx, + ); +} diff --git a/packages/workspaces/src/di/container.test.ts b/packages/workspaces/src/di/container.test.ts new file mode 100644 index 0000000..12820f3 --- /dev/null +++ b/packages/workspaces/src/di/container.test.ts @@ -0,0 +1,40 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { workspacesContainer } from "./container"; +import { WORKSPACES_SYMBOLS } from "./symbols"; +import { WorkspacesModule } from "./module"; +import { MockWorkspaceRepository } from "@/infrastructure/repositories/workspace.repository.mock"; +import type { IWorkspaceRepository } from "@/application/repositories/workspace.repository.interface"; +import type { IGetWorkspaceUseCase } from "@/application/use-cases/get-workspace.use-case"; +import type { IGetWorkspaceController } from "@/interface-adapters/controllers/get-workspace.controller"; + +describe("workspacesContainer", () => { + beforeEach(() => { + workspacesContainer.unbindAll(); + workspacesContainer.load(WorkspacesModule); + }); + + afterEach(() => { + workspacesContainer.unbindAll(); + }); + + it("resolves IWorkspaceRepository to MockWorkspaceRepository", () => { + const repo = workspacesContainer.get( + WORKSPACES_SYMBOLS.IWorkspaceRepository, + ); + expect(repo).toBeInstanceOf(MockWorkspaceRepository); + }); + + it("resolves IGetWorkspaceUseCase as a function", () => { + const useCase = workspacesContainer.get( + WORKSPACES_SYMBOLS.IGetWorkspaceUseCase, + ); + expect(typeof useCase).toBe("function"); + }); + + it("resolves IGetWorkspaceController as a function", () => { + const controller = workspacesContainer.get( + WORKSPACES_SYMBOLS.IGetWorkspaceController, + ); + expect(typeof controller).toBe("function"); + }); +}); diff --git a/packages/workspaces/src/di/container.ts b/packages/workspaces/src/di/container.ts new file mode 100644 index 0000000..0da9805 --- /dev/null +++ b/packages/workspaces/src/di/container.ts @@ -0,0 +1,6 @@ +import "reflect-metadata"; +import { Container } from "inversify"; +import { WorkspacesModule } from "./module"; + +export const workspacesContainer = new Container({ defaultScope: "Singleton" }); +workspacesContainer.load(WorkspacesModule); diff --git a/packages/workspaces/src/di/module.ts b/packages/workspaces/src/di/module.ts new file mode 100644 index 0000000..4704886 --- /dev/null +++ b/packages/workspaces/src/di/module.ts @@ -0,0 +1,39 @@ +import { ContainerModule, type interfaces } from "inversify"; + +import type { IWorkspaceRepository } from "../application/repositories/workspace.repository.interface"; +import { MockWorkspaceRepository } from "../infrastructure/repositories/workspace.repository.mock"; +import { + getWorkspaceUseCase, + type IGetWorkspaceUseCase, +} from "../application/use-cases/get-workspace.use-case"; +import { + getWorkspaceController, + type IGetWorkspaceController, +} from "../interface-adapters/controllers/get-workspace.controller"; +import { WORKSPACES_SYMBOLS } from "./symbols"; + +export const WorkspacesModule = new ContainerModule((bind: interfaces.Bind) => { + bind(WORKSPACES_SYMBOLS.IWorkspaceRepository).to( + MockWorkspaceRepository, + ); + + bind( + WORKSPACES_SYMBOLS.IGetWorkspaceUseCase, + ).toDynamicValue((ctx) => + getWorkspaceUseCase( + ctx.container.get( + WORKSPACES_SYMBOLS.IWorkspaceRepository, + ), + ), + ); + + bind( + WORKSPACES_SYMBOLS.IGetWorkspaceController, + ).toDynamicValue((ctx) => + getWorkspaceController( + ctx.container.get( + WORKSPACES_SYMBOLS.IGetWorkspaceUseCase, + ), + ), + ); +}); diff --git a/packages/workspaces/src/di/symbols.ts b/packages/workspaces/src/di/symbols.ts new file mode 100644 index 0000000..1d11ee7 --- /dev/null +++ b/packages/workspaces/src/di/symbols.ts @@ -0,0 +1,10 @@ +export const WORKSPACES_SYMBOLS = { + IWorkspaceRepository: Symbol.for("workspaces:IWorkspaceRepository"), + // Use cases + IGetWorkspaceUseCase: Symbol.for("workspaces:IGetWorkspaceUseCase"), + // Controllers + IGetWorkspaceController: Symbol.for("workspaces:IGetWorkspaceController"), + // + // + // +} as const; diff --git a/packages/workspaces/src/entities/errors/common.ts b/packages/workspaces/src/entities/errors/common.ts new file mode 100644 index 0000000..40b2976 --- /dev/null +++ b/packages/workspaces/src/entities/errors/common.ts @@ -0,0 +1,6 @@ +export class InputParseError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = "InputParseError"; + } +} diff --git a/packages/workspaces/src/entities/errors/workspace.ts b/packages/workspaces/src/entities/errors/workspace.ts new file mode 100644 index 0000000..fd5f939 --- /dev/null +++ b/packages/workspaces/src/entities/errors/workspace.ts @@ -0,0 +1,6 @@ +export class WorkspaceNotFoundError extends Error { + constructor(message = "Workspace not found", options?: ErrorOptions) { + super(message, options); + this.name = "WorkspaceNotFoundError"; + } +} diff --git a/packages/workspaces/src/entities/models/workspace.test.ts b/packages/workspaces/src/entities/models/workspace.test.ts new file mode 100644 index 0000000..b883c77 --- /dev/null +++ b/packages/workspaces/src/entities/models/workspace.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { workspaceSchema } from "./workspace"; + +describe("workspaceSchema", () => { + it("accepts a valid workspace", () => { + const result = workspaceSchema.parse({ id: "1", name: "Example" }); + expect(result.id).toBe("1"); + expect(result.name).toBe("Example"); + }); + + it("rejects an empty id", () => { + expect(() => workspaceSchema.parse({ id: "", name: "ok" })).toThrow(); + }); + + it("rejects an empty name", () => { + expect(() => workspaceSchema.parse({ id: "1", name: "" })).toThrow(); + }); + + it("rejects a name over 128 chars", () => { + expect(() => + workspaceSchema.parse({ id: "1", name: "x".repeat(129) }), + ).toThrow(); + }); +}); diff --git a/packages/workspaces/src/entities/models/workspace.ts b/packages/workspaces/src/entities/models/workspace.ts new file mode 100644 index 0000000..1ef1dfd --- /dev/null +++ b/packages/workspaces/src/entities/models/workspace.ts @@ -0,0 +1,8 @@ +import { z } from "zod"; + +export const workspaceSchema = z.object({ + id: z.string().min(1), + name: z.string().min(1).max(128), +}); + +export type Workspace = z.infer; diff --git a/packages/workspaces/src/feature.manifest.ts b/packages/workspaces/src/feature.manifest.ts new file mode 100644 index 0000000..a3f8e95 --- /dev/null +++ b/packages/workspaces/src/feature.manifest.ts @@ -0,0 +1,55 @@ +import { defineFeature } from "@repo/core-shared/conformance"; + +/** + * The workspaces feature's conformance manifest. Drives binding-slot + * types in `di/bind-production.ts` and is read by ESLint, the boot + * assertion, and the CI drift gate. + * + * Conventions: + * - `mutates: true` for any use case that creates, updates, or deletes state + * - `audits` lists every audit event the use case emits (must match calls + * to `auditLog.record(...)` in the factory body — ESLint enforces this) + * - `publishes` / `consumes` cover cross-feature events through `IEventBus` + */ +export const workspacesManifest = defineFeature({ + name: "workspaces", + requiredCores: [], + useCases: { + getWorkspace: { + mutates: false, + audits: [], + publishes: [], + consumes: [], + analyticsEvents: [], + }, + }, + realtimeChannels: [], + jobs: [], + // + // Coverage bands — single source of truth (ADR-020). Read by: + // - vitest.config.ts (test-time thresholds, L0) + // - assertFeatureConformance (boot-time, fails dev/prod on drift) + // - pnpm coverage:diff (cover-the-diff gate, L1) + // Edit here; the helper in vitest.config picks up the new numbers. + coverage: { + bands: { + baseline: { statements: 80, branches: 75, functions: 80, lines: 80 }, + entities: { statements: 100, branches: 100, functions: 100, lines: 100 }, + "use-cases": { + statements: 100, + branches: 95, + functions: 100, + lines: 100, + }, + controllers: { + statements: 100, + branches: 95, + functions: 100, + lines: 100, + }, + }, + mutationTargets: ["entities", "use-cases"], + }, +} as const); + +export type WorkspacesManifest = typeof workspacesManifest; diff --git a/packages/workspaces/src/index.ts b/packages/workspaces/src/index.ts new file mode 100644 index 0000000..752bdd5 --- /dev/null +++ b/packages/workspaces/src/index.ts @@ -0,0 +1,19 @@ +export type { Workspace } from "./entities/models/workspace"; +export type { WorkspacesRouter } from "./integrations/api/router"; +export { WorkspaceNotFoundError } from "./entities/errors/workspace"; +export { InputParseError } from "./entities/errors/common"; + +// Use case schemas + types +export { + getWorkspaceInputSchema, + getWorkspaceOutputSchema, + type GetWorkspaceInput, + type GetWorkspaceOutput, + type IGetWorkspaceUseCase, +} from "./application/use-cases/get-workspace.use-case"; + +// Controller type aliases +export type { IGetWorkspaceController } from "./interface-adapters/controllers/get-workspace.controller"; + +// +// diff --git a/packages/workspaces/src/infrastructure/repositories/workspace.repository.mock.test.ts b/packages/workspaces/src/infrastructure/repositories/workspace.repository.mock.test.ts new file mode 100644 index 0000000..941bca5 --- /dev/null +++ b/packages/workspaces/src/infrastructure/repositories/workspace.repository.mock.test.ts @@ -0,0 +1,15 @@ +import { describe } from "vitest"; +import { RecordingTracer } from "@repo/core-testing/instrumentation"; +import { MockWorkspaceRepository } from "@/infrastructure/repositories/workspace.repository.mock"; +import { + workspaceRepositoryContract, + CONTRACT_WORKSPACE_SEED, +} from "@/__contracts__/workspace-repository.contract"; + +describe("MockWorkspaceRepository", () => { + const tracer = new RecordingTracer(); + workspaceRepositoryContract.run( + () => new MockWorkspaceRepository(new Map(CONTRACT_WORKSPACE_SEED), tracer), + { tracer: () => tracer }, + ); +}); diff --git a/packages/workspaces/src/infrastructure/repositories/workspace.repository.mock.ts b/packages/workspaces/src/infrastructure/repositories/workspace.repository.mock.ts new file mode 100644 index 0000000..99f4ca4 --- /dev/null +++ b/packages/workspaces/src/infrastructure/repositories/workspace.repository.mock.ts @@ -0,0 +1,45 @@ +import "reflect-metadata"; +import { injectable } from "inversify"; +import { + NoopTracer, + NoopLogger, + type ITracer, + type ILogger, +} from "@repo/core-shared/instrumentation"; + +import type { IWorkspaceRepository } from "../../application/repositories/workspace.repository.interface"; +import type { Workspace } from "../../entities/models/workspace"; + +const DEFAULT_DATA = new Map([ + ["seed-1", { id: "seed-1", name: "Seed One" }], + ["seed-2", { id: "seed-2", name: "Seed Two" }], +]); + +@injectable() +export class MockWorkspaceRepository implements IWorkspaceRepository { + private readonly data: Map; + private tracer: ITracer; + private logger: ILogger; + + constructor( + initialData?: Map, + tracer: ITracer = new NoopTracer(), + logger: ILogger = new NoopLogger(), + ) { + this.data = initialData ?? new Map(DEFAULT_DATA); + this.tracer = tracer; + this.logger = logger; + void this.logger; // currently unused; reserved for future mock-thrown captures + } + + async getWorkspace(id: string): Promise { + return this.tracer.startSpan( + { name: "workspace.getWorkspace", op: "repository", attributes: {} }, + async (span) => { + const found = this.data.get(id) ?? null; + span.setAttribute("found", found !== null); + return found; + }, + ); + } +} diff --git a/packages/workspaces/src/infrastructure/repositories/workspace.repository.span.test.ts b/packages/workspaces/src/infrastructure/repositories/workspace.repository.span.test.ts new file mode 100644 index 0000000..278859b --- /dev/null +++ b/packages/workspaces/src/infrastructure/repositories/workspace.repository.span.test.ts @@ -0,0 +1,22 @@ +import { describe, it, expect } from "vitest"; +import { + RecordingTracer, + RecordingLogger, +} from "@repo/core-testing/instrumentation"; +import { MockWorkspaceRepository } from "@/infrastructure/repositories/workspace.repository.mock"; + +// Mock repo also wraps in spans. +describe("MockWorkspaceRepository emits spans", () => { + it("getWorkspace emits one span with op='repository'", async () => { + const tracer = new RecordingTracer(); + const logger = new RecordingLogger(); + const repo = new MockWorkspaceRepository(undefined, tracer, logger); + await repo.getWorkspace("seed-1"); + expect(tracer.spans).toHaveLength(1); + expect(tracer.spans[0]).toMatchObject({ + name: "workspace.getWorkspace", + op: "repository", + }); + expect(typeof tracer.spans[0]!.attributes.found).toBe("boolean"); + }); +}); diff --git a/packages/workspaces/src/infrastructure/repositories/workspace.repository.test.ts b/packages/workspaces/src/infrastructure/repositories/workspace.repository.test.ts new file mode 100644 index 0000000..c0a4abb --- /dev/null +++ b/packages/workspaces/src/infrastructure/repositories/workspace.repository.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect } from "vitest"; +import { + RecordingTracer, + RecordingLogger, +} from "@repo/core-testing/instrumentation"; +import { stubPayloadConfig } from "@repo/core-testing/payload/stub-config"; +import { WorkspaceRepository } from "@/infrastructure/repositories/workspace.repository"; + +// Phase-1 scaffold: the real repository returns null until the Payload +// collection is wired. These tests pin the span shape and the stub return +// value so that callers (use case + DI tests) keep working when the body is +// later replaced with a real `payload.find()` call. + +describe("WorkspaceRepository (Phase-1 stub)", () => { + it("returns null and emits a span with op='repository'", async () => { + const tracer = new RecordingTracer(); + const logger = new RecordingLogger(); + const repo = new WorkspaceRepository(stubPayloadConfig, tracer, logger); + + const result = await repo.getWorkspace("anything"); + + expect(result).toBeNull(); + expect(tracer.spans).toHaveLength(1); + expect(tracer.spans[0]).toMatchObject({ + name: "workspace.getWorkspace", + op: "repository", + }); + }); + + it("records the requested id as a span attribute", async () => { + const tracer = new RecordingTracer(); + const repo = new WorkspaceRepository(stubPayloadConfig, tracer); + await repo.getWorkspace("custom-id"); + expect(tracer.spans[0]!.attributes.id).toBe("custom-id"); + expect(tracer.spans[0]!.attributes.found).toBe(false); + }); +}); diff --git a/packages/workspaces/src/infrastructure/repositories/workspace.repository.ts b/packages/workspaces/src/infrastructure/repositories/workspace.repository.ts new file mode 100644 index 0000000..c6faabd --- /dev/null +++ b/packages/workspaces/src/infrastructure/repositories/workspace.repository.ts @@ -0,0 +1,65 @@ +import "reflect-metadata"; +import { injectable } from "inversify"; +import type { SanitizedConfig } from "payload"; +import { + NoopTracer, + NoopLogger, + type ITracer, + type ILogger, +} from "@repo/core-shared/instrumentation"; + +import type { IWorkspaceRepository } from "../../application/repositories/workspace.repository.interface"; +import type { Workspace } from "../../entities/models/workspace"; + +const FEATURE = "workspaces" as const; +const REPO = "workspace" as const; + +/** + * Phase-1 scaffold — the Payload collection has not been added yet, so this + * repository emits a span but always resolves to `null`. Once you add the + * collection at `integrations/cms/collections/workspace.ts` and + * register it with Payload, replace the stub below with a real `payload.find` + * call (see `packages/blog/src/infrastructure/repositories/articles.repository.ts` + * for the canonical pattern). + */ +@injectable() +export class WorkspaceRepository implements IWorkspaceRepository { + private config: SanitizedConfig; + private tracer: ITracer; + private logger: ILogger; + + constructor( + config: SanitizedConfig, + tracer: ITracer = new NoopTracer(), + logger: ILogger = new NoopLogger(), + ) { + this.config = config; + this.tracer = tracer; + this.logger = logger; + void this.config; + } + + async getWorkspace(id: string): Promise { + return this.tracer.startSpan( + { name: "workspace.getWorkspace", op: "repository", attributes: {} }, + async (span) => { + try { + // TODO: replace with `payload.find({ collection: "workspaces", where: { id: { equals: id } } })` + // once the Payload collection is registered. + span.setAttribute("id", id); + span.setAttribute("found", false); + return null; + } catch (err) { + this.logger.captureException(err, { + tags: { feature: FEATURE, repo: REPO, method: "getWorkspace" }, + }); + span.setStatus( + "error", + err instanceof Error ? err.message : String(err), + ); + throw err; + } + }, + ); + } +} diff --git a/packages/workspaces/src/integrations/api/procedures.ts b/packages/workspaces/src/integrations/api/procedures.ts new file mode 100644 index 0000000..a377d57 --- /dev/null +++ b/packages/workspaces/src/integrations/api/procedures.ts @@ -0,0 +1,12 @@ +import { t } from "@repo/core-shared/trpc/init"; +import { defineErrorMiddleware } from "@repo/core-shared/trpc/define-error-middleware"; + +import { WorkspaceNotFoundError } from "../../entities/errors/workspace"; +import { InputParseError } from "../../entities/errors/common"; + +export const workspacesProcedure = t.procedure.use( + defineErrorMiddleware([ + [InputParseError, "BAD_REQUEST"], + [WorkspaceNotFoundError, "NOT_FOUND"], + ]), +); diff --git a/packages/workspaces/src/integrations/api/router.test.ts b/packages/workspaces/src/integrations/api/router.test.ts new file mode 100644 index 0000000..6f0fe49 --- /dev/null +++ b/packages/workspaces/src/integrations/api/router.test.ts @@ -0,0 +1,93 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { TRPCError } from "@trpc/server"; +import { injectable } from "inversify"; +import { workspacesContainer } from "@/di/container"; +import { WorkspacesModule } from "@/di/module"; +import { WORKSPACES_SYMBOLS } from "@/di/symbols"; +import { getWorkspaceUseCase } from "@/application/use-cases/get-workspace.use-case"; +import { getWorkspaceController } from "@/interface-adapters/controllers/get-workspace.controller"; +import { workspacesRouter } from "@/integrations/api/router"; + +describe("workspacesRouter", () => { + beforeEach(() => { + workspacesContainer.unbindAll(); + workspacesContainer.load(WorkspacesModule); + }); + + afterEach(() => { + workspacesContainer.unbindAll(); + }); + + it("exposes the getWorkspace procedure", () => { + const names = Object.keys(workspacesRouter._def.procedures); + expect(names).toContain("getWorkspace"); + }); + + it("getWorkspace returns the seeded workspace", async () => { + const caller = workspacesRouter.createCaller({}); + const result = await caller.getWorkspace({ id: "seed-1" }); + expect(result.id).toBe("seed-1"); + }); +}); + +describe("workspacesRouter (error mapping)", () => { + beforeEach(() => { + workspacesContainer.unbindAll(); + workspacesContainer.load(WorkspacesModule); + }); + + afterEach(() => { + workspacesContainer.unbindAll(); + }); + + it("translates InputParseError → BAD_REQUEST when extra fields are passed", async () => { + const caller = workspacesRouter.createCaller({}); + try { + await caller.getWorkspace({ + id: "seed-1", + unexpected: "field", + } as unknown as { id: string }); + throw new Error("expected throw"); + } catch (e) { + expect(e).toBeInstanceOf(TRPCError); + expect((e as TRPCError).code).toBe("BAD_REQUEST"); + } + }); + + it("translates WorkspaceNotFoundError → NOT_FOUND when repository returns null", async () => { + @injectable() + class NullWorkspaceRepository { + async getWorkspace() { + return null; + } + } + + workspacesContainer.unbindAll(); + workspacesContainer + .bind(WORKSPACES_SYMBOLS.IWorkspaceRepository) + .to(NullWorkspaceRepository); + workspacesContainer + .bind(WORKSPACES_SYMBOLS.IGetWorkspaceUseCase) + .toDynamicValue((ctx) => + getWorkspaceUseCase( + ctx.container.get(WORKSPACES_SYMBOLS.IWorkspaceRepository), + ), + ); + workspacesContainer + .bind(WORKSPACES_SYMBOLS.IGetWorkspaceController) + .toDynamicValue((ctx) => + getWorkspaceController( + ctx.container.get(WORKSPACES_SYMBOLS.IGetWorkspaceUseCase), + ), + ); + + const caller = workspacesRouter.createCaller({}); + try { + await caller.getWorkspace({ id: "anything" }); + throw new Error("expected throw"); + } catch (e) { + expect(e).toBeInstanceOf(TRPCError); + expect((e as TRPCError).code).toBe("NOT_FOUND"); + } + }); +}); diff --git a/packages/workspaces/src/integrations/api/router.ts b/packages/workspaces/src/integrations/api/router.ts new file mode 100644 index 0000000..30f3388 --- /dev/null +++ b/packages/workspaces/src/integrations/api/router.ts @@ -0,0 +1,22 @@ +import { router } from "@repo/core-shared/trpc/init"; + +import { workspacesContainer } from "../../di/container"; +import { WORKSPACES_SYMBOLS } from "../../di/symbols"; + +import { getWorkspaceInputSchema } from "../../application/use-cases/get-workspace.use-case"; +import type { IGetWorkspaceController } from "../../interface-adapters/controllers/get-workspace.controller"; + +import { workspacesProcedure } from "./procedures"; + +export const workspacesRouter = router({ + getWorkspace: workspacesProcedure + .input(getWorkspaceInputSchema) + .query(({ input }) => { + const ctrl = workspacesContainer.get( + WORKSPACES_SYMBOLS.IGetWorkspaceController, + ); + return ctrl(input); + }), +}); + +export type WorkspacesRouter = typeof workspacesRouter; diff --git a/packages/workspaces/src/integrations/cms/index.ts b/packages/workspaces/src/integrations/cms/index.ts new file mode 100644 index 0000000..14ff78c --- /dev/null +++ b/packages/workspaces/src/integrations/cms/index.ts @@ -0,0 +1,7 @@ +// Payload CMS integration barrel for @repo/workspaces. +// Re-export this feature's collections and globals here as you add them +// under ./collections and ./globals. See packages/auth/src/integrations/cms +// for the canonical shape. The `` anchor below is required by +// `pnpm turbo gen job` and `pnpm turbo gen event` — do not remove it. +export {}; +// diff --git a/packages/workspaces/src/interface-adapters/controllers/get-workspace.controller.test.ts b/packages/workspaces/src/interface-adapters/controllers/get-workspace.controller.test.ts new file mode 100644 index 0000000..25d2782 --- /dev/null +++ b/packages/workspaces/src/interface-adapters/controllers/get-workspace.controller.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { getWorkspaceController } from "@/interface-adapters/controllers/get-workspace.controller"; +import { getWorkspaceUseCase } from "@/application/use-cases/get-workspace.use-case"; +import { MockWorkspaceRepository } from "@/infrastructure/repositories/workspace.repository.mock"; +import { InputParseError } from "@/entities/errors/common"; + +describe("getWorkspaceController", () => { + it("returns the workspace for a valid id", async () => { + const repo = new MockWorkspaceRepository(); + const useCase = getWorkspaceUseCase(repo); + const controller = getWorkspaceController(useCase); + + const result = await controller({ id: "seed-1" }); + + expect(result.id).toBe("seed-1"); + }); + + it("throws InputParseError when input is missing fields", async () => { + const repo = new MockWorkspaceRepository(); + const useCase = getWorkspaceUseCase(repo); + const controller = getWorkspaceController(useCase); + + await expect(controller({})).rejects.toBeInstanceOf(InputParseError); + }); + + it("throws InputParseError when input has unexpected fields (strict)", async () => { + const repo = new MockWorkspaceRepository(); + const useCase = getWorkspaceUseCase(repo); + const controller = getWorkspaceController(useCase); + + await expect( + controller({ id: "seed-1", unexpected: true }), + ).rejects.toBeInstanceOf(InputParseError); + }); +}); diff --git a/packages/workspaces/src/interface-adapters/controllers/get-workspace.controller.ts b/packages/workspaces/src/interface-adapters/controllers/get-workspace.controller.ts new file mode 100644 index 0000000..c14ce16 --- /dev/null +++ b/packages/workspaces/src/interface-adapters/controllers/get-workspace.controller.ts @@ -0,0 +1,25 @@ +import { InputParseError } from "../../entities/errors/common"; +import { + getWorkspaceInputSchema, + type GetWorkspaceOutput, + type IGetWorkspaceUseCase, +} from "../../application/use-cases/get-workspace.use-case"; + +function presenter(value: GetWorkspaceOutput) { + return value; +} + +export type IGetWorkspaceController = ReturnType; + +export const getWorkspaceController = + (getWorkspaceUseCase: IGetWorkspaceUseCase) => + async (input: unknown): Promise> => { + const parsed = getWorkspaceInputSchema.safeParse(input); + if (!parsed.success) { + throw new InputParseError("Invalid get-workspace input", { + cause: parsed.error, + }); + } + const result = await getWorkspaceUseCase(parsed.data); + return presenter(result); + }; diff --git a/packages/workspaces/src/ui/index.ts b/packages/workspaces/src/ui/index.ts new file mode 100644 index 0000000..937b2df --- /dev/null +++ b/packages/workspaces/src/ui/index.ts @@ -0,0 +1,4 @@ +// Phase-1 placeholder. Re-export React Query option builders here once you +// add `ui/query.ts`. See packages/auth/src/ui/index.ts for the +// canonical shape. +export {}; diff --git a/packages/workspaces/stryker.config.json b/packages/workspaces/stryker.config.json new file mode 100644 index 0000000..31ea453 --- /dev/null +++ b/packages/workspaces/stryker.config.json @@ -0,0 +1,5 @@ +{ + "$schema": "../../node_modules/@stryker-mutator/core/schema/stryker-schema.json", + "_comment": "workspaces feature mutation testing config. Extends @repo/core-testing/stryker.base.json (ADR-020 L3). Run with `pnpm mutate --filter @repo/workspaces`.", + "extends": "@repo/core-testing/stryker.base.json" +} diff --git a/packages/workspaces/tsconfig.json b/packages/workspaces/tsconfig.json new file mode 100644 index 0000000..a936111 --- /dev/null +++ b/packages/workspaces/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "@repo/core-typescript/base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": ".", + "lib": ["ES2022", "DOM"], + "jsx": "preserve", + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src/**/*", "tests/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/workspaces/turbo.json b/packages/workspaces/turbo.json new file mode 100644 index 0000000..8a1d41a --- /dev/null +++ b/packages/workspaces/turbo.json @@ -0,0 +1,4 @@ +{ + "extends": ["//"], + "tags": ["feature"] +} diff --git a/packages/workspaces/vitest.config.ts b/packages/workspaces/vitest.config.ts new file mode 100644 index 0000000..524276b --- /dev/null +++ b/packages/workspaces/vitest.config.ts @@ -0,0 +1,32 @@ +import path from "node:path"; +import { mergeConfig } from "vitest/config"; +import { nodeVitestConfig } from "@repo/core-typescript/vitest.base.node"; +import { + DEFAULT_COVERAGE_BANDS, + vitestThresholdsFromBands, +} from "@repo/core-shared/conformance/coverage"; + +// Coverage thresholds derived from DEFAULT_COVERAGE_BANDS via the shared +// helper (ADR-020). The feature.manifest.ts `coverage.bands` section +// declares these for boot-time `assertFeatureConformance`. Edit the +// manifest when adjusting per-feature bands. +export default mergeConfig(nodeVitestConfig, { + test: { + coverage: { + exclude: [ + // DI bootstrap — wires InversifyJS at app startup; not unit-testable + "src/di/bind-production.ts", + // Pure TypeScript interface files — not executable + "src/application/repositories/**", + // Payload CMS templates — declarative data, tested via integration + "src/integrations/cms/**", + // React Query option builders — integration-tested in apps + "src/ui/**", + ], + thresholds: vitestThresholdsFromBands(DEFAULT_COVERAGE_BANDS), + }, + }, + resolve: { + alias: { "@": path.resolve(__dirname, "./src") }, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 97b4d79..ace558f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -188,6 +188,9 @@ importers: "@repo/core-trpc": specifier: workspace:^ version: link:../../packages/core-trpc + "@repo/workspaces": + specifier: workspace:* + version: link:../../packages/workspaces "@sentry/nextjs": specifier: ^10.51.0 version: 10.51.0(@opentelemetry/core@2.7.1(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.1))(next@15.5.14(@babel/core@7.25.9)(@opentelemetry/api@1.9.1)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.99.0))(react@19.2.4)(webpack@5.106.2) @@ -359,6 +362,9 @@ importers: "@repo/core-shared": specifier: workspace:* version: link:../core-shared + "@repo/workspaces": + specifier: workspace:* + version: link:../workspaces "@trpc/server": specifier: ^11.0.0 version: 11.16.0(typescript@5.9.3) @@ -1019,6 +1025,46 @@ importers: specifier: ^3.0.0 version: 3.2.4(@types/debug@4.1.13)(@types/node@25.5.2)(happy-dom@20.8.9)(jiti@2.7.0)(jsdom@25.0.1)(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0) + packages/workspaces: + dependencies: + "@repo/core-shared": + specifier: workspace:* + version: link:../core-shared + "@trpc/server": + specifier: ^11.0.0 + version: 11.16.0(typescript@5.9.3) + inversify: + specifier: ^6.2.0 + version: 6.2.2(reflect-metadata@0.2.2) + payload: + specifier: ^3.14.0 + version: 3.81.0(graphql@16.13.2)(typescript@5.9.3) + reflect-metadata: + specifier: ^0.2.2 + version: 0.2.2 + zod: + specifier: ^3.24.0 + version: 3.25.76 + devDependencies: + "@repo/core-eslint": + specifier: workspace:* + version: link:../core-eslint + "@repo/core-testing": + specifier: workspace:* + version: link:../core-testing + "@repo/core-typescript": + specifier: workspace:* + version: link:../core-typescript + "@types/node": + specifier: ^22.0.0 + version: 22.19.17 + "@vitest/coverage-v8": + specifier: ^3.2.4 + version: 3.2.4(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.17)(happy-dom@20.8.9)(jiti@2.7.0)(jsdom@25.0.1)(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0)) + vitest: + specifier: ^3.1.0 + version: 3.2.4(@types/debug@4.1.13)(@types/node@22.19.17)(happy-dom@20.8.9)(jiti@2.7.0)(jsdom@25.0.1)(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0) + turbo/generators: dependencies: "@turbo/gen":