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 vendorConnectionKey = "VendorsPage_vendors";
export const vendorsQuery = graphql` export const vendorsQuery = graphql`
query VendorGraphListQuery($organizationId: ID!, $snapshotId: ID) { query VendorGraphListQuery($organizationId: ID!) {
node(id: $organizationId) { node(id: $organizationId) {
... on Organization { ... on Organization {
id id
canCreateVendor: permission(action: "core:vendor:create") 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 } after: { type: "CursorKey", defaultValue: null }
before: { type: "CursorKey", defaultValue: null } before: { type: "CursorKey", defaultValue: null }
last: { type: "Int", defaultValue: null } last: { type: "Int", defaultValue: null }
snapshotId: { type: "ID", defaultValue: null }
) { ) {
vendors( vendors(
first: $first first: $first
@@ -137,13 +145,11 @@ export const paginatedVendorsFragment = graphql`
last: $last last: $last
before: $before before: $before
orderBy: $order orderBy: $order
filter: { snapshotId: $snapshotId } ) @connection(key: "VendorsListQuery_vendors") {
) @connection(key: "VendorsListQuery_vendors", filters: ["filter"]) {
__id __id
edges { edges {
node { node {
id id
snapshotId
name name
websiteUrl websiteUrl
updatedAt updatedAt
@@ -174,7 +180,6 @@ export const vendorNodeQuery = graphql`
node(id: $vendorId) { node(id: $vendorId) {
id id
... on Vendor { ... on Vendor {
snapshotId
name name
websiteUrl websiteUrl
canAssess: permission(action: "core:vendor:assess") 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 // OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE. // PERFORMANCE OF THIS SOFTWARE.
import { faviconUrl, validateSnapshotConsistency } from "@probo/helpers"; import { faviconUrl } from "@probo/helpers";
import { useTranslate } from "@probo/i18n"; import { useTranslate } from "@probo/i18n";
import { import {
ActionDropdown, ActionDropdown,
@@ -31,11 +31,10 @@ import {
useFragment, useFragment,
usePreloadedQuery, usePreloadedQuery,
} from "react-relay"; } 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 { VendorComplianceTabFragment$key } from "#/__generated__/core/VendorComplianceTabFragment.graphql";
import type { VendorGraphNodeQuery } from "#/__generated__/core/VendorGraphNodeQuery.graphql"; import type { VendorGraphNodeQuery } from "#/__generated__/core/VendorGraphNodeQuery.graphql";
import { SnapshotBanner } from "#/components/SnapshotBanner";
import { import {
useDeleteVendor, useDeleteVendor,
vendorConnectionKey, vendorConnectionKey,
@@ -54,10 +53,7 @@ export default function VendorDetailPage(props: Props) {
const { node: vendor } = usePreloadedQuery(vendorNodeQuery, props.queryRef); const { node: vendor } = usePreloadedQuery(vendorNodeQuery, props.queryRef);
const { __ } = useTranslate(); const { __ } = useTranslate();
const organizationId = useOrganizationId(); const organizationId = useOrganizationId();
const { snapshotId } = useParams<{ snapshotId?: string }>();
const isSnapshotMode = Boolean(snapshotId);
validateSnapshotConsistency(vendor, snapshotId);
const deleteVendor = useDeleteVendor( const deleteVendor = useDeleteVendor(
vendor, vendor,
ConnectionHandler.getConnectionID(organizationId, vendorConnectionKey), ConnectionHandler.getConnectionID(organizationId, vendorConnectionKey),
@@ -68,19 +64,13 @@ export default function VendorDetailPage(props: Props) {
vendor as VendorComplianceTabFragment$key, vendor as VendorComplianceTabFragment$key,
).complianceReports.edges.length; ).complianceReports.edges.length;
const vendorsUrl const vendorsUrl = `/organizations/${organizationId}/vendors`;
= isSnapshotMode && snapshotId
? `/organizations/${organizationId}/snapshots/${snapshotId}/vendors`
: `/organizations/${organizationId}/vendors`;
const baseVendorUrl const baseVendorUrl
= isSnapshotMode && snapshotId = `/organizations/${organizationId}/vendors/${vendor.id}`;
? `/organizations/${organizationId}/snapshots/${snapshotId}/vendors/${vendor.id}`
: `/organizations/${organizationId}/vendors/${vendor.id}`;
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{snapshotId && <SnapshotBanner snapshotId={snapshotId} />}
<Breadcrumb <Breadcrumb
items={[ items={[
{ {
@@ -103,28 +93,26 @@ export default function VendorDetailPage(props: Props) {
)} )}
<div className="text-2xl">{vendor.name}</div> <div className="text-2xl">{vendor.name}</div>
</div> </div>
{!isSnapshotMode && ( <div className="flex gap-2 items-center">
<div className="flex gap-2 items-center"> {vendor.canAssess && (
{vendor.canAssess && ( <ImportAssessmentDialog vendorId={vendor.id}>
<ImportAssessmentDialog vendorId={vendor.id}> <Button icon={IconPageTextLine} variant="secondary">
<Button icon={IconPageTextLine} variant="secondary"> {__("Assessment From Website")}
{__("Assessment From Website")} </Button>
</Button> </ImportAssessmentDialog>
</ImportAssessmentDialog> )}
)} {vendor.canDelete && (
{vendor.canDelete && ( <ActionDropdown variant="secondary">
<ActionDropdown variant="secondary"> <DropdownItem
<DropdownItem variant="danger"
variant="danger" icon={IconTrashCan}
icon={IconTrashCan} onClick={deleteVendor}
onClick={deleteVendor} >
> {__("Delete")}
{__("Delete")} </DropdownItem>
</DropdownItem> </ActionDropdown>
</ActionDropdown> )}
)} </div>
</div>
)}
</div> </div>
<Tabs> <Tabs>

View File

@@ -20,8 +20,10 @@ import {
Avatar, Avatar,
Button, Button,
DropdownItem, DropdownItem,
IconPageTextLine,
IconPlusLarge, IconPlusLarge,
IconTrashCan, IconTrashCan,
IconUpload,
PageHeader, PageHeader,
RiskBadge, RiskBadge,
Tbody, Tbody,
@@ -35,14 +37,13 @@ import {
usePaginationFragment, usePaginationFragment,
usePreloadedQuery, usePreloadedQuery,
} from "react-relay"; } from "react-relay";
import { useParams } from "react-router"; import { useNavigate } from "react-router";
import type { VendorGraphListQuery } from "#/__generated__/core/VendorGraphListQuery.graphql"; import type { VendorGraphListQuery } from "#/__generated__/core/VendorGraphListQuery.graphql";
import type { import type {
VendorGraphPaginatedFragment$data, VendorGraphPaginatedFragment$data,
VendorGraphPaginatedFragment$key, VendorGraphPaginatedFragment$key,
} from "#/__generated__/core/VendorGraphPaginatedFragment.graphql"; } from "#/__generated__/core/VendorGraphPaginatedFragment.graphql";
import { SnapshotBanner } from "#/components/SnapshotBanner";
import { SortableTable, SortableTh } from "#/components/SortableTable"; import { SortableTable, SortableTh } from "#/components/SortableTable";
import { import {
paginatedVendorsFragment, paginatedVendorsFragment,
@@ -53,6 +54,7 @@ import { useOrganizationId } from "#/hooks/useOrganizationId";
import type { NodeOf } from "#/types"; import type { NodeOf } from "#/types";
import { CreateVendorDialog } from "./dialogs/CreateVendorDialog"; import { CreateVendorDialog } from "./dialogs/CreateVendorDialog";
import { PublishVendorListDialog } from "./dialogs/PublishVendorListDialog";
type Vendor = NodeOf<VendorGraphPaginatedFragment$data["vendors"]>; type Vendor = NodeOf<VendorGraphPaginatedFragment$data["vendors"]>;
@@ -63,8 +65,7 @@ type Props = {
export default function VendorsPage(props: Props) { export default function VendorsPage(props: Props) {
const { __ } = useTranslate(); const { __ } = useTranslate();
const organizationId = useOrganizationId(); const organizationId = useOrganizationId();
const { snapshotId } = useParams<{ snapshotId?: string }>(); const navigate = useNavigate();
const isSnapshotMode = Boolean(snapshotId);
const data = usePreloadedQuery(vendorsQuery, props.queryRef); const data = usePreloadedQuery(vendorsQuery, props.queryRef);
// eslint-disable-next-line relay/generated-typescript-types // eslint-disable-next-line relay/generated-typescript-types
@@ -79,26 +80,54 @@ export default function VendorsPage(props: Props) {
usePageTitle(__("Vendors")); usePageTitle(__("Vendors"));
const hasAnyAction 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 ( return (
<div className="space-y-6"> <div className="space-y-6">
{snapshotId && <SnapshotBanner snapshotId={snapshotId} />}
<PageHeader <PageHeader
title={__("Vendors")} title={__("Vendors")}
description={__( description={__(
"Vendors are third-party services that your company uses. Add them to keep track of their risk and compliance status.", "Vendors are third-party services that your company uses. Add them to keep track of their risk and compliance status.",
)} )}
> >
{!isSnapshotMode && data.node.canCreateVendor && ( <div className="flex gap-2">
<CreateVendorDialog {vendorsDocument && (
connection={connectionId} <Button
organizationId={organizationId} variant="secondary"
> icon={IconPageTextLine}
<Button icon={IconPlusLarge}>{__("Add vendor")}</Button> onClick={() => void navigate(
</CreateVendorDialog> `/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> </PageHeader>
<SortableTable {...pagination}> <SortableTable {...pagination}>
<Thead> <Thead>
@@ -137,16 +166,11 @@ function VendorRow({
connectionId: string; connectionId: string;
hasAnyAction: boolean; hasAnyAction: boolean;
}) { }) {
const { snapshotId } = useParams<{ snapshotId?: string }>();
const isSnapshotMode = Boolean(snapshotId);
const { __ } = useTranslate(); const { __ } = useTranslate();
const latestAssessment = vendor.riskAssessments?.edges[0]?.node; const latestAssessment = vendor.riskAssessments?.edges[0]?.node;
const deleteVendor = useDeleteVendor(vendor, connectionId); const deleteVendor = useDeleteVendor(vendor, connectionId);
const vendorUrl const vendorUrl = `/organizations/${organizationId}/vendors/${vendor.id}/overview`;
= isSnapshotMode && snapshotId
? `/organizations/${organizationId}/snapshots/${snapshotId}/vendors/${vendor.id}/overview`
: `/organizations/${organizationId}/vendors/${vendor.id}/overview`;
return ( 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 }) => loader: loaderFromQueryLoader(({ organizationId }) =>
loadQuery<VendorGraphListQuery>(coreEnvironment, vendorsQuery, { loadQuery<VendorGraphListQuery>(coreEnvironment, vendorsQuery, {
organizationId: organizationId, 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( 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[]; ] 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{ "input": map[string]any{
"organizationId": owner.GetOrganizationID().String(), "organizationId": owner.GetOrganizationID().String(),
"name": fmt.Sprintf("Snapshot to Delete %d", time.Now().UnixNano()), "name": fmt.Sprintf("Snapshot to Delete %d", time.Now().UnixNano()),
"type": "VENDORS", "type": "RISKS",
}, },
}, &createResult) }, &createResult)
require.NoError(t, err) require.NoError(t, err)
@@ -140,7 +140,7 @@ func TestSnapshot_List(t *testing.T) {
owner := testutil.NewClient(t, testutil.RoleOwner) owner := testutil.NewClient(t, testutil.RoleOwner)
// Create multiple snapshots // Create multiple snapshots
snapshotTypes := []string{"RISKS", "VENDORS"} snapshotTypes := []string{"RISKS"}
for i, snapshotType := range snapshotTypes { for i, snapshotType := range snapshotTypes {
query := ` query := `
mutation CreateSnapshot($input: CreateSnapshotInput!) { mutation CreateSnapshot($input: CreateSnapshotInput!) {
@@ -219,7 +219,7 @@ func TestSnapshot_Types(t *testing.T) {
t.Parallel() t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner) owner := testutil.NewClient(t, testutil.RoleOwner)
snapshotTypes := []string{"RISKS", "VENDORS"} snapshotTypes := []string{"RISKS"}
for _, snapshotType := range snapshotTypes { for _, snapshotType := range snapshotTypes {
t.Run(snapshotType, func(t *testing.T) { 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) mc := testutil.NewMCPClient(t, owner)
orgID := owner.GetOrganizationID().String() orgID := owner.GetOrganizationID().String()
// Create a vendor so the snapshot has data // Create a risk so the snapshot has data
factory.CreateVendor(owner) factory.CreateRisk(owner)
// Take snapshot // Take snapshot
var takeResult struct { var takeResult struct {
@@ -41,7 +41,7 @@ func TestMCP_Snapshot(t *testing.T) {
mc.CallToolInto("takeSnapshot", map[string]any{ mc.CallToolInto("takeSnapshot", map[string]any{
"organizationId": orgID, "organizationId": orgID,
"name": factory.SafeName("Snapshot"), "name": factory.SafeName("Snapshot"),
"snapshotsType": "VENDORS", "snapshotsType": "RISKS",
}, &takeResult) }, &takeResult)
require.NotEmpty(t, takeResult.Snapshot.ID) require.NotEmpty(t, takeResult.Snapshot.ID)

View File

@@ -16,8 +16,6 @@ type Translator = (s: string) => string;
export const snapshotTypes = [ export const snapshotTypes = [
"RISKS", "RISKS",
"VENDORS",
"PROCESSING_ACTIVITIES",
] as const; ] as const;
export function getSnapshotTypeLabel(__: Translator, type: string | null | undefined) { 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 getDataPrivacyAgreementOp from './getDataPrivacyAgreement.operation';
import * as deleteDataPrivacyAgreementOp from './deleteDataPrivacyAgreement.operation'; import * as deleteDataPrivacyAgreementOp from './deleteDataPrivacyAgreement.operation';
import * as updateDataPrivacyAgreementOp from './updateDataPrivacyAgreement.operation'; import * as updateDataPrivacyAgreementOp from './updateDataPrivacyAgreement.operation';
import * as publishOp from './publish.operation';
export const description: INodeProperties[] = [ export const description: INodeProperties[] = [
{ {
@@ -178,6 +179,12 @@ export const description: INodeProperties[] = [
description: 'Get a vendor service', description: 'Get a vendor service',
action: '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', name: 'Update',
value: 'update', value: 'update',
@@ -237,6 +244,7 @@ export const description: INodeProperties[] = [
...getDataPrivacyAgreementOp.description, ...getDataPrivacyAgreementOp.description,
...deleteDataPrivacyAgreementOp.description, ...deleteDataPrivacyAgreementOp.description,
...updateDataPrivacyAgreementOp.description, ...updateDataPrivacyAgreementOp.description,
...publishOp.description,
]; ];
export { export {
@@ -266,4 +274,5 @@ export {
getDataPrivacyAgreementOp as getDataPrivacyAgreement, getDataPrivacyAgreementOp as getDataPrivacyAgreement,
deleteDataPrivacyAgreementOp as deleteDataPrivacyAgreement, deleteDataPrivacyAgreementOp as deleteDataPrivacyAgreement,
updateDataPrivacyAgreementOp as updateDataPrivacyAgreement, 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/create"
"go.probo.inc/probo/pkg/cmd/vendormgmt/delete" "go.probo.inc/probo/pkg/cmd/vendormgmt/delete"
"go.probo.inc/probo/pkg/cmd/vendormgmt/list" "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/update"
"go.probo.inc/probo/pkg/cmd/vendormgmt/view" "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(update.NewCmdUpdate(f))
cmd.AddCommand(delete.NewCmdDelete(f)) cmd.AddCommand(delete.NewCmdDelete(f))
cmd.AddCommand(assess.NewCmdAssess(f)) cmd.AddCommand(assess.NewCmdAssess(f))
cmd.AddCommand(publish.NewCmdPublish(f))
return cmd 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 WHERE
%s %s
AND organization_id = @organization_id AND organization_id = @organization_id
AND type NOT IN ('STATEMENTS_OF_APPLICABILITY', 'DATA', 'ASSETS') AND type = 'RISKS'
AND %s AND %s
` `
@@ -164,7 +164,7 @@ FROM
WHERE WHERE
%s %s
AND organization_id = @organization_id AND organization_id = @organization_id
AND type NOT IN ('STATEMENTS_OF_APPLICABILITY', 'DATA', 'ASSETS') AND type = 'RISKS'
AND %s AND %s
` `

View File

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

View File

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

View File

@@ -27,6 +27,113 @@ import (
"go.probo.inc/probo/pkg/page" "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 ( type (
Vendor struct { Vendor struct {
ID gid.GID `db:"id"` ID gid.GID `db:"id"`
@@ -52,17 +159,11 @@ type (
SecurityPageURL *string `db:"security_page_url"` SecurityPageURL *string `db:"security_page_url"`
TrustPageURL *string `db:"trust_page_url"` TrustPageURL *string `db:"trust_page_url"`
ShowOnTrustCenter bool `db:"show_on_trust_center"` 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"` CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"` UpdatedAt time.Time `db:"updated_at"`
} }
Vendors []*Vendor 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 { func (v Vendor) CursorKey(orderBy VendorOrderField) page.CursorKey {
@@ -123,8 +224,6 @@ SELECT
security_page_url, security_page_url,
trust_page_url, trust_page_url,
show_on_trust_center, show_on_trust_center,
snapshot_id,
source_id,
created_at, created_at,
updated_at updated_at
FROM FROM
@@ -191,8 +290,6 @@ SELECT
security_page_url, security_page_url,
trust_page_url, trust_page_url,
show_on_trust_center, show_on_trust_center,
snapshot_id,
source_id,
created_at, created_at,
updated_at updated_at
FROM FROM
@@ -253,8 +350,6 @@ INSERT INTO
security_page_url, security_page_url,
trust_page_url, trust_page_url,
show_on_trust_center, show_on_trust_center,
snapshot_id,
source_id,
created_at, created_at,
updated_at updated_at
) )
@@ -282,8 +377,6 @@ VALUES (
@security_page_url, @security_page_url,
@trust_page_url, @trust_page_url,
@show_on_trust_center, @show_on_trust_center,
@snapshot_id,
@source_id,
@created_at, @created_at,
@updated_at @updated_at
) )
@@ -313,8 +406,6 @@ VALUES (
"security_page_url": v.SecurityPageURL, "security_page_url": v.SecurityPageURL,
"trust_page_url": v.TrustPageURL, "trust_page_url": v.TrustPageURL,
"show_on_trust_center": v.ShowOnTrustCenter, "show_on_trust_center": v.ShowOnTrustCenter,
"snapshot_id": v.SnapshotID,
"source_id": v.SourceID,
"created_at": v.CreatedAt, "created_at": v.CreatedAt,
"updated_at": v.UpdatedAt, "updated_at": v.UpdatedAt,
} }
@@ -355,7 +446,8 @@ FROM
WHERE WHERE
%s %s
AND organization_id = @organization_id AND organization_id = @organization_id
AND %s AND snapshot_id IS NULL
AND %s
` `
q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment()) q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment())
@@ -375,6 +467,67 @@ WHERE
return count, nil 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( func (v *Vendors) LoadByOrganizationID(
ctx context.Context, ctx context.Context,
conn pg.Querier, conn pg.Querier,
@@ -408,8 +561,6 @@ SELECT
security_page_url, security_page_url,
trust_page_url, trust_page_url,
show_on_trust_center, show_on_trust_center,
snapshot_id,
source_id,
created_at, created_at,
updated_at updated_at
FROM FROM
@@ -417,6 +568,7 @@ FROM
WHERE WHERE
%s %s
AND organization_id = @organization_id AND organization_id = @organization_id
AND snapshot_id IS NULL
AND %s AND %s
AND %s AND %s
` `
@@ -613,8 +765,6 @@ WITH vend AS (
v.security_page_url, v.security_page_url,
v.trust_page_url, v.trust_page_url,
v.show_on_trust_center, v.show_on_trust_center,
v.snapshot_id,
v.source_id,
v.created_at, v.created_at,
v.updated_at v.updated_at
FROM FROM
@@ -648,8 +798,6 @@ SELECT
security_page_url, security_page_url,
trust_page_url, trust_page_url,
show_on_trust_center, show_on_trust_center,
snapshot_id,
source_id,
created_at, created_at,
updated_at updated_at
FROM FROM
@@ -749,8 +897,6 @@ WITH vend AS (
v.security_page_url, v.security_page_url,
v.trust_page_url, v.trust_page_url,
v.show_on_trust_center, v.show_on_trust_center,
v.snapshot_id,
v.source_id,
v.created_at, v.created_at,
v.updated_at v.updated_at
FROM FROM
@@ -784,8 +930,6 @@ SELECT
security_page_url, security_page_url,
trust_page_url, trust_page_url,
show_on_trust_center, show_on_trust_center,
snapshot_id,
source_id,
created_at, created_at,
updated_at updated_at
FROM FROM
@@ -846,8 +990,6 @@ WITH vend AS (
v.security_page_url, v.security_page_url,
v.trust_page_url, v.trust_page_url,
v.show_on_trust_center, v.show_on_trust_center,
v.snapshot_id,
v.source_id,
v.created_at, v.created_at,
v.updated_at v.updated_at
FROM FROM
@@ -881,8 +1023,6 @@ SELECT
security_page_url, security_page_url,
trust_page_url, trust_page_url,
show_on_trust_center, show_on_trust_center,
snapshot_id,
source_id,
created_at, created_at,
updated_at updated_at
FROM FROM
@@ -944,8 +1084,6 @@ WITH vend AS (
v.security_page_url, v.security_page_url,
v.trust_page_url, v.trust_page_url,
v.show_on_trust_center, v.show_on_trust_center,
v.snapshot_id,
v.source_id,
v.created_at, v.created_at,
v.updated_at v.updated_at
FROM FROM
@@ -979,8 +1117,6 @@ SELECT
security_page_url, security_page_url,
trust_page_url, trust_page_url,
show_on_trust_center, show_on_trust_center,
snapshot_id,
source_id,
created_at, created_at,
updated_at updated_at
FROM FROM
@@ -1034,6 +1170,7 @@ filtered_vendors AS (
vendors v vendors v
WHERE WHERE
v.tenant_id = @tenant_id v.tenant_id = @tenant_id
AND v.snapshot_id IS NULL
) )
SELECT SELECT
pav.processing_activity_id, pav.processing_activity_id,
@@ -1106,8 +1243,6 @@ WITH vend AS (
v.security_page_url, v.security_page_url,
v.trust_page_url, v.trust_page_url,
v.show_on_trust_center, v.show_on_trust_center,
v.snapshot_id,
v.source_id,
v.created_at, v.created_at,
v.updated_at v.updated_at
FROM FROM
@@ -1141,8 +1276,6 @@ SELECT
security_page_url, security_page_url,
trust_page_url, trust_page_url,
show_on_trust_center, show_on_trust_center,
snapshot_id,
source_id,
created_at, created_at,
updated_at updated_at
FROM FROM
@@ -1169,108 +1302,3 @@ ORDER BY name ASC
return nil 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"` ValidFrom *time.Time `db:"valid_from"`
ValidUntil *time.Time `db:"valid_until"` ValidUntil *time.Time `db:"valid_until"`
FileID gid.GID `db:"file_id"` FileID gid.GID `db:"file_id"`
SnapshotID *gid.GID `db:"snapshot_id"`
SourceID *gid.GID `db:"source_id"`
CreatedAt time.Time `db:"created_at"` CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"` UpdatedAt time.Time `db:"updated_at"`
} }
@@ -84,8 +82,6 @@ SELECT
valid_from, valid_from,
valid_until, valid_until,
file_id, file_id,
snapshot_id,
source_id,
created_at, created_at,
updated_at updated_at
FROM FROM
@@ -93,6 +89,7 @@ FROM
WHERE WHERE
%s %s
AND vendor_id = @vendor_id AND vendor_id = @vendor_id
AND snapshot_id IS NULL
LIMIT 1; LIMIT 1;
` `
@@ -116,6 +113,60 @@ LIMIT 1;
return nil 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( func (vbaa *VendorBusinessAssociateAgreement) LoadByID(
ctx context.Context, ctx context.Context,
conn pg.Querier, conn pg.Querier,
@@ -130,8 +181,6 @@ SELECT
valid_from, valid_from,
valid_until, valid_until,
file_id, file_id,
snapshot_id,
source_id,
created_at, created_at,
updated_at updated_at
FROM FROM
@@ -215,8 +264,6 @@ INSERT INTO
valid_from, valid_from,
valid_until, valid_until,
file_id, file_id,
snapshot_id,
source_id,
created_at, created_at,
updated_at updated_at
) )
@@ -228,8 +275,6 @@ VALUES (
@valid_from, @valid_from,
@valid_until, @valid_until,
@file_id, @file_id,
@snapshot_id,
@source_id,
@created_at, @created_at,
@updated_at @updated_at
) )
@@ -238,8 +283,6 @@ ON CONFLICT (organization_id, vendor_id) DO UPDATE SET
valid_from = EXCLUDED.valid_from, valid_from = EXCLUDED.valid_from,
valid_until = EXCLUDED.valid_until, valid_until = EXCLUDED.valid_until,
file_id = EXCLUDED.file_id, file_id = EXCLUDED.file_id,
snapshot_id = EXCLUDED.snapshot_id,
source_id = EXCLUDED.source_id,
updated_at = EXCLUDED.updated_at updated_at = EXCLUDED.updated_at
` `
args := pgx.StrictNamedArgs{ args := pgx.StrictNamedArgs{
@@ -250,8 +293,6 @@ ON CONFLICT (organization_id, vendor_id) DO UPDATE SET
"valid_from": vbaa.ValidFrom, "valid_from": vbaa.ValidFrom,
"valid_until": vbaa.ValidUntil, "valid_until": vbaa.ValidUntil,
"file_id": vbaa.FileID, "file_id": vbaa.FileID,
"snapshot_id": vbaa.SnapshotID,
"source_id": vbaa.SourceID,
"created_at": vbaa.CreatedAt, "created_at": vbaa.CreatedAt,
"updated_at": vbaa.UpdatedAt, "updated_at": vbaa.UpdatedAt,
} }
@@ -317,65 +358,3 @@ WHERE
_, err := conn.Exec(ctx, q, args) _, err := conn.Exec(ctx, q, args)
return err 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"` ValidUntil *time.Time `db:"valid_until"`
ReportName string `db:"report_name"` ReportName string `db:"report_name"`
ReportFileId *gid.GID `db:"report_file_id"` 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"` CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"` UpdatedAt time.Time `db:"updated_at"`
} }
@@ -86,8 +84,6 @@ SELECT
valid_until, valid_until,
report_name, report_name,
report_file_id, report_file_id,
snapshot_id,
source_id,
created_at, created_at,
updated_at updated_at
FROM FROM
@@ -95,6 +91,7 @@ FROM
WHERE WHERE
%s %s
AND vendor_id = @vendor_id AND vendor_id = @vendor_id
AND snapshot_id IS NULL
AND %s AND %s
` `
@@ -119,6 +116,63 @@ WHERE
return nil 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( func (vcr *VendorComplianceReport) LoadByID(
ctx context.Context, ctx context.Context,
conn pg.Querier, conn pg.Querier,
@@ -134,8 +188,6 @@ SELECT
valid_until, valid_until,
report_name, report_name,
report_file_id, report_file_id,
snapshot_id,
source_id,
created_at, created_at,
updated_at updated_at
FROM FROM
@@ -251,67 +303,3 @@ RETURNING report_file_id
} }
return nil 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"` Email *mail.Addr `db:"email"`
Phone *string `db:"phone"` Phone *string `db:"phone"`
Role *string `db:"role"` Role *string `db:"role"`
SnapshotID *gid.GID `db:"snapshot_id"`
SourceID *gid.GID `db:"source_id"`
CreatedAt time.Time `db:"created_at"` CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"` UpdatedAt time.Time `db:"updated_at"`
} }
@@ -88,8 +86,6 @@ SELECT
email, email,
phone, phone,
role, role,
snapshot_id,
source_id,
created_at, created_at,
updated_at updated_at
FROM FROM
@@ -141,8 +137,6 @@ SELECT
email, email,
phone, phone,
role, role,
snapshot_id,
source_id,
created_at, created_at,
updated_at updated_at
FROM FROM
@@ -150,6 +144,7 @@ FROM
WHERE WHERE
%s %s
AND vendor_id = @vendor_id AND vendor_id = @vendor_id
AND snapshot_id IS NULL
AND %s AND %s
` `
q = fmt.Sprintf(q, scope.SQLFragment(), cursor.SQLFragment()) q = fmt.Sprintf(q, scope.SQLFragment(), cursor.SQLFragment())
@@ -176,6 +171,63 @@ WHERE
return nil 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( func (vc VendorContact) Insert(
ctx context.Context, ctx context.Context,
conn pg.Tx, conn pg.Tx,
@@ -296,67 +348,3 @@ WHERE
return nil 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"` ValidFrom *time.Time `db:"valid_from"`
ValidUntil *time.Time `db:"valid_until"` ValidUntil *time.Time `db:"valid_until"`
FileID gid.GID `db:"file_id"` FileID gid.GID `db:"file_id"`
SnapshotID *gid.GID `db:"snapshot_id"`
SourceID *gid.GID `db:"source_id"`
CreatedAt time.Time `db:"created_at"` CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"` UpdatedAt time.Time `db:"updated_at"`
} }
@@ -84,8 +82,6 @@ SELECT
valid_from, valid_from,
valid_until, valid_until,
file_id, file_id,
snapshot_id,
source_id,
created_at, created_at,
updated_at updated_at
FROM FROM
@@ -93,6 +89,7 @@ FROM
WHERE WHERE
%s %s
AND vendor_id = @vendor_id AND vendor_id = @vendor_id
AND snapshot_id IS NULL
LIMIT 1; LIMIT 1;
` `
@@ -116,6 +113,60 @@ LIMIT 1;
return nil 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( func (vdpa *VendorDataPrivacyAgreement) LoadByID(
ctx context.Context, ctx context.Context,
conn pg.Querier, conn pg.Querier,
@@ -130,8 +181,6 @@ SELECT
valid_from, valid_from,
valid_until, valid_until,
file_id, file_id,
snapshot_id,
source_id,
created_at, created_at,
updated_at updated_at
FROM FROM
@@ -215,8 +264,6 @@ INSERT INTO
valid_from, valid_from,
valid_until, valid_until,
file_id, file_id,
snapshot_id,
source_id,
created_at, created_at,
updated_at updated_at
) )
@@ -228,8 +275,6 @@ VALUES (
@valid_from, @valid_from,
@valid_until, @valid_until,
@file_id, @file_id,
@snapshot_id,
@source_id,
@created_at, @created_at,
@updated_at @updated_at
) )
@@ -238,8 +283,6 @@ ON CONFLICT (organization_id, vendor_id) DO UPDATE SET
valid_from = EXCLUDED.valid_from, valid_from = EXCLUDED.valid_from,
valid_until = EXCLUDED.valid_until, valid_until = EXCLUDED.valid_until,
file_id = EXCLUDED.file_id, file_id = EXCLUDED.file_id,
snapshot_id = EXCLUDED.snapshot_id,
source_id = EXCLUDED.source_id,
updated_at = EXCLUDED.updated_at updated_at = EXCLUDED.updated_at
` `
args := pgx.StrictNamedArgs{ args := pgx.StrictNamedArgs{
@@ -250,8 +293,6 @@ ON CONFLICT (organization_id, vendor_id) DO UPDATE SET
"valid_from": vdpa.ValidFrom, "valid_from": vdpa.ValidFrom,
"valid_until": vdpa.ValidUntil, "valid_until": vdpa.ValidUntil,
"file_id": vdpa.FileID, "file_id": vdpa.FileID,
"snapshot_id": vdpa.SnapshotID,
"source_id": vdpa.SourceID,
"created_at": vdpa.CreatedAt, "created_at": vdpa.CreatedAt,
"updated_at": vdpa.UpdatedAt, "updated_at": vdpa.UpdatedAt,
} }
@@ -316,65 +357,3 @@ WHERE
_, err := conn.Exec(ctx, q, args) _, err := conn.Exec(ctx, q, args)
return err 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 ( import (
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
"go.probo.inc/probo/pkg/gid"
) )
type ( type (
VendorFilter struct { VendorFilter struct {
showOnTrustCenter *bool showOnTrustCenter *bool
snapshotID **gid.GID
} }
) )
func NewVendorFilter(snapshotID **gid.GID, showOnTrustCenter *bool) *VendorFilter { func NewVendorFilter(showOnTrustCenter *bool) *VendorFilter {
return &VendorFilter{ return &VendorFilter{
snapshotID: snapshotID,
showOnTrustCenter: showOnTrustCenter, showOnTrustCenter: showOnTrustCenter,
} }
} }
@@ -42,17 +39,6 @@ func (f *VendorFilter) SQLArguments() pgx.StrictNamedArgs {
args["show_on_trust_center"] = nil 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 return args
} }
@@ -64,14 +50,5 @@ func (f *VendorFilter) SQLFragment() string {
show_on_trust_center = @show_on_trust_center::boolean show_on_trust_center = @show_on_trust_center::boolean
ELSE TRUE ELSE TRUE
END 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"` DataSensitivity DataSensitivity `db:"data_sensitivity"`
BusinessImpact BusinessImpact `db:"business_impact"` BusinessImpact BusinessImpact `db:"business_impact"`
Notes *string `db:"notes"` Notes *string `db:"notes"`
SnapshotID *gid.GID `db:"snapshot_id"`
SourceID *gid.GID `db:"source_id"`
CreatedAt time.Time `db:"created_at"` CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"` UpdatedAt time.Time `db:"updated_at"`
} }
@@ -137,8 +135,6 @@ SELECT
data_sensitivity, data_sensitivity,
business_impact, business_impact,
notes, notes,
snapshot_id,
source_id,
created_at, created_at,
updated_at updated_at
FROM FROM
@@ -186,8 +182,6 @@ SELECT
data_sensitivity, data_sensitivity,
business_impact, business_impact,
notes, notes,
snapshot_id,
source_id,
created_at, created_at,
updated_at updated_at
FROM FROM
@@ -195,6 +189,7 @@ FROM
WHERE WHERE
%s %s
AND vendor_id = @vendor_id AND vendor_id = @vendor_id
AND snapshot_id IS NULL
ORDER BY ORDER BY
created_at DESC created_at DESC
LIMIT 1; LIMIT 1;
@@ -238,8 +233,6 @@ SELECT
data_sensitivity, data_sensitivity,
business_impact, business_impact,
notes, notes,
snapshot_id,
source_id,
created_at, created_at,
updated_at updated_at
FROM FROM
@@ -247,6 +240,7 @@ FROM
WHERE WHERE
%s %s
AND vendor_id = @vendor_id AND vendor_id = @vendor_id
AND snapshot_id IS NULL
AND %s AND %s
` `
@@ -271,66 +265,59 @@ WHERE
return nil return nil
} }
func (v VendorRiskAssessments) InsertVendorSnapshots( func (r *VendorRiskAssessments) LoadByVendorIDs(
ctx context.Context, ctx context.Context,
conn pg.Tx, conn pg.Querier,
scope Scoper, scope Scoper,
organizationID gid.GID, vendorIDs []gid.GID,
snapshotID gid.GID,
) error { ) error {
query := ` if len(vendorIDs) == 0 {
WITH *r = VendorRiskAssessments{}
snapshot_vendors AS ( return nil
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,
} }
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()) maps.Copy(args, scope.SQLArguments())
_, err := conn.Exec(ctx, query, args) rows, err := conn.Query(ctx, q, args)
if err != nil { 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 return nil
} }

View File

@@ -34,8 +34,6 @@ type (
VendorID gid.GID `db:"vendor_id"` VendorID gid.GID `db:"vendor_id"`
Name string `db:"name"` Name string `db:"name"`
Description *string `db:"description"` Description *string `db:"description"`
SnapshotID *gid.GID `db:"snapshot_id"`
SourceID *gid.GID `db:"source_id"`
CreatedAt time.Time `db:"created_at"` CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"` UpdatedAt time.Time `db:"updated_at"`
} }
@@ -81,8 +79,6 @@ SELECT
vendor_id, vendor_id,
name, name,
description, description,
snapshot_id,
source_id,
created_at, created_at,
updated_at updated_at
FROM FROM
@@ -132,8 +128,6 @@ SELECT
vendor_id, vendor_id,
name, name,
description, description,
snapshot_id,
source_id,
created_at, created_at,
updated_at updated_at
FROM FROM
@@ -141,6 +135,7 @@ FROM
WHERE WHERE
%s %s
AND vendor_id = @vendor_id AND vendor_id = @vendor_id
AND snapshot_id IS NULL
AND %s AND %s
` `
q = fmt.Sprintf(q, scope.SQLFragment(), cursor.SQLFragment()) q = fmt.Sprintf(q, scope.SQLFragment(), cursor.SQLFragment())
@@ -167,6 +162,61 @@ WHERE
return nil 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( func (vs VendorService) Insert(
ctx context.Context, ctx context.Context,
conn pg.Tx, conn pg.Tx,
@@ -277,63 +327,3 @@ WHERE
return nil 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 LocalLawRisk string
SupplementaryMeasures 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 { func BoolLabel(v bool) string {

View File

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

View File

@@ -2563,3 +2563,536 @@ func BuildTransferImpactAssessmentListDocument(data docgen.TransferImpactAssessm
} }
return buf.String(), nil 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 last: Int
before: CursorKey before: CursorKey
orderBy: VendorOrder orderBy: VendorOrder
filter: VendorFilter = { snapshotId: null }
): VendorConnection! @goField(forceResolver: true) ): VendorConnection! @goField(forceResolver: true)
vendorsDocument: Document @goField(forceResolver: true)
webhookSubscriptions( webhookSubscriptions(
first: Int first: Int
after: CursorKey after: CursorKey

View File

@@ -1,16 +1,6 @@
enum SnapshotsType enum SnapshotsType
@goModel(model: "go.probo.inc/probo/pkg/coredata.SnapshotsType") { @goModel(model: "go.probo.inc/probo/pkg/coredata.SnapshotsType") {
RISKS @goEnum(value: "go.probo.inc/probo/pkg/coredata.SnapshotsTypeRisks") 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 enum SnapshotOrderField

View File

@@ -206,13 +206,8 @@ input VendorRiskAssessmentOrder {
direction: OrderDirection! direction: OrderDirection!
} }
input VendorFilter {
snapshotId: ID
}
type Vendor implements Node { type Vendor implements Node {
id: ID! id: ID!
snapshotId: ID
name: String! name: String!
category: VendorCategory! category: VendorCategory!
description: String description: String
@@ -462,6 +457,19 @@ extend type Mutation {
input: CreateVendorRiskAssessmentInput! input: CreateVendorRiskAssessmentInput!
): CreateVendorRiskAssessmentPayload! ): CreateVendorRiskAssessmentPayload!
assessVendor(input: AssessVendorInput!): AssessVendorPayload! assessVendor(input: AssessVendorInput!): AssessVendorPayload!
publishVendorList(
input: PublishVendorListInput!
): PublishVendorListPayload!
}
input PublishVendorListInput {
organizationId: ID!
approverIds: [ID!]
}
type PublishVendorListPayload {
documentEdge: DocumentEdge!
documentVersionEdge: DocumentVersionEdge!
} }
input CreateVendorInput { 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. // 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 { if err := r.authorize(ctx, obj.ID, probo.ActionVendorList); err != nil {
return nil, err 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) cursor := types.NewCursor(first, after, last, before, pageOrderBy)
var vendorFilter = coredata.NewVendorFilter(nil, nil) vendorFilter := coredata.NewVendorFilter(nil)
if filter != nil {
vendorFilter = coredata.NewVendorFilter(&filter.SnapshotID, nil)
}
page, err := prb.Vendors.ListForOrganizationID(ctx, obj.ID, cursor, vendorFilter) page, err := prb.Vendors.ListForOrganizationID(ctx, obj.ID, cursor, vendorFilter)
if err != nil { 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 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. // 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) { 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 { 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, WebsiteURL: v.WebsiteURL,
Category: v.Category, Category: v.Category,
ShowOnTrustCenter: v.ShowOnTrustCenter, ShowOnTrustCenter: v.ShowOnTrustCenter,
SnapshotID: v.SnapshotID,
Countries: v.Countries, Countries: v.Countries,
UpdatedAt: v.UpdatedAt, UpdatedAt: v.UpdatedAt,
CreatedAt: v.CreatedAt, CreatedAt: v.CreatedAt,

View File

@@ -565,6 +565,29 @@ func (r *mutationResolver) AssessVendor(ctx context.Context, input types.AssessV
}, nil }, 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. // Organization is the resolver for the organization field.
func (r *vendorResolver) Organization(ctx context.Context, obj *types.Vendor) (*types.Organization, error) { func (r *vendorResolver) Organization(ctx context.Context, obj *types.Vendor) (*types.Organization, error) {
if err := r.authorize(ctx, obj.ID, probo.ActionOrganizationGet); err != nil { 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) cursor := types.NewCursor(input.Size, input.Cursor, pageOrderBy)
noSnapshot := (*gid.GID)(nil) vendorFilter := coredata.NewVendorFilter(nil)
vendorFilter := coredata.NewVendorFilter(&noSnapshot, nil)
if input.Filter != nil {
vendorFilter = coredata.NewVendorFilter(&input.Filter.SnapshotID, nil)
}
page, err := prb.Vendors.ListForOrganizationID(ctx, input.OrganizationID, cursor, vendorFilter) page, err := prb.Vendors.ListForOrganizationID(ctx, input.OrganizationID, cursor, vendorFilter)
if err != nil { if err != nil {
@@ -4840,3 +4836,19 @@ func (r *Resolver) PublishTransferImpactAssessmentListTool(ctx context.Context,
DocumentVersionID: documentVersion.ID, DocumentVersionID: documentVersion.ID,
}, nil }, 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: cursor:
$ref: "#/components/schemas/CursorKey" $ref: "#/components/schemas/CursorKey"
description: Page cursor 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: ListVendorsOutput:
type: object type: object
@@ -560,11 +551,6 @@ components:
- string - string
- "null" - "null"
description: Notes description: Notes
snapshot_id:
anyOf:
- $ref: "#/components/schemas/GID"
- type: "null"
description: Snapshot ID
created_at: created_at:
type: string type: string
format: date-time format: date-time
@@ -5372,7 +5358,6 @@ components:
type: string type: string
enum: enum:
- RISKS - RISKS
- VENDORS
- NONCONFORMITIES - NONCONFORMITIES
- OBLIGATIONS - OBLIGATIONS
- CONTINUAL_IMPROVEMENTS - CONTINUAL_IMPROVEMENTS
@@ -7119,6 +7104,33 @@ components:
$ref: "#/components/schemas/GID" $ref: "#/components/schemas/GID"
description: Created document version ID 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: PublishStatementOfApplicabilityInput:
type: object type: object
required: required:
@@ -10396,6 +10408,14 @@ tools:
$ref: "#/components/schemas/PublishTransferImpactAssessmentListInput" $ref: "#/components/schemas/PublishTransferImpactAssessmentListInput"
outputSchema: outputSchema:
$ref: "#/components/schemas/PublishTransferImpactAssessmentListOutput" $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 - name: publishStatementOfApplicability
description: Publish a statement of applicability as a document. If a document already exists, a new version is created. description: Publish a statement of applicability as a document. If a document already exists, a new version is created.
hints: hints:

View File

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

View File

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