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

@@ -113,3 +113,33 @@ WHERE
_, err := conn.Exec(ctx, q, args)
return err
}
func (cp ControlDocument) DeleteByDocumentIDs(
ctx context.Context,
conn pg.Conn,
scope Scoper,
documentIDs []gid.GID,
) error {
q := `
DELETE
FROM
controls_documents
WHERE
%s
AND document_id = ANY(@document_ids);
`
args := pgx.StrictNamedArgs{
"document_ids": documentIDs,
}
maps.Copy(args, scope.SQLArguments())
q = fmt.Sprintf(q, scope.SQLFragment())
if _, err := conn.Exec(ctx, q, args); err != nil {
return fmt.Errorf("cannot delete control document mappings by document ids: %w", err)
}
return nil
}

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,

View File

@@ -26,6 +26,7 @@ type (
published *bool
userEmail *mail.Addr
documentTypes []DocumentType
status []DocumentStatus
}
)
@@ -43,6 +44,7 @@ func NewDocumentTrustCenterFilter() *DocumentFilter {
TrustCenterVisibilityPublic,
},
published: &published,
status: []DocumentStatus{DocumentStatusActive},
}
}
@@ -61,6 +63,11 @@ func (f *DocumentFilter) WithDocumentTypes(documentTypes []DocumentType) *Docume
return f
}
func (f *DocumentFilter) WithStatus(status []DocumentStatus) *DocumentFilter {
f.status = status
return f
}
func (f *DocumentFilter) SQLArguments() pgx.NamedArgs {
var visibilities []string
if f.trustCenterVisibilities != nil {
@@ -78,12 +85,21 @@ func (f *DocumentFilter) SQLArguments() pgx.NamedArgs {
}
}
var status []string
if f.status != nil {
status = make([]string, len(f.status))
for i, s := range f.status {
status[i] = s.String()
}
}
return pgx.NamedArgs{
"query": f.query,
"trust_center_visibilities": visibilities,
"published": f.published,
"user_email": f.userEmail,
"document_types": documentTypes,
"document_status": status,
}
}
@@ -131,5 +147,10 @@ func (f *DocumentFilter) SQLFragment() string {
document_type = ANY(@document_types::document_type[])
ELSE TRUE
END
AND
CASE
WHEN @document_status::text[] IS NULL THEN TRUE
ELSE status::text = ANY(@document_status::text[])
END
)`
}

View File

@@ -19,56 +19,43 @@ import (
"fmt"
)
type (
DocumentStatus uint8
)
type DocumentStatus string
const (
DocumentStatusDraft DocumentStatus = iota
DocumentStatusPublished
DocumentStatusActive DocumentStatus = "ACTIVE"
DocumentStatusArchived DocumentStatus = "ARCHIVED"
)
func (ps DocumentStatus) MarshalText() ([]byte, error) {
return []byte(ps.String()), nil
func (s DocumentStatus) IsValid() bool {
switch s {
case DocumentStatusActive, DocumentStatusArchived:
return true
}
return false
}
func (ps *DocumentStatus) UnmarshalText(data []byte) error {
val := string(data)
func (s DocumentStatus) String() string { return string(s) }
switch val {
case DocumentStatusDraft.String():
*ps = DocumentStatusDraft
case DocumentStatusPublished.String():
*ps = DocumentStatusPublished
default:
return fmt.Errorf("invalid DocumentStatus value: %q", val)
func (s *DocumentStatus) UnmarshalText(text []byte) error {
*s = DocumentStatus(text)
if !s.IsValid() {
return fmt.Errorf("%s is not a valid DocumentStatus", string(text))
}
return nil
}
func (ps DocumentStatus) String() string {
var val string
switch ps {
case DocumentStatusDraft:
val = "DRAFT"
case DocumentStatusPublished:
val = "PUBLISHED"
}
return val
func (s DocumentStatus) MarshalText() ([]byte, error) {
return []byte(s.String()), nil
}
func (ps *DocumentStatus) Scan(value any) error {
func (s *DocumentStatus) Scan(value any) error {
val, ok := value.(string)
if !ok {
return fmt.Errorf("invalid scan source for DocumentStatus, expected string got %T", value)
}
return ps.UnmarshalText([]byte(val))
return s.UnmarshalText([]byte(val))
}
func (ps DocumentStatus) Value() (driver.Value, error) {
return ps.String(), nil
func (s DocumentStatus) Value() (driver.Value, error) {
return s.String(), nil
}

View File

@@ -38,7 +38,7 @@ type (
Classification DocumentClassification `db:"classification"`
Content string `db:"content"`
Changelog string `db:"changelog"`
Status DocumentStatus `db:"status"`
Status DocumentVersionStatus `db:"status"`
PublishedAt *time.Time `db:"published_at"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
@@ -49,17 +49,29 @@ type (
// AuthorizationAttributes returns the authorization attributes for policy evaluation.
func (dv *DocumentVersion) AuthorizationAttributes(ctx context.Context, conn pg.Conn) (map[string]string, error) {
q := `SELECT organization_id FROM document_versions WHERE id = $1 LIMIT 1;`
q := `
SELECT
dv.organization_id,
d.status
FROM document_versions dv
INNER JOIN documents d ON d.id = dv.document_id
WHERE dv.id = $1
LIMIT 1;
`
var organizationID gid.GID
if err := conn.QueryRow(ctx, q, dv.ID).Scan(&organizationID); err != nil {
var documentStatus DocumentStatus
if err := conn.QueryRow(ctx, q, dv.ID).Scan(&organizationID, &documentStatus); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrResourceNotFound
}
return nil, fmt.Errorf("cannot query document version 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 (dv *DocumentVersions) LoadByDocumentID(
@@ -377,7 +389,7 @@ LIMIT 1;
args := pgx.StrictNamedArgs{
"document_id": documentID,
"status": DocumentStatusPublished,
"status": DocumentVersionStatusPublished,
}
maps.Copy(args, scope.SQLArguments())

View File

@@ -0,0 +1,74 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package coredata
import (
"database/sql/driver"
"fmt"
)
type (
DocumentVersionStatus uint8
)
const (
DocumentVersionStatusDraft DocumentVersionStatus = iota
DocumentVersionStatusPublished
)
func (ps DocumentVersionStatus) MarshalText() ([]byte, error) {
return []byte(ps.String()), nil
}
func (ps *DocumentVersionStatus) UnmarshalText(data []byte) error {
val := string(data)
switch val {
case DocumentVersionStatusDraft.String():
*ps = DocumentVersionStatusDraft
case DocumentVersionStatusPublished.String():
*ps = DocumentVersionStatusPublished
default:
return fmt.Errorf("invalid DocumentVersionStatus value: %q", val)
}
return nil
}
func (ps DocumentVersionStatus) String() string {
var val string
switch ps {
case DocumentVersionStatusDraft:
val = "DRAFT"
case DocumentVersionStatusPublished:
val = "PUBLISHED"
}
return val
}
func (ps *DocumentVersionStatus) Scan(value any) error {
val, ok := value.(string)
if !ok {
return fmt.Errorf("invalid scan source for DocumentVersionStatus, expected string got %T", value)
}
return ps.UnmarshalText([]byte(val))
}
func (ps DocumentVersionStatus) Value() (driver.Value, error) {
return ps.String(), nil
}

View File

@@ -0,0 +1,7 @@
ALTER TYPE policy_status RENAME TO document_version_status;
CREATE TYPE document_status AS ENUM ('ACTIVE', 'ARCHIVED');
ALTER TABLE documents ADD COLUMN archived_at TIMESTAMP WITH TIME ZONE;
ALTER TABLE documents ADD COLUMN status document_status NOT NULL DEFAULT 'ACTIVE';
ALTER TABLE documents ALTER COLUMN status DROP DEFAULT;

View File

@@ -99,3 +99,33 @@ WHERE
_, err := conn.Exec(ctx, q, args)
return err
}
func (rp RiskDocument) DeleteByDocumentIDs(
ctx context.Context,
conn pg.Conn,
scope Scoper,
documentIDs []gid.GID,
) error {
q := `
DELETE
FROM
risks_documents
WHERE
%s
AND document_id = ANY(@document_ids);
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"document_ids": documentIDs,
}
maps.Copy(args, scope.SQLArguments())
if _, err := conn.Exec(ctx, q, args); err != nil {
return fmt.Errorf("cannot delete risk document mappings by document ids: %w", err)
}
return nil
}