Add vendor publish to document system

Replace the old snapshot-based system for vendors with the publish
document system, mirroring the prior processing activity / DPIA / TIA
migration. Includes the GraphQL mutation, MCP tool, CLI command, n8n
operation, frontend publish dialog, e2e tests, and a prosemirror
register template covering vendor profile fields plus per-vendor
sections for services, contacts, risk assessments, compliance reports,
BAA and DPA agreements.

The vendor register lives as a generated DocumentTypeRegister document
on the organization, reused across publishes (the major version bumps
on every republish). Approvers can be passed in to create a draft
pending approval; otherwise the version is published immediately. The
frontend Vendors page exposes a Publish button and a Document link
button when the document exists, and pre-fills the previous default
approvers.

Remove snapshot mode entirely from vendors and their sub-entities: drop
snapshotId/sourceId from GraphQL Vendor type and VendorFilter; remove
SnapshotsTypeVendors from the snapshot registry and delete
Vendors.Snapshot, VendorSnapshotter interface and all
*.InsertVendorSnapshots methods on contacts, services, risk
assessments, compliance reports, BAA and DPA. Drop the snapshot routes
and banner from the frontend. The snapshot_id columns remain in the
database but are now filtered out with snapshot_id IS NULL.

Add Get/Upsert/Clear GeneratedDocumentID methods on Vendor backed by a
new vendors_document_id column on generated_documents, matching the
ProcessingActivity/Finding/Obligation pattern.

Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
Sacha Al Himdani
2026-04-28 18:54:09 +02:00
parent 5629c8ccc0
commit c026f67bd9
40 changed files with 3144 additions and 792 deletions

View File

@@ -109,12 +109,21 @@ export const useDeleteVendor = (
export const vendorConnectionKey = "VendorsPage_vendors";
export const vendorsQuery = graphql`
query VendorGraphListQuery($organizationId: ID!, $snapshotId: ID) {
query VendorGraphListQuery($organizationId: ID!) {
node(id: $organizationId) {
... on Organization {
id
canCreateVendor: permission(action: "core:vendor:create")
...VendorGraphPaginatedFragment @arguments(snapshotId: $snapshotId)
canPublishVendor: permission(action: "core:vendor:publish")
vendorsDocument {
id
currentPublishedMajor
currentPublishedMinor
defaultApprovers {
id
}
}
...VendorGraphPaginatedFragment
}
}
}
@@ -129,7 +138,6 @@ export const paginatedVendorsFragment = graphql`
after: { type: "CursorKey", defaultValue: null }
before: { type: "CursorKey", defaultValue: null }
last: { type: "Int", defaultValue: null }
snapshotId: { type: "ID", defaultValue: null }
) {
vendors(
first: $first
@@ -137,13 +145,11 @@ export const paginatedVendorsFragment = graphql`
last: $last
before: $before
orderBy: $order
filter: { snapshotId: $snapshotId }
) @connection(key: "VendorsListQuery_vendors", filters: ["filter"]) {
) @connection(key: "VendorsListQuery_vendors") {
__id
edges {
node {
id
snapshotId
name
websiteUrl
updatedAt
@@ -174,7 +180,6 @@ export const vendorNodeQuery = graphql`
node(id: $vendorId) {
id
... on Vendor {
snapshotId
name
websiteUrl
canAssess: permission(action: "core:vendor:assess")

View File

@@ -12,7 +12,7 @@
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { faviconUrl, validateSnapshotConsistency } from "@probo/helpers";
import { faviconUrl } from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import {
ActionDropdown,
@@ -31,11 +31,10 @@ import {
useFragment,
usePreloadedQuery,
} from "react-relay";
import { Outlet, useParams } from "react-router";
import { Outlet } from "react-router";
import type { VendorComplianceTabFragment$key } from "#/__generated__/core/VendorComplianceTabFragment.graphql";
import type { VendorGraphNodeQuery } from "#/__generated__/core/VendorGraphNodeQuery.graphql";
import { SnapshotBanner } from "#/components/SnapshotBanner";
import {
useDeleteVendor,
vendorConnectionKey,
@@ -54,10 +53,7 @@ export default function VendorDetailPage(props: Props) {
const { node: vendor } = usePreloadedQuery(vendorNodeQuery, props.queryRef);
const { __ } = useTranslate();
const organizationId = useOrganizationId();
const { snapshotId } = useParams<{ snapshotId?: string }>();
const isSnapshotMode = Boolean(snapshotId);
validateSnapshotConsistency(vendor, snapshotId);
const deleteVendor = useDeleteVendor(
vendor,
ConnectionHandler.getConnectionID(organizationId, vendorConnectionKey),
@@ -68,19 +64,13 @@ export default function VendorDetailPage(props: Props) {
vendor as VendorComplianceTabFragment$key,
).complianceReports.edges.length;
const vendorsUrl
= isSnapshotMode && snapshotId
? `/organizations/${organizationId}/snapshots/${snapshotId}/vendors`
: `/organizations/${organizationId}/vendors`;
const vendorsUrl = `/organizations/${organizationId}/vendors`;
const baseVendorUrl
= isSnapshotMode && snapshotId
? `/organizations/${organizationId}/snapshots/${snapshotId}/vendors/${vendor.id}`
: `/organizations/${organizationId}/vendors/${vendor.id}`;
= `/organizations/${organizationId}/vendors/${vendor.id}`;
return (
<div className="space-y-6">
{snapshotId && <SnapshotBanner snapshotId={snapshotId} />}
<Breadcrumb
items={[
{
@@ -103,28 +93,26 @@ export default function VendorDetailPage(props: Props) {
)}
<div className="text-2xl">{vendor.name}</div>
</div>
{!isSnapshotMode && (
<div className="flex gap-2 items-center">
{vendor.canAssess && (
<ImportAssessmentDialog vendorId={vendor.id}>
<Button icon={IconPageTextLine} variant="secondary">
{__("Assessment From Website")}
</Button>
</ImportAssessmentDialog>
)}
{vendor.canDelete && (
<ActionDropdown variant="secondary">
<DropdownItem
variant="danger"
icon={IconTrashCan}
onClick={deleteVendor}
>
{__("Delete")}
</DropdownItem>
</ActionDropdown>
)}
</div>
)}
<div className="flex gap-2 items-center">
{vendor.canAssess && (
<ImportAssessmentDialog vendorId={vendor.id}>
<Button icon={IconPageTextLine} variant="secondary">
{__("Assessment From Website")}
</Button>
</ImportAssessmentDialog>
)}
{vendor.canDelete && (
<ActionDropdown variant="secondary">
<DropdownItem
variant="danger"
icon={IconTrashCan}
onClick={deleteVendor}
>
{__("Delete")}
</DropdownItem>
</ActionDropdown>
)}
</div>
</div>
<Tabs>

View File

@@ -20,8 +20,10 @@ import {
Avatar,
Button,
DropdownItem,
IconPageTextLine,
IconPlusLarge,
IconTrashCan,
IconUpload,
PageHeader,
RiskBadge,
Tbody,
@@ -35,14 +37,13 @@ import {
usePaginationFragment,
usePreloadedQuery,
} from "react-relay";
import { useParams } from "react-router";
import { useNavigate } from "react-router";
import type { VendorGraphListQuery } from "#/__generated__/core/VendorGraphListQuery.graphql";
import type {
VendorGraphPaginatedFragment$data,
VendorGraphPaginatedFragment$key,
} from "#/__generated__/core/VendorGraphPaginatedFragment.graphql";
import { SnapshotBanner } from "#/components/SnapshotBanner";
import { SortableTable, SortableTh } from "#/components/SortableTable";
import {
paginatedVendorsFragment,
@@ -53,6 +54,7 @@ import { useOrganizationId } from "#/hooks/useOrganizationId";
import type { NodeOf } from "#/types";
import { CreateVendorDialog } from "./dialogs/CreateVendorDialog";
import { PublishVendorListDialog } from "./dialogs/PublishVendorListDialog";
type Vendor = NodeOf<VendorGraphPaginatedFragment$data["vendors"]>;
@@ -63,8 +65,7 @@ type Props = {
export default function VendorsPage(props: Props) {
const { __ } = useTranslate();
const organizationId = useOrganizationId();
const { snapshotId } = useParams<{ snapshotId?: string }>();
const isSnapshotMode = Boolean(snapshotId);
const navigate = useNavigate();
const data = usePreloadedQuery(vendorsQuery, props.queryRef);
// eslint-disable-next-line relay/generated-typescript-types
@@ -79,26 +80,54 @@ export default function VendorsPage(props: Props) {
usePageTitle(__("Vendors"));
const hasAnyAction
= !isSnapshotMode
&& vendors.some(({ canUpdate, canDelete }) => canUpdate || canDelete);
= vendors.some(({ canUpdate, canDelete }) => canUpdate || canDelete);
const vendorsDocument = data.node?.vendorsDocument;
const defaultApproverIds
= vendorsDocument?.defaultApprovers?.map(a => a.id) ?? [];
return (
<div className="space-y-6">
{snapshotId && <SnapshotBanner snapshotId={snapshotId} />}
<PageHeader
title={__("Vendors")}
description={__(
"Vendors are third-party services that your company uses. Add them to keep track of their risk and compliance status.",
)}
>
{!isSnapshotMode && data.node.canCreateVendor && (
<CreateVendorDialog
connection={connectionId}
organizationId={organizationId}
>
<Button icon={IconPlusLarge}>{__("Add vendor")}</Button>
</CreateVendorDialog>
)}
<div className="flex gap-2">
{vendorsDocument && (
<Button
variant="secondary"
icon={IconPageTextLine}
onClick={() => void navigate(
`/organizations/${organizationId}/documents/${vendorsDocument.id}`,
)}
>
{__("Document")}
</Button>
)}
{data.node.canPublishVendor && (
<PublishVendorListDialog
organizationId={organizationId}
defaultApproverIds={defaultApproverIds}
onPublished={documentId => void navigate(
`/organizations/${organizationId}/documents/${documentId}`,
)}
>
<Button variant="secondary" icon={IconUpload}>
{__("Publish")}
</Button>
</PublishVendorListDialog>
)}
{data.node.canCreateVendor && (
<CreateVendorDialog
connection={connectionId}
organizationId={organizationId}
>
<Button icon={IconPlusLarge}>{__("Add vendor")}</Button>
</CreateVendorDialog>
)}
</div>
</PageHeader>
<SortableTable {...pagination}>
<Thead>
@@ -137,16 +166,11 @@ function VendorRow({
connectionId: string;
hasAnyAction: boolean;
}) {
const { snapshotId } = useParams<{ snapshotId?: string }>();
const isSnapshotMode = Boolean(snapshotId);
const { __ } = useTranslate();
const latestAssessment = vendor.riskAssessments?.edges[0]?.node;
const deleteVendor = useDeleteVendor(vendor, connectionId);
const vendorUrl
= isSnapshotMode && snapshotId
? `/organizations/${organizationId}/snapshots/${snapshotId}/vendors/${vendor.id}/overview`
: `/organizations/${organizationId}/vendors/${vendor.id}/overview`;
const vendorUrl = `/organizations/${organizationId}/vendors/${vendor.id}/overview`;
return (
<>

View File

@@ -0,0 +1,160 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { formatError, type GraphQLError } from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import {
Button,
Dialog,
DialogContent,
DialogFooter,
IconSend,
IconUpload,
useDialogRef,
useToast,
} from "@probo/ui";
import type { ReactNode } from "react";
import { useMemo } from "react";
import { useMutation } from "react-relay";
import { graphql } from "relay-runtime";
import { z } from "zod";
import type { PublishVendorListDialogMutation } from "#/__generated__/core/PublishVendorListDialogMutation.graphql";
import { PeopleMultiSelectField } from "#/components/form/PeopleMultiSelectField";
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
const publishMutation = graphql`
mutation PublishVendorListDialogMutation(
$input: PublishVendorListInput!
) {
publishVendorList(input: $input) {
documentEdge {
node {
id
}
}
}
}
`;
type Props = {
children: ReactNode;
organizationId: string;
defaultApproverIds?: string[];
onPublished?: (documentId: string) => void;
};
export function PublishVendorListDialog({
children,
organizationId,
defaultApproverIds,
onPublished,
}: Props) {
const { __ } = useTranslate();
const { toast } = useToast();
const dialogRef = useDialogRef();
const schema = useMemo(() =>
z.object({
approverIds: z.array(z.string()),
}), []);
const {
control,
handleSubmit,
reset,
watch,
} = useFormWithSchema(schema, {
defaultValues: {
approverIds: defaultApproverIds ?? [],
},
});
const [publish, isPublishing]
= useMutation<PublishVendorListDialogMutation>(publishMutation);
const approverIds = watch("approverIds");
const hasApprovers = approverIds.length > 0;
const onSubmit = (data: z.infer<typeof schema>) => {
publish({
variables: {
input: {
organizationId,
approverIds: data.approverIds.length > 0 ? data.approverIds : undefined,
},
},
onCompleted(response) {
const documentId = response.publishVendorList?.documentEdge?.node?.id;
if (documentId) {
toast({
title: __("Success"),
description: hasApprovers
? __("Approval requested successfully.")
: __("Vendors published successfully."),
variant: "success",
});
dialogRef.current?.close();
reset();
onPublished?.(documentId);
}
},
onError(error) {
toast({
title: __("Error"),
description: formatError(
__("Failed to publish vendors"),
error as GraphQLError,
),
variant: "error",
});
},
});
};
return (
<Dialog
className="max-w-xl"
ref={dialogRef}
trigger={children}
title={__("Publish Vendors")}
>
<form onSubmit={e => void handleSubmit(onSubmit)(e)}>
<DialogContent padded>
<div className="space-y-4">
<p className="text-sm text-txt-secondary">
{__("Select approvers to request approval before publishing, or publish directly without approvers.")}
</p>
<PeopleMultiSelectField
name="approverIds"
label={__("Approvers")}
control={control}
organizationId={organizationId}
placeholder={__("Add approvers...")}
/>
</div>
</DialogContent>
<DialogFooter>
<Button
type="submit"
icon={hasApprovers ? IconSend : IconUpload}
disabled={isPublishing}
>
{hasApprovers ? __("Request approval") : __("Publish")}
</Button>
</DialogFooter>
</form>
</Dialog>
);
}

View File

@@ -34,20 +34,6 @@ export const vendorRoutes = [
loader: loaderFromQueryLoader(({ organizationId }) =>
loadQuery<VendorGraphListQuery>(coreEnvironment, vendorsQuery, {
organizationId: organizationId,
snapshotId: null,
}),
),
Component: withQueryRef(
lazy(() => import("#/pages/organizations/vendors/VendorsPage")),
),
},
{
path: "snapshots/:snapshotId/vendors",
Fallback: PageSkeleton,
loader: loaderFromQueryLoader(({ organizationId, snapshotId }) =>
loadQuery<VendorGraphListQuery>(coreEnvironment, vendorsQuery, {
organizationId: organizationId,
snapshotId,
}),
),
Component: withQueryRef(
@@ -113,63 +99,4 @@ export const vendorRoutes = [
},
],
},
{
path: "snapshots/:snapshotId/vendors/:vendorId",
Fallback: PageSkeleton,
loader: loaderFromQueryLoader(({ vendorId }) =>
loadQuery<VendorGraphNodeQuery>(coreEnvironment, vendorNodeQuery, {
vendorId: vendorId,
}),
),
Component: withQueryRef(
lazy(() => import("../pages/organizations/vendors/VendorDetailPage")),
),
children: [
{
path: "overview",
Fallback: LinkCardSkeleton,
Component: lazy(
() => import("../pages/organizations/vendors/tabs/VendorOverviewTab"),
),
},
{
path: "certifications",
Fallback: LinkCardSkeleton,
Component: lazy(
() =>
import("../pages/organizations/vendors/tabs/VendorCertificationsTab"),
),
},
{
path: "compliance",
Fallback: LinkCardSkeleton,
Component: lazy(
() =>
import("../pages/organizations/vendors/tabs/VendorComplianceTab"),
),
},
{
path: "risks",
Fallback: LinkCardSkeleton,
Component: lazy(
() =>
import("../pages/organizations/vendors/tabs/VendorRiskAssessmentTab"),
),
},
{
path: "contacts",
Fallback: LinkCardSkeleton,
Component: lazy(
() => import("../pages/organizations/vendors/tabs/VendorContactsTab"),
),
},
{
path: "services",
Fallback: LinkCardSkeleton,
Component: lazy(
() => import("../pages/organizations/vendors/tabs/VendorServicesTab"),
),
},
],
},
] satisfies AppRoute[];

View File

@@ -0,0 +1,749 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
// Command migrate-vendor-snapshots-to-documents creates documents and document
// versions from existing vendor snapshots. For each organization that has vendor
// snapshots, it generates a vendor list document using the same ProseMirror
// builder as the publish flow.
package main
import (
"context"
"flag"
"fmt"
"net/url"
"os"
"strings"
"time"
"github.com/jackc/pgx/v5"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/docgen"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/probo"
)
func main() {
if err := run(); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
}
func run() error {
var (
pgDSN string
dryRun bool
)
flag.StringVar(
&pgDSN,
"pg-dsn",
os.Getenv("DATABASE_URL"),
"PostgreSQL connection URL (default: DATABASE_URL env)",
)
flag.BoolVar(&dryRun, "dry-run", false, "show what would be done without writing")
flag.Parse()
if pgDSN == "" {
return fmt.Errorf("set -pg-dsn or DATABASE_URL")
}
ctx := context.Background()
pgClient, err := newPgClientFromDSN(pgDSN)
if err != nil {
return fmt.Errorf("cannot create pg client: %w", err)
}
return migrate(ctx, pgClient, dryRun)
}
type orgWithVendorSnapshots struct {
organizationID gid.GID
tenantID gid.TenantID
organizationName string
}
type vendorSnapshot struct {
snapshotID string
publishedAt time.Time
}
func migrate(ctx context.Context, pgClient *pg.Client, dryRun bool) error {
var orgs []orgWithVendorSnapshots
err := pgClient.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error {
var err error
orgs, err = loadOrgsWithVendorSnapshots(ctx, conn)
return err
})
if err != nil {
return err
}
if len(orgs) == 0 {
fmt.Println("no organizations with vendor snapshots to migrate")
return nil
}
var stats struct {
documents, versions, failed int
}
for _, org := range orgs {
if dryRun {
var count int
err := pgClient.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error {
snapshots, err := loadVendorSnapshots(ctx, conn, org.organizationID)
count = len(snapshots)
return err
})
if err != nil {
return err
}
fmt.Printf("would migrate org %s (%s) — %d vendor snapshot(s)\n",
org.organizationID, org.organizationName, count)
continue
}
err := pgClient.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error {
return migrateOrg(ctx, tx, org)
})
if err != nil {
fmt.Fprintf(os.Stderr, "FAIL org %s (%s): %v\n",
org.organizationID, org.organizationName, err)
stats.failed++
continue
}
stats.documents++
}
if dryRun {
fmt.Printf("\n%d organization(s) would be migrated\n", len(orgs))
return nil
}
fmt.Printf("\nmigrated %d organization(s), %d failed\n",
stats.documents, stats.failed)
return nil
}
func migrateOrg(ctx context.Context, tx pg.Tx, org orgWithVendorSnapshots) error {
snapshots, err := loadVendorSnapshots(ctx, tx, org.organizationID)
if err != nil {
return err
}
if len(snapshots) == 0 {
return nil
}
documentID := gid.New(org.tenantID, coredata.DocumentEntityType)
now := time.Now()
_, err = tx.Exec(
ctx,
`
INSERT INTO documents (
id, tenant_id, organization_id, write_mode,
current_published_major, current_published_minor,
trust_center_visibility, status, created_at, updated_at
) VALUES (
@id, @tenant_id, @organization_id,
'GENERATED'::document_write_mode,
@current_published_major, 0,
'NONE'::trust_center_visibility,
'ACTIVE'::document_status,
@created_at, @updated_at
)`,
pgx.NamedArgs{
"id": documentID,
"tenant_id": org.tenantID,
"organization_id": org.organizationID,
"current_published_major": len(snapshots),
"created_at": now,
"updated_at": now,
},
)
if err != nil {
return fmt.Errorf("cannot insert document: %w", err)
}
_, err = tx.Exec(
ctx,
`INSERT INTO generated_documents (organization_id, tenant_id, vendors_document_id, created_at, updated_at)
VALUES (@organization_id, @tenant_id, @vendors_document_id, @created_at, @updated_at)
ON CONFLICT (organization_id) DO UPDATE SET vendors_document_id = @vendors_document_id, updated_at = @updated_at`,
pgx.NamedArgs{
"organization_id": org.organizationID,
"tenant_id": org.tenantID,
"vendors_document_id": documentID,
"created_at": now,
"updated_at": now,
},
)
if err != nil {
return fmt.Errorf("cannot link document: %w", err)
}
for major, snap := range snapshots {
content, err := buildSnapshotContent(ctx, tx, snap.snapshotID, org.organizationName, snap.publishedAt)
if err != nil {
return fmt.Errorf("cannot build content for snapshot %s: %w", snap.snapshotID, err)
}
versionID := gid.New(org.tenantID, coredata.DocumentVersionEntityType)
_, err = tx.Exec(
ctx,
`
INSERT INTO document_versions (
id, tenant_id, organization_id, document_id,
title, major, minor, classification, document_type,
content, changelog, status, orientation,
pdf_attempt_count,
published_at, created_at, updated_at
) VALUES (
@id, @tenant_id, @organization_id, @document_id,
@title, @major, 0,
'CONFIDENTIAL'::document_classification,
'REGISTER'::document_type,
@content, '',
'PUBLISHED'::document_version_status,
'PORTRAIT'::document_version_orientation,
0,
@published_at, @published_at, @published_at
)`,
pgx.NamedArgs{
"id": versionID,
"tenant_id": org.tenantID,
"organization_id": org.organizationID,
"document_id": documentID,
"title": "Vendors",
"major": major + 1,
"content": content,
"published_at": snap.publishedAt,
},
)
if err != nil {
return fmt.Errorf("cannot insert version for snapshot %s: %w", snap.snapshotID, err)
}
}
fmt.Printf("OK org %s (%s) — %d version(s)\n",
org.organizationID, org.organizationName, len(snapshots))
return nil
}
func loadOrgsWithVendorSnapshots(ctx context.Context, conn pg.Querier) ([]orgWithVendorSnapshots, error) {
rows, err := conn.Query(
ctx,
`
SELECT DISTINCT
o.id,
o.tenant_id,
o.name,
o.created_at
FROM organizations o
WHERE NOT EXISTS (
SELECT 1 FROM generated_documents gd
WHERE gd.organization_id = o.id AND gd.vendors_document_id IS NOT NULL
)
AND EXISTS (
SELECT 1 FROM snapshots s
WHERE s.organization_id = o.id AND s.type = 'VENDORS'
)
ORDER BY o.created_at;
`,
)
if err != nil {
return nil, fmt.Errorf("cannot query organizations with vendor snapshots: %w", err)
}
defer rows.Close()
var result []orgWithVendorSnapshots
for rows.Next() {
var o orgWithVendorSnapshots
var createdAt time.Time
if err := rows.Scan(&o.organizationID, &o.tenantID, &o.organizationName, &createdAt); err != nil {
return nil, fmt.Errorf("cannot scan organization: %w", err)
}
result = append(result, o)
}
return result, rows.Err()
}
func loadVendorSnapshots(ctx context.Context, conn pg.Querier, organizationID gid.GID) ([]vendorSnapshot, error) {
rows, err := conn.Query(
ctx,
`
SELECT DISTINCT
s.id,
s.created_at
FROM snapshots s
WHERE s.organization_id = @organization_id
AND s.type = 'VENDORS'
ORDER BY s.created_at ASC;
`,
pgx.NamedArgs{"organization_id": organizationID},
)
if err != nil {
return nil, fmt.Errorf("cannot query vendor snapshots for org %s: %w", organizationID, err)
}
defer rows.Close()
var result []vendorSnapshot
for rows.Next() {
var s vendorSnapshot
if err := rows.Scan(&s.snapshotID, &s.publishedAt); err != nil {
return nil, fmt.Errorf("cannot scan snapshot: %w", err)
}
result = append(result, s)
}
return result, rows.Err()
}
type vendorInfo struct {
id string
name string
category string
legalName *string
description *string
headquarterAddress *string
websiteURL *string
privacyPolicyURL *string
serviceLevelAgreementURL *string
dataProcessingAgreementURL *string
businessAssociateAgreementURL *string
subprocessorsListURL *string
statusPageURL *string
termsOfServiceURL *string
securityPageURL *string
trustPageURL *string
certifications []string
countries []string
businessOwnerName string
securityOwnerName string
}
func buildSnapshotContent(
ctx context.Context,
tx pg.Tx,
snapshotID string,
orgName string,
publishedAt time.Time,
) (string, error) {
vendorRows, err := tx.Query(
ctx,
`
SELECT
v.id,
v.name,
v.category,
v.legal_name,
v.description,
v.headquarter_address,
v.website_url,
v.privacy_policy_url,
v.service_level_agreement_url,
v.data_processing_agreement_url,
v.business_associate_agreement_url,
v.subprocessors_list_url,
v.status_page_url,
v.terms_of_service_url,
v.security_page_url,
v.trust_page_url,
v.certifications,
v.countries,
COALESCE(bo.full_name, 'Not assigned'),
COALESCE(so.full_name, 'Not assigned')
FROM vendors v
LEFT JOIN iam_membership_profiles bo ON bo.id = v.business_owner_profile_id
LEFT JOIN iam_membership_profiles so ON so.id = v.security_owner_profile_id
WHERE v.snapshot_id = @snapshot_id
ORDER BY v.name ASC;
`,
pgx.NamedArgs{"snapshot_id": snapshotID},
)
if err != nil {
return "", fmt.Errorf("cannot load snapshot vendors: %w", err)
}
defer vendorRows.Close()
var vendors []vendorInfo
for vendorRows.Next() {
var v vendorInfo
if err := vendorRows.Scan(
&v.id, &v.name, &v.category,
&v.legalName, &v.description, &v.headquarterAddress,
&v.websiteURL, &v.privacyPolicyURL, &v.serviceLevelAgreementURL,
&v.dataProcessingAgreementURL, &v.businessAssociateAgreementURL,
&v.subprocessorsListURL, &v.statusPageURL, &v.termsOfServiceURL,
&v.securityPageURL, &v.trustPageURL,
&v.certifications, &v.countries,
&v.businessOwnerName, &v.securityOwnerName,
); err != nil {
return "", fmt.Errorf("cannot scan vendor: %w", err)
}
vendors = append(vendors, v)
}
if err := vendorRows.Err(); err != nil {
return "", err
}
vendorIDs := make([]string, len(vendors))
for i, v := range vendors {
vendorIDs[i] = v.id
}
servicesByVendor, err := loadSnapshotServices(ctx, tx, snapshotID, vendorIDs)
if err != nil {
return "", err
}
contactsByVendor, err := loadSnapshotContacts(ctx, tx, snapshotID, vendorIDs)
if err != nil {
return "", err
}
assessmentsByVendor, err := loadSnapshotRiskAssessments(ctx, tx, snapshotID, vendorIDs)
if err != nil {
return "", err
}
reportsByVendor, err := loadSnapshotComplianceReports(ctx, tx, snapshotID, vendorIDs)
if err != nil {
return "", err
}
baaByVendor, err := loadSnapshotBAAs(ctx, tx, snapshotID, vendorIDs)
if err != nil {
return "", err
}
dpaByVendor, err := loadSnapshotDPAs(ctx, tx, snapshotID, vendorIDs)
if err != nil {
return "", err
}
rows := make([]docgen.VendorListRow, 0, len(vendors))
for _, v := range vendors {
row := docgen.VendorListRow{
Name: v.name,
LegalName: deref(v.legalName),
Description: deref(v.description),
Category: formatCategory(v.category),
HeadquarterAddress: deref(v.headquarterAddress),
WebsiteURL: deref(v.websiteURL),
PrivacyPolicyURL: deref(v.privacyPolicyURL),
ServiceLevelAgreementURL: deref(v.serviceLevelAgreementURL),
DataProcessingAgreementURL: deref(v.dataProcessingAgreementURL),
BusinessAssociateAgreementURL: deref(v.businessAssociateAgreementURL),
SubprocessorsListURL: deref(v.subprocessorsListURL),
StatusPageURL: deref(v.statusPageURL),
TermsOfServiceURL: deref(v.termsOfServiceURL),
SecurityPageURL: deref(v.securityPageURL),
TrustPageURL: deref(v.trustPageURL),
Certifications: joinOrDefault(v.certifications),
Countries: joinOrDefault(v.countries),
BusinessOwner: v.businessOwnerName,
SecurityOwner: v.securityOwnerName,
Services: servicesByVendor[v.id],
Contacts: contactsByVendor[v.id],
RiskAssessments: assessmentsByVendor[v.id],
ComplianceReports: reportsByVendor[v.id],
BusinessAssociateAgreement: baaByVendor[v.id],
DataPrivacyAgreement: dpaByVendor[v.id],
}
rows = append(rows, row)
}
docData := docgen.VendorListData{
Title: "Vendors",
OrganizationName: orgName,
CreatedAt: publishedAt,
TotalVendors: len(rows),
Rows: rows,
}
return probo.BuildVendorListDocument(docData)
}
func loadSnapshotServices(ctx context.Context, tx pg.Tx, snapshotID string, vendorIDs []string) (map[string][]docgen.VendorListService, error) {
rows, err := tx.Query(ctx,
`SELECT vs.vendor_id, vs.name, COALESCE(vs.description, 'Not specified')
FROM vendor_services vs
WHERE vs.snapshot_id = @snapshot_id AND vs.vendor_id = ANY(@vendor_ids)
ORDER BY vs.vendor_id, vs.name ASC`,
pgx.NamedArgs{"snapshot_id": snapshotID, "vendor_ids": vendorIDs})
if err != nil {
return nil, fmt.Errorf("cannot load snapshot services: %w", err)
}
defer rows.Close()
result := make(map[string][]docgen.VendorListService)
for rows.Next() {
var vendorID, name, desc string
if err := rows.Scan(&vendorID, &name, &desc); err != nil {
return nil, fmt.Errorf("cannot scan service: %w", err)
}
result[vendorID] = append(result[vendorID], docgen.VendorListService{Name: name, Description: desc})
}
return result, rows.Err()
}
func loadSnapshotContacts(ctx context.Context, tx pg.Tx, snapshotID string, vendorIDs []string) (map[string][]docgen.VendorListContact, error) {
rows, err := tx.Query(ctx,
`SELECT vc.vendor_id,
COALESCE(vc.full_name, 'Not specified'),
COALESCE(vc.email, 'Not specified'),
COALESCE(vc.phone, 'Not specified'),
COALESCE(vc.role, 'Not specified')
FROM vendor_contacts vc
WHERE vc.snapshot_id = @snapshot_id AND vc.vendor_id = ANY(@vendor_ids)
ORDER BY vc.vendor_id, vc.full_name ASC`,
pgx.NamedArgs{"snapshot_id": snapshotID, "vendor_ids": vendorIDs})
if err != nil {
return nil, fmt.Errorf("cannot load snapshot contacts: %w", err)
}
defer rows.Close()
result := make(map[string][]docgen.VendorListContact)
for rows.Next() {
var vendorID, name, email, phone, role string
if err := rows.Scan(&vendorID, &name, &email, &phone, &role); err != nil {
return nil, fmt.Errorf("cannot scan contact: %w", err)
}
result[vendorID] = append(result[vendorID], docgen.VendorListContact{
FullName: name, Email: email, Phone: phone, Role: role,
})
}
return result, rows.Err()
}
func loadSnapshotRiskAssessments(ctx context.Context, tx pg.Tx, snapshotID string, vendorIDs []string) (map[string][]docgen.VendorListRiskAssessment, error) {
rows, err := tx.Query(ctx,
`SELECT vra.vendor_id, vra.created_at, vra.expires_at, vra.data_sensitivity, vra.business_impact, COALESCE(vra.notes, 'Not specified')
FROM vendor_risk_assessments vra
WHERE vra.snapshot_id = @snapshot_id AND vra.vendor_id = ANY(@vendor_ids)
ORDER BY vra.vendor_id, vra.created_at DESC`,
pgx.NamedArgs{"snapshot_id": snapshotID, "vendor_ids": vendorIDs})
if err != nil {
return nil, fmt.Errorf("cannot load snapshot risk assessments: %w", err)
}
defer rows.Close()
result := make(map[string][]docgen.VendorListRiskAssessment)
for rows.Next() {
var vendorID, sensitivity, impact, notes string
var assessedAt, expiresAt time.Time
if err := rows.Scan(&vendorID, &assessedAt, &expiresAt, &sensitivity, &impact, &notes); err != nil {
return nil, fmt.Errorf("cannot scan risk assessment: %w", err)
}
result[vendorID] = append(result[vendorID], docgen.VendorListRiskAssessment{
AssessedAt: assessedAt.Format("2006-01-02"),
ExpiresAt: expiresAt.Format("2006-01-02"),
DataSensitivity: sensitivity,
BusinessImpact: impact,
Notes: notes,
})
}
return result, rows.Err()
}
func loadSnapshotComplianceReports(ctx context.Context, tx pg.Tx, snapshotID string, vendorIDs []string) (map[string][]docgen.VendorListComplianceReport, error) {
rows, err := tx.Query(ctx,
`SELECT vcr.vendor_id, vcr.report_name, vcr.report_date, vcr.valid_until
FROM vendor_compliance_reports vcr
WHERE vcr.snapshot_id = @snapshot_id AND vcr.vendor_id = ANY(@vendor_ids)
ORDER BY vcr.vendor_id, vcr.report_date DESC`,
pgx.NamedArgs{"snapshot_id": snapshotID, "vendor_ids": vendorIDs})
if err != nil {
return nil, fmt.Errorf("cannot load snapshot compliance reports: %w", err)
}
defer rows.Close()
result := make(map[string][]docgen.VendorListComplianceReport)
for rows.Next() {
var vendorID, name string
var reportDate time.Time
var validUntil *time.Time
if err := rows.Scan(&vendorID, &name, &reportDate, &validUntil); err != nil {
return nil, fmt.Errorf("cannot scan compliance report: %w", err)
}
vu := "Not specified"
if validUntil != nil {
vu = validUntil.Format("2006-01-02")
}
result[vendorID] = append(result[vendorID], docgen.VendorListComplianceReport{
ReportName: name, ReportDate: reportDate.Format("2006-01-02"), ValidUntil: vu,
})
}
return result, rows.Err()
}
func loadSnapshotBAAs(ctx context.Context, tx pg.Tx, snapshotID string, vendorIDs []string) (map[string]*docgen.VendorListAgreement, error) {
rows, err := tx.Query(ctx,
`SELECT vbaa.vendor_id, vbaa.valid_from, vbaa.valid_until
FROM vendor_business_associate_agreements vbaa
WHERE vbaa.snapshot_id = @snapshot_id AND vbaa.vendor_id = ANY(@vendor_ids)`,
pgx.NamedArgs{"snapshot_id": snapshotID, "vendor_ids": vendorIDs})
if err != nil {
return nil, fmt.Errorf("cannot load snapshot BAAs: %w", err)
}
defer rows.Close()
result := make(map[string]*docgen.VendorListAgreement)
for rows.Next() {
var vendorID string
var validFrom, validUntil *time.Time
if err := rows.Scan(&vendorID, &validFrom, &validUntil); err != nil {
return nil, fmt.Errorf("cannot scan BAA: %w", err)
}
result[vendorID] = &docgen.VendorListAgreement{
ValidFrom: fmtTime(validFrom), ValidUntil: fmtTime(validUntil),
}
}
return result, rows.Err()
}
func loadSnapshotDPAs(ctx context.Context, tx pg.Tx, snapshotID string, vendorIDs []string) (map[string]*docgen.VendorListAgreement, error) {
rows, err := tx.Query(ctx,
`SELECT vdpa.vendor_id, vdpa.valid_from, vdpa.valid_until
FROM vendor_data_privacy_agreements vdpa
WHERE vdpa.snapshot_id = @snapshot_id AND vdpa.vendor_id = ANY(@vendor_ids)`,
pgx.NamedArgs{"snapshot_id": snapshotID, "vendor_ids": vendorIDs})
if err != nil {
return nil, fmt.Errorf("cannot load snapshot DPAs: %w", err)
}
defer rows.Close()
result := make(map[string]*docgen.VendorListAgreement)
for rows.Next() {
var vendorID string
var validFrom, validUntil *time.Time
if err := rows.Scan(&vendorID, &validFrom, &validUntil); err != nil {
return nil, fmt.Errorf("cannot scan DPA: %w", err)
}
result[vendorID] = &docgen.VendorListAgreement{
ValidFrom: fmtTime(validFrom), ValidUntil: fmtTime(validUntil),
}
}
return result, rows.Err()
}
func deref(s *string) string {
if s == nil || *s == "" {
return "Not specified"
}
return *s
}
func joinOrDefault(items []string) string {
if len(items) == 0 {
return "Not specified"
}
return strings.Join(items, ", ")
}
func fmtTime(t *time.Time) string {
if t == nil {
return "Not specified"
}
return t.Format("2006-01-02")
}
func formatCategory(c string) string {
switch c {
case "ANALYTICS":
return "Analytics"
case "CLOUD_MONITORING":
return "Cloud Monitoring"
case "CLOUD_PROVIDER":
return "Cloud Provider"
case "COLLABORATION":
return "Collaboration"
case "CUSTOMER_SUPPORT":
return "Customer Support"
case "DATA_STORAGE_AND_PROCESSING":
return "Data Storage and Processing"
case "DOCUMENT_MANAGEMENT":
return "Document Management"
case "EMPLOYEE_MANAGEMENT":
return "Employee Management"
case "ENGINEERING":
return "Engineering"
case "FINANCE":
return "Finance"
case "IDENTITY_PROVIDER":
return "Identity Provider"
case "IT":
return "IT"
case "MARKETING":
return "Marketing"
case "OFFICE_OPERATIONS":
return "Office Operations"
case "OTHER":
return "Other"
case "PASSWORD_MANAGEMENT":
return "Password Management"
case "PRODUCT_AND_DESIGN":
return "Product and Design"
case "PROFESSIONAL_SERVICES":
return "Professional Services"
case "RECRUITING":
return "Recruiting"
case "SALES":
return "Sales"
case "SECURITY":
return "Security"
case "VERSION_CONTROL":
return "Version Control"
default:
return c
}
}
func newPgClientFromDSN(dsn string) (*pg.Client, error) {
u, err := url.Parse(dsn)
if err != nil {
return nil, fmt.Errorf("cannot parse DSN")
}
var opts []pg.Option
if u.Host != "" {
opts = append(opts, pg.WithAddr(u.Host))
}
if u.User != nil {
opts = append(opts, pg.WithUser(u.User.Username()))
if password, ok := u.User.Password(); ok {
opts = append(opts, pg.WithPassword(password))
}
}
if len(u.Path) > 1 {
opts = append(opts, pg.WithDatabase(u.Path[1:]))
}
return pg.NewClient(opts...)
}

View File

@@ -104,7 +104,7 @@ func TestSnapshot_Delete(t *testing.T) {
"input": map[string]any{
"organizationId": owner.GetOrganizationID().String(),
"name": fmt.Sprintf("Snapshot to Delete %d", time.Now().UnixNano()),
"type": "VENDORS",
"type": "RISKS",
},
}, &createResult)
require.NoError(t, err)
@@ -140,7 +140,7 @@ func TestSnapshot_List(t *testing.T) {
owner := testutil.NewClient(t, testutil.RoleOwner)
// Create multiple snapshots
snapshotTypes := []string{"RISKS", "VENDORS"}
snapshotTypes := []string{"RISKS"}
for i, snapshotType := range snapshotTypes {
query := `
mutation CreateSnapshot($input: CreateSnapshotInput!) {
@@ -219,7 +219,7 @@ func TestSnapshot_Types(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
snapshotTypes := []string{"RISKS", "VENDORS"}
snapshotTypes := []string{"RISKS"}
for _, snapshotType := range snapshotTypes {
t.Run(snapshotType, func(t *testing.T) {

View File

@@ -0,0 +1,336 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package console_test
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.probo.inc/probo/e2e/internal/factory"
"go.probo.inc/probo/e2e/internal/testutil"
)
func TestVendor_PublishVendorList(t *testing.T) {
t.Parallel()
t.Run(
"publish without approvers publishes immediately",
func(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
factory.CreateVendor(owner, factory.Attrs{"name": "Test Vendor"})
const query = `
mutation($input: PublishVendorListInput!) {
publishVendorList(input: $input) {
documentEdge {
node {
id
writeMode
status
}
}
documentVersionEdge {
node {
id
title
documentType
status
major
minor
content
}
}
}
}
`
var result struct {
PublishVendorList struct {
DocumentEdge struct {
Node struct {
ID string `json:"id"`
WriteMode string `json:"writeMode"`
Status string `json:"status"`
} `json:"node"`
} `json:"documentEdge"`
DocumentVersionEdge struct {
Node struct {
ID string `json:"id"`
Title string `json:"title"`
DocumentType string `json:"documentType"`
Status string `json:"status"`
Major int `json:"major"`
Minor int `json:"minor"`
Content string `json:"content"`
} `json:"node"`
} `json:"documentVersionEdge"`
} `json:"publishVendorList"`
}
err := owner.Execute(
query,
map[string]any{
"input": map[string]any{
"organizationId": owner.GetOrganizationID(),
},
},
&result,
)
require.NoError(t, err)
doc := result.PublishVendorList.DocumentEdge.Node
assert.NotEmpty(t, doc.ID)
assert.Equal(t, "GENERATED", doc.WriteMode)
assert.Equal(t, "ACTIVE", doc.Status)
ver := result.PublishVendorList.DocumentVersionEdge.Node
assert.NotEmpty(t, ver.ID)
assert.Equal(t, "REGISTER", ver.DocumentType)
assert.Equal(t, "PUBLISHED", ver.Status)
assert.Equal(t, 1, ver.Major)
assert.Equal(t, 0, ver.Minor)
assert.Contains(t, ver.Content, "Purpose")
},
)
t.Run(
"publish with approvers creates draft pending approval",
func(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
const query = `
mutation($input: PublishVendorListInput!) {
publishVendorList(input: $input) {
documentEdge {
node { id writeMode }
}
documentVersionEdge {
node { id status major }
}
}
}
`
var result struct {
PublishVendorList struct {
DocumentEdge struct {
Node struct {
ID string `json:"id"`
WriteMode string `json:"writeMode"`
} `json:"node"`
} `json:"documentEdge"`
DocumentVersionEdge struct {
Node struct {
ID string `json:"id"`
Status string `json:"status"`
Major int `json:"major"`
} `json:"node"`
} `json:"documentVersionEdge"`
} `json:"publishVendorList"`
}
err := owner.Execute(
query,
map[string]any{
"input": map[string]any{
"organizationId": owner.GetOrganizationID(),
"approverIds": []string{owner.GetProfileID().String()},
},
},
&result,
)
require.NoError(t, err)
doc := result.PublishVendorList.DocumentEdge.Node
assert.NotEmpty(t, doc.ID)
assert.Equal(t, "GENERATED", doc.WriteMode)
ver := result.PublishVendorList.DocumentVersionEdge.Node
assert.NotEmpty(t, ver.ID)
assert.Equal(t, "PENDING_APPROVAL", ver.Status)
},
)
t.Run(
"second publish reuses document and bumps major version",
func(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
factory.CreateVendor(owner, factory.Attrs{"name": "Reuse Vendor"})
const query = `
mutation($input: PublishVendorListInput!) {
publishVendorList(input: $input) {
documentEdge { node { id } }
documentVersionEdge { node { id major } }
}
}
`
var r1, r2 struct {
PublishVendorList struct {
DocumentEdge struct {
Node struct {
ID string `json:"id"`
} `json:"node"`
} `json:"documentEdge"`
DocumentVersionEdge struct {
Node struct {
ID string `json:"id"`
Major int `json:"major"`
} `json:"node"`
} `json:"documentVersionEdge"`
} `json:"publishVendorList"`
}
input := map[string]any{
"input": map[string]any{
"organizationId": owner.GetOrganizationID(),
},
}
err := owner.Execute(query, input, &r1)
require.NoError(t, err)
err = owner.Execute(query, input, &r2)
require.NoError(t, err)
assert.Equal(t,
r1.PublishVendorList.DocumentEdge.Node.ID,
r2.PublishVendorList.DocumentEdge.Node.ID,
"should reuse same document",
)
assert.Equal(t, 1, r1.PublishVendorList.DocumentVersionEdge.Node.Major)
assert.Equal(t, 2, r2.PublishVendorList.DocumentVersionEdge.Node.Major)
},
)
t.Run(
"organization vendorsDocument links to published document",
func(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
factory.CreateVendor(owner, factory.Attrs{"name": "Linked Vendor"})
const publishQuery = `
mutation($input: PublishVendorListInput!) {
publishVendorList(input: $input) {
documentEdge { node { id } }
documentVersionEdge { node { id } }
}
}
`
var publishResult struct {
PublishVendorList struct {
DocumentEdge struct {
Node struct {
ID string `json:"id"`
} `json:"node"`
} `json:"documentEdge"`
DocumentVersionEdge struct {
Node struct {
ID string `json:"id"`
} `json:"node"`
} `json:"documentVersionEdge"`
} `json:"publishVendorList"`
}
err := owner.Execute(
publishQuery,
map[string]any{
"input": map[string]any{
"organizationId": owner.GetOrganizationID(),
},
},
&publishResult,
)
require.NoError(t, err)
docID := publishResult.PublishVendorList.DocumentEdge.Node.ID
const orgQuery = `
query($id: ID!) {
node(id: $id) {
... on Organization {
id
vendorsDocument { id }
}
}
}
`
var orgResult struct {
Node struct {
ID string `json:"id"`
VendorsDocument *struct {
ID string `json:"id"`
} `json:"vendorsDocument"`
} `json:"node"`
}
err = owner.Execute(
orgQuery,
map[string]any{"id": owner.GetOrganizationID()},
&orgResult,
)
require.NoError(t, err)
require.NotNil(t, orgResult.Node.VendorsDocument)
assert.Equal(t, docID, orgResult.Node.VendorsDocument.ID)
},
)
}
func TestVendor_PublishVendorList_RBAC(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
viewer := testutil.NewClientInOrg(t, testutil.RoleViewer, owner)
factory.CreateVendor(owner, factory.Attrs{"name": "RBAC Vendor"})
const query = `
mutation($input: PublishVendorListInput!) {
publishVendorList(input: $input) {
documentEdge { node { id } }
documentVersionEdge { node { id } }
}
}
`
t.Run(
"viewer cannot publish vendor list",
func(t *testing.T) {
t.Parallel()
err := viewer.ExecuteShouldFail(
query,
map[string]any{
"input": map[string]any{
"organizationId": owner.GetOrganizationID(),
},
},
)
testutil.RequireForbiddenError(t, err)
},
)
}

View File

@@ -29,8 +29,8 @@ func TestMCP_Snapshot(t *testing.T) {
mc := testutil.NewMCPClient(t, owner)
orgID := owner.GetOrganizationID().String()
// Create a vendor so the snapshot has data
factory.CreateVendor(owner)
// Create a risk so the snapshot has data
factory.CreateRisk(owner)
// Take snapshot
var takeResult struct {
@@ -41,7 +41,7 @@ func TestMCP_Snapshot(t *testing.T) {
mc.CallToolInto("takeSnapshot", map[string]any{
"organizationId": orgID,
"name": factory.SafeName("Snapshot"),
"snapshotsType": "VENDORS",
"snapshotsType": "RISKS",
}, &takeResult)
require.NotEmpty(t, takeResult.Snapshot.ID)

View File

@@ -16,8 +16,6 @@ type Translator = (s: string) => string;
export const snapshotTypes = [
"RISKS",
"VENDORS",
"PROCESSING_ACTIVITIES",
] as const;
export function getSnapshotTypeLabel(__: Translator, type: string | null | undefined) {

View File

@@ -39,6 +39,7 @@ import * as updateBusinessAssociateAgreementOp from './updateBusinessAssociateAg
import * as getDataPrivacyAgreementOp from './getDataPrivacyAgreement.operation';
import * as deleteDataPrivacyAgreementOp from './deleteDataPrivacyAgreement.operation';
import * as updateDataPrivacyAgreementOp from './updateDataPrivacyAgreement.operation';
import * as publishOp from './publish.operation';
export const description: INodeProperties[] = [
{
@@ -178,6 +179,12 @@ export const description: INodeProperties[] = [
description: 'Get a vendor service',
action: 'Get a vendor service',
},
{
name: 'Publish List',
value: 'publish',
description: 'Publish the vendor register as a document version',
action: 'Publish the vendor register',
},
{
name: 'Update',
value: 'update',
@@ -237,6 +244,7 @@ export const description: INodeProperties[] = [
...getDataPrivacyAgreementOp.description,
...deleteDataPrivacyAgreementOp.description,
...updateDataPrivacyAgreementOp.description,
...publishOp.description,
];
export {
@@ -266,4 +274,5 @@ export {
getDataPrivacyAgreementOp as getDataPrivacyAgreement,
deleteDataPrivacyAgreementOp as deleteDataPrivacyAgreement,
updateDataPrivacyAgreementOp as updateDataPrivacyAgreement,
publishOp as publish,
};

View File

@@ -0,0 +1,101 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import type { INodeProperties, IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
import { proboApiRequest } from '../../GenericFunctions';
export const description: INodeProperties[] = [
{
displayName: 'Organization ID',
name: 'organizationId',
type: 'string',
displayOptions: {
show: {
resource: ['vendor'],
operation: ['publish'],
},
},
default: '',
description: 'The ID of the organization whose vendor list to publish',
required: true,
},
{
displayName: 'Approver IDs',
name: 'approverIds',
type: 'string',
displayOptions: {
show: {
resource: ['vendor'],
operation: ['publish'],
},
},
default: '',
description: 'Comma-separated list of approver profile IDs',
},
];
export async function execute(
this: IExecuteFunctions,
itemIndex: number,
): Promise<INodeExecutionData> {
const organizationId = this.getNodeParameter('organizationId', itemIndex) as string;
const approverIds = this.getNodeParameter('approverIds', itemIndex, '') as string;
const query = `
mutation PublishVendorList($input: PublishVendorListInput!) {
publishVendorList(input: $input) {
documentEdge {
node {
id
status
currentPublishedMajor
currentPublishedMinor
createdAt
updatedAt
}
}
documentVersionEdge {
node {
id
title
major
minor
status
classification
documentType
publishedAt
createdAt
updatedAt
}
}
}
}
`;
const input: Record<string, unknown> = { organizationId };
if (approverIds) {
input.approverIds = approverIds
.split(',')
.map(id => id.trim())
.filter(Boolean);
}
const responseData = await proboApiRequest.call(this, query, { input });
return {
json: responseData,
pairedItem: { item: itemIndex },
};
}

View File

@@ -0,0 +1,148 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package publish
import (
"encoding/json"
"fmt"
"github.com/spf13/cobra"
"go.probo.inc/probo/pkg/cli/api"
"go.probo.inc/probo/pkg/cmd/cmdutil"
)
const publishMutation = `
mutation($input: PublishVendorListInput!) {
publishVendorList(input: $input) {
documentEdge {
node {
id
status
createdAt
}
}
documentVersionEdge {
node {
id
title
major
minor
status
}
}
}
}
`
type publishResponse struct {
PublishVendorList struct {
DocumentEdge struct {
Node struct {
ID string `json:"id"`
Status string `json:"status"`
CreatedAt string `json:"createdAt"`
} `json:"node"`
} `json:"documentEdge"`
DocumentVersionEdge struct {
Node struct {
ID string `json:"id"`
Title string `json:"title"`
Major int `json:"major"`
Minor int `json:"minor"`
Status string `json:"status"`
} `json:"node"`
} `json:"documentVersionEdge"`
} `json:"publishVendorList"`
}
func NewCmdPublish(f *cmdutil.Factory) *cobra.Command {
var (
flagOrg string
flagApprover []string
)
cmd := &cobra.Command{
Use: "publish",
Short: "Publish the vendor register as a document version",
Example: ` # Publish the vendor register
prb vendor publish --org ORG_ID
# Publish with approvers
prb vendor publish --org ORG_ID --approver PROFILE_ID1 --approver PROFILE_ID2`,
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := f.Config()
if err != nil {
return err
}
host, hc, err := cfg.DefaultHost()
if err != nil {
return err
}
if flagOrg == "" {
flagOrg = hc.Organization
}
if flagOrg == "" {
return fmt.Errorf("organization is required: pass --org or run `prb auth login`")
}
client := api.NewClient(
host,
hc.Token,
"/api/console/v1/graphql",
cfg.HTTPTimeoutDuration(),
cmdutil.TokenRefreshOption(cfg, host, hc),
)
input := map[string]any{
"organizationId": flagOrg,
}
if len(flagApprover) > 0 {
input["approverIds"] = flagApprover
}
data, err := client.Do(
publishMutation,
map[string]any{"input": input},
)
if err != nil {
return err
}
var resp publishResponse
if err := json.Unmarshal(data, &resp); err != nil {
return fmt.Errorf("cannot parse response: %w", err)
}
v := resp.PublishVendorList.DocumentVersionEdge.Node
_, _ = fmt.Fprintf(
f.IOStreams.Out,
"Published vendor register %s (v%d.%d)\n",
v.Title,
v.Major,
v.Minor,
)
return nil
},
}
cmd.Flags().StringVar(&flagOrg, "org", "", "Organization ID")
cmd.Flags().StringArrayVar(&flagApprover, "approver", nil, "Approver profile ID (can be repeated)")
return cmd
}

View File

@@ -21,6 +21,7 @@ import (
"go.probo.inc/probo/pkg/cmd/vendormgmt/create"
"go.probo.inc/probo/pkg/cmd/vendormgmt/delete"
"go.probo.inc/probo/pkg/cmd/vendormgmt/list"
"go.probo.inc/probo/pkg/cmd/vendormgmt/publish"
"go.probo.inc/probo/pkg/cmd/vendormgmt/update"
"go.probo.inc/probo/pkg/cmd/vendormgmt/view"
)
@@ -37,6 +38,7 @@ func NewCmdVendor(f *cmdutil.Factory) *cobra.Command {
cmd.AddCommand(update.NewCmdUpdate(f))
cmd.AddCommand(delete.NewCmdDelete(f))
cmd.AddCommand(assess.NewCmdAssess(f))
cmd.AddCommand(publish.NewCmdPublish(f))
return cmd
}

View File

@@ -0,0 +1,16 @@
-- Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
--
-- Permission to use, copy, modify, and/or distribute this software for any
-- purpose with or without fee is hereby granted, provided that the above
-- copyright notice and this permission notice appear in all copies.
--
-- THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
-- REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
-- AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
-- INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
-- LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
-- OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
-- PERFORMANCE OF THIS SOFTWARE.
ALTER TABLE generated_documents
ADD COLUMN vendors_document_id TEXT REFERENCES documents(id) ON DELETE SET NULL;

View File

@@ -124,7 +124,7 @@ FROM
WHERE
%s
AND organization_id = @organization_id
AND type NOT IN ('STATEMENTS_OF_APPLICABILITY', 'DATA', 'ASSETS')
AND type = 'RISKS'
AND %s
`
@@ -164,7 +164,7 @@ FROM
WHERE
%s
AND organization_id = @organization_id
AND type NOT IN ('STATEMENTS_OF_APPLICABILITY', 'DATA', 'ASSETS')
AND type = 'RISKS'
AND %s
`

View File

@@ -25,7 +25,6 @@ type (
const (
SnapshotsTypeRisks SnapshotsType = "RISKS"
SnapshotsTypeVendors SnapshotsType = "VENDORS"
SnapshotsTypeAssets SnapshotsType = "ASSETS"
SnapshotsTypeData SnapshotsType = "DATA"
SnapshotsTypeFindings SnapshotsType = "FINDINGS"
@@ -37,7 +36,6 @@ const (
func SnapshotsTypes() []SnapshotsType {
return []SnapshotsType{
SnapshotsTypeRisks,
SnapshotsTypeVendors,
}
}
@@ -59,8 +57,6 @@ func (st *SnapshotsType) Scan(value any) error {
switch s {
case SnapshotsTypeRisks.String():
*st = SnapshotsTypeRisks
case SnapshotsTypeVendors.String():
*st = SnapshotsTypeVendors
case SnapshotsTypeAssets.String():
*st = SnapshotsTypeAssets
case SnapshotsTypeData.String():

View File

@@ -30,8 +30,6 @@ func GetSnapshottable(snapshotType SnapshotsType) (Snapshottable, error) {
switch snapshotType {
case SnapshotsTypeRisks:
return Risks{}, nil
case SnapshotsTypeVendors:
return Vendors{}, nil
default:
return nil, fmt.Errorf("unsupported snapshot type: %s", snapshotType)
}

View File

@@ -27,6 +27,113 @@ import (
"go.probo.inc/probo/pkg/page"
)
func (v Vendor) GetGeneratedDocumentID(
ctx context.Context,
conn pg.Querier,
organizationID gid.GID,
) (*gid.GID, error) {
var documentID *gid.GID
err := conn.QueryRow(
ctx,
`
SELECT
vendors_document_id
FROM
generated_documents
WHERE
organization_id = @organization_id
`,
pgx.NamedArgs{"organization_id": organizationID},
).Scan(&documentID)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("cannot get vendor list document ID: %w", err)
}
return documentID, nil
}
func (v Vendor) UpsertGeneratedDocumentID(
ctx context.Context,
conn pg.Tx,
organizationID gid.GID,
tenantID gid.TenantID,
documentID gid.GID,
) error {
now := time.Now()
_, err := conn.Exec(
ctx,
`
INSERT INTO generated_documents (
organization_id,
tenant_id,
vendors_document_id,
created_at,
updated_at
) VALUES (
@organization_id,
@tenant_id,
@vendors_document_id,
@created_at,
@updated_at
)
ON CONFLICT (organization_id) DO UPDATE
SET
vendors_document_id = @vendors_document_id,
updated_at = @updated_at
`,
pgx.NamedArgs{
"organization_id": organizationID,
"tenant_id": tenantID,
"vendors_document_id": documentID,
"created_at": now,
"updated_at": now,
},
)
if err != nil {
return fmt.Errorf("cannot upsert vendor list document ID: %w", err)
}
return nil
}
func (v Vendor) ClearGeneratedDocumentID(
ctx context.Context,
conn pg.Tx,
documentIDs []gid.GID,
) error {
ids := make([]string, len(documentIDs))
for i, id := range documentIDs {
ids[i] = id.String()
}
_, err := conn.Exec(
ctx,
`
UPDATE
generated_documents
SET
vendors_document_id = NULL,
updated_at = @now
WHERE
vendors_document_id = ANY(@ids)
`,
pgx.NamedArgs{
"ids": ids,
"now": time.Now(),
},
)
if err != nil {
return fmt.Errorf("cannot clear vendor list document references: %w", err)
}
return nil
}
type (
Vendor struct {
ID gid.GID `db:"id"`
@@ -52,17 +159,11 @@ type (
SecurityPageURL *string `db:"security_page_url"`
TrustPageURL *string `db:"trust_page_url"`
ShowOnTrustCenter bool `db:"show_on_trust_center"`
SnapshotID *gid.GID `db:"snapshot_id"`
SourceID *gid.GID `db:"source_id"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
Vendors []*Vendor
VendorSnapshotter interface {
InsertVendorSnapshots(ctx context.Context, conn pg.Tx, scope Scoper, organizationID, snapshotID gid.GID) error
}
)
func (v Vendor) CursorKey(orderBy VendorOrderField) page.CursorKey {
@@ -123,8 +224,6 @@ SELECT
security_page_url,
trust_page_url,
show_on_trust_center,
snapshot_id,
source_id,
created_at,
updated_at
FROM
@@ -191,8 +290,6 @@ SELECT
security_page_url,
trust_page_url,
show_on_trust_center,
snapshot_id,
source_id,
created_at,
updated_at
FROM
@@ -253,8 +350,6 @@ INSERT INTO
security_page_url,
trust_page_url,
show_on_trust_center,
snapshot_id,
source_id,
created_at,
updated_at
)
@@ -282,8 +377,6 @@ VALUES (
@security_page_url,
@trust_page_url,
@show_on_trust_center,
@snapshot_id,
@source_id,
@created_at,
@updated_at
)
@@ -313,8 +406,6 @@ VALUES (
"security_page_url": v.SecurityPageURL,
"trust_page_url": v.TrustPageURL,
"show_on_trust_center": v.ShowOnTrustCenter,
"snapshot_id": v.SnapshotID,
"source_id": v.SourceID,
"created_at": v.CreatedAt,
"updated_at": v.UpdatedAt,
}
@@ -355,7 +446,8 @@ FROM
WHERE
%s
AND organization_id = @organization_id
AND %s
AND snapshot_id IS NULL
AND %s
`
q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment())
@@ -375,6 +467,67 @@ WHERE
return count, nil
}
func (v *Vendors) LoadAllByOrganizationID(
ctx context.Context,
conn pg.Querier,
scope Scoper,
organizationID gid.GID,
) error {
q := `
SELECT
id,
tenant_id,
organization_id,
name,
description,
category,
headquarter_address,
legal_name,
website_url,
privacy_policy_url,
service_level_agreement_url,
data_processing_agreement_url,
business_associate_agreement_url,
subprocessors_list_url,
certifications,
countries,
business_owner_profile_id,
security_owner_profile_id,
status_page_url,
terms_of_service_url,
security_page_url,
trust_page_url,
show_on_trust_center,
created_at,
updated_at
FROM
vendors
WHERE
%s
AND organization_id = @organization_id
AND snapshot_id IS NULL
ORDER BY name ASC
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{"organization_id": organizationID}
maps.Copy(args, scope.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query vendors: %w", err)
}
vendors, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[Vendor])
if err != nil {
return fmt.Errorf("cannot collect vendors: %w", err)
}
*v = vendors
return nil
}
func (v *Vendors) LoadByOrganizationID(
ctx context.Context,
conn pg.Querier,
@@ -408,8 +561,6 @@ SELECT
security_page_url,
trust_page_url,
show_on_trust_center,
snapshot_id,
source_id,
created_at,
updated_at
FROM
@@ -417,6 +568,7 @@ FROM
WHERE
%s
AND organization_id = @organization_id
AND snapshot_id IS NULL
AND %s
AND %s
`
@@ -613,8 +765,6 @@ WITH vend AS (
v.security_page_url,
v.trust_page_url,
v.show_on_trust_center,
v.snapshot_id,
v.source_id,
v.created_at,
v.updated_at
FROM
@@ -648,8 +798,6 @@ SELECT
security_page_url,
trust_page_url,
show_on_trust_center,
snapshot_id,
source_id,
created_at,
updated_at
FROM
@@ -749,8 +897,6 @@ WITH vend AS (
v.security_page_url,
v.trust_page_url,
v.show_on_trust_center,
v.snapshot_id,
v.source_id,
v.created_at,
v.updated_at
FROM
@@ -784,8 +930,6 @@ SELECT
security_page_url,
trust_page_url,
show_on_trust_center,
snapshot_id,
source_id,
created_at,
updated_at
FROM
@@ -846,8 +990,6 @@ WITH vend AS (
v.security_page_url,
v.trust_page_url,
v.show_on_trust_center,
v.snapshot_id,
v.source_id,
v.created_at,
v.updated_at
FROM
@@ -881,8 +1023,6 @@ SELECT
security_page_url,
trust_page_url,
show_on_trust_center,
snapshot_id,
source_id,
created_at,
updated_at
FROM
@@ -944,8 +1084,6 @@ WITH vend AS (
v.security_page_url,
v.trust_page_url,
v.show_on_trust_center,
v.snapshot_id,
v.source_id,
v.created_at,
v.updated_at
FROM
@@ -979,8 +1117,6 @@ SELECT
security_page_url,
trust_page_url,
show_on_trust_center,
snapshot_id,
source_id,
created_at,
updated_at
FROM
@@ -1034,6 +1170,7 @@ filtered_vendors AS (
vendors v
WHERE
v.tenant_id = @tenant_id
AND v.snapshot_id IS NULL
)
SELECT
pav.processing_activity_id,
@@ -1106,8 +1243,6 @@ WITH vend AS (
v.security_page_url,
v.trust_page_url,
v.show_on_trust_center,
v.snapshot_id,
v.source_id,
v.created_at,
v.updated_at
FROM
@@ -1141,8 +1276,6 @@ SELECT
security_page_url,
trust_page_url,
show_on_trust_center,
snapshot_id,
source_id,
created_at,
updated_at
FROM
@@ -1169,108 +1302,3 @@ ORDER BY name ASC
return nil
}
func (v Vendors) Snapshot(ctx context.Context, conn pg.Tx, scope Scoper, organizationID, snapshotID gid.GID) error {
for _, snapshotter := range []VendorSnapshotter{
Vendors{},
VendorServices{},
VendorContacts{},
VendorRiskAssessments{},
VendorComplianceReports{},
VendorBusinessAssociateAgreements{},
VendorDataPrivacyAgreements{},
} {
if err := snapshotter.InsertVendorSnapshots(ctx, conn, scope, organizationID, snapshotID); err != nil {
return fmt.Errorf("cannot create vendor snapshots: (%T) %w", snapshotter, err)
}
}
return nil
}
func (v Vendors) InsertVendorSnapshots(
ctx context.Context,
conn pg.Tx,
scope Scoper,
organizationID gid.GID,
snapshotID gid.GID,
) error {
query := `
INSERT INTO vendors (
tenant_id,
id,
snapshot_id,
source_id,
organization_id,
name,
description,
category,
headquarter_address,
legal_name,
website_url,
privacy_policy_url,
service_level_agreement_url,
data_processing_agreement_url,
business_associate_agreement_url,
subprocessors_list_url,
certifications,
countries,
business_owner_profile_id,
security_owner_profile_id,
status_page_url,
terms_of_service_url,
security_page_url,
trust_page_url,
show_on_trust_center,
created_at,
updated_at
)
SELECT
@tenant_id,
generate_gid(decode_base64_unpadded(@tenant_id), @vendor_entity_type),
@snapshot_id,
v.id,
v.organization_id,
v.name,
v.description,
v.category,
v.headquarter_address,
v.legal_name,
v.website_url,
v.privacy_policy_url,
v.service_level_agreement_url,
v.data_processing_agreement_url,
v.business_associate_agreement_url,
v.subprocessors_list_url,
v.certifications,
v.countries,
v.business_owner_profile_id,
v.security_owner_profile_id,
v.status_page_url,
v.terms_of_service_url,
v.security_page_url,
v.trust_page_url,
v.show_on_trust_center,
v.created_at,
v.updated_at
FROM vendors v
WHERE %s AND organization_id = @organization_id AND snapshot_id IS NULL
`
query = fmt.Sprintf(query, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"tenant_id": scope.GetTenantID(),
"snapshot_id": snapshotID,
"organization_id": organizationID,
"vendor_entity_type": VendorEntityType,
}
maps.Copy(args, scope.SQLArguments())
_, err := conn.Exec(ctx, query, args)
if err != nil {
return fmt.Errorf("cannot insert vendor snapshots: %w", err)
}
return nil
}

View File

@@ -36,8 +36,6 @@ type (
ValidFrom *time.Time `db:"valid_from"`
ValidUntil *time.Time `db:"valid_until"`
FileID gid.GID `db:"file_id"`
SnapshotID *gid.GID `db:"snapshot_id"`
SourceID *gid.GID `db:"source_id"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
@@ -84,8 +82,6 @@ SELECT
valid_from,
valid_until,
file_id,
snapshot_id,
source_id,
created_at,
updated_at
FROM
@@ -93,6 +89,7 @@ FROM
WHERE
%s
AND vendor_id = @vendor_id
AND snapshot_id IS NULL
LIMIT 1;
`
@@ -116,6 +113,60 @@ LIMIT 1;
return nil
}
func (vbaas *VendorBusinessAssociateAgreements) LoadByVendorIDs(
ctx context.Context,
conn pg.Querier,
scope Scoper,
vendorIDs []gid.GID,
) error {
if len(vendorIDs) == 0 {
*vbaas = VendorBusinessAssociateAgreements{}
return nil
}
q := `
SELECT
id,
organization_id,
vendor_id,
valid_from,
valid_until,
file_id,
created_at,
updated_at
FROM
vendor_business_associate_agreements
WHERE
%s
AND vendor_id = ANY(@vendor_ids)
AND snapshot_id IS NULL
`
q = fmt.Sprintf(q, scope.SQLFragment())
ids := make([]string, len(vendorIDs))
for i, id := range vendorIDs {
ids[i] = id.String()
}
args := pgx.NamedArgs{"vendor_ids": ids}
maps.Copy(args, scope.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query vendor business associate agreements: %w", err)
}
agreements, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[VendorBusinessAssociateAgreement])
if err != nil {
return fmt.Errorf("cannot collect vendor business associate agreements: %w", err)
}
*vbaas = agreements
return nil
}
func (vbaa *VendorBusinessAssociateAgreement) LoadByID(
ctx context.Context,
conn pg.Querier,
@@ -130,8 +181,6 @@ SELECT
valid_from,
valid_until,
file_id,
snapshot_id,
source_id,
created_at,
updated_at
FROM
@@ -215,8 +264,6 @@ INSERT INTO
valid_from,
valid_until,
file_id,
snapshot_id,
source_id,
created_at,
updated_at
)
@@ -228,8 +275,6 @@ VALUES (
@valid_from,
@valid_until,
@file_id,
@snapshot_id,
@source_id,
@created_at,
@updated_at
)
@@ -238,8 +283,6 @@ ON CONFLICT (organization_id, vendor_id) DO UPDATE SET
valid_from = EXCLUDED.valid_from,
valid_until = EXCLUDED.valid_until,
file_id = EXCLUDED.file_id,
snapshot_id = EXCLUDED.snapshot_id,
source_id = EXCLUDED.source_id,
updated_at = EXCLUDED.updated_at
`
args := pgx.StrictNamedArgs{
@@ -250,8 +293,6 @@ ON CONFLICT (organization_id, vendor_id) DO UPDATE SET
"valid_from": vbaa.ValidFrom,
"valid_until": vbaa.ValidUntil,
"file_id": vbaa.FileID,
"snapshot_id": vbaa.SnapshotID,
"source_id": vbaa.SourceID,
"created_at": vbaa.CreatedAt,
"updated_at": vbaa.UpdatedAt,
}
@@ -317,65 +358,3 @@ WHERE
_, err := conn.Exec(ctx, q, args)
return err
}
func (v VendorBusinessAssociateAgreements) InsertVendorSnapshots(
ctx context.Context,
conn pg.Tx,
scope Scoper,
organizationID gid.GID,
snapshotID gid.GID,
) error {
query := `
WITH
snapshot_vendors AS (
SELECT id, source_id
FROM vendors
WHERE organization_id = @organization_id AND snapshot_id = @snapshot_id
)
INSERT INTO vendor_business_associate_agreements (
tenant_id,
id,
snapshot_id,
source_id,
organization_id,
vendor_id,
valid_from,
valid_until,
file_id,
created_at,
updated_at
)
SELECT
@tenant_id,
generate_gid(decode_base64_unpadded(@tenant_id), @vendor_business_associate_agreement_entity_type),
@snapshot_id,
vbaa.id,
vbaa.organization_id,
sv.id,
vbaa.valid_from,
vbaa.valid_until,
vbaa.file_id,
vbaa.created_at,
vbaa.updated_at
FROM vendor_business_associate_agreements vbaa
INNER JOIN snapshot_vendors sv ON sv.source_id = vbaa.vendor_id
WHERE %s AND vbaa.snapshot_id IS NULL
`
query = fmt.Sprintf(query, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"tenant_id": scope.GetTenantID(),
"snapshot_id": snapshotID,
"organization_id": organizationID,
"vendor_business_associate_agreement_entity_type": VendorBusinessAssociateAgreementEntityType,
}
maps.Copy(args, scope.SQLArguments())
_, err := conn.Exec(ctx, query, args)
if err != nil {
return fmt.Errorf("cannot insert vendor business associate agreement snapshots: %w", err)
}
return nil
}

View File

@@ -36,8 +36,6 @@ type (
ValidUntil *time.Time `db:"valid_until"`
ReportName string `db:"report_name"`
ReportFileId *gid.GID `db:"report_file_id"`
SnapshotID *gid.GID `db:"snapshot_id"`
SourceID *gid.GID `db:"source_id"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
@@ -86,8 +84,6 @@ SELECT
valid_until,
report_name,
report_file_id,
snapshot_id,
source_id,
created_at,
updated_at
FROM
@@ -95,6 +91,7 @@ FROM
WHERE
%s
AND vendor_id = @vendor_id
AND snapshot_id IS NULL
AND %s
`
@@ -119,6 +116,63 @@ WHERE
return nil
}
func (vcs *VendorComplianceReports) LoadByVendorIDs(
ctx context.Context,
conn pg.Querier,
scope Scoper,
vendorIDs []gid.GID,
) error {
if len(vendorIDs) == 0 {
*vcs = VendorComplianceReports{}
return nil
}
q := `
SELECT
id,
organization_id,
vendor_id,
report_date,
valid_until,
report_name,
report_file_id,
created_at,
updated_at
FROM
vendor_compliance_reports
WHERE
%s
AND vendor_id = ANY(@vendor_ids)
AND snapshot_id IS NULL
ORDER BY
vendor_id, report_date DESC
`
q = fmt.Sprintf(q, scope.SQLFragment())
ids := make([]string, len(vendorIDs))
for i, id := range vendorIDs {
ids[i] = id.String()
}
args := pgx.NamedArgs{"vendor_ids": ids}
maps.Copy(args, scope.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query vendor compliance reports: %w", err)
}
vendorComplianceReports, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[VendorComplianceReport])
if err != nil {
return fmt.Errorf("cannot collect vendor compliance reports: %w", err)
}
*vcs = vendorComplianceReports
return nil
}
func (vcr *VendorComplianceReport) LoadByID(
ctx context.Context,
conn pg.Querier,
@@ -134,8 +188,6 @@ SELECT
valid_until,
report_name,
report_file_id,
snapshot_id,
source_id,
created_at,
updated_at
FROM
@@ -251,67 +303,3 @@ RETURNING report_file_id
}
return nil
}
func (vcrs VendorComplianceReports) InsertVendorSnapshots(
ctx context.Context,
conn pg.Tx,
scope Scoper,
organizationID gid.GID,
snapshotID gid.GID,
) error {
query := `
WITH
snapshot_vendors AS (
SELECT id, source_id
FROM vendors
WHERE organization_id = @organization_id AND snapshot_id = @snapshot_id
)
INSERT INTO vendor_compliance_reports (
tenant_id,
id,
organization_id,
snapshot_id,
source_id,
vendor_id,
report_date,
valid_until,
report_name,
report_file_id,
created_at,
updated_at
)
SELECT
@tenant_id,
generate_gid(decode_base64_unpadded(@tenant_id), @vendor_compliance_report_entity_type),
@organization_id,
@snapshot_id,
vcr.id,
sv.id,
vcr.report_date,
vcr.valid_until,
vcr.report_name,
vcr.report_file_id,
vcr.created_at,
vcr.updated_at
FROM vendor_compliance_reports vcr
INNER JOIN snapshot_vendors sv ON sv.source_id = vcr.vendor_id
WHERE %s AND vcr.snapshot_id IS NULL
`
query = fmt.Sprintf(query, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"tenant_id": scope.GetTenantID(),
"snapshot_id": snapshotID,
"organization_id": organizationID,
"vendor_compliance_report_entity_type": VendorComplianceReportEntityType,
}
maps.Copy(args, scope.SQLArguments())
_, err := conn.Exec(ctx, query, args)
if err != nil {
return fmt.Errorf("cannot insert vendor compliance report snapshots: %w", err)
}
return nil
}

View File

@@ -37,8 +37,6 @@ type (
Email *mail.Addr `db:"email"`
Phone *string `db:"phone"`
Role *string `db:"role"`
SnapshotID *gid.GID `db:"snapshot_id"`
SourceID *gid.GID `db:"source_id"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
@@ -88,8 +86,6 @@ SELECT
email,
phone,
role,
snapshot_id,
source_id,
created_at,
updated_at
FROM
@@ -141,8 +137,6 @@ SELECT
email,
phone,
role,
snapshot_id,
source_id,
created_at,
updated_at
FROM
@@ -150,6 +144,7 @@ FROM
WHERE
%s
AND vendor_id = @vendor_id
AND snapshot_id IS NULL
AND %s
`
q = fmt.Sprintf(q, scope.SQLFragment(), cursor.SQLFragment())
@@ -176,6 +171,63 @@ WHERE
return nil
}
func (vc *VendorContacts) LoadByVendorIDs(
ctx context.Context,
conn pg.Querier,
scope Scoper,
vendorIDs []gid.GID,
) error {
if len(vendorIDs) == 0 {
*vc = VendorContacts{}
return nil
}
q := `
SELECT
id,
organization_id,
vendor_id,
full_name,
email,
phone,
role,
created_at,
updated_at
FROM
vendor_contacts
WHERE
%s
AND vendor_id = ANY(@vendor_ids)
AND snapshot_id IS NULL
ORDER BY
vendor_id, full_name ASC
`
q = fmt.Sprintf(q, scope.SQLFragment())
ids := make([]string, len(vendorIDs))
for i, id := range vendorIDs {
ids[i] = id.String()
}
args := pgx.StrictNamedArgs{"vendor_ids": ids}
maps.Copy(args, scope.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query vendor contacts: %w", err)
}
defer rows.Close()
vendorContacts, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[VendorContact])
if err != nil {
return fmt.Errorf("cannot collect vendor contacts: %w", err)
}
*vc = vendorContacts
return nil
}
func (vc VendorContact) Insert(
ctx context.Context,
conn pg.Tx,
@@ -296,67 +348,3 @@ WHERE
return nil
}
func (vc VendorContacts) InsertVendorSnapshots(
ctx context.Context,
conn pg.Tx,
scope Scoper,
organizationID gid.GID,
snapshotID gid.GID,
) error {
query := `
WITH
snapshot_vendors AS (
SELECT id, source_id
FROM vendors
WHERE organization_id = @organization_id AND snapshot_id = @snapshot_id
)
INSERT INTO vendor_contacts (
tenant_id,
id,
organization_id,
snapshot_id,
source_id,
vendor_id,
full_name,
email,
phone,
role,
created_at,
updated_at
)
SELECT
@tenant_id,
generate_gid(decode_base64_unpadded(@tenant_id), @vendor_contact_entity_type),
@organization_id,
@snapshot_id,
vc.id,
sv.id,
vc.full_name,
vc.email,
vc.phone,
vc.role,
vc.created_at,
vc.updated_at
FROM vendor_contacts vc
INNER JOIN snapshot_vendors sv ON sv.source_id = vc.vendor_id
WHERE %s AND vc.snapshot_id IS NULL
`
query = fmt.Sprintf(query, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"tenant_id": scope.GetTenantID(),
"snapshot_id": snapshotID,
"organization_id": organizationID,
"vendor_contact_entity_type": VendorContactEntityType,
}
maps.Copy(args, scope.SQLArguments())
_, err := conn.Exec(ctx, query, args)
if err != nil {
return fmt.Errorf("cannot insert vendor contact snapshots: %w", err)
}
return nil
}

View File

@@ -36,8 +36,6 @@ type (
ValidFrom *time.Time `db:"valid_from"`
ValidUntil *time.Time `db:"valid_until"`
FileID gid.GID `db:"file_id"`
SnapshotID *gid.GID `db:"snapshot_id"`
SourceID *gid.GID `db:"source_id"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
@@ -84,8 +82,6 @@ SELECT
valid_from,
valid_until,
file_id,
snapshot_id,
source_id,
created_at,
updated_at
FROM
@@ -93,6 +89,7 @@ FROM
WHERE
%s
AND vendor_id = @vendor_id
AND snapshot_id IS NULL
LIMIT 1;
`
@@ -116,6 +113,60 @@ LIMIT 1;
return nil
}
func (vdpas *VendorDataPrivacyAgreements) LoadByVendorIDs(
ctx context.Context,
conn pg.Querier,
scope Scoper,
vendorIDs []gid.GID,
) error {
if len(vendorIDs) == 0 {
*vdpas = VendorDataPrivacyAgreements{}
return nil
}
q := `
SELECT
id,
organization_id,
vendor_id,
valid_from,
valid_until,
file_id,
created_at,
updated_at
FROM
vendor_data_privacy_agreements
WHERE
%s
AND vendor_id = ANY(@vendor_ids)
AND snapshot_id IS NULL
`
q = fmt.Sprintf(q, scope.SQLFragment())
ids := make([]string, len(vendorIDs))
for i, id := range vendorIDs {
ids[i] = id.String()
}
args := pgx.NamedArgs{"vendor_ids": ids}
maps.Copy(args, scope.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query vendor data privacy agreements: %w", err)
}
agreements, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[VendorDataPrivacyAgreement])
if err != nil {
return fmt.Errorf("cannot collect vendor data privacy agreements: %w", err)
}
*vdpas = agreements
return nil
}
func (vdpa *VendorDataPrivacyAgreement) LoadByID(
ctx context.Context,
conn pg.Querier,
@@ -130,8 +181,6 @@ SELECT
valid_from,
valid_until,
file_id,
snapshot_id,
source_id,
created_at,
updated_at
FROM
@@ -215,8 +264,6 @@ INSERT INTO
valid_from,
valid_until,
file_id,
snapshot_id,
source_id,
created_at,
updated_at
)
@@ -228,8 +275,6 @@ VALUES (
@valid_from,
@valid_until,
@file_id,
@snapshot_id,
@source_id,
@created_at,
@updated_at
)
@@ -238,8 +283,6 @@ ON CONFLICT (organization_id, vendor_id) DO UPDATE SET
valid_from = EXCLUDED.valid_from,
valid_until = EXCLUDED.valid_until,
file_id = EXCLUDED.file_id,
snapshot_id = EXCLUDED.snapshot_id,
source_id = EXCLUDED.source_id,
updated_at = EXCLUDED.updated_at
`
args := pgx.StrictNamedArgs{
@@ -250,8 +293,6 @@ ON CONFLICT (organization_id, vendor_id) DO UPDATE SET
"valid_from": vdpa.ValidFrom,
"valid_until": vdpa.ValidUntil,
"file_id": vdpa.FileID,
"snapshot_id": vdpa.SnapshotID,
"source_id": vdpa.SourceID,
"created_at": vdpa.CreatedAt,
"updated_at": vdpa.UpdatedAt,
}
@@ -316,65 +357,3 @@ WHERE
_, err := conn.Exec(ctx, q, args)
return err
}
func (vdpa VendorDataPrivacyAgreements) InsertVendorSnapshots(
ctx context.Context,
conn pg.Tx,
scope Scoper,
organizationID gid.GID,
snapshotID gid.GID,
) error {
query := `
WITH
snapshot_vendors AS (
SELECT id, source_id
FROM vendors
WHERE organization_id = @organization_id AND snapshot_id = @snapshot_id
)
INSERT INTO vendor_data_privacy_agreements (
tenant_id,
id,
snapshot_id,
source_id,
organization_id,
vendor_id,
valid_from,
valid_until,
file_id,
created_at,
updated_at
)
SELECT
@tenant_id,
generate_gid(decode_base64_unpadded(@tenant_id), @vendor_data_privacy_agreement_entity_type),
@snapshot_id,
vdpa.id,
vdpa.organization_id,
sv.id,
vdpa.valid_from,
vdpa.valid_until,
vdpa.file_id,
vdpa.created_at,
vdpa.updated_at
FROM vendor_data_privacy_agreements vdpa
INNER JOIN snapshot_vendors sv ON sv.source_id = vdpa.vendor_id
WHERE %s AND vdpa.snapshot_id IS NULL
`
query = fmt.Sprintf(query, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"tenant_id": scope.GetTenantID(),
"snapshot_id": snapshotID,
"organization_id": organizationID,
"vendor_data_privacy_agreement_entity_type": VendorDataPrivacyAgreementEntityType,
}
maps.Copy(args, scope.SQLArguments())
_, err := conn.Exec(ctx, query, args)
if err != nil {
return fmt.Errorf("cannot insert vendor data privacy agreement snapshots: %w", err)
}
return nil
}

View File

@@ -16,19 +16,16 @@ package coredata
import (
"github.com/jackc/pgx/v5"
"go.probo.inc/probo/pkg/gid"
)
type (
VendorFilter struct {
showOnTrustCenter *bool
snapshotID **gid.GID
}
)
func NewVendorFilter(snapshotID **gid.GID, showOnTrustCenter *bool) *VendorFilter {
func NewVendorFilter(showOnTrustCenter *bool) *VendorFilter {
return &VendorFilter{
snapshotID: snapshotID,
showOnTrustCenter: showOnTrustCenter,
}
}
@@ -42,17 +39,6 @@ func (f *VendorFilter) SQLArguments() pgx.StrictNamedArgs {
args["show_on_trust_center"] = nil
}
if f.snapshotID == nil {
args["has_snapshot_filter"] = false
args["filter_snapshot_id"] = nil
} else if *f.snapshotID == nil {
args["has_snapshot_filter"] = true
args["filter_snapshot_id"] = nil
} else {
args["has_snapshot_filter"] = true
args["filter_snapshot_id"] = **f.snapshotID
}
return args
}
@@ -64,14 +50,5 @@ func (f *VendorFilter) SQLFragment() string {
show_on_trust_center = @show_on_trust_center::boolean
ELSE TRUE
END
AND
CASE
WHEN @has_snapshot_filter::boolean = false THEN TRUE
WHEN @has_snapshot_filter::boolean = true AND @filter_snapshot_id::text IS NOT NULL THEN
snapshot_id = @filter_snapshot_id::text
WHEN @has_snapshot_filter::boolean = true AND @filter_snapshot_id::text IS NULL THEN
snapshot_id IS NULL
ELSE TRUE
END
)`
}

View File

@@ -37,8 +37,6 @@ type (
DataSensitivity DataSensitivity `db:"data_sensitivity"`
BusinessImpact BusinessImpact `db:"business_impact"`
Notes *string `db:"notes"`
SnapshotID *gid.GID `db:"snapshot_id"`
SourceID *gid.GID `db:"source_id"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
@@ -137,8 +135,6 @@ SELECT
data_sensitivity,
business_impact,
notes,
snapshot_id,
source_id,
created_at,
updated_at
FROM
@@ -186,8 +182,6 @@ SELECT
data_sensitivity,
business_impact,
notes,
snapshot_id,
source_id,
created_at,
updated_at
FROM
@@ -195,6 +189,7 @@ FROM
WHERE
%s
AND vendor_id = @vendor_id
AND snapshot_id IS NULL
ORDER BY
created_at DESC
LIMIT 1;
@@ -238,8 +233,6 @@ SELECT
data_sensitivity,
business_impact,
notes,
snapshot_id,
source_id,
created_at,
updated_at
FROM
@@ -247,6 +240,7 @@ FROM
WHERE
%s
AND vendor_id = @vendor_id
AND snapshot_id IS NULL
AND %s
`
@@ -271,66 +265,59 @@ WHERE
return nil
}
func (v VendorRiskAssessments) InsertVendorSnapshots(
func (r *VendorRiskAssessments) LoadByVendorIDs(
ctx context.Context,
conn pg.Tx,
conn pg.Querier,
scope Scoper,
organizationID gid.GID,
snapshotID gid.GID,
vendorIDs []gid.GID,
) error {
query := `
WITH
snapshot_vendors AS (
SELECT id, source_id
FROM vendors
WHERE organization_id = @organization_id AND snapshot_id = @snapshot_id
)
INSERT INTO vendor_risk_assessments (
tenant_id,
id,
snapshot_id,
source_id,
organization_id,
vendor_id,
expires_at,
data_sensitivity,
business_impact,
notes,
created_at,
updated_at
)
SELECT
@tenant_id,
generate_gid(decode_base64_unpadded(@tenant_id), @vendor_risk_assessment_entity_type),
@snapshot_id,
vra.id,
vra.organization_id,
sv.id,
vra.expires_at,
vra.data_sensitivity,
vra.business_impact,
vra.notes,
vra.created_at,
vra.updated_at
FROM vendor_risk_assessments vra
INNER JOIN snapshot_vendors sv ON sv.source_id = vra.vendor_id
WHERE %s AND vra.snapshot_id IS NULL
`
query = fmt.Sprintf(query, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"tenant_id": scope.GetTenantID(),
"snapshot_id": snapshotID,
"organization_id": organizationID,
"vendor_risk_assessment_entity_type": VendorRiskAssessmentEntityType,
if len(vendorIDs) == 0 {
*r = VendorRiskAssessments{}
return nil
}
q := `
SELECT
id,
organization_id,
vendor_id,
expires_at,
data_sensitivity,
business_impact,
notes,
created_at,
updated_at
FROM
vendor_risk_assessments
WHERE
%s
AND vendor_id = ANY(@vendor_ids)
AND snapshot_id IS NULL
ORDER BY
vendor_id, created_at DESC
`
q = fmt.Sprintf(q, scope.SQLFragment())
ids := make([]string, len(vendorIDs))
for i, id := range vendorIDs {
ids[i] = id.String()
}
args := pgx.StrictNamedArgs{"vendor_ids": ids}
maps.Copy(args, scope.SQLArguments())
_, err := conn.Exec(ctx, query, args)
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot insert vendor risk assessment snapshots: %w", err)
return fmt.Errorf("cannot query risk assessments: %w", err)
}
assessments, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[VendorRiskAssessment])
if err != nil {
return fmt.Errorf("cannot collect risk assessments: %w", err)
}
*r = assessments
return nil
}

View File

@@ -34,8 +34,6 @@ type (
VendorID gid.GID `db:"vendor_id"`
Name string `db:"name"`
Description *string `db:"description"`
SnapshotID *gid.GID `db:"snapshot_id"`
SourceID *gid.GID `db:"source_id"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
@@ -81,8 +79,6 @@ SELECT
vendor_id,
name,
description,
snapshot_id,
source_id,
created_at,
updated_at
FROM
@@ -132,8 +128,6 @@ SELECT
vendor_id,
name,
description,
snapshot_id,
source_id,
created_at,
updated_at
FROM
@@ -141,6 +135,7 @@ FROM
WHERE
%s
AND vendor_id = @vendor_id
AND snapshot_id IS NULL
AND %s
`
q = fmt.Sprintf(q, scope.SQLFragment(), cursor.SQLFragment())
@@ -167,6 +162,61 @@ WHERE
return nil
}
func (vs *VendorServices) LoadByVendorIDs(
ctx context.Context,
conn pg.Querier,
scope Scoper,
vendorIDs []gid.GID,
) error {
if len(vendorIDs) == 0 {
*vs = VendorServices{}
return nil
}
q := `
SELECT
id,
organization_id,
vendor_id,
name,
description,
created_at,
updated_at
FROM
vendor_services
WHERE
%s
AND vendor_id = ANY(@vendor_ids)
AND snapshot_id IS NULL
ORDER BY
vendor_id, name ASC
`
q = fmt.Sprintf(q, scope.SQLFragment())
ids := make([]string, len(vendorIDs))
for i, id := range vendorIDs {
ids[i] = id.String()
}
args := pgx.StrictNamedArgs{"vendor_ids": ids}
maps.Copy(args, scope.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query vendor services: %w", err)
}
defer rows.Close()
vendorServices, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[VendorService])
if err != nil {
return fmt.Errorf("cannot collect vendor services: %w", err)
}
*vs = vendorServices
return nil
}
func (vs VendorService) Insert(
ctx context.Context,
conn pg.Tx,
@@ -277,63 +327,3 @@ WHERE
return nil
}
func (vs VendorServices) InsertVendorSnapshots(
ctx context.Context,
conn pg.Tx,
scope Scoper,
organizationID gid.GID,
snapshotID gid.GID,
) error {
query := `
WITH
snapshot_vendors AS (
SELECT id, source_id
FROM vendors
WHERE organization_id = @organization_id AND snapshot_id = @snapshot_id
)
INSERT INTO vendor_services (
tenant_id,
id,
organization_id,
snapshot_id,
source_id,
vendor_id,
name,
description,
created_at,
updated_at
)
SELECT
@tenant_id,
generate_gid(decode_base64_unpadded(@tenant_id), @vendor_service_entity_type),
@organization_id,
@snapshot_id,
vs.id,
sv.id,
vs.name,
vs.description,
vs.created_at,
vs.updated_at
FROM vendor_services vs
INNER JOIN snapshot_vendors sv ON sv.source_id = vs.vendor_id
WHERE %s AND vs.snapshot_id IS NULL
`
query = fmt.Sprintf(query, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"tenant_id": scope.GetTenantID(),
"snapshot_id": snapshotID,
"organization_id": organizationID,
"vendor_service_entity_type": VendorServiceEntityType,
}
maps.Copy(args, scope.SQLArguments())
_, err := conn.Exec(ctx, query, args)
if err != nil {
return fmt.Errorf("cannot insert vendor service snapshots: %w", err)
}
return nil
}

View File

@@ -366,6 +366,73 @@ type (
LocalLawRisk string
SupplementaryMeasures string
}
VendorListData struct {
Title string
OrganizationName string
CreatedAt time.Time
TotalVendors int
Rows []VendorListRow
}
VendorListRow struct {
Name string
LegalName string
Description string
Category string
HeadquarterAddress string
WebsiteURL string
PrivacyPolicyURL string
ServiceLevelAgreementURL string
DataProcessingAgreementURL string
BusinessAssociateAgreementURL string
SubprocessorsListURL string
StatusPageURL string
TermsOfServiceURL string
SecurityPageURL string
TrustPageURL string
Certifications string
Countries string
BusinessOwner string
SecurityOwner string
Services []VendorListService
Contacts []VendorListContact
RiskAssessments []VendorListRiskAssessment
ComplianceReports []VendorListComplianceReport
BusinessAssociateAgreement *VendorListAgreement
DataPrivacyAgreement *VendorListAgreement
}
VendorListService struct {
Name string
Description string
}
VendorListContact struct {
FullName string
Email string
Phone string
Role string
}
VendorListRiskAssessment struct {
AssessedAt string
ExpiresAt string
DataSensitivity string
BusinessImpact string
Notes string
}
VendorListComplianceReport struct {
ReportName string
ReportDate string
ValidUntil string
}
VendorListAgreement struct {
ValidFrom string
ValidUntil string
}
)
func BoolLabel(v bool) string {

View File

@@ -84,12 +84,13 @@ const (
ActionTrustCenterFileCreate = "core:trust-center-file:create"
// Vendor actions
ActionVendorList = "core:vendor:list"
ActionVendorGet = "core:vendor:get"
ActionVendorCreate = "core:vendor:create"
ActionVendorUpdate = "core:vendor:update"
ActionVendorDelete = "core:vendor:delete"
ActionVendorAssess = "core:vendor:assess"
ActionVendorList = "core:vendor:list"
ActionVendorGet = "core:vendor:get"
ActionVendorCreate = "core:vendor:create"
ActionVendorUpdate = "core:vendor:update"
ActionVendorDelete = "core:vendor:delete"
ActionVendorAssess = "core:vendor:assess"
ActionVendorPublish = "core:vendor:publish"
// VendorContact actions
ActionVendorContactGet = "core:vendor-contact:get"

View File

@@ -2563,3 +2563,536 @@ func BuildTransferImpactAssessmentListDocument(data docgen.TransferImpactAssessm
}
return buf.String(), nil
}
func (s *GeneratedDocumentService) PublishVendorList(
ctx context.Context,
organizationID gid.GID,
approverIDs []gid.GID,
) (*coredata.Document, *coredata.DocumentVersion, error) {
// Phase 1: collect data and render the prosemirror document outside any
// write transaction. Both the bulk reads of vendors + sub-entities and the
// JSON template rendering are slow enough that holding write locks across
// them would needlessly block other writers.
var documentData docgen.VendorListData
err := s.svc.pg.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error {
organization := &coredata.Organization{}
if err := organization.LoadByID(ctx, conn, s.svc.scope, organizationID); err != nil {
return fmt.Errorf("cannot load organization: %w", err)
}
var err error
documentData, err = s.buildVendorListDocumentData(ctx, conn, organization)
if err != nil {
return fmt.Errorf("cannot build document data: %w", err)
}
return nil
})
if err != nil {
return nil, nil, err
}
prosemirrorJSON, err := BuildVendorListDocument(documentData)
if err != nil {
return nil, nil, fmt.Errorf("cannot build prosemirror document: %w", err)
}
// Phase 2: persist the document and version in a write transaction.
var (
document *coredata.Document
documentVersion *coredata.DocumentVersion
)
err = s.svc.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
now := time.Now()
vendor := coredata.Vendor{}
vendorDocumentID, err := vendor.GetGeneratedDocumentID(ctx, tx, organizationID)
if err != nil {
return fmt.Errorf("cannot query generated documents: %w", err)
}
var existingDoc *coredata.Document
if vendorDocumentID != nil {
doc := &coredata.Document{}
err = doc.LoadByID(ctx, tx, s.svc.scope, *vendorDocumentID)
if err != nil && !errors.Is(err, coredata.ErrResourceNotFound) {
return fmt.Errorf("cannot load vendor list document: %w", err)
}
if err == nil && doc.ArchivedAt == nil {
existingDoc = doc
} else {
if err := vendor.ClearGeneratedDocumentID(ctx, tx, []gid.GID{*vendorDocumentID}); err != nil {
return fmt.Errorf("cannot clear document reference: %w", err)
}
}
}
hasApprovers := len(approverIDs) > 0
if existingDoc == nil {
documentID := gid.New(s.svc.scope.GetTenantID(), coredata.DocumentEntityType)
document = &coredata.Document{
ID: documentID,
OrganizationID: organizationID,
WriteMode: coredata.DocumentWriteModeGenerated,
TrustCenterVisibility: coredata.TrustCenterVisibilityNone,
Status: coredata.DocumentStatusActive,
CreatedAt: now,
UpdatedAt: now,
}
if err := document.Insert(ctx, tx, s.svc.scope); err != nil {
return fmt.Errorf("cannot insert document: %w", err)
}
if err := vendor.UpsertGeneratedDocumentID(ctx, tx, organizationID, s.svc.scope.GetTenantID(), documentID); err != nil {
return fmt.Errorf("cannot upsert generated documents: %w", err)
}
} else {
document = existingDoc
}
var newMajor int
if document.CurrentPublishedMajor != nil {
newMajor = *document.CurrentPublishedMajor + 1
} else {
newMajor = 1
}
versionStatus := coredata.DocumentVersionStatusPublished
var publishedAt *time.Time
if hasApprovers {
versionStatus = coredata.DocumentVersionStatusDraft
} else {
publishedAt = &now
}
documentVersionID := gid.New(s.svc.scope.GetTenantID(), coredata.DocumentVersionEntityType)
documentVersion = &coredata.DocumentVersion{
ID: documentVersionID,
OrganizationID: organizationID,
DocumentID: document.ID,
Title: "Vendors",
Major: newMajor,
Minor: 0,
Content: prosemirrorJSON,
Status: versionStatus,
Classification: coredata.DocumentClassificationConfidential,
DocumentType: coredata.DocumentTypeRegister,
Orientation: coredata.DocumentVersionOrientationPortrait,
PublishedAt: publishedAt,
CreatedAt: now,
UpdatedAt: now,
}
if err := documentVersion.Insert(ctx, tx, s.svc.scope); err != nil {
if errors.Is(err, coredata.ErrResourceAlreadyExists) {
return fmt.Errorf("a version is pending approval, approve or reject it before publishing a new one: %w", err)
}
return fmt.Errorf("cannot insert document version: %w", err)
}
if hasApprovers {
defaultApprovers := &coredata.DocumentDefaultApprovers{}
if err := defaultApprovers.MergeByDocumentID(ctx, tx, s.svc.scope, document.ID, organizationID, approverIDs); err != nil {
return fmt.Errorf("cannot save default approvers: %w", err)
}
_, err := s.svc.DocumentApprovals.RequestApprovalInTx(
ctx,
tx,
document,
documentVersion,
approverIDs,
nil,
)
if err != nil {
return fmt.Errorf("cannot request approval: %w", err)
}
} else {
zero := 0
document.CurrentPublishedMajor = &newMajor
document.CurrentPublishedMinor = &zero
document.UpdatedAt = now
if err := document.Update(ctx, tx, s.svc.scope); err != nil {
return fmt.Errorf("cannot update document: %w", err)
}
}
return nil
},
)
if err != nil {
return nil, nil, err
}
return document, documentVersion, nil
}
func (s *GeneratedDocumentService) GetVendorsDocumentID(
ctx context.Context,
organizationID gid.GID,
) (*gid.GID, error) {
var documentID *gid.GID
err := s.svc.pg.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error {
vendor := coredata.Vendor{}
var err error
documentID, err = vendor.GetGeneratedDocumentID(ctx, conn, organizationID)
return err
})
if err != nil {
return nil, fmt.Errorf("cannot get vendor list document ID: %w", err)
}
return documentID, nil
}
func (s *GeneratedDocumentService) buildVendorListDocumentData(
ctx context.Context,
conn pg.Querier,
organization *coredata.Organization,
) (docgen.VendorListData, error) {
var vendors coredata.Vendors
if err := vendors.LoadAllByOrganizationID(ctx, conn, s.svc.scope, organization.ID); err != nil {
return docgen.VendorListData{}, fmt.Errorf("cannot load vendors: %w", err)
}
if len(vendors) == 0 {
return docgen.VendorListData{
Title: "Vendors",
OrganizationName: organization.Name,
CreatedAt: time.Now(),
TotalVendors: 0,
}, nil
}
ownerIDSet := make(map[gid.GID]struct{})
ownerIDs := make([]gid.GID, 0)
for _, v := range vendors {
if v.BusinessOwnerID != nil {
if _, ok := ownerIDSet[*v.BusinessOwnerID]; !ok {
ownerIDs = append(ownerIDs, *v.BusinessOwnerID)
ownerIDSet[*v.BusinessOwnerID] = struct{}{}
}
}
if v.SecurityOwnerID != nil {
if _, ok := ownerIDSet[*v.SecurityOwnerID]; !ok {
ownerIDs = append(ownerIDs, *v.SecurityOwnerID)
ownerIDSet[*v.SecurityOwnerID] = struct{}{}
}
}
}
profileMap := make(map[gid.GID]*coredata.MembershipProfile)
if len(ownerIDs) > 0 {
var profiles coredata.MembershipProfiles
if err := profiles.LoadByIDs(ctx, conn, s.svc.scope, ownerIDs); err != nil {
return docgen.VendorListData{}, fmt.Errorf("cannot load owner profiles: %w", err)
}
for _, p := range profiles {
profileMap[p.ID] = p
}
}
vendorIDs := make([]gid.GID, len(vendors))
for i, v := range vendors {
vendorIDs[i] = v.ID
}
var allServices coredata.VendorServices
if err := allServices.LoadByVendorIDs(ctx, conn, s.svc.scope, vendorIDs); err != nil {
return docgen.VendorListData{}, fmt.Errorf("cannot load vendor services: %w", err)
}
servicesByVendor := make(map[gid.GID]coredata.VendorServices, len(vendors))
for _, vs := range allServices {
servicesByVendor[vs.VendorID] = append(servicesByVendor[vs.VendorID], vs)
}
var allContacts coredata.VendorContacts
if err := allContacts.LoadByVendorIDs(ctx, conn, s.svc.scope, vendorIDs); err != nil {
return docgen.VendorListData{}, fmt.Errorf("cannot load vendor contacts: %w", err)
}
contactsByVendor := make(map[gid.GID]coredata.VendorContacts, len(vendors))
for _, c := range allContacts {
contactsByVendor[c.VendorID] = append(contactsByVendor[c.VendorID], c)
}
var allAssessments coredata.VendorRiskAssessments
if err := allAssessments.LoadByVendorIDs(ctx, conn, s.svc.scope, vendorIDs); err != nil {
return docgen.VendorListData{}, fmt.Errorf("cannot load vendor risk assessments: %w", err)
}
assessmentsByVendor := make(map[gid.GID]coredata.VendorRiskAssessments, len(vendors))
for _, ra := range allAssessments {
assessmentsByVendor[ra.VendorID] = append(assessmentsByVendor[ra.VendorID], ra)
}
var allReports coredata.VendorComplianceReports
if err := allReports.LoadByVendorIDs(ctx, conn, s.svc.scope, vendorIDs); err != nil {
return docgen.VendorListData{}, fmt.Errorf("cannot load vendor compliance reports: %w", err)
}
reportsByVendor := make(map[gid.GID]coredata.VendorComplianceReports, len(vendors))
for _, r := range allReports {
reportsByVendor[r.VendorID] = append(reportsByVendor[r.VendorID], r)
}
var allBAAs coredata.VendorBusinessAssociateAgreements
if err := allBAAs.LoadByVendorIDs(ctx, conn, s.svc.scope, vendorIDs); err != nil {
return docgen.VendorListData{}, fmt.Errorf("cannot load vendor business associate agreements: %w", err)
}
baaByVendor := make(map[gid.GID]*coredata.VendorBusinessAssociateAgreement, len(allBAAs))
for _, b := range allBAAs {
baaByVendor[b.VendorID] = b
}
var allDPAs coredata.VendorDataPrivacyAgreements
if err := allDPAs.LoadByVendorIDs(ctx, conn, s.svc.scope, vendorIDs); err != nil {
return docgen.VendorListData{}, fmt.Errorf("cannot load vendor data privacy agreements: %w", err)
}
dpaByVendor := make(map[gid.GID]*coredata.VendorDataPrivacyAgreement, len(allDPAs))
for _, d := range allDPAs {
dpaByVendor[d.VendorID] = d
}
rows := make([]docgen.VendorListRow, 0, len(vendors))
for _, v := range vendors {
row := docgen.VendorListRow{
Name: v.Name,
LegalName: derefStringOrNotSpecified(v.LegalName),
Description: derefStringOrNotSpecified(v.Description),
Category: formatVendorCategory(v.Category),
HeadquarterAddress: derefStringOrNotSpecified(v.HeadquarterAddress),
WebsiteURL: derefStringOrNotSpecified(v.WebsiteURL),
PrivacyPolicyURL: derefStringOrNotSpecified(v.PrivacyPolicyURL),
ServiceLevelAgreementURL: derefStringOrNotSpecified(v.ServiceLevelAgreementURL),
DataProcessingAgreementURL: derefStringOrNotSpecified(v.DataProcessingAgreementURL),
BusinessAssociateAgreementURL: derefStringOrNotSpecified(v.BusinessAssociateAgreementURL),
SubprocessorsListURL: derefStringOrNotSpecified(v.SubprocessorsListURL),
StatusPageURL: derefStringOrNotSpecified(v.StatusPageURL),
TermsOfServiceURL: derefStringOrNotSpecified(v.TermsOfServiceURL),
SecurityPageURL: derefStringOrNotSpecified(v.SecurityPageURL),
TrustPageURL: derefStringOrNotSpecified(v.TrustPageURL),
Certifications: joinOrNotSpecified(v.Certifications),
Countries: formatCountries(v.Countries),
BusinessOwner: lookupProfileName(profileMap, v.BusinessOwnerID),
SecurityOwner: lookupProfileName(profileMap, v.SecurityOwnerID),
}
for _, vs := range servicesByVendor[v.ID] {
row.Services = append(row.Services, docgen.VendorListService{
Name: vs.Name,
Description: derefStringOrNotSpecified(vs.Description),
})
}
for _, c := range contactsByVendor[v.ID] {
email := ""
if c.Email != nil {
email = c.Email.String()
}
row.Contacts = append(row.Contacts, docgen.VendorListContact{
FullName: derefStringOrNotSpecified(c.FullName),
Email: stringOrNotSpecified(email),
Phone: derefStringOrNotSpecified(c.Phone),
Role: derefStringOrNotSpecified(c.Role),
})
}
for _, ra := range assessmentsByVendor[v.ID] {
row.RiskAssessments = append(row.RiskAssessments, docgen.VendorListRiskAssessment{
AssessedAt: ra.CreatedAt.Format("2006-01-02"),
ExpiresAt: ra.ExpiresAt.Format("2006-01-02"),
DataSensitivity: formatDataSensitivity(ra.DataSensitivity),
BusinessImpact: formatBusinessImpact(ra.BusinessImpact),
Notes: derefStringOrNotSpecified(ra.Notes),
})
}
for _, r := range reportsByVendor[v.ID] {
row.ComplianceReports = append(row.ComplianceReports, docgen.VendorListComplianceReport{
ReportName: r.ReportName,
ReportDate: r.ReportDate.Format("2006-01-02"),
ValidUntil: formatTimeOrNotSpecified(r.ValidUntil),
})
}
if baa := baaByVendor[v.ID]; baa != nil {
row.BusinessAssociateAgreement = &docgen.VendorListAgreement{
ValidFrom: formatTimeOrNotSpecified(baa.ValidFrom),
ValidUntil: formatTimeOrNotSpecified(baa.ValidUntil),
}
}
if dpa := dpaByVendor[v.ID]; dpa != nil {
row.DataPrivacyAgreement = &docgen.VendorListAgreement{
ValidFrom: formatTimeOrNotSpecified(dpa.ValidFrom),
ValidUntil: formatTimeOrNotSpecified(dpa.ValidUntil),
}
}
rows = append(rows, row)
}
return docgen.VendorListData{
Title: "Vendors",
OrganizationName: organization.Name,
CreatedAt: time.Now(),
TotalVendors: len(vendors),
Rows: rows,
}, nil
}
func stringOrNotSpecified(s string) string {
if s == "" {
return "Not specified"
}
return s
}
func formatTimeOrNotSpecified(t *time.Time) string {
if t == nil {
return "Not specified"
}
return t.Format("2006-01-02")
}
func joinOrNotSpecified(items []string) string {
if len(items) == 0 {
return "Not specified"
}
return strings.Join(items, ", ")
}
func formatCountries(c coredata.CountryCodes) string {
if len(c) == 0 {
return "Not specified"
}
parts := make([]string, len(c))
for i, cc := range c {
parts[i] = string(cc)
}
return strings.Join(parts, ", ")
}
func lookupProfileName(profiles map[gid.GID]*coredata.MembershipProfile, id *gid.GID) string {
if id == nil {
return "Not assigned"
}
if p, ok := profiles[*id]; ok {
return p.FullName
}
return "Not assigned"
}
func formatDataSensitivity(s coredata.DataSensitivity) string {
switch s {
case coredata.DataSensitivityNone:
return "None"
case coredata.DataSensitivityLow:
return "Low"
case coredata.DataSensitivityMedium:
return "Medium"
case coredata.DataSensitivityHigh:
return "High"
case coredata.DataSensitivityCritical:
return "Critical"
default:
return string(s)
}
}
func formatBusinessImpact(b coredata.BusinessImpact) string {
switch b {
case coredata.BusinessImpactLow:
return "Low"
case coredata.BusinessImpactMedium:
return "Medium"
case coredata.BusinessImpactHigh:
return "High"
case coredata.BusinessImpactCritical:
return "Critical"
default:
return string(b)
}
}
func formatVendorCategory(c coredata.VendorCategory) string {
switch c {
case coredata.VendorCategoryAnalytics:
return "Analytics"
case coredata.VendorCategoryCloudMonitoring:
return "Cloud Monitoring"
case coredata.VendorCategoryCloudProvider:
return "Cloud Provider"
case coredata.VendorCategoryCollaboration:
return "Collaboration"
case coredata.VendorCategoryCustomerSupport:
return "Customer Support"
case coredata.VendorCategoryDataStorageAndProcessing:
return "Data Storage and Processing"
case coredata.VendorCategoryDocumentManagement:
return "Document Management"
case coredata.VendorCategoryEmployeeManagement:
return "Employee Management"
case coredata.VendorCategoryEngineering:
return "Engineering"
case coredata.VendorCategoryFinance:
return "Finance"
case coredata.VendorCategoryIdentityProvider:
return "Identity Provider"
case coredata.VendorCategoryIT:
return "IT"
case coredata.VendorCategoryMarketing:
return "Marketing"
case coredata.VendorCategoryOfficeOperations:
return "Office Operations"
case coredata.VendorCategoryOther:
return "Other"
case coredata.VendorCategoryPasswordManagement:
return "Password Management"
case coredata.VendorCategoryProductAndDesign:
return "Product and Design"
case coredata.VendorCategoryProfessionalServices:
return "Professional Services"
case coredata.VendorCategoryRecruiting:
return "Recruiting"
case coredata.VendorCategorySales:
return "Sales"
case coredata.VendorCategorySecurity:
return "Security"
case coredata.VendorCategoryVersionControl:
return "Version Control"
default:
return string(c)
}
}
var vendorListTemplate = template.Must(
template.New("vendor_list.json.tmpl").
Funcs(template.FuncMap{
"json": func(v any) (string, error) {
b, err := json.Marshal(v)
if err != nil {
return "", err
}
return string(b), nil
},
"printf": fmt.Sprintf,
"add": func(a, b int) int { return a + b },
}).
ParseFS(Templates, "templates/vendor_list.json.tmpl"),
)
func BuildVendorListDocument(data docgen.VendorListData) (string, error) {
var buf bytes.Buffer
if err := vendorListTemplate.Execute(&buf, data); err != nil {
return "", fmt.Errorf("cannot execute vendor list template: %w", err)
}
return buf.String(), nil
}

View File

@@ -0,0 +1,302 @@
{
"type": "doc",
"content": [
{
"type": "heading",
"attrs": { "level": 1 },
"content": [{ "type": "text", "text": "1. Purpose" }]
},
{
"type": "paragraph",
"content": [{ "type": "text", "text": "This document provides a comprehensive register of all vendors used by the organization. It captures vendor profile information, services consumed, contacts, risk assessments, compliance reports, and contractual agreements (BAA, DPA) for each vendor." }]
},
{ "type": "horizontalRule" },
{
"type": "heading",
"attrs": { "level": 1 },
"content": [{ "type": "text", "text": "2. Vendors" }]
}{{range $i, $r := .Rows}},
{
"type": "heading",
"attrs": { "level": 2 },
"content": [{ "type": "text", "text": {{json (printf "2.%d %s" (add $i 1) $r.Name)}} }]
},
{
"type": "heading",
"attrs": { "level": 3 },
"content": [{ "type": "text", "text": {{json (printf "2.%d.1 General Information" (add $i 1))}} }]
},
{
"type": "paragraph",
"content": [
{ "type": "text", "text": "Legal Name: ", "marks": [{ "type": "bold" }] },
{ "type": "text", "text": {{json $r.LegalName}} }
]
},
{
"type": "paragraph",
"content": [
{ "type": "text", "text": "Description: ", "marks": [{ "type": "bold" }] },
{ "type": "text", "text": {{json $r.Description}} }
]
},
{
"type": "paragraph",
"content": [
{ "type": "text", "text": "Category: ", "marks": [{ "type": "bold" }] },
{ "type": "text", "text": {{json $r.Category}} }
]
},
{
"type": "paragraph",
"content": [
{ "type": "text", "text": "Headquarter Address: ", "marks": [{ "type": "bold" }] },
{ "type": "text", "text": {{json $r.HeadquarterAddress}} }
]
},
{
"type": "paragraph",
"content": [
{ "type": "text", "text": "Countries: ", "marks": [{ "type": "bold" }] },
{ "type": "text", "text": {{json $r.Countries}} }
]
},
{
"type": "paragraph",
"content": [
{ "type": "text", "text": "Certifications: ", "marks": [{ "type": "bold" }] },
{ "type": "text", "text": {{json $r.Certifications}} }
]
},
{
"type": "heading",
"attrs": { "level": 3 },
"content": [{ "type": "text", "text": {{json (printf "2.%d.2 URLs & Pages" (add $i 1))}} }]
},
{
"type": "paragraph",
"content": [
{ "type": "text", "text": "Website: ", "marks": [{ "type": "bold" }] },
{ "type": "text", "text": {{json $r.WebsiteURL}} }
]
},
{
"type": "paragraph",
"content": [
{ "type": "text", "text": "Privacy Policy: ", "marks": [{ "type": "bold" }] },
{ "type": "text", "text": {{json $r.PrivacyPolicyURL}} }
]
},
{
"type": "paragraph",
"content": [
{ "type": "text", "text": "Service Level Agreement: ", "marks": [{ "type": "bold" }] },
{ "type": "text", "text": {{json $r.ServiceLevelAgreementURL}} }
]
},
{
"type": "paragraph",
"content": [
{ "type": "text", "text": "Data Processing Agreement: ", "marks": [{ "type": "bold" }] },
{ "type": "text", "text": {{json $r.DataProcessingAgreementURL}} }
]
},
{
"type": "paragraph",
"content": [
{ "type": "text", "text": "Business Associate Agreement: ", "marks": [{ "type": "bold" }] },
{ "type": "text", "text": {{json $r.BusinessAssociateAgreementURL}} }
]
},
{
"type": "paragraph",
"content": [
{ "type": "text", "text": "Subprocessors List: ", "marks": [{ "type": "bold" }] },
{ "type": "text", "text": {{json $r.SubprocessorsListURL}} }
]
},
{
"type": "paragraph",
"content": [
{ "type": "text", "text": "Status Page: ", "marks": [{ "type": "bold" }] },
{ "type": "text", "text": {{json $r.StatusPageURL}} }
]
},
{
"type": "paragraph",
"content": [
{ "type": "text", "text": "Terms of Service: ", "marks": [{ "type": "bold" }] },
{ "type": "text", "text": {{json $r.TermsOfServiceURL}} }
]
},
{
"type": "paragraph",
"content": [
{ "type": "text", "text": "Security Page: ", "marks": [{ "type": "bold" }] },
{ "type": "text", "text": {{json $r.SecurityPageURL}} }
]
},
{
"type": "paragraph",
"content": [
{ "type": "text", "text": "Trust Page: ", "marks": [{ "type": "bold" }] },
{ "type": "text", "text": {{json $r.TrustPageURL}} }
]
},
{
"type": "heading",
"attrs": { "level": 3 },
"content": [{ "type": "text", "text": {{json (printf "2.%d.3 Owners" (add $i 1))}} }]
},
{
"type": "paragraph",
"content": [
{ "type": "text", "text": "Business Owner: ", "marks": [{ "type": "bold" }] },
{ "type": "text", "text": {{json $r.BusinessOwner}} }
]
},
{
"type": "paragraph",
"content": [
{ "type": "text", "text": "Security Owner: ", "marks": [{ "type": "bold" }] },
{ "type": "text", "text": {{json $r.SecurityOwner}} }
]
},
{
"type": "heading",
"attrs": { "level": 3 },
"content": [{ "type": "text", "text": {{json (printf "2.%d.4 Services" (add $i 1))}} }]
}{{if $r.Services}}{{range $r.Services}},
{
"type": "paragraph",
"content": [
{ "type": "text", "text": {{json (printf "%s — " .Name)}}, "marks": [{ "type": "bold" }] },
{ "type": "text", "text": {{json .Description}} }
]
}{{end}}{{else}},
{
"type": "paragraph",
"content": [{ "type": "text", "text": "No services recorded." }]
}{{end}},
{
"type": "heading",
"attrs": { "level": 3 },
"content": [{ "type": "text", "text": {{json (printf "2.%d.5 Contacts" (add $i 1))}} }]
}{{if $r.Contacts}}{{range $r.Contacts}},
{
"type": "paragraph",
"content": [
{ "type": "text", "text": {{json (printf "%s — " .FullName)}}, "marks": [{ "type": "bold" }] },
{ "type": "text", "text": {{json (printf "%s, %s, %s" .Role .Email .Phone)}} }
]
}{{end}}{{else}},
{
"type": "paragraph",
"content": [{ "type": "text", "text": "No contacts recorded." }]
}{{end}},
{
"type": "heading",
"attrs": { "level": 3 },
"content": [{ "type": "text", "text": {{json (printf "2.%d.6 Risk Assessments" (add $i 1))}} }]
}{{if $r.RiskAssessments}}{{range $r.RiskAssessments}},
{
"type": "paragraph",
"content": [
{ "type": "text", "text": "Assessed on: ", "marks": [{ "type": "bold" }] },
{ "type": "text", "text": {{json .AssessedAt}} },
{ "type": "text", "text": " · Expires on: ", "marks": [{ "type": "bold" }] },
{ "type": "text", "text": {{json .ExpiresAt}} },
{ "type": "text", "text": " · Data Sensitivity: ", "marks": [{ "type": "bold" }] },
{ "type": "text", "text": {{json .DataSensitivity}} },
{ "type": "text", "text": " · Business Impact: ", "marks": [{ "type": "bold" }] },
{ "type": "text", "text": {{json .BusinessImpact}} }
]
},
{
"type": "paragraph",
"content": [
{ "type": "text", "text": "Notes: ", "marks": [{ "type": "bold" }] },
{ "type": "text", "text": {{json .Notes}} }
]
}{{end}}{{else}},
{
"type": "paragraph",
"content": [{ "type": "text", "text": "No risk assessments recorded." }]
}{{end}},
{
"type": "heading",
"attrs": { "level": 3 },
"content": [{ "type": "text", "text": {{json (printf "2.%d.7 Compliance Reports" (add $i 1))}} }]
}{{if $r.ComplianceReports}}{{range $r.ComplianceReports}},
{
"type": "paragraph",
"content": [
{ "type": "text", "text": {{json (printf "%s — " .ReportName)}}, "marks": [{ "type": "bold" }] },
{ "type": "text", "text": {{json (printf "Date: %s · Valid Until: %s" .ReportDate .ValidUntil)}} }
]
}{{end}}{{else}},
{
"type": "paragraph",
"content": [{ "type": "text", "text": "No compliance reports recorded." }]
}{{end}},
{
"type": "heading",
"attrs": { "level": 3 },
"content": [{ "type": "text", "text": {{json (printf "2.%d.8 Agreements" (add $i 1))}} }]
},
{
"type": "paragraph",
"content": [
{ "type": "text", "text": "Business Associate Agreement: ", "marks": [{ "type": "bold" }] },
{ "type": "text", "text": {{if $r.BusinessAssociateAgreement}}{{json (printf "Yes — From %s until %s" $r.BusinessAssociateAgreement.ValidFrom $r.BusinessAssociateAgreement.ValidUntil)}}{{else}}"No"{{end}} }
]
},
{
"type": "paragraph",
"content": [
{ "type": "text", "text": "Data Privacy Agreement: ", "marks": [{ "type": "bold" }] },
{ "type": "text", "text": {{if $r.DataPrivacyAgreement}}{{json (printf "Yes — From %s until %s" $r.DataPrivacyAgreement.ValidFrom $r.DataPrivacyAgreement.ValidUntil)}}{{else}}"No"{{end}} }
]
}{{end}},
{ "type": "horizontalRule" },
{
"type": "heading",
"attrs": { "level": 1 },
"content": [{ "type": "text", "text": "3. Annexes" }]
},
{
"type": "heading",
"attrs": { "level": 2 },
"content": [{ "type": "text", "text": "3.1 Lexicon" }]
},
{
"type": "heading",
"attrs": { "level": 3 },
"content": [{ "type": "text", "text": "Data Sensitivity" }]
},
{
"type": "bulletList",
"content": [
{ "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "None: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "No sensitive data is shared with the vendor." }] }] },
{ "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Low: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "The vendor processes low sensitivity data such as public information." }] }] },
{ "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Medium: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "The vendor processes medium sensitivity data such as internal business data." }] }] },
{ "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "High: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "The vendor processes high sensitivity data such as personal data, financial data, or trade secrets." }] }] },
{ "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Critical: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "The vendor processes the most sensitive categories of data, where unauthorized disclosure would cause severe harm." }] }] }
]
},
{
"type": "heading",
"attrs": { "level": 3 },
"content": [{ "type": "text", "text": "Business Impact" }]
},
{
"type": "bulletList",
"content": [
{ "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Low: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "Limited disruption to operations if the vendor service is unavailable." }] }] },
{ "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Medium: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "Significant disruption to operations if the vendor service is unavailable." }] }] },
{ "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "High: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "Severe disruption or outage if the vendor service is unavailable." }] }] },
{ "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Critical: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "Operations cannot continue if the vendor service is unavailable; immediate business-wide impact." }] }] }
]
}
]
}

View File

@@ -329,9 +329,10 @@ type Organization implements Node {
last: Int
before: CursorKey
orderBy: VendorOrder
filter: VendorFilter = { snapshotId: null }
): VendorConnection! @goField(forceResolver: true)
vendorsDocument: Document @goField(forceResolver: true)
webhookSubscriptions(
first: Int
after: CursorKey

View File

@@ -1,16 +1,6 @@
enum SnapshotsType
@goModel(model: "go.probo.inc/probo/pkg/coredata.SnapshotsType") {
RISKS @goEnum(value: "go.probo.inc/probo/pkg/coredata.SnapshotsTypeRisks")
VENDORS
@goEnum(value: "go.probo.inc/probo/pkg/coredata.SnapshotsTypeVendors")
PROCESSING_ACTIVITIES
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.SnapshotsTypeProcessingActivities"
)
STATEMENTS_OF_APPLICABILITY
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.SnapshotsTypeStatementsOfApplicability"
)
}
enum SnapshotOrderField

View File

@@ -206,13 +206,8 @@ input VendorRiskAssessmentOrder {
direction: OrderDirection!
}
input VendorFilter {
snapshotId: ID
}
type Vendor implements Node {
id: ID!
snapshotId: ID
name: String!
category: VendorCategory!
description: String
@@ -462,6 +457,19 @@ extend type Mutation {
input: CreateVendorRiskAssessmentInput!
): CreateVendorRiskAssessmentPayload!
assessVendor(input: AssessVendorInput!): AssessVendorPayload!
publishVendorList(
input: PublishVendorListInput!
): PublishVendorListPayload!
}
input PublishVendorListInput {
organizationId: ID!
approverIds: [ID!]
}
type PublishVendorListPayload {
documentEdge: DocumentEdge!
documentVersionEdge: DocumentVersionEdge!
}
input CreateVendorInput {

View File

@@ -1210,7 +1210,7 @@ func (r *organizationResolver) CookieBanners(ctx context.Context, obj *types.Org
}
// Vendors is the resolver for the vendors field.
func (r *organizationResolver) Vendors(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.VendorOrderBy, filter *types.VendorFilter) (*types.VendorConnection, error) {
func (r *organizationResolver) Vendors(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.VendorOrderBy) (*types.VendorConnection, error) {
if err := r.authorize(ctx, obj.ID, probo.ActionVendorList); err != nil {
return nil, err
}
@@ -1230,10 +1230,7 @@ func (r *organizationResolver) Vendors(ctx context.Context, obj *types.Organizat
cursor := types.NewCursor(first, after, last, before, pageOrderBy)
var vendorFilter = coredata.NewVendorFilter(nil, nil)
if filter != nil {
vendorFilter = coredata.NewVendorFilter(&filter.SnapshotID, nil)
}
vendorFilter := coredata.NewVendorFilter(nil)
page, err := prb.Vendors.ListForOrganizationID(ctx, obj.ID, cursor, vendorFilter)
if err != nil {
@@ -1244,6 +1241,35 @@ func (r *organizationResolver) Vendors(ctx context.Context, obj *types.Organizat
return types.NewVendorConnection(page, r, obj.ID), nil
}
// VendorsDocument is the resolver for the vendorsDocument field.
func (r *organizationResolver) VendorsDocument(ctx context.Context, obj *types.Organization) (*types.Document, error) {
if err := r.authorize(ctx, obj.ID, probo.ActionDocumentGet); err != nil {
return nil, err
}
prb := r.ProboService(ctx, obj.ID.TenantID())
documentID, err := prb.GeneratedDocuments.GetVendorsDocumentID(ctx, obj.ID)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot get vendors document ID", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
if documentID == nil {
return nil, nil
}
document, err := prb.Documents.Get(ctx, *documentID)
if err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return nil, nil
}
r.logger.ErrorCtx(ctx, "cannot load vendors document", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return types.NewDocument(document), nil
}
// WebhookSubscriptions is the resolver for the webhookSubscriptions field.
func (r *organizationResolver) WebhookSubscriptions(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.WebhookSubscriptionOrderBy) (*types.WebhookSubscriptionConnection, error) {
if err := r.authorize(ctx, obj.ID, probo.ActionWebhookSubscriptionList); err != nil {

View File

@@ -84,7 +84,6 @@ func NewVendor(v *coredata.Vendor) *Vendor {
WebsiteURL: v.WebsiteURL,
Category: v.Category,
ShowOnTrustCenter: v.ShowOnTrustCenter,
SnapshotID: v.SnapshotID,
Countries: v.Countries,
UpdatedAt: v.UpdatedAt,
CreatedAt: v.CreatedAt,

View File

@@ -565,6 +565,29 @@ func (r *mutationResolver) AssessVendor(ctx context.Context, input types.AssessV
}, nil
}
// PublishVendorList is the resolver for the publishVendorList field.
func (r *mutationResolver) PublishVendorList(ctx context.Context, input types.PublishVendorListInput) (*types.PublishVendorListPayload, error) {
if err := r.authorize(ctx, input.OrganizationID, probo.ActionVendorPublish); err != nil {
return nil, err
}
prb := r.ProboService(ctx, input.OrganizationID.TenantID())
document, documentVersion, err := prb.GeneratedDocuments.PublishVendorList(ctx, input.OrganizationID, input.ApproverIds)
if err != nil {
if errors.Is(err, coredata.ErrResourceAlreadyExists) {
return nil, gqlutils.Conflict(ctx, err)
}
r.logger.ErrorCtx(ctx, "cannot publish vendor list", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return &types.PublishVendorListPayload{
DocumentEdge: types.NewDocumentEdge(document, coredata.DocumentOrderFieldCreatedAt),
DocumentVersionEdge: types.NewDocumentVersionEdge(documentVersion, coredata.DocumentVersionOrderFieldCreatedAt),
}, nil
}
// Organization is the resolver for the organization field.
func (r *vendorResolver) Organization(ctx context.Context, obj *types.Vendor) (*types.Organization, error) {
if err := r.authorize(ctx, obj.ID, probo.ActionOrganizationGet); err != nil {

View File

@@ -63,11 +63,7 @@ func (r *Resolver) ListVendorsTool(ctx context.Context, req *mcp.CallToolRequest
cursor := types.NewCursor(input.Size, input.Cursor, pageOrderBy)
noSnapshot := (*gid.GID)(nil)
vendorFilter := coredata.NewVendorFilter(&noSnapshot, nil)
if input.Filter != nil {
vendorFilter = coredata.NewVendorFilter(&input.Filter.SnapshotID, nil)
}
vendorFilter := coredata.NewVendorFilter(nil)
page, err := prb.Vendors.ListForOrganizationID(ctx, input.OrganizationID, cursor, vendorFilter)
if err != nil {
@@ -4840,3 +4836,19 @@ func (r *Resolver) PublishTransferImpactAssessmentListTool(ctx context.Context,
DocumentVersionID: documentVersion.ID,
}, nil
}
func (r *Resolver) PublishVendorListTool(ctx context.Context, req *mcp.CallToolRequest, input *types.PublishVendorListInput) (*mcp.CallToolResult, types.PublishVendorListOutput, error) {
r.MustAuthorize(ctx, input.OrganizationID, probo.ActionVendorPublish)
svc := r.ProboService(ctx, input.OrganizationID)
document, documentVersion, err := svc.GeneratedDocuments.PublishVendorList(ctx, input.OrganizationID, input.ApproverIds)
if err != nil {
return nil, types.PublishVendorListOutput{}, fmt.Errorf("cannot publish vendor list: %w", err)
}
return nil, types.PublishVendorListOutput{
DocumentID: document.ID,
DocumentVersionID: documentVersion.ID,
}, nil
}

View File

@@ -362,15 +362,6 @@ components:
cursor:
$ref: "#/components/schemas/CursorKey"
description: Page cursor
filter:
type: object
properties:
snapshot_id:
anyOf:
- $ref: "#/components/schemas/GID"
- type: "null"
description: Filter by snapshot ID. Defaults to null, which returns only vendors with no snapshot (current live data). Pass a specific snapshot ID to retrieve vendors as they were at that snapshot.
default: null
ListVendorsOutput:
type: object
@@ -560,11 +551,6 @@ components:
- string
- "null"
description: Notes
snapshot_id:
anyOf:
- $ref: "#/components/schemas/GID"
- type: "null"
description: Snapshot ID
created_at:
type: string
format: date-time
@@ -5372,7 +5358,6 @@ components:
type: string
enum:
- RISKS
- VENDORS
- NONCONFORMITIES
- OBLIGATIONS
- CONTINUAL_IMPROVEMENTS
@@ -7119,6 +7104,33 @@ components:
$ref: "#/components/schemas/GID"
description: Created document version ID
PublishVendorListInput:
type: object
required:
- organization_id
properties:
organization_id:
$ref: "#/components/schemas/GID"
description: Organization ID
approver_ids:
type: array
items:
$ref: "#/components/schemas/GID"
description: Optional approver profile IDs. If provided, creates a draft pending approval instead of publishing immediately.
PublishVendorListOutput:
type: object
required:
- document_id
- document_version_id
properties:
document_id:
$ref: "#/components/schemas/GID"
description: Created or updated document ID
document_version_id:
$ref: "#/components/schemas/GID"
description: Created document version ID
PublishStatementOfApplicabilityInput:
type: object
required:
@@ -10396,6 +10408,14 @@ tools:
$ref: "#/components/schemas/PublishTransferImpactAssessmentListInput"
outputSchema:
$ref: "#/components/schemas/PublishTransferImpactAssessmentListOutput"
- name: publishVendorList
description: Publish the vendor register for an organization as a document. If a document already exists, a new version is created.
hints:
readonly: false
inputSchema:
$ref: "#/components/schemas/PublishVendorListInput"
outputSchema:
$ref: "#/components/schemas/PublishVendorListOutput"
- name: publishStatementOfApplicability
description: Publish a statement of applicability as a document. If a document already exists, a new version is created.
hints:

View File

@@ -29,7 +29,6 @@ func NewVendorRiskAssessment(v *coredata.VendorRiskAssessment) *VendorRiskAssess
DataSensitivity: v.DataSensitivity,
BusinessImpact: v.BusinessImpact,
Notes: v.Notes,
SnapshotID: v.SnapshotID,
CreatedAt: v.CreatedAt,
UpdatedAt: v.UpdatedAt,
}

View File

@@ -64,8 +64,7 @@ func (s VendorService) ListForOrganizationId(
ctx,
func(ctx context.Context, conn pg.Querier) error {
showOnTrustCenter := true
var nilSnapshotID *gid.GID = nil
filter := coredata.NewVendorFilter(&nilSnapshotID, &showOnTrustCenter)
filter := coredata.NewVendorFilter(&showOnTrustCenter)
err := vendors.LoadByOrganizationID(ctx, conn, s.svc.scope, organizationID, cursor, filter)
if err != nil {
@@ -99,8 +98,7 @@ func (s VendorService) CountForTrustCenterId(
vendors := &coredata.Vendors{}
showOnTrustCenter := true
var nilSnapshotID *gid.GID = nil
filter := coredata.NewVendorFilter(&nilSnapshotID, &showOnTrustCenter)
filter := coredata.NewVendorFilter(&showOnTrustCenter)
count, err = vendors.CountByOrganizationID(ctx, conn, s.svc.scope, trustCenter.OrganizationID, filter)
if err != nil {
return fmt.Errorf("cannot count vendors: %w", err)