Add document archiving

Documents can be archived and unarchived. Archived documents are
read-only, excluded from the trust center, and moved to a dedicated
Archived tab in the document list.

- Add archived_at timestamp and status (ACTIVE/ARCHIVED) PG enum column
- Rename DocumentStatus → DocumentVersionStatus, introduce DocumentStatus
- Archive/unarchive mutations in GraphQL, MCP, and CLI
- Bulk archive/unarchive mutations with Active/Archived tabs in the list
- ABAC policies: write actions denied on archived docs, unarchive denied
  on active docs
- Remove control/risk mappings and reset trust center visibility on archive
- Exclude archived documents from mapping dialogs and trust center tab

Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
Sacha Al Himdani
2026-03-19 14:15:16 +01:00
parent 2e12c11c0c
commit 1db8e7133e
29 changed files with 1194 additions and 199 deletions

View File

@@ -37,6 +37,8 @@ type (
Classification DocumentClassification `db:"classification"`
CurrentPublishedVersion *int `db:"current_published_version"`
TrustCenterVisibility TrustCenterVisibility `db:"trust_center_visibility"`
Status DocumentStatus `db:"status"`
ArchivedAt *time.Time `db:"archived_at"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
@@ -59,17 +61,21 @@ func (p Document) CursorKey(orderBy DocumentOrderField) page.CursorKey {
// AuthorizationAttributes returns the authorization attributes for policy evaluation.
func (d *Document) AuthorizationAttributes(ctx context.Context, conn pg.Conn) (map[string]string, error) {
q := `SELECT organization_id FROM documents WHERE id = $1 LIMIT 1;`
q := `SELECT organization_id, status FROM documents WHERE id = $1 LIMIT 1;`
var organizationID gid.GID
if err := conn.QueryRow(ctx, q, d.ID).Scan(&organizationID); err != nil {
var documentStatus DocumentStatus
if err := conn.QueryRow(ctx, q, d.ID).Scan(&organizationID, &documentStatus); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrResourceNotFound
}
return nil, fmt.Errorf("cannot query document authorization attributes: %w", err)
}
return map[string]string{"organization_id": organizationID.String()}, nil
return map[string]string{
"organization_id": organizationID.String(),
"document_status": documentStatus.String(),
}, nil
}
func (p *Document) LoadByID(
@@ -87,6 +93,8 @@ SELECT
classification,
current_published_version,
trust_center_visibility,
status,
archived_at,
created_at,
updated_at
FROM
@@ -138,6 +146,8 @@ SELECT
classification,
current_published_version,
trust_center_visibility,
status,
archived_at,
created_at,
updated_at
FROM
@@ -190,6 +200,8 @@ SELECT
classification,
current_published_version,
trust_center_visibility,
status,
archived_at,
created_at,
updated_at
FROM
@@ -271,6 +283,8 @@ SELECT
classification,
current_published_version,
trust_center_visibility,
status,
archived_at,
created_at,
updated_at
FROM
@@ -321,6 +335,8 @@ SELECT
classification,
current_published_version,
trust_center_visibility,
status,
archived_at,
created_at,
updated_at
FROM
@@ -384,6 +400,8 @@ SELECT
classification,
current_published_version,
trust_center_visibility,
status,
archived_at,
created_at,
updated_at
FROM
@@ -431,6 +449,8 @@ INSERT INTO
classification,
current_published_version,
trust_center_visibility,
status,
archived_at,
created_at,
updated_at
)
@@ -443,6 +463,8 @@ VALUES (
@classification,
@current_published_version,
@trust_center_visibility,
@status,
@archived_at,
@created_at,
@updated_at
);
@@ -457,6 +479,8 @@ VALUES (
"classification": p.Classification,
"current_published_version": p.CurrentPublishedVersion,
"trust_center_visibility": p.TrustCenterVisibility,
"status": p.Status,
"archived_at": p.ArchivedAt,
"created_at": p.CreatedAt,
"updated_at": p.UpdatedAt,
}
@@ -515,6 +539,8 @@ SET
document_type = @document_type,
classification = @classification,
trust_center_visibility = @trust_center_visibility,
status = @status,
archived_at = @archived_at,
updated_at = @updated_at
WHERE
%s
@@ -531,6 +557,8 @@ WHERE
"document_type": p.DocumentType,
"classification": p.Classification,
"trust_center_visibility": p.TrustCenterVisibility,
"status": p.Status,
"archived_at": p.ArchivedAt,
}
maps.Copy(args, scope.SQLArguments())
@@ -603,6 +631,8 @@ SELECT
scoped_documents.classification,
scoped_documents.current_published_version,
scoped_documents.trust_center_visibility,
scoped_documents.status,
scoped_documents.archived_at,
scoped_documents.created_at,
scoped_documents.updated_at
FROM scoped_documents
@@ -692,6 +722,8 @@ SELECT
scoped_documents.classification,
scoped_documents.current_published_version,
scoped_documents.trust_center_visibility,
scoped_documents.status,
scoped_documents.archived_at,
scoped_documents.created_at,
scoped_documents.updated_at
FROM scoped_documents
@@ -744,6 +776,59 @@ UPDATE documents SET deleted_at = @deleted_at WHERE %s AND id = ANY(@document_id
return err
}
func (p *Documents) BulkArchive(
ctx context.Context,
conn pg.Conn,
scope Scoper,
) error {
q := `
UPDATE documents SET status = 'ARCHIVED', archived_at = @archived_at, trust_center_visibility = 'NONE' WHERE %s AND id = ANY(@document_ids)
`
q = fmt.Sprintf(q, scope.SQLFragment())
ids := make([]gid.GID, len(*p))
for i, doc := range *p {
ids[i] = doc.ID
}
args := pgx.StrictNamedArgs{
"document_ids": ids,
"archived_at": time.Now(),
}
maps.Copy(args, scope.SQLArguments())
if _, err := conn.Exec(ctx, q, args); err != nil {
return fmt.Errorf("cannot bulk archive documents: %w", err)
}
return nil
}
func (p *Documents) BulkUnarchive(
ctx context.Context,
conn pg.Conn,
scope Scoper,
) error {
q := `
UPDATE documents SET status = 'ACTIVE', archived_at = NULL WHERE %s AND id = ANY(@document_ids)
`
q = fmt.Sprintf(q, scope.SQLFragment())
ids := make([]gid.GID, len(*p))
for i, doc := range *p {
ids[i] = doc.ID
}
args := pgx.StrictNamedArgs{
"document_ids": ids,
}
maps.Copy(args, scope.SQLArguments())
if _, err := conn.Exec(ctx, q, args); err != nil {
return fmt.Errorf("cannot bulk unarchive documents: %w", err)
}
return nil
}
func (p *Document) IsLastSignableVersionSignedByUserEmail(
ctx context.Context,
conn pg.Conn,