Add major.minor document versioning

Introduce a two-part version scheme (major.minor) for documents.
Drafts start at 0.1 and increment minor on each new draft. Publishing
as minor keeps the current version, publishing as major bumps to the
next major.0. Both current_published_major and current_published_minor
are tracked on the document for exact version lookups.

Signatures and approval quorums aggregate across all versions sharing
the same major number using CTE joins. Approval page mutations spread
the decision fragment so Relay updates the version row state without
requiring a page refresh.

GraphQL, MCP, and service layer expose separate publishMajor and
publishMinor mutations instead of a single mutation with a type enum.

Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
Sacha Al Himdani
2026-03-27 19:57:58 +01:00
parent c5a412ea41
commit 68bc8cf59a
31 changed files with 877 additions and 417 deletions

View File

@@ -16,7 +16,7 @@ const fragment = graphql`
edges {
node {
id
currentPublishedVersion
currentPublishedMajor
...CompliancePageDocumentListItem_documentFragment
}
}
@@ -30,7 +30,7 @@ export function CompliancePageDocumentList(props: { fragmentRef: CompliancePageD
const { __ } = useTranslate();
const { compliancePage, documents } = useFragment<CompliancePageDocumentListFragment$key>(fragment, fragmentRef);
const publishedDocuments = documents.edges.filter(({ node }) => node.currentPublishedVersion != null);
const publishedDocuments = documents.edges.filter(({ node }) => node.currentPublishedMajor != null);
return (
<div className="space-y-[10px]">

View File

@@ -75,7 +75,8 @@ const unarchiveDocumentMutation = graphql`
const versionFragment = graphql`
fragment DocumentActionsDropdown_versionFragment on DocumentVersion {
id
version
major
minor
status
canDeleteDraft: permission(action: "core:document-version:delete-draft")
}
@@ -229,7 +230,7 @@ export function DocumentActionsDropdownn(props: {
__(
"This will permanently delete the draft version %s of \"%s\". This action cannot be undone.",
),
version.version,
`${version.major}.${version.minor}`,
document.title,
),
},
@@ -259,7 +260,7 @@ export function DocumentActionsDropdownn(props: {
if (data.exportDocumentVersionPDF) {
const link = window.document.createElement("a");
link.href = data.exportDocumentVersionPDF.data;
link.download = `${document.title}-v${version.version}.pdf`;
link.download = `${document.title}-v${version.major}.${version.minor}.pdf`;
window.document.body.appendChild(link);
link.click();
window.document.body.removeChild(link);

View File

@@ -29,7 +29,8 @@ const versionFragment = graphql`
fragment DocumentLayoutDrawer_versionFragment on DocumentVersion {
id
classification
version
major
minor
status
updatedAt
publishedAt
@@ -202,7 +203,9 @@ export function DocumentLayoutDrawer(props: {
</PropertyRow>
<PropertyRow label={__("Version")}>
<div className="text-sm text-txt-secondary">
{version.version}
{version.major}
.
{version.minor}
</div>
</PropertyRow>
<PropertyRow label={__("Last modified")}>

View File

@@ -22,7 +22,8 @@ const fragment = graphql`
node {
id
status
version
major
minor
approvalQuorums(first: 1, orderBy: { field: CREATED_AT, direction: DESC }) {
edges {
node {
@@ -148,7 +149,9 @@ export function DocumentListItem(props: {
</Td>
<Td className="w-20">
v
{lastVersion.version}
{lastVersion.major}
.
{lastVersion.minor}
</Td>
<Td className="w-28">
{getDocumentTypeLabel(__, document.documentType)}

View File

@@ -13,7 +13,8 @@ import { useOrganizationId } from "#/hooks/useOrganizationId";
const fragment = graphql`
fragment DocumentVersionsDropdownItemFragment on DocumentVersion {
id
version
major
minor
status
publishedAt
updatedAt
@@ -51,7 +52,9 @@ export function DocumentVersionsDropdownItem(props: {
)}
>
<div className="text-base text-txt-primary whitespace-nowrap font-bold text-center">
{version.version}
{version.major}
.
{version.minor}
</div>
</div>
<div className="flex-1 space-y-[2px] overflow-hidden">

View File

@@ -18,7 +18,8 @@ import { graphql } from "relay-runtime";
import { z } from "zod";
import type { PublishDialog_documentFragment$key } from "#/__generated__/core/PublishDialog_documentFragment.graphql";
import type { PublishDialog_publishMutation } from "#/__generated__/core/PublishDialog_publishMutation.graphql";
import type { PublishDialog_publishMajorMutation } from "#/__generated__/core/PublishDialog_publishMajorMutation.graphql";
import type { PublishDialog_publishMinorMutation } from "#/__generated__/core/PublishDialog_publishMinorMutation.graphql";
import type { PublishDialog_requestApprovalMutation } from "#/__generated__/core/PublishDialog_requestApprovalMutation.graphql";
import { PeopleMultiSelectField } from "#/components/form/PeopleMultiSelectField";
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
@@ -62,9 +63,24 @@ const documentFragment = graphql`
}
`;
const publishMutation = graphql`
mutation PublishDialog_publishMutation($input: PublishDocumentVersionInput!) {
publishDocumentVersion(input: $input) {
const publishMajorMutation = graphql`
mutation PublishDialog_publishMajorMutation($input: PublishMajorDocumentVersionInput!) {
publishMajorDocumentVersion(input: $input) {
document {
id
status
}
documentVersion {
id
status
}
}
}
`;
const publishMinorMutation = graphql`
mutation PublishDialog_publishMinorMutation($input: PublishMinorDocumentVersionInput!) {
publishMinorDocumentVersion(input: $input) {
document {
id
status
@@ -160,36 +176,52 @@ export function PublishDialog({
},
}));
const [publishVersion, isPublishing] = useMutation<PublishDialog_publishMutation>(publishMutation);
const [requestApproval, isRequesting] = useMutation<PublishDialog_requestApprovalMutation>(requestApprovalMutation);
const [publishMajor, isPublishingMajor]
= useMutation<PublishDialog_publishMajorMutation>(publishMajorMutation);
const [publishMinor, isPublishingMinor]
= useMutation<PublishDialog_publishMinorMutation>(publishMinorMutation);
const [requestApproval, isRequesting]
= useMutation<PublishDialog_requestApprovalMutation>(requestApprovalMutation);
const isBusy = isPublishing || isRequesting;
const isBusy = isPublishingMajor || isPublishingMinor || isRequesting;
const approverIds = watch("approverIds");
const actionRef = useRef<"publish" | "request-approval">("publish");
const actionRef = useRef<"publish" | "publish-minor" | "request-approval">("publish");
const handlePublish = (data: z.infer<typeof schema>) => {
publishVersion({
const onPublishCompleted = (_: unknown, errors: ReadonlyArray<{ message: string }> | null) => {
if (errors?.length) {
toast({
title: __("Error"),
description: formatError(__("Failed to publish document"), [...errors]),
variant: "error",
});
} else {
toast({
title: __("Success"),
description: __("Document published successfully."),
variant: "success",
});
dialogRef.current?.close();
onSuccess();
}
};
const onPublishError = (error: Error) => {
toast({ title: __("Error"), description: error.message, variant: "error" });
};
const handlePublishMajor = (data: z.infer<typeof schema>) => {
publishMajor({
variables: { input: { documentId, changelog: data.changelog } },
onCompleted(_, errors) {
if (errors?.length) {
toast({
title: __("Error"),
description: formatError(__("Failed to publish document"), errors),
variant: "error",
});
} else {
toast({
title: __("Success"),
description: __("Document published successfully."),
variant: "success",
});
dialogRef.current?.close();
onSuccess();
}
},
onError(error) {
toast({ title: __("Error"), description: error.message, variant: "error" });
},
onCompleted: onPublishCompleted,
onError: onPublishError,
});
};
const handlePublishMinor = (data: z.infer<typeof schema>) => {
publishMinor({
variables: { input: { documentId, changelog: data.changelog } },
onCompleted: onPublishCompleted,
onError: onPublishError,
});
};
@@ -231,7 +263,9 @@ export function PublishDialog({
<form
onSubmit={e => void handleSubmit((data) => {
if (actionRef.current === "publish") {
handlePublish(data);
handlePublishMajor(data);
} else if (actionRef.current === "publish-minor") {
handlePublishMinor(data);
} else {
onRequestApproval(data);
}
@@ -284,38 +318,34 @@ export function PublishDialog({
</div>
</DialogContent>
<DialogFooter>
{hasPendingApproval
? (
<Button
type="submit"
icon={IconUpload}
onClick={() => { actionRef.current = "publish"; }}
disabled={isBusy}
>
{__("Publish now")}
</Button>
)
: (
<>
<Button
type="submit"
variant="secondary"
icon={IconUpload}
onClick={() => { actionRef.current = "publish"; }}
disabled={isBusy}
>
{__("Publish now")}
</Button>
<Button
type="submit"
icon={IconSend}
onClick={() => { actionRef.current = "request-approval"; }}
disabled={isBusy || approverIds.length === 0}
>
{__("Request approval")}
</Button>
</>
)}
<Button
type="submit"
variant="secondary"
icon={IconUpload}
onClick={() => { actionRef.current = "publish-minor"; }}
disabled={isBusy}
>
{__("Publish as minor")}
</Button>
<Button
type="submit"
variant="secondary"
icon={IconUpload}
onClick={() => { actionRef.current = "publish"; }}
disabled={isBusy}
>
{__("Publish now")}
</Button>
{!hasPendingApproval && (
<Button
type="submit"
icon={IconSend}
onClick={() => { actionRef.current = "request-approval"; }}
disabled={isBusy || approverIds.length === 0}
>
{__("Request approval")}
</Button>
)}
</DialogFooter>
</form>
</Dialog>

View File

@@ -5,17 +5,19 @@ import {
Dialog,
DialogContent,
DialogFooter,
IconUpload,
IconWarning,
Textarea,
useDialogRef,
useToast,
} from "@probo/ui";
import { type ReactNode } from "react";
import { type ReactNode, useRef } from "react";
import { useMutation } from "react-relay";
import { graphql } from "relay-runtime";
import { z } from "zod";
import type { PublishDocumentsDialogMutation } from "#/__generated__/core/PublishDocumentsDialogMutation.graphql";
import type { PublishDocumentsDialog_majorMutation } from "#/__generated__/core/PublishDocumentsDialog_majorMutation.graphql";
import type { PublishDocumentsDialog_minorMutation } from "#/__generated__/core/PublishDocumentsDialog_minorMutation.graphql";
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
type Props = {
@@ -24,11 +26,27 @@ type Props = {
onSave: () => void;
};
const documentsPublishMutation = graphql`
mutation PublishDocumentsDialogMutation(
const publishMajorMutation = graphql`
mutation PublishDocumentsDialog_majorMutation(
$input: BulkPublishDocumentVersionsInput!
) {
bulkPublishDocumentVersions(input: $input) {
bulkPublishMajorDocumentVersions(input: $input) {
documentVersions {
id
}
documents {
id
...DocumentListItemFragment
}
}
}
`;
const publishMinorMutation = graphql`
mutation PublishDocumentsDialog_minorMutation(
$input: BulkPublishDocumentVersionsInput!
) {
bulkPublishMinorDocumentVersions(input: $input) {
documentVersions {
id
}
@@ -48,12 +66,18 @@ export function PublishDocumentsDialog({
const { __ } = useTranslate();
const { toast } = useToast();
const dialogRef = useDialogRef();
const actionRef = useRef<"major" | "minor">("major");
const schema = z.object({
changelog: z.string().min(1, __("Changelog is required")),
});
const [publishMutation, isPublishing] = useMutation<PublishDocumentsDialogMutation>(documentsPublishMutation);
const [publishMajor, isPublishingMajor]
= useMutation<PublishDocumentsDialog_majorMutation>(publishMajorMutation);
const [publishMinor, isPublishingMinor]
= useMutation<PublishDocumentsDialog_minorMutation>(publishMinorMutation);
const isBusy = isPublishingMajor || isPublishingMinor;
const {
handleSubmit,
@@ -65,41 +89,47 @@ export function PublishDocumentsDialog({
},
});
const onSubmit = (data: z.infer<typeof schema>) => {
publishMutation({
variables: {
input: {
documentIds,
changelog: data.changelog,
},
},
onCompleted(_, errors) {
if (errors?.length) {
toast({
title: __("Error"),
description: formatError(__("Failed to publish documents"), errors),
variant: "error",
});
} else {
toast({
title: __("Success"),
description: sprintf(__("%s documents published"), documentIds.length),
variant: "success",
});
dialogRef.current?.close();
onSave();
}
},
onError(error) {
toast({
title: __("Error"),
description: error.message,
variant: "error",
});
},
const onCompleted = (_: unknown, errors: ReadonlyArray<{ message: string }> | null) => {
if (errors?.length) {
toast({
title: __("Error"),
description: formatError(__("Failed to publish documents"), [...errors]),
variant: "error",
});
} else {
toast({
title: __("Success"),
description: sprintf(__("%s documents published"), documentIds.length),
variant: "success",
});
dialogRef.current?.close();
onSave();
}
};
const onError = (error: Error) => {
toast({
title: __("Error"),
description: error.message,
variant: "error",
});
};
const onSubmit = (data: z.infer<typeof schema>) => {
const variables = {
input: {
documentIds,
changelog: data.changelog,
},
};
if (actionRef.current === "minor") {
publishMinor({ variables, onCompleted, onError });
} else {
publishMajor({ variables, onCompleted, onError });
}
};
return (
<Dialog
className="max-w-xl"
@@ -135,7 +165,20 @@ export function PublishDocumentsDialog({
</div>
</DialogContent>
<DialogFooter>
<Button type="submit" disabled={isPublishing}>
<Button
type="submit"
variant="secondary"
icon={IconUpload}
disabled={isBusy}
onClick={() => { actionRef.current = "minor"; }}
>
{__("Publish as minor")}
</Button>
<Button
type="submit"
disabled={isBusy}
onClick={() => { actionRef.current = "major"; }}
>
{sprintf(__("Publish %s documents"), documentIds.length)}
</Button>
</DialogFooter>

View File

@@ -51,7 +51,8 @@ const createDraftDocument = graphql`
content
status
publishedAt
version
major
minor
updatedAt
signatures(first: 100) {
edges {

View File

@@ -70,9 +70,11 @@ const documentFragment = graphql`
const versionRowFragment = graphql`
fragment DocumentApprovePageVersionRowFragment on EmployeeDocumentVersion {
id
version
major
minor
publishedAt
approvalDecision {
id
state
}
}
@@ -96,10 +98,7 @@ const approveDocumentVersionMutation = graphql`
) {
approveDocumentVersion(input: $input) {
approvalDecision {
id
state
decidedAt
comment
...DocumentApprovePageDecisionFragment
}
}
}
@@ -111,10 +110,7 @@ const rejectDocumentVersionMutation = graphql`
) {
rejectDocumentVersion(input: $input) {
approvalDecision {
id
state
decidedAt
comment
...DocumentApprovePageDecisionFragment
}
}
}
@@ -192,14 +188,14 @@ function VersionRow({
)}
>
{versionData.publishedAt
? `v${versionData.version} - ${(() => {
? `v${versionData.major}.${versionData.minor} - ${(() => {
const date = new Date(versionData.publishedAt);
const day = String(date.getDate()).padStart(2, "0");
const month = String(date.getMonth() + 1).padStart(2, "0");
const year = date.getFullYear();
return `${day}/${month}/${year}`;
})()}`
: `v${versionData.version}`}
: `v${versionData.major}.${versionData.minor}`}
</p>
</div>
<div className="flex-shrink-0">

View File

@@ -9,7 +9,8 @@ const fragment = graphql`
fragment VersionRowFragment on EmployeeDocumentVersion {
# eslint-disable-next-line relay/unused-fields
id
version
major
minor
signed
publishedAt
}
@@ -55,14 +56,14 @@ export function VersionRow({
)}
>
{versionData.publishedAt
? `v${versionData.version} - ${(() => {
? `v${versionData.major}.${versionData.minor} - ${(() => {
const date = new Date(versionData.publishedAt);
const day = String(date.getDate()).padStart(2, "0");
const month = String(date.getMonth() + 1).padStart(2, "0");
const year = date.getFullYear();
return `${day}/${month}/${year}`;
})()}`
: `v${versionData.version}`}
: `v${versionData.major}.${versionData.minor}`}
</p>
</div>
<div className="flex-shrink-0">

View File

@@ -223,7 +223,8 @@ func TestDocumentVersion_PublishVersion(t *testing.T) {
node {
id
status
version
major
minor
}
}
}
@@ -237,9 +238,10 @@ func TestDocumentVersion_PublishVersion(t *testing.T) {
Versions struct {
Edges []struct {
Node struct {
ID string `json:"id"`
Status string `json:"status"`
Version int `json:"version"`
ID string `json:"id"`
Status string `json:"status"`
Major int `json:"major"`
Minor int `json:"minor"`
} `json:"node"`
} `json:"edges"`
} `json:"versions"`
@@ -251,7 +253,8 @@ func TestDocumentVersion_PublishVersion(t *testing.T) {
require.NotEmpty(t, result.Node.Versions.Edges)
assert.Equal(t, "PUBLISHED", result.Node.Versions.Edges[0].Node.Status)
assert.Equal(t, 1, result.Node.Versions.Edges[0].Node.Version)
assert.Equal(t, 1, result.Node.Versions.Edges[0].Node.Major)
assert.Equal(t, 0, result.Node.Versions.Edges[0].Node.Minor)
}
func TestDocumentVersion_CreateDraft(t *testing.T) {
@@ -435,8 +438,8 @@ func TestDocumentVersion_BulkPublish(t *testing.T) {
approveTestDocument(t, owner, docID2)
query := `
mutation BulkPublishDocumentVersions($input: BulkPublishDocumentVersionsInput!) {
bulkPublishDocumentVersions(input: $input) {
mutation BulkPublishMajorDocumentVersions($input: BulkPublishDocumentVersionsInput!) {
bulkPublishMajorDocumentVersions(input: $input) {
documentVersions {
id
status
@@ -446,12 +449,12 @@ func TestDocumentVersion_BulkPublish(t *testing.T) {
`
var result struct {
BulkPublishDocumentVersions struct {
BulkPublishMajorDocumentVersions struct {
DocumentVersions []struct {
ID string `json:"id"`
Status string `json:"status"`
} `json:"documentVersions"`
} `json:"bulkPublishDocumentVersions"`
} `json:"bulkPublishMajorDocumentVersions"`
}
err := owner.Execute(query, map[string]any{
@@ -462,8 +465,8 @@ func TestDocumentVersion_BulkPublish(t *testing.T) {
}, &result)
require.NoError(t, err)
assert.Equal(t, 2, len(result.BulkPublishDocumentVersions.DocumentVersions))
for _, dv := range result.BulkPublishDocumentVersions.DocumentVersions {
assert.Equal(t, 2, len(result.BulkPublishMajorDocumentVersions.DocumentVersions))
for _, dv := range result.BulkPublishMajorDocumentVersions.DocumentVersions {
assert.Equal(t, "PUBLISHED", dv.Status)
}
}

View File

@@ -30,17 +30,18 @@ import (
type (
Document struct {
ID gid.GID `db:"id"`
OrganizationID gid.GID `db:"organization_id"`
Title string `db:"title"`
DocumentType DocumentType `db:"document_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"`
ID gid.GID `db:"id"`
OrganizationID gid.GID `db:"organization_id"`
Title string `db:"title"`
DocumentType DocumentType `db:"document_type"`
Classification DocumentClassification `db:"classification"`
CurrentPublishedMajor *int `db:"current_published_major"`
CurrentPublishedMinor *int `db:"current_published_minor"`
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"`
}
Documents []*Document
@@ -128,7 +129,8 @@ SELECT
title,
document_type,
classification,
current_published_version,
current_published_major,
current_published_minor,
trust_center_visibility,
status,
archived_at,
@@ -181,7 +183,8 @@ SELECT
title,
document_type,
classification,
current_published_version,
current_published_major,
current_published_minor,
trust_center_visibility,
status,
archived_at,
@@ -235,7 +238,8 @@ SELECT
title,
document_type,
classification,
current_published_version,
current_published_major,
current_published_minor,
trust_center_visibility,
status,
archived_at,
@@ -318,7 +322,8 @@ SELECT
title,
document_type,
classification,
current_published_version,
current_published_major,
current_published_minor,
trust_center_visibility,
status,
archived_at,
@@ -370,7 +375,8 @@ SELECT
title,
document_type,
classification,
current_published_version,
current_published_major,
current_published_minor,
trust_center_visibility,
status,
archived_at,
@@ -424,7 +430,8 @@ WITH published_documents AS (
documents d
LEFT JOIN document_versions dv
ON dv.document_id = d.id
AND dv.version_number = d.current_published_version
AND dv.major = d.current_published_major
AND dv.minor = d.current_published_minor
WHERE
d.deleted_at IS NULL
AND d.organization_id = @organization_id
@@ -435,7 +442,8 @@ SELECT
COALESCE(published_title, title) AS title,
document_type,
classification,
current_published_version,
current_published_major,
current_published_minor,
trust_center_visibility,
status,
archived_at,
@@ -484,7 +492,8 @@ INSERT INTO
title,
document_type,
classification,
current_published_version,
current_published_major,
current_published_minor,
trust_center_visibility,
status,
archived_at,
@@ -498,7 +507,8 @@ VALUES (
@title,
@document_type,
@classification,
@current_published_version,
@current_published_major,
@current_published_minor,
@trust_center_visibility,
@status,
@archived_at,
@@ -508,18 +518,19 @@ VALUES (
`
args := pgx.StrictNamedArgs{
"tenant_id": scope.GetTenantID(),
"document_id": p.ID,
"organization_id": p.OrganizationID,
"title": p.Title,
"document_type": p.DocumentType,
"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,
"tenant_id": scope.GetTenantID(),
"document_id": p.ID,
"organization_id": p.OrganizationID,
"title": p.Title,
"document_type": p.DocumentType,
"classification": p.Classification,
"current_published_major": p.CurrentPublishedMajor,
"current_published_minor": p.CurrentPublishedMinor,
"trust_center_visibility": p.TrustCenterVisibility,
"status": p.Status,
"archived_at": p.ArchivedAt,
"created_at": p.CreatedAt,
"updated_at": p.UpdatedAt,
}
_, err := conn.Exec(ctx, q, args)
return err
@@ -572,7 +583,8 @@ UPDATE
documents
SET
title = @title,
current_published_version = @current_published_version,
current_published_major = @current_published_major,
current_published_minor = @current_published_minor,
document_type = @document_type,
classification = @classification,
trust_center_visibility = @trust_center_visibility,
@@ -587,15 +599,16 @@ WHERE
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"document_id": p.ID,
"updated_at": time.Now(),
"title": p.Title,
"current_published_version": p.CurrentPublishedVersion,
"document_type": p.DocumentType,
"classification": p.Classification,
"trust_center_visibility": p.TrustCenterVisibility,
"status": p.Status,
"archived_at": p.ArchivedAt,
"document_id": p.ID,
"updated_at": time.Now(),
"title": p.Title,
"current_published_major": p.CurrentPublishedMajor,
"current_published_minor": p.CurrentPublishedMinor,
"document_type": p.DocumentType,
"classification": p.Classification,
"trust_center_visibility": p.TrustCenterVisibility,
"status": p.Status,
"archived_at": p.ArchivedAt,
}
maps.Copy(args, scope.SQLArguments())
@@ -666,7 +679,8 @@ SELECT
scoped_documents.title,
scoped_documents.document_type,
scoped_documents.classification,
scoped_documents.current_published_version,
scoped_documents.current_published_major,
scoped_documents.current_published_minor,
scoped_documents.trust_center_visibility,
scoped_documents.status,
scoped_documents.archived_at,
@@ -757,7 +771,8 @@ SELECT
scoped_documents.title,
scoped_documents.document_type,
scoped_documents.classification,
scoped_documents.current_published_version,
scoped_documents.current_published_major,
scoped_documents.current_published_minor,
scoped_documents.trust_center_visibility,
scoped_documents.status,
scoped_documents.archived_at,
@@ -874,28 +889,29 @@ func (p *Document) IsLastSignableVersionSignedByUserEmail(
userEmail mail.Addr,
) (bool, error) {
q := `
WITH last_signable_version AS (
WITH max_signable_major AS (
SELECT MAX(dv.major) AS major
FROM document_versions dv
INNER JOIN document_version_signatures dvs ON dvs.document_version_id = dv.id
INNER JOIN iam_membership_profiles p ON dvs.signed_by_profile_id = p.id
INNER JOIN identities i ON p.identity_id = i.id
WHERE dv.document_id = @document_id
AND i.email_address = @user_email::CITEXT
),
last_signable_version AS (
SELECT
d.id AS document_id,
d.tenant_id,
dv.version_number,
dv.major,
dvs.state
FROM documents d
INNER JOIN document_versions dv ON dv.document_id = d.id
INNER JOIN max_signable_major msm ON dv.major = msm.major
INNER JOIN document_version_signatures dvs ON dvs.document_version_id = dv.id
INNER JOIN iam_membership_profiles p ON dvs.signed_by_profile_id = p.id
INNER JOIN identities i ON p.identity_id = i.id
WHERE d.id = @document_id
AND i.email_address = @user_email::CITEXT
AND dv.version_number = (
SELECT MAX(dv2.version_number)
FROM document_versions dv2
INNER JOIN document_version_signatures dvs2 ON dvs2.document_version_id = dv2.id
INNER JOIN iam_membership_profiles p2 ON dvs2.signed_by_profile_id = p2.id
INNER JOIN identities i2 ON p2.identity_id = i2.id
WHERE dv2.document_id = d.id
AND i2.email_address = @user_email::CITEXT
)
)
SELECT EXISTS (
SELECT 1
@@ -938,7 +954,7 @@ WITH viewer_decision AS (
SELECT
dvad.tenant_id,
dvad.state,
dv.version_number,
dv.major,
dvaq.created_at AS quorum_created_at
FROM documents d
INNER JOIN document_versions dv ON dv.document_id = d.id
@@ -951,7 +967,7 @@ WITH viewer_decision AS (
SELECT state
FROM viewer_decision
WHERE %s
ORDER BY version_number DESC, quorum_created_at DESC
ORDER BY major DESC, quorum_created_at DESC
LIMIT 1
`

View File

@@ -131,8 +131,8 @@ func (f *DocumentFilter) SQLFragment() string {
AND
CASE
WHEN @published::boolean IS NULL THEN TRUE
WHEN @published::boolean IS TRUE THEN current_published_version IS NOT NULL
WHEN @published::boolean IS FALSE THEN current_published_version IS NULL
WHEN @published::boolean IS TRUE THEN current_published_major IS NOT NULL
WHEN @published::boolean IS FALSE THEN current_published_major IS NULL
END
AND
CASE

View File

@@ -34,7 +34,8 @@ type (
OrganizationID gid.GID `db:"organization_id"`
DocumentID gid.GID `db:"document_id"`
Title string `db:"title"`
VersionNumber int `db:"version_number"`
Major int `db:"major"`
Minor int `db:"minor"`
Classification DocumentClassification `db:"classification"`
Content string `db:"content"`
Changelog string `db:"changelog"`
@@ -116,7 +117,8 @@ SELECT
organization_id,
document_id,
title,
version_number,
major,
minor,
classification,
content,
changelog,
@@ -177,7 +179,8 @@ SELECT
organization_id,
document_id,
title,
version_number,
major,
minor,
classification,
content,
changelog,
@@ -227,7 +230,8 @@ INSERT INTO document_versions (
organization_id,
document_id,
title,
version_number,
major,
minor,
classification,
content,
changelog,
@@ -241,7 +245,8 @@ VALUES (
@organization_id,
@document_id,
@title,
@version_number,
@major,
@minor,
@classification,
@content,
@changelog,
@@ -256,7 +261,8 @@ VALUES (
"organization_id": dv.OrganizationID,
"document_id": dv.DocumentID,
"title": dv.Title,
"version_number": dv.VersionNumber,
"major": dv.Major,
"minor": dv.Minor,
"classification": dv.Classification,
"content": dv.Content,
"changelog": dv.Changelog,
@@ -270,7 +276,7 @@ VALUES (
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
if pgErr.Code == "23505" {
if pgErr.ConstraintName == "document_versions_document_id_version_number_key" || pgErr.ConstraintName == "document_one_draft_version_idx" {
if pgErr.ConstraintName == "document_versions_document_id_major_minor_key" || pgErr.ConstraintName == "document_one_draft_version_idx" {
return ErrResourceAlreadyExists
}
}
@@ -281,12 +287,13 @@ VALUES (
return nil
}
func (dv *DocumentVersion) LoadByDocumentIDAndVersionNumber(
func (dv *DocumentVersion) LoadByDocumentIDAndVersion(
ctx context.Context,
conn pg.Conn,
scope Scoper,
documentID gid.GID,
versionNumber int,
major int,
minor int,
) error {
q := `
SELECT
@@ -294,7 +301,8 @@ SELECT
organization_id,
document_id,
title,
version_number,
major,
minor,
classification,
content,
changelog,
@@ -307,15 +315,17 @@ FROM
WHERE
%s
AND document_id = @document_id
AND version_number = @version_number
AND major = @major
AND minor = @minor
LIMIT 1;
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"document_id": documentID,
"version_number": versionNumber,
"document_id": documentID,
"major": major,
"minor": minor,
}
maps.Copy(args, scope.SQLArguments())
@@ -346,7 +356,8 @@ SELECT
organization_id,
document_id,
title,
version_number,
major,
minor,
classification,
content,
changelog,
@@ -396,7 +407,8 @@ SELECT
organization_id,
document_id,
title,
version_number,
major,
minor,
classification,
content,
changelog,
@@ -444,6 +456,8 @@ func (dv DocumentVersion) Update(
q := `
UPDATE document_versions SET
title = @title,
major = @major,
minor = @minor,
changelog = @changelog,
status = @status,
content = @content,
@@ -459,6 +473,8 @@ WHERE %s
args := pgx.StrictNamedArgs{
"document_version_id": dv.ID,
"title": dv.Title,
"major": dv.Major,
"minor": dv.Minor,
"changelog": dv.Changelog,
"status": dv.Status,
"content": dv.Content,

View File

@@ -115,19 +115,26 @@ func (q *DocumentVersionApprovalQuorum) LoadLastByDocumentVersionID(
documentVersionID gid.GID,
) error {
query := `
WITH source_version AS (
SELECT document_id, major FROM document_versions WHERE id = @version_id
),
major_versions AS (
SELECT dv.id FROM document_versions dv
INNER JOIN source_version sv ON dv.document_id = sv.document_id AND dv.major = sv.major
)
SELECT
id,
organization_id,
version_id,
status,
created_at,
updated_at
document_version_approval_quorums.id,
document_version_approval_quorums.organization_id,
document_version_approval_quorums.version_id,
document_version_approval_quorums.status,
document_version_approval_quorums.created_at,
document_version_approval_quorums.updated_at
FROM
document_version_approval_quorums
INNER JOIN major_versions mv ON document_version_approval_quorums.version_id = mv.id
WHERE
%s
AND version_id = @version_id
ORDER BY created_at DESC
ORDER BY document_version_approval_quorums.created_at DESC
LIMIT 1
`
@@ -162,18 +169,25 @@ func (q *DocumentVersionApprovalQuorums) LoadAllByDocumentVersionID(
cursor *page.Cursor[DocumentVersionApprovalQuorumOrderField],
) error {
query := `
WITH source_version AS (
SELECT document_id, major FROM document_versions WHERE id = @version_id
),
major_versions AS (
SELECT dv.id FROM document_versions dv
INNER JOIN source_version sv ON dv.document_id = sv.document_id AND dv.major = sv.major
)
SELECT
id,
organization_id,
version_id,
status,
created_at,
updated_at
document_version_approval_quorums.id,
document_version_approval_quorums.organization_id,
document_version_approval_quorums.version_id,
document_version_approval_quorums.status,
document_version_approval_quorums.created_at,
document_version_approval_quorums.updated_at
FROM
document_version_approval_quorums
INNER JOIN major_versions mv ON document_version_approval_quorums.version_id = mv.id
WHERE
%s
AND version_id = @version_id
AND %s
`
@@ -205,13 +219,20 @@ func (q *DocumentVersionApprovalQuorums) CountByDocumentVersionID(
documentVersionID gid.GID,
) (int, error) {
query := `
WITH source_version AS (
SELECT document_id, major FROM document_versions WHERE id = @version_id
),
major_versions AS (
SELECT dv.id FROM document_versions dv
INNER JOIN source_version sv ON dv.document_id = sv.document_id AND dv.major = sv.major
)
SELECT
COUNT(id)
COUNT(document_version_approval_quorums.id)
FROM
document_version_approval_quorums
INNER JOIN major_versions mv ON document_version_approval_quorums.version_id = mv.id
WHERE
%s
AND version_id = @version_id
`
query = fmt.Sprintf(query, scope.SQLFragment())

View File

@@ -236,21 +236,28 @@ func (pvss *DocumentVersionSignatures) LoadByDocumentVersionID(
filter *DocumentVersionSignatureFilter,
) error {
q := `
WITH source_version AS (
SELECT document_id, major FROM document_versions WHERE id = @document_version_id
),
major_versions AS (
SELECT dv.id FROM document_versions dv
INNER JOIN source_version sv ON dv.document_id = sv.document_id AND dv.major = sv.major
)
SELECT
id,
organization_id,
document_version_id,
state,
signed_by_profile_id,
signed_at,
requested_at,
created_at,
updated_at
document_version_signatures.id,
document_version_signatures.organization_id,
document_version_signatures.document_version_id,
document_version_signatures.state,
document_version_signatures.signed_by_profile_id,
document_version_signatures.signed_at,
document_version_signatures.requested_at,
document_version_signatures.created_at,
document_version_signatures.updated_at
FROM
document_version_signatures
INNER JOIN major_versions mv ON document_version_signatures.document_version_id = mv.id
WHERE
%s
AND document_version_id = @document_version_id
AND %s
AND %s
`
@@ -348,7 +355,14 @@ func (pvss *DocumentVersionSignaturesWithPeople) LoadByDocumentVersionIDWithPeop
limit int,
) error {
q := `
WITH sigs AS (
WITH source_version AS (
SELECT document_id, major FROM document_versions WHERE id = @document_version_id
),
major_versions AS (
SELECT dv.id FROM document_versions dv
INNER JOIN source_version sv ON dv.document_id = sv.document_id AND dv.major = sv.major
),
signatures_with_people AS (
SELECT
dvs.id,
dvs.organization_id,
@@ -360,16 +374,10 @@ WITH sigs AS (
dvs.requested_at,
dvs.created_at,
dvs.updated_at,
p.full_name as signed_by_full_name
FROM
document_version_signatures dvs
INNER JOIN
iam_membership_profiles p ON dvs.signed_by_profile_id = p.id
WHERE
dvs.document_version_id = @document_version_id
ORDER BY
p.full_name ASC
LIMIT @limit
p.full_name AS signed_by_full_name
FROM document_version_signatures dvs
INNER JOIN major_versions mv ON dvs.document_version_id = mv.id
INNER JOIN iam_membership_profiles p ON dvs.signed_by_profile_id = p.id
)
SELECT
id,
@@ -383,9 +391,12 @@ SELECT
updated_at,
signed_by_full_name
FROM
sigs
signatures_with_people
WHERE
%s
ORDER BY
signed_by_full_name ASC
LIMIT @limit
`
q = fmt.Sprintf(q, scope.SQLFragment())
@@ -419,18 +430,31 @@ func (pvs *DocumentVersionSignature) IsSignedByUserEmail(
userEmail mail.Addr,
) (bool, error) {
q := `
SELECT EXISTS (
SELECT 1
WITH source_version AS (
SELECT document_id, major FROM document_versions WHERE id = @document_version_id
),
major_versions AS (
SELECT dv.id FROM document_versions dv
INNER JOIN source_version sv ON dv.document_id = sv.document_id AND dv.major = sv.major
),
signed_emails AS (
SELECT dvs.id, dvs.tenant_id
FROM document_version_signatures dvs
INNER JOIN major_versions mv ON dvs.document_version_id = mv.id
INNER JOIN iam_membership_profiles p ON dvs.signed_by_profile_id = p.id
INNER JOIN identities i ON p.identity_id = i.id
WHERE dvs.document_version_id = @document_version_id
AND i.email_address = @user_email::CITEXT
WHERE i.email_address = @user_email::CITEXT
AND dvs.state = 'SIGNED'
AND dvs.tenant_id = @tenant_id
)
SELECT EXISTS (
SELECT 1
FROM signed_emails
WHERE %s
) AS signed
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"document_version_id": documentVersionID,
"user_email": userEmail,
@@ -458,13 +482,20 @@ func (dvs *DocumentVersionSignatures) CountByDocumentVersionID(
filter *DocumentVersionSignatureFilter,
) (int, error) {
q := `
WITH source_version AS (
SELECT document_id, major FROM document_versions WHERE id = @document_version_id
),
major_versions AS (
SELECT dv.id FROM document_versions dv
INNER JOIN source_version sv ON dv.document_id = sv.document_id AND dv.major = sv.major
)
SELECT
COUNT(id)
COUNT(document_version_signatures.id)
FROM
document_version_signatures
INNER JOIN major_versions mv ON document_version_signatures.document_version_id = mv.id
WHERE
%s
AND document_version_id = @document_version_id
AND %s
`

View File

@@ -0,0 +1,11 @@
ALTER TABLE document_versions DROP CONSTRAINT document_versions_document_id_version_number_key;
ALTER TABLE document_versions RENAME COLUMN version_number TO major;
ALTER TABLE document_versions ADD COLUMN minor INTEGER NOT NULL DEFAULT 0;
ALTER TABLE document_versions ALTER COLUMN minor DROP DEFAULT;
ALTER TABLE document_versions ADD CONSTRAINT document_versions_document_id_major_minor_key UNIQUE (document_id, major, minor);
ALTER TABLE documents RENAME COLUMN current_published_version TO current_published_major;
ALTER TABLE documents ADD COLUMN current_published_minor INTEGER;
UPDATE documents SET current_published_minor = 0 WHERE current_published_major IS NOT NULL;

View File

@@ -218,7 +218,8 @@ type (
DocumentData struct {
Title string
Content string
Version int
Major int
Minor int
Classification Classification
Approvers []string
Description string

View File

@@ -39,7 +39,7 @@ func TestRenderHTML(t *testing.T) {
data: DocumentData{
Title: "Test Document",
Content: "# Main Title\n\nThis is **bold** text with *italic* formatting.",
Version: 1,
Major: 1,
Classification: ClassificationPublic,
Approvers: []string{"John Doe"},
PublishedAt: &now,
@@ -57,7 +57,7 @@ func TestRenderHTML(t *testing.T) {
"<h1>Main Title</h1>",
"<strong>bold</strong>",
"<em>italic</em>",
"<td>1</td>",
"<td>1.0</td>",
"PUBLIC",
"John Doe",
"Alice Smith",
@@ -383,7 +383,7 @@ func BenchmarkGenerateHTML(b *testing.B) {
data := DocumentData{
Title: "Benchmark Document",
Content: "# Title\n\nThis is **bold** text with *italic* formatting.\n\n- Item 1\n- Item 2",
Version: 1,
Major: 1,
Classification: ClassificationPublic,
Approvers: []string{"John Doe"},
PublishedAt: &now,

View File

@@ -404,7 +404,7 @@
{{- end}}
<tr>
<td>Version:</td>
<td>{{.Version}}</td>
<td>{{.Major}}.{{.Minor}}</td>
</tr>
{{- if .PublishedAt}}
<tr>

View File

@@ -960,26 +960,13 @@ func (s *DocumentApprovalService) publishVersion(
return fmt.Errorf("cannot load document version: %w", err)
}
document := &coredata.Document{}
if err := document.LoadByID(ctx, tx, s.svc.scope, version.DocumentID); err != nil {
return fmt.Errorf("cannot load document: %w", err)
}
_, _, err := s.svc.Documents.publishMajorVersionInTx(
ctx,
tx,
version.DocumentID,
nil,
false,
)
now := time.Now()
document.CurrentPublishedVersion = &version.VersionNumber
document.UpdatedAt = now
version.Status = coredata.DocumentVersionStatusPublished
version.PublishedAt = &now
version.UpdatedAt = now
if err := document.Update(ctx, tx, s.svc.scope); err != nil {
return fmt.Errorf("cannot update document: %w", err)
}
if err := version.Update(ctx, tx, s.svc.scope); err != nil {
return fmt.Errorf("cannot update document version: %w", err)
}
return nil
return err
}

View File

@@ -107,7 +107,6 @@ type (
BulkPublishVersionsRequest struct {
DocumentIDs []gid.GID
PublishedBy gid.GID
Changelog string
}
)
@@ -337,11 +336,11 @@ func (s DocumentService) GenerateChangelog(
return fmt.Errorf("cannot load document: %w", err)
}
if document.CurrentPublishedVersion == nil {
if document.CurrentPublishedMajor == nil {
initialVersionChangelog := "Initial version"
changelog = &initialVersionChangelog
} else {
if err := publishedVersion.LoadByDocumentIDAndVersionNumber(ctx, conn, s.svc.scope, documentID, *document.CurrentPublishedVersion); err != nil {
if err := publishedVersion.LoadByDocumentIDAndVersion(ctx, conn, s.svc.scope, documentID, *document.CurrentPublishedMajor, *document.CurrentPublishedMinor); err != nil {
return fmt.Errorf("cannot load published version: %w", err)
}
}
@@ -369,7 +368,7 @@ func (s DocumentService) GenerateChangelog(
return changelog, nil
}
func (s *DocumentService) BulkPublishVersions(
func (s *DocumentService) BulkPublishMajorVersions(
ctx context.Context,
req BulkPublishVersionsRequest,
) ([]*coredata.DocumentVersion, []*coredata.Document, error) {
@@ -380,7 +379,7 @@ func (s *DocumentService) BulkPublishVersions(
ctx,
func(tx pg.Conn) error {
for _, documentID := range req.DocumentIDs {
document, version, err := s.publishVersionInTx(ctx, tx, documentID, req.PublishedBy, &req.Changelog, true)
document, version, err := s.publishMajorVersionInTx(ctx, tx, documentID, &req.Changelog, true)
if err != nil {
return fmt.Errorf("cannot publish document %q: %w", documentID, err)
}
@@ -400,7 +399,38 @@ func (s *DocumentService) BulkPublishVersions(
return publishedVersions, updatedDocuments, nil
}
func (s *DocumentService) PublishVersion(
func (s *DocumentService) BulkPublishMinorVersions(
ctx context.Context,
req BulkPublishVersionsRequest,
) ([]*coredata.DocumentVersion, []*coredata.Document, error) {
var publishedVersions []*coredata.DocumentVersion
var updatedDocuments []*coredata.Document
err := s.svc.pg.WithTx(
ctx,
func(tx pg.Conn) error {
for _, documentID := range req.DocumentIDs {
document, version, err := s.publishMinorVersionInTx(ctx, tx, documentID, &req.Changelog, true)
if err != nil {
return fmt.Errorf("cannot publish document %q: %w", documentID, err)
}
publishedVersions = append(publishedVersions, version)
updatedDocuments = append(updatedDocuments, document)
}
return nil
},
)
if err != nil {
return nil, nil, err
}
return publishedVersions, updatedDocuments, nil
}
func (s *DocumentService) PublishMajorVersion(
ctx context.Context,
documentID gid.GID,
publishedBy gid.GID,
@@ -414,9 +444,9 @@ func (s *DocumentService) PublishVersion(
func(tx pg.Conn) error {
var err error
document, documentVersion, err = s.publishVersionInTx(ctx, tx, documentID, publishedBy, changelog, false)
document, documentVersion, err = s.publishMajorVersionInTx(ctx, tx, documentID, changelog, false)
if err != nil {
return fmt.Errorf("cannot publish version: %w", err)
return fmt.Errorf("cannot publish major version: %w", err)
}
return nil
@@ -430,66 +460,31 @@ func (s *DocumentService) PublishVersion(
return document, documentVersion, nil
}
func (s *DocumentService) publishVersionInTx(
func (s *DocumentService) PublishMinorVersion(
ctx context.Context,
tx pg.Conn,
documentID gid.GID,
publishedBy gid.GID,
changelog *string,
ignoreExisting bool,
) (*coredata.Document, *coredata.DocumentVersion, error) {
document := &coredata.Document{}
documentVersion := &coredata.DocumentVersion{}
publishedVersion := &coredata.DocumentVersion{}
now := time.Now()
var document *coredata.Document
var documentVersion *coredata.DocumentVersion
if err := document.LoadByID(ctx, tx, s.svc.scope, documentID); err != nil {
return nil, nil, fmt.Errorf("cannot load document %q: %w", documentID, err)
}
err := s.svc.pg.WithTx(
ctx,
func(tx pg.Conn) error {
var err error
if document.ArchivedAt != nil {
return nil, nil, &ErrDocumentArchived{}
}
document, documentVersion, err = s.publishMinorVersionInTx(ctx, tx, documentID, changelog, false)
if err != nil {
return fmt.Errorf("cannot publish minor version: %w", err)
}
if err := documentVersion.LoadLatestVersion(ctx, tx, s.svc.scope, documentID); err != nil {
return nil, nil, fmt.Errorf("cannot load current draft: %w", err)
}
return nil
},
)
if ignoreExisting && documentVersion.Status == coredata.DocumentVersionStatusPublished {
return document, documentVersion, nil
}
if documentVersion.Status != coredata.DocumentVersionStatusDraft {
return nil, nil, fmt.Errorf("cannot publish version")
}
if document.CurrentPublishedVersion != nil {
if err := publishedVersion.LoadByDocumentIDAndVersionNumber(ctx, tx, s.svc.scope, documentID, *document.CurrentPublishedVersion); err != nil {
return nil, nil, fmt.Errorf("cannot load published version: %w", err)
}
if publishedVersion.Content == documentVersion.Content &&
publishedVersion.Title == documentVersion.Title {
return nil, nil, &ErrDocumentVersionNoChanges{}
}
}
if changelog != nil {
documentVersion.Changelog = *changelog
}
document.CurrentPublishedVersion = &documentVersion.VersionNumber
document.UpdatedAt = now
documentVersion.Status = coredata.DocumentVersionStatusPublished
documentVersion.PublishedAt = &now
documentVersion.UpdatedAt = now
if err := document.Update(ctx, tx, s.svc.scope); err != nil {
return nil, nil, fmt.Errorf("cannot update document: %w", err)
}
if err := documentVersion.Update(ctx, tx, s.svc.scope); err != nil {
return nil, nil, fmt.Errorf("cannot update document version: %w", err)
if err != nil {
return nil, nil, err
}
return document, documentVersion, nil
@@ -528,7 +523,8 @@ func (s *DocumentService) Create(
ID: documentVersionID,
DocumentID: documentID,
Title: req.Title,
VersionNumber: 1,
Major: 0,
Minor: 1,
Content: req.Content,
Status: coredata.DocumentVersionStatusDraft,
Classification: req.Classification,
@@ -1004,7 +1000,8 @@ func (s *DocumentService) CreateDraft(
draftVersion.OrganizationID = document.OrganizationID
draftVersion.DocumentID = documentID
draftVersion.Title = document.Title
draftVersion.VersionNumber = latestVersion.VersionNumber + 1
draftVersion.Major = latestVersion.Major
draftVersion.Minor = latestVersion.Minor + 1
draftVersion.Classification = document.Classification
draftVersion.Content = latestVersion.Content
draftVersion.Status = coredata.DocumentVersionStatusDraft
@@ -1043,7 +1040,7 @@ func (s *DocumentService) DeleteDraft(
return fmt.Errorf("cannot delete published document version")
}
if documentVersion.VersionNumber == 1 {
if documentVersion.Major == 0 && documentVersion.Minor == 1 {
return fmt.Errorf("cannot delete the first version of a document")
}
@@ -1961,7 +1958,8 @@ func exportDocumentPDF(
docData := docgen.DocumentData{
Title: version.Title,
Content: version.Content,
Version: version.VersionNumber,
Major: version.Major,
Minor: version.Minor,
Classification: classification,
Approvers: approverNames,
PublishedAt: version.PublishedAt,
@@ -2168,3 +2166,133 @@ func sanitizeFilename(title string) string {
return sanitized
}
func (s *DocumentService) loadDraftForPublish(
ctx context.Context,
tx pg.Conn,
documentID gid.GID,
ignoreExisting bool,
) (*coredata.Document, *coredata.DocumentVersion, error) {
document := &coredata.Document{}
documentVersion := &coredata.DocumentVersion{}
if err := document.LoadByID(ctx, tx, s.svc.scope, documentID); err != nil {
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.DocumentVersionStatusPublished {
return document, documentVersion, nil
}
if documentVersion.Status != coredata.DocumentVersionStatusDraft {
return nil, nil, &ErrDocumentVersionNotDraft{}
}
return document, documentVersion, nil
}
func (s *DocumentService) finalizePublish(
ctx context.Context,
tx pg.Conn,
document *coredata.Document,
documentVersion *coredata.DocumentVersion,
changelog *string,
) error {
now := time.Now()
if changelog != nil {
documentVersion.Changelog = *changelog
}
document.UpdatedAt = now
documentVersion.Status = coredata.DocumentVersionStatusPublished
documentVersion.PublishedAt = &now
documentVersion.UpdatedAt = now
if err := document.Update(ctx, tx, s.svc.scope); err != nil {
return fmt.Errorf("cannot update document: %w", err)
}
if err := documentVersion.Update(ctx, tx, s.svc.scope); err != nil {
return fmt.Errorf("cannot update document version: %w", err)
}
return nil
}
func (s *DocumentService) publishMajorVersionInTx(
ctx context.Context,
tx pg.Conn,
documentID gid.GID,
changelog *string,
ignoreExisting bool,
) (*coredata.Document, *coredata.DocumentVersion, error) {
document, documentVersion, err := s.loadDraftForPublish(ctx, tx, documentID, ignoreExisting)
if err != nil {
return nil, nil, err
}
if ignoreExisting && documentVersion.Status == coredata.DocumentVersionStatusPublished {
return document, documentVersion, nil
}
if document.CurrentPublishedMajor != nil {
publishedVersion := &coredata.DocumentVersion{}
if err := publishedVersion.LoadByDocumentIDAndVersion(ctx, tx, s.svc.scope, documentID, *document.CurrentPublishedMajor, *document.CurrentPublishedMinor); err != nil {
return nil, nil, fmt.Errorf("cannot load published version: %w", err)
}
if publishedVersion.Content == documentVersion.Content &&
publishedVersion.Title == documentVersion.Title {
return nil, nil, &ErrDocumentVersionNoChanges{}
}
documentVersion.Major = *document.CurrentPublishedMajor + 1
} else {
documentVersion.Major = 1
}
documentVersion.Minor = 0
document.CurrentPublishedMajor = &documentVersion.Major
document.CurrentPublishedMinor = &documentVersion.Minor
if err := s.finalizePublish(ctx, tx, document, documentVersion, changelog); err != nil {
return nil, nil, err
}
return document, documentVersion, nil
}
func (s *DocumentService) publishMinorVersionInTx(
ctx context.Context,
tx pg.Conn,
documentID gid.GID,
changelog *string,
ignoreExisting bool,
) (*coredata.Document, *coredata.DocumentVersion, error) {
document, documentVersion, err := s.loadDraftForPublish(ctx, tx, documentID, ignoreExisting)
if err != nil {
return nil, nil, err
}
if ignoreExisting && documentVersion.Status == coredata.DocumentVersionStatusPublished {
return document, documentVersion, nil
}
document.CurrentPublishedMajor = &documentVersion.Major
document.CurrentPublishedMinor = &documentVersion.Minor
if err := s.finalizePublish(ctx, tx, document, documentVersion, changelog); err != nil {
return nil, nil, err
}
return document, documentVersion, nil
}

View File

@@ -2441,7 +2441,8 @@ type Document implements Node {
description: String
documentType: DocumentType!
classification: DocumentClassification!
currentPublishedVersion: Int
currentPublishedMajor: Int
currentPublishedMinor: Int
trustCenterVisibility: TrustCenterVisibility!
organization: Organization! @goField(forceResolver: true)
@@ -2501,7 +2502,8 @@ type EmployeeDocumentVersion
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.EmployeeDocumentVersion"
) {
id: ID!
version: Int!
major: Int!
minor: Int!
status: DocumentVersionStatus!
signed: Boolean! @goField(forceResolver: true)
approvalDecision: DocumentVersionApprovalDecision @goField(forceResolver: true)
@@ -3792,10 +3794,16 @@ type Mutation {
exportStateOfApplicabilityPDF(
input: ExportStateOfApplicabilityPDFInput!
): ExportStateOfApplicabilityPDFPayload!
publishDocumentVersion(
input: PublishDocumentVersionInput!
publishMajorDocumentVersion(
input: PublishMajorDocumentVersionInput!
): PublishDocumentVersionPayload!
bulkPublishDocumentVersions(
publishMinorDocumentVersion(
input: PublishMinorDocumentVersionInput!
): PublishDocumentVersionPayload!
bulkPublishMajorDocumentVersions(
input: BulkPublishDocumentVersionsInput!
): BulkPublishDocumentVersionsPayload!
bulkPublishMinorDocumentVersions(
input: BulkPublishDocumentVersionsInput!
): BulkPublishDocumentVersionsPayload!
requestDocumentVersionApproval(
@@ -5355,7 +5363,8 @@ type DocumentVersion implements Node {
id: ID!
document: Document! @goField(forceResolver: true)
status: DocumentVersionStatus!
version: Int!
major: Int!
minor: Int!
content: String!
changelog: String!
title: String!
@@ -5686,7 +5695,12 @@ type RequestDocumentVersionApprovalPayload {
approvalQuorum: DocumentVersionApprovalQuorum!
}
input PublishDocumentVersionInput {
input PublishMajorDocumentVersionInput {
documentId: ID!
changelog: String
}
input PublishMinorDocumentVersionInput {
documentId: ID!
changelog: String
}

View File

@@ -80,13 +80,14 @@ func NewDocument(document *coredata.Document) *Document {
Organization: &Organization{
ID: document.OrganizationID,
},
DocumentType: document.DocumentType,
Classification: document.Classification,
CurrentPublishedVersion: document.CurrentPublishedVersion,
TrustCenterVisibility: document.TrustCenterVisibility,
Status: document.Status,
ArchivedAt: document.ArchivedAt,
CreatedAt: document.CreatedAt,
UpdatedAt: document.UpdatedAt,
DocumentType: document.DocumentType,
Classification: document.Classification,
CurrentPublishedMajor: document.CurrentPublishedMajor,
CurrentPublishedMinor: document.CurrentPublishedMinor,
TrustCenterVisibility: document.TrustCenterVisibility,
Status: document.Status,
ArchivedAt: document.ArchivedAt,
CreatedAt: document.CreatedAt,
UpdatedAt: document.UpdatedAt,
}
}

View File

@@ -76,7 +76,8 @@ func NewDocumentVersion(documentVersion *coredata.DocumentVersion) *DocumentVers
Document: &Document{
ID: documentVersion.DocumentID,
},
Version: documentVersion.VersionNumber,
Major: documentVersion.Major,
Minor: documentVersion.Minor,
Title: documentVersion.Title,
Content: documentVersion.Content,
Status: documentVersion.Status,

View File

@@ -66,7 +66,8 @@ type (
EmployeeDocumentVersion struct {
ID gid.GID
OrganizationID gid.GID
Version int
Major int
Minor int
Status coredata.DocumentVersionStatus
PublishedAt *time.Time
CreatedAt time.Time

View File

@@ -1647,7 +1647,8 @@ func (r *employeeDocumentResolver) Versions(ctx context.Context, obj *types.Empl
employeeVersions[i] = &types.EmployeeDocumentVersion{
ID: v.ID,
OrganizationID: v.OrganizationID,
Version: v.VersionNumber,
Major: v.Major,
Minor: v.Minor,
Status: v.Status,
PublishedAt: v.PublishedAt,
CreatedAt: v.CreatedAt,
@@ -5012,16 +5013,20 @@ func (r *mutationResolver) ExportStateOfApplicabilityPDF(ctx context.Context, in
}, nil
}
// PublishDocumentVersion is the resolver for the publishDocumentVersion field.
func (r *mutationResolver) PublishDocumentVersion(ctx context.Context, input types.PublishDocumentVersionInput) (*types.PublishDocumentVersionPayload, error) {
// PublishMajorDocumentVersion is the resolver for the publishMajorDocumentVersion field.
func (r *mutationResolver) PublishMajorDocumentVersion(ctx context.Context, input types.PublishMajorDocumentVersionInput) (*types.PublishDocumentVersionPayload, error) {
if err := r.authorize(ctx, input.DocumentID, probo.ActionDocumentVersionPublish); err != nil {
return nil, err
}
identity := authn.IdentityFromContext(ctx)
prb := r.ProboService(ctx, input.DocumentID.TenantID())
document, documentVersion, err := prb.Documents.PublishVersion(ctx, input.DocumentID, identity.ID, input.Changelog)
document, documentVersion, err := prb.Documents.PublishMajorVersion(
ctx,
input.DocumentID,
authn.IdentityFromContext(ctx).ID,
input.Changelog,
)
if err != nil {
if errArchived, ok := errors.AsType[*probo.ErrDocumentArchived](err); ok {
return nil, gqlutils.Conflict(ctx, errArchived)
@@ -5031,7 +5036,11 @@ func (r *mutationResolver) PublishDocumentVersion(ctx context.Context, input typ
return nil, gqlutils.Invalid(ctx, errNotDraft)
}
r.logger.ErrorCtx(ctx, "cannot publish document version", log.Error(err))
if errNoChanges, ok := errors.AsType[*probo.ErrDocumentVersionNoChanges](err); ok {
return nil, gqlutils.Invalid(ctx, errNoChanges)
}
r.logger.ErrorCtx(ctx, "cannot publish major document version", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
@@ -5041,8 +5050,41 @@ func (r *mutationResolver) PublishDocumentVersion(ctx context.Context, input typ
}, nil
}
// BulkPublishDocumentVersions is the resolver for the bulkPublishDocumentVersions field.
func (r *mutationResolver) BulkPublishDocumentVersions(ctx context.Context, input types.BulkPublishDocumentVersionsInput) (*types.BulkPublishDocumentVersionsPayload, error) {
// PublishMinorDocumentVersion is the resolver for the publishMinorDocumentVersion field.
func (r *mutationResolver) PublishMinorDocumentVersion(ctx context.Context, input types.PublishMinorDocumentVersionInput) (*types.PublishDocumentVersionPayload, error) {
if err := r.authorize(ctx, input.DocumentID, probo.ActionDocumentVersionPublish); err != nil {
return nil, err
}
prb := r.ProboService(ctx, input.DocumentID.TenantID())
document, documentVersion, err := prb.Documents.PublishMinorVersion(
ctx,
input.DocumentID,
authn.IdentityFromContext(ctx).ID,
input.Changelog,
)
if err != nil {
if errArchived, ok := errors.AsType[*probo.ErrDocumentArchived](err); ok {
return nil, gqlutils.Conflict(ctx, errArchived)
}
if errNotDraft, ok := errors.AsType[*probo.ErrDocumentVersionNotDraft](err); ok {
return nil, gqlutils.Invalid(ctx, errNotDraft)
}
r.logger.ErrorCtx(ctx, "cannot publish minor document version", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return &types.PublishDocumentVersionPayload{
Document: types.NewDocument(document),
DocumentVersion: types.NewDocumentVersion(documentVersion),
}, nil
}
// BulkPublishMajorDocumentVersions is the resolver for the bulkPublishMajorDocumentVersions field.
func (r *mutationResolver) BulkPublishMajorDocumentVersions(ctx context.Context, input types.BulkPublishDocumentVersionsInput) (*types.BulkPublishDocumentVersionsPayload, error) {
if len(input.DocumentIds) == 0 {
return &types.BulkPublishDocumentVersionsPayload{
DocumentVersions: []*types.DocumentVersion{},
@@ -5056,12 +5098,10 @@ func (r *mutationResolver) BulkPublishDocumentVersions(ctx context.Context, inpu
}
}
identity := authn.IdentityFromContext(ctx)
prb := r.ProboService(ctx, input.DocumentIds[0].TenantID())
versions, documents, err := prb.Documents.BulkPublishVersions(ctx, probo.BulkPublishVersionsRequest{
versions, documents, err := prb.Documents.BulkPublishMajorVersions(ctx, probo.BulkPublishVersionsRequest{
DocumentIDs: input.DocumentIds,
PublishedBy: identity.ID,
Changelog: input.Changelog,
})
if err != nil {
@@ -5073,7 +5113,57 @@ func (r *mutationResolver) BulkPublishDocumentVersions(ctx context.Context, inpu
return nil, gqlutils.Invalid(ctx, errNotDraft)
}
r.logger.ErrorCtx(ctx, "cannot bulk publish document versions", log.Error(err))
r.logger.ErrorCtx(ctx, "cannot bulk publish major document versions", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
typesVersions := make([]*types.DocumentVersion, len(versions))
for i, v := range versions {
typesVersions[i] = types.NewDocumentVersion(v)
}
typesDocuments := make([]*types.Document, len(documents))
for i, d := range documents {
typesDocuments[i] = types.NewDocument(d)
}
return &types.BulkPublishDocumentVersionsPayload{
DocumentVersions: typesVersions,
Documents: typesDocuments,
}, nil
}
// BulkPublishMinorDocumentVersions is the resolver for the bulkPublishMinorDocumentVersions field.
func (r *mutationResolver) BulkPublishMinorDocumentVersions(ctx context.Context, input types.BulkPublishDocumentVersionsInput) (*types.BulkPublishDocumentVersionsPayload, error) {
if len(input.DocumentIds) == 0 {
return &types.BulkPublishDocumentVersionsPayload{
DocumentVersions: []*types.DocumentVersion{},
Documents: []*types.Document{},
}, nil
}
for _, documentID := range input.DocumentIds {
if err := r.authorize(ctx, documentID, probo.ActionDocumentVersionPublish); err != nil {
return nil, err
}
}
prb := r.ProboService(ctx, input.DocumentIds[0].TenantID())
versions, documents, err := prb.Documents.BulkPublishMinorVersions(ctx, probo.BulkPublishVersionsRequest{
DocumentIDs: input.DocumentIds,
Changelog: input.Changelog,
})
if err != nil {
if errArchived, ok := errors.AsType[*probo.ErrDocumentArchived](err); ok {
return nil, gqlutils.Conflict(ctx, errArchived)
}
if errNotDraft, ok := errors.AsType[*probo.ErrDocumentVersionNotDraft](err); ok {
return nil, gqlutils.Invalid(ctx, errNotDraft)
}
r.logger.ErrorCtx(ctx, "cannot bulk publish minor document versions", log.Error(err))
return nil, gqlutils.Internal(ctx)
}

View File

@@ -2196,24 +2196,6 @@ func (r *Resolver) UpdateDocumentVersionTool(ctx context.Context, req *mcp.CallT
}, nil
}
func (r *Resolver) PublishDocumentVersionTool(ctx context.Context, req *mcp.CallToolRequest, input *types.PublishDocumentVersionInput) (*mcp.CallToolResult, types.PublishDocumentVersionOutput, error) {
r.MustAuthorize(ctx, input.DocumentID, probo.ActionDocumentVersionPublish)
svc := r.ProboService(ctx, input.DocumentID)
user := authn.IdentityFromContext(ctx)
document, documentVersion, err := svc.Documents.PublishVersion(ctx, input.DocumentID, user.ID, input.Changelog)
if err != nil {
panic(fmt.Errorf("cannot publish document version: %w", err))
}
return nil, types.PublishDocumentVersionOutput{
Document: types.NewDocument(document),
DocumentVersion: types.NewDocumentVersion(documentVersion),
}, nil
}
func (r *Resolver) ListDocumentVersionSignaturesTool(ctx context.Context, req *mcp.CallToolRequest, input *types.ListDocumentVersionSignaturesInput) (*mcp.CallToolResult, types.ListDocumentVersionSignaturesOutput, error) {
r.MustAuthorize(ctx, input.DocumentVersionID, probo.ActionDocumentVersionSignatureList)
@@ -3324,3 +3306,47 @@ func (r *Resolver) RequestDocumentVersionApprovalTool(ctx context.Context, req *
DocumentVersion: types.NewDocumentVersion(documentVersion),
}, nil
}
func (r *Resolver) PublishMajorDocumentVersionTool(ctx context.Context, req *mcp.CallToolRequest, input *types.PublishMajorDocumentVersionInput) (*mcp.CallToolResult, types.PublishMajorDocumentVersionOutput, error) {
r.MustAuthorize(ctx, input.DocumentID, probo.ActionDocumentVersionPublish)
svc := r.ProboService(ctx, input.DocumentID)
user := authn.IdentityFromContext(ctx)
document, documentVersion, err := svc.Documents.PublishMajorVersion(
ctx,
input.DocumentID,
user.ID,
input.Changelog,
)
if err != nil {
panic(fmt.Errorf("cannot publish major document version: %w", err))
}
return nil, types.PublishMajorDocumentVersionOutput{
Document: types.NewDocument(document),
DocumentVersion: types.NewDocumentVersion(documentVersion),
}, nil
}
func (r *Resolver) PublishMinorDocumentVersionTool(ctx context.Context, req *mcp.CallToolRequest, input *types.PublishMinorDocumentVersionInput) (*mcp.CallToolResult, types.PublishMinorDocumentVersionOutput, error) {
r.MustAuthorize(ctx, input.DocumentID, probo.ActionDocumentVersionPublish)
svc := r.ProboService(ctx, input.DocumentID)
user := authn.IdentityFromContext(ctx)
document, documentVersion, err := svc.Documents.PublishMinorVersion(
ctx,
input.DocumentID,
user.ID,
input.Changelog,
)
if err != nil {
panic(fmt.Errorf("cannot publish minor document version: %w", err))
}
return nil, types.PublishMinorDocumentVersionOutput{
Document: types.NewDocument(document),
DocumentVersion: types.NewDocumentVersion(documentVersion),
}, nil
}

View File

@@ -5207,11 +5207,16 @@ components:
classification:
$ref: "#/components/schemas/DocumentClassification"
description: Document classification
current_published_version:
current_published_major:
type:
- integer
- "null"
description: Current published version number
description: Current published major version number
current_published_minor:
type:
- integer
- "null"
description: Current published minor version number
trust_center_visibility:
$ref: "#/components/schemas/TrustCenterVisibility"
description: Trust center visibility
@@ -5240,7 +5245,8 @@ components:
- organization_id
- document_id
- title
- version_number
- major
- minor
- classification
- content
- changelog
@@ -5260,9 +5266,12 @@ components:
title:
type: string
description: Document version title
version_number:
major:
type: integer
description: Version number
description: Major version number
minor:
type: integer
description: Minor version number
classification:
$ref: "#/components/schemas/DocumentClassification"
description: Document classification
@@ -5606,7 +5615,19 @@ components:
$ref: "#/components/schemas/GID"
description: Deleted document version ID
PublishDocumentVersionInput:
PublishMajorDocumentVersionInput:
type: object
required:
- document_id
properties:
document_id:
$ref: "#/components/schemas/GID"
description: Document ID
changelog:
type: string
description: Changelog for this version
PublishMinorDocumentVersionInput:
type: object
required:
- document_id
@@ -7471,12 +7492,20 @@ tools:
$ref: "#/components/schemas/DeleteDraftDocumentVersionInput"
outputSchema:
$ref: "#/components/schemas/DeleteDraftDocumentVersionOutput"
- name: publishDocumentVersion
description: Publish a draft document version
- name: publishMajorDocumentVersion
description: Publish a draft document version as a new major version
hints:
readonly: false
inputSchema:
$ref: "#/components/schemas/PublishDocumentVersionInput"
$ref: "#/components/schemas/PublishMajorDocumentVersionInput"
outputSchema:
$ref: "#/components/schemas/PublishDocumentVersionOutput"
- name: publishMinorDocumentVersion
description: Publish a draft document version as a minor version
hints:
readonly: false
inputSchema:
$ref: "#/components/schemas/PublishMinorDocumentVersionInput"
outputSchema:
$ref: "#/components/schemas/PublishDocumentVersionOutput"
- name: requestDocumentVersionApproval

View File

@@ -21,17 +21,18 @@ import (
func NewDocument(d *coredata.Document) *Document {
return &Document{
ID: d.ID,
OrganizationID: d.OrganizationID,
Title: d.Title,
DocumentType: d.DocumentType,
Classification: d.Classification,
CurrentPublishedVersion: d.CurrentPublishedVersion,
TrustCenterVisibility: d.TrustCenterVisibility,
Status: d.Status,
ArchivedAt: d.ArchivedAt,
CreatedAt: d.CreatedAt,
UpdatedAt: d.UpdatedAt,
ID: d.ID,
OrganizationID: d.OrganizationID,
Title: d.Title,
DocumentType: d.DocumentType,
Classification: d.Classification,
CurrentPublishedMajor: d.CurrentPublishedMajor,
CurrentPublishedMinor: d.CurrentPublishedMinor,
TrustCenterVisibility: d.TrustCenterVisibility,
Status: d.Status,
ArchivedAt: d.ArchivedAt,
CreatedAt: d.CreatedAt,
UpdatedAt: d.UpdatedAt,
}
}
@@ -84,7 +85,8 @@ func NewDocumentVersion(dv *coredata.DocumentVersion) *DocumentVersion {
OrganizationID: dv.OrganizationID,
DocumentID: dv.DocumentID,
Title: dv.Title,
VersionNumber: dv.VersionNumber,
Major: dv.Major,
Minor: dv.Minor,
Classification: dv.Classification,
Content: dv.Content,
Changelog: dv.Changelog,

View File

@@ -244,7 +244,8 @@ func (s *DocumentService) exportPDFData(
docData := docgen.DocumentData{
Title: version.Title,
Content: version.Content,
Version: version.VersionNumber,
Major: version.Major,
Minor: version.Minor,
Classification: classification,
Approvers: approverNames,
PublishedAt: version.PublishedAt,