From 04e3eb81a7e07cccce51a9df25363502ba8d76b5 Mon Sep 17 00:00:00 2001 From: Sacha Al Himdani Date: Tue, 16 Sep 2025 09:51:17 +0200 Subject: [PATCH] Upgrade documents Signed-off-by: Sacha Al Himdani --- apps/console/src/hooks/graph/DocumentGraph.ts | 45 ++ ...raphBulkDeleteDocumentsMutation.graphql.ts | 115 ++++ ...raphBulkExportDocumentsMutation.graphql.ts | 92 ++++ .../documents/DocumentDetailPage.tsx | 14 +- .../organizations/documents/DocumentsPage.tsx | 210 +++++--- .../frameworks/FrameworkDetailPage.tsx | 2 +- packages/ui/src/Atoms/Table/Table.tsx | 5 +- pkg/coredata/document.go | 24 + pkg/coredata/entity_type_reg.go | 2 +- pkg/coredata/export_job.go | 246 +++++++++ pkg/coredata/export_job_status.go | 51 ++ pkg/coredata/export_job_type.go | 45 ++ pkg/coredata/framework_export.go | 172 ------ pkg/coredata/framework_export_order_field.go | 40 -- pkg/coredata/framework_export_status.go | 86 --- pkg/coredata/migrations/20250916T121639Z.sql | 34 ++ pkg/probo/document_service.go | 329 +++++++++++ pkg/probo/framework_service.go | 130 ++++- pkg/probo/service.go | 184 ++----- pkg/probod/probod.go | 16 +- pkg/server/api/console/v1/schema.graphql | 22 + pkg/server/api/console/v1/schema/schema.go | 510 ++++++++++++++++++ pkg/server/api/console/v1/types/types.go | 16 + pkg/server/api/console/v1/v1_resolver.go | 43 ++ 24 files changed, 1910 insertions(+), 523 deletions(-) create mode 100644 apps/console/src/hooks/graph/__generated__/DocumentGraphBulkDeleteDocumentsMutation.graphql.ts create mode 100644 apps/console/src/hooks/graph/__generated__/DocumentGraphBulkExportDocumentsMutation.graphql.ts create mode 100644 pkg/coredata/export_job.go create mode 100644 pkg/coredata/export_job_status.go create mode 100644 pkg/coredata/export_job_type.go delete mode 100644 pkg/coredata/framework_export.go delete mode 100644 pkg/coredata/framework_export_order_field.go delete mode 100644 pkg/coredata/framework_export_status.go create mode 100644 pkg/coredata/migrations/20250916T121639Z.sql diff --git a/apps/console/src/hooks/graph/DocumentGraph.ts b/apps/console/src/hooks/graph/DocumentGraph.ts index 03f85c222..19953adcb 100644 --- a/apps/console/src/hooks/graph/DocumentGraph.ts +++ b/apps/console/src/hooks/graph/DocumentGraph.ts @@ -4,6 +4,7 @@ import { useMutationWithToasts } from "../useMutationWithToasts"; import type { DocumentGraphDeleteMutation } from "./__generated__/DocumentGraphDeleteMutation.graphql"; import type { DocumentGraphSendSigningNotificationsMutation } from "./__generated__/DocumentGraphSendSigningNotificationsMutation.graphql"; import type { DocumentGraphDeleteDraftMutation } from "./__generated__/DocumentGraphDeleteDraftMutation.graphql"; +import type { DocumentGraphBulkExportDocumentsMutation } from "./__generated__/DocumentGraphBulkExportDocumentsMutation.graphql"; export const documentsQuery = graphql` query DocumentGraphListQuery($organizationId: ID!) { @@ -61,6 +62,28 @@ export function useDeleteDraftDocumentVersionMutation() { ); } +const bulkDeleteDocumentsMutation = graphql` + mutation DocumentGraphBulkDeleteDocumentsMutation( + $input: BulkDeleteDocumentsInput! + ) { + bulkDeleteDocuments(input: $input) { + deletedDocumentIds @deleteRecord + } + } +`; + +export function useBulkDeleteDocumentsMutation() { + const { __ } = useTranslate(); + + return useMutationWithToasts( + bulkDeleteDocumentsMutation, + { + successMessage: __("Documents deleted successfully."), + errorMessage: __("Failed to delete documents. Please try again."), + } + ); +} + const sendSigningNotificationsMutation = graphql` mutation DocumentGraphSendSigningNotificationsMutation( $input: SendSigningNotificationsInput! @@ -83,6 +106,28 @@ export function useSendSigningNotificationsMutation() { ); } +const bulkExportDocumentsMutation = graphql` + mutation DocumentGraphBulkExportDocumentsMutation( + $input: BulkExportDocumentsInput! + ) { + bulkExportDocuments(input: $input) { + exportJobId + } + } +`; + +export function useBulkExportDocumentsMutation() { + const { __ } = useTranslate(); + + return useMutationWithToasts( + bulkExportDocumentsMutation, + { + successMessage: __("Document export started successfully. You will receive an email when the export is ready."), + errorMessage: __("Failed to start document export. Please try again."), + } + ); +} + export const documentNodeQuery = graphql` query DocumentGraphNodeQuery($documentId: ID!) { node(id: $documentId) { diff --git a/apps/console/src/hooks/graph/__generated__/DocumentGraphBulkDeleteDocumentsMutation.graphql.ts b/apps/console/src/hooks/graph/__generated__/DocumentGraphBulkDeleteDocumentsMutation.graphql.ts new file mode 100644 index 000000000..d3e975227 --- /dev/null +++ b/apps/console/src/hooks/graph/__generated__/DocumentGraphBulkDeleteDocumentsMutation.graphql.ts @@ -0,0 +1,115 @@ +/** + * @generated SignedSource<> + * @lightSyntaxTransform + * @nogrep + */ + +/* tslint:disable */ +/* eslint-disable */ +// @ts-nocheck + +import { ConcreteRequest } from 'relay-runtime'; +export type BulkDeleteDocumentsInput = { + documentIds: ReadonlyArray; +}; +export type DocumentGraphBulkDeleteDocumentsMutation$variables = { + input: BulkDeleteDocumentsInput; +}; +export type DocumentGraphBulkDeleteDocumentsMutation$data = { + readonly bulkDeleteDocuments: { + readonly deletedDocumentIds: ReadonlyArray; + }; +}; +export type DocumentGraphBulkDeleteDocumentsMutation = { + response: DocumentGraphBulkDeleteDocumentsMutation$data; + variables: DocumentGraphBulkDeleteDocumentsMutation$variables; +}; + +const node: ConcreteRequest = (function(){ +var v0 = [ + { + "defaultValue": null, + "kind": "LocalArgument", + "name": "input" + } +], +v1 = [ + { + "kind": "Variable", + "name": "input", + "variableName": "input" + } +], +v2 = { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "deletedDocumentIds", + "storageKey": null +}; +return { + "fragment": { + "argumentDefinitions": (v0/*: any*/), + "kind": "Fragment", + "metadata": null, + "name": "DocumentGraphBulkDeleteDocumentsMutation", + "selections": [ + { + "alias": null, + "args": (v1/*: any*/), + "concreteType": "BulkDeleteDocumentsPayload", + "kind": "LinkedField", + "name": "bulkDeleteDocuments", + "plural": false, + "selections": [ + (v2/*: any*/) + ], + "storageKey": null + } + ], + "type": "Mutation", + "abstractKey": null + }, + "kind": "Request", + "operation": { + "argumentDefinitions": (v0/*: any*/), + "kind": "Operation", + "name": "DocumentGraphBulkDeleteDocumentsMutation", + "selections": [ + { + "alias": null, + "args": (v1/*: any*/), + "concreteType": "BulkDeleteDocumentsPayload", + "kind": "LinkedField", + "name": "bulkDeleteDocuments", + "plural": false, + "selections": [ + (v2/*: any*/), + { + "alias": null, + "args": null, + "filters": null, + "handle": "deleteRecord", + "key": "", + "kind": "ScalarHandle", + "name": "deletedDocumentIds" + } + ], + "storageKey": null + } + ] + }, + "params": { + "cacheID": "655f43d282b8e0007f3b93e381e7ff84", + "id": null, + "metadata": {}, + "name": "DocumentGraphBulkDeleteDocumentsMutation", + "operationKind": "mutation", + "text": "mutation DocumentGraphBulkDeleteDocumentsMutation(\n $input: BulkDeleteDocumentsInput!\n) {\n bulkDeleteDocuments(input: $input) {\n deletedDocumentIds\n }\n}\n" + } +}; +})(); + +(node as any).hash = "919857a24f366b8ebe004fcadf503c3d"; + +export default node; diff --git a/apps/console/src/hooks/graph/__generated__/DocumentGraphBulkExportDocumentsMutation.graphql.ts b/apps/console/src/hooks/graph/__generated__/DocumentGraphBulkExportDocumentsMutation.graphql.ts new file mode 100644 index 000000000..fdd52f8a0 --- /dev/null +++ b/apps/console/src/hooks/graph/__generated__/DocumentGraphBulkExportDocumentsMutation.graphql.ts @@ -0,0 +1,92 @@ +/** + * @generated SignedSource<> + * @lightSyntaxTransform + * @nogrep + */ + +/* tslint:disable */ +/* eslint-disable */ +// @ts-nocheck + +import { ConcreteRequest } from 'relay-runtime'; +export type BulkExportDocumentsInput = { + documentIds: ReadonlyArray; +}; +export type DocumentGraphBulkExportDocumentsMutation$variables = { + input: BulkExportDocumentsInput; +}; +export type DocumentGraphBulkExportDocumentsMutation$data = { + readonly bulkExportDocuments: { + readonly exportJobId: string; + }; +}; +export type DocumentGraphBulkExportDocumentsMutation = { + response: DocumentGraphBulkExportDocumentsMutation$data; + variables: DocumentGraphBulkExportDocumentsMutation$variables; +}; + +const node: ConcreteRequest = (function(){ +var v0 = [ + { + "defaultValue": null, + "kind": "LocalArgument", + "name": "input" + } +], +v1 = [ + { + "alias": null, + "args": [ + { + "kind": "Variable", + "name": "input", + "variableName": "input" + } + ], + "concreteType": "BulkExportDocumentsPayload", + "kind": "LinkedField", + "name": "bulkExportDocuments", + "plural": false, + "selections": [ + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "exportJobId", + "storageKey": null + } + ], + "storageKey": null + } +]; +return { + "fragment": { + "argumentDefinitions": (v0/*: any*/), + "kind": "Fragment", + "metadata": null, + "name": "DocumentGraphBulkExportDocumentsMutation", + "selections": (v1/*: any*/), + "type": "Mutation", + "abstractKey": null + }, + "kind": "Request", + "operation": { + "argumentDefinitions": (v0/*: any*/), + "kind": "Operation", + "name": "DocumentGraphBulkExportDocumentsMutation", + "selections": (v1/*: any*/) + }, + "params": { + "cacheID": "2f4eb206613f8fba307aeee5bad27b93", + "id": null, + "metadata": {}, + "name": "DocumentGraphBulkExportDocumentsMutation", + "operationKind": "mutation", + "text": "mutation DocumentGraphBulkExportDocumentsMutation(\n $input: BulkExportDocumentsInput!\n) {\n bulkExportDocuments(input: $input) {\n exportJobId\n }\n}\n" + } +}; +})(); + +(node as any).hash = "0a7bae4b58713bcc824a774c31bd6dca"; + +export default node; diff --git a/apps/console/src/pages/organizations/documents/DocumentDetailPage.tsx b/apps/console/src/pages/organizations/documents/DocumentDetailPage.tsx index 6a20f8ff6..ce63f07ce 100644 --- a/apps/console/src/pages/organizations/documents/DocumentDetailPage.tsx +++ b/apps/console/src/pages/organizations/documents/DocumentDetailPage.tsx @@ -524,12 +524,14 @@ export default function DocumentDetailPage(props: Props) { {__("Controls")} {controlsCount} - - {__("Signatures")} - - {signedSignatures.length}/{signatures.length} - - + {!isDraft && ( + + {__("Signatures")} + + {signedSignatures.length}/{signatures.length} + + + )} diff --git a/apps/console/src/pages/organizations/documents/DocumentsPage.tsx b/apps/console/src/pages/organizations/documents/DocumentsPage.tsx index 768d93c7b..0f0056457 100644 --- a/apps/console/src/pages/organizations/documents/DocumentsPage.tsx +++ b/apps/console/src/pages/organizations/documents/DocumentsPage.tsx @@ -19,6 +19,8 @@ import { IconCrossLargeX, IconSignature, IconCheckmark1, + IconArrowDown, + Card, } from "@probo/ui"; import { useFragment, @@ -32,6 +34,8 @@ import { documentsQuery, useDeleteDocumentMutation, useSendSigningNotificationsMutation, + useBulkDeleteDocumentsMutation, + useBulkExportDocumentsMutation, } from "/hooks/graph/DocumentGraph"; import type { DocumentsPageListFragment$key } from "./__generated__/DocumentsPageListFragment.graphql"; import { useList, usePageTitle } from "@probo/hooks"; @@ -94,7 +98,10 @@ export default function DocumentsPage(props: Props) { .filter(Boolean); const connectionId = pagination.data.documents.__id; const [sendSigningNotifications] = useSendSigningNotificationsMutation(); + const [bulkDeleteDocuments] = useBulkDeleteDocumentsMutation(); + const [bulkExportDocuments] = useBulkExportDocumentsMutation(); const { list: selection, toggle, clear, reset } = useList([]); + const confirm = useConfirm(); usePageTitle(__("Documents")); @@ -106,6 +113,39 @@ export default function DocumentsPage(props: Props) { }); }; + const handleBulkDelete = () => { + const documentCount = selection.length; + confirm( + () => + bulkDeleteDocuments({ + variables: { + input: { documentIds: selection }, + }, + }).then(() => { + clear(); + }), + { + message: sprintf( + __( + 'This will permanently delete %s document%s. This action cannot be undone.' + ), + documentCount, + documentCount > 1 ? 's' : '' + ), + } + ); + }; + + const handleBulkExport = () => { + bulkExportDocuments({ + variables: { + input: { documentIds: selection }, + }, + }).then(() => { + clear(); + }); + }; + return (
- - - {selection.length === 0 ? ( - - - 0} - onChange={() => reset(documents.map((d) => d.id))} - /> - - {__("Name")} - {__("Status")} - {__("Type")} - {__("Owner")} - {__("Last update")} - {__("Signatures")} - - - ) : ( - - -
-
- {sprintf(__("%s documents selected"), selection.length)} - - -
-
- - - + {documents.length > 0 ? ( + + + {selection.length === 0 ? ( + + + 0} + onChange={() => reset(documents.map((d) => d.id))} + /> + + {__("Name")} + {__("Status")} + {__("Type")} + {__("Owner")} + {__("Last update")} + {__("Signatures")} + + + ) : ( + + +
+
+ {sprintf(__("%s documents selected"), selection.length)} - + +
+
+ + + - + + +
-
- - - )} - - - {documents.map((document) => ( - toggle(document.id)} - key={document.id} - document={document} - organizationId={organization.id} - connectionId={connectionId} - /> - ))} - - + + + )} + + + {documents.map((document) => ( + toggle(document.id)} + key={document.id} + document={document} + organizationId={organization.id} + connectionId={connectionId} + /> + ))} + + + ) : ( + +
+

+ {__("No documents yet")} +

+

+ {__("Create your first document to get started.")} +

+
+
+ )}
); } @@ -278,34 +349,27 @@ function DocumentRow({ return ( - + - +
- {document.title}
- + {isDraft ? __("Draft") : __("Published")} - {getDocumentTypeLabel(__, document.documentType)} - + {getDocumentTypeLabel(__, document.documentType)} +
{document.owner?.fullName}
- + {dateFormat(document.updatedAt, { year: "numeric", month: "short", @@ -313,10 +377,10 @@ function DocumentRow({ weekday: "short", })} - + {signedCount}/{signatures.length} - + ) { return ( . -// -// 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 ( - "context" - "errors" - "fmt" - "maps" - "time" - - "github.com/getprobo/probo/pkg/gid" - "github.com/getprobo/probo/pkg/page" - "github.com/jackc/pgx/v5" - "go.gearno.de/kit/pg" -) - -type ( - FrameworkExport struct { - ID gid.GID `db:"id"` - FrameworkID gid.GID `db:"framework_id"` - RecipientEmail string `db:"recipient_email"` - RecipientName string `db:"recipient_name"` - Status FrameworkExportStatus `db:"status"` - FileID *gid.GID `db:"file_id"` - CreatedAt time.Time `db:"created_at"` - StartedAt *time.Time `db:"started_at"` - CompletedAt *time.Time `db:"completed_at"` - } - - FrameworkExports []*FrameworkExport -) - -var ( - ErrNoFrameworkExportAvailable = errors.New("no framework export available") -) - -func (fe FrameworkExport) CursorKey(orderBy FrameworkExportOrderField) page.CursorKey { - switch orderBy { - case FrameworkExportOrderFieldCreatedAt: - return page.NewCursorKey(fe.ID, fe.CreatedAt) - } - - panic(fmt.Sprintf("unsupported order by: %s", orderBy)) -} - -func (fe *FrameworkExport) Insert( - ctx context.Context, - conn pg.Conn, - scope Scoper, -) error { - q := ` -INSERT INTO framework_exports ( - id, - tenant_id, - framework_id, - recipient_email, - recipient_name, - status, - created_at -) VALUES ( - @id, - @tenant_id, - @framework_id, - @recipient_email, - @recipient_name, - @status, - @created_at -)` - - args := pgx.StrictNamedArgs{ - "id": fe.ID, - "tenant_id": scope.GetTenantID(), - "framework_id": fe.FrameworkID, - "recipient_email": fe.RecipientEmail, - "recipient_name": fe.RecipientName, - "status": fe.Status, - "created_at": fe.CreatedAt, - } - - _, err := conn.Exec(ctx, q, args) - return err -} - -func (fe *FrameworkExport) Update( - ctx context.Context, - conn pg.Conn, - scope Scoper, -) error { - q := ` -UPDATE - framework_exports -SET - status = @status, - file_id = @file_id, - started_at = @started_at, - completed_at = @completed_at -WHERE - %s - AND id = @id -` - - q = fmt.Sprintf(q, scope.SQLFragment()) - - args := pgx.StrictNamedArgs{ - "status": fe.Status, - "file_id": fe.FileID, - "started_at": fe.StartedAt, - "completed_at": fe.CompletedAt, - "id": fe.ID, - } - maps.Copy(args, scope.SQLArguments()) - - _, err := conn.Exec(ctx, q, args) - return err -} - -func (fe *FrameworkExport) LoadNextPendingForUpdateSkipLocked( - ctx context.Context, - conn pg.Conn, -) error { - q := ` -SELECT - id, - framework_id, - recipient_email, - recipient_name, - status, - file_id, - created_at, - started_at, - completed_at -FROM - framework_exports -WHERE - status = @status -ORDER BY - created_at ASC -LIMIT 1 -FOR UPDATE SKIP LOCKED -` - - args := pgx.StrictNamedArgs{"status": FrameworkExportStatusPending} - - rows, err := conn.Query(ctx, q, args) - if err != nil { - return err - } - - fe2, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[FrameworkExport]) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return ErrNoFrameworkExportAvailable - } - return fmt.Errorf("cannot collect framework export: %w", err) - } - - *fe = fe2 - return nil -} diff --git a/pkg/coredata/framework_export_order_field.go b/pkg/coredata/framework_export_order_field.go deleted file mode 100644 index be02d1934..000000000 --- a/pkg/coredata/framework_export_order_field.go +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright (c) 2025 Probo Inc . -// -// 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 - -type ( - FrameworkExportOrderField string -) - -const ( - FrameworkExportOrderFieldCreatedAt FrameworkExportOrderField = "CREATED_AT" -) - -func (p FrameworkExportOrderField) Column() string { - return string(p) -} - -func (p FrameworkExportOrderField) String() string { - return string(p) -} - -func (p FrameworkExportOrderField) MarshalText() ([]byte, error) { - return []byte(p.String()), nil -} - -func (p *FrameworkExportOrderField) UnmarshalText(text []byte) error { - *p = FrameworkExportOrderField(text) - return nil -} diff --git a/pkg/coredata/framework_export_status.go b/pkg/coredata/framework_export_status.go deleted file mode 100644 index fa717c55c..000000000 --- a/pkg/coredata/framework_export_status.go +++ /dev/null @@ -1,86 +0,0 @@ -// Copyright (c) 2025 Probo Inc . -// -// 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 ( - FrameworkExportStatus string -) - -const ( - FrameworkExportStatusPending FrameworkExportStatus = "pending" - FrameworkExportStatusProcessing FrameworkExportStatus = "processing" - FrameworkExportStatusCompleted FrameworkExportStatus = "completed" - FrameworkExportStatusFailed FrameworkExportStatus = "failed" -) - -func (pvs FrameworkExportStatus) MarshalText() ([]byte, error) { - return []byte(pvs.String()), nil -} - -func (pvs *FrameworkExportStatus) UnmarshalText(data []byte) error { - val := string(data) - - switch val { - case FrameworkExportStatusPending.String(): - *pvs = FrameworkExportStatusPending - case FrameworkExportStatusProcessing.String(): - *pvs = FrameworkExportStatusProcessing - case FrameworkExportStatusCompleted.String(): - *pvs = FrameworkExportStatusCompleted - case FrameworkExportStatusFailed.String(): - *pvs = FrameworkExportStatusFailed - default: - return fmt.Errorf("invalid FrameworkExportStatus value: %q", val) - } - - return nil -} - -func (pvs FrameworkExportStatus) String() string { - var val string - - switch pvs { - case FrameworkExportStatusPending: - val = "pending" - case FrameworkExportStatusProcessing: - val = "processing" - case FrameworkExportStatusCompleted: - val = "completed" - case FrameworkExportStatusFailed: - val = "failed" - default: - panic(fmt.Errorf("invalid FrameworkExportStatus value: %q", string(pvs))) - } - - return val -} - -func (pvs *FrameworkExportStatus) Scan(value any) error { - val, ok := value.(string) - if !ok { - return fmt.Errorf("invalid scan source for FrameworkExportStatus, expected string got %T", value) - } - - return pvs.UnmarshalText([]byte(val)) -} - -func (pvs FrameworkExportStatus) Value() (driver.Value, error) { - return pvs.String(), nil -} diff --git a/pkg/coredata/migrations/20250916T121639Z.sql b/pkg/coredata/migrations/20250916T121639Z.sql new file mode 100644 index 000000000..af589c237 --- /dev/null +++ b/pkg/coredata/migrations/20250916T121639Z.sql @@ -0,0 +1,34 @@ +CREATE TYPE export_jobs_status AS ENUM ( + 'PENDING', + 'PROCESSING', + 'COMPLETED', + 'FAILED' +); + +CREATE TYPE export_jobs_type AS ENUM ( + 'FRAMEWORK', + 'DOCUMENT' +); + +CREATE TABLE export_jobs ( + id TEXT PRIMARY KEY, + tenant_id TEXT NOT NULL, + type export_jobs_type NOT NULL, + arguments JSONB NOT NULL, + error TEXT, + status export_jobs_status NOT NULL, + file_id TEXT, + recipient_email TEXT NOT NULL, + recipient_name TEXT NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL, + started_at TIMESTAMP WITH TIME ZONE, + completed_at TIMESTAMP WITH TIME ZONE +); + +ALTER TABLE export_jobs ADD CONSTRAINT export_jobs_file_id_fkey + FOREIGN KEY (file_id) + REFERENCES files(id) + ON UPDATE CASCADE + ON DELETE SET NULL; + +DROP TABLE framework_exports; diff --git a/pkg/probo/document_service.go b/pkg/probo/document_service.go index 351ec9e90..25bd8ea80 100644 --- a/pkg/probo/document_service.go +++ b/pkg/probo/document_service.go @@ -1,12 +1,19 @@ package probo import ( + "archive/zip" "context" + "encoding/json" "fmt" "io" "net/url" + "os" + "regexp" + "strings" "time" + "unicode" + "github.com/aws/aws-sdk-go-v2/service/s3" "github.com/getprobo/probo/pkg/coredata" "github.com/getprobo/probo/pkg/docgen" "github.com/getprobo/probo/pkg/gid" @@ -14,7 +21,9 @@ import ( "github.com/getprobo/probo/pkg/page" "github.com/getprobo/probo/pkg/statelesstoken" "github.com/jackc/pgx/v5" + "go.gearno.de/crypto/uuid" "go.gearno.de/kit/pg" + "go.gearno.de/x/ref" ) type ( @@ -68,6 +77,22 @@ type ( const ( TokenTypeSigningRequest = "signing_request" + + documentExportEmailExpiresIn = 24 * time.Hour + documentExportEmailSubject = "Your document export is ready" + documentExportEmailBody = ` +Your document export has been completed successfully. + +You can download the export using the link below: +[1] %s + +This link will expire in 24 hours.` + + maxFilenameLength = 200 +) + +var ( + invalidFilenameChars = regexp.MustCompile(`[<>:"/\\|?*\x00-\x1f\x7f]`) ) func (e ErrSignatureNotCancellable) Error() string { @@ -814,6 +839,76 @@ func (s *DocumentService) SoftDelete( ) } +func (s *DocumentService) BulkSoftDelete( + 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.BulkSoftDelete(ctx, conn, s.svc.scope) + }, + ) +} + +func (s *DocumentService) RequestExport( + ctx context.Context, + documentIDs []gid.GID, + recipientEmail string, + recipientName string, +) (*coredata.ExportJob, error) { + var exportJobID gid.GID + exportJob := &coredata.ExportJob{} + + err := s.svc.pg.WithTx(ctx, func(conn pg.Conn) error { + for _, documentID := range documentIDs { + document := &coredata.Document{} + if err := document.LoadByID(ctx, conn, s.svc.scope, documentID); err != nil { + return fmt.Errorf("cannot load document %q: %w", documentID, err) + } + } + + now := time.Now() + exportJobID = gid.New(s.svc.scope.GetTenantID(), coredata.ExportJobEntityType) + + args := coredata.DocumentExportArguments{ + DocumentIDs: documentIDs, + } + argsJSON, err := json.Marshal(args) + if err != nil { + return fmt.Errorf("cannot marshal document export arguments: %w", err) + } + + exportJob = &coredata.ExportJob{ + ID: exportJobID, + Type: coredata.ExportJobTypeDocument, + Arguments: argsJSON, + Status: coredata.ExportJobStatusPending, + RecipientEmail: recipientEmail, + RecipientName: recipientName, + CreatedAt: now, + } + + if err := exportJob.Insert(ctx, conn, s.svc.scope); err != nil { + return fmt.Errorf("cannot insert export job: %w", err) + } + + return nil + }) + + if err != nil { + return nil, err + } + + return exportJob, nil +} + func (s *DocumentService) ListVersions( ctx context.Context, documentID gid.GID, @@ -1118,6 +1213,97 @@ func (s *DocumentService) ExportPDF( return data, nil } +func (s *DocumentService) BuildAndUploadExport(ctx context.Context, exportJobID gid.GID) (*coredata.ExportJob, error) { + exportJob := &coredata.ExportJob{} + err := s.svc.pg.WithTx( + ctx, + func(tx pg.Conn) error { + if err := exportJob.LoadByID(ctx, tx, s.svc.scope, exportJobID); err != nil { + return fmt.Errorf("cannot load export job: %w", err) + } + + documentIDs, err := exportJob.GetDocumentIDs() + if err != nil { + return fmt.Errorf("cannot get document IDs: %w", err) + } + + tempDir := os.TempDir() + tempFile, err := os.CreateTemp(tempDir, "probo-document-export-*.zip") + if err != nil { + return fmt.Errorf("cannot create temp file: %w", err) + } + defer tempFile.Close() + defer os.Remove(tempFile.Name()) + + err = s.Export(ctx, documentIDs, tempFile) + if err != nil { + return fmt.Errorf("cannot export documents: %w", err) + } + + uuid, err := uuid.NewV4() + if err != nil { + return fmt.Errorf("cannot generate uuid: %w", err) + } + + if _, err := tempFile.Seek(0, 0); err != nil { + return fmt.Errorf("cannot seek temp file: %w", err) + } + + fileInfo, err := tempFile.Stat() + if err != nil { + return fmt.Errorf("cannot stat temp file: %w", err) + } + + _, err = s.svc.s3.PutObject( + ctx, + &s3.PutObjectInput{ + Bucket: ref.Ref(s.svc.bucket), + Key: ref.Ref(uuid.String()), + Body: tempFile, + ContentLength: ref.Ref(fileInfo.Size()), + ContentType: ref.Ref("application/zip"), + Metadata: map[string]string{ + "type": "document-export", + "export-job-id": exportJob.ID.String(), + }, + }, + ) + if err != nil { + return fmt.Errorf("cannot upload file to S3: %w", err) + } + + now := time.Now() + + file := coredata.File{ + ID: gid.New(exportJob.ID.TenantID(), coredata.FileEntityType), + BucketName: s.svc.bucket, + MimeType: "application/zip", + FileName: fmt.Sprintf("Documents Export %s.zip", now.Format("2006-01-02")), + FileKey: uuid.String(), + FileSize: int(fileInfo.Size()), + CreatedAt: now, + UpdatedAt: now, + } + + if err := file.Insert(ctx, tx, s.svc.scope); err != nil { + return fmt.Errorf("cannot insert file: %w", err) + } + + exportJob.FileID = &file.ID + if err := exportJob.Update(ctx, tx, s.svc.scope); err != nil { + return fmt.Errorf("cannot update export job: %w", err) + } + + return nil + }, + ) + if err != nil { + return nil, err + } + + return exportJob, nil +} + func exportDocumentPDF( ctx context.Context, html2pdfConverter *html2pdf.Converter, @@ -1240,3 +1426,146 @@ func exportDocumentPDF( } return pdfData, nil } + +func (s *DocumentService) Export( + ctx context.Context, + documentIDs []gid.GID, + file io.Writer, +) (err error) { + archive := zip.NewWriter(file) + defer func() { + if closeErr := archive.Close(); closeErr != nil && err == nil { + err = fmt.Errorf("cannot close archive: %w", closeErr) + } + }() + + return s.svc.pg.WithTx( + ctx, + func(conn pg.Conn) error { + for i, documentID := range documentIDs { + document := &coredata.Document{} + if err := document.LoadByID(ctx, conn, s.svc.scope, documentID); err != nil { + return fmt.Errorf("cannot load document %q: %w", documentID, err) + } + + documentVersion := &coredata.DocumentVersion{} + if err := documentVersion.LoadLatestVersion(ctx, conn, s.svc.scope, documentID); err != nil { + return fmt.Errorf("cannot load document version for %q: %w", documentID, err) + } + + exportedPDF, err := exportDocumentPDF( + ctx, + s.html2pdfConverter, + conn, + s.svc.scope, + documentVersion.ID, + ) + if err != nil { + return fmt.Errorf("cannot export document PDF for %q: %w", documentID, err) + } + + filename := fmt.Sprintf("%d_%s.pdf", i+1, sanitizeFilename(document.Title)) + w, err := archive.Create(filename) + if err != nil { + return fmt.Errorf("cannot create document in archive: %w", err) + } + + _, err = w.Write(exportedPDF) + if err != nil { + return fmt.Errorf("cannot write document to archive: %w", err) + } + } + + return nil + }, + ) +} + +func (s *DocumentService) SendExportEmail( + ctx context.Context, + fileID gid.GID, + recipientName string, + recipientEmail string, +) error { + return s.svc.pg.WithTx( + ctx, + func(tx pg.Conn) error { + file := &coredata.File{} + if err := file.LoadByID(ctx, tx, s.svc.scope, fileID); err != nil { + return fmt.Errorf("cannot load file: %w", err) + } + + downloadURL, err := s.GenerateDocumentExportDownloadURL(ctx, file) + if err != nil { + return fmt.Errorf("cannot generate download URL: %w", err) + } + + email := coredata.NewEmail( + recipientName, + recipientEmail, + documentExportEmailSubject, + fmt.Sprintf(documentExportEmailBody, downloadURL), + ) + + if err := email.Insert(ctx, tx); err != nil { + return fmt.Errorf("cannot insert email: %w", err) + } + + return nil + }, + ) +} + +func (s *DocumentService) GenerateDocumentExportDownloadURL( + ctx context.Context, + file *coredata.File, +) (string, error) { + presignClient := s3.NewPresignClient(s.svc.s3) + + presignedReq, err := presignClient.PresignGetObject( + ctx, + &s3.GetObjectInput{ + Bucket: ref.Ref(s.svc.bucket), + Key: ref.Ref(file.FileKey), + ResponseCacheControl: ref.Ref("max-age=3600, public"), + ResponseContentType: ref.Ref(file.MimeType), + ResponseContentDisposition: ref.Ref(fmt.Sprintf("attachment; filename=\"%s\"", file.FileName)), + }, + func(opts *s3.PresignOptions) { + opts.Expires = documentExportEmailExpiresIn + }, + ) + + if err != nil { + return "", fmt.Errorf("cannot presign GetObject request: %w", err) + } + + return presignedReq.URL, nil +} + +func sanitizeFilename(title string) string { + if title == "" { + return "Untitled" + } + + sanitized := invalidFilenameChars.ReplaceAllString(title, "_") + + sanitized = strings.TrimFunc(sanitized, func(r rune) bool { + return unicode.IsSpace(r) || r == '.' + }) + + sanitized = regexp.MustCompile(`[\s_]+`).ReplaceAllString(sanitized, "_") + + if sanitized == "" || sanitized == "_" { + sanitized = "Untitled" + } + + if len(sanitized) > maxFilenameLength-20 { + sanitized = sanitized[:maxFilenameLength-20] + sanitized = strings.TrimFunc(sanitized, func(r rune) bool { + return r == unicode.ReplacementChar + }) + } + + return sanitized +} diff --git a/pkg/probo/framework_service.go b/pkg/probo/framework_service.go index 0c284c339..fcd3abecb 100644 --- a/pkg/probo/framework_service.go +++ b/pkg/probo/framework_service.go @@ -17,8 +17,10 @@ package probo import ( "archive/zip" "context" + "encoding/json" "fmt" "io" + "os" "time" "github.com/aws/aws-sdk-go-v2/aws" @@ -29,6 +31,7 @@ import ( "github.com/getprobo/probo/pkg/page" "github.com/getprobo/probo/pkg/slug" "github.com/getprobo/probo/pkg/soagen" + "go.gearno.de/crypto/uuid" "go.gearno.de/kit/pg" "go.gearno.de/x/ref" ) @@ -82,8 +85,9 @@ func (s FrameworkService) RequestExport( frameworkID gid.GID, recipientEmail string, recipientName string, -) (error, *coredata.FrameworkExport) { - frameworkExport := &coredata.FrameworkExport{} +) (error, *coredata.ExportJob) { + var exportJobID gid.GID + exportJob := &coredata.ExportJob{} err := s.svc.pg.WithTx(ctx, func(conn pg.Conn) error { framework := &coredata.Framework{} @@ -92,18 +96,28 @@ func (s FrameworkService) RequestExport( } now := time.Now() + exportJobID = gid.New(s.svc.scope.GetTenantID(), coredata.ExportJobEntityType) - frameworkExport = &coredata.FrameworkExport{ - ID: gid.New(framework.ID.TenantID(), coredata.FrameworkExportEntityType), - FrameworkID: frameworkID, - Status: coredata.FrameworkExportStatusPending, + args := coredata.FrameworkExportArguments{ + FrameworkID: frameworkID, + } + argsJSON, err := json.Marshal(args) + if err != nil { + return fmt.Errorf("cannot marshal framework export arguments: %w", err) + } + + exportJob = &coredata.ExportJob{ + ID: exportJobID, + Type: coredata.ExportJobTypeFramework, + Arguments: argsJSON, + Status: coredata.ExportJobStatusPending, RecipientEmail: recipientEmail, RecipientName: recipientName, CreatedAt: now, } - if err := frameworkExport.Insert(ctx, conn, s.svc.scope); err != nil { - return fmt.Errorf("cannot insert framework export: %w", err) + if err := exportJob.Insert(ctx, conn, s.svc.scope); err != nil { + return fmt.Errorf("cannot insert export job: %w", err) } return nil @@ -113,7 +127,7 @@ func (s FrameworkService) RequestExport( return err, nil } - return nil, frameworkExport + return nil, exportJob } func (s FrameworkService) Export( @@ -675,7 +689,7 @@ func (s FrameworkService) StateOfApplicability(ctx context.Context, frameworkID return output, nil } -func (s FrameworkService) SendFrameworkExportEmail( +func (s FrameworkService) SendExportEmail( ctx context.Context, fileID gid.GID, recipientName string, @@ -736,3 +750,99 @@ func (s FrameworkService) GenerateFrameworkExportDownloadURL( return presignedReq.URL, nil } + +func (s *FrameworkService) BuildAndUploadExport(ctx context.Context, exportJobID gid.GID) (*coredata.ExportJob, error) { + exportJob := &coredata.ExportJob{} + err := s.svc.pg.WithTx( + ctx, + func(tx pg.Conn) error { + if err := exportJob.LoadByID(ctx, tx, s.svc.scope, exportJobID); err != nil { + return fmt.Errorf("cannot load export job: %w", err) + } + + frameworkID, err := exportJob.GetFrameworkID() + if err != nil { + return fmt.Errorf("cannot get framework ID: %w", err) + } + + framework := &coredata.Framework{} + if err := framework.LoadByID(ctx, tx, s.svc.scope, frameworkID); err != nil { + return fmt.Errorf("cannot load framework: %w", err) + } + + tempDir := os.TempDir() + tempFile, err := os.CreateTemp(tempDir, "probo-framework-export-*.zip") + if err != nil { + return fmt.Errorf("cannot create temp file: %w", err) + } + defer tempFile.Close() + defer os.Remove(tempFile.Name()) + + err = s.Export(ctx, frameworkID, tempFile) + if err != nil { + return fmt.Errorf("cannot export framework: %w", err) + } + + uuid, err := uuid.NewV4() + if err != nil { + return fmt.Errorf("cannot generate uuid: %w", err) + } + + if _, err := tempFile.Seek(0, 0); err != nil { + return fmt.Errorf("cannot seek temp file: %w", err) + } + + fileInfo, err := tempFile.Stat() + if err != nil { + return fmt.Errorf("cannot stat temp file: %w", err) + } + + _, err = s.svc.s3.PutObject( + ctx, + &s3.PutObjectInput{ + Bucket: ref.Ref(s.svc.bucket), + Key: ref.Ref(uuid.String()), + Body: tempFile, + ContentLength: ref.Ref(fileInfo.Size()), + ContentType: ref.Ref("application/zip"), + Metadata: map[string]string{ + "type": "framework-export", + "export-job-id": exportJob.ID.String(), + }, + }, + ) + if err != nil { + return fmt.Errorf("cannot upload file to S3: %w", err) + } + + now := time.Now() + + file := coredata.File{ + ID: gid.New(exportJob.ID.TenantID(), coredata.FileEntityType), + BucketName: s.svc.bucket, + MimeType: "application/zip", + FileName: fmt.Sprintf("Framework Export %s.zip", now.Format("2006-01-02")), + FileKey: uuid.String(), + FileSize: int(fileInfo.Size()), + CreatedAt: now, + UpdatedAt: now, + } + + if err := file.Insert(ctx, tx, s.svc.scope); err != nil { + return fmt.Errorf("cannot insert file: %w", err) + } + + exportJob.FileID = &file.ID + if err := exportJob.Update(ctx, tx, s.svc.scope); err != nil { + return fmt.Errorf("cannot update export job: %w", err) + } + + return nil + }, + ) + if err != nil { + return nil, err + } + + return exportJob, nil +} diff --git a/pkg/probo/service.go b/pkg/probo/service.go index 26b86f282..4a22bc935 100644 --- a/pkg/probo/service.go +++ b/pkg/probo/service.go @@ -17,7 +17,6 @@ package probo import ( "context" "fmt" - "os" "time" "github.com/aws/aws-sdk-go-v2/service/s3" @@ -28,11 +27,15 @@ import ( "github.com/getprobo/probo/pkg/gid" "github.com/getprobo/probo/pkg/html2pdf" "github.com/getprobo/probo/pkg/usrmgr" - "go.gearno.de/crypto/uuid" "go.gearno.de/kit/pg" "go.gearno.de/x/ref" ) +type ExportService interface { + BuildAndUploadExport(ctx context.Context, exportJobID gid.GID) (*coredata.ExportJob, error) + SendExportEmail(ctx context.Context, fileID gid.GID, recipientName, recipientEmail string) error +} + type ( TrustConfig struct { TokenSecret string @@ -191,170 +194,100 @@ func (s *Service) WithTenant(tenantID gid.TenantID) *TenantService { return tenantService } -func (s *Service) ExportFrameworkJob(ctx context.Context) error { - fe, scope, err := s.lockExport(ctx) +func (s *Service) ExportJob(ctx context.Context) error { + exportJob, err := s.lockExportJob(ctx) if err != nil { - return fmt.Errorf("cannot lock framework export: %w", err) + return fmt.Errorf("cannot lock export job: %w", err) } - fe, buildErr := s.buildAndUploadExport(ctx, scope, fe) + tenantService := s.WithTenant(exportJob.ID.TenantID()) + + var exportService ExportService + + switch exportJob.Type { + case coredata.ExportJobTypeFramework: + exportService = tenantService.Frameworks + case coredata.ExportJobTypeDocument: + exportService = tenantService.Documents + default: + unknownTypeErr := fmt.Errorf("unknown export job type: %q", exportJob.Type) + if err := s.commitFailedExport(ctx, exportJob, unknownTypeErr); err != nil { + return fmt.Errorf("unknown export job type %q, and cannot commit failed export: %w", exportJob.Type, err) + } + return unknownTypeErr + } + + exportJob, buildErr := exportService.BuildAndUploadExport(ctx, exportJob.ID) if buildErr != nil { - if err := s.commitFailedExport(ctx, scope, fe); err != nil { + if err := s.commitFailedExport(ctx, exportJob, buildErr); err != nil { return fmt.Errorf( - "cannot build and upload framework export: %w, and cannot commit failed export: %w", + "cannot build and upload %s export: %w, and cannot commit failed export: %w", + exportJob.Type, buildErr, err, ) } - - return fmt.Errorf("cannot build and upload framework export: %w", buildErr) + return fmt.Errorf("cannot build and upload %s export: %w", exportJob.Type, buildErr) } - tenantService := s.WithTenant(scope.GetTenantID()) - if emailErr := tenantService.Frameworks.SendFrameworkExportEmail(ctx, *fe.FileID, fe.RecipientName, fe.RecipientEmail); emailErr != nil { - if err := s.commitFailedExport(ctx, scope, fe); err != nil { + if emailErr := exportService.SendExportEmail(ctx, *exportJob.FileID, exportJob.RecipientName, exportJob.RecipientEmail); emailErr != nil { + if err := s.commitFailedExport(ctx, exportJob, emailErr); err != nil { return fmt.Errorf( "cannot send completion email: %w, and cannot commit failed export: %w", emailErr, err, ) } - return fmt.Errorf("cannot send completion email: %w", emailErr) } - if err := s.commitSuccessfulExport(ctx, scope, fe); err != nil { - return fmt.Errorf("cannot commit successful export: %w", err) + if err := s.commitSuccessfulExport(ctx, exportJob); err != nil { + return fmt.Errorf("cannot commit successful %s export: %w", exportJob.Type, err) } return nil } -func (s *Service) lockExport(ctx context.Context) (*coredata.FrameworkExport, coredata.Scoper, error) { - fe := &coredata.FrameworkExport{} +func (s *Service) lockExportJob(ctx context.Context) (*coredata.ExportJob, error) { + exportJob := &coredata.ExportJob{} var scope coredata.Scoper err := s.pg.WithTx(ctx, func(tx pg.Conn) error { - if err := fe.LoadNextPendingForUpdateSkipLocked(ctx, tx); err != nil { - return fmt.Errorf("cannot load next pending framework export: %w", err) + if err := exportJob.LoadNextPendingForUpdateSkipLocked(ctx, tx); err != nil { + return fmt.Errorf("cannot load next pending export job: %w", err) } - scope = coredata.NewScope(fe.ID.TenantID()) + scope = coredata.NewScope(exportJob.ID.TenantID()) - fe.Status = coredata.FrameworkExportStatusProcessing - fe.StartedAt = ref.Ref(time.Now()) - if err := fe.Update(ctx, tx, scope); err != nil { - return fmt.Errorf("cannot update framework export: %w", err) + exportJob.Status = coredata.ExportJobStatusProcessing + exportJob.StartedAt = ref.Ref(time.Now()) + if err := exportJob.Update(ctx, tx, scope); err != nil { + return fmt.Errorf("cannot update %s export job: %w", exportJob.Type, err) } return nil }, ) if err != nil { - return nil, nil, fmt.Errorf("cannot lock framework export: %w", err) + return nil, fmt.Errorf("cannot lock export job: %w", err) } - return fe, scope, nil + return exportJob, nil } -func (s *Service) buildAndUploadExport(ctx context.Context, scope coredata.Scoper, fe *coredata.FrameworkExport) (*coredata.FrameworkExport, error) { - err := s.pg.WithTx( - ctx, - func(tx pg.Conn) error { - framework := &coredata.Framework{} - if err := framework.LoadByID(ctx, tx, scope, fe.FrameworkID); err != nil { - return fmt.Errorf("cannot load framework: %w", err) - } - - tempDir := os.TempDir() - tempFile, err := os.CreateTemp(tempDir, "probo-framework-export-*.zip") - if err != nil { - return fmt.Errorf("cannot create temp file: %w", err) - } - defer tempFile.Close() - defer os.Remove(tempFile.Name()) - - tenantService := s.WithTenant(scope.GetTenantID()) - err = tenantService.Frameworks.Export(ctx, fe.FrameworkID, tempFile) - if err != nil { - return fmt.Errorf("cannot export framework: %w", err) - } - - uuid, err := uuid.NewV4() - if err != nil { - return fmt.Errorf("cannot generate uuid: %w", err) - } - - if _, err := tempFile.Seek(0, 0); err != nil { - return fmt.Errorf("cannot seek temp file: %w", err) - } - - fileInfo, err := tempFile.Stat() - if err != nil { - return fmt.Errorf("cannot stat temp file: %w", err) - } - - _, err = s.s3.PutObject( - ctx, - &s3.PutObjectInput{ - Bucket: ref.Ref(s.bucket), - Key: ref.Ref(uuid.String()), - Body: tempFile, - ContentLength: ref.Ref(fileInfo.Size()), - ContentType: ref.Ref("application/zip"), - Metadata: map[string]string{ - "framework-id": framework.ID.String(), - "framework-export-id": fe.ID.String(), - }, - }, - ) - if err != nil { - return fmt.Errorf("cannot upload file to S3: %w", err) - } - - now := time.Now() - - file := coredata.File{ - ID: gid.New(fe.ID.TenantID(), coredata.FileEntityType), - BucketName: s.bucket, - MimeType: "application/zip", - FileName: fmt.Sprintf("%s Archive %s.zip", framework.Name, now.Format("2006-01-02")), - FileKey: uuid.String(), - FileSize: int(fileInfo.Size()), - CreatedAt: now, - UpdatedAt: now, - } - - if err := file.Insert(ctx, tx, scope); err != nil { - return fmt.Errorf("cannot insert file: %w", err) - } - - fe.FileID = &file.ID - if err := fe.Update(ctx, tx, scope); err != nil { - return fmt.Errorf("cannot update framework export: %w", err) - } - - return nil - }, - ) - - if err != nil { - return fe, fmt.Errorf("cannot build and upload export: %w", err) - } - - return fe, nil -} - -func (s *Service) commitFailedExport(ctx context.Context, scope coredata.Scoper, fe *coredata.FrameworkExport) error { - fe.CompletedAt = ref.Ref(time.Now()) - fe.Status = coredata.FrameworkExportStatusFailed +func (s *Service) commitFailedExport(ctx context.Context, exportJob *coredata.ExportJob, failureErr error) error { + exportJob.CompletedAt = ref.Ref(time.Now()) + exportJob.Status = coredata.ExportJobStatusFailed + errorMsg := failureErr.Error() + exportJob.Error = &errorMsg return s.pg.WithConn( ctx, func(conn pg.Conn) error { - if err := fe.Update(ctx, conn, scope); err != nil { - return fmt.Errorf("cannot update framework export: %w", err) + scope := coredata.NewScope(exportJob.ID.TenantID()) + if err := exportJob.Update(ctx, conn, scope); err != nil { + return fmt.Errorf("cannot update %s export job: %w", exportJob.Type, err) } return nil @@ -362,15 +295,16 @@ func (s *Service) commitFailedExport(ctx context.Context, scope coredata.Scoper, ) } -func (s *Service) commitSuccessfulExport(ctx context.Context, scope coredata.Scoper, fe *coredata.FrameworkExport) error { - fe.CompletedAt = ref.Ref(time.Now()) - fe.Status = coredata.FrameworkExportStatusCompleted +func (s *Service) commitSuccessfulExport(ctx context.Context, exportJob *coredata.ExportJob) error { + exportJob.CompletedAt = ref.Ref(time.Now()) + exportJob.Status = coredata.ExportJobStatusCompleted return s.pg.WithConn( ctx, func(conn pg.Conn) error { - if err := fe.Update(ctx, conn, scope); err != nil { - return fmt.Errorf("cannot update framework export: %w", err) + scope := coredata.NewScope(exportJob.ID.TenantID()) + if err := exportJob.Update(ctx, conn, scope); err != nil { + return fmt.Errorf("cannot update %s export job: %w", exportJob.Type, err) } return nil diff --git a/pkg/probod/probod.go b/pkg/probod/probod.go index dd9616ccf..20b4861b8 100644 --- a/pkg/probod/probod.go +++ b/pkg/probod/probod.go @@ -330,19 +330,19 @@ func (impl *Implm) Run( } }() - frameworkExporterCtx, stopFrameworkExporter := context.WithCancel(context.Background()) + exportJobExporterCtx, stopExportJobExporter := context.WithCancel(context.Background()) wg.Add(1) go func() { defer wg.Done() - if err := impl.runFrameworkExporter(frameworkExporterCtx, proboService, l.Named("framework-exporter")); err != nil { - cancel(fmt.Errorf("framework exporter crashed: %w", err)) + if err := impl.runExportJob(exportJobExporterCtx, proboService, l.Named("export-job-exporter")); err != nil { + cancel(fmt.Errorf("export job exporter crashed: %w", err)) } }() <-ctx.Done() stopMailer() - stopFrameworkExporter() + stopExportJobExporter() stopApiServer() wg.Wait() @@ -352,7 +352,7 @@ func (impl *Implm) Run( return context.Cause(ctx) } -func (impl *Implm) runFrameworkExporter( +func (impl *Implm) runExportJob( ctx context.Context, proboService *probo.Service, l *log.Logger, @@ -362,9 +362,9 @@ LOOP: case <-ctx.Done(): return ctx.Err() case <-time.After(30 * time.Second): - if err := proboService.ExportFrameworkJob(ctx); err != nil { - if !errors.Is(err, coredata.ErrNoFrameworkExportAvailable) { - l.ErrorCtx(ctx, "cannot process framework export", log.Error(err)) + if err := proboService.ExportJob(ctx); err != nil { + if !errors.Is(err, coredata.ErrNoExportJobAvailable) { + l.ErrorCtx(ctx, "cannot process export job", log.Error(err)) } } diff --git a/pkg/server/api/console/v1/schema.graphql b/pkg/server/api/console/v1/schema.graphql index 9c6cbcff1..37c233985 100644 --- a/pkg/server/api/console/v1/schema.graphql +++ b/pkg/server/api/console/v1/schema.graphql @@ -2643,6 +2643,12 @@ type Mutation { bulkPublishDocumentVersions( input: BulkPublishDocumentVersionsInput! ): BulkPublishDocumentVersionsPayload! + bulkDeleteDocuments( + input: BulkDeleteDocumentsInput! + ): BulkDeleteDocumentsPayload! + bulkExportDocuments( + input: BulkExportDocumentsInput! + ): BulkExportDocumentsPayload! generateDocumentChangelog( input: GenerateDocumentChangelogInput! ): GenerateDocumentChangelogPayload! @@ -3856,6 +3862,22 @@ type BulkPublishDocumentVersionsPayload { documentEdges: [DocumentEdge!]! } +input BulkDeleteDocumentsInput { + documentIds: [ID!]! +} + +input BulkExportDocumentsInput { + documentIds: [ID!]! +} + +type BulkDeleteDocumentsPayload { + deletedDocumentIds: [ID!]! +} + +type BulkExportDocumentsPayload { + exportJobId: ID! +} + input PublishDocumentVersionInput { documentId: ID! changelog: String diff --git a/pkg/server/api/console/v1/schema/schema.go b/pkg/server/api/console/v1/schema/schema.go index 48cdabb23..1b4b315bc 100644 --- a/pkg/server/api/console/v1/schema/schema.go +++ b/pkg/server/api/console/v1/schema/schema.go @@ -159,6 +159,14 @@ type ComplexityRoot struct { Node func(childComplexity int) int } + BulkDeleteDocumentsPayload struct { + DeletedDocumentIds func(childComplexity int) int + } + + BulkExportDocumentsPayload struct { + ExportJobID func(childComplexity int) int + } + BulkPublishDocumentVersionsPayload struct { DocumentEdges func(childComplexity int) int DocumentVersionEdges func(childComplexity int) int @@ -714,6 +722,8 @@ type ComplexityRoot struct { Mutation struct { AssessVendor func(childComplexity int, input types.AssessVendorInput) int AssignTask func(childComplexity int, input types.AssignTaskInput) int + BulkDeleteDocuments func(childComplexity int, input types.BulkDeleteDocumentsInput) int + BulkExportDocuments func(childComplexity int, input types.BulkExportDocumentsInput) int BulkPublishDocumentVersions func(childComplexity int, input types.BulkPublishDocumentVersionsInput) int BulkRequestSignatures func(childComplexity int, input types.BulkRequestSignaturesInput) int CancelSignatureRequest func(childComplexity int, input types.CancelSignatureRequestInput) int @@ -1629,6 +1639,8 @@ type MutationResolver interface { DeleteDocument(ctx context.Context, input types.DeleteDocumentInput) (*types.DeleteDocumentPayload, error) PublishDocumentVersion(ctx context.Context, input types.PublishDocumentVersionInput) (*types.PublishDocumentVersionPayload, error) BulkPublishDocumentVersions(ctx context.Context, input types.BulkPublishDocumentVersionsInput) (*types.BulkPublishDocumentVersionsPayload, error) + BulkDeleteDocuments(ctx context.Context, input types.BulkDeleteDocumentsInput) (*types.BulkDeleteDocumentsPayload, error) + BulkExportDocuments(ctx context.Context, input types.BulkExportDocumentsInput) (*types.BulkExportDocumentsPayload, error) GenerateDocumentChangelog(ctx context.Context, input types.GenerateDocumentChangelogInput) (*types.GenerateDocumentChangelogPayload, error) CreateDraftDocumentVersion(ctx context.Context, input types.CreateDraftDocumentVersionInput) (*types.CreateDraftDocumentVersionPayload, error) DeleteDraftDocumentVersion(ctx context.Context, input types.DeleteDraftDocumentVersionInput) (*types.DeleteDraftDocumentVersionPayload, error) @@ -2090,6 +2102,20 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.complexity.AuditEdge.Node(childComplexity), true + case "BulkDeleteDocumentsPayload.deletedDocumentIds": + if e.complexity.BulkDeleteDocumentsPayload.DeletedDocumentIds == nil { + break + } + + return e.complexity.BulkDeleteDocumentsPayload.DeletedDocumentIds(childComplexity), true + + case "BulkExportDocumentsPayload.exportJobId": + if e.complexity.BulkExportDocumentsPayload.ExportJobID == nil { + break + } + + return e.complexity.BulkExportDocumentsPayload.ExportJobID(childComplexity), true + case "BulkPublishDocumentVersionsPayload.documentEdges": if e.complexity.BulkPublishDocumentVersionsPayload.DocumentEdges == nil { break @@ -3859,6 +3885,30 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.complexity.Mutation.AssignTask(childComplexity, args["input"].(types.AssignTaskInput)), true + case "Mutation.bulkDeleteDocuments": + if e.complexity.Mutation.BulkDeleteDocuments == nil { + break + } + + args, err := ec.field_Mutation_bulkDeleteDocuments_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Mutation.BulkDeleteDocuments(childComplexity, args["input"].(types.BulkDeleteDocumentsInput)), true + + case "Mutation.bulkExportDocuments": + if e.complexity.Mutation.BulkExportDocuments == nil { + break + } + + args, err := ec.field_Mutation_bulkExportDocuments_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Mutation.BulkExportDocuments(childComplexity, args["input"].(types.BulkExportDocumentsInput)), true + case "Mutation.bulkPublishDocumentVersions": if e.complexity.Mutation.BulkPublishDocumentVersions == nil { break @@ -7834,6 +7884,8 @@ func (e *executableSchema) Exec(ctx context.Context) graphql.ResponseHandler { ec.unmarshalInputAssetOrder, ec.unmarshalInputAssignTaskInput, ec.unmarshalInputAuditOrder, + ec.unmarshalInputBulkDeleteDocumentsInput, + ec.unmarshalInputBulkExportDocumentsInput, ec.unmarshalInputBulkPublishDocumentVersionsInput, ec.unmarshalInputBulkRequestSignaturesInput, ec.unmarshalInputCancelSignatureRequestInput, @@ -10724,6 +10776,12 @@ type Mutation { bulkPublishDocumentVersions( input: BulkPublishDocumentVersionsInput! ): BulkPublishDocumentVersionsPayload! + bulkDeleteDocuments( + input: BulkDeleteDocumentsInput! + ): BulkDeleteDocumentsPayload! + bulkExportDocuments( + input: BulkExportDocumentsInput! + ): BulkExportDocumentsPayload! generateDocumentChangelog( input: GenerateDocumentChangelogInput! ): GenerateDocumentChangelogPayload! @@ -11937,6 +11995,22 @@ type BulkPublishDocumentVersionsPayload { documentEdges: [DocumentEdge!]! } +input BulkDeleteDocumentsInput { + documentIds: [ID!]! +} + +input BulkExportDocumentsInput { + documentIds: [ID!]! +} + +type BulkDeleteDocumentsPayload { + deletedDocumentIds: [ID!]! +} + +type BulkExportDocumentsPayload { + exportJobId: ID! +} + input PublishDocumentVersionInput { documentId: ID! changelog: String @@ -13861,6 +13935,52 @@ func (ec *executionContext) field_Mutation_assignTask_argsInput( return zeroVal, nil } +func (ec *executionContext) field_Mutation_bulkDeleteDocuments_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := ec.field_Mutation_bulkDeleteDocuments_argsInput(ctx, rawArgs) + if err != nil { + return nil, err + } + args["input"] = arg0 + return args, nil +} +func (ec *executionContext) field_Mutation_bulkDeleteDocuments_argsInput( + ctx context.Context, + rawArgs map[string]any, +) (types.BulkDeleteDocumentsInput, error) { + ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("input")) + if tmp, ok := rawArgs["input"]; ok { + return ec.unmarshalNBulkDeleteDocumentsInput2githubᚗcomᚋgetproboᚋproboᚋpkgᚋserverᚋapiᚋconsoleᚋv1ᚋtypesᚐBulkDeleteDocumentsInput(ctx, tmp) + } + + var zeroVal types.BulkDeleteDocumentsInput + return zeroVal, nil +} + +func (ec *executionContext) field_Mutation_bulkExportDocuments_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := ec.field_Mutation_bulkExportDocuments_argsInput(ctx, rawArgs) + if err != nil { + return nil, err + } + args["input"] = arg0 + return args, nil +} +func (ec *executionContext) field_Mutation_bulkExportDocuments_argsInput( + ctx context.Context, + rawArgs map[string]any, +) (types.BulkExportDocumentsInput, error) { + ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("input")) + if tmp, ok := rawArgs["input"]; ok { + return ec.unmarshalNBulkExportDocumentsInput2githubᚗcomᚋgetproboᚋproboᚋpkgᚋserverᚋapiᚋconsoleᚋv1ᚋtypesᚐBulkExportDocumentsInput(ctx, tmp) + } + + var zeroVal types.BulkExportDocumentsInput + return zeroVal, nil +} + func (ec *executionContext) field_Mutation_bulkPublishDocumentVersions_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} @@ -21578,6 +21698,94 @@ func (ec *executionContext) fieldContext_AuditEdge_node(_ context.Context, field return fc, nil } +func (ec *executionContext) _BulkDeleteDocumentsPayload_deletedDocumentIds(ctx context.Context, field graphql.CollectedField, obj *types.BulkDeleteDocumentsPayload) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_BulkDeleteDocumentsPayload_deletedDocumentIds(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.DeletedDocumentIds, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.([]gid.GID) + fc.Result = res + return ec.marshalNID2ᚕgithubᚗcomᚋgetproboᚋproboᚋpkgᚋgidᚐGIDᚄ(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_BulkDeleteDocumentsPayload_deletedDocumentIds(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "BulkDeleteDocumentsPayload", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type ID does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _BulkExportDocumentsPayload_exportJobId(ctx context.Context, field graphql.CollectedField, obj *types.BulkExportDocumentsPayload) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_BulkExportDocumentsPayload_exportJobId(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.ExportJobID, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(gid.GID) + fc.Result = res + return ec.marshalNID2githubᚗcomᚋgetproboᚋproboᚋpkgᚋgidᚐGID(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_BulkExportDocumentsPayload_exportJobId(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "BulkExportDocumentsPayload", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type ID does not have child fields") + }, + } + return fc, nil +} + func (ec *executionContext) _BulkPublishDocumentVersionsPayload_documentVersionEdges(ctx context.Context, field graphql.CollectedField, obj *types.BulkPublishDocumentVersionsPayload) (ret graphql.Marshaler) { fc, err := ec.fieldContext_BulkPublishDocumentVersionsPayload_documentVersionEdges(ctx, field) if err != nil { @@ -37958,6 +38166,124 @@ func (ec *executionContext) fieldContext_Mutation_bulkPublishDocumentVersions(ct return fc, nil } +func (ec *executionContext) _Mutation_bulkDeleteDocuments(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Mutation_bulkDeleteDocuments(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Mutation().BulkDeleteDocuments(rctx, fc.Args["input"].(types.BulkDeleteDocumentsInput)) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(*types.BulkDeleteDocumentsPayload) + fc.Result = res + return ec.marshalNBulkDeleteDocumentsPayload2ᚖgithubᚗcomᚋgetproboᚋproboᚋpkgᚋserverᚋapiᚋconsoleᚋv1ᚋtypesᚐBulkDeleteDocumentsPayload(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Mutation_bulkDeleteDocuments(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Mutation", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "deletedDocumentIds": + return ec.fieldContext_BulkDeleteDocumentsPayload_deletedDocumentIds(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type BulkDeleteDocumentsPayload", field.Name) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Mutation_bulkDeleteDocuments_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + +func (ec *executionContext) _Mutation_bulkExportDocuments(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Mutation_bulkExportDocuments(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Mutation().BulkExportDocuments(rctx, fc.Args["input"].(types.BulkExportDocumentsInput)) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(*types.BulkExportDocumentsPayload) + fc.Result = res + return ec.marshalNBulkExportDocumentsPayload2ᚖgithubᚗcomᚋgetproboᚋproboᚋpkgᚋserverᚋapiᚋconsoleᚋv1ᚋtypesᚐBulkExportDocumentsPayload(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Mutation_bulkExportDocuments(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Mutation", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "exportJobId": + return ec.fieldContext_BulkExportDocumentsPayload_exportJobId(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type BulkExportDocumentsPayload", field.Name) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Mutation_bulkExportDocuments_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + func (ec *executionContext) _Mutation_generateDocumentChangelog(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { fc, err := ec.fieldContext_Mutation_generateDocumentChangelog(ctx, field) if err != nil { @@ -61021,6 +61347,60 @@ func (ec *executionContext) unmarshalInputAuditOrder(ctx context.Context, obj an return it, nil } +func (ec *executionContext) unmarshalInputBulkDeleteDocumentsInput(ctx context.Context, obj any) (types.BulkDeleteDocumentsInput, error) { + var it types.BulkDeleteDocumentsInput + asMap := map[string]any{} + for k, v := range obj.(map[string]any) { + asMap[k] = v + } + + fieldsInOrder := [...]string{"documentIds"} + for _, k := range fieldsInOrder { + v, ok := asMap[k] + if !ok { + continue + } + switch k { + case "documentIds": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("documentIds")) + data, err := ec.unmarshalNID2ᚕgithubᚗcomᚋgetproboᚋproboᚋpkgᚋgidᚐGIDᚄ(ctx, v) + if err != nil { + return it, err + } + it.DocumentIds = data + } + } + + return it, nil +} + +func (ec *executionContext) unmarshalInputBulkExportDocumentsInput(ctx context.Context, obj any) (types.BulkExportDocumentsInput, error) { + var it types.BulkExportDocumentsInput + asMap := map[string]any{} + for k, v := range obj.(map[string]any) { + asMap[k] = v + } + + fieldsInOrder := [...]string{"documentIds"} + for _, k := range fieldsInOrder { + v, ok := asMap[k] + if !ok { + continue + } + switch k { + case "documentIds": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("documentIds")) + data, err := ec.unmarshalNID2ᚕgithubᚗcomᚋgetproboᚋproboᚋpkgᚋgidᚐGIDᚄ(ctx, v) + if err != nil { + return it, err + } + it.DocumentIds = data + } + } + + return it, nil +} + func (ec *executionContext) unmarshalInputBulkPublishDocumentVersionsInput(ctx context.Context, obj any) (types.BulkPublishDocumentVersionsInput, error) { var it types.BulkPublishDocumentVersionsInput asMap := map[string]any{} @@ -68443,6 +68823,84 @@ func (ec *executionContext) _AuditEdge(ctx context.Context, sel ast.SelectionSet return out } +var bulkDeleteDocumentsPayloadImplementors = []string{"BulkDeleteDocumentsPayload"} + +func (ec *executionContext) _BulkDeleteDocumentsPayload(ctx context.Context, sel ast.SelectionSet, obj *types.BulkDeleteDocumentsPayload) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, bulkDeleteDocumentsPayloadImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("BulkDeleteDocumentsPayload") + case "deletedDocumentIds": + out.Values[i] = ec._BulkDeleteDocumentsPayload_deletedDocumentIds(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.deferred, int32(len(deferred))) + + for label, dfs := range deferred { + ec.processDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + +var bulkExportDocumentsPayloadImplementors = []string{"BulkExportDocumentsPayload"} + +func (ec *executionContext) _BulkExportDocumentsPayload(ctx context.Context, sel ast.SelectionSet, obj *types.BulkExportDocumentsPayload) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, bulkExportDocumentsPayloadImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("BulkExportDocumentsPayload") + case "exportJobId": + out.Values[i] = ec._BulkExportDocumentsPayload_exportJobId(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.deferred, int32(len(deferred))) + + for label, dfs := range deferred { + ec.processDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + var bulkPublishDocumentVersionsPayloadImplementors = []string{"BulkPublishDocumentVersionsPayload"} func (ec *executionContext) _BulkPublishDocumentVersionsPayload(ctx context.Context, sel ast.SelectionSet, obj *types.BulkPublishDocumentVersionsPayload) graphql.Marshaler { @@ -74828,6 +75286,20 @@ func (ec *executionContext) _Mutation(ctx context.Context, sel ast.SelectionSet) if out.Values[i] == graphql.Null { out.Invalids++ } + case "bulkDeleteDocuments": + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Mutation_bulkDeleteDocuments(ctx, field) + }) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "bulkExportDocuments": + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Mutation_bulkExportDocuments(ctx, field) + }) + if out.Values[i] == graphql.Null { + out.Invalids++ + } case "generateDocumentChangelog": out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { return ec._Mutation_generateDocumentChangelog(ctx, field) @@ -82656,6 +83128,44 @@ func (ec *executionContext) marshalNBoolean2bool(ctx context.Context, sel ast.Se return res } +func (ec *executionContext) unmarshalNBulkDeleteDocumentsInput2githubᚗcomᚋgetproboᚋproboᚋpkgᚋserverᚋapiᚋconsoleᚋv1ᚋtypesᚐBulkDeleteDocumentsInput(ctx context.Context, v any) (types.BulkDeleteDocumentsInput, error) { + res, err := ec.unmarshalInputBulkDeleteDocumentsInput(ctx, v) + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalNBulkDeleteDocumentsPayload2githubᚗcomᚋgetproboᚋproboᚋpkgᚋserverᚋapiᚋconsoleᚋv1ᚋtypesᚐBulkDeleteDocumentsPayload(ctx context.Context, sel ast.SelectionSet, v types.BulkDeleteDocumentsPayload) graphql.Marshaler { + return ec._BulkDeleteDocumentsPayload(ctx, sel, &v) +} + +func (ec *executionContext) marshalNBulkDeleteDocumentsPayload2ᚖgithubᚗcomᚋgetproboᚋproboᚋpkgᚋserverᚋapiᚋconsoleᚋv1ᚋtypesᚐBulkDeleteDocumentsPayload(ctx context.Context, sel ast.SelectionSet, v *types.BulkDeleteDocumentsPayload) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + ec.Errorf(ctx, "the requested element is null which the schema does not allow") + } + return graphql.Null + } + return ec._BulkDeleteDocumentsPayload(ctx, sel, v) +} + +func (ec *executionContext) unmarshalNBulkExportDocumentsInput2githubᚗcomᚋgetproboᚋproboᚋpkgᚋserverᚋapiᚋconsoleᚋv1ᚋtypesᚐBulkExportDocumentsInput(ctx context.Context, v any) (types.BulkExportDocumentsInput, error) { + res, err := ec.unmarshalInputBulkExportDocumentsInput(ctx, v) + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalNBulkExportDocumentsPayload2githubᚗcomᚋgetproboᚋproboᚋpkgᚋserverᚋapiᚋconsoleᚋv1ᚋtypesᚐBulkExportDocumentsPayload(ctx context.Context, sel ast.SelectionSet, v types.BulkExportDocumentsPayload) graphql.Marshaler { + return ec._BulkExportDocumentsPayload(ctx, sel, &v) +} + +func (ec *executionContext) marshalNBulkExportDocumentsPayload2ᚖgithubᚗcomᚋgetproboᚋproboᚋpkgᚋserverᚋapiᚋconsoleᚋv1ᚋtypesᚐBulkExportDocumentsPayload(ctx context.Context, sel ast.SelectionSet, v *types.BulkExportDocumentsPayload) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + ec.Errorf(ctx, "the requested element is null which the schema does not allow") + } + return graphql.Null + } + return ec._BulkExportDocumentsPayload(ctx, sel, v) +} + func (ec *executionContext) unmarshalNBulkPublishDocumentVersionsInput2githubᚗcomᚋgetproboᚋproboᚋpkgᚋserverᚋapiᚋconsoleᚋv1ᚋtypesᚐBulkPublishDocumentVersionsInput(ctx context.Context, v any) (types.BulkPublishDocumentVersionsInput, error) { res, err := ec.unmarshalInputBulkPublishDocumentVersionsInput(ctx, v) return res, graphql.ErrorOnPath(ctx, err) diff --git a/pkg/server/api/console/v1/types/types.go b/pkg/server/api/console/v1/types/types.go index a50d5ac5a..a6adcfa6a 100644 --- a/pkg/server/api/console/v1/types/types.go +++ b/pkg/server/api/console/v1/types/types.go @@ -85,6 +85,22 @@ type AuditEdge struct { Node *Audit `json:"node"` } +type BulkDeleteDocumentsInput struct { + DocumentIds []gid.GID `json:"documentIds"` +} + +type BulkDeleteDocumentsPayload struct { + DeletedDocumentIds []gid.GID `json:"deletedDocumentIds"` +} + +type BulkExportDocumentsInput struct { + DocumentIds []gid.GID `json:"documentIds"` +} + +type BulkExportDocumentsPayload struct { + ExportJobID gid.GID `json:"exportJobId"` +} + type BulkPublishDocumentVersionsInput struct { DocumentIds []gid.GID `json:"documentIds"` Changelog string `json:"changelog"` diff --git a/pkg/server/api/console/v1/v1_resolver.go b/pkg/server/api/console/v1/v1_resolver.go index e68a84907..0ee1ce934 100644 --- a/pkg/server/api/console/v1/v1_resolver.go +++ b/pkg/server/api/console/v1/v1_resolver.go @@ -2543,6 +2543,49 @@ func (r *mutationResolver) BulkPublishDocumentVersions(ctx context.Context, inpu }, nil } +// BulkDeleteDocuments is the resolver for the bulkDeleteDocuments field. +func (r *mutationResolver) BulkDeleteDocuments(ctx context.Context, input types.BulkDeleteDocumentsInput) (*types.BulkDeleteDocumentsPayload, error) { + if len(input.DocumentIds) == 0 { + return &types.BulkDeleteDocumentsPayload{ + DeletedDocumentIds: []gid.GID{}, + }, nil + } + + prb := r.ProboService(ctx, input.DocumentIds[0].TenantID()) + + err := prb.Documents.BulkSoftDelete(ctx, input.DocumentIds) + if err != nil { + panic(fmt.Errorf("cannot bulk delete documents: %w", err)) + } + + return &types.BulkDeleteDocumentsPayload{ + DeletedDocumentIds: input.DocumentIds, + }, 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 { + panic(fmt.Errorf("no document ids provided")) + } + + prb := r.ProboService(ctx, input.DocumentIds[0].TenantID()) + + user := UserFromContext(ctx) + if user == nil { + panic(fmt.Errorf("user not found")) + } + + documentExport, err := prb.Documents.RequestExport(ctx, input.DocumentIds, user.EmailAddress, user.FullName) + if err != nil { + panic(fmt.Errorf("cannot request document export: %w", err)) + } + + return &types.BulkExportDocumentsPayload{ + ExportJobID: documentExport.ID, + }, nil +} + // GenerateDocumentChangelog is the resolver for the generateDocumentChangelog field. func (r *mutationResolver) GenerateDocumentChangelog(ctx context.Context, input types.GenerateDocumentChangelogInput) (*types.GenerateDocumentChangelogPayload, error) { prb := r.ProboService(ctx, input.DocumentID.TenantID())