diff --git a/apps/console/src/hooks/graph/VendorGraph.ts b/apps/console/src/hooks/graph/VendorGraph.ts
index 23c909858..51a85ecec 100644
--- a/apps/console/src/hooks/graph/VendorGraph.ts
+++ b/apps/console/src/hooks/graph/VendorGraph.ts
@@ -109,12 +109,21 @@ export const useDeleteVendor = (
export const vendorConnectionKey = "VendorsPage_vendors";
export const vendorsQuery = graphql`
- query VendorGraphListQuery($organizationId: ID!, $snapshotId: ID) {
+ query VendorGraphListQuery($organizationId: ID!) {
node(id: $organizationId) {
... on Organization {
id
canCreateVendor: permission(action: "core:vendor:create")
- ...VendorGraphPaginatedFragment @arguments(snapshotId: $snapshotId)
+ canPublishVendor: permission(action: "core:vendor:publish")
+ vendorsDocument {
+ id
+ currentPublishedMajor
+ currentPublishedMinor
+ defaultApprovers {
+ id
+ }
+ }
+ ...VendorGraphPaginatedFragment
}
}
}
@@ -129,7 +138,6 @@ export const paginatedVendorsFragment = graphql`
after: { type: "CursorKey", defaultValue: null }
before: { type: "CursorKey", defaultValue: null }
last: { type: "Int", defaultValue: null }
- snapshotId: { type: "ID", defaultValue: null }
) {
vendors(
first: $first
@@ -137,13 +145,11 @@ export const paginatedVendorsFragment = graphql`
last: $last
before: $before
orderBy: $order
- filter: { snapshotId: $snapshotId }
- ) @connection(key: "VendorsListQuery_vendors", filters: ["filter"]) {
+ ) @connection(key: "VendorsListQuery_vendors") {
__id
edges {
node {
id
- snapshotId
name
websiteUrl
updatedAt
@@ -174,7 +180,6 @@ export const vendorNodeQuery = graphql`
node(id: $vendorId) {
id
... on Vendor {
- snapshotId
name
websiteUrl
canAssess: permission(action: "core:vendor:assess")
diff --git a/apps/console/src/pages/organizations/vendors/VendorDetailPage.tsx b/apps/console/src/pages/organizations/vendors/VendorDetailPage.tsx
index 93185d40d..3cf5f6bc7 100644
--- a/apps/console/src/pages/organizations/vendors/VendorDetailPage.tsx
+++ b/apps/console/src/pages/organizations/vendors/VendorDetailPage.tsx
@@ -12,7 +12,7 @@
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
-import { faviconUrl, validateSnapshotConsistency } from "@probo/helpers";
+import { faviconUrl } from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import {
ActionDropdown,
@@ -31,11 +31,10 @@ import {
useFragment,
usePreloadedQuery,
} from "react-relay";
-import { Outlet, useParams } from "react-router";
+import { Outlet } from "react-router";
import type { VendorComplianceTabFragment$key } from "#/__generated__/core/VendorComplianceTabFragment.graphql";
import type { VendorGraphNodeQuery } from "#/__generated__/core/VendorGraphNodeQuery.graphql";
-import { SnapshotBanner } from "#/components/SnapshotBanner";
import {
useDeleteVendor,
vendorConnectionKey,
@@ -54,10 +53,7 @@ export default function VendorDetailPage(props: Props) {
const { node: vendor } = usePreloadedQuery(vendorNodeQuery, props.queryRef);
const { __ } = useTranslate();
const organizationId = useOrganizationId();
- const { snapshotId } = useParams<{ snapshotId?: string }>();
- const isSnapshotMode = Boolean(snapshotId);
- validateSnapshotConsistency(vendor, snapshotId);
const deleteVendor = useDeleteVendor(
vendor,
ConnectionHandler.getConnectionID(organizationId, vendorConnectionKey),
@@ -68,19 +64,13 @@ export default function VendorDetailPage(props: Props) {
vendor as VendorComplianceTabFragment$key,
).complianceReports.edges.length;
- const vendorsUrl
- = isSnapshotMode && snapshotId
- ? `/organizations/${organizationId}/snapshots/${snapshotId}/vendors`
- : `/organizations/${organizationId}/vendors`;
+ const vendorsUrl = `/organizations/${organizationId}/vendors`;
const baseVendorUrl
- = isSnapshotMode && snapshotId
- ? `/organizations/${organizationId}/snapshots/${snapshotId}/vendors/${vendor.id}`
- : `/organizations/${organizationId}/vendors/${vendor.id}`;
+ = `/organizations/${organizationId}/vendors/${vendor.id}`;
return (
- {snapshotId && }
{vendor.name}
- {!isSnapshotMode && (
-
- {vendor.canAssess && (
-
-
-
- )}
- {vendor.canDelete && (
-
-
- {__("Delete")}
-
-
- )}
-
- )}
+
+ {vendor.canAssess && (
+
+
+
+ )}
+ {vendor.canDelete && (
+
+
+ {__("Delete")}
+
+
+ )}
+
diff --git a/apps/console/src/pages/organizations/vendors/VendorsPage.tsx b/apps/console/src/pages/organizations/vendors/VendorsPage.tsx
index 48b24e6cf..d318184df 100644
--- a/apps/console/src/pages/organizations/vendors/VendorsPage.tsx
+++ b/apps/console/src/pages/organizations/vendors/VendorsPage.tsx
@@ -20,8 +20,10 @@ import {
Avatar,
Button,
DropdownItem,
+ IconPageTextLine,
IconPlusLarge,
IconTrashCan,
+ IconUpload,
PageHeader,
RiskBadge,
Tbody,
@@ -35,14 +37,13 @@ import {
usePaginationFragment,
usePreloadedQuery,
} from "react-relay";
-import { useParams } from "react-router";
+import { useNavigate } from "react-router";
import type { VendorGraphListQuery } from "#/__generated__/core/VendorGraphListQuery.graphql";
import type {
VendorGraphPaginatedFragment$data,
VendorGraphPaginatedFragment$key,
} from "#/__generated__/core/VendorGraphPaginatedFragment.graphql";
-import { SnapshotBanner } from "#/components/SnapshotBanner";
import { SortableTable, SortableTh } from "#/components/SortableTable";
import {
paginatedVendorsFragment,
@@ -53,6 +54,7 @@ import { useOrganizationId } from "#/hooks/useOrganizationId";
import type { NodeOf } from "#/types";
import { CreateVendorDialog } from "./dialogs/CreateVendorDialog";
+import { PublishVendorListDialog } from "./dialogs/PublishVendorListDialog";
type Vendor = NodeOf;
@@ -63,8 +65,7 @@ type Props = {
export default function VendorsPage(props: Props) {
const { __ } = useTranslate();
const organizationId = useOrganizationId();
- const { snapshotId } = useParams<{ snapshotId?: string }>();
- const isSnapshotMode = Boolean(snapshotId);
+ const navigate = useNavigate();
const data = usePreloadedQuery(vendorsQuery, props.queryRef);
// eslint-disable-next-line relay/generated-typescript-types
@@ -79,26 +80,54 @@ export default function VendorsPage(props: Props) {
usePageTitle(__("Vendors"));
const hasAnyAction
- = !isSnapshotMode
- && vendors.some(({ canUpdate, canDelete }) => canUpdate || canDelete);
+ = vendors.some(({ canUpdate, canDelete }) => canUpdate || canDelete);
+
+ const vendorsDocument = data.node?.vendorsDocument;
+ const defaultApproverIds
+ = vendorsDocument?.defaultApprovers?.map(a => a.id) ?? [];
return (
- {snapshotId &&
}
- {!isSnapshotMode && data.node.canCreateVendor && (
-
-
-
- )}
+
+ {vendorsDocument && (
+
+ )}
+ {data.node.canPublishVendor && (
+
void navigate(
+ `/organizations/${organizationId}/documents/${documentId}`,
+ )}
+ >
+
+
+ )}
+ {data.node.canCreateVendor && (
+
+
+
+ )}
+
@@ -137,16 +166,11 @@ function VendorRow({
connectionId: string;
hasAnyAction: boolean;
}) {
- const { snapshotId } = useParams<{ snapshotId?: string }>();
- const isSnapshotMode = Boolean(snapshotId);
const { __ } = useTranslate();
const latestAssessment = vendor.riskAssessments?.edges[0]?.node;
const deleteVendor = useDeleteVendor(vendor, connectionId);
- const vendorUrl
- = isSnapshotMode && snapshotId
- ? `/organizations/${organizationId}/snapshots/${snapshotId}/vendors/${vendor.id}/overview`
- : `/organizations/${organizationId}/vendors/${vendor.id}/overview`;
+ const vendorUrl = `/organizations/${organizationId}/vendors/${vendor.id}/overview`;
return (
<>
diff --git a/apps/console/src/pages/organizations/vendors/dialogs/PublishVendorListDialog.tsx b/apps/console/src/pages/organizations/vendors/dialogs/PublishVendorListDialog.tsx
new file mode 100644
index 000000000..0efcbc441
--- /dev/null
+++ b/apps/console/src/pages/organizations/vendors/dialogs/PublishVendorListDialog.tsx
@@ -0,0 +1,160 @@
+// Copyright (c) 2026 Probo Inc .
+//
+// 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(publishMutation);
+
+ const approverIds = watch("approverIds");
+ const hasApprovers = approverIds.length > 0;
+
+ const onSubmit = (data: z.infer) => {
+ 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 (
+
+ );
+}
diff --git a/apps/console/src/routes/vendorRoutes.ts b/apps/console/src/routes/vendorRoutes.ts
index 835494d04..d69d9df6a 100644
--- a/apps/console/src/routes/vendorRoutes.ts
+++ b/apps/console/src/routes/vendorRoutes.ts
@@ -34,20 +34,6 @@ export const vendorRoutes = [
loader: loaderFromQueryLoader(({ organizationId }) =>
loadQuery(coreEnvironment, vendorsQuery, {
organizationId: organizationId,
- snapshotId: null,
- }),
- ),
- Component: withQueryRef(
- lazy(() => import("#/pages/organizations/vendors/VendorsPage")),
- ),
- },
- {
- path: "snapshots/:snapshotId/vendors",
- Fallback: PageSkeleton,
- loader: loaderFromQueryLoader(({ organizationId, snapshotId }) =>
- loadQuery(coreEnvironment, vendorsQuery, {
- organizationId: organizationId,
- snapshotId,
}),
),
Component: withQueryRef(
@@ -113,63 +99,4 @@ export const vendorRoutes = [
},
],
},
- {
- path: "snapshots/:snapshotId/vendors/:vendorId",
- Fallback: PageSkeleton,
- loader: loaderFromQueryLoader(({ vendorId }) =>
- loadQuery(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[];
diff --git a/cmd/migrate-vendor-snapshots-to-documents/main.go b/cmd/migrate-vendor-snapshots-to-documents/main.go
new file mode 100644
index 000000000..faf8ba683
--- /dev/null
+++ b/cmd/migrate-vendor-snapshots-to-documents/main.go
@@ -0,0 +1,749 @@
+// Copyright (c) 2026 Probo Inc .
+//
+// 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, ¬es); 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...)
+}
diff --git a/e2e/console/snapshot_test.go b/e2e/console/snapshot_test.go
index ed52a5081..670481144 100644
--- a/e2e/console/snapshot_test.go
+++ b/e2e/console/snapshot_test.go
@@ -104,7 +104,7 @@ func TestSnapshot_Delete(t *testing.T) {
"input": map[string]any{
"organizationId": owner.GetOrganizationID().String(),
"name": fmt.Sprintf("Snapshot to Delete %d", time.Now().UnixNano()),
- "type": "VENDORS",
+ "type": "RISKS",
},
}, &createResult)
require.NoError(t, err)
@@ -140,7 +140,7 @@ func TestSnapshot_List(t *testing.T) {
owner := testutil.NewClient(t, testutil.RoleOwner)
// Create multiple snapshots
- snapshotTypes := []string{"RISKS", "VENDORS"}
+ snapshotTypes := []string{"RISKS"}
for i, snapshotType := range snapshotTypes {
query := `
mutation CreateSnapshot($input: CreateSnapshotInput!) {
@@ -219,7 +219,7 @@ func TestSnapshot_Types(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
- snapshotTypes := []string{"RISKS", "VENDORS"}
+ snapshotTypes := []string{"RISKS"}
for _, snapshotType := range snapshotTypes {
t.Run(snapshotType, func(t *testing.T) {
diff --git a/e2e/console/vendor_publish_test.go b/e2e/console/vendor_publish_test.go
new file mode 100644
index 000000000..cc9a2facc
--- /dev/null
+++ b/e2e/console/vendor_publish_test.go
@@ -0,0 +1,336 @@
+// Copyright (c) 2026 Probo Inc .
+//
+// 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)
+ },
+ )
+}
diff --git a/e2e/mcp/snapshot_test.go b/e2e/mcp/snapshot_test.go
index 9eb3fc218..73361766d 100644
--- a/e2e/mcp/snapshot_test.go
+++ b/e2e/mcp/snapshot_test.go
@@ -29,8 +29,8 @@ func TestMCP_Snapshot(t *testing.T) {
mc := testutil.NewMCPClient(t, owner)
orgID := owner.GetOrganizationID().String()
- // Create a vendor so the snapshot has data
- factory.CreateVendor(owner)
+ // Create a risk so the snapshot has data
+ factory.CreateRisk(owner)
// Take snapshot
var takeResult struct {
@@ -41,7 +41,7 @@ func TestMCP_Snapshot(t *testing.T) {
mc.CallToolInto("takeSnapshot", map[string]any{
"organizationId": orgID,
"name": factory.SafeName("Snapshot"),
- "snapshotsType": "VENDORS",
+ "snapshotsType": "RISKS",
}, &takeResult)
require.NotEmpty(t, takeResult.Snapshot.ID)
diff --git a/packages/helpers/src/snapshots.ts b/packages/helpers/src/snapshots.ts
index 4dc6e9826..5e8144516 100644
--- a/packages/helpers/src/snapshots.ts
+++ b/packages/helpers/src/snapshots.ts
@@ -16,8 +16,6 @@ type Translator = (s: string) => string;
export const snapshotTypes = [
"RISKS",
- "VENDORS",
- "PROCESSING_ACTIVITIES",
] as const;
export function getSnapshotTypeLabel(__: Translator, type: string | null | undefined) {
diff --git a/packages/n8n-node/nodes/Probo/actions/vendor/index.ts b/packages/n8n-node/nodes/Probo/actions/vendor/index.ts
index 8c6d741aa..ec5cdbe38 100644
--- a/packages/n8n-node/nodes/Probo/actions/vendor/index.ts
+++ b/packages/n8n-node/nodes/Probo/actions/vendor/index.ts
@@ -39,6 +39,7 @@ import * as updateBusinessAssociateAgreementOp from './updateBusinessAssociateAg
import * as getDataPrivacyAgreementOp from './getDataPrivacyAgreement.operation';
import * as deleteDataPrivacyAgreementOp from './deleteDataPrivacyAgreement.operation';
import * as updateDataPrivacyAgreementOp from './updateDataPrivacyAgreement.operation';
+import * as publishOp from './publish.operation';
export const description: INodeProperties[] = [
{
@@ -178,6 +179,12 @@ export const description: INodeProperties[] = [
description: 'Get a vendor service',
action: 'Get a vendor service',
},
+ {
+ name: 'Publish List',
+ value: 'publish',
+ description: 'Publish the vendor register as a document version',
+ action: 'Publish the vendor register',
+ },
{
name: 'Update',
value: 'update',
@@ -237,6 +244,7 @@ export const description: INodeProperties[] = [
...getDataPrivacyAgreementOp.description,
...deleteDataPrivacyAgreementOp.description,
...updateDataPrivacyAgreementOp.description,
+ ...publishOp.description,
];
export {
@@ -266,4 +274,5 @@ export {
getDataPrivacyAgreementOp as getDataPrivacyAgreement,
deleteDataPrivacyAgreementOp as deleteDataPrivacyAgreement,
updateDataPrivacyAgreementOp as updateDataPrivacyAgreement,
+ publishOp as publish,
};
diff --git a/packages/n8n-node/nodes/Probo/actions/vendor/publish.operation.ts b/packages/n8n-node/nodes/Probo/actions/vendor/publish.operation.ts
new file mode 100644
index 000000000..f4f3961e1
--- /dev/null
+++ b/packages/n8n-node/nodes/Probo/actions/vendor/publish.operation.ts
@@ -0,0 +1,101 @@
+// Copyright (c) 2026 Probo Inc .
+//
+// 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 {
+ 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 = { 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 },
+ };
+}
diff --git a/pkg/cmd/vendormgmt/publish/publish.go b/pkg/cmd/vendormgmt/publish/publish.go
new file mode 100644
index 000000000..9980bbbf9
--- /dev/null
+++ b/pkg/cmd/vendormgmt/publish/publish.go
@@ -0,0 +1,148 @@
+// Copyright (c) 2026 Probo Inc .
+//
+// 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
+}
diff --git a/pkg/cmd/vendormgmt/vendormgmt.go b/pkg/cmd/vendormgmt/vendormgmt.go
index a43fa9f6b..da139ba4a 100644
--- a/pkg/cmd/vendormgmt/vendormgmt.go
+++ b/pkg/cmd/vendormgmt/vendormgmt.go
@@ -21,6 +21,7 @@ import (
"go.probo.inc/probo/pkg/cmd/vendormgmt/create"
"go.probo.inc/probo/pkg/cmd/vendormgmt/delete"
"go.probo.inc/probo/pkg/cmd/vendormgmt/list"
+ "go.probo.inc/probo/pkg/cmd/vendormgmt/publish"
"go.probo.inc/probo/pkg/cmd/vendormgmt/update"
"go.probo.inc/probo/pkg/cmd/vendormgmt/view"
)
@@ -37,6 +38,7 @@ func NewCmdVendor(f *cmdutil.Factory) *cobra.Command {
cmd.AddCommand(update.NewCmdUpdate(f))
cmd.AddCommand(delete.NewCmdDelete(f))
cmd.AddCommand(assess.NewCmdAssess(f))
+ cmd.AddCommand(publish.NewCmdPublish(f))
return cmd
}
diff --git a/pkg/coredata/migrations/20260428T152537Z.sql b/pkg/coredata/migrations/20260428T152537Z.sql
new file mode 100644
index 000000000..06b563bec
--- /dev/null
+++ b/pkg/coredata/migrations/20260428T152537Z.sql
@@ -0,0 +1,16 @@
+-- Copyright (c) 2026 Probo Inc .
+--
+-- 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;
diff --git a/pkg/coredata/snapshot.go b/pkg/coredata/snapshot.go
index 2a8e9d677..7cf816d3f 100644
--- a/pkg/coredata/snapshot.go
+++ b/pkg/coredata/snapshot.go
@@ -124,7 +124,7 @@ FROM
WHERE
%s
AND organization_id = @organization_id
- AND type NOT IN ('STATEMENTS_OF_APPLICABILITY', 'DATA', 'ASSETS')
+ AND type = 'RISKS'
AND %s
`
@@ -164,7 +164,7 @@ FROM
WHERE
%s
AND organization_id = @organization_id
- AND type NOT IN ('STATEMENTS_OF_APPLICABILITY', 'DATA', 'ASSETS')
+ AND type = 'RISKS'
AND %s
`
diff --git a/pkg/coredata/snapshots_type.go b/pkg/coredata/snapshots_type.go
index f30b3ccca..050429e28 100644
--- a/pkg/coredata/snapshots_type.go
+++ b/pkg/coredata/snapshots_type.go
@@ -25,7 +25,6 @@ type (
const (
SnapshotsTypeRisks SnapshotsType = "RISKS"
- SnapshotsTypeVendors SnapshotsType = "VENDORS"
SnapshotsTypeAssets SnapshotsType = "ASSETS"
SnapshotsTypeData SnapshotsType = "DATA"
SnapshotsTypeFindings SnapshotsType = "FINDINGS"
@@ -37,7 +36,6 @@ const (
func SnapshotsTypes() []SnapshotsType {
return []SnapshotsType{
SnapshotsTypeRisks,
- SnapshotsTypeVendors,
}
}
@@ -59,8 +57,6 @@ func (st *SnapshotsType) Scan(value any) error {
switch s {
case SnapshotsTypeRisks.String():
*st = SnapshotsTypeRisks
- case SnapshotsTypeVendors.String():
- *st = SnapshotsTypeVendors
case SnapshotsTypeAssets.String():
*st = SnapshotsTypeAssets
case SnapshotsTypeData.String():
diff --git a/pkg/coredata/snapshottable.go b/pkg/coredata/snapshottable.go
index 01ffbbb20..034efd36c 100644
--- a/pkg/coredata/snapshottable.go
+++ b/pkg/coredata/snapshottable.go
@@ -30,8 +30,6 @@ func GetSnapshottable(snapshotType SnapshotsType) (Snapshottable, error) {
switch snapshotType {
case SnapshotsTypeRisks:
return Risks{}, nil
- case SnapshotsTypeVendors:
- return Vendors{}, nil
default:
return nil, fmt.Errorf("unsupported snapshot type: %s", snapshotType)
}
diff --git a/pkg/coredata/vendor.go b/pkg/coredata/vendor.go
index 360615f63..bb78456b0 100644
--- a/pkg/coredata/vendor.go
+++ b/pkg/coredata/vendor.go
@@ -27,6 +27,113 @@ import (
"go.probo.inc/probo/pkg/page"
)
+func (v Vendor) GetGeneratedDocumentID(
+ ctx context.Context,
+ conn pg.Querier,
+ organizationID gid.GID,
+) (*gid.GID, error) {
+ var documentID *gid.GID
+
+ err := conn.QueryRow(
+ ctx,
+ `
+SELECT
+ vendors_document_id
+FROM
+ generated_documents
+WHERE
+ organization_id = @organization_id
+`,
+ pgx.NamedArgs{"organization_id": organizationID},
+ ).Scan(&documentID)
+ if errors.Is(err, pgx.ErrNoRows) {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, fmt.Errorf("cannot get vendor list document ID: %w", err)
+ }
+
+ return documentID, nil
+}
+
+func (v Vendor) UpsertGeneratedDocumentID(
+ ctx context.Context,
+ conn pg.Tx,
+ organizationID gid.GID,
+ tenantID gid.TenantID,
+ documentID gid.GID,
+) error {
+ now := time.Now()
+
+ _, err := conn.Exec(
+ ctx,
+ `
+INSERT INTO generated_documents (
+ organization_id,
+ tenant_id,
+ vendors_document_id,
+ created_at,
+ updated_at
+) VALUES (
+ @organization_id,
+ @tenant_id,
+ @vendors_document_id,
+ @created_at,
+ @updated_at
+)
+ON CONFLICT (organization_id) DO UPDATE
+SET
+ vendors_document_id = @vendors_document_id,
+ updated_at = @updated_at
+`,
+ pgx.NamedArgs{
+ "organization_id": organizationID,
+ "tenant_id": tenantID,
+ "vendors_document_id": documentID,
+ "created_at": now,
+ "updated_at": now,
+ },
+ )
+ if err != nil {
+ return fmt.Errorf("cannot upsert vendor list document ID: %w", err)
+ }
+
+ return nil
+}
+
+func (v Vendor) ClearGeneratedDocumentID(
+ ctx context.Context,
+ conn pg.Tx,
+ documentIDs []gid.GID,
+) error {
+ ids := make([]string, len(documentIDs))
+ for i, id := range documentIDs {
+ ids[i] = id.String()
+ }
+
+ _, err := conn.Exec(
+ ctx,
+ `
+UPDATE
+ generated_documents
+SET
+ vendors_document_id = NULL,
+ updated_at = @now
+WHERE
+ vendors_document_id = ANY(@ids)
+`,
+ pgx.NamedArgs{
+ "ids": ids,
+ "now": time.Now(),
+ },
+ )
+ if err != nil {
+ return fmt.Errorf("cannot clear vendor list document references: %w", err)
+ }
+
+ return nil
+}
+
type (
Vendor struct {
ID gid.GID `db:"id"`
@@ -52,17 +159,11 @@ type (
SecurityPageURL *string `db:"security_page_url"`
TrustPageURL *string `db:"trust_page_url"`
ShowOnTrustCenter bool `db:"show_on_trust_center"`
- SnapshotID *gid.GID `db:"snapshot_id"`
- SourceID *gid.GID `db:"source_id"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
Vendors []*Vendor
-
- VendorSnapshotter interface {
- InsertVendorSnapshots(ctx context.Context, conn pg.Tx, scope Scoper, organizationID, snapshotID gid.GID) error
- }
)
func (v Vendor) CursorKey(orderBy VendorOrderField) page.CursorKey {
@@ -123,8 +224,6 @@ SELECT
security_page_url,
trust_page_url,
show_on_trust_center,
- snapshot_id,
- source_id,
created_at,
updated_at
FROM
@@ -191,8 +290,6 @@ SELECT
security_page_url,
trust_page_url,
show_on_trust_center,
- snapshot_id,
- source_id,
created_at,
updated_at
FROM
@@ -253,8 +350,6 @@ INSERT INTO
security_page_url,
trust_page_url,
show_on_trust_center,
- snapshot_id,
- source_id,
created_at,
updated_at
)
@@ -282,8 +377,6 @@ VALUES (
@security_page_url,
@trust_page_url,
@show_on_trust_center,
- @snapshot_id,
- @source_id,
@created_at,
@updated_at
)
@@ -313,8 +406,6 @@ VALUES (
"security_page_url": v.SecurityPageURL,
"trust_page_url": v.TrustPageURL,
"show_on_trust_center": v.ShowOnTrustCenter,
- "snapshot_id": v.SnapshotID,
- "source_id": v.SourceID,
"created_at": v.CreatedAt,
"updated_at": v.UpdatedAt,
}
@@ -355,7 +446,8 @@ FROM
WHERE
%s
AND organization_id = @organization_id
- AND %s
+ AND snapshot_id IS NULL
+ AND %s
`
q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment())
@@ -375,6 +467,67 @@ WHERE
return count, nil
}
+func (v *Vendors) LoadAllByOrganizationID(
+ ctx context.Context,
+ conn pg.Querier,
+ scope Scoper,
+ organizationID gid.GID,
+) error {
+ q := `
+SELECT
+ id,
+ tenant_id,
+ organization_id,
+ name,
+ description,
+ category,
+ headquarter_address,
+ legal_name,
+ website_url,
+ privacy_policy_url,
+ service_level_agreement_url,
+ data_processing_agreement_url,
+ business_associate_agreement_url,
+ subprocessors_list_url,
+ certifications,
+ countries,
+ business_owner_profile_id,
+ security_owner_profile_id,
+ status_page_url,
+ terms_of_service_url,
+ security_page_url,
+ trust_page_url,
+ show_on_trust_center,
+ created_at,
+ updated_at
+FROM
+ vendors
+WHERE
+ %s
+ AND organization_id = @organization_id
+ AND snapshot_id IS NULL
+ORDER BY name ASC
+`
+ q = fmt.Sprintf(q, scope.SQLFragment())
+
+ args := pgx.StrictNamedArgs{"organization_id": organizationID}
+ maps.Copy(args, scope.SQLArguments())
+
+ rows, err := conn.Query(ctx, q, args)
+ if err != nil {
+ return fmt.Errorf("cannot query vendors: %w", err)
+ }
+
+ vendors, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[Vendor])
+ if err != nil {
+ return fmt.Errorf("cannot collect vendors: %w", err)
+ }
+
+ *v = vendors
+
+ return nil
+}
+
func (v *Vendors) LoadByOrganizationID(
ctx context.Context,
conn pg.Querier,
@@ -408,8 +561,6 @@ SELECT
security_page_url,
trust_page_url,
show_on_trust_center,
- snapshot_id,
- source_id,
created_at,
updated_at
FROM
@@ -417,6 +568,7 @@ FROM
WHERE
%s
AND organization_id = @organization_id
+ AND snapshot_id IS NULL
AND %s
AND %s
`
@@ -613,8 +765,6 @@ WITH vend AS (
v.security_page_url,
v.trust_page_url,
v.show_on_trust_center,
- v.snapshot_id,
- v.source_id,
v.created_at,
v.updated_at
FROM
@@ -648,8 +798,6 @@ SELECT
security_page_url,
trust_page_url,
show_on_trust_center,
- snapshot_id,
- source_id,
created_at,
updated_at
FROM
@@ -749,8 +897,6 @@ WITH vend AS (
v.security_page_url,
v.trust_page_url,
v.show_on_trust_center,
- v.snapshot_id,
- v.source_id,
v.created_at,
v.updated_at
FROM
@@ -784,8 +930,6 @@ SELECT
security_page_url,
trust_page_url,
show_on_trust_center,
- snapshot_id,
- source_id,
created_at,
updated_at
FROM
@@ -846,8 +990,6 @@ WITH vend AS (
v.security_page_url,
v.trust_page_url,
v.show_on_trust_center,
- v.snapshot_id,
- v.source_id,
v.created_at,
v.updated_at
FROM
@@ -881,8 +1023,6 @@ SELECT
security_page_url,
trust_page_url,
show_on_trust_center,
- snapshot_id,
- source_id,
created_at,
updated_at
FROM
@@ -944,8 +1084,6 @@ WITH vend AS (
v.security_page_url,
v.trust_page_url,
v.show_on_trust_center,
- v.snapshot_id,
- v.source_id,
v.created_at,
v.updated_at
FROM
@@ -979,8 +1117,6 @@ SELECT
security_page_url,
trust_page_url,
show_on_trust_center,
- snapshot_id,
- source_id,
created_at,
updated_at
FROM
@@ -1034,6 +1170,7 @@ filtered_vendors AS (
vendors v
WHERE
v.tenant_id = @tenant_id
+ AND v.snapshot_id IS NULL
)
SELECT
pav.processing_activity_id,
@@ -1106,8 +1243,6 @@ WITH vend AS (
v.security_page_url,
v.trust_page_url,
v.show_on_trust_center,
- v.snapshot_id,
- v.source_id,
v.created_at,
v.updated_at
FROM
@@ -1141,8 +1276,6 @@ SELECT
security_page_url,
trust_page_url,
show_on_trust_center,
- snapshot_id,
- source_id,
created_at,
updated_at
FROM
@@ -1169,108 +1302,3 @@ ORDER BY name ASC
return nil
}
-
-func (v Vendors) Snapshot(ctx context.Context, conn pg.Tx, scope Scoper, organizationID, snapshotID gid.GID) error {
- for _, snapshotter := range []VendorSnapshotter{
- Vendors{},
- VendorServices{},
- VendorContacts{},
- VendorRiskAssessments{},
- VendorComplianceReports{},
- VendorBusinessAssociateAgreements{},
- VendorDataPrivacyAgreements{},
- } {
- if err := snapshotter.InsertVendorSnapshots(ctx, conn, scope, organizationID, snapshotID); err != nil {
- return fmt.Errorf("cannot create vendor snapshots: (%T) %w", snapshotter, err)
- }
- }
-
- return nil
-}
-
-func (v Vendors) InsertVendorSnapshots(
- ctx context.Context,
- conn pg.Tx,
- scope Scoper,
- organizationID gid.GID,
- snapshotID gid.GID,
-) error {
- query := `
-INSERT INTO vendors (
- tenant_id,
- id,
- snapshot_id,
- source_id,
- organization_id,
- name,
- description,
- category,
- headquarter_address,
- legal_name,
- website_url,
- privacy_policy_url,
- service_level_agreement_url,
- data_processing_agreement_url,
- business_associate_agreement_url,
- subprocessors_list_url,
- certifications,
- countries,
- business_owner_profile_id,
- security_owner_profile_id,
- status_page_url,
- terms_of_service_url,
- security_page_url,
- trust_page_url,
- show_on_trust_center,
- created_at,
- updated_at
-)
-SELECT
- @tenant_id,
- generate_gid(decode_base64_unpadded(@tenant_id), @vendor_entity_type),
- @snapshot_id,
- v.id,
- v.organization_id,
- v.name,
- v.description,
- v.category,
- v.headquarter_address,
- v.legal_name,
- v.website_url,
- v.privacy_policy_url,
- v.service_level_agreement_url,
- v.data_processing_agreement_url,
- v.business_associate_agreement_url,
- v.subprocessors_list_url,
- v.certifications,
- v.countries,
- v.business_owner_profile_id,
- v.security_owner_profile_id,
- v.status_page_url,
- v.terms_of_service_url,
- v.security_page_url,
- v.trust_page_url,
- v.show_on_trust_center,
- v.created_at,
- v.updated_at
-FROM vendors v
-WHERE %s AND organization_id = @organization_id AND snapshot_id IS NULL
- `
-
- query = fmt.Sprintf(query, scope.SQLFragment())
-
- args := pgx.StrictNamedArgs{
- "tenant_id": scope.GetTenantID(),
- "snapshot_id": snapshotID,
- "organization_id": organizationID,
- "vendor_entity_type": VendorEntityType,
- }
- maps.Copy(args, scope.SQLArguments())
-
- _, err := conn.Exec(ctx, query, args)
- if err != nil {
- return fmt.Errorf("cannot insert vendor snapshots: %w", err)
- }
-
- return nil
-}
diff --git a/pkg/coredata/vendor_business_associate_agreement.go b/pkg/coredata/vendor_business_associate_agreement.go
index e8db2f8d6..654c930b2 100644
--- a/pkg/coredata/vendor_business_associate_agreement.go
+++ b/pkg/coredata/vendor_business_associate_agreement.go
@@ -36,8 +36,6 @@ type (
ValidFrom *time.Time `db:"valid_from"`
ValidUntil *time.Time `db:"valid_until"`
FileID gid.GID `db:"file_id"`
- SnapshotID *gid.GID `db:"snapshot_id"`
- SourceID *gid.GID `db:"source_id"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
@@ -84,8 +82,6 @@ SELECT
valid_from,
valid_until,
file_id,
- snapshot_id,
- source_id,
created_at,
updated_at
FROM
@@ -93,6 +89,7 @@ FROM
WHERE
%s
AND vendor_id = @vendor_id
+ AND snapshot_id IS NULL
LIMIT 1;
`
@@ -116,6 +113,60 @@ LIMIT 1;
return nil
}
+func (vbaas *VendorBusinessAssociateAgreements) LoadByVendorIDs(
+ ctx context.Context,
+ conn pg.Querier,
+ scope Scoper,
+ vendorIDs []gid.GID,
+) error {
+ if len(vendorIDs) == 0 {
+ *vbaas = VendorBusinessAssociateAgreements{}
+ return nil
+ }
+
+ q := `
+SELECT
+ id,
+ organization_id,
+ vendor_id,
+ valid_from,
+ valid_until,
+ file_id,
+ created_at,
+ updated_at
+FROM
+ vendor_business_associate_agreements
+WHERE
+ %s
+ AND vendor_id = ANY(@vendor_ids)
+ AND snapshot_id IS NULL
+`
+
+ q = fmt.Sprintf(q, scope.SQLFragment())
+
+ ids := make([]string, len(vendorIDs))
+ for i, id := range vendorIDs {
+ ids[i] = id.String()
+ }
+
+ args := pgx.NamedArgs{"vendor_ids": ids}
+ maps.Copy(args, scope.SQLArguments())
+
+ rows, err := conn.Query(ctx, q, args)
+ if err != nil {
+ return fmt.Errorf("cannot query vendor business associate agreements: %w", err)
+ }
+
+ agreements, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[VendorBusinessAssociateAgreement])
+ if err != nil {
+ return fmt.Errorf("cannot collect vendor business associate agreements: %w", err)
+ }
+
+ *vbaas = agreements
+
+ return nil
+}
+
func (vbaa *VendorBusinessAssociateAgreement) LoadByID(
ctx context.Context,
conn pg.Querier,
@@ -130,8 +181,6 @@ SELECT
valid_from,
valid_until,
file_id,
- snapshot_id,
- source_id,
created_at,
updated_at
FROM
@@ -215,8 +264,6 @@ INSERT INTO
valid_from,
valid_until,
file_id,
- snapshot_id,
- source_id,
created_at,
updated_at
)
@@ -228,8 +275,6 @@ VALUES (
@valid_from,
@valid_until,
@file_id,
- @snapshot_id,
- @source_id,
@created_at,
@updated_at
)
@@ -238,8 +283,6 @@ ON CONFLICT (organization_id, vendor_id) DO UPDATE SET
valid_from = EXCLUDED.valid_from,
valid_until = EXCLUDED.valid_until,
file_id = EXCLUDED.file_id,
- snapshot_id = EXCLUDED.snapshot_id,
- source_id = EXCLUDED.source_id,
updated_at = EXCLUDED.updated_at
`
args := pgx.StrictNamedArgs{
@@ -250,8 +293,6 @@ ON CONFLICT (organization_id, vendor_id) DO UPDATE SET
"valid_from": vbaa.ValidFrom,
"valid_until": vbaa.ValidUntil,
"file_id": vbaa.FileID,
- "snapshot_id": vbaa.SnapshotID,
- "source_id": vbaa.SourceID,
"created_at": vbaa.CreatedAt,
"updated_at": vbaa.UpdatedAt,
}
@@ -317,65 +358,3 @@ WHERE
_, err := conn.Exec(ctx, q, args)
return err
}
-
-func (v VendorBusinessAssociateAgreements) InsertVendorSnapshots(
- ctx context.Context,
- conn pg.Tx,
- scope Scoper,
- organizationID gid.GID,
- snapshotID gid.GID,
-) error {
- query := `
-WITH
- snapshot_vendors AS (
- SELECT id, source_id
- FROM vendors
- WHERE organization_id = @organization_id AND snapshot_id = @snapshot_id
- )
-INSERT INTO vendor_business_associate_agreements (
- tenant_id,
- id,
- snapshot_id,
- source_id,
- organization_id,
- vendor_id,
- valid_from,
- valid_until,
- file_id,
- created_at,
- updated_at
-)
-SELECT
- @tenant_id,
- generate_gid(decode_base64_unpadded(@tenant_id), @vendor_business_associate_agreement_entity_type),
- @snapshot_id,
- vbaa.id,
- vbaa.organization_id,
- sv.id,
- vbaa.valid_from,
- vbaa.valid_until,
- vbaa.file_id,
- vbaa.created_at,
- vbaa.updated_at
-FROM vendor_business_associate_agreements vbaa
-INNER JOIN snapshot_vendors sv ON sv.source_id = vbaa.vendor_id
-WHERE %s AND vbaa.snapshot_id IS NULL
- `
-
- query = fmt.Sprintf(query, scope.SQLFragment())
-
- args := pgx.StrictNamedArgs{
- "tenant_id": scope.GetTenantID(),
- "snapshot_id": snapshotID,
- "organization_id": organizationID,
- "vendor_business_associate_agreement_entity_type": VendorBusinessAssociateAgreementEntityType,
- }
- maps.Copy(args, scope.SQLArguments())
-
- _, err := conn.Exec(ctx, query, args)
- if err != nil {
- return fmt.Errorf("cannot insert vendor business associate agreement snapshots: %w", err)
- }
-
- return nil
-}
diff --git a/pkg/coredata/vendor_compliance_report.go b/pkg/coredata/vendor_compliance_report.go
index b34a4ccff..cb69ea8e5 100644
--- a/pkg/coredata/vendor_compliance_report.go
+++ b/pkg/coredata/vendor_compliance_report.go
@@ -36,8 +36,6 @@ type (
ValidUntil *time.Time `db:"valid_until"`
ReportName string `db:"report_name"`
ReportFileId *gid.GID `db:"report_file_id"`
- SnapshotID *gid.GID `db:"snapshot_id"`
- SourceID *gid.GID `db:"source_id"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
@@ -86,8 +84,6 @@ SELECT
valid_until,
report_name,
report_file_id,
- snapshot_id,
- source_id,
created_at,
updated_at
FROM
@@ -95,6 +91,7 @@ FROM
WHERE
%s
AND vendor_id = @vendor_id
+ AND snapshot_id IS NULL
AND %s
`
@@ -119,6 +116,63 @@ WHERE
return nil
}
+func (vcs *VendorComplianceReports) LoadByVendorIDs(
+ ctx context.Context,
+ conn pg.Querier,
+ scope Scoper,
+ vendorIDs []gid.GID,
+) error {
+ if len(vendorIDs) == 0 {
+ *vcs = VendorComplianceReports{}
+ return nil
+ }
+
+ q := `
+SELECT
+ id,
+ organization_id,
+ vendor_id,
+ report_date,
+ valid_until,
+ report_name,
+ report_file_id,
+ created_at,
+ updated_at
+FROM
+ vendor_compliance_reports
+WHERE
+ %s
+ AND vendor_id = ANY(@vendor_ids)
+ AND snapshot_id IS NULL
+ORDER BY
+ vendor_id, report_date DESC
+`
+
+ q = fmt.Sprintf(q, scope.SQLFragment())
+
+ ids := make([]string, len(vendorIDs))
+ for i, id := range vendorIDs {
+ ids[i] = id.String()
+ }
+
+ args := pgx.NamedArgs{"vendor_ids": ids}
+ maps.Copy(args, scope.SQLArguments())
+
+ rows, err := conn.Query(ctx, q, args)
+ if err != nil {
+ return fmt.Errorf("cannot query vendor compliance reports: %w", err)
+ }
+
+ vendorComplianceReports, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[VendorComplianceReport])
+ if err != nil {
+ return fmt.Errorf("cannot collect vendor compliance reports: %w", err)
+ }
+
+ *vcs = vendorComplianceReports
+
+ return nil
+}
+
func (vcr *VendorComplianceReport) LoadByID(
ctx context.Context,
conn pg.Querier,
@@ -134,8 +188,6 @@ SELECT
valid_until,
report_name,
report_file_id,
- snapshot_id,
- source_id,
created_at,
updated_at
FROM
@@ -251,67 +303,3 @@ RETURNING report_file_id
}
return nil
}
-
-func (vcrs VendorComplianceReports) InsertVendorSnapshots(
- ctx context.Context,
- conn pg.Tx,
- scope Scoper,
- organizationID gid.GID,
- snapshotID gid.GID,
-) error {
- query := `
-WITH
- snapshot_vendors AS (
- SELECT id, source_id
- FROM vendors
- WHERE organization_id = @organization_id AND snapshot_id = @snapshot_id
- )
-INSERT INTO vendor_compliance_reports (
- tenant_id,
- id,
- organization_id,
- snapshot_id,
- source_id,
- vendor_id,
- report_date,
- valid_until,
- report_name,
- report_file_id,
- created_at,
- updated_at
-)
-SELECT
- @tenant_id,
- generate_gid(decode_base64_unpadded(@tenant_id), @vendor_compliance_report_entity_type),
- @organization_id,
- @snapshot_id,
- vcr.id,
- sv.id,
- vcr.report_date,
- vcr.valid_until,
- vcr.report_name,
- vcr.report_file_id,
- vcr.created_at,
- vcr.updated_at
-FROM vendor_compliance_reports vcr
-INNER JOIN snapshot_vendors sv ON sv.source_id = vcr.vendor_id
-WHERE %s AND vcr.snapshot_id IS NULL
- `
-
- query = fmt.Sprintf(query, scope.SQLFragment())
-
- args := pgx.StrictNamedArgs{
- "tenant_id": scope.GetTenantID(),
- "snapshot_id": snapshotID,
- "organization_id": organizationID,
- "vendor_compliance_report_entity_type": VendorComplianceReportEntityType,
- }
- maps.Copy(args, scope.SQLArguments())
-
- _, err := conn.Exec(ctx, query, args)
- if err != nil {
- return fmt.Errorf("cannot insert vendor compliance report snapshots: %w", err)
- }
-
- return nil
-}
diff --git a/pkg/coredata/vendor_contact.go b/pkg/coredata/vendor_contact.go
index 8d25beef0..96ee9f49f 100644
--- a/pkg/coredata/vendor_contact.go
+++ b/pkg/coredata/vendor_contact.go
@@ -37,8 +37,6 @@ type (
Email *mail.Addr `db:"email"`
Phone *string `db:"phone"`
Role *string `db:"role"`
- SnapshotID *gid.GID `db:"snapshot_id"`
- SourceID *gid.GID `db:"source_id"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
@@ -88,8 +86,6 @@ SELECT
email,
phone,
role,
- snapshot_id,
- source_id,
created_at,
updated_at
FROM
@@ -141,8 +137,6 @@ SELECT
email,
phone,
role,
- snapshot_id,
- source_id,
created_at,
updated_at
FROM
@@ -150,6 +144,7 @@ FROM
WHERE
%s
AND vendor_id = @vendor_id
+ AND snapshot_id IS NULL
AND %s
`
q = fmt.Sprintf(q, scope.SQLFragment(), cursor.SQLFragment())
@@ -176,6 +171,63 @@ WHERE
return nil
}
+func (vc *VendorContacts) LoadByVendorIDs(
+ ctx context.Context,
+ conn pg.Querier,
+ scope Scoper,
+ vendorIDs []gid.GID,
+) error {
+ if len(vendorIDs) == 0 {
+ *vc = VendorContacts{}
+ return nil
+ }
+
+ q := `
+SELECT
+ id,
+ organization_id,
+ vendor_id,
+ full_name,
+ email,
+ phone,
+ role,
+ created_at,
+ updated_at
+FROM
+ vendor_contacts
+WHERE
+ %s
+ AND vendor_id = ANY(@vendor_ids)
+ AND snapshot_id IS NULL
+ORDER BY
+ vendor_id, full_name ASC
+`
+ q = fmt.Sprintf(q, scope.SQLFragment())
+
+ ids := make([]string, len(vendorIDs))
+ for i, id := range vendorIDs {
+ ids[i] = id.String()
+ }
+
+ args := pgx.StrictNamedArgs{"vendor_ids": ids}
+ maps.Copy(args, scope.SQLArguments())
+
+ rows, err := conn.Query(ctx, q, args)
+ if err != nil {
+ return fmt.Errorf("cannot query vendor contacts: %w", err)
+ }
+ defer rows.Close()
+
+ vendorContacts, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[VendorContact])
+ if err != nil {
+ return fmt.Errorf("cannot collect vendor contacts: %w", err)
+ }
+
+ *vc = vendorContacts
+
+ return nil
+}
+
func (vc VendorContact) Insert(
ctx context.Context,
conn pg.Tx,
@@ -296,67 +348,3 @@ WHERE
return nil
}
-
-func (vc VendorContacts) InsertVendorSnapshots(
- ctx context.Context,
- conn pg.Tx,
- scope Scoper,
- organizationID gid.GID,
- snapshotID gid.GID,
-) error {
- query := `
-WITH
- snapshot_vendors AS (
- SELECT id, source_id
- FROM vendors
- WHERE organization_id = @organization_id AND snapshot_id = @snapshot_id
- )
-INSERT INTO vendor_contacts (
- tenant_id,
- id,
- organization_id,
- snapshot_id,
- source_id,
- vendor_id,
- full_name,
- email,
- phone,
- role,
- created_at,
- updated_at
-)
-SELECT
- @tenant_id,
- generate_gid(decode_base64_unpadded(@tenant_id), @vendor_contact_entity_type),
- @organization_id,
- @snapshot_id,
- vc.id,
- sv.id,
- vc.full_name,
- vc.email,
- vc.phone,
- vc.role,
- vc.created_at,
- vc.updated_at
-FROM vendor_contacts vc
-INNER JOIN snapshot_vendors sv ON sv.source_id = vc.vendor_id
-WHERE %s AND vc.snapshot_id IS NULL
- `
-
- query = fmt.Sprintf(query, scope.SQLFragment())
-
- args := pgx.StrictNamedArgs{
- "tenant_id": scope.GetTenantID(),
- "snapshot_id": snapshotID,
- "organization_id": organizationID,
- "vendor_contact_entity_type": VendorContactEntityType,
- }
- maps.Copy(args, scope.SQLArguments())
-
- _, err := conn.Exec(ctx, query, args)
- if err != nil {
- return fmt.Errorf("cannot insert vendor contact snapshots: %w", err)
- }
-
- return nil
-}
diff --git a/pkg/coredata/vendor_data_privacy_agreement.go b/pkg/coredata/vendor_data_privacy_agreement.go
index 3ee4e861a..648d4ff23 100644
--- a/pkg/coredata/vendor_data_privacy_agreement.go
+++ b/pkg/coredata/vendor_data_privacy_agreement.go
@@ -36,8 +36,6 @@ type (
ValidFrom *time.Time `db:"valid_from"`
ValidUntil *time.Time `db:"valid_until"`
FileID gid.GID `db:"file_id"`
- SnapshotID *gid.GID `db:"snapshot_id"`
- SourceID *gid.GID `db:"source_id"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
@@ -84,8 +82,6 @@ SELECT
valid_from,
valid_until,
file_id,
- snapshot_id,
- source_id,
created_at,
updated_at
FROM
@@ -93,6 +89,7 @@ FROM
WHERE
%s
AND vendor_id = @vendor_id
+ AND snapshot_id IS NULL
LIMIT 1;
`
@@ -116,6 +113,60 @@ LIMIT 1;
return nil
}
+func (vdpas *VendorDataPrivacyAgreements) LoadByVendorIDs(
+ ctx context.Context,
+ conn pg.Querier,
+ scope Scoper,
+ vendorIDs []gid.GID,
+) error {
+ if len(vendorIDs) == 0 {
+ *vdpas = VendorDataPrivacyAgreements{}
+ return nil
+ }
+
+ q := `
+SELECT
+ id,
+ organization_id,
+ vendor_id,
+ valid_from,
+ valid_until,
+ file_id,
+ created_at,
+ updated_at
+FROM
+ vendor_data_privacy_agreements
+WHERE
+ %s
+ AND vendor_id = ANY(@vendor_ids)
+ AND snapshot_id IS NULL
+`
+
+ q = fmt.Sprintf(q, scope.SQLFragment())
+
+ ids := make([]string, len(vendorIDs))
+ for i, id := range vendorIDs {
+ ids[i] = id.String()
+ }
+
+ args := pgx.NamedArgs{"vendor_ids": ids}
+ maps.Copy(args, scope.SQLArguments())
+
+ rows, err := conn.Query(ctx, q, args)
+ if err != nil {
+ return fmt.Errorf("cannot query vendor data privacy agreements: %w", err)
+ }
+
+ agreements, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[VendorDataPrivacyAgreement])
+ if err != nil {
+ return fmt.Errorf("cannot collect vendor data privacy agreements: %w", err)
+ }
+
+ *vdpas = agreements
+
+ return nil
+}
+
func (vdpa *VendorDataPrivacyAgreement) LoadByID(
ctx context.Context,
conn pg.Querier,
@@ -130,8 +181,6 @@ SELECT
valid_from,
valid_until,
file_id,
- snapshot_id,
- source_id,
created_at,
updated_at
FROM
@@ -215,8 +264,6 @@ INSERT INTO
valid_from,
valid_until,
file_id,
- snapshot_id,
- source_id,
created_at,
updated_at
)
@@ -228,8 +275,6 @@ VALUES (
@valid_from,
@valid_until,
@file_id,
- @snapshot_id,
- @source_id,
@created_at,
@updated_at
)
@@ -238,8 +283,6 @@ ON CONFLICT (organization_id, vendor_id) DO UPDATE SET
valid_from = EXCLUDED.valid_from,
valid_until = EXCLUDED.valid_until,
file_id = EXCLUDED.file_id,
- snapshot_id = EXCLUDED.snapshot_id,
- source_id = EXCLUDED.source_id,
updated_at = EXCLUDED.updated_at
`
args := pgx.StrictNamedArgs{
@@ -250,8 +293,6 @@ ON CONFLICT (organization_id, vendor_id) DO UPDATE SET
"valid_from": vdpa.ValidFrom,
"valid_until": vdpa.ValidUntil,
"file_id": vdpa.FileID,
- "snapshot_id": vdpa.SnapshotID,
- "source_id": vdpa.SourceID,
"created_at": vdpa.CreatedAt,
"updated_at": vdpa.UpdatedAt,
}
@@ -316,65 +357,3 @@ WHERE
_, err := conn.Exec(ctx, q, args)
return err
}
-
-func (vdpa VendorDataPrivacyAgreements) InsertVendorSnapshots(
- ctx context.Context,
- conn pg.Tx,
- scope Scoper,
- organizationID gid.GID,
- snapshotID gid.GID,
-) error {
- query := `
-WITH
- snapshot_vendors AS (
- SELECT id, source_id
- FROM vendors
- WHERE organization_id = @organization_id AND snapshot_id = @snapshot_id
- )
-INSERT INTO vendor_data_privacy_agreements (
- tenant_id,
- id,
- snapshot_id,
- source_id,
- organization_id,
- vendor_id,
- valid_from,
- valid_until,
- file_id,
- created_at,
- updated_at
-)
-SELECT
- @tenant_id,
- generate_gid(decode_base64_unpadded(@tenant_id), @vendor_data_privacy_agreement_entity_type),
- @snapshot_id,
- vdpa.id,
- vdpa.organization_id,
- sv.id,
- vdpa.valid_from,
- vdpa.valid_until,
- vdpa.file_id,
- vdpa.created_at,
- vdpa.updated_at
-FROM vendor_data_privacy_agreements vdpa
-INNER JOIN snapshot_vendors sv ON sv.source_id = vdpa.vendor_id
-WHERE %s AND vdpa.snapshot_id IS NULL
- `
-
- query = fmt.Sprintf(query, scope.SQLFragment())
-
- args := pgx.StrictNamedArgs{
- "tenant_id": scope.GetTenantID(),
- "snapshot_id": snapshotID,
- "organization_id": organizationID,
- "vendor_data_privacy_agreement_entity_type": VendorDataPrivacyAgreementEntityType,
- }
- maps.Copy(args, scope.SQLArguments())
-
- _, err := conn.Exec(ctx, query, args)
- if err != nil {
- return fmt.Errorf("cannot insert vendor data privacy agreement snapshots: %w", err)
- }
-
- return nil
-}
diff --git a/pkg/coredata/vendor_filter.go b/pkg/coredata/vendor_filter.go
index 47e5b3916..0df713933 100644
--- a/pkg/coredata/vendor_filter.go
+++ b/pkg/coredata/vendor_filter.go
@@ -16,19 +16,16 @@ package coredata
import (
"github.com/jackc/pgx/v5"
- "go.probo.inc/probo/pkg/gid"
)
type (
VendorFilter struct {
showOnTrustCenter *bool
- snapshotID **gid.GID
}
)
-func NewVendorFilter(snapshotID **gid.GID, showOnTrustCenter *bool) *VendorFilter {
+func NewVendorFilter(showOnTrustCenter *bool) *VendorFilter {
return &VendorFilter{
- snapshotID: snapshotID,
showOnTrustCenter: showOnTrustCenter,
}
}
@@ -42,17 +39,6 @@ func (f *VendorFilter) SQLArguments() pgx.StrictNamedArgs {
args["show_on_trust_center"] = nil
}
- if f.snapshotID == nil {
- args["has_snapshot_filter"] = false
- args["filter_snapshot_id"] = nil
- } else if *f.snapshotID == nil {
- args["has_snapshot_filter"] = true
- args["filter_snapshot_id"] = nil
- } else {
- args["has_snapshot_filter"] = true
- args["filter_snapshot_id"] = **f.snapshotID
- }
-
return args
}
@@ -64,14 +50,5 @@ func (f *VendorFilter) SQLFragment() string {
show_on_trust_center = @show_on_trust_center::boolean
ELSE TRUE
END
- AND
- CASE
- WHEN @has_snapshot_filter::boolean = false THEN TRUE
- WHEN @has_snapshot_filter::boolean = true AND @filter_snapshot_id::text IS NOT NULL THEN
- snapshot_id = @filter_snapshot_id::text
- WHEN @has_snapshot_filter::boolean = true AND @filter_snapshot_id::text IS NULL THEN
- snapshot_id IS NULL
- ELSE TRUE
- END
)`
}
diff --git a/pkg/coredata/vendor_risk_assessment.go b/pkg/coredata/vendor_risk_assessment.go
index c28069bd0..64b06702c 100644
--- a/pkg/coredata/vendor_risk_assessment.go
+++ b/pkg/coredata/vendor_risk_assessment.go
@@ -37,8 +37,6 @@ type (
DataSensitivity DataSensitivity `db:"data_sensitivity"`
BusinessImpact BusinessImpact `db:"business_impact"`
Notes *string `db:"notes"`
- SnapshotID *gid.GID `db:"snapshot_id"`
- SourceID *gid.GID `db:"source_id"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
@@ -137,8 +135,6 @@ SELECT
data_sensitivity,
business_impact,
notes,
- snapshot_id,
- source_id,
created_at,
updated_at
FROM
@@ -186,8 +182,6 @@ SELECT
data_sensitivity,
business_impact,
notes,
- snapshot_id,
- source_id,
created_at,
updated_at
FROM
@@ -195,6 +189,7 @@ FROM
WHERE
%s
AND vendor_id = @vendor_id
+ AND snapshot_id IS NULL
ORDER BY
created_at DESC
LIMIT 1;
@@ -238,8 +233,6 @@ SELECT
data_sensitivity,
business_impact,
notes,
- snapshot_id,
- source_id,
created_at,
updated_at
FROM
@@ -247,6 +240,7 @@ FROM
WHERE
%s
AND vendor_id = @vendor_id
+ AND snapshot_id IS NULL
AND %s
`
@@ -271,66 +265,59 @@ WHERE
return nil
}
-func (v VendorRiskAssessments) InsertVendorSnapshots(
+func (r *VendorRiskAssessments) LoadByVendorIDs(
ctx context.Context,
- conn pg.Tx,
+ conn pg.Querier,
scope Scoper,
- organizationID gid.GID,
- snapshotID gid.GID,
+ vendorIDs []gid.GID,
) error {
- query := `
-WITH
- snapshot_vendors AS (
- SELECT id, source_id
- FROM vendors
- WHERE organization_id = @organization_id AND snapshot_id = @snapshot_id
- )
-INSERT INTO vendor_risk_assessments (
- tenant_id,
- id,
- snapshot_id,
- source_id,
- organization_id,
- vendor_id,
- expires_at,
- data_sensitivity,
- business_impact,
- notes,
- created_at,
- updated_at
-)
-SELECT
- @tenant_id,
- generate_gid(decode_base64_unpadded(@tenant_id), @vendor_risk_assessment_entity_type),
- @snapshot_id,
- vra.id,
- vra.organization_id,
- sv.id,
- vra.expires_at,
- vra.data_sensitivity,
- vra.business_impact,
- vra.notes,
- vra.created_at,
- vra.updated_at
-FROM vendor_risk_assessments vra
-INNER JOIN snapshot_vendors sv ON sv.source_id = vra.vendor_id
-WHERE %s AND vra.snapshot_id IS NULL
- `
-
- query = fmt.Sprintf(query, scope.SQLFragment())
-
- args := pgx.StrictNamedArgs{
- "tenant_id": scope.GetTenantID(),
- "snapshot_id": snapshotID,
- "organization_id": organizationID,
- "vendor_risk_assessment_entity_type": VendorRiskAssessmentEntityType,
+ if len(vendorIDs) == 0 {
+ *r = VendorRiskAssessments{}
+ return nil
}
+
+ q := `
+SELECT
+ id,
+ organization_id,
+ vendor_id,
+ expires_at,
+ data_sensitivity,
+ business_impact,
+ notes,
+ created_at,
+ updated_at
+FROM
+ vendor_risk_assessments
+WHERE
+ %s
+ AND vendor_id = ANY(@vendor_ids)
+ AND snapshot_id IS NULL
+ORDER BY
+ vendor_id, created_at DESC
+`
+
+ q = fmt.Sprintf(q, scope.SQLFragment())
+
+ ids := make([]string, len(vendorIDs))
+ for i, id := range vendorIDs {
+ ids[i] = id.String()
+ }
+
+ args := pgx.StrictNamedArgs{"vendor_ids": ids}
maps.Copy(args, scope.SQLArguments())
- _, err := conn.Exec(ctx, query, args)
+ rows, err := conn.Query(ctx, q, args)
if err != nil {
- return fmt.Errorf("cannot insert vendor risk assessment snapshots: %w", err)
+ return fmt.Errorf("cannot query risk assessments: %w", err)
}
+ assessments, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[VendorRiskAssessment])
+ if err != nil {
+ return fmt.Errorf("cannot collect risk assessments: %w", err)
+ }
+
+ *r = assessments
+
return nil
}
diff --git a/pkg/coredata/vendor_service.go b/pkg/coredata/vendor_service.go
index 0ed62ad90..79170d135 100644
--- a/pkg/coredata/vendor_service.go
+++ b/pkg/coredata/vendor_service.go
@@ -34,8 +34,6 @@ type (
VendorID gid.GID `db:"vendor_id"`
Name string `db:"name"`
Description *string `db:"description"`
- SnapshotID *gid.GID `db:"snapshot_id"`
- SourceID *gid.GID `db:"source_id"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
@@ -81,8 +79,6 @@ SELECT
vendor_id,
name,
description,
- snapshot_id,
- source_id,
created_at,
updated_at
FROM
@@ -132,8 +128,6 @@ SELECT
vendor_id,
name,
description,
- snapshot_id,
- source_id,
created_at,
updated_at
FROM
@@ -141,6 +135,7 @@ FROM
WHERE
%s
AND vendor_id = @vendor_id
+ AND snapshot_id IS NULL
AND %s
`
q = fmt.Sprintf(q, scope.SQLFragment(), cursor.SQLFragment())
@@ -167,6 +162,61 @@ WHERE
return nil
}
+func (vs *VendorServices) LoadByVendorIDs(
+ ctx context.Context,
+ conn pg.Querier,
+ scope Scoper,
+ vendorIDs []gid.GID,
+) error {
+ if len(vendorIDs) == 0 {
+ *vs = VendorServices{}
+ return nil
+ }
+
+ q := `
+SELECT
+ id,
+ organization_id,
+ vendor_id,
+ name,
+ description,
+ created_at,
+ updated_at
+FROM
+ vendor_services
+WHERE
+ %s
+ AND vendor_id = ANY(@vendor_ids)
+ AND snapshot_id IS NULL
+ORDER BY
+ vendor_id, name ASC
+`
+ q = fmt.Sprintf(q, scope.SQLFragment())
+
+ ids := make([]string, len(vendorIDs))
+ for i, id := range vendorIDs {
+ ids[i] = id.String()
+ }
+
+ args := pgx.StrictNamedArgs{"vendor_ids": ids}
+ maps.Copy(args, scope.SQLArguments())
+
+ rows, err := conn.Query(ctx, q, args)
+ if err != nil {
+ return fmt.Errorf("cannot query vendor services: %w", err)
+ }
+ defer rows.Close()
+
+ vendorServices, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[VendorService])
+ if err != nil {
+ return fmt.Errorf("cannot collect vendor services: %w", err)
+ }
+
+ *vs = vendorServices
+
+ return nil
+}
+
func (vs VendorService) Insert(
ctx context.Context,
conn pg.Tx,
@@ -277,63 +327,3 @@ WHERE
return nil
}
-
-func (vs VendorServices) InsertVendorSnapshots(
- ctx context.Context,
- conn pg.Tx,
- scope Scoper,
- organizationID gid.GID,
- snapshotID gid.GID,
-) error {
- query := `
-WITH
- snapshot_vendors AS (
- SELECT id, source_id
- FROM vendors
- WHERE organization_id = @organization_id AND snapshot_id = @snapshot_id
- )
-INSERT INTO vendor_services (
- tenant_id,
- id,
- organization_id,
- snapshot_id,
- source_id,
- vendor_id,
- name,
- description,
- created_at,
- updated_at
-)
-SELECT
- @tenant_id,
- generate_gid(decode_base64_unpadded(@tenant_id), @vendor_service_entity_type),
- @organization_id,
- @snapshot_id,
- vs.id,
- sv.id,
- vs.name,
- vs.description,
- vs.created_at,
- vs.updated_at
-FROM vendor_services vs
-INNER JOIN snapshot_vendors sv ON sv.source_id = vs.vendor_id
-WHERE %s AND vs.snapshot_id IS NULL
- `
-
- query = fmt.Sprintf(query, scope.SQLFragment())
-
- args := pgx.StrictNamedArgs{
- "tenant_id": scope.GetTenantID(),
- "snapshot_id": snapshotID,
- "organization_id": organizationID,
- "vendor_service_entity_type": VendorServiceEntityType,
- }
- maps.Copy(args, scope.SQLArguments())
-
- _, err := conn.Exec(ctx, query, args)
- if err != nil {
- return fmt.Errorf("cannot insert vendor service snapshots: %w", err)
- }
-
- return nil
-}
diff --git a/pkg/docgen/generator.go b/pkg/docgen/generator.go
index f5c1d71b9..7a6d6362f 100644
--- a/pkg/docgen/generator.go
+++ b/pkg/docgen/generator.go
@@ -366,6 +366,73 @@ type (
LocalLawRisk string
SupplementaryMeasures string
}
+
+ VendorListData struct {
+ Title string
+ OrganizationName string
+ CreatedAt time.Time
+ TotalVendors int
+ Rows []VendorListRow
+ }
+
+ VendorListRow struct {
+ Name string
+ LegalName string
+ Description string
+ Category string
+ HeadquarterAddress string
+ WebsiteURL string
+ PrivacyPolicyURL string
+ ServiceLevelAgreementURL string
+ DataProcessingAgreementURL string
+ BusinessAssociateAgreementURL string
+ SubprocessorsListURL string
+ StatusPageURL string
+ TermsOfServiceURL string
+ SecurityPageURL string
+ TrustPageURL string
+ Certifications string
+ Countries string
+ BusinessOwner string
+ SecurityOwner string
+ Services []VendorListService
+ Contacts []VendorListContact
+ RiskAssessments []VendorListRiskAssessment
+ ComplianceReports []VendorListComplianceReport
+ BusinessAssociateAgreement *VendorListAgreement
+ DataPrivacyAgreement *VendorListAgreement
+ }
+
+ VendorListService struct {
+ Name string
+ Description string
+ }
+
+ VendorListContact struct {
+ FullName string
+ Email string
+ Phone string
+ Role string
+ }
+
+ VendorListRiskAssessment struct {
+ AssessedAt string
+ ExpiresAt string
+ DataSensitivity string
+ BusinessImpact string
+ Notes string
+ }
+
+ VendorListComplianceReport struct {
+ ReportName string
+ ReportDate string
+ ValidUntil string
+ }
+
+ VendorListAgreement struct {
+ ValidFrom string
+ ValidUntil string
+ }
)
func BoolLabel(v bool) string {
diff --git a/pkg/probo/actions.go b/pkg/probo/actions.go
index 45ffd8064..98e7ab962 100644
--- a/pkg/probo/actions.go
+++ b/pkg/probo/actions.go
@@ -84,12 +84,13 @@ const (
ActionTrustCenterFileCreate = "core:trust-center-file:create"
// Vendor actions
- ActionVendorList = "core:vendor:list"
- ActionVendorGet = "core:vendor:get"
- ActionVendorCreate = "core:vendor:create"
- ActionVendorUpdate = "core:vendor:update"
- ActionVendorDelete = "core:vendor:delete"
- ActionVendorAssess = "core:vendor:assess"
+ ActionVendorList = "core:vendor:list"
+ ActionVendorGet = "core:vendor:get"
+ ActionVendorCreate = "core:vendor:create"
+ ActionVendorUpdate = "core:vendor:update"
+ ActionVendorDelete = "core:vendor:delete"
+ ActionVendorAssess = "core:vendor:assess"
+ ActionVendorPublish = "core:vendor:publish"
// VendorContact actions
ActionVendorContactGet = "core:vendor-contact:get"
diff --git a/pkg/probo/generated_document_service.go b/pkg/probo/generated_document_service.go
index 0e826a315..7fb0dba37 100644
--- a/pkg/probo/generated_document_service.go
+++ b/pkg/probo/generated_document_service.go
@@ -2563,3 +2563,536 @@ func BuildTransferImpactAssessmentListDocument(data docgen.TransferImpactAssessm
}
return buf.String(), nil
}
+
+func (s *GeneratedDocumentService) PublishVendorList(
+ ctx context.Context,
+ organizationID gid.GID,
+ approverIDs []gid.GID,
+) (*coredata.Document, *coredata.DocumentVersion, error) {
+ // Phase 1: collect data and render the prosemirror document outside any
+ // write transaction. Both the bulk reads of vendors + sub-entities and the
+ // JSON template rendering are slow enough that holding write locks across
+ // them would needlessly block other writers.
+ var documentData docgen.VendorListData
+ err := s.svc.pg.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error {
+ organization := &coredata.Organization{}
+ if err := organization.LoadByID(ctx, conn, s.svc.scope, organizationID); err != nil {
+ return fmt.Errorf("cannot load organization: %w", err)
+ }
+
+ var err error
+ documentData, err = s.buildVendorListDocumentData(ctx, conn, organization)
+ if err != nil {
+ return fmt.Errorf("cannot build document data: %w", err)
+ }
+ return nil
+ })
+ if err != nil {
+ return nil, nil, err
+ }
+
+ prosemirrorJSON, err := BuildVendorListDocument(documentData)
+ if err != nil {
+ return nil, nil, fmt.Errorf("cannot build prosemirror document: %w", err)
+ }
+
+ // Phase 2: persist the document and version in a write transaction.
+ var (
+ document *coredata.Document
+ documentVersion *coredata.DocumentVersion
+ )
+
+ err = s.svc.pg.WithTx(
+ ctx,
+ func(ctx context.Context, tx pg.Tx) error {
+ now := time.Now()
+
+ vendor := coredata.Vendor{}
+ vendorDocumentID, err := vendor.GetGeneratedDocumentID(ctx, tx, organizationID)
+ if err != nil {
+ return fmt.Errorf("cannot query generated documents: %w", err)
+ }
+
+ var existingDoc *coredata.Document
+ if vendorDocumentID != nil {
+ doc := &coredata.Document{}
+ err = doc.LoadByID(ctx, tx, s.svc.scope, *vendorDocumentID)
+ if err != nil && !errors.Is(err, coredata.ErrResourceNotFound) {
+ return fmt.Errorf("cannot load vendor list document: %w", err)
+ }
+
+ if err == nil && doc.ArchivedAt == nil {
+ existingDoc = doc
+ } else {
+ if err := vendor.ClearGeneratedDocumentID(ctx, tx, []gid.GID{*vendorDocumentID}); err != nil {
+ return fmt.Errorf("cannot clear document reference: %w", err)
+ }
+ }
+ }
+
+ hasApprovers := len(approverIDs) > 0
+
+ if existingDoc == nil {
+ documentID := gid.New(s.svc.scope.GetTenantID(), coredata.DocumentEntityType)
+
+ document = &coredata.Document{
+ ID: documentID,
+ OrganizationID: organizationID,
+ WriteMode: coredata.DocumentWriteModeGenerated,
+ TrustCenterVisibility: coredata.TrustCenterVisibilityNone,
+ Status: coredata.DocumentStatusActive,
+ CreatedAt: now,
+ UpdatedAt: now,
+ }
+
+ if err := document.Insert(ctx, tx, s.svc.scope); err != nil {
+ return fmt.Errorf("cannot insert document: %w", err)
+ }
+
+ if err := vendor.UpsertGeneratedDocumentID(ctx, tx, organizationID, s.svc.scope.GetTenantID(), documentID); err != nil {
+ return fmt.Errorf("cannot upsert generated documents: %w", err)
+ }
+ } else {
+ document = existingDoc
+ }
+
+ var newMajor int
+ if document.CurrentPublishedMajor != nil {
+ newMajor = *document.CurrentPublishedMajor + 1
+ } else {
+ newMajor = 1
+ }
+
+ versionStatus := coredata.DocumentVersionStatusPublished
+ var publishedAt *time.Time
+ if hasApprovers {
+ versionStatus = coredata.DocumentVersionStatusDraft
+ } else {
+ publishedAt = &now
+ }
+
+ documentVersionID := gid.New(s.svc.scope.GetTenantID(), coredata.DocumentVersionEntityType)
+ documentVersion = &coredata.DocumentVersion{
+ ID: documentVersionID,
+ OrganizationID: organizationID,
+ DocumentID: document.ID,
+ Title: "Vendors",
+ Major: newMajor,
+ Minor: 0,
+ Content: prosemirrorJSON,
+ Status: versionStatus,
+ Classification: coredata.DocumentClassificationConfidential,
+ DocumentType: coredata.DocumentTypeRegister,
+ Orientation: coredata.DocumentVersionOrientationPortrait,
+ PublishedAt: publishedAt,
+ CreatedAt: now,
+ UpdatedAt: now,
+ }
+
+ if err := documentVersion.Insert(ctx, tx, s.svc.scope); err != nil {
+ if errors.Is(err, coredata.ErrResourceAlreadyExists) {
+ return fmt.Errorf("a version is pending approval, approve or reject it before publishing a new one: %w", err)
+ }
+ return fmt.Errorf("cannot insert document version: %w", err)
+ }
+
+ if hasApprovers {
+ defaultApprovers := &coredata.DocumentDefaultApprovers{}
+ if err := defaultApprovers.MergeByDocumentID(ctx, tx, s.svc.scope, document.ID, organizationID, approverIDs); err != nil {
+ return fmt.Errorf("cannot save default approvers: %w", err)
+ }
+
+ _, err := s.svc.DocumentApprovals.RequestApprovalInTx(
+ ctx,
+ tx,
+ document,
+ documentVersion,
+ approverIDs,
+ nil,
+ )
+ if err != nil {
+ return fmt.Errorf("cannot request approval: %w", err)
+ }
+ } else {
+ zero := 0
+ document.CurrentPublishedMajor = &newMajor
+ document.CurrentPublishedMinor = &zero
+ document.UpdatedAt = now
+
+ if err := document.Update(ctx, tx, s.svc.scope); err != nil {
+ return fmt.Errorf("cannot update document: %w", err)
+ }
+ }
+
+ return nil
+ },
+ )
+
+ if err != nil {
+ return nil, nil, err
+ }
+
+ return document, documentVersion, nil
+}
+
+func (s *GeneratedDocumentService) GetVendorsDocumentID(
+ ctx context.Context,
+ organizationID gid.GID,
+) (*gid.GID, error) {
+ var documentID *gid.GID
+
+ err := s.svc.pg.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error {
+ vendor := coredata.Vendor{}
+ var err error
+ documentID, err = vendor.GetGeneratedDocumentID(ctx, conn, organizationID)
+ return err
+ })
+ if err != nil {
+ return nil, fmt.Errorf("cannot get vendor list document ID: %w", err)
+ }
+
+ return documentID, nil
+}
+
+func (s *GeneratedDocumentService) buildVendorListDocumentData(
+ ctx context.Context,
+ conn pg.Querier,
+ organization *coredata.Organization,
+) (docgen.VendorListData, error) {
+ var vendors coredata.Vendors
+ if err := vendors.LoadAllByOrganizationID(ctx, conn, s.svc.scope, organization.ID); err != nil {
+ return docgen.VendorListData{}, fmt.Errorf("cannot load vendors: %w", err)
+ }
+
+ if len(vendors) == 0 {
+ return docgen.VendorListData{
+ Title: "Vendors",
+ OrganizationName: organization.Name,
+ CreatedAt: time.Now(),
+ TotalVendors: 0,
+ }, nil
+ }
+
+ ownerIDSet := make(map[gid.GID]struct{})
+ ownerIDs := make([]gid.GID, 0)
+ for _, v := range vendors {
+ if v.BusinessOwnerID != nil {
+ if _, ok := ownerIDSet[*v.BusinessOwnerID]; !ok {
+ ownerIDs = append(ownerIDs, *v.BusinessOwnerID)
+ ownerIDSet[*v.BusinessOwnerID] = struct{}{}
+ }
+ }
+ if v.SecurityOwnerID != nil {
+ if _, ok := ownerIDSet[*v.SecurityOwnerID]; !ok {
+ ownerIDs = append(ownerIDs, *v.SecurityOwnerID)
+ ownerIDSet[*v.SecurityOwnerID] = struct{}{}
+ }
+ }
+ }
+
+ profileMap := make(map[gid.GID]*coredata.MembershipProfile)
+ if len(ownerIDs) > 0 {
+ var profiles coredata.MembershipProfiles
+ if err := profiles.LoadByIDs(ctx, conn, s.svc.scope, ownerIDs); err != nil {
+ return docgen.VendorListData{}, fmt.Errorf("cannot load owner profiles: %w", err)
+ }
+ for _, p := range profiles {
+ profileMap[p.ID] = p
+ }
+ }
+
+ vendorIDs := make([]gid.GID, len(vendors))
+ for i, v := range vendors {
+ vendorIDs[i] = v.ID
+ }
+
+ var allServices coredata.VendorServices
+ if err := allServices.LoadByVendorIDs(ctx, conn, s.svc.scope, vendorIDs); err != nil {
+ return docgen.VendorListData{}, fmt.Errorf("cannot load vendor services: %w", err)
+ }
+ servicesByVendor := make(map[gid.GID]coredata.VendorServices, len(vendors))
+ for _, vs := range allServices {
+ servicesByVendor[vs.VendorID] = append(servicesByVendor[vs.VendorID], vs)
+ }
+
+ var allContacts coredata.VendorContacts
+ if err := allContacts.LoadByVendorIDs(ctx, conn, s.svc.scope, vendorIDs); err != nil {
+ return docgen.VendorListData{}, fmt.Errorf("cannot load vendor contacts: %w", err)
+ }
+ contactsByVendor := make(map[gid.GID]coredata.VendorContacts, len(vendors))
+ for _, c := range allContacts {
+ contactsByVendor[c.VendorID] = append(contactsByVendor[c.VendorID], c)
+ }
+
+ var allAssessments coredata.VendorRiskAssessments
+ if err := allAssessments.LoadByVendorIDs(ctx, conn, s.svc.scope, vendorIDs); err != nil {
+ return docgen.VendorListData{}, fmt.Errorf("cannot load vendor risk assessments: %w", err)
+ }
+ assessmentsByVendor := make(map[gid.GID]coredata.VendorRiskAssessments, len(vendors))
+ for _, ra := range allAssessments {
+ assessmentsByVendor[ra.VendorID] = append(assessmentsByVendor[ra.VendorID], ra)
+ }
+
+ var allReports coredata.VendorComplianceReports
+ if err := allReports.LoadByVendorIDs(ctx, conn, s.svc.scope, vendorIDs); err != nil {
+ return docgen.VendorListData{}, fmt.Errorf("cannot load vendor compliance reports: %w", err)
+ }
+ reportsByVendor := make(map[gid.GID]coredata.VendorComplianceReports, len(vendors))
+ for _, r := range allReports {
+ reportsByVendor[r.VendorID] = append(reportsByVendor[r.VendorID], r)
+ }
+
+ var allBAAs coredata.VendorBusinessAssociateAgreements
+ if err := allBAAs.LoadByVendorIDs(ctx, conn, s.svc.scope, vendorIDs); err != nil {
+ return docgen.VendorListData{}, fmt.Errorf("cannot load vendor business associate agreements: %w", err)
+ }
+ baaByVendor := make(map[gid.GID]*coredata.VendorBusinessAssociateAgreement, len(allBAAs))
+ for _, b := range allBAAs {
+ baaByVendor[b.VendorID] = b
+ }
+
+ var allDPAs coredata.VendorDataPrivacyAgreements
+ if err := allDPAs.LoadByVendorIDs(ctx, conn, s.svc.scope, vendorIDs); err != nil {
+ return docgen.VendorListData{}, fmt.Errorf("cannot load vendor data privacy agreements: %w", err)
+ }
+ dpaByVendor := make(map[gid.GID]*coredata.VendorDataPrivacyAgreement, len(allDPAs))
+ for _, d := range allDPAs {
+ dpaByVendor[d.VendorID] = d
+ }
+
+ rows := make([]docgen.VendorListRow, 0, len(vendors))
+ for _, v := range vendors {
+ row := docgen.VendorListRow{
+ Name: v.Name,
+ LegalName: derefStringOrNotSpecified(v.LegalName),
+ Description: derefStringOrNotSpecified(v.Description),
+ Category: formatVendorCategory(v.Category),
+ HeadquarterAddress: derefStringOrNotSpecified(v.HeadquarterAddress),
+ WebsiteURL: derefStringOrNotSpecified(v.WebsiteURL),
+ PrivacyPolicyURL: derefStringOrNotSpecified(v.PrivacyPolicyURL),
+ ServiceLevelAgreementURL: derefStringOrNotSpecified(v.ServiceLevelAgreementURL),
+ DataProcessingAgreementURL: derefStringOrNotSpecified(v.DataProcessingAgreementURL),
+ BusinessAssociateAgreementURL: derefStringOrNotSpecified(v.BusinessAssociateAgreementURL),
+ SubprocessorsListURL: derefStringOrNotSpecified(v.SubprocessorsListURL),
+ StatusPageURL: derefStringOrNotSpecified(v.StatusPageURL),
+ TermsOfServiceURL: derefStringOrNotSpecified(v.TermsOfServiceURL),
+ SecurityPageURL: derefStringOrNotSpecified(v.SecurityPageURL),
+ TrustPageURL: derefStringOrNotSpecified(v.TrustPageURL),
+ Certifications: joinOrNotSpecified(v.Certifications),
+ Countries: formatCountries(v.Countries),
+ BusinessOwner: lookupProfileName(profileMap, v.BusinessOwnerID),
+ SecurityOwner: lookupProfileName(profileMap, v.SecurityOwnerID),
+ }
+
+ for _, vs := range servicesByVendor[v.ID] {
+ row.Services = append(row.Services, docgen.VendorListService{
+ Name: vs.Name,
+ Description: derefStringOrNotSpecified(vs.Description),
+ })
+ }
+
+ for _, c := range contactsByVendor[v.ID] {
+ email := ""
+ if c.Email != nil {
+ email = c.Email.String()
+ }
+ row.Contacts = append(row.Contacts, docgen.VendorListContact{
+ FullName: derefStringOrNotSpecified(c.FullName),
+ Email: stringOrNotSpecified(email),
+ Phone: derefStringOrNotSpecified(c.Phone),
+ Role: derefStringOrNotSpecified(c.Role),
+ })
+ }
+
+ for _, ra := range assessmentsByVendor[v.ID] {
+ row.RiskAssessments = append(row.RiskAssessments, docgen.VendorListRiskAssessment{
+ AssessedAt: ra.CreatedAt.Format("2006-01-02"),
+ ExpiresAt: ra.ExpiresAt.Format("2006-01-02"),
+ DataSensitivity: formatDataSensitivity(ra.DataSensitivity),
+ BusinessImpact: formatBusinessImpact(ra.BusinessImpact),
+ Notes: derefStringOrNotSpecified(ra.Notes),
+ })
+ }
+
+ for _, r := range reportsByVendor[v.ID] {
+ row.ComplianceReports = append(row.ComplianceReports, docgen.VendorListComplianceReport{
+ ReportName: r.ReportName,
+ ReportDate: r.ReportDate.Format("2006-01-02"),
+ ValidUntil: formatTimeOrNotSpecified(r.ValidUntil),
+ })
+ }
+
+ if baa := baaByVendor[v.ID]; baa != nil {
+ row.BusinessAssociateAgreement = &docgen.VendorListAgreement{
+ ValidFrom: formatTimeOrNotSpecified(baa.ValidFrom),
+ ValidUntil: formatTimeOrNotSpecified(baa.ValidUntil),
+ }
+ }
+
+ if dpa := dpaByVendor[v.ID]; dpa != nil {
+ row.DataPrivacyAgreement = &docgen.VendorListAgreement{
+ ValidFrom: formatTimeOrNotSpecified(dpa.ValidFrom),
+ ValidUntil: formatTimeOrNotSpecified(dpa.ValidUntil),
+ }
+ }
+
+ rows = append(rows, row)
+ }
+
+ return docgen.VendorListData{
+ Title: "Vendors",
+ OrganizationName: organization.Name,
+ CreatedAt: time.Now(),
+ TotalVendors: len(vendors),
+ Rows: rows,
+ }, nil
+}
+
+func stringOrNotSpecified(s string) string {
+ if s == "" {
+ return "Not specified"
+ }
+ return s
+}
+
+func formatTimeOrNotSpecified(t *time.Time) string {
+ if t == nil {
+ return "Not specified"
+ }
+ return t.Format("2006-01-02")
+}
+
+func joinOrNotSpecified(items []string) string {
+ if len(items) == 0 {
+ return "Not specified"
+ }
+ return strings.Join(items, ", ")
+}
+
+func formatCountries(c coredata.CountryCodes) string {
+ if len(c) == 0 {
+ return "Not specified"
+ }
+ parts := make([]string, len(c))
+ for i, cc := range c {
+ parts[i] = string(cc)
+ }
+ return strings.Join(parts, ", ")
+}
+
+func lookupProfileName(profiles map[gid.GID]*coredata.MembershipProfile, id *gid.GID) string {
+ if id == nil {
+ return "Not assigned"
+ }
+ if p, ok := profiles[*id]; ok {
+ return p.FullName
+ }
+ return "Not assigned"
+}
+
+func formatDataSensitivity(s coredata.DataSensitivity) string {
+ switch s {
+ case coredata.DataSensitivityNone:
+ return "None"
+ case coredata.DataSensitivityLow:
+ return "Low"
+ case coredata.DataSensitivityMedium:
+ return "Medium"
+ case coredata.DataSensitivityHigh:
+ return "High"
+ case coredata.DataSensitivityCritical:
+ return "Critical"
+ default:
+ return string(s)
+ }
+}
+
+func formatBusinessImpact(b coredata.BusinessImpact) string {
+ switch b {
+ case coredata.BusinessImpactLow:
+ return "Low"
+ case coredata.BusinessImpactMedium:
+ return "Medium"
+ case coredata.BusinessImpactHigh:
+ return "High"
+ case coredata.BusinessImpactCritical:
+ return "Critical"
+ default:
+ return string(b)
+ }
+}
+
+func formatVendorCategory(c coredata.VendorCategory) string {
+ switch c {
+ case coredata.VendorCategoryAnalytics:
+ return "Analytics"
+ case coredata.VendorCategoryCloudMonitoring:
+ return "Cloud Monitoring"
+ case coredata.VendorCategoryCloudProvider:
+ return "Cloud Provider"
+ case coredata.VendorCategoryCollaboration:
+ return "Collaboration"
+ case coredata.VendorCategoryCustomerSupport:
+ return "Customer Support"
+ case coredata.VendorCategoryDataStorageAndProcessing:
+ return "Data Storage and Processing"
+ case coredata.VendorCategoryDocumentManagement:
+ return "Document Management"
+ case coredata.VendorCategoryEmployeeManagement:
+ return "Employee Management"
+ case coredata.VendorCategoryEngineering:
+ return "Engineering"
+ case coredata.VendorCategoryFinance:
+ return "Finance"
+ case coredata.VendorCategoryIdentityProvider:
+ return "Identity Provider"
+ case coredata.VendorCategoryIT:
+ return "IT"
+ case coredata.VendorCategoryMarketing:
+ return "Marketing"
+ case coredata.VendorCategoryOfficeOperations:
+ return "Office Operations"
+ case coredata.VendorCategoryOther:
+ return "Other"
+ case coredata.VendorCategoryPasswordManagement:
+ return "Password Management"
+ case coredata.VendorCategoryProductAndDesign:
+ return "Product and Design"
+ case coredata.VendorCategoryProfessionalServices:
+ return "Professional Services"
+ case coredata.VendorCategoryRecruiting:
+ return "Recruiting"
+ case coredata.VendorCategorySales:
+ return "Sales"
+ case coredata.VendorCategorySecurity:
+ return "Security"
+ case coredata.VendorCategoryVersionControl:
+ return "Version Control"
+ default:
+ return string(c)
+ }
+}
+
+var vendorListTemplate = template.Must(
+ template.New("vendor_list.json.tmpl").
+ Funcs(template.FuncMap{
+ "json": func(v any) (string, error) {
+ b, err := json.Marshal(v)
+ if err != nil {
+ return "", err
+ }
+ return string(b), nil
+ },
+ "printf": fmt.Sprintf,
+ "add": func(a, b int) int { return a + b },
+ }).
+ ParseFS(Templates, "templates/vendor_list.json.tmpl"),
+)
+
+func BuildVendorListDocument(data docgen.VendorListData) (string, error) {
+ var buf bytes.Buffer
+ if err := vendorListTemplate.Execute(&buf, data); err != nil {
+ return "", fmt.Errorf("cannot execute vendor list template: %w", err)
+ }
+ return buf.String(), nil
+}
diff --git a/pkg/probo/templates/vendor_list.json.tmpl b/pkg/probo/templates/vendor_list.json.tmpl
new file mode 100644
index 000000000..800802c06
--- /dev/null
+++ b/pkg/probo/templates/vendor_list.json.tmpl
@@ -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." }] }] }
+ ]
+ }
+ ]
+}
diff --git a/pkg/server/api/console/v1/graphql/organization.graphql b/pkg/server/api/console/v1/graphql/organization.graphql
index 01c68c464..30c5020af 100644
--- a/pkg/server/api/console/v1/graphql/organization.graphql
+++ b/pkg/server/api/console/v1/graphql/organization.graphql
@@ -329,9 +329,10 @@ type Organization implements Node {
last: Int
before: CursorKey
orderBy: VendorOrder
- filter: VendorFilter = { snapshotId: null }
): VendorConnection! @goField(forceResolver: true)
+ vendorsDocument: Document @goField(forceResolver: true)
+
webhookSubscriptions(
first: Int
after: CursorKey
diff --git a/pkg/server/api/console/v1/graphql/snapshot.graphql b/pkg/server/api/console/v1/graphql/snapshot.graphql
index b2680c8f0..09d5c1396 100644
--- a/pkg/server/api/console/v1/graphql/snapshot.graphql
+++ b/pkg/server/api/console/v1/graphql/snapshot.graphql
@@ -1,16 +1,6 @@
enum SnapshotsType
@goModel(model: "go.probo.inc/probo/pkg/coredata.SnapshotsType") {
RISKS @goEnum(value: "go.probo.inc/probo/pkg/coredata.SnapshotsTypeRisks")
- VENDORS
- @goEnum(value: "go.probo.inc/probo/pkg/coredata.SnapshotsTypeVendors")
- PROCESSING_ACTIVITIES
- @goEnum(
- value: "go.probo.inc/probo/pkg/coredata.SnapshotsTypeProcessingActivities"
- )
- STATEMENTS_OF_APPLICABILITY
- @goEnum(
- value: "go.probo.inc/probo/pkg/coredata.SnapshotsTypeStatementsOfApplicability"
- )
}
enum SnapshotOrderField
diff --git a/pkg/server/api/console/v1/graphql/vendor.graphql b/pkg/server/api/console/v1/graphql/vendor.graphql
index 34054fa50..250c6d3c1 100644
--- a/pkg/server/api/console/v1/graphql/vendor.graphql
+++ b/pkg/server/api/console/v1/graphql/vendor.graphql
@@ -206,13 +206,8 @@ input VendorRiskAssessmentOrder {
direction: OrderDirection!
}
-input VendorFilter {
- snapshotId: ID
-}
-
type Vendor implements Node {
id: ID!
- snapshotId: ID
name: String!
category: VendorCategory!
description: String
@@ -462,6 +457,19 @@ extend type Mutation {
input: CreateVendorRiskAssessmentInput!
): CreateVendorRiskAssessmentPayload!
assessVendor(input: AssessVendorInput!): AssessVendorPayload!
+ publishVendorList(
+ input: PublishVendorListInput!
+ ): PublishVendorListPayload!
+}
+
+input PublishVendorListInput {
+ organizationId: ID!
+ approverIds: [ID!]
+}
+
+type PublishVendorListPayload {
+ documentEdge: DocumentEdge!
+ documentVersionEdge: DocumentVersionEdge!
}
input CreateVendorInput {
diff --git a/pkg/server/api/console/v1/organization_resolvers.go b/pkg/server/api/console/v1/organization_resolvers.go
index cbbaecdec..c2880d7e2 100644
--- a/pkg/server/api/console/v1/organization_resolvers.go
+++ b/pkg/server/api/console/v1/organization_resolvers.go
@@ -1210,7 +1210,7 @@ func (r *organizationResolver) CookieBanners(ctx context.Context, obj *types.Org
}
// Vendors is the resolver for the vendors field.
-func (r *organizationResolver) Vendors(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.VendorOrderBy, filter *types.VendorFilter) (*types.VendorConnection, error) {
+func (r *organizationResolver) Vendors(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.VendorOrderBy) (*types.VendorConnection, error) {
if err := r.authorize(ctx, obj.ID, probo.ActionVendorList); err != nil {
return nil, err
}
@@ -1230,10 +1230,7 @@ func (r *organizationResolver) Vendors(ctx context.Context, obj *types.Organizat
cursor := types.NewCursor(first, after, last, before, pageOrderBy)
- var vendorFilter = coredata.NewVendorFilter(nil, nil)
- if filter != nil {
- vendorFilter = coredata.NewVendorFilter(&filter.SnapshotID, nil)
- }
+ vendorFilter := coredata.NewVendorFilter(nil)
page, err := prb.Vendors.ListForOrganizationID(ctx, obj.ID, cursor, vendorFilter)
if err != nil {
@@ -1244,6 +1241,35 @@ func (r *organizationResolver) Vendors(ctx context.Context, obj *types.Organizat
return types.NewVendorConnection(page, r, obj.ID), nil
}
+// VendorsDocument is the resolver for the vendorsDocument field.
+func (r *organizationResolver) VendorsDocument(ctx context.Context, obj *types.Organization) (*types.Document, error) {
+ if err := r.authorize(ctx, obj.ID, probo.ActionDocumentGet); err != nil {
+ return nil, err
+ }
+
+ prb := r.ProboService(ctx, obj.ID.TenantID())
+
+ documentID, err := prb.GeneratedDocuments.GetVendorsDocumentID(ctx, obj.ID)
+ if err != nil {
+ r.logger.ErrorCtx(ctx, "cannot get vendors document ID", log.Error(err))
+ return nil, gqlutils.Internal(ctx)
+ }
+ if documentID == nil {
+ return nil, nil
+ }
+
+ document, err := prb.Documents.Get(ctx, *documentID)
+ if err != nil {
+ if errors.Is(err, coredata.ErrResourceNotFound) {
+ return nil, nil
+ }
+ r.logger.ErrorCtx(ctx, "cannot load vendors document", log.Error(err))
+ return nil, gqlutils.Internal(ctx)
+ }
+
+ return types.NewDocument(document), nil
+}
+
// WebhookSubscriptions is the resolver for the webhookSubscriptions field.
func (r *organizationResolver) WebhookSubscriptions(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.WebhookSubscriptionOrderBy) (*types.WebhookSubscriptionConnection, error) {
if err := r.authorize(ctx, obj.ID, probo.ActionWebhookSubscriptionList); err != nil {
diff --git a/pkg/server/api/console/v1/types/vendor.go b/pkg/server/api/console/v1/types/vendor.go
index 78c7d5866..28e10e891 100644
--- a/pkg/server/api/console/v1/types/vendor.go
+++ b/pkg/server/api/console/v1/types/vendor.go
@@ -84,7 +84,6 @@ func NewVendor(v *coredata.Vendor) *Vendor {
WebsiteURL: v.WebsiteURL,
Category: v.Category,
ShowOnTrustCenter: v.ShowOnTrustCenter,
- SnapshotID: v.SnapshotID,
Countries: v.Countries,
UpdatedAt: v.UpdatedAt,
CreatedAt: v.CreatedAt,
diff --git a/pkg/server/api/console/v1/vendor_resolvers.go b/pkg/server/api/console/v1/vendor_resolvers.go
index 109e8516a..4b9d81aa1 100644
--- a/pkg/server/api/console/v1/vendor_resolvers.go
+++ b/pkg/server/api/console/v1/vendor_resolvers.go
@@ -565,6 +565,29 @@ func (r *mutationResolver) AssessVendor(ctx context.Context, input types.AssessV
}, nil
}
+// PublishVendorList is the resolver for the publishVendorList field.
+func (r *mutationResolver) PublishVendorList(ctx context.Context, input types.PublishVendorListInput) (*types.PublishVendorListPayload, error) {
+ if err := r.authorize(ctx, input.OrganizationID, probo.ActionVendorPublish); err != nil {
+ return nil, err
+ }
+
+ prb := r.ProboService(ctx, input.OrganizationID.TenantID())
+
+ document, documentVersion, err := prb.GeneratedDocuments.PublishVendorList(ctx, input.OrganizationID, input.ApproverIds)
+ if err != nil {
+ if errors.Is(err, coredata.ErrResourceAlreadyExists) {
+ return nil, gqlutils.Conflict(ctx, err)
+ }
+ r.logger.ErrorCtx(ctx, "cannot publish vendor list", log.Error(err))
+ return nil, gqlutils.Internal(ctx)
+ }
+
+ return &types.PublishVendorListPayload{
+ DocumentEdge: types.NewDocumentEdge(document, coredata.DocumentOrderFieldCreatedAt),
+ DocumentVersionEdge: types.NewDocumentVersionEdge(documentVersion, coredata.DocumentVersionOrderFieldCreatedAt),
+ }, nil
+}
+
// Organization is the resolver for the organization field.
func (r *vendorResolver) Organization(ctx context.Context, obj *types.Vendor) (*types.Organization, error) {
if err := r.authorize(ctx, obj.ID, probo.ActionOrganizationGet); err != nil {
diff --git a/pkg/server/api/mcp/v1/schema.resolvers.go b/pkg/server/api/mcp/v1/schema.resolvers.go
index 5286185a0..fd0745367 100644
--- a/pkg/server/api/mcp/v1/schema.resolvers.go
+++ b/pkg/server/api/mcp/v1/schema.resolvers.go
@@ -63,11 +63,7 @@ func (r *Resolver) ListVendorsTool(ctx context.Context, req *mcp.CallToolRequest
cursor := types.NewCursor(input.Size, input.Cursor, pageOrderBy)
- noSnapshot := (*gid.GID)(nil)
- vendorFilter := coredata.NewVendorFilter(&noSnapshot, nil)
- if input.Filter != nil {
- vendorFilter = coredata.NewVendorFilter(&input.Filter.SnapshotID, nil)
- }
+ vendorFilter := coredata.NewVendorFilter(nil)
page, err := prb.Vendors.ListForOrganizationID(ctx, input.OrganizationID, cursor, vendorFilter)
if err != nil {
@@ -4840,3 +4836,19 @@ func (r *Resolver) PublishTransferImpactAssessmentListTool(ctx context.Context,
DocumentVersionID: documentVersion.ID,
}, nil
}
+
+func (r *Resolver) PublishVendorListTool(ctx context.Context, req *mcp.CallToolRequest, input *types.PublishVendorListInput) (*mcp.CallToolResult, types.PublishVendorListOutput, error) {
+ r.MustAuthorize(ctx, input.OrganizationID, probo.ActionVendorPublish)
+
+ svc := r.ProboService(ctx, input.OrganizationID)
+
+ document, documentVersion, err := svc.GeneratedDocuments.PublishVendorList(ctx, input.OrganizationID, input.ApproverIds)
+ if err != nil {
+ return nil, types.PublishVendorListOutput{}, fmt.Errorf("cannot publish vendor list: %w", err)
+ }
+
+ return nil, types.PublishVendorListOutput{
+ DocumentID: document.ID,
+ DocumentVersionID: documentVersion.ID,
+ }, nil
+}
diff --git a/pkg/server/api/mcp/v1/specification.yaml b/pkg/server/api/mcp/v1/specification.yaml
index c6f4a1d32..43c88d50c 100644
--- a/pkg/server/api/mcp/v1/specification.yaml
+++ b/pkg/server/api/mcp/v1/specification.yaml
@@ -362,15 +362,6 @@ components:
cursor:
$ref: "#/components/schemas/CursorKey"
description: Page cursor
- filter:
- type: object
- properties:
- snapshot_id:
- anyOf:
- - $ref: "#/components/schemas/GID"
- - type: "null"
- description: Filter by snapshot ID. Defaults to null, which returns only vendors with no snapshot (current live data). Pass a specific snapshot ID to retrieve vendors as they were at that snapshot.
- default: null
ListVendorsOutput:
type: object
@@ -560,11 +551,6 @@ components:
- string
- "null"
description: Notes
- snapshot_id:
- anyOf:
- - $ref: "#/components/schemas/GID"
- - type: "null"
- description: Snapshot ID
created_at:
type: string
format: date-time
@@ -5372,7 +5358,6 @@ components:
type: string
enum:
- RISKS
- - VENDORS
- NONCONFORMITIES
- OBLIGATIONS
- CONTINUAL_IMPROVEMENTS
@@ -7119,6 +7104,33 @@ components:
$ref: "#/components/schemas/GID"
description: Created document version ID
+ PublishVendorListInput:
+ type: object
+ required:
+ - organization_id
+ properties:
+ organization_id:
+ $ref: "#/components/schemas/GID"
+ description: Organization ID
+ approver_ids:
+ type: array
+ items:
+ $ref: "#/components/schemas/GID"
+ description: Optional approver profile IDs. If provided, creates a draft pending approval instead of publishing immediately.
+
+ PublishVendorListOutput:
+ type: object
+ required:
+ - document_id
+ - document_version_id
+ properties:
+ document_id:
+ $ref: "#/components/schemas/GID"
+ description: Created or updated document ID
+ document_version_id:
+ $ref: "#/components/schemas/GID"
+ description: Created document version ID
+
PublishStatementOfApplicabilityInput:
type: object
required:
@@ -10396,6 +10408,14 @@ tools:
$ref: "#/components/schemas/PublishTransferImpactAssessmentListInput"
outputSchema:
$ref: "#/components/schemas/PublishTransferImpactAssessmentListOutput"
+ - name: publishVendorList
+ description: Publish the vendor register for an organization as a document. If a document already exists, a new version is created.
+ hints:
+ readonly: false
+ inputSchema:
+ $ref: "#/components/schemas/PublishVendorListInput"
+ outputSchema:
+ $ref: "#/components/schemas/PublishVendorListOutput"
- name: publishStatementOfApplicability
description: Publish a statement of applicability as a document. If a document already exists, a new version is created.
hints:
diff --git a/pkg/server/api/mcp/v1/types/vendor.go b/pkg/server/api/mcp/v1/types/vendor.go
index 7ca8789dd..6d7b19e5b 100644
--- a/pkg/server/api/mcp/v1/types/vendor.go
+++ b/pkg/server/api/mcp/v1/types/vendor.go
@@ -29,7 +29,6 @@ func NewVendorRiskAssessment(v *coredata.VendorRiskAssessment) *VendorRiskAssess
DataSensitivity: v.DataSensitivity,
BusinessImpact: v.BusinessImpact,
Notes: v.Notes,
- SnapshotID: v.SnapshotID,
CreatedAt: v.CreatedAt,
UpdatedAt: v.UpdatedAt,
}
diff --git a/pkg/trust/vendor_service.go b/pkg/trust/vendor_service.go
index 0b03424b3..42dab04e0 100644
--- a/pkg/trust/vendor_service.go
+++ b/pkg/trust/vendor_service.go
@@ -64,8 +64,7 @@ func (s VendorService) ListForOrganizationId(
ctx,
func(ctx context.Context, conn pg.Querier) error {
showOnTrustCenter := true
- var nilSnapshotID *gid.GID = nil
- filter := coredata.NewVendorFilter(&nilSnapshotID, &showOnTrustCenter)
+ filter := coredata.NewVendorFilter(&showOnTrustCenter)
err := vendors.LoadByOrganizationID(ctx, conn, s.svc.scope, organizationID, cursor, filter)
if err != nil {
@@ -99,8 +98,7 @@ func (s VendorService) CountForTrustCenterId(
vendors := &coredata.Vendors{}
showOnTrustCenter := true
- var nilSnapshotID *gid.GID = nil
- filter := coredata.NewVendorFilter(&nilSnapshotID, &showOnTrustCenter)
+ filter := coredata.NewVendorFilter(&showOnTrustCenter)
count, err = vendors.CountByOrganizationID(ctx, conn, s.svc.scope, trustCenter.OrganizationID, filter)
if err != nil {
return fmt.Errorf("cannot count vendors: %w", err)