Add vendor publish to document system

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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