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:
25
pkg/coredata/migrations/20260717T133746Z.sql
Normal file
25
pkg/coredata/migrations/20260717T133746Z.sql
Normal file
@@ -0,0 +1,25 @@
|
||||
-- Copyright (c) 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.
|
||||
|
||||
ALTER TYPE rights_request_type ADD VALUE IF NOT EXISTS 'RECTIFICATION';
|
||||
ALTER TYPE rights_request_type ADD VALUE IF NOT EXISTS 'OBJECTION';
|
||||
ALTER TYPE rights_request_type ADD VALUE IF NOT EXISTS 'COMPLAINT';
|
||||
|
||||
ALTER TYPE rights_request_state ADD VALUE IF NOT EXISTS 'REJECTED';
|
||||
@@ -31,6 +31,7 @@ const (
|
||||
RightsRequestStateTodo RightsRequestState = "TODO"
|
||||
RightsRequestStateInProgress RightsRequestState = "IN_PROGRESS"
|
||||
RightsRequestStateDone RightsRequestState = "DONE"
|
||||
RightsRequestStateRejected RightsRequestState = "REJECTED"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -44,6 +45,7 @@ func RightsRequestStates() []RightsRequestState {
|
||||
RightsRequestStateTodo,
|
||||
RightsRequestStateInProgress,
|
||||
RightsRequestStateDone,
|
||||
RightsRequestStateRejected,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,7 +54,8 @@ func (v RightsRequestState) IsValid() bool {
|
||||
case
|
||||
RightsRequestStateTodo,
|
||||
RightsRequestStateInProgress,
|
||||
RightsRequestStateDone:
|
||||
RightsRequestStateDone,
|
||||
RightsRequestStateRejected:
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -28,9 +28,12 @@ import (
|
||||
type RightsRequestType string
|
||||
|
||||
const (
|
||||
RightsRequestTypeAccess RightsRequestType = "ACCESS"
|
||||
RightsRequestTypeDeletion RightsRequestType = "DELETION"
|
||||
RightsRequestTypePortability RightsRequestType = "PORTABILITY"
|
||||
RightsRequestTypeAccess RightsRequestType = "ACCESS"
|
||||
RightsRequestTypeDeletion RightsRequestType = "DELETION"
|
||||
RightsRequestTypeRectification RightsRequestType = "RECTIFICATION"
|
||||
RightsRequestTypePortability RightsRequestType = "PORTABILITY"
|
||||
RightsRequestTypeObjection RightsRequestType = "OBJECTION"
|
||||
RightsRequestTypeComplaint RightsRequestType = "COMPLAINT"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -43,7 +46,10 @@ func RightsRequestTypes() []RightsRequestType {
|
||||
return []RightsRequestType{
|
||||
RightsRequestTypeAccess,
|
||||
RightsRequestTypeDeletion,
|
||||
RightsRequestTypeRectification,
|
||||
RightsRequestTypePortability,
|
||||
RightsRequestTypeObjection,
|
||||
RightsRequestTypeComplaint,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,7 +58,10 @@ func (v RightsRequestType) IsValid() bool {
|
||||
case
|
||||
RightsRequestTypeAccess,
|
||||
RightsRequestTypeDeletion,
|
||||
RightsRequestTypePortability:
|
||||
RightsRequestTypeRectification,
|
||||
RightsRequestTypePortability,
|
||||
RightsRequestTypeObjection,
|
||||
RightsRequestTypeComplaint:
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -240,6 +240,98 @@ WHERE
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rrs *RightsRequests) CountByOrganizationIDAndContact(
|
||||
ctx context.Context,
|
||||
conn pg.Querier,
|
||||
scope Scoper,
|
||||
organizationID gid.GID,
|
||||
contact string,
|
||||
) (int, error) {
|
||||
q := `
|
||||
SELECT
|
||||
COUNT(id)
|
||||
FROM
|
||||
rights_requests
|
||||
WHERE
|
||||
%s
|
||||
AND organization_id = @organization_id
|
||||
AND contact = @contact
|
||||
`
|
||||
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"organization_id": organizationID,
|
||||
"contact": contact,
|
||||
}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
row := conn.QueryRow(ctx, q, args)
|
||||
|
||||
var count int
|
||||
|
||||
err := row.Scan(&count)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("cannot count rights requests: %w", err)
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (rrs *RightsRequests) LoadByOrganizationIDAndContact(
|
||||
ctx context.Context,
|
||||
conn pg.Querier,
|
||||
scope Scoper,
|
||||
organizationID gid.GID,
|
||||
contact string,
|
||||
cursor *page.Cursor[RightsRequestOrderField],
|
||||
) error {
|
||||
q := `
|
||||
SELECT
|
||||
id,
|
||||
organization_id,
|
||||
request_type,
|
||||
request_state,
|
||||
data_subject,
|
||||
contact,
|
||||
details,
|
||||
deadline,
|
||||
action_taken,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
rights_requests
|
||||
WHERE
|
||||
%s
|
||||
AND organization_id = @organization_id
|
||||
AND contact = @contact
|
||||
AND %s
|
||||
`
|
||||
|
||||
q = fmt.Sprintf(q, scope.SQLFragment(), cursor.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"organization_id": organizationID,
|
||||
"contact": contact,
|
||||
}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
maps.Copy(args, cursor.SQLArguments())
|
||||
|
||||
rows, err := conn.Query(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query rights requests: %w", err)
|
||||
}
|
||||
|
||||
requests, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[RightsRequest])
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot collect rights requests: %w", err)
|
||||
}
|
||||
|
||||
*rrs = requests
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rr *RightsRequest) Insert(
|
||||
ctx context.Context,
|
||||
conn pg.Tx,
|
||||
|
||||
Reference in New Issue
Block a user