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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
)`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
74
pkg/coredata/document_version_status.go
Normal file
74
pkg/coredata/document_version_status.go
Normal 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
|
||||
}
|
||||
7
pkg/coredata/migrations/20260317T120000Z.sql
Normal file
7
pkg/coredata/migrations/20260317T120000Z.sql
Normal 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;
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user