Add data request pages to compliance portal
Let trust-portal data subjects submit and track GDPR/CCPA rights requests. The new Data Requests page lists the viewer's own requests and a dialog submits new ones, scoped server-side to the verified viewer email so former or inactive users can still exercise their rights. Submission requires magic-link sign-in (reusing the existing gate) but not the NDA gate. Extend the shared rights_request enums with RECTIFICATION, OBJECTION and COMPLAINT types plus a REJECTED state, and keep the console GraphQL, @probo/helpers and the MCP specification in sync. Expose a trust GraphQL surface (myRightsRequests query, createRightsRequest mutation) backed by a trust service and contact-scoped coredata loaders. Add the missing v2 UI kit primitives the dialog needs on top of Base UI: a SegmentedControl radio-cards group, a form Textarea, and a Field wrapper. Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
153
pkg/trust/rights_request_service.go
Normal file
153
pkg/trust/rights_request_service.go
Normal file
@@ -0,0 +1,153 @@
|
||||
// Copyright (c) 2025-2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
// SOFTWARE.
|
||||
|
||||
package trust
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"go.gearno.de/kit/pg"
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
"go.probo.inc/probo/pkg/page"
|
||||
)
|
||||
|
||||
// RightsRequestDeadlineDays is the number of days a portal-submitted data
|
||||
// subject request is given before its response deadline. Thirty days matches
|
||||
// the GDPR Article 12(3) one-month standard (the shorter of GDPR / CCPA), and
|
||||
// the console can adjust it afterwards.
|
||||
const RightsRequestDeadlineDays = 30
|
||||
|
||||
type (
|
||||
RightsRequestService struct {
|
||||
svc *Service
|
||||
}
|
||||
|
||||
// CreateRightsRequest is a data subject request submitted from the trust
|
||||
// portal. The organization comes from the current compliance page and the
|
||||
// contact from the verified viewer's identity, so neither is client-supplied.
|
||||
CreateRightsRequest struct {
|
||||
OrganizationID gid.GID
|
||||
RequestType coredata.RightsRequestType
|
||||
DataSubject *string
|
||||
Contact string
|
||||
Details *string
|
||||
}
|
||||
)
|
||||
|
||||
func (s *RightsRequestService) Create(
|
||||
ctx context.Context,
|
||||
scope coredata.Scoper,
|
||||
req *CreateRightsRequest,
|
||||
) (*coredata.RightsRequest, error) {
|
||||
now := time.Now()
|
||||
deadline := now.AddDate(0, 0, RightsRequestDeadlineDays)
|
||||
|
||||
request := &coredata.RightsRequest{
|
||||
ID: gid.New(scope.GetTenantID(), coredata.RightsRequestEntityType),
|
||||
OrganizationID: req.OrganizationID,
|
||||
RequestType: req.RequestType,
|
||||
RequestState: coredata.RightsRequestStateTodo,
|
||||
DataSubject: req.DataSubject,
|
||||
Contact: &req.Contact,
|
||||
Details: req.Details,
|
||||
Deadline: &deadline,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
err := s.svc.pg.WithTx(
|
||||
ctx,
|
||||
func(ctx context.Context, tx pg.Tx) error {
|
||||
organization := &coredata.Organization{}
|
||||
if err := organization.LoadByID(ctx, tx, scope, req.OrganizationID); err != nil {
|
||||
return fmt.Errorf("cannot load organization: %w", err)
|
||||
}
|
||||
|
||||
if err := request.Insert(ctx, tx, scope); err != nil {
|
||||
return fmt.Errorf("cannot insert rights request: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return request, nil
|
||||
}
|
||||
|
||||
func (s RightsRequestService) CountForOrganizationIDAndContact(
|
||||
ctx context.Context,
|
||||
scope coredata.Scoper,
|
||||
organizationID gid.GID,
|
||||
contact string,
|
||||
) (int, error) {
|
||||
var count int
|
||||
|
||||
err := s.svc.pg.WithConn(
|
||||
ctx,
|
||||
func(ctx context.Context, conn pg.Querier) (err error) {
|
||||
requests := coredata.RightsRequests{}
|
||||
|
||||
count, err = requests.CountByOrganizationIDAndContact(ctx, conn, scope, organizationID, contact)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot count rights requests: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (s RightsRequestService) ListForOrganizationIDAndContact(
|
||||
ctx context.Context,
|
||||
scope coredata.Scoper,
|
||||
organizationID gid.GID,
|
||||
contact string,
|
||||
cursor *page.Cursor[coredata.RightsRequestOrderField],
|
||||
) (*page.Page[*coredata.RightsRequest, coredata.RightsRequestOrderField], error) {
|
||||
var requests coredata.RightsRequests
|
||||
|
||||
err := s.svc.pg.WithConn(
|
||||
ctx,
|
||||
func(ctx context.Context, conn pg.Querier) error {
|
||||
err := requests.LoadByOrganizationIDAndContact(ctx, conn, scope, organizationID, contact, cursor)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot load rights requests: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return page.NewPage(requests, cursor), nil
|
||||
}
|
||||
@@ -73,6 +73,7 @@ type (
|
||||
Reports *ReportService
|
||||
Organizations *OrganizationService
|
||||
ComplianceExternalURLs *ComplianceExternalURLService
|
||||
RightsRequests *RightsRequestService
|
||||
resourceAlias *resourcealias.Service
|
||||
}
|
||||
)
|
||||
@@ -119,6 +120,7 @@ func NewService(
|
||||
svc.Reports = &ReportService{svc: svc}
|
||||
svc.Organizations = &OrganizationService{svc: svc}
|
||||
svc.ComplianceExternalURLs = &ComplianceExternalURLService{svc: svc}
|
||||
svc.RightsRequests = &RightsRequestService{svc: svc}
|
||||
|
||||
return svc
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user