From c026f67bd984293d08c079bbdfdfc30d8ae79dae Mon Sep 17 00:00:00 2001 From: Sacha Al Himdani Date: Tue, 28 Apr 2026 18:54:09 +0200 Subject: [PATCH] Add vendor publish to document system Replace the old snapshot-based system for vendors with the publish document system, mirroring the prior processing activity / DPIA / TIA migration. Includes the GraphQL mutation, MCP tool, CLI command, n8n operation, frontend publish dialog, e2e tests, and a prosemirror register template covering vendor profile fields plus per-vendor sections for services, contacts, risk assessments, compliance reports, BAA and DPA agreements. The vendor register lives as a generated DocumentTypeRegister document on the organization, reused across publishes (the major version bumps on every republish). Approvers can be passed in to create a draft pending approval; otherwise the version is published immediately. The frontend Vendors page exposes a Publish button and a Document link button when the document exists, and pre-fills the previous default approvers. Remove snapshot mode entirely from vendors and their sub-entities: drop snapshotId/sourceId from GraphQL Vendor type and VendorFilter; remove SnapshotsTypeVendors from the snapshot registry and delete Vendors.Snapshot, VendorSnapshotter interface and all *.InsertVendorSnapshots methods on contacts, services, risk assessments, compliance reports, BAA and DPA. Drop the snapshot routes and banner from the frontend. The snapshot_id columns remain in the database but are now filtered out with snapshot_id IS NULL. Add Get/Upsert/Clear GeneratedDocumentID methods on Vendor backed by a new vendors_document_id column on generated_documents, matching the ProcessingActivity/Finding/Obligation pattern. Signed-off-by: Sacha Al Himdani --- apps/console/src/hooks/graph/VendorGraph.ts | 19 +- .../vendors/VendorDetailPage.tsx | 60 +- .../organizations/vendors/VendorsPage.tsx | 66 +- .../dialogs/PublishVendorListDialog.tsx | 160 ++++ apps/console/src/routes/vendorRoutes.ts | 73 -- .../main.go | 749 ++++++++++++++++++ e2e/console/snapshot_test.go | 6 +- e2e/console/vendor_publish_test.go | 336 ++++++++ e2e/mcp/snapshot_test.go | 6 +- packages/helpers/src/snapshots.ts | 2 - .../nodes/Probo/actions/vendor/index.ts | 9 + .../Probo/actions/vendor/publish.operation.ts | 101 +++ pkg/cmd/vendormgmt/publish/publish.go | 148 ++++ pkg/cmd/vendormgmt/vendormgmt.go | 2 + pkg/coredata/migrations/20260428T152537Z.sql | 16 + pkg/coredata/snapshot.go | 4 +- pkg/coredata/snapshots_type.go | 4 - pkg/coredata/snapshottable.go | 2 - pkg/coredata/vendor.go | 316 ++++---- .../vendor_business_associate_agreement.go | 131 ++- pkg/coredata/vendor_compliance_report.go | 128 ++- pkg/coredata/vendor_contact.go | 128 ++- pkg/coredata/vendor_data_privacy_agreement.go | 131 ++- pkg/coredata/vendor_filter.go | 25 +- pkg/coredata/vendor_risk_assessment.go | 107 ++- pkg/coredata/vendor_service.go | 122 ++- pkg/docgen/generator.go | 67 ++ pkg/probo/actions.go | 13 +- pkg/probo/generated_document_service.go | 533 +++++++++++++ pkg/probo/templates/vendor_list.json.tmpl | 302 +++++++ .../console/v1/graphql/organization.graphql | 3 +- .../api/console/v1/graphql/snapshot.graphql | 10 - .../api/console/v1/graphql/vendor.graphql | 18 +- .../api/console/v1/organization_resolvers.go | 36 +- pkg/server/api/console/v1/types/vendor.go | 1 - pkg/server/api/console/v1/vendor_resolvers.go | 23 + pkg/server/api/mcp/v1/schema.resolvers.go | 22 +- pkg/server/api/mcp/v1/specification.yaml | 50 +- pkg/server/api/mcp/v1/types/vendor.go | 1 - pkg/trust/vendor_service.go | 6 +- 40 files changed, 3144 insertions(+), 792 deletions(-) create mode 100644 apps/console/src/pages/organizations/vendors/dialogs/PublishVendorListDialog.tsx create mode 100644 cmd/migrate-vendor-snapshots-to-documents/main.go create mode 100644 e2e/console/vendor_publish_test.go create mode 100644 packages/n8n-node/nodes/Probo/actions/vendor/publish.operation.ts create mode 100644 pkg/cmd/vendormgmt/publish/publish.go create mode 100644 pkg/coredata/migrations/20260428T152537Z.sql create mode 100644 pkg/probo/templates/vendor_list.json.tmpl 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 ( + +
void handleSubmit(onSubmit)(e)}> + +
+

+ {__("Select approvers to request approval before publishing, or publish directly without approvers.")} +

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