Rename vendors to third parties
Renames the user-facing 'vendor' concept to 'third party' across the entire codebase. The shared common_third_parties reference table is unchanged. Migration. Renames the vendor_category enum, the vendors and vendor_<entity> tables (contacts, services, compliance_reports, business_associate_agreements, data_privacy_agreements, risk_assessments) and their vendor_id columns, the asset_vendors / data_vendors / processing_activity_vendors junction tables, generated_documents.vendors_document_id, the webhook_event_type 'vendor:<verb>' values, and the snapshots_type 'VENDORS' value. Backend. Renames coredata models and SQL queries, probo services, GraphQL / MCP API surface, console / trust / webhook resolvers and types, the CLI (prb vendor* -> prb third-party*; pkg/cmd/vendormgmt -> pkg/cmd/thirdpartymgmt), the document generator, vetting agent prompts, and the common-third-parties-import command. Frontend, packages, n8n, e2e. Renames apps/console pages, components, hooks, routes, dialogs, and tabs; the shared @probo/vendors package (now @probo/third-parties); the @probo/ui Vendors atoms (now ThirdParties, VendorLogo -> ThirdPartyLogo); the n8n community node actions/vendor folder (now actions/thirdParty); and the e2e Go test suite (console and MCP). Filesystem and URL paths use kebab-case (third-parties), GraphQL fields and TypeScript identifiers use camelCase (thirdParty / thirdParties), Go types use PascalCase (ThirdParty), and human-facing text uses 'third party' with a space. Co-authored-by: Bryan Frimin <bryan@getprobo.com> Signed-off-by: Bryan Frimin <bryan@getprobo.com> Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
@@ -22,12 +22,12 @@ The `pages/` folder **is** the route tree. Every route segment maps to a folder
|
||||
// Bad — separate routes/ folder duplicates pages/ structure
|
||||
src/
|
||||
routes/
|
||||
vendorRoutes.ts # route definitions for vendors
|
||||
thirdPartyRoutes.ts # route definitions for third parties
|
||||
assetRoutes.ts # route definitions for assets
|
||||
pages/
|
||||
organizations/
|
||||
vendors/
|
||||
VendorsPage.tsx
|
||||
third-parties/
|
||||
ThirdPartiesPage.tsx
|
||||
assets/
|
||||
AssetsPage.tsx
|
||||
```
|
||||
@@ -37,9 +37,9 @@ src/
|
||||
src/
|
||||
pages/
|
||||
organizations/
|
||||
vendors/
|
||||
routes.ts # route definitions for vendors
|
||||
VendorsPage.tsx
|
||||
third-parties/
|
||||
routes.ts # route definitions for third parties
|
||||
ThirdPartiesPage.tsx
|
||||
assets/
|
||||
routes.ts # route definitions for assets
|
||||
AssetsPage.tsx
|
||||
@@ -77,11 +77,11 @@ Use the correct suffix so the role is clear from the file name alone:
|
||||
|
||||
```text
|
||||
// Bad — a layout route named as a "Page"
|
||||
VendorDetailPage.tsx # renders <Outlet />, wraps child routes
|
||||
ThirdPartyDetailPage.tsx # renders <Outlet />, wraps child routes
|
||||
CookieBannerConfigPage.tsx # renders tabs + <Outlet />
|
||||
|
||||
// Good — layout routes use the "Layout" suffix
|
||||
VendorDetailLayout.tsx
|
||||
ThirdPartyDetailLayout.tsx
|
||||
CookieBannerConfigLayout.tsx
|
||||
```
|
||||
|
||||
@@ -142,26 +142,26 @@ export default function CookieBannerLayout() {
|
||||
Contains route objects for the current folder's feature, exported as a named array and spread into the parent. Keep imports minimal — only `lazy`, skeleton components, and typing.
|
||||
|
||||
```ts
|
||||
// pages/organizations/vendors/routes.ts
|
||||
// pages/organizations/third-parties/routes.ts
|
||||
import { lazy } from "@probo/react-lazy";
|
||||
import type { AppRoute } from "@probo/routes";
|
||||
|
||||
import { VendorsPageSkeleton } from "./VendorsPageSkeleton";
|
||||
import { ThirdPartiesPageSkeleton } from "./ThirdPartiesPageSkeleton";
|
||||
|
||||
export const vendorRoutes = [
|
||||
export const thirdPartyRoutes = [
|
||||
{
|
||||
path: "vendors",
|
||||
Fallback: VendorsPageSkeleton,
|
||||
Component: lazy(() => import("./VendorsPageLoader")),
|
||||
path: "third-parties",
|
||||
Fallback: ThirdPartiesPageSkeleton,
|
||||
Component: lazy(() => import("./ThirdPartiesPageLoader")),
|
||||
},
|
||||
{
|
||||
path: "vendors/:vendorId",
|
||||
Fallback: VendorsPageSkeleton,
|
||||
Component: lazy(() => import("./VendorDetailLayoutLoader")),
|
||||
path: "third-parties/:thirdPartyId",
|
||||
Fallback: ThirdPartiesPageSkeleton,
|
||||
Component: lazy(() => import("./ThirdPartyDetailLayoutLoader")),
|
||||
children: [
|
||||
{
|
||||
path: "overview",
|
||||
Component: lazy(() => import("./overview/VendorOverviewPage")),
|
||||
Component: lazy(() => import("./overview/ThirdPartyOverviewPage")),
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -173,35 +173,35 @@ export const vendorRoutes = [
|
||||
The loader is the **lazy bundle entry point**. It sets up providers, triggers the Relay query, shows a skeleton until the query resolves, then renders the page.
|
||||
|
||||
```tsx
|
||||
// pages/organizations/vendors/VendorsPageLoader.tsx
|
||||
// pages/organizations/third-parties/ThirdPartiesPageLoader.tsx
|
||||
import { Suspense, useEffect } from "react";
|
||||
import { useQueryLoader } from "react-relay";
|
||||
|
||||
import type { VendorsPageQuery } from "#/__generated__/core/VendorsPageQuery.graphql";
|
||||
import type { ThirdPartiesPageQuery } from "#/__generated__/core/ThirdPartiesPageQuery.graphql";
|
||||
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||
|
||||
import VendorsPage, { vendorsPageQuery } from "./VendorsPage";
|
||||
import { VendorsPageSkeleton } from "./VendorsPageSkeleton";
|
||||
import ThirdPartiesPage, { thirdPartiesPageQuery } from "./ThirdPartiesPage";
|
||||
import { ThirdPartiesPageSkeleton } from "./ThirdPartiesPageSkeleton";
|
||||
|
||||
function VendorsPageQueryLoader() {
|
||||
function ThirdPartiesPageQueryLoader() {
|
||||
const organizationId = useOrganizationId();
|
||||
const [queryRef, loadQuery] = useQueryLoader<VendorsPageQuery>(vendorsPageQuery);
|
||||
const [queryRef, loadQuery] = useQueryLoader<ThirdPartiesPageQuery>(thirdPartiesPageQuery);
|
||||
|
||||
useEffect(() => {
|
||||
loadQuery({ organizationId });
|
||||
}, [loadQuery, organizationId]);
|
||||
|
||||
if (!queryRef) {
|
||||
return <VendorsPageSkeleton />;
|
||||
return <ThirdPartiesPageSkeleton />;
|
||||
}
|
||||
|
||||
return <VendorsPage queryRef={queryRef} />
|
||||
return <ThirdPartiesPage queryRef={queryRef} />
|
||||
}
|
||||
|
||||
export default function VendorsPageLoader() {
|
||||
export default function ThirdPartiesPageLoader() {
|
||||
return (
|
||||
<CoreRelayProvider>
|
||||
<VendorsPageQueryLoader />
|
||||
<ThirdPartiesPageQueryLoader />
|
||||
</CoreRelayProvider>
|
||||
);
|
||||
}
|
||||
@@ -212,9 +212,9 @@ export default function VendorsPageLoader() {
|
||||
Receives the `queryRef` from the loader and renders the UI. Default export so `lazy()` can import it.
|
||||
|
||||
```tsx
|
||||
// pages/organizations/vendors/VendorsPage.tsx
|
||||
export default function VendorsPage({ queryRef }: VendorsPageProps) {
|
||||
const data = usePreloadedQuery(vendorsPageQuery, queryRef);
|
||||
// pages/organizations/third-parties/ThirdPartiesPage.tsx
|
||||
export default function ThirdPartiesPage({ queryRef }: ThirdPartiesPageProps) {
|
||||
const data = usePreloadedQuery(thirdPartiesPageQuery, queryRef);
|
||||
return (/* … */);
|
||||
}
|
||||
```
|
||||
@@ -224,8 +224,8 @@ export default function VendorsPage({ queryRef }: VendorsPageProps) {
|
||||
A lightweight loading placeholder. Keep it free of data-fetching logic so it loads instantly.
|
||||
|
||||
```tsx
|
||||
// pages/organizations/vendors/VendorsPageSkeleton.tsx
|
||||
export function VendorsPageSkeleton() {
|
||||
// pages/organizations/third-parties/ThirdPartiesPageSkeleton.tsx
|
||||
export function ThirdPartiesPageSkeleton() {
|
||||
return (/* pulse / skeleton UI */);
|
||||
}
|
||||
```
|
||||
@@ -235,8 +235,8 @@ export function VendorsPageSkeleton() {
|
||||
Rendered by the route error boundary when the page throws.
|
||||
|
||||
```tsx
|
||||
// pages/organizations/vendors/VendorsPageError.tsx
|
||||
export function VendorsPageError() {
|
||||
// pages/organizations/third-parties/ThirdPartiesPageError.tsx
|
||||
export function ThirdPartiesPageError() {
|
||||
const error = useRouteError();
|
||||
return (/* error UI */);
|
||||
}
|
||||
@@ -244,24 +244,24 @@ export function VendorsPageError() {
|
||||
|
||||
## File naming
|
||||
|
||||
Component files (`.tsx` that export a React component) use **PascalCase**: `VendorsPage.tsx`, `VendorContactRow.tsx`, `VendorsPageSkeleton.tsx`.
|
||||
Component files (`.tsx` that export a React component) use **PascalCase**: `ThirdPartiesPage.tsx`, `ThirdPartyContactRow.tsx`, `ThirdPartiesPageSkeleton.tsx`.
|
||||
|
||||
All other helper files (utilities, hooks, constants, configuration) use **camelCase**: `routes.ts`, `useVendorFilters.ts`, `formatCurrency.ts`, `constants.ts`.
|
||||
All other helper files (utilities, hooks, constants, configuration) use **camelCase**: `routes.ts`, `useThirdPartyFilters.ts`, `formatCurrency.ts`, `constants.ts`.
|
||||
|
||||
### Do / don't: file naming
|
||||
|
||||
```text
|
||||
// Bad — helper file in PascalCase
|
||||
pages/organizations/vendors/FormatVendorStatus.ts
|
||||
pages/organizations/vendors/UseVendorFilters.ts
|
||||
pages/organizations/vendors/Routes.ts
|
||||
pages/organizations/third-parties/FormatThirdPartyStatus.ts
|
||||
pages/organizations/third-parties/UseThirdPartyFilters.ts
|
||||
pages/organizations/third-parties/Routes.ts
|
||||
|
||||
// Good — helpers are camelCase, components are PascalCase
|
||||
pages/organizations/vendors/formatVendorStatus.ts
|
||||
pages/organizations/vendors/useVendorFilters.ts
|
||||
pages/organizations/vendors/routes.ts
|
||||
pages/organizations/vendors/VendorsPage.tsx
|
||||
pages/organizations/vendors/VendorsPageSkeleton.tsx
|
||||
pages/organizations/third-parties/formatThirdPartyStatus.ts
|
||||
pages/organizations/third-parties/useThirdPartyFilters.ts
|
||||
pages/organizations/third-parties/routes.ts
|
||||
pages/organizations/third-parties/ThirdPartiesPage.tsx
|
||||
pages/organizations/third-parties/ThirdPartiesPageSkeleton.tsx
|
||||
```
|
||||
|
||||
## `_components` folder
|
||||
@@ -270,7 +270,7 @@ Sub-components that are used **only** by a single page live in a `_components/`
|
||||
|
||||
| Situation | Where the component lives |
|
||||
| ------------------------------------------ | ---------------------------------------------------------------------------------- |
|
||||
| Used by one page only | `pages/organizations/vendors/_components/` |
|
||||
| Used by one page only | `pages/organizations/third-parties/_components/` |
|
||||
| Used by multiple pages in the same feature | Nearest common ancestor's `_components/` (e.g. `pages/organizations/_components/`) |
|
||||
| Reusable UI primitive | `@probo/ui` package |
|
||||
|
||||
@@ -278,8 +278,8 @@ Sub-components that are used **only** by a single page live in a `_components/`
|
||||
|
||||
```text
|
||||
// Bad — shared component buried in a single page's _components
|
||||
pages/organizations/vendors/_components/StatusBadge.tsx # also used by risks page
|
||||
pages/organizations/risks/SomeRiskPage.tsx # imports ../../vendors/_components/StatusBadge
|
||||
pages/organizations/third-parties/_components/StatusBadge.tsx # also used by risks page
|
||||
pages/organizations/risks/SomeRiskPage.tsx # imports ../../third-parties/_components/StatusBadge
|
||||
|
||||
// Good — shared component hoisted to common ancestor
|
||||
pages/organizations/_components/StatusBadge.tsx
|
||||
@@ -287,10 +287,10 @@ pages/organizations/_components/StatusBadge.tsx
|
||||
|
||||
```text
|
||||
// Bad — page-specific helper placed in a global folder
|
||||
src/components/VendorContactRow.tsx # only used by VendorContactsTab
|
||||
src/components/ThirdPartyContactRow.tsx # only used by ThirdPartyContactsTab
|
||||
|
||||
// Good — scoped to the page that uses it
|
||||
pages/organizations/vendors/_components/VendorContactRow.tsx
|
||||
pages/organizations/third-parties/_components/ThirdPartyContactRow.tsx
|
||||
```
|
||||
|
||||
## Child-route folder naming
|
||||
@@ -303,40 +303,40 @@ Folders that contain child-route pages are named after the **resource or concept
|
||||
// Bad — folder named after a UI element
|
||||
configuration/
|
||||
tabs/ # "tabs" is a UI component, not a resource
|
||||
VendorOverviewTab.tsx
|
||||
VendorComplianceTab.tsx
|
||||
ThirdPartyOverviewTab.tsx
|
||||
ThirdPartyComplianceTab.tsx
|
||||
|
||||
// Good — folders named after the resource each child route represents
|
||||
configuration/
|
||||
overview/
|
||||
VendorOverviewPage.tsx
|
||||
ThirdPartyOverviewPage.tsx
|
||||
compliance/
|
||||
VendorCompliancePage.tsx
|
||||
ThirdPartyCompliancePage.tsx
|
||||
```
|
||||
|
||||
This also means child-route components use the `*Page` suffix (not `*Tab`), because they are pages in their own right — the fact that a tab bar navigates between them is an implementation detail of the parent layout.
|
||||
|
||||
## Full example tree
|
||||
|
||||
Target layout for a `vendors` feature under `pages/organizations/`:
|
||||
Target layout for a `third-parties` feature under `pages/organizations/`:
|
||||
|
||||
```text
|
||||
pages/organizations/vendors/
|
||||
routes.ts # route definitions for vendors
|
||||
VendorsPageLoader.tsx # lazy entry — providers + Suspense + query loader
|
||||
VendorsPage.tsx # page component (usePreloadedQuery)
|
||||
VendorsPageSkeleton.tsx # loading fallback
|
||||
VendorDetailLayoutLoader.tsx # lazy entry for detail layout
|
||||
VendorDetailLayout.tsx # layout — breadcrumbs, tabs, <Outlet />
|
||||
VendorDetailLayoutSkeleton.tsx # detail loading fallback
|
||||
NewVendorPage.tsx # mutation-only page — default export, wraps itself in the Relay provider
|
||||
_components/ # sub-components used only by vendor pages
|
||||
VendorContactRow.tsx
|
||||
VendorRiskSummary.tsx
|
||||
overview/ # child route: /vendors/:vendorId/overview
|
||||
VendorOverviewPage.tsx
|
||||
compliance/ # child route: /vendors/:vendorId/compliance
|
||||
VendorCompliancePage.tsx
|
||||
contacts/ # child route: /vendors/:vendorId/contacts
|
||||
VendorContactsPage.tsx
|
||||
pages/organizations/third-parties/
|
||||
routes.ts # route definitions for third parties
|
||||
ThirdPartiesPageLoader.tsx # lazy entry — providers + Suspense + query loader
|
||||
ThirdPartiesPage.tsx # page component (usePreloadedQuery)
|
||||
ThirdPartiesPageSkeleton.tsx # loading fallback
|
||||
ThirdPartyDetailLayoutLoader.tsx # lazy entry for detail layout
|
||||
ThirdPartyDetailLayout.tsx # layout — breadcrumbs, tabs, <Outlet />
|
||||
ThirdPartyDetailLayoutSkeleton.tsx # detail loading fallback
|
||||
NewThirdPartyPage.tsx # mutation-only page — default export, wraps itself in the Relay provider
|
||||
_components/ # sub-components used only by third party pages
|
||||
ThirdPartyContactRow.tsx
|
||||
ThirdPartyRiskSummary.tsx
|
||||
overview/ # child route: /third-parties/:thirdPartyId/overview
|
||||
ThirdPartyOverviewPage.tsx
|
||||
compliance/ # child route: /third-parties/:thirdPartyId/compliance
|
||||
ThirdPartyCompliancePage.tsx
|
||||
contacts/ # child route: /third-parties/:thirdPartyId/contacts
|
||||
ThirdPartyContactsPage.tsx
|
||||
```
|
||||
|
||||
@@ -8,18 +8,18 @@ Policy-based authorization in `pkg/iam/` using an evaluation model similar to AW
|
||||
|
||||
**Policy** — a named collection of statements:
|
||||
```go
|
||||
policy.NewPolicy("vendor-crud", "Vendor CRUD",
|
||||
policy.Allow(ActionVendorGet, ActionVendorList).WithSID("read-vendors"),
|
||||
policy.Deny(ActionVendorDelete).WithSID("deny-vendor-delete"),
|
||||
).WithDescription("Standard vendor access")
|
||||
policy.NewPolicy("thirdParty-crud", "ThirdParty CRUD",
|
||||
policy.Allow(ActionThirdPartyGet, ActionThirdPartyList).WithSID("read-thirdParties"),
|
||||
policy.Deny(ActionThirdPartyDelete).WithSID("deny-thirdParty-delete"),
|
||||
).WithDescription("Standard third party access")
|
||||
```
|
||||
|
||||
**Statement** — a single permission rule with effect (allow/deny), actions, optional resources, and optional conditions.
|
||||
|
||||
**Action format** — `SERVICE:RESOURCE:OPERATION` with wildcard support:
|
||||
```
|
||||
core:vendor:create # specific action
|
||||
core:vendor:* # all vendor actions
|
||||
core:thirdParty:create # specific action
|
||||
core:thirdParty:* # all third party actions
|
||||
core:* # all core actions
|
||||
* # everything
|
||||
```
|
||||
@@ -39,8 +39,8 @@ The evaluator processes all statements against a request:
|
||||
```go
|
||||
err := iamService.Authorizer.Authorize(ctx, iam.AuthorizeParams{
|
||||
Principal: identityID, // who
|
||||
Resource: vendorID, // what
|
||||
Action: probo.ActionVendorGet, // which action
|
||||
Resource: thirdPartyID, // what
|
||||
Action: probo.ActionThirdPartyGet, // which action
|
||||
ResourceAttributes: map[string]string{}, // optional extra attributes
|
||||
})
|
||||
```
|
||||
@@ -78,8 +78,8 @@ Conditions constrain when a statement applies. All conditions must be satisfied.
|
||||
// Users can only access resources in their organization
|
||||
organizationCondition := policy.Equals("principal.organization_id", "resource.organization_id")
|
||||
|
||||
policy.Allow(ActionVendorGet).
|
||||
WithSID("view-vendor").
|
||||
policy.Allow(ActionThirdPartyGet).
|
||||
WithSID("view-thirdParty").
|
||||
When(organizationCondition)
|
||||
```
|
||||
|
||||
@@ -97,14 +97,14 @@ Key paths use `principal.ATTR` or `resource.ATTR` (e.g., `principal.organization
|
||||
Resources that support authorization must implement this interface in `pkg/coredata/`:
|
||||
|
||||
```go
|
||||
func (v *Vendor) AuthorizationAttributes(ctx context.Context, conn pg.Conn) (map[string]string, error) {
|
||||
q := `SELECT organization_id FROM vendors WHERE id = $1 LIMIT 1;`
|
||||
func (v *ThirdParty) AuthorizationAttributes(ctx context.Context, conn pg.Conn) (map[string]string, error) {
|
||||
q := `SELECT organization_id FROM thirdParties WHERE id = $1 LIMIT 1;`
|
||||
var organizationID gid.GID
|
||||
if err := conn.QueryRow(ctx, q, v.ID).Scan(&organizationID); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrResourceNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("cannot query vendor authorization attributes: %w", err)
|
||||
return nil, fmt.Errorf("cannot query third party authorization attributes: %w", err)
|
||||
}
|
||||
return map[string]string{"organization_id": organizationID.String()}, nil
|
||||
}
|
||||
@@ -126,14 +126,14 @@ var (
|
||||
|
||||
**GraphQL resolvers** use `AuthorizeFunc` from `pkg/server/api/authz/`:
|
||||
```go
|
||||
if err := authorize(ctx, vendorID, probo.ActionVendorGet); err != nil {
|
||||
if err := authorize(ctx, thirdPartyID, probo.ActionThirdPartyGet); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
```
|
||||
|
||||
**MCP resolvers** use `MustAuthorize` which panics (caught by middleware):
|
||||
```go
|
||||
r.MustAuthorize(ctx, input.ID, probo.ActionVendorGet)
|
||||
r.MustAuthorize(ctx, input.ID, probo.ActionThirdPartyGet)
|
||||
```
|
||||
|
||||
## File locations
|
||||
@@ -155,11 +155,11 @@ IAM actions live in `pkg/iam/iam_actions.go`, probo actions in `pkg/probo/action
|
||||
|
||||
```go
|
||||
const (
|
||||
ActionVendorGet = "core:vendor:get"
|
||||
ActionVendorList = "core:vendor:list"
|
||||
ActionVendorCreate = "core:vendor:create"
|
||||
ActionVendorUpdate = "core:vendor:update"
|
||||
ActionVendorDelete = "core:vendor:delete"
|
||||
ActionThirdPartyGet = "core:thirdParty:get"
|
||||
ActionThirdPartyList = "core:thirdParty:list"
|
||||
ActionThirdPartyCreate = "core:thirdParty:create"
|
||||
ActionThirdPartyUpdate = "core:thirdParty:update"
|
||||
ActionThirdPartyDelete = "core:thirdParty:delete"
|
||||
)
|
||||
```
|
||||
|
||||
|
||||
@@ -13,18 +13,18 @@ Follow the [seven rules of a great Git commit message](https://cbea.ms/git-commi
|
||||
The subject line should complete the sentence: "If applied, this commit will *your subject line here*".
|
||||
|
||||
```
|
||||
Add vendor assessment agent for third-party reviews
|
||||
Add third-party assessment agent for third-party reviews
|
||||
|
||||
The existing changelog generator only covers internal changes.
|
||||
This introduces a dedicated agent that evaluates third-party
|
||||
vendors against our compliance criteria, producing a structured
|
||||
thirdParties against our compliance criteria, producing a structured
|
||||
risk report.
|
||||
```
|
||||
|
||||
Not every commit needs a body -- a single line is fine when the change is self-explanatory:
|
||||
|
||||
```
|
||||
Fix typo in vendor assessment prompt
|
||||
Fix typo in third-party assessment prompt
|
||||
```
|
||||
|
||||
## Signing and Authorship
|
||||
|
||||
@@ -43,7 +43,7 @@ Two patterns in `e2e/internal/factory/`:
|
||||
|
||||
**Builder pattern (preferred):**
|
||||
```go
|
||||
vendorID := factory.NewVendor(owner).
|
||||
thirdPartyID := factory.NewThirdParty(owner).
|
||||
WithName("Stripe").
|
||||
WithCategory("CLOUD_PROVIDER").
|
||||
Create()
|
||||
@@ -59,7 +59,7 @@ controlID := factory.NewControl(owner, frameworkID).
|
||||
|
||||
**Simple factory functions:**
|
||||
```go
|
||||
vendorID := factory.CreateVendor(c, factory.Attrs{"name": "Acme"})
|
||||
thirdPartyID := factory.CreateThirdParty(c, factory.Attrs{"name": "Acme"})
|
||||
taskID := factory.CreateTask(c, &measureID, factory.Attrs{"name": "Task 1"})
|
||||
```
|
||||
|
||||
@@ -70,7 +70,7 @@ Use `factory.SafeName("prefix")` for unique names and `factory.SafeEmail()` for
|
||||
Every test and subtest **must** call `t.Parallel()`. One test file per entity in `e2e/console/`. Function naming: `TestEntity_Operation`.
|
||||
|
||||
```go
|
||||
func TestVendor_Create(t *testing.T) {
|
||||
func TestThirdParty_Create(t *testing.T) {
|
||||
t.Parallel()
|
||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||
|
||||
@@ -78,9 +78,9 @@ func TestVendor_Create(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const query = `
|
||||
mutation CreateVendor($input: CreateVendorInput!) {
|
||||
createVendor(input: $input) {
|
||||
vendorEdge {
|
||||
mutation CreateThirdParty($input: CreateThirdPartyInput!) {
|
||||
createThirdParty(input: $input) {
|
||||
thirdPartyEdge {
|
||||
node { id name }
|
||||
}
|
||||
}
|
||||
@@ -88,25 +88,25 @@ func TestVendor_Create(t *testing.T) {
|
||||
`
|
||||
|
||||
var result struct {
|
||||
CreateVendor struct {
|
||||
VendorEdge struct {
|
||||
CreateThirdParty struct {
|
||||
ThirdPartyEdge struct {
|
||||
Node struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
} `json:"node"`
|
||||
} `json:"vendorEdge"`
|
||||
} `json:"createVendor"`
|
||||
} `json:"thirdPartyEdge"`
|
||||
} `json:"createThirdParty"`
|
||||
}
|
||||
|
||||
err := owner.Execute(query, map[string]any{
|
||||
"input": map[string]any{
|
||||
"organizationId": owner.GetOrganizationID().String(),
|
||||
"name": factory.SafeName("Vendor"),
|
||||
"name": factory.SafeName("ThirdParty"),
|
||||
},
|
||||
}, &result)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, result.CreateVendor.VendorEdge.Node.ID)
|
||||
assert.NotEmpty(t, result.CreateThirdParty.ThirdPartyEdge.Node.ID)
|
||||
})
|
||||
}
|
||||
```
|
||||
@@ -139,13 +139,13 @@ t.Run("other org cannot access", func(t *testing.T) {
|
||||
owner1 := testutil.NewClient(t, testutil.RoleOwner)
|
||||
owner2 := testutil.NewClient(t, testutil.RoleOwner)
|
||||
|
||||
vendorID := factory.NewVendor(owner1).WithName("Vendor").Create()
|
||||
thirdPartyID := factory.NewThirdParty(owner1).WithName("ThirdParty").Create()
|
||||
|
||||
var result struct {
|
||||
Node *struct{ ID string } `json:"node"`
|
||||
}
|
||||
err := owner2.Execute(nodeQuery, map[string]any{"id": vendorID}, &result)
|
||||
testutil.AssertNodeNotAccessible(t, err, result.Node == nil, "Vendor")
|
||||
err := owner2.Execute(nodeQuery, map[string]any{"id": thirdPartyID}, &result)
|
||||
testutil.AssertNodeNotAccessible(t, err, result.Node == nil, "ThirdParty")
|
||||
})
|
||||
```
|
||||
|
||||
@@ -214,7 +214,7 @@ for _, tt := range tests {
|
||||
```go
|
||||
err := owner.ExecuteWithFile(
|
||||
uploadQuery,
|
||||
map[string]any{"input": map[string]any{"vendorId": vendorID, "file": nil}},
|
||||
map[string]any{"input": map[string]any{"thirdPartyId": thirdPartyID, "file": nil}},
|
||||
"input.file",
|
||||
testutil.UploadFile{
|
||||
Filename: "report.pdf",
|
||||
|
||||
@@ -131,7 +131,7 @@ if errors.As(err, &ve) {
|
||||
- Constructors: `New*` (e.g. `NewService`, `NewServer`, `NewBridge`)
|
||||
- Config structs: `*Config` suffix (e.g. `APIConfig`, `PgConfig`, `TrustCenterConfig`)
|
||||
- Request structs: `*Request` suffix (e.g. `UpdateTrustCenterRequest`)
|
||||
- Unexported types for internal data: lowercase (e.g. `vendorInfo`, `ctxKey`)
|
||||
- Unexported types for internal data: lowercase (e.g. `thirdPartyInfo`, `ctxKey`)
|
||||
|
||||
## Functional options and Config structs
|
||||
|
||||
|
||||
@@ -7,9 +7,9 @@ Schema-first GraphQL using [gqlgen](https://gqlgen.com/). The schema is hand-wri
|
||||
Each API's schema lives in `pkg/server/api/{api}/v1/graphql/` as multiple `.graphql` files, one per coredata model:
|
||||
|
||||
- `base.graphql` — directives, scalars, Node interface, PageInfo, OrderDirection, root Query/Mutation/Organization types
|
||||
- Entity files (e.g., `vendor.graphql`, `control.graphql`) — use `extend type Mutation` to add their mutations.
|
||||
- Entity files (e.g., `thirdParty.graphql`, `control.graphql`) — use `extend type Mutation` to add their mutations.
|
||||
|
||||
gqlgen's `follow-schema` layout generates one resolver file per schema file (e.g., `vendor.resolvers.go`). Types that get extended across files (Organization, Mutation, Viewer, TrustCenter) must be defined in `base.graphql`.
|
||||
gqlgen's `follow-schema` layout generates one resolver file per schema file (e.g., `thirdParty.resolvers.go`). Types that get extended across files (Organization, Mutation, Viewer, TrustCenter) must be defined in `base.graphql`.
|
||||
|
||||
### `extend type` restrictions
|
||||
|
||||
@@ -20,18 +20,18 @@ gqlgen's `follow-schema` layout generates one resolver file per schema file (e.g
|
||||
**Always define a custom Go type for connection types** using the `@goModel` directive. The model path points to the `types` package for the relevant API. The `totalCount` field must use `@goField(forceResolver: true)`. Edge types do not need `@goModel`.
|
||||
|
||||
```graphql
|
||||
type VendorConnection
|
||||
type ThirdPartyConnection
|
||||
@goModel(
|
||||
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.VendorConnection"
|
||||
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.ThirdPartyConnection"
|
||||
) {
|
||||
totalCount: Int! @goField(forceResolver: true)
|
||||
edges: [VendorEdge!]!
|
||||
edges: [ThirdPartyEdge!]!
|
||||
pageInfo: PageInfo!
|
||||
}
|
||||
|
||||
type VendorEdge {
|
||||
type ThirdPartyEdge {
|
||||
cursor: CursorKey!
|
||||
node: Vendor!
|
||||
node: ThirdParty!
|
||||
}
|
||||
```
|
||||
|
||||
@@ -42,12 +42,12 @@ Without `@goModel`, gqlgen generates a default struct that lacks the custom fiel
|
||||
Map GraphQL enums to existing Go types using `@goModel` on the enum and `@goEnum` on each value:
|
||||
|
||||
```graphql
|
||||
enum VendorOrderField
|
||||
@goModel(model: "go.probo.inc/probo/pkg/coredata.VendorOrderField") {
|
||||
enum ThirdPartyOrderField
|
||||
@goModel(model: "go.probo.inc/probo/pkg/coredata.ThirdPartyOrderField") {
|
||||
CREATED_AT
|
||||
@goEnum(value: "go.probo.inc/probo/pkg/coredata.VendorOrderFieldCreatedAt")
|
||||
@goEnum(value: "go.probo.inc/probo/pkg/coredata.ThirdPartyOrderFieldCreatedAt")
|
||||
NAME
|
||||
@goEnum(value: "go.probo.inc/probo/pkg/coredata.VendorOrderFieldName")
|
||||
@goEnum(value: "go.probo.inc/probo/pkg/coredata.ThirdPartyOrderFieldName")
|
||||
}
|
||||
```
|
||||
|
||||
@@ -88,14 +88,14 @@ Connection fields on parent types use standard Relay arguments:
|
||||
|
||||
```graphql
|
||||
type Organization {
|
||||
vendors(
|
||||
thirdParties(
|
||||
first: Int
|
||||
after: CursorKey
|
||||
last: Int
|
||||
before: CursorKey
|
||||
orderBy: VendorOrder
|
||||
filter: VendorFilter
|
||||
): VendorConnection!
|
||||
orderBy: ThirdPartyOrder
|
||||
filter: ThirdPartyFilter
|
||||
): ThirdPartyConnection!
|
||||
}
|
||||
```
|
||||
|
||||
@@ -105,11 +105,11 @@ Each connection type lives in `types/*_connection.go` and follows this structure
|
||||
|
||||
```go
|
||||
type (
|
||||
VendorOrderBy OrderBy[coredata.VendorOrderField]
|
||||
ThirdPartyOrderBy OrderBy[coredata.ThirdPartyOrderField]
|
||||
|
||||
VendorConnection struct {
|
||||
ThirdPartyConnection struct {
|
||||
TotalCount int
|
||||
Edges []*VendorEdge
|
||||
Edges []*ThirdPartyEdge
|
||||
PageInfo PageInfo
|
||||
|
||||
Resolver any
|
||||
@@ -117,17 +117,17 @@ type (
|
||||
}
|
||||
)
|
||||
|
||||
func NewVendorConnection(
|
||||
p *page.Page[*coredata.Vendor, coredata.VendorOrderField],
|
||||
func NewThirdPartyConnection(
|
||||
p *page.Page[*coredata.ThirdParty, coredata.ThirdPartyOrderField],
|
||||
parentType any,
|
||||
parentID gid.GID,
|
||||
) *VendorConnection {
|
||||
edges := make([]*VendorEdge, len(p.Data))
|
||||
) *ThirdPartyConnection {
|
||||
edges := make([]*ThirdPartyEdge, len(p.Data))
|
||||
for i, v := range p.Data {
|
||||
edges[i] = NewVendorEdge(v, p.Cursor.OrderBy.Field)
|
||||
edges[i] = NewThirdPartyEdge(v, p.Cursor.OrderBy.Field)
|
||||
}
|
||||
|
||||
return &VendorConnection{
|
||||
return &ThirdPartyConnection{
|
||||
Edges: edges,
|
||||
PageInfo: *NewPageInfo(p),
|
||||
|
||||
@@ -136,13 +136,13 @@ func NewVendorConnection(
|
||||
}
|
||||
}
|
||||
|
||||
func NewVendorEdge(
|
||||
v *coredata.Vendor,
|
||||
orderBy coredata.VendorOrderField,
|
||||
) *VendorEdge {
|
||||
return &VendorEdge{
|
||||
func NewThirdPartyEdge(
|
||||
v *coredata.ThirdParty,
|
||||
orderBy coredata.ThirdPartyOrderField,
|
||||
) *ThirdPartyEdge {
|
||||
return &ThirdPartyEdge{
|
||||
Cursor: v.CursorKey(orderBy),
|
||||
Node: NewVendor(v),
|
||||
Node: NewThirdParty(v),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -8,7 +8,7 @@ MCP tools are defined in `pkg/server/api/mcp/v1/specification.yaml` and generate
|
||||
- `specification.yaml` — tool definitions, input/output schemas, component schemas
|
||||
- `resolver.go` — `Resolver` struct, `MustAuthorize`, service accessors
|
||||
- `helpers.go` — pagination helpers, `UnwrapOmittable`
|
||||
- `types/*.go` (except `types/types.go`) — type conversion helpers (`NewVendor()`, `NewListVendorsOutput()`, etc.)
|
||||
- `types/*.go` (except `types/types.go`) — type conversion helpers (`NewThirdParty()`, `NewListThirdPartiesOutput()`, etc.)
|
||||
- `schema.resolvers.go` — tool implementation bodies (stubs generated, you edit the bodies)
|
||||
|
||||
**Generated** (do not edit):
|
||||
@@ -24,16 +24,16 @@ go generate ./pkg/server/api/mcp/v1
|
||||
|
||||
```yaml
|
||||
tools:
|
||||
- name: listVendors
|
||||
description: List all vendors for the organization
|
||||
- name: listThirdParties
|
||||
description: List all thirdParties for the organization
|
||||
hints:
|
||||
readonly: true
|
||||
idempotent: true
|
||||
destructive: false
|
||||
inputSchema:
|
||||
$ref: "#/components/schemas/ListVendorsInput"
|
||||
$ref: "#/components/schemas/ListThirdPartiesInput"
|
||||
outputSchema:
|
||||
$ref: "#/components/schemas/ListVendorsOutput"
|
||||
$ref: "#/components/schemas/ListThirdPartiesOutput"
|
||||
```
|
||||
|
||||
Input/output schemas reference `components/schemas`. Map custom Go types with the `go.probo.inc/mcpgen/type` extension:
|
||||
@@ -51,11 +51,11 @@ components:
|
||||
Generated stubs follow this pattern:
|
||||
|
||||
```go
|
||||
func (r *Resolver) ListVendorsTool(
|
||||
func (r *Resolver) ListThirdPartiesTool(
|
||||
ctx context.Context,
|
||||
req *mcp.CallToolRequest,
|
||||
input *types.ListVendorsInput,
|
||||
) (*mcp.CallToolResult, types.ListVendorsOutput, error)
|
||||
input *types.ListThirdPartiesInput,
|
||||
) (*mcp.CallToolResult, types.ListThirdPartiesOutput, error)
|
||||
```
|
||||
|
||||
First return is always `nil`. Errors are either returned (for recoverable) or panicked (for authorization and unexpected failures).
|
||||
@@ -65,24 +65,24 @@ First return is always `nil`. Errors are either returned (for recoverable) or pa
|
||||
Use `MustAuthorize` which panics on failure (caught by middleware):
|
||||
|
||||
```go
|
||||
r.MustAuthorize(ctx, input.OrganizationID, probo.ActionVendorList)
|
||||
r.MustAuthorize(ctx, input.OrganizationID, probo.ActionThirdPartyList)
|
||||
```
|
||||
|
||||
## Common resolver patterns
|
||||
|
||||
**List with pagination:**
|
||||
```go
|
||||
func (r *Resolver) ListVendorsTool(ctx context.Context, req *mcp.CallToolRequest, input *types.ListVendorsInput) (*mcp.CallToolResult, types.ListVendorsOutput, error) {
|
||||
r.MustAuthorize(ctx, input.OrganizationID, probo.ActionVendorList)
|
||||
func (r *Resolver) ListThirdPartiesTool(ctx context.Context, req *mcp.CallToolRequest, input *types.ListThirdPartiesInput) (*mcp.CallToolResult, types.ListThirdPartiesOutput, error) {
|
||||
r.MustAuthorize(ctx, input.OrganizationID, probo.ActionThirdPartyList)
|
||||
|
||||
prb := r.ProboService(ctx, input.OrganizationID)
|
||||
|
||||
pageOrderBy := page.OrderBy[coredata.VendorOrderField]{
|
||||
Field: coredata.VendorOrderFieldCreatedAt,
|
||||
pageOrderBy := page.OrderBy[coredata.ThirdPartyOrderField]{
|
||||
Field: coredata.ThirdPartyOrderFieldCreatedAt,
|
||||
Direction: page.OrderDirectionDesc,
|
||||
}
|
||||
if input.OrderBy != nil {
|
||||
pageOrderBy = page.OrderBy[coredata.VendorOrderField]{
|
||||
pageOrderBy = page.OrderBy[coredata.ThirdPartyOrderField]{
|
||||
Field: input.OrderBy.Field,
|
||||
Direction: input.OrderBy.Direction,
|
||||
}
|
||||
@@ -90,12 +90,12 @@ func (r *Resolver) ListVendorsTool(ctx context.Context, req *mcp.CallToolRequest
|
||||
|
||||
cursor := types.NewCursor(input.Size, input.Cursor, pageOrderBy)
|
||||
|
||||
page, err := prb.Vendors.ListForOrganizationID(ctx, input.OrganizationID, cursor, coredata.NewVendorFilter(nil, nil))
|
||||
page, err := prb.ThirdParties.ListForOrganizationID(ctx, input.OrganizationID, cursor, coredata.NewThirdPartyFilter(nil, nil))
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("cannot list vendors: %w", err))
|
||||
panic(fmt.Errorf("cannot list thirdParties: %w", err))
|
||||
}
|
||||
|
||||
return nil, types.NewListVendorsOutput(page), nil
|
||||
return nil, types.NewListThirdPartiesOutput(page), nil
|
||||
}
|
||||
```
|
||||
|
||||
@@ -156,8 +156,8 @@ Description: UnwrapOmittable(input.Description),
|
||||
Live in `types/*.go` (not the generated `types/types.go`). One file per entity:
|
||||
|
||||
```go
|
||||
func NewVendor(v *coredata.Vendor) *Vendor {
|
||||
return &Vendor{
|
||||
func NewThirdParty(v *coredata.ThirdParty) *ThirdParty {
|
||||
return &ThirdParty{
|
||||
ID: v.ID,
|
||||
OrganizationID: v.OrganizationID,
|
||||
Name: v.Name,
|
||||
@@ -166,21 +166,21 @@ func NewVendor(v *coredata.Vendor) *Vendor {
|
||||
}
|
||||
}
|
||||
|
||||
func NewListVendorsOutput(vendorPage *page.Page[*coredata.Vendor, coredata.VendorOrderField]) ListVendorsOutput {
|
||||
vendors := make([]*Vendor, 0, len(vendorPage.Data))
|
||||
for _, v := range vendorPage.Data {
|
||||
vendors = append(vendors, NewVendor(v))
|
||||
func NewListThirdPartiesOutput(thirdPartyPage *page.Page[*coredata.ThirdParty, coredata.ThirdPartyOrderField]) ListThirdPartiesOutput {
|
||||
thirdParties := make([]*ThirdParty, 0, len(thirdPartyPage.Data))
|
||||
for _, v := range thirdPartyPage.Data {
|
||||
thirdParties = append(thirdParties, NewThirdParty(v))
|
||||
}
|
||||
|
||||
var nextCursor *page.CursorKey
|
||||
if len(vendorPage.Data) > 0 {
|
||||
cursorKey := vendorPage.Data[len(vendorPage.Data)-1].CursorKey(vendorPage.Cursor.OrderBy.Field)
|
||||
if len(thirdPartyPage.Data) > 0 {
|
||||
cursorKey := thirdPartyPage.Data[len(thirdPartyPage.Data)-1].CursorKey(thirdPartyPage.Cursor.OrderBy.Field)
|
||||
nextCursor = &cursorKey
|
||||
}
|
||||
|
||||
return ListVendorsOutput{
|
||||
return ListThirdPartiesOutput{
|
||||
NextCursor: nextCursor,
|
||||
Vendors: vendors,
|
||||
ThirdParties: thirdParties,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -79,10 +79,10 @@ export function UserCard({ name }: UserCardProps) {
|
||||
|
||||
```tsx
|
||||
// Good — destructure in body when parameter-level destructuring would exceed the line-length limit
|
||||
export function VendorComplianceOverviewPanel(
|
||||
props: VendorComplianceOverviewPanelProps,
|
||||
export function ThirdPartyComplianceOverviewPanel(
|
||||
props: ThirdPartyComplianceOverviewPanelProps,
|
||||
) {
|
||||
const { className, vendorKey, onStatusChange } = props;
|
||||
const { className, thirdPartyKey, onStatusChange } = props;
|
||||
// …
|
||||
}
|
||||
```
|
||||
@@ -139,11 +139,11 @@ export function Thing({ label }: ThingProps) {
|
||||
|
||||
```tsx
|
||||
// Good — rare exception: route entry default export (names still clear in module)
|
||||
type VendorsPageProps = {
|
||||
queryRef: PreloadedQuery<VendorsQuery>;
|
||||
type ThirdPartiesPageProps = {
|
||||
queryRef: PreloadedQuery<ThirdPartiesQuery>;
|
||||
};
|
||||
|
||||
export default function VendorsPage({ queryRef }: VendorsPageProps) {
|
||||
export default function ThirdPartiesPage({ queryRef }: ThirdPartiesPageProps) {
|
||||
// …
|
||||
}
|
||||
```
|
||||
@@ -204,7 +204,7 @@ Use props for:
|
||||
### Hooks for data and URL-derived identity
|
||||
|
||||
- **Fetched data:** Colocate Relay fragments and queries per [`contrib/claude/relay.md`](relay.md) (`useFragment`, `useLazyLoadQuery`, `usePreloadedQuery`, etc.) in the component that needs the data.
|
||||
- **Route parameters:** Call `useParams()` (or a small `useOrganizationId()`-style hook) **inside** the component that needs the id — avoid drilling `organizationId` / `vendorId` from a parent that only read the URL to pass them down.
|
||||
- **Route parameters:** Call `useParams()` (or a small `useOrganizationId()`-style hook) **inside** the component that needs the id — avoid drilling `organizationId` / `thirdPartyId` from a parent that only read the URL to pass them down.
|
||||
|
||||
### Relay: framework wiring is not “business data props”
|
||||
|
||||
@@ -214,36 +214,36 @@ Relay sometimes requires **opaque handles** on props: e.g. **`queryRef`** for `u
|
||||
|
||||
```tsx
|
||||
// Bad — parent only needed the param to pass it down
|
||||
function VendorLayout() {
|
||||
const { vendorId } = useParams();
|
||||
function ThirdPartyLayout() {
|
||||
const { thirdPartyId } = useParams();
|
||||
return (
|
||||
<main>
|
||||
<VendorSummary vendorId={vendorId!} />
|
||||
<ThirdPartySummary thirdPartyId={thirdPartyId!} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function VendorSummary({ vendorId }: { vendorId: string }) {
|
||||
function ThirdPartySummary({ thirdPartyId }: { thirdPartyId: string }) {
|
||||
return <div>{/* … */}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// Good — component that needs the id reads it (or uses a dedicated hook)
|
||||
function VendorLayout() {
|
||||
function ThirdPartyLayout() {
|
||||
return (
|
||||
<main>
|
||||
<VendorSummary />
|
||||
<ThirdPartySummary />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function VendorSummary() {
|
||||
const { vendorId } = useParams();
|
||||
if (vendorId == null) {
|
||||
function ThirdPartySummary() {
|
||||
const { thirdPartyId } = useParams();
|
||||
if (thirdPartyId == null) {
|
||||
return null;
|
||||
}
|
||||
return <div>{/* use vendorId in a hook / query … */}</div>;
|
||||
return <div>{/* use thirdPartyId in a hook / query … */}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -251,13 +251,13 @@ function VendorSummary() {
|
||||
|
||||
```tsx
|
||||
// Bad — parent loaded data and passes fields as props
|
||||
function VendorPage() {
|
||||
const vendor = useLazyLoadQuery(/* … */);
|
||||
function ThirdPartyPage() {
|
||||
const thirdParty = useLazyLoadQuery(/* … */);
|
||||
return (
|
||||
<VendorHeader
|
||||
name={vendor.name}
|
||||
riskScore={vendor.riskScore}
|
||||
updatedAt={vendor.updatedAt}
|
||||
<ThirdPartyHeader
|
||||
name={thirdParty.name}
|
||||
riskScore={thirdParty.riskScore}
|
||||
updatedAt={thirdParty.updatedAt}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -265,24 +265,24 @@ function VendorPage() {
|
||||
|
||||
```tsx
|
||||
// Good — header colocates its fragment and reads via useFragment
|
||||
const vendorHeaderFragment = graphql`
|
||||
fragment VendorHeader_vendor on Vendor {
|
||||
const thirdPartyHeaderFragment = graphql`
|
||||
fragment ThirdPartyHeader_thirdParty on ThirdParty {
|
||||
name
|
||||
riskScore
|
||||
updatedAt
|
||||
}
|
||||
`;
|
||||
|
||||
interface VendorHeaderProps {
|
||||
interface ThirdPartyHeaderProps {
|
||||
className?: string;
|
||||
vendorKey: VendorHeader_vendor$key;
|
||||
thirdPartyKey: ThirdPartyHeader_thirdParty$key;
|
||||
}
|
||||
|
||||
export function VendorHeader({ className, vendorKey }: VendorHeaderProps) {
|
||||
const vendor = useFragment(vendorHeaderFragment, vendorKey);
|
||||
export function ThirdPartyHeader({ className, thirdPartyKey }: ThirdPartyHeaderProps) {
|
||||
const thirdParty = useFragment(thirdPartyHeaderFragment, thirdPartyKey);
|
||||
return (
|
||||
<header className={className}>
|
||||
{/* render from vendor … */}
|
||||
{/* render from thirdParty … */}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -191,7 +191,7 @@ Fragments colocate data requirements with the component that reads them:
|
||||
|
||||
```tsx
|
||||
const contactFragment = graphql`
|
||||
fragment ContactRow_contactFragment on VendorContact {
|
||||
fragment ContactRow_contactFragment on ThirdPartyContact {
|
||||
id
|
||||
fullName
|
||||
email
|
||||
@@ -199,8 +199,8 @@ const contactFragment = graphql`
|
||||
role
|
||||
createdAt
|
||||
updatedAt
|
||||
canUpdate: permission(action: "core:vendor-contact:update")
|
||||
canDelete: permission(action: "core:vendor-contact:delete")
|
||||
canUpdate: permission(action: "core:thirdParty-contact:update")
|
||||
canDelete: permission(action: "core:thirdParty-contact:delete")
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -215,12 +215,12 @@ function ContactRow(props: { contactKey: ContactRow_contactFragment$key }) {
|
||||
For lists that support sorting and pagination, use `@refetchable` with `@argumentDefinitions`:
|
||||
|
||||
```tsx
|
||||
const vendorContactsFragment = graphql`
|
||||
fragment VendorContactsTabFragment on Vendor
|
||||
@refetchable(queryName: "VendorContactsListQuery")
|
||||
const thirdPartyContactsFragment = graphql`
|
||||
fragment ThirdPartyContactsTabFragment on ThirdParty
|
||||
@refetchable(queryName: "ThirdPartyContactsListQuery")
|
||||
@argumentDefinitions(
|
||||
first: { type: "Int", defaultValue: 50 }
|
||||
order: { type: "VendorContactOrder", defaultValue: null }
|
||||
order: { type: "ThirdPartyContactOrder", defaultValue: null }
|
||||
after: { type: "CursorKey", defaultValue: null }
|
||||
before: { type: "CursorKey", defaultValue: null }
|
||||
last: { type: "Int", defaultValue: null }
|
||||
@@ -231,18 +231,18 @@ const vendorContactsFragment = graphql`
|
||||
last: $last
|
||||
before: $before
|
||||
orderBy: $order
|
||||
) @connection(key: "VendorContactsTabFragment_contacts") {
|
||||
) @connection(key: "ThirdPartyContactsTabFragment_contacts") {
|
||||
__id
|
||||
edges {
|
||||
node {
|
||||
...VendorContactsTabFragment_contact
|
||||
...ThirdPartyContactsTabFragment_contact
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const [data, refetch] = useRefetchableFragment(vendorContactsFragment, vendor);
|
||||
const [data, refetch] = useRefetchableFragment(thirdPartyContactsFragment, thirdParty);
|
||||
const connectionId = data.contacts.__id;
|
||||
```
|
||||
|
||||
@@ -251,9 +251,9 @@ const connectionId = data.contacts.__id;
|
||||
Use `usePaginationFragment` for cursor-based Relay pagination:
|
||||
|
||||
```tsx
|
||||
const pagination = usePaginationFragment(paginatedVendorsFragment, data.node);
|
||||
const vendors = pagination.data.vendors?.edges.map(edge => edge.node);
|
||||
const connectionId = pagination.data.vendors.__id;
|
||||
const pagination = usePaginationFragment(paginatedThirdPartiesFragment, data.node);
|
||||
const thirdParties = pagination.data.thirdParties?.edges.map(edge => edge.node);
|
||||
const connectionId = pagination.data.thirdParties.__id;
|
||||
```
|
||||
|
||||
The `@connection(key: "...", filters: [...])` directive on the fragment tells Relay how to manage the paginated list in the store. The `filters` array controls which variables affect the connection identity.
|
||||
@@ -294,7 +294,7 @@ createCookieBanner({ variables: { ... } });
|
||||
#### Examples
|
||||
|
||||
```tsx
|
||||
const [deleteVendor] = useMutation<VendorGraphDeleteMutation>(deleteVendorMutation);
|
||||
const [deleteThirdParty] = useMutation<ThirdPartyGraphDeleteMutation>(deleteThirdPartyMutation);
|
||||
```
|
||||
|
||||
For mutations with user feedback, combine with `useToast` and use `onCompleted`/`onError` callbacks:
|
||||
@@ -415,9 +415,9 @@ This is useful for dialogs, drawers, or other components rendered outside the su
|
||||
```tsx
|
||||
// Add new edge to a connection
|
||||
const createMutation = graphql`
|
||||
mutation CreateVendorMutation($input: CreateVendorInput!, $connections: [ID!]!) {
|
||||
createVendor(input: $input) {
|
||||
vendorEdge @prependEdge(connections: $connections) {
|
||||
mutation CreateThirdPartyMutation($input: CreateThirdPartyInput!, $connections: [ID!]!) {
|
||||
createThirdParty(input: $input) {
|
||||
thirdPartyEdge @prependEdge(connections: $connections) {
|
||||
node {
|
||||
id
|
||||
name
|
||||
@@ -429,19 +429,19 @@ const createMutation = graphql`
|
||||
|
||||
// Remove an edge from a connection
|
||||
const deleteMutation = graphql`
|
||||
mutation DeleteVendorMutation($input: DeleteVendorInput!, $connections: [ID!]!) {
|
||||
deleteVendor(input: $input) {
|
||||
deletedVendorId @deleteEdge(connections: $connections)
|
||||
mutation DeleteThirdPartyMutation($input: DeleteThirdPartyInput!, $connections: [ID!]!) {
|
||||
deleteThirdParty(input: $input) {
|
||||
deletedThirdPartyId @deleteEdge(connections: $connections)
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// Update in-place (Relay matches by id — no directive needed)
|
||||
const updateMutation = graphql`
|
||||
mutation UpdateContactMutation($input: UpdateVendorContactInput!) {
|
||||
updateVendorContact(input: $input) {
|
||||
vendorContact {
|
||||
...VendorContactsTabFragment_contact
|
||||
mutation UpdateContactMutation($input: UpdateThirdPartyContactInput!) {
|
||||
updateThirdPartyContact(input: $input) {
|
||||
thirdPartyContact {
|
||||
...ThirdPartyContactsTabFragment_contact
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -505,15 +505,15 @@ Destructive mutations (delete) are wrapped with a confirmation dialog:
|
||||
|
||||
```tsx
|
||||
const confirm = useConfirm();
|
||||
const [deleteVendor] = useMutation<DeleteVendorMutation>(deleteVendorMutation);
|
||||
const [deleteThirdParty] = useMutation<DeleteThirdPartyMutation>(deleteThirdPartyMutation);
|
||||
|
||||
return () => {
|
||||
confirm(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
deleteVendor({
|
||||
deleteThirdParty({
|
||||
variables: {
|
||||
input: { vendorId: vendor.id! },
|
||||
input: { thirdPartyId: thirdParty.id! },
|
||||
connections: [connectionId],
|
||||
},
|
||||
onCompleted() {
|
||||
@@ -534,14 +534,14 @@ return () => {
|
||||
GraphQL operations are colocated with the components that use them. See [`contrib/claude/app-arborescence.md`](app-arborescence.md) for the full folder layout.
|
||||
|
||||
```
|
||||
pages/organizations/vendors/
|
||||
VendorsPage.tsx # query + pagination fragment
|
||||
pages/organizations/third-parties/
|
||||
ThirdPartiesPage.tsx # query + pagination fragment
|
||||
_components/
|
||||
CreateContactDialog.tsx # create mutation
|
||||
EditContactDialog.tsx # update mutation
|
||||
tabs/
|
||||
VendorContactsTab.tsx # refetchable fragment + item fragment
|
||||
VendorComplianceTab.tsx
|
||||
ThirdPartyContactsTab.tsx # refetchable fragment + item fragment
|
||||
ThirdPartyComplianceTab.tsx
|
||||
```
|
||||
|
||||
Component-specific operations (queries, fragments, mutations) are defined inline in the component file that uses them. Shared sub-components live in `_components/` next to the page (scoped to the nearest common ancestor).
|
||||
@@ -7,7 +7,7 @@ Custom fluent validation API in `pkg/validator/`. Used in every service method t
|
||||
Create a validator, chain `Check()` calls for each field, then call `Error()` to get accumulated errors:
|
||||
|
||||
```go
|
||||
func (req *CreateVendorRequest) Validate() error {
|
||||
func (req *CreateThirdPartyRequest) Validate() error {
|
||||
v := validator.New()
|
||||
|
||||
v.Check(req.OrganizationID, "organization_id",
|
||||
@@ -19,7 +19,7 @@ func (req *CreateVendorRequest) Validate() error {
|
||||
validator.SafeTextNoNewLine(TitleMaxLength),
|
||||
)
|
||||
v.Check(req.Category, "category",
|
||||
validator.OneOfSlice(coredata.VendorCategories()),
|
||||
validator.OneOfSlice(coredata.ThirdPartyCategories()),
|
||||
)
|
||||
|
||||
return v.Error()
|
||||
@@ -81,7 +81,7 @@ v.CheckEach(ids, "ids", func(index int, item any) {
|
||||
gidValue := item.(gid.GID)
|
||||
v.Check(gidValue, fmt.Sprintf("ids[%d]", index),
|
||||
validator.Required(),
|
||||
validator.GID(coredata.VendorEntityType),
|
||||
validator.GID(coredata.ThirdPartyEntityType),
|
||||
)
|
||||
})
|
||||
```
|
||||
@@ -135,7 +135,7 @@ Validation errors flow naturally through Go's error interface:
|
||||
3. GraphQL/HTTP handlers convert `ValidationErrors` to appropriate response format
|
||||
|
||||
```go
|
||||
func (s *Service) CreateVendor(ctx context.Context, req CreateVendorRequest) (*coredata.Vendor, error) {
|
||||
func (s *Service) CreateThirdParty(ctx context.Context, req CreateThirdPartyRequest) (*coredata.ThirdParty, error) {
|
||||
if err := req.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -480,7 +480,7 @@ create_risk \
|
||||
SECURITY MITIGATED 1 5
|
||||
|
||||
create_risk \
|
||||
"Third-party SaaS vendor data breach" \
|
||||
"Third-party SaaS data breach" \
|
||||
OPERATIONAL TRANSFERRED 3 4
|
||||
create_risk \
|
||||
"Cloud region outage causing service disruption" \
|
||||
@@ -514,7 +514,7 @@ create_risk \
|
||||
"Breach notification deadline missed" \
|
||||
COMPLIANCE MITIGATED 1 5
|
||||
create_risk \
|
||||
"Inadequate data processing agreements with vendors" \
|
||||
"Inadequate data processing agreements with third parties" \
|
||||
COMPLIANCE MITIGATED 3 3
|
||||
create_risk \
|
||||
"Employee data retained beyond legal period" \
|
||||
@@ -553,17 +553,17 @@ create_risk \
|
||||
|
||||
echo " 35 risks created"
|
||||
|
||||
echo " Creating vendors..."
|
||||
echo " Creating third parties..."
|
||||
|
||||
create_vendor() {
|
||||
create_third_party() {
|
||||
local name="$1"
|
||||
local description="$2"
|
||||
|
||||
local resp
|
||||
resp=$(prb_api "createVendor: $name" '
|
||||
mutation($input: CreateVendorInput!) {
|
||||
createVendor(input: $input) {
|
||||
vendorEdge {
|
||||
resp=$(prb_api "createThirdParty: $name" '
|
||||
mutation($input: CreateThirdPartyInput!) {
|
||||
createThirdParty(input: $input) {
|
||||
thirdPartyEdge {
|
||||
node { id }
|
||||
}
|
||||
}
|
||||
@@ -574,55 +574,55 @@ create_vendor() {
|
||||
description="$description" \
|
||||
)")
|
||||
local id
|
||||
id=$(echo "$resp" | jq -r '.data.createVendor.vendorEdge.node.id // empty')
|
||||
id=$(echo "$resp" | jq -r '.data.createThirdParty.thirdPartyEdge.node.id // empty')
|
||||
if [ -z "$id" ]; then
|
||||
echo "ERROR (createVendor: $name): no vendor id in response" >&2
|
||||
echo "ERROR (createThirdParty: $name): no third party id in response" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
create_vendor "Amazon Web Services" \
|
||||
create_third_party "Amazon Web Services" \
|
||||
"Cloud infrastructure and compute"
|
||||
create_vendor "Google Cloud Platform" \
|
||||
create_third_party "Google Cloud Platform" \
|
||||
"BigQuery analytics and AI services"
|
||||
create_vendor "Google Workspace" \
|
||||
create_third_party "Google Workspace" \
|
||||
"Email, calendar, and productivity suite"
|
||||
create_vendor "Microsoft 365" \
|
||||
create_third_party "Microsoft 365" \
|
||||
"Office productivity and collaboration"
|
||||
create_vendor "Datadog" \
|
||||
create_third_party "Datadog" \
|
||||
"Application monitoring and observability"
|
||||
create_vendor "PagerDuty" \
|
||||
create_third_party "PagerDuty" \
|
||||
"Incident management and on-call scheduling"
|
||||
create_vendor "Slack" \
|
||||
create_third_party "Slack" \
|
||||
"Team communication and messaging"
|
||||
create_vendor "GitHub" \
|
||||
create_third_party "GitHub" \
|
||||
"Source code management and CI/CD"
|
||||
create_vendor "Stripe" \
|
||||
create_third_party "Stripe" \
|
||||
"Payment processing and billing"
|
||||
create_vendor "Salesforce" \
|
||||
create_third_party "Salesforce" \
|
||||
"Customer relationship management"
|
||||
create_vendor "HubSpot" \
|
||||
create_third_party "HubSpot" \
|
||||
"Marketing automation and CRM"
|
||||
create_vendor "Notion" \
|
||||
create_third_party "Notion" \
|
||||
"Documentation and knowledge management"
|
||||
create_vendor "1Password" \
|
||||
create_third_party "1Password" \
|
||||
"Enterprise password management"
|
||||
create_vendor "Okta" \
|
||||
create_third_party "Okta" \
|
||||
"Identity and access management"
|
||||
create_vendor "CrowdStrike" \
|
||||
create_third_party "CrowdStrike" \
|
||||
"Endpoint protection and threat intelligence"
|
||||
create_vendor "Vanta" \
|
||||
create_third_party "Vanta" \
|
||||
"Compliance automation and monitoring"
|
||||
create_vendor "Jira" \
|
||||
create_third_party "Jira" \
|
||||
"Project management and issue tracking"
|
||||
create_vendor "Cloudflare" \
|
||||
create_third_party "Cloudflare" \
|
||||
"CDN, DNS, and DDoS protection"
|
||||
create_vendor "Twilio SendGrid" \
|
||||
create_third_party "Twilio SendGrid" \
|
||||
"Transactional email delivery"
|
||||
create_vendor "Snowflake" \
|
||||
create_third_party "Snowflake" \
|
||||
"Cloud data warehouse"
|
||||
|
||||
echo " 20 vendors created"
|
||||
echo " 20 third parties created"
|
||||
|
||||
echo " Creating measures..."
|
||||
|
||||
@@ -696,7 +696,7 @@ echo ""
|
||||
echo " Created:"
|
||||
echo " 3 frameworks, 43 controls"
|
||||
echo " 35 risks"
|
||||
echo " 20 vendors"
|
||||
echo " 20 third parties"
|
||||
echo " 15 measures"
|
||||
echo " 8 people"
|
||||
echo ""
|
||||
|
||||
Reference in New Issue
Block a user