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:
Sacha Al Himdani
2026-05-13 16:15:33 +02:00
parent 9eed0d71c8
commit eecbe4c46c
281 changed files with 8491 additions and 8425 deletions

View File

@@ -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
```

View File

@@ -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"
)
```

View File

@@ -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

View File

@@ -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",

View File

@@ -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

View File

@@ -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),
}
}
```

View File

@@ -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,
}
}
```

View File

@@ -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>
);
}

View File

@@ -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).

View File

@@ -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
}

View File

@@ -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 ""