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
|
||||
}
|
||||
|
||||
@@ -181,6 +181,8 @@ const (
|
||||
ActionDocumentUpdate = "core:document:update"
|
||||
ActionDocumentDelete = "core:document:delete"
|
||||
ActionDocumentChangelogGenerate = "core:document:generate-changelog"
|
||||
ActionDocumentArchive = "core:document:archive"
|
||||
ActionDocumentUnarchive = "core:document:unarchive"
|
||||
ActionDocumentDraftVersionCreate = "core:document:create-draft-version"
|
||||
ActionDocumentSendSigningNotifications = "core:document:send-signing-notifications"
|
||||
|
||||
|
||||
@@ -49,6 +49,12 @@ type (
|
||||
ErrDocumentVersionNotDraft struct {
|
||||
}
|
||||
|
||||
ErrDocumentArchived struct {
|
||||
}
|
||||
|
||||
ErrDocumentNotArchived struct {
|
||||
}
|
||||
|
||||
ErrDocumentVersionSignatureAlreadySigned struct {
|
||||
}
|
||||
|
||||
@@ -164,6 +170,14 @@ func (e ErrDocumentVersionNotDraft) Error() string {
|
||||
return "cannot update a published document version"
|
||||
}
|
||||
|
||||
func (e ErrDocumentArchived) Error() string {
|
||||
return "cannot modify an archived document"
|
||||
}
|
||||
|
||||
func (e ErrDocumentNotArchived) Error() string {
|
||||
return "cannot unarchive a document that is not archived"
|
||||
}
|
||||
|
||||
func (e ErrDocumentVersionSignatureAlreadySigned) Error() string {
|
||||
return "document version signature already signed"
|
||||
}
|
||||
@@ -331,7 +345,7 @@ func (s DocumentService) GenerateChangelog(
|
||||
return fmt.Errorf("cannot load draft version: %w", err)
|
||||
}
|
||||
|
||||
if draftVersion.Status != coredata.DocumentStatusDraft {
|
||||
if draftVersion.Status != coredata.DocumentVersionStatusDraft {
|
||||
return fmt.Errorf("latest version is not a draft")
|
||||
}
|
||||
|
||||
@@ -450,15 +464,19 @@ func (s *DocumentService) publishVersionInTx(
|
||||
return nil, nil, fmt.Errorf("cannot load document %q: %w", documentID, err)
|
||||
}
|
||||
|
||||
if document.ArchivedAt != nil {
|
||||
return nil, nil, &ErrDocumentArchived{}
|
||||
}
|
||||
|
||||
if err := documentVersion.LoadLatestVersion(ctx, tx, s.svc.scope, documentID); err != nil {
|
||||
return nil, nil, fmt.Errorf("cannot load current draft: %w", err)
|
||||
}
|
||||
|
||||
if ignoreExisting && documentVersion.Status == coredata.DocumentStatusPublished {
|
||||
if ignoreExisting && documentVersion.Status == coredata.DocumentVersionStatusPublished {
|
||||
return document, documentVersion, nil
|
||||
}
|
||||
|
||||
if documentVersion.Status != coredata.DocumentStatusDraft {
|
||||
if documentVersion.Status != coredata.DocumentVersionStatusDraft {
|
||||
return nil, nil, fmt.Errorf("cannot publish version")
|
||||
}
|
||||
|
||||
@@ -479,7 +497,7 @@ func (s *DocumentService) publishVersionInTx(
|
||||
document.CurrentPublishedVersion = &documentVersion.VersionNumber
|
||||
document.UpdatedAt = now
|
||||
|
||||
documentVersion.Status = coredata.DocumentStatusPublished
|
||||
documentVersion.Status = coredata.DocumentVersionStatusPublished
|
||||
documentVersion.PublishedAt = &now
|
||||
documentVersion.UpdatedAt = now
|
||||
|
||||
@@ -514,6 +532,7 @@ func (s *DocumentService) Create(
|
||||
DocumentType: req.DocumentType,
|
||||
TrustCenterVisibility: coredata.TrustCenterVisibilityNone,
|
||||
Classification: req.Classification,
|
||||
Status: coredata.DocumentStatusActive,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
@@ -528,7 +547,7 @@ func (s *DocumentService) Create(
|
||||
Title: req.Title,
|
||||
VersionNumber: 1,
|
||||
Content: req.Content,
|
||||
Status: coredata.DocumentStatusDraft,
|
||||
Status: coredata.DocumentVersionStatusDraft,
|
||||
Classification: req.Classification,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
@@ -741,7 +760,7 @@ func (s *DocumentService) signDocumentVersionInTx(
|
||||
return nil, fmt.Errorf("cannot load document version %q: %w", documentVersionID, err)
|
||||
}
|
||||
|
||||
if documentVersion.Status != coredata.DocumentStatusPublished {
|
||||
if documentVersion.Status != coredata.DocumentVersionStatusPublished {
|
||||
return nil, fmt.Errorf("cannot sign unpublished version")
|
||||
}
|
||||
|
||||
@@ -790,7 +809,11 @@ func (s *DocumentService) UpdateVersion(
|
||||
return fmt.Errorf("cannot load document %q: %w", documentVersion.DocumentID, err)
|
||||
}
|
||||
|
||||
if documentVersion.Status != coredata.DocumentStatusDraft {
|
||||
if document.ArchivedAt != nil {
|
||||
return &ErrDocumentArchived{}
|
||||
}
|
||||
|
||||
if documentVersion.Status != coredata.DocumentVersionStatusDraft {
|
||||
return &ErrDocumentVersionNotDraft{}
|
||||
}
|
||||
|
||||
@@ -871,7 +894,7 @@ func (s *DocumentService) BulkRequestSignatures(
|
||||
return fmt.Errorf("cannot load latest version for document %q: %w", documentID, err)
|
||||
}
|
||||
|
||||
if documentVersion.Status != coredata.DocumentStatusPublished {
|
||||
if documentVersion.Status != coredata.DocumentVersionStatusPublished {
|
||||
return fmt.Errorf("cannot request signature for unpublished document %q", documentID)
|
||||
}
|
||||
|
||||
@@ -948,7 +971,7 @@ func (s *DocumentService) RequestSignature(
|
||||
return nil, fmt.Errorf("cannot get document version: %w", err)
|
||||
}
|
||||
|
||||
if documentVersion.Status != coredata.DocumentStatusPublished {
|
||||
if documentVersion.Status != coredata.DocumentVersionStatusPublished {
|
||||
return nil, fmt.Errorf("cannot request signature for unpublished version")
|
||||
}
|
||||
|
||||
@@ -1046,7 +1069,7 @@ func (s *DocumentService) CreateDraft(
|
||||
return fmt.Errorf("cannot load latest version: %w", err)
|
||||
}
|
||||
|
||||
if latestVersion.Status != coredata.DocumentStatusPublished {
|
||||
if latestVersion.Status != coredata.DocumentVersionStatusPublished {
|
||||
return fmt.Errorf("cannot create draft from unpublished version")
|
||||
}
|
||||
|
||||
@@ -1057,7 +1080,7 @@ func (s *DocumentService) CreateDraft(
|
||||
draftVersion.VersionNumber = latestVersion.VersionNumber + 1
|
||||
draftVersion.Classification = document.Classification
|
||||
draftVersion.Content = latestVersion.Content
|
||||
draftVersion.Status = coredata.DocumentStatusDraft
|
||||
draftVersion.Status = coredata.DocumentVersionStatusDraft
|
||||
draftVersion.CreatedAt = now
|
||||
draftVersion.UpdatedAt = now
|
||||
|
||||
@@ -1106,7 +1129,7 @@ func (s *DocumentService) DeleteDraft(
|
||||
return fmt.Errorf("cannot load document version: %w", err)
|
||||
}
|
||||
|
||||
if documentVersion.Status != coredata.DocumentStatusDraft {
|
||||
if documentVersion.Status != coredata.DocumentVersionStatusDraft {
|
||||
return fmt.Errorf("cannot delete published document version")
|
||||
}
|
||||
|
||||
@@ -1155,6 +1178,52 @@ func (s *DocumentService) BulkSoftDelete(
|
||||
)
|
||||
}
|
||||
|
||||
func (s *DocumentService) BulkArchive(
|
||||
ctx context.Context,
|
||||
documentIDs []gid.GID,
|
||||
) error {
|
||||
documents := coredata.Documents{}
|
||||
|
||||
for _, documentID := range documentIDs {
|
||||
documents = append(documents, &coredata.Document{ID: documentID})
|
||||
}
|
||||
|
||||
return s.svc.pg.WithTx(
|
||||
ctx,
|
||||
func(tx pg.Conn) error {
|
||||
controlDocument := coredata.ControlDocument{}
|
||||
if err := controlDocument.DeleteByDocumentIDs(ctx, tx, s.svc.scope, documentIDs); err != nil {
|
||||
return fmt.Errorf("cannot delete control mappings: %w", err)
|
||||
}
|
||||
|
||||
riskDocument := coredata.RiskDocument{}
|
||||
if err := riskDocument.DeleteByDocumentIDs(ctx, tx, s.svc.scope, documentIDs); err != nil {
|
||||
return fmt.Errorf("cannot delete risk mappings: %w", err)
|
||||
}
|
||||
|
||||
return documents.BulkArchive(ctx, tx, s.svc.scope)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func (s *DocumentService) BulkUnarchive(
|
||||
ctx context.Context,
|
||||
documentIDs []gid.GID,
|
||||
) error {
|
||||
documents := coredata.Documents{}
|
||||
|
||||
for _, documentID := range documentIDs {
|
||||
documents = append(documents, &coredata.Document{ID: documentID})
|
||||
}
|
||||
|
||||
return s.svc.pg.WithConn(
|
||||
ctx,
|
||||
func(conn pg.Conn) error {
|
||||
return documents.BulkUnarchive(ctx, conn, s.svc.scope)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func (s *DocumentService) RequestExport(
|
||||
ctx context.Context,
|
||||
documentIDs []gid.GID,
|
||||
@@ -1519,6 +1588,10 @@ func (s *DocumentService) Update(
|
||||
return fmt.Errorf("cannot load document %q: %w", req.DocumentID, err)
|
||||
}
|
||||
|
||||
if document.ArchivedAt != nil {
|
||||
return &ErrDocumentArchived{}
|
||||
}
|
||||
|
||||
if req.Title != nil {
|
||||
document.Title = *req.Title
|
||||
}
|
||||
@@ -1574,7 +1647,7 @@ func (s *DocumentService) Update(
|
||||
|
||||
draftVersion := &coredata.DocumentVersion{}
|
||||
err := draftVersion.LoadLatestVersion(ctx, tx, s.svc.scope, req.DocumentID)
|
||||
if err == nil && draftVersion.Status == coredata.DocumentStatusDraft {
|
||||
if err == nil && draftVersion.Status == coredata.DocumentVersionStatusDraft {
|
||||
draftVersion.Title = document.Title
|
||||
draftVersion.Classification = document.Classification
|
||||
draftVersion.UpdatedAt = now
|
||||
@@ -1614,6 +1687,91 @@ func (s *DocumentService) Update(
|
||||
return document, nil
|
||||
}
|
||||
|
||||
func (s *DocumentService) Archive(
|
||||
ctx context.Context,
|
||||
documentID gid.GID,
|
||||
) (*coredata.Document, error) {
|
||||
document := &coredata.Document{}
|
||||
now := time.Now()
|
||||
|
||||
err := s.svc.pg.WithTx(
|
||||
ctx,
|
||||
func(tx pg.Conn) error {
|
||||
if err := document.LoadByID(ctx, tx, s.svc.scope, documentID); err != nil {
|
||||
return fmt.Errorf("cannot load document %q: %w", documentID, err)
|
||||
}
|
||||
|
||||
if document.ArchivedAt != nil {
|
||||
return &ErrDocumentArchived{}
|
||||
}
|
||||
|
||||
controlDocument := coredata.ControlDocument{}
|
||||
if err := controlDocument.DeleteByDocumentIDs(ctx, tx, s.svc.scope, []gid.GID{documentID}); err != nil {
|
||||
return fmt.Errorf("cannot delete control mappings: %w", err)
|
||||
}
|
||||
|
||||
riskDocument := coredata.RiskDocument{}
|
||||
if err := riskDocument.DeleteByDocumentIDs(ctx, tx, s.svc.scope, []gid.GID{documentID}); err != nil {
|
||||
return fmt.Errorf("cannot delete risk mappings: %w", err)
|
||||
}
|
||||
|
||||
document.Status = coredata.DocumentStatusArchived
|
||||
document.ArchivedAt = &now
|
||||
document.UpdatedAt = now
|
||||
document.TrustCenterVisibility = coredata.TrustCenterVisibilityNone
|
||||
|
||||
if err := document.Update(ctx, tx, s.svc.scope); err != nil {
|
||||
return fmt.Errorf("cannot archive document: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return document, nil
|
||||
}
|
||||
|
||||
func (s *DocumentService) Unarchive(
|
||||
ctx context.Context,
|
||||
documentID gid.GID,
|
||||
) (*coredata.Document, error) {
|
||||
document := &coredata.Document{}
|
||||
now := time.Now()
|
||||
|
||||
err := s.svc.pg.WithTx(
|
||||
ctx,
|
||||
func(tx pg.Conn) error {
|
||||
if err := document.LoadByID(ctx, tx, s.svc.scope, documentID); err != nil {
|
||||
return fmt.Errorf("cannot load document %q: %w", documentID, err)
|
||||
}
|
||||
|
||||
if document.ArchivedAt == nil {
|
||||
return &ErrDocumentNotArchived{}
|
||||
}
|
||||
|
||||
document.Status = coredata.DocumentStatusActive
|
||||
document.ArchivedAt = nil
|
||||
document.UpdatedAt = now
|
||||
|
||||
if err := document.Update(ctx, tx, s.svc.scope); err != nil {
|
||||
return fmt.Errorf("cannot unarchive document: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return document, nil
|
||||
}
|
||||
|
||||
func (s *DocumentService) CancelSignatureRequest(
|
||||
ctx context.Context,
|
||||
documentVersionSignatureID gid.GID,
|
||||
|
||||
@@ -19,12 +19,37 @@ import (
|
||||
"go.probo.inc/probo/pkg/iam/policy"
|
||||
)
|
||||
|
||||
var organizationCondition = policy.Equals("principal.organization_id", "resource.organization_id")
|
||||
var (
|
||||
organizationCondition = policy.Equals("principal.organization_id", "resource.organization_id")
|
||||
documentWriteActiveOnly = policy.Deny(
|
||||
ActionDocumentUpdate,
|
||||
ActionDocumentArchive,
|
||||
ActionDocumentDraftVersionCreate,
|
||||
ActionDocumentChangelogGenerate,
|
||||
ActionDocumentSendSigningNotifications,
|
||||
ActionDocumentVersionUpdate,
|
||||
ActionDocumentVersionPublish,
|
||||
ActionDocumentVersionDeleteDraft,
|
||||
ActionDocumentVersionSignatureRequest,
|
||||
ActionDocumentVersionCancelSignature,
|
||||
).WithSID("document-write-active-only").When(
|
||||
organizationCondition,
|
||||
policy.Equals("resource.document_status", "ARCHIVED"),
|
||||
)
|
||||
documentUnarchiveArchivedOnly = policy.Deny(
|
||||
ActionDocumentUnarchive,
|
||||
).WithSID("document-unarchive-archived-only").When(
|
||||
organizationCondition,
|
||||
policy.Equals("resource.document_status", "ACTIVE"),
|
||||
)
|
||||
)
|
||||
|
||||
// OwnerPolicy defines permissions for organization owners.
|
||||
var OwnerPolicy = policy.NewPolicy(
|
||||
"probo:owner",
|
||||
"Probo Owner",
|
||||
documentWriteActiveOnly,
|
||||
documentUnarchiveArchivedOnly,
|
||||
policy.Allow("core:*").WithSID("full-core-access").When(organizationCondition),
|
||||
).WithDescription("Full probo access for organization owners")
|
||||
|
||||
@@ -32,6 +57,8 @@ var OwnerPolicy = policy.NewPolicy(
|
||||
var AdminPolicy = policy.NewPolicy(
|
||||
"probo:admin",
|
||||
"Probo Admin",
|
||||
documentWriteActiveOnly,
|
||||
documentUnarchiveArchivedOnly,
|
||||
policy.Allow("core:*").WithSID("full-core-access").When(organizationCondition),
|
||||
).WithDescription("Probo admin access - can manage core entities")
|
||||
|
||||
|
||||
@@ -69,15 +69,26 @@ enum EvidenceState
|
||||
@goEnum(value: "go.probo.inc/probo/pkg/coredata.EvidenceStateRequested")
|
||||
}
|
||||
|
||||
enum DocumentStatus
|
||||
@goModel(model: "go.probo.inc/probo/pkg/coredata.DocumentStatus") {
|
||||
DRAFT @goEnum(value: "go.probo.inc/probo/pkg/coredata.DocumentStatusDraft")
|
||||
enum DocumentVersionStatus
|
||||
@goModel(model: "go.probo.inc/probo/pkg/coredata.DocumentVersionStatus") {
|
||||
DRAFT
|
||||
@goEnum(
|
||||
value: "go.probo.inc/probo/pkg/coredata.DocumentVersionStatusDraft"
|
||||
)
|
||||
PUBLISHED
|
||||
@goEnum(
|
||||
value: "go.probo.inc/probo/pkg/coredata.DocumentStatusPublished"
|
||||
value: "go.probo.inc/probo/pkg/coredata.DocumentVersionStatusPublished"
|
||||
)
|
||||
}
|
||||
|
||||
enum DocumentStatus
|
||||
@goModel(model: "go.probo.inc/probo/pkg/coredata.DocumentStatus") {
|
||||
ACTIVE
|
||||
@goEnum(value: "go.probo.inc/probo/pkg/coredata.DocumentStatusActive")
|
||||
ARCHIVED
|
||||
@goEnum(value: "go.probo.inc/probo/pkg/coredata.DocumentStatusArchived")
|
||||
}
|
||||
|
||||
enum EvidenceType
|
||||
@goModel(model: "go.probo.inc/probo/pkg/coredata.EvidenceType") {
|
||||
FILE @goEnum(value: "go.probo.inc/probo/pkg/coredata.EvidenceTypeFile")
|
||||
@@ -1569,7 +1580,7 @@ input ApplicabilityStatementOrder
|
||||
}
|
||||
|
||||
input DocumentVersionFilter {
|
||||
status: DocumentStatus
|
||||
status: DocumentVersionStatus
|
||||
}
|
||||
|
||||
# Input Types for Filtering
|
||||
@@ -1580,6 +1591,7 @@ input ControlFilter {
|
||||
input DocumentFilter {
|
||||
query: String
|
||||
documentTypes: [DocumentType!]
|
||||
status: [DocumentStatus!]
|
||||
}
|
||||
|
||||
input MeasureFilter {
|
||||
@@ -2402,6 +2414,9 @@ type Document implements Node {
|
||||
filter: ControlFilter
|
||||
): ControlConnection! @goField(forceResolver: true)
|
||||
|
||||
status: DocumentStatus!
|
||||
archivedAt: Datetime
|
||||
|
||||
createdAt: Datetime!
|
||||
updatedAt: Datetime!
|
||||
|
||||
@@ -3657,6 +3672,8 @@ type Mutation {
|
||||
# Document mutations
|
||||
createDocument(input: CreateDocumentInput!): CreateDocumentPayload!
|
||||
updateDocument(input: UpdateDocumentInput!): UpdateDocumentPayload!
|
||||
archiveDocument(input: ArchiveDocumentInput!): ArchiveDocumentPayload!
|
||||
unarchiveDocument(input: UnarchiveDocumentInput!): UnarchiveDocumentPayload!
|
||||
deleteDocument(input: DeleteDocumentInput!): DeleteDocumentPayload!
|
||||
# Meeting mutations
|
||||
createMeeting(input: CreateMeetingInput!): CreateMeetingPayload!
|
||||
@@ -3694,6 +3711,12 @@ type Mutation {
|
||||
bulkDeleteDocuments(
|
||||
input: BulkDeleteDocumentsInput!
|
||||
): BulkDeleteDocumentsPayload!
|
||||
bulkArchiveDocuments(
|
||||
input: BulkArchiveDocumentsInput!
|
||||
): BulkArchiveDocumentsPayload!
|
||||
bulkUnarchiveDocuments(
|
||||
input: BulkUnarchiveDocumentsInput!
|
||||
): BulkUnarchiveDocumentsPayload!
|
||||
bulkExportDocuments(
|
||||
input: BulkExportDocumentsInput!
|
||||
): BulkExportDocumentsPayload!
|
||||
@@ -4367,6 +4390,14 @@ input ExportTransferImpactAssessmentsPDFInput {
|
||||
filter: TransferImpactAssessmentFilter
|
||||
}
|
||||
|
||||
input ArchiveDocumentInput {
|
||||
documentId: ID!
|
||||
}
|
||||
|
||||
input UnarchiveDocumentInput {
|
||||
documentId: ID!
|
||||
}
|
||||
|
||||
input DeleteDocumentInput {
|
||||
documentId: ID!
|
||||
}
|
||||
@@ -5086,6 +5117,14 @@ type UpdateDocumentPayload {
|
||||
document: Document!
|
||||
}
|
||||
|
||||
type ArchiveDocumentPayload {
|
||||
document: Document!
|
||||
}
|
||||
|
||||
type UnarchiveDocumentPayload {
|
||||
document: Document!
|
||||
}
|
||||
|
||||
type DeleteDocumentPayload {
|
||||
deletedDocumentId: ID!
|
||||
}
|
||||
@@ -5197,7 +5236,7 @@ type DeleteMeasurePayload {
|
||||
type DocumentVersion implements Node {
|
||||
id: ID!
|
||||
document: Document! @goField(forceResolver: true)
|
||||
status: DocumentStatus!
|
||||
status: DocumentVersionStatus!
|
||||
version: Int!
|
||||
content: String!
|
||||
changelog: String!
|
||||
@@ -5326,6 +5365,22 @@ input BulkDeleteDocumentsInput {
|
||||
documentIds: [ID!]!
|
||||
}
|
||||
|
||||
input BulkArchiveDocumentsInput {
|
||||
documentIds: [ID!]!
|
||||
}
|
||||
|
||||
type BulkArchiveDocumentsPayload {
|
||||
documents: [Document!]!
|
||||
}
|
||||
|
||||
input BulkUnarchiveDocumentsInput {
|
||||
documentIds: [ID!]!
|
||||
}
|
||||
|
||||
type BulkUnarchiveDocumentsPayload {
|
||||
documents: [Document!]!
|
||||
}
|
||||
|
||||
input BulkExportDocumentsInput {
|
||||
documentIds: [ID!]!
|
||||
withWatermark: Boolean!
|
||||
|
||||
@@ -84,6 +84,8 @@ func NewDocument(document *coredata.Document) *Document {
|
||||
Classification: document.Classification,
|
||||
CurrentPublishedVersion: document.CurrentPublishedVersion,
|
||||
TrustCenterVisibility: document.TrustCenterVisibility,
|
||||
Status: document.Status,
|
||||
ArchivedAt: document.ArchivedAt,
|
||||
CreatedAt: document.CreatedAt,
|
||||
UpdatedAt: document.UpdatedAt,
|
||||
}
|
||||
|
||||
@@ -4286,6 +4286,9 @@ func (r *mutationResolver) UpdateDocument(ctx context.Context, input types.Updat
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
if errArchived, ok := errors.AsType[*probo.ErrDocumentArchived](err); ok {
|
||||
return nil, gqlutils.Conflict(ctx, errArchived)
|
||||
}
|
||||
if validationErrors, ok := errors.AsType[validator.ValidationErrors](err); ok {
|
||||
return nil, gqlutils.InvalidValidationErrors(ctx, validationErrors)
|
||||
}
|
||||
@@ -4298,6 +4301,50 @@ func (r *mutationResolver) UpdateDocument(ctx context.Context, input types.Updat
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ArchiveDocument is the resolver for the archiveDocument field.
|
||||
func (r *mutationResolver) ArchiveDocument(ctx context.Context, input types.ArchiveDocumentInput) (*types.ArchiveDocumentPayload, error) {
|
||||
if err := r.authorize(ctx, input.DocumentID, probo.ActionDocumentArchive); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
prb := r.ProboService(ctx, input.DocumentID.TenantID())
|
||||
|
||||
document, err := prb.Documents.Archive(ctx, input.DocumentID)
|
||||
if err != nil {
|
||||
if errArchived, ok := errors.AsType[*probo.ErrDocumentArchived](err); ok {
|
||||
return nil, gqlutils.Conflict(ctx, errArchived)
|
||||
}
|
||||
r.logger.ErrorCtx(ctx, "cannot archive document", log.Error(err))
|
||||
return nil, gqlutils.Internal(ctx)
|
||||
}
|
||||
|
||||
return &types.ArchiveDocumentPayload{
|
||||
Document: types.NewDocument(document),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// UnarchiveDocument is the resolver for the unarchiveDocument field.
|
||||
func (r *mutationResolver) UnarchiveDocument(ctx context.Context, input types.UnarchiveDocumentInput) (*types.UnarchiveDocumentPayload, error) {
|
||||
if err := r.authorize(ctx, input.DocumentID, probo.ActionDocumentUnarchive); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
prb := r.ProboService(ctx, input.DocumentID.TenantID())
|
||||
|
||||
document, err := prb.Documents.Unarchive(ctx, input.DocumentID)
|
||||
if err != nil {
|
||||
if errNotArchived, ok := errors.AsType[*probo.ErrDocumentNotArchived](err); ok {
|
||||
return nil, gqlutils.Conflict(ctx, errNotArchived)
|
||||
}
|
||||
r.logger.ErrorCtx(ctx, "cannot unarchive document", log.Error(err))
|
||||
return nil, gqlutils.Internal(ctx)
|
||||
}
|
||||
|
||||
return &types.UnarchiveDocumentPayload{
|
||||
Document: types.NewDocument(document),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DeleteDocument is the resolver for the deleteDocument field.
|
||||
func (r *mutationResolver) DeleteDocument(ctx context.Context, input types.DeleteDocumentInput) (*types.DeleteDocumentPayload, error) {
|
||||
if err := r.authorize(ctx, input.DocumentID, probo.ActionDocumentDelete); err != nil {
|
||||
@@ -4607,6 +4654,10 @@ func (r *mutationResolver) PublishDocumentVersion(ctx context.Context, input typ
|
||||
return nil, gqlutils.Invalid(ctx, errNoChanges)
|
||||
}
|
||||
|
||||
if errArchived, ok := errors.AsType[*probo.ErrDocumentArchived](err); ok {
|
||||
return nil, gqlutils.Conflict(ctx, errArchived)
|
||||
}
|
||||
|
||||
r.logger.ErrorCtx(ctx, "cannot publish document version", log.Error(err))
|
||||
return nil, gqlutils.Internal(ctx)
|
||||
}
|
||||
@@ -4687,6 +4738,58 @@ func (r *mutationResolver) BulkDeleteDocuments(ctx context.Context, input types.
|
||||
}, nil
|
||||
}
|
||||
|
||||
// BulkArchiveDocuments is the resolver for the bulkArchiveDocuments field.
|
||||
func (r *mutationResolver) BulkArchiveDocuments(ctx context.Context, input types.BulkArchiveDocumentsInput) (*types.BulkArchiveDocumentsPayload, error) {
|
||||
if len(input.DocumentIds) == 0 {
|
||||
return &types.BulkArchiveDocumentsPayload{
|
||||
Documents: []*types.Document{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
for _, documentID := range input.DocumentIds {
|
||||
if err := r.authorize(ctx, documentID, probo.ActionDocumentArchive); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
prb := r.ProboService(ctx, input.DocumentIds[0].TenantID())
|
||||
|
||||
if err := prb.Documents.BulkArchive(ctx, input.DocumentIds); err != nil {
|
||||
r.logger.ErrorCtx(ctx, "cannot bulk archive documents", log.Error(err))
|
||||
return nil, gqlutils.Internal(ctx)
|
||||
}
|
||||
|
||||
return &types.BulkArchiveDocumentsPayload{
|
||||
Documents: []*types.Document{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// BulkUnarchiveDocuments is the resolver for the bulkUnarchiveDocuments field.
|
||||
func (r *mutationResolver) BulkUnarchiveDocuments(ctx context.Context, input types.BulkUnarchiveDocumentsInput) (*types.BulkUnarchiveDocumentsPayload, error) {
|
||||
if len(input.DocumentIds) == 0 {
|
||||
return &types.BulkUnarchiveDocumentsPayload{
|
||||
Documents: []*types.Document{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
for _, documentID := range input.DocumentIds {
|
||||
if err := r.authorize(ctx, documentID, probo.ActionDocumentUnarchive); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
prb := r.ProboService(ctx, input.DocumentIds[0].TenantID())
|
||||
|
||||
if err := prb.Documents.BulkUnarchive(ctx, input.DocumentIds); err != nil {
|
||||
r.logger.ErrorCtx(ctx, "cannot bulk unarchive documents", log.Error(err))
|
||||
return nil, gqlutils.Internal(ctx)
|
||||
}
|
||||
|
||||
return &types.BulkUnarchiveDocumentsPayload{
|
||||
Documents: []*types.Document{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// BulkExportDocuments is the resolver for the bulkExportDocuments field.
|
||||
func (r *mutationResolver) BulkExportDocuments(ctx context.Context, input types.BulkExportDocumentsInput) (*types.BulkExportDocumentsPayload, error) {
|
||||
if len(input.DocumentIds) == 0 {
|
||||
@@ -4803,6 +4906,9 @@ func (r *mutationResolver) UpdateDocumentVersion(ctx context.Context, input type
|
||||
return nil, gqlutils.Conflict(ctx, errNotDraft)
|
||||
}
|
||||
|
||||
if errArchived, ok := errors.AsType[*probo.ErrDocumentArchived](err); ok {
|
||||
return nil, gqlutils.Conflict(ctx, errArchived)
|
||||
}
|
||||
if validationErrors, ok := errors.AsType[validator.ValidationErrors](err); ok {
|
||||
return nil, gqlutils.InvalidValidationErrors(ctx, validationErrors)
|
||||
}
|
||||
@@ -6479,7 +6585,8 @@ func (r *organizationResolver) Documents(ctx context.Context, obj *types.Organiz
|
||||
var documentFilter = coredata.NewDocumentFilter(nil)
|
||||
if filter != nil {
|
||||
documentFilter = coredata.NewDocumentFilter(filter.Query).
|
||||
WithDocumentTypes(filter.DocumentTypes)
|
||||
WithDocumentTypes(filter.DocumentTypes).
|
||||
WithStatus(filter.Status)
|
||||
}
|
||||
|
||||
page, err := prb.Documents.ListByOrganizationID(ctx, obj.ID, cursor, documentFilter)
|
||||
|
||||
@@ -799,7 +799,7 @@ func (r *Resolver) AddFindingTool(ctx context.Context, req *mcp.CallToolRequest,
|
||||
Kind: input.Kind,
|
||||
Description: input.Description,
|
||||
Source: input.Source,
|
||||
IdentifiedOn: input.IdentifiedOn,
|
||||
IdentifiedOn: input.IdentifiedOn,
|
||||
RootCause: input.RootCause,
|
||||
CorrectiveAction: input.CorrectiveAction,
|
||||
OwnerID: input.OwnerID,
|
||||
@@ -830,7 +830,7 @@ func (r *Resolver) UpdateFindingTool(ctx context.Context, req *mcp.CallToolReque
|
||||
ID: input.ID,
|
||||
Description: UnwrapOmittable(input.Description),
|
||||
Source: UnwrapOmittable(input.Source),
|
||||
IdentifiedOn: UnwrapOmittable(input.IdentifiedOn),
|
||||
IdentifiedOn: UnwrapOmittable(input.IdentifiedOn),
|
||||
RootCause: UnwrapOmittable(input.RootCause),
|
||||
CorrectiveAction: UnwrapOmittable(input.CorrectiveAction),
|
||||
OwnerID: input.OwnerID,
|
||||
@@ -3201,3 +3201,43 @@ func (r *Resolver) ListFindingAuditsTool(ctx context.Context, req *mcp.CallToolR
|
||||
|
||||
return nil, types.NewListFindingAuditsOutput(auditPage), nil
|
||||
}
|
||||
|
||||
func (r *Resolver) ArchiveDocumentTool(ctx context.Context, req *mcp.CallToolRequest, input *types.ArchiveDocumentInput) (*mcp.CallToolResult, types.ArchiveDocumentOutput, error) {
|
||||
r.MustAuthorize(ctx, input.ID, probo.ActionDocumentArchive)
|
||||
|
||||
svc := r.ProboService(ctx, input.ID)
|
||||
|
||||
document, err := svc.Documents.Archive(ctx, input.ID)
|
||||
if err != nil {
|
||||
return nil, types.ArchiveDocumentOutput{}, fmt.Errorf("cannot archive document: %w", err)
|
||||
}
|
||||
|
||||
approverPage, err := svc.Documents.ListApprovers(ctx, input.ID, allApproversCursor())
|
||||
if err != nil {
|
||||
return nil, types.ArchiveDocumentOutput{}, fmt.Errorf("cannot list document approvers: %w", err)
|
||||
}
|
||||
|
||||
return nil, types.ArchiveDocumentOutput{
|
||||
Document: types.NewDocument(document, profileIDs(approverPage)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *Resolver) UnarchiveDocumentTool(ctx context.Context, req *mcp.CallToolRequest, input *types.UnarchiveDocumentInput) (*mcp.CallToolResult, types.UnarchiveDocumentOutput, error) {
|
||||
r.MustAuthorize(ctx, input.ID, probo.ActionDocumentUnarchive)
|
||||
|
||||
svc := r.ProboService(ctx, input.ID)
|
||||
|
||||
document, err := svc.Documents.Unarchive(ctx, input.ID)
|
||||
if err != nil {
|
||||
return nil, types.UnarchiveDocumentOutput{}, fmt.Errorf("cannot unarchive document: %w", err)
|
||||
}
|
||||
|
||||
approverPage, err := svc.Documents.ListApprovers(ctx, input.ID, allApproversCursor())
|
||||
if err != nil {
|
||||
return nil, types.UnarchiveDocumentOutput{}, fmt.Errorf("cannot list document approvers: %w", err)
|
||||
}
|
||||
|
||||
return nil, types.UnarchiveDocumentOutput{
|
||||
Document: types.NewDocument(document, profileIDs(approverPage)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -5057,11 +5057,18 @@ components:
|
||||
- SECRET
|
||||
go.probo.inc/mcpgen/type: go.probo.inc/probo/pkg/coredata.DocumentClassification
|
||||
|
||||
DocumentStatus:
|
||||
DocumentVersionStatus:
|
||||
type: string
|
||||
enum:
|
||||
- DRAFT
|
||||
- PUBLISHED
|
||||
go.probo.inc/mcpgen/type: go.probo.inc/probo/pkg/coredata.DocumentVersionStatus
|
||||
|
||||
DocumentStatus:
|
||||
type: string
|
||||
enum:
|
||||
- ACTIVE
|
||||
- ARCHIVED
|
||||
go.probo.inc/mcpgen/type: go.probo.inc/probo/pkg/coredata.DocumentStatus
|
||||
|
||||
DocumentVersionSignatureState:
|
||||
@@ -5142,6 +5149,7 @@ components:
|
||||
- document_type
|
||||
- classification
|
||||
- trust_center_visibility
|
||||
- status
|
||||
- created_at
|
||||
- updated_at
|
||||
properties:
|
||||
@@ -5173,6 +5181,15 @@ components:
|
||||
trust_center_visibility:
|
||||
$ref: "#/components/schemas/TrustCenterVisibility"
|
||||
description: Trust center visibility
|
||||
status:
|
||||
$ref: "#/components/schemas/DocumentStatus"
|
||||
description: Document status
|
||||
archived_at:
|
||||
type:
|
||||
- string
|
||||
- "null"
|
||||
format: date-time
|
||||
description: Archive timestamp
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
@@ -5228,8 +5245,8 @@ components:
|
||||
type: string
|
||||
description: Changelog
|
||||
status:
|
||||
$ref: "#/components/schemas/DocumentStatus"
|
||||
description: Document status
|
||||
$ref: "#/components/schemas/DocumentVersionStatus"
|
||||
description: Document version status
|
||||
published_at:
|
||||
type:
|
||||
- string
|
||||
@@ -5434,6 +5451,40 @@ components:
|
||||
document:
|
||||
$ref: "#/components/schemas/Document"
|
||||
|
||||
ArchiveDocumentInput:
|
||||
type: object
|
||||
required:
|
||||
- id
|
||||
properties:
|
||||
id:
|
||||
$ref: "#/components/schemas/GID"
|
||||
description: Document ID
|
||||
|
||||
ArchiveDocumentOutput:
|
||||
type: object
|
||||
required:
|
||||
- document
|
||||
properties:
|
||||
document:
|
||||
$ref: "#/components/schemas/Document"
|
||||
|
||||
UnarchiveDocumentInput:
|
||||
type: object
|
||||
required:
|
||||
- id
|
||||
properties:
|
||||
id:
|
||||
$ref: "#/components/schemas/GID"
|
||||
description: Document ID
|
||||
|
||||
UnarchiveDocumentOutput:
|
||||
type: object
|
||||
required:
|
||||
- document
|
||||
properties:
|
||||
document:
|
||||
$ref: "#/components/schemas/Document"
|
||||
|
||||
ListDocumentVersionsInput:
|
||||
type: object
|
||||
required:
|
||||
@@ -7133,6 +7184,22 @@ tools:
|
||||
$ref: "#/components/schemas/UpdateDocumentInput"
|
||||
outputSchema:
|
||||
$ref: "#/components/schemas/UpdateDocumentOutput"
|
||||
- name: archiveDocument
|
||||
description: Archive a document to prevent further modifications
|
||||
hints:
|
||||
readonly: false
|
||||
inputSchema:
|
||||
$ref: "#/components/schemas/ArchiveDocumentInput"
|
||||
outputSchema:
|
||||
$ref: "#/components/schemas/ArchiveDocumentOutput"
|
||||
- name: unarchiveDocument
|
||||
description: Unarchive a document to allow modifications again
|
||||
hints:
|
||||
readonly: false
|
||||
inputSchema:
|
||||
$ref: "#/components/schemas/UnarchiveDocumentInput"
|
||||
outputSchema:
|
||||
$ref: "#/components/schemas/UnarchiveDocumentOutput"
|
||||
- name: listDocumentVersions
|
||||
description: List all versions for a document
|
||||
hints:
|
||||
|
||||
@@ -30,6 +30,8 @@ func NewDocument(d *coredata.Document, approverIDs []gid.GID) *Document {
|
||||
Classification: d.Classification,
|
||||
CurrentPublishedVersion: d.CurrentPublishedVersion,
|
||||
TrustCenterVisibility: d.TrustCenterVisibility,
|
||||
Status: d.Status,
|
||||
ArchivedAt: d.ArchivedAt,
|
||||
CreatedAt: d.CreatedAt,
|
||||
UpdatedAt: d.UpdatedAt,
|
||||
}
|
||||
|
||||
@@ -95,6 +95,12 @@ func (r *documentResolver) IsUserAuthorized(ctx context.Context, obj *types.Docu
|
||||
|
||||
document, err := trustService.Documents.Get(ctx, trustCenter.OrganizationID, obj.ID)
|
||||
if err != nil {
|
||||
if errors.Is(err, trust.ErrDocumentNotFound) || errors.Is(err, trust.ErrDocumentNotVisible) || errors.Is(err, coredata.ErrResourceNotFound) {
|
||||
return false, gqlutils.NotFoundf(ctx, "document %q not found", obj.ID)
|
||||
}
|
||||
if _, ok := errors.AsType[*trust.ErrDocumentArchived](err); ok {
|
||||
return false, gqlutils.NotFoundf(ctx, "document %q not found", obj.ID)
|
||||
}
|
||||
r.logger.ErrorCtx(ctx, "cannot load document", log.Error(err))
|
||||
return false, gqlutils.Internal(ctx)
|
||||
}
|
||||
@@ -363,6 +369,12 @@ func (r *mutationResolver) ExportDocumentPDF(ctx context.Context, input types.Ex
|
||||
|
||||
document, err := trustService.Documents.Get(ctx, trustCenter.OrganizationID, input.DocumentID)
|
||||
if err != nil {
|
||||
if errors.Is(err, trust.ErrDocumentNotFound) || errors.Is(err, trust.ErrDocumentNotVisible) || errors.Is(err, coredata.ErrResourceNotFound) {
|
||||
return nil, gqlutils.NotFoundf(ctx, "document %q not found", input.DocumentID)
|
||||
}
|
||||
if _, ok := errors.AsType[*trust.ErrDocumentArchived](err); ok {
|
||||
return nil, gqlutils.NotFoundf(ctx, "document %q not found", input.DocumentID)
|
||||
}
|
||||
r.logger.ErrorCtx(ctx, "cannot load document", log.Error(err))
|
||||
return nil, gqlutils.Internal(ctx)
|
||||
}
|
||||
@@ -525,6 +537,12 @@ func (r *mutationResolver) RequestDocumentAccess(ctx context.Context, input type
|
||||
|
||||
document, err := trustService.Documents.Get(ctx, trustCenter.OrganizationID, input.DocumentID)
|
||||
if err != nil {
|
||||
if errors.Is(err, trust.ErrDocumentNotFound) || errors.Is(err, trust.ErrDocumentNotVisible) || errors.Is(err, coredata.ErrResourceNotFound) {
|
||||
return nil, gqlutils.NotFoundf(ctx, "document %q not found", input.DocumentID)
|
||||
}
|
||||
if _, ok := errors.AsType[*trust.ErrDocumentArchived](err); ok {
|
||||
return nil, gqlutils.NotFoundf(ctx, "document %q not found", input.DocumentID)
|
||||
}
|
||||
r.logger.ErrorCtx(ctx, "cannot load document", log.Error(err))
|
||||
return nil, gqlutils.Internal(ctx)
|
||||
}
|
||||
@@ -871,6 +889,9 @@ func (r *queryResolver) Node(ctx context.Context, id gid.GID) (types.Node, error
|
||||
if errors.Is(err, trust.ErrDocumentNotFound) || errors.Is(err, trust.ErrDocumentNotVisible) || errors.Is(err, coredata.ErrResourceNotFound) {
|
||||
return nil, gqlutils.NotFoundf(ctx, "node %q not found", id)
|
||||
}
|
||||
if _, ok := errors.AsType[*trust.ErrDocumentArchived](err); ok {
|
||||
return nil, gqlutils.NotFoundf(ctx, "node %q not found", id)
|
||||
}
|
||||
r.logger.ErrorCtx(ctx, "cannot get document", log.Error(err))
|
||||
return nil, gqlutils.Internal(ctx)
|
||||
}
|
||||
|
||||
@@ -34,8 +34,14 @@ type (
|
||||
svc *TenantService
|
||||
html2pdfConverter *html2pdf.Converter
|
||||
}
|
||||
|
||||
ErrDocumentArchived struct{}
|
||||
)
|
||||
|
||||
func (e ErrDocumentArchived) Error() string {
|
||||
return "cannot access an archived document"
|
||||
}
|
||||
|
||||
func (s *DocumentService) ListForOrganizationId(
|
||||
ctx context.Context,
|
||||
organizationID gid.GID,
|
||||
@@ -103,6 +109,10 @@ func (s DocumentService) Get(
|
||||
return fmt.Errorf("cannot load document: %w", err)
|
||||
}
|
||||
|
||||
if document.ArchivedAt != nil {
|
||||
return &ErrDocumentArchived{}
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
@@ -138,6 +148,10 @@ func (s *DocumentService) exportPDFData(
|
||||
return fmt.Errorf("cannot load document: %w", err)
|
||||
}
|
||||
|
||||
if document.ArchivedAt != nil {
|
||||
return &ErrDocumentArchived{}
|
||||
}
|
||||
|
||||
if document.TrustCenterVisibility == coredata.TrustCenterVisibilityNone {
|
||||
return fmt.Errorf("document not visible on trust center")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user