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:
@@ -8,10 +8,22 @@ enum RightsRequestType
|
||||
@goEnum(
|
||||
value: "go.probo.inc/probo/pkg/coredata.RightsRequestTypeDeletion"
|
||||
)
|
||||
RECTIFICATION
|
||||
@goEnum(
|
||||
value: "go.probo.inc/probo/pkg/coredata.RightsRequestTypeRectification"
|
||||
)
|
||||
PORTABILITY
|
||||
@goEnum(
|
||||
value: "go.probo.inc/probo/pkg/coredata.RightsRequestTypePortability"
|
||||
)
|
||||
OBJECTION
|
||||
@goEnum(
|
||||
value: "go.probo.inc/probo/pkg/coredata.RightsRequestTypeObjection"
|
||||
)
|
||||
COMPLAINT
|
||||
@goEnum(
|
||||
value: "go.probo.inc/probo/pkg/coredata.RightsRequestTypeComplaint"
|
||||
)
|
||||
}
|
||||
|
||||
enum RightsRequestState
|
||||
@@ -24,6 +36,10 @@ enum RightsRequestState
|
||||
)
|
||||
DONE
|
||||
@goEnum(value: "go.probo.inc/probo/pkg/coredata.RightsRequestStateDone")
|
||||
REJECTED
|
||||
@goEnum(
|
||||
value: "go.probo.inc/probo/pkg/coredata.RightsRequestStateRejected"
|
||||
)
|
||||
}
|
||||
|
||||
enum RightsRequestOrderField
|
||||
|
||||
@@ -8489,7 +8489,10 @@ components:
|
||||
enum:
|
||||
- ACCESS
|
||||
- DELETION
|
||||
- RECTIFICATION
|
||||
- PORTABILITY
|
||||
- OBJECTION
|
||||
- COMPLAINT
|
||||
go.probo.inc/mcpgen/type: go.probo.inc/probo/pkg/coredata.RightsRequestType
|
||||
|
||||
RightsRequestState:
|
||||
@@ -8498,6 +8501,7 @@ components:
|
||||
- TODO
|
||||
- IN_PROGRESS
|
||||
- DONE
|
||||
- REJECTED
|
||||
go.probo.inc/mcpgen/type: go.probo.inc/probo/pkg/coredata.RightsRequestState
|
||||
|
||||
RightsRequestOrderField:
|
||||
|
||||
83
pkg/server/api/trust/v1/graphql/rights_request.graphql
Normal file
83
pkg/server/api/trust/v1/graphql/rights_request.graphql
Normal file
@@ -0,0 +1,83 @@
|
||||
enum RightsRequestType
|
||||
@goModel(model: "go.probo.inc/probo/pkg/coredata.RightsRequestType") {
|
||||
ACCESS @goEnum(value: "go.probo.inc/probo/pkg/coredata.RightsRequestTypeAccess")
|
||||
DELETION
|
||||
@goEnum(value: "go.probo.inc/probo/pkg/coredata.RightsRequestTypeDeletion")
|
||||
RECTIFICATION
|
||||
@goEnum(
|
||||
value: "go.probo.inc/probo/pkg/coredata.RightsRequestTypeRectification"
|
||||
)
|
||||
PORTABILITY
|
||||
@goEnum(
|
||||
value: "go.probo.inc/probo/pkg/coredata.RightsRequestTypePortability"
|
||||
)
|
||||
OBJECTION
|
||||
@goEnum(value: "go.probo.inc/probo/pkg/coredata.RightsRequestTypeObjection")
|
||||
COMPLAINT
|
||||
@goEnum(value: "go.probo.inc/probo/pkg/coredata.RightsRequestTypeComplaint")
|
||||
}
|
||||
|
||||
enum RightsRequestState
|
||||
@goModel(model: "go.probo.inc/probo/pkg/coredata.RightsRequestState") {
|
||||
TODO @goEnum(value: "go.probo.inc/probo/pkg/coredata.RightsRequestStateTodo")
|
||||
IN_PROGRESS
|
||||
@goEnum(
|
||||
value: "go.probo.inc/probo/pkg/coredata.RightsRequestStateInProgress"
|
||||
)
|
||||
DONE @goEnum(value: "go.probo.inc/probo/pkg/coredata.RightsRequestStateDone")
|
||||
REJECTED
|
||||
@goEnum(value: "go.probo.inc/probo/pkg/coredata.RightsRequestStateRejected")
|
||||
}
|
||||
|
||||
type RightsRequest implements Node {
|
||||
id: ID!
|
||||
requestType: RightsRequestType!
|
||||
requestState: RightsRequestState!
|
||||
dataSubject: String
|
||||
contact: String
|
||||
details: String
|
||||
deadline: Datetime
|
||||
actionTaken: String
|
||||
createdAt: Datetime!
|
||||
updatedAt: Datetime!
|
||||
}
|
||||
|
||||
type RightsRequestConnection {
|
||||
edges: [RightsRequestEdge!]!
|
||||
pageInfo: PageInfo!
|
||||
}
|
||||
|
||||
type RightsRequestEdge {
|
||||
cursor: CursorKey!
|
||||
node: RightsRequest!
|
||||
}
|
||||
|
||||
extend type Query {
|
||||
# The current viewer's own data subject requests for this trust center,
|
||||
# scoped by their verified email. Returns an empty connection for guests so
|
||||
# the portal can still render its empty state.
|
||||
myRightsRequests(
|
||||
first: Int
|
||||
after: CursorKey
|
||||
last: Int
|
||||
before: CursorKey
|
||||
): RightsRequestConnection!
|
||||
}
|
||||
|
||||
extend type Mutation {
|
||||
# Submit a data subject request. Requires a verified viewer; the request is
|
||||
# attributed to the viewer's email, so no NDA gate applies.
|
||||
createRightsRequest(
|
||||
input: CreateRightsRequestInput!
|
||||
): CreateRightsRequestPayload! @authentication(required: PRESENT)
|
||||
}
|
||||
|
||||
input CreateRightsRequestInput {
|
||||
requestType: RightsRequestType!
|
||||
dataSubject: String
|
||||
details: String
|
||||
}
|
||||
|
||||
type CreateRightsRequestPayload {
|
||||
rightsRequestEdge: RightsRequestEdge!
|
||||
}
|
||||
85
pkg/server/api/trust/v1/rights_request_resolvers.go
Normal file
85
pkg/server/api/trust/v1/rights_request_resolvers.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package trust_v1
|
||||
|
||||
// This file will be automatically regenerated based on the schema, any resolver
|
||||
// implementations
|
||||
// will be copied through when generating and any unknown code will be moved to the end.
|
||||
// Code generated by github.com/99designs/gqlgen version v0.17.93
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"go.gearno.de/kit/log"
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
"go.probo.inc/probo/pkg/page"
|
||||
"go.probo.inc/probo/pkg/server/api/authn"
|
||||
"go.probo.inc/probo/pkg/server/api/compliancepage"
|
||||
"go.probo.inc/probo/pkg/server/api/trust/v1/types"
|
||||
"go.probo.inc/probo/pkg/server/gqlutils"
|
||||
"go.probo.inc/probo/pkg/trust"
|
||||
)
|
||||
|
||||
// CreateRightsRequest is the resolver for the createRightsRequest field.
|
||||
func (r *mutationResolver) CreateRightsRequest(ctx context.Context, input types.CreateRightsRequestInput) (*types.CreateRightsRequestPayload, error) {
|
||||
identity := authn.IdentityFromContext(ctx)
|
||||
if identity == nil {
|
||||
return nil, gqlutils.Unauthenticatedf(ctx, "authentication is required to submit a request")
|
||||
}
|
||||
|
||||
compliancePage := compliancepage.CompliancePageFromContext(ctx)
|
||||
scope := coredata.NewScopeFromObjectID(compliancePage.OrganizationID)
|
||||
|
||||
rightsRequest, err := r.trust.RightsRequests.Create(
|
||||
ctx,
|
||||
scope,
|
||||
&trust.CreateRightsRequest{
|
||||
OrganizationID: compliancePage.OrganizationID,
|
||||
RequestType: input.RequestType,
|
||||
DataSubject: input.DataSubject,
|
||||
Contact: identity.EmailAddress.String(),
|
||||
Details: input.Details,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
r.logger.ErrorCtx(ctx, "cannot create rights request", log.Error(err))
|
||||
return nil, gqlutils.Internal(ctx)
|
||||
}
|
||||
|
||||
return &types.CreateRightsRequestPayload{
|
||||
RightsRequestEdge: types.NewRightsRequestEdge(
|
||||
rightsRequest,
|
||||
coredata.RightsRequestOrderFieldCreatedAt,
|
||||
),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// MyRightsRequests is the resolver for the myRightsRequests field.
|
||||
func (r *queryResolver) MyRightsRequests(ctx context.Context, first *int, after *page.CursorKey, last *int, before *page.CursorKey) (*types.RightsRequestConnection, error) {
|
||||
pageOrderBy := page.OrderBy[coredata.RightsRequestOrderField]{
|
||||
Field: coredata.RightsRequestOrderFieldCreatedAt,
|
||||
Direction: page.OrderDirectionDesc,
|
||||
}
|
||||
cursor := types.NewCursor(first, after, last, before, pageOrderBy)
|
||||
|
||||
identity := authn.IdentityFromContext(ctx)
|
||||
if identity == nil {
|
||||
emptyPage := page.NewPage([]*coredata.RightsRequest{}, cursor)
|
||||
return types.NewRightsRequestConnection(emptyPage), nil
|
||||
}
|
||||
|
||||
compliancePage := compliancepage.CompliancePageFromContext(ctx)
|
||||
scope := coredata.NewScopeFromObjectID(compliancePage.OrganizationID)
|
||||
|
||||
result, err := r.trust.RightsRequests.ListForOrganizationIDAndContact(
|
||||
ctx,
|
||||
scope,
|
||||
compliancePage.OrganizationID,
|
||||
identity.EmailAddress.String(),
|
||||
cursor,
|
||||
)
|
||||
if err != nil {
|
||||
r.logger.ErrorCtx(ctx, "cannot list rights requests", log.Error(err))
|
||||
return nil, gqlutils.Internal(ctx)
|
||||
}
|
||||
|
||||
return types.NewRightsRequestConnection(result), nil
|
||||
}
|
||||
66
pkg/server/api/trust/v1/types/rights_request.go
Normal file
66
pkg/server/api/trust/v1/types/rights_request.go
Normal file
@@ -0,0 +1,66 @@
|
||||
// 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 types
|
||||
|
||||
import (
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
"go.probo.inc/probo/pkg/page"
|
||||
)
|
||||
|
||||
func NewRightsRequest(rr *coredata.RightsRequest) *RightsRequest {
|
||||
return &RightsRequest{
|
||||
ID: rr.ID,
|
||||
RequestType: rr.RequestType,
|
||||
RequestState: rr.RequestState,
|
||||
DataSubject: rr.DataSubject,
|
||||
Contact: rr.Contact,
|
||||
Details: rr.Details,
|
||||
Deadline: rr.Deadline,
|
||||
ActionTaken: rr.ActionTaken,
|
||||
CreatedAt: rr.CreatedAt,
|
||||
UpdatedAt: rr.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func NewRightsRequestEdge(
|
||||
rr *coredata.RightsRequest,
|
||||
orderBy coredata.RightsRequestOrderField,
|
||||
) *RightsRequestEdge {
|
||||
return &RightsRequestEdge{
|
||||
Cursor: rr.CursorKey(orderBy),
|
||||
Node: NewRightsRequest(rr),
|
||||
}
|
||||
}
|
||||
|
||||
func NewRightsRequestConnection(
|
||||
p *page.Page[*coredata.RightsRequest, coredata.RightsRequestOrderField],
|
||||
) *RightsRequestConnection {
|
||||
edges := make([]*RightsRequestEdge, len(p.Data))
|
||||
|
||||
for i, item := range p.Data {
|
||||
edges[i] = NewRightsRequestEdge(item, p.Cursor.OrderBy.Field)
|
||||
}
|
||||
|
||||
return &RightsRequestConnection{
|
||||
Edges: edges,
|
||||
PageInfo: NewPageInfo(p),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user