Move document title ownership from document to version

Title is now owned by document_versions, following the same pattern as
classification and document_type. The documents.title column is made
nullable with a TODO to drop it. Backend loads title from a
latest_versions CTE for ordering purposes only. The frontend resolves
title from the latest version, and DocumentTitleForm now operates on
DocumentVersion using UpdateDocumentVersion mutation.

Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
Sacha Al Himdani
2026-04-07 18:45:46 +02:00
parent ba386d188b
commit b63f043849
20 changed files with 329 additions and 333 deletions

View File

@@ -43,11 +43,11 @@ import { LinkedDocumentDialog } from "./LinkedDocumentsDialog";
const linkedDocumentFragment = graphql` const linkedDocumentFragment = graphql`
fragment LinkedDocumentsCardFragment on Document { fragment LinkedDocumentsCardFragment on Document {
id id
title
versions(first: 1) { versions(first: 1) {
edges { edges {
node { node {
id id
title
documentType documentType
status status
} }
@@ -217,7 +217,7 @@ function DocumentRow(props: {
height={36} height={36}
className="border-4 border-highlight rounded box-content" className="border-4 border-highlight rounded box-content"
/> />
{document.title} {document.versions.edges[0].node.title}
</div> </div>
</Td> </Td>
<Td> <Td>

View File

@@ -71,10 +71,10 @@ const documentsFragment = graphql`
edges { edges {
node { node {
id id
title
versions(first: 1, orderBy: { field: CREATED_AT, direction: DESC }) { versions(first: 1, orderBy: { field: CREATED_AT, direction: DESC }) {
edges { edges {
node { node {
title
documentType documentType
} }
} }
@@ -135,7 +135,7 @@ function LinkedDocumentsDialogContent(props: Omit<Props, "children">) {
const filteredDocuments = useMemo(() => { const filteredDocuments = useMemo(() => {
return documents.filter(document => return documents.filter(document =>
document.title.toLowerCase().includes(search.toLowerCase()), document.versions.edges[0].node.title.toLowerCase().includes(search.toLowerCase()),
); );
}, [documents, search]); }, [documents, search]);
@@ -192,7 +192,7 @@ function DocumentRow(props: RowProps) {
className="py-4 flex items-center gap-4 hover:bg-subtle cursor-pointer px-6 w-full h-[100px]" className="py-4 flex items-center gap-4 hover:bg-subtle cursor-pointer px-6 w-full h-[100px]"
onClick={() => onClick(props.document.id)} onClick={() => onClick(props.document.id)}
> >
{props.document.title} {props.document.versions.edges[0].node.title}
<DocumentTypeBadge type={props.document.versions.edges[0].node.documentType} /> <DocumentTypeBadge type={props.document.versions.edges[0].node.documentType} />
<Button <Button
disabled={props.disabled} disabled={props.disabled}

View File

@@ -44,10 +44,10 @@ const documentAccessFragment = graphql`
status status
document { document {
id id
title
versions(first: 1, orderBy: { field: CREATED_AT, direction: DESC }) { versions(first: 1, orderBy: { field: CREATED_AT, direction: DESC }) {
edges { edges {
node { node {
title
documentType documentType
} }
} }
@@ -87,7 +87,7 @@ function toDocumentAccessInfo(
return { return {
persisted: node.id !== node.document.id, persisted: node.id !== node.document.id,
variant: "info", variant: "info",
name: node.document.title, name: node.document.versions?.edges[0]?.node.title ?? "",
type: "document", type: "document",
typeLabel: __("Document"), typeLabel: __("Document"),
category: node.document.versions?.edges[0]?.node.documentType ?? "", category: node.document.versions?.edges[0]?.node.documentType ?? "",

View File

@@ -35,7 +35,9 @@ export const documentLayoutQuery = graphql`
__typename __typename
... on DocumentVersion { ... on DocumentVersion {
id id
title
status status
...DocumentTitleFormFragment
...DocumentActionsDropdown_versionFragment ...DocumentActionsDropdown_versionFragment
...DocumentLayoutDrawer_versionFragment ...DocumentLayoutDrawer_versionFragment
signatures(first: 0 filter: { activeContract: true }) { signatures(first: 0 filter: { activeContract: true }) {
@@ -64,14 +66,12 @@ export const documentLayoutQuery = graphql`
__typename __typename
... on Document { ... on Document {
id id
title
status status
canPublish: permission(action: "core:document-version:publish") canPublish: permission(action: "core:document-version:publish")
...PublishDialog_documentFragment ...PublishDialog_documentFragment
controlInfo: controls(first: 0) { controlInfo: controls(first: 0) {
totalCount totalCount
} }
...DocumentTitleFormFragment
...DocumentActionsDropdown_documentFragment ...DocumentActionsDropdown_documentFragment
...DocumentLayoutDrawer_documentFragment ...DocumentLayoutDrawer_documentFragment
# We use this on /documents/:documentId # We use this on /documents/:documentId
@@ -79,7 +79,9 @@ export const documentLayoutQuery = graphql`
edges { edges {
node { node {
id id
title
status status
...DocumentTitleFormFragment
...DocumentActionsDropdown_versionFragment ...DocumentActionsDropdown_versionFragment
...DocumentLayoutDrawer_versionFragment ...DocumentLayoutDrawer_versionFragment
signatures(first: 0 filter: { activeContract: true }) { signatures(first: 0 filter: { activeContract: true }) {
@@ -158,7 +160,7 @@ export function DocumentLayout(props: { queryRef: PreloadedQuery<DocumentLayoutQ
to: `/organizations/${organizationId}/documents`, to: `/organizations/${organizationId}/documents`,
}, },
{ {
label: document.title, label: currentVersion.title,
}, },
]} ]}
/> />
@@ -182,7 +184,7 @@ export function DocumentLayout(props: { queryRef: PreloadedQuery<DocumentLayoutQ
</div> </div>
<PageHeader <PageHeader
title={<DocumentTitleForm fKey={document} />} title={<DocumentTitleForm fKey={currentVersion} />}
/> />
<Tabs> <Tabs>

View File

@@ -34,7 +34,6 @@ import { CurrentUser } from "#/providers/CurrentUser";
const documentFragment = graphql` const documentFragment = graphql`
fragment DocumentActionsDropdown_documentFragment on Document { fragment DocumentActionsDropdown_documentFragment on Document {
id id
title
status status
canUpdate: permission(action: "core:document:update") canUpdate: permission(action: "core:document:update")
canArchive: permission(action: "core:document:archive") canArchive: permission(action: "core:document:archive")
@@ -45,6 +44,7 @@ const documentFragment = graphql`
edges { edges {
node { node {
id id
title
status status
} }
} }
@@ -120,6 +120,7 @@ const unarchiveDocumentMutation = graphql`
const versionFragment = graphql` const versionFragment = graphql`
fragment DocumentActionsDropdown_versionFragment on DocumentVersion { fragment DocumentActionsDropdown_versionFragment on DocumentVersion {
id id
title
major major
minor minor
status status
@@ -226,7 +227,7 @@ export function DocumentActionsDropdown(props: {
{ {
message: sprintf( message: sprintf(
__("This will archive the document \"%s\". It will no longer be editable."), __("This will archive the document \"%s\". It will no longer be editable."),
document.title, lastVersion.title,
), ),
variant: "danger", variant: "danger",
label: __("Archive"), label: __("Archive"),
@@ -272,7 +273,7 @@ export function DocumentActionsDropdown(props: {
__( __(
"This will permanently delete the document \"%s\". This action cannot be undone.", "This will permanently delete the document \"%s\". This action cannot be undone.",
), ),
document.title, lastVersion.title,
), ),
}, },
); );
@@ -306,7 +307,7 @@ export function DocumentActionsDropdown(props: {
"This will permanently delete the draft version %s of \"%s\". This action cannot be undone.", "This will permanently delete the draft version %s of \"%s\". This action cannot be undone.",
), ),
`${version.major}.${version.minor}`, `${version.major}.${version.minor}`,
document.title, lastVersion.title,
), ),
}, },
); );
@@ -340,7 +341,7 @@ export function DocumentActionsDropdown(props: {
if (data.exportDocumentVersionPDF) { if (data.exportDocumentVersionPDF) {
const link = window.document.createElement("a"); const link = window.document.createElement("a");
link.href = data.exportDocumentVersionPDF.data; link.href = data.exportDocumentVersionPDF.data;
link.download = `${document.title}-v${version.major}.${version.minor}.pdf`; link.download = `${version.title}-v${version.major}.${version.minor}.pdf`;
window.document.body.appendChild(link); window.document.body.appendChild(link);
link.click(); link.click();
window.document.body.removeChild(link); window.document.body.removeChild(link);

View File

@@ -25,7 +25,6 @@ import { useOrganizationId } from "#/hooks/useOrganizationId";
const fragment = graphql` const fragment = graphql`
fragment DocumentListItemFragment on Document { fragment DocumentListItemFragment on Document {
id id
title
updatedAt updatedAt
canDelete: permission(action: "core:document:delete") canDelete: permission(action: "core:document:delete")
defaultApprovers { defaultApprovers {
@@ -36,6 +35,7 @@ const fragment = graphql`
edges { edges {
node { node {
id id
title
status status
major major
minor minor
@@ -135,7 +135,7 @@ export function DocumentListItem(props: {
__( __(
"This will permanently delete the document \"%s\". This action cannot be undone.", "This will permanently delete the document \"%s\". This action cannot be undone.",
), ),
document.title, lastVersion.title,
), ),
}, },
); );
@@ -149,7 +149,7 @@ export function DocumentListItem(props: {
<Checkbox checked={checked} onChange={onCheck} /> <Checkbox checked={checked} onChange={onCheck} />
</Td> </Td>
<Td className="min-w-0"> <Td className="min-w-0">
<div className="flex gap-4 items-center">{document.title}</div> <div className="flex gap-4 items-center">{lastVersion.title}</div>
</Td> </Td>
<Td className="w-24"> <Td className="w-24">
<Badge variant={statusVariant[lastVersion.status]}> <Badge variant={statusVariant[lastVersion.status]}>

View File

@@ -12,22 +12,22 @@
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR // OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE. // PERFORMANCE OF THIS SOFTWARE.
import { formatError } from "@probo/helpers";
import { useTranslate } from "@probo/i18n"; import { useTranslate } from "@probo/i18n";
import { Button, IconCheckmark1, IconCrossLargeX, IconPencil, Input } from "@probo/ui"; import { Button, IconCheckmark1, IconCrossLargeX, IconPencil, Input, useToast } from "@probo/ui";
import { useState } from "react"; import { useState } from "react";
import { useFragment } from "react-relay"; import { useFragment, useMutation } from "react-relay";
import { graphql } from "relay-runtime"; import { graphql } from "relay-runtime";
import { z } from "zod"; import { z } from "zod";
import type { DocumentTitleFormFragment$key } from "#/__generated__/core/DocumentTitleFormFragment.graphql"; import type { DocumentTitleFormFragment$key } from "#/__generated__/core/DocumentTitleFormFragment.graphql";
import type { DocumentTitleFormMutation } from "#/__generated__/core/DocumentTitleFormMutation.graphql"; import type { DocumentTitleFormMutation } from "#/__generated__/core/DocumentTitleFormMutation.graphql";
import { useFormWithSchema } from "#/hooks/useFormWithSchema"; import { useFormWithSchema } from "#/hooks/useFormWithSchema";
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
const updateDocumentTitleMutation = graphql` const updateDocumentVersionTitleMutation = graphql`
mutation DocumentTitleFormMutation($input: UpdateDocumentInput!) { mutation DocumentTitleFormMutation($input: UpdateDocumentVersionInput!) {
updateDocument(input: $input) { updateDocumentVersion(input: $input) {
document { documentVersion {
...DocumentTitleFormFragment ...DocumentTitleFormFragment
} }
} }
@@ -35,10 +35,11 @@ const updateDocumentTitleMutation = graphql`
`; `;
const fragment = graphql` const fragment = graphql`
fragment DocumentTitleFormFragment on Document { fragment DocumentTitleFormFragment on DocumentVersion {
id id
title title
canUpdate: permission(action: "core:document:update") status
canUpdate: permission(action: "core:document-version:update")
} }
`; `;
@@ -50,38 +51,40 @@ export function DocumentTitleForm(props: { fKey: DocumentTitleFormFragment$key }
const { fKey } = props; const { fKey } = props;
const { __ } = useTranslate(); const { __ } = useTranslate();
const { toast } = useToast();
const document = useFragment<DocumentTitleFormFragment$key>(fragment, fKey); const version = useFragment<DocumentTitleFormFragment$key>(fragment, fKey);
const [updateDocument, isUpdatingDocument] const [updateDocumentVersion, isUpdating]
= useMutationWithToasts<DocumentTitleFormMutation>( = useMutation<DocumentTitleFormMutation>(updateDocumentVersionTitleMutation);
updateDocumentTitleMutation,
{
successMessage: __("Document updated successfully."),
errorMessage: __("Failed to update document"),
},
);
const [isEditingTitle, setIsEditingTitle] = useState(false); const [isEditingTitle, setIsEditingTitle] = useState(false);
const { register, handleSubmit, reset } = useFormWithSchema( const { register, handleSubmit, reset } = useFormWithSchema(
schema, schema,
{ {
defaultValues: { defaultValues: {
title: document.title, title: version.title,
}, },
}, },
); );
const handleUpdateTitle = async (data: { title: string }) => { const handleUpdateTitle = (data: { title: string }) => {
await updateDocument({ updateDocumentVersion({
variables: { variables: {
input: { input: {
id: document.id, documentVersionId: version.id,
title: data.title, title: data.title,
}, },
}, },
onSuccess: () => { onCompleted(_, errors) {
if (errors?.length) {
toast({ title: __("Error"), description: formatError(__("Failed to update document"), errors), variant: "error" });
return;
}
setIsEditingTitle(false); setIsEditingTitle(false);
}, },
onError(error) {
toast({ title: __("Error"), description: error.message, variant: "error" });
},
}); });
}; };
@@ -107,7 +110,7 @@ export function DocumentTitleForm(props: { fKey: DocumentTitleFormFragment$key }
variant="quaternary" variant="quaternary"
icon={IconCheckmark1} icon={IconCheckmark1}
onClick={() => void handleSubmit(handleUpdateTitle)()} onClick={() => void handleSubmit(handleUpdateTitle)()}
disabled={isUpdatingDocument} disabled={isUpdating}
/> />
<Button <Button
variant="quaternary" variant="quaternary"
@@ -121,8 +124,8 @@ export function DocumentTitleForm(props: { fKey: DocumentTitleFormFragment$key }
) )
: ( : (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span>{document.title}</span> <span>{version.title}</span>
{document.canUpdate && ( {version.canUpdate && version.status === "DRAFT" && (
<Button <Button
variant="quaternary" variant="quaternary"
icon={IconPencil} icon={IconPencil}

View File

@@ -45,7 +45,7 @@ func TestDocument_Create(t *testing.T) {
"documentType": "POLICY", "documentType": "POLICY",
"classification": "INTERNAL", "classification": "INTERNAL",
}, },
assertField: "title", assertField: "versionTitle",
assertValue: "Security Policy", assertValue: "Security Policy",
}, },
{ {
@@ -102,12 +102,12 @@ func TestDocument_Create(t *testing.T) {
documentEdge { documentEdge {
node { node {
id id
title
} }
} }
documentVersionEdge { documentVersionEdge {
node { node {
id id
title
documentType documentType
} }
} }
@@ -124,13 +124,13 @@ func TestDocument_Create(t *testing.T) {
CreateDocument struct { CreateDocument struct {
DocumentEdge struct { DocumentEdge struct {
Node struct { Node struct {
ID string `json:"id"` ID string `json:"id"`
Title string `json:"title"`
} `json:"node"` } `json:"node"`
} `json:"documentEdge"` } `json:"documentEdge"`
DocumentVersionEdge struct { DocumentVersionEdge struct {
Node struct { Node struct {
ID string `json:"id"` ID string `json:"id"`
Title string `json:"title"`
DocumentType string `json:"documentType"` DocumentType string `json:"documentType"`
} `json:"node"` } `json:"node"`
} `json:"documentVersionEdge"` } `json:"documentVersionEdge"`
@@ -146,8 +146,8 @@ func TestDocument_Create(t *testing.T) {
versionNode := result.CreateDocument.DocumentVersionEdge.Node versionNode := result.CreateDocument.DocumentVersionEdge.Node
switch tt.assertField { switch tt.assertField {
case "title": case "versionTitle":
assert.Equal(t, tt.assertValue, node.Title) assert.Equal(t, tt.assertValue, versionNode.Title)
case "documentType": case "documentType":
assert.Equal(t, tt.assertValue, versionNode.DocumentType) assert.Equal(t, tt.assertValue, versionNode.DocumentType)
} }
@@ -290,18 +290,19 @@ func TestDocument_Update(t *testing.T) {
owner := testutil.NewClient(t, testutil.RoleOwner) owner := testutil.NewClient(t, testutil.RoleOwner)
t.Run( t.Run(
"update title", "update title via document version",
func(t *testing.T) { func(t *testing.T) {
t.Parallel() t.Parallel()
documentID := factory.NewDocument(owner). doc := factory.NewDocument(owner).
WithTitle("Document to Update"). WithTitle("Document to Update")
Create() doc.Create()
versionID := doc.VersionID()
query := ` query := `
mutation UpdateDocument($input: UpdateDocumentInput!) { mutation UpdateDocumentVersion($input: UpdateDocumentVersionInput!) {
updateDocument(input: $input) { updateDocumentVersion(input: $input) {
document { documentVersion {
id id
title title
} }
@@ -310,31 +311,33 @@ func TestDocument_Update(t *testing.T) {
` `
var result struct { var result struct {
UpdateDocument struct { UpdateDocumentVersion struct {
Document struct { DocumentVersion struct {
ID string `json:"id"` ID string `json:"id"`
Title string `json:"title"` Title string `json:"title"`
} `json:"document"` } `json:"documentVersion"`
} `json:"updateDocument"` } `json:"updateDocumentVersion"`
} }
err := owner.Execute(query, map[string]any{ err := owner.Execute(query, map[string]any{
"input": map[string]any{ "input": map[string]any{
"id": documentID, "documentVersionId": versionID,
"title": "Updated Document Title", "title": "Updated Document Title",
}, },
}, &result) }, &result)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, "Updated Document Title", result.UpdateDocument.Document.Title) assert.Equal(t, "Updated Document Title", result.UpdateDocumentVersion.DocumentVersion.Title)
}, },
) )
} }
func TestDocument_Update_Validation(t *testing.T) { func TestDocumentVersion_Update_TitleValidation(t *testing.T) {
t.Parallel() t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner) owner := testutil.NewClient(t, testutil.RoleOwner)
baseDocumentID := factory.NewDocument(owner).WithTitle("Validation Test Document").Create() doc := factory.NewDocument(owner).WithTitle("Validation Test Document")
doc.Create()
baseVersionID := doc.VersionID()
tests := []struct { tests := []struct {
name string name string
@@ -342,51 +345,43 @@ func TestDocument_Update_Validation(t *testing.T) {
input func(id string) map[string]any input func(id string) map[string]any
wantErrorContains string wantErrorContains string
}{ }{
{
name: "invalid ID format",
setup: func() string { return "invalid-id-format" },
input: func(id string) map[string]any {
return map[string]any{"id": id, "title": "Test"}
},
wantErrorContains: "base64",
},
{ {
name: "title with HTML tags", name: "title with HTML tags",
setup: func() string { return baseDocumentID }, setup: func() string { return baseVersionID },
input: func(id string) map[string]any { input: func(id string) map[string]any {
return map[string]any{"id": id, "title": "<script>alert('xss')</script>"} return map[string]any{"documentVersionId": id, "title": "<script>alert('xss')</script>"}
}, },
wantErrorContains: "HTML", wantErrorContains: "HTML",
}, },
{ {
name: "title with newline", name: "title with newline",
setup: func() string { return baseDocumentID }, setup: func() string { return baseVersionID },
input: func(id string) map[string]any { input: func(id string) map[string]any {
return map[string]any{"id": id, "title": "Test\nDocument"} return map[string]any{"documentVersionId": id, "title": "Test\nDocument"}
}, },
wantErrorContains: "newline", wantErrorContains: "newline",
}, },
{ {
name: "title with carriage return", name: "title with carriage return",
setup: func() string { return baseDocumentID }, setup: func() string { return baseVersionID },
input: func(id string) map[string]any { input: func(id string) map[string]any {
return map[string]any{"id": id, "title": "Test\rDocument"} return map[string]any{"documentVersionId": id, "title": "Test\rDocument"}
}, },
wantErrorContains: "carriage return", wantErrorContains: "carriage return",
}, },
{ {
name: "title with null byte", name: "title with null byte",
setup: func() string { return baseDocumentID }, setup: func() string { return baseVersionID },
input: func(id string) map[string]any { input: func(id string) map[string]any {
return map[string]any{"id": id, "title": "Test\x00Document"} return map[string]any{"documentVersionId": id, "title": "Test\x00Document"}
}, },
wantErrorContains: "control character", wantErrorContains: "control character",
}, },
{ {
name: "title with zero-width space", name: "title with zero-width space",
setup: func() string { return baseDocumentID }, setup: func() string { return baseVersionID },
input: func(id string) map[string]any { input: func(id string) map[string]any {
return map[string]any{"id": id, "title": "Test\u200BDocument"} return map[string]any{"documentVersionId": id, "title": "Test\u200BDocument"}
}, },
wantErrorContains: "zero-width", wantErrorContains: "zero-width",
}, },
@@ -394,19 +389,19 @@ func TestDocument_Update_Validation(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
documentID := tt.setup() versionID := tt.setup()
query := ` query := `
mutation UpdateDocument($input: UpdateDocumentInput!) { mutation UpdateDocumentVersion($input: UpdateDocumentVersionInput!) {
updateDocument(input: $input) { updateDocumentVersion(input: $input) {
document { documentVersion {
id id
} }
} }
} }
` `
_, err := owner.Do(query, map[string]any{"input": tt.input(documentID)}) _, err := owner.Do(query, map[string]any{"input": tt.input(versionID)})
require.Error(t, err) require.Error(t, err)
assert.Contains(t, err.Error(), tt.wantErrorContains) assert.Contains(t, err.Error(), tt.wantErrorContains)
}) })
@@ -494,7 +489,6 @@ func TestDocument_List(t *testing.T) {
edges { edges {
node { node {
id id
title
} }
} }
totalCount totalCount
@@ -509,8 +503,7 @@ func TestDocument_List(t *testing.T) {
Documents struct { Documents struct {
Edges []struct { Edges []struct {
Node struct { Node struct {
ID string `json:"id"` ID string `json:"id"`
Title string `json:"title"`
} `json:"node"` } `json:"node"`
} `json:"edges"` } `json:"edges"`
TotalCount int `json:"totalCount"` TotalCount int `json:"totalCount"`
@@ -535,7 +528,6 @@ func TestDocument_Query(t *testing.T) {
node(id: $id) { node(id: $id) {
... on Document { ... on Document {
id id
title
} }
} }
} }
@@ -648,8 +640,8 @@ func TestDocument_Timestamps(t *testing.T) {
err = owner.Execute(updateQuery, map[string]any{ err = owner.Execute(updateQuery, map[string]any{
"input": map[string]any{ "input": map[string]any{
"id": documentID, "id": documentID,
"title": "Updated Timestamp Test", "trustCenterVisibility": "PRIVATE",
}, },
}, &updateResult) }, &updateResult)
require.NoError(t, err) require.NoError(t, err)
@@ -781,8 +773,8 @@ func TestDocument_RBAC(t *testing.T) {
} }
`, map[string]any{ `, map[string]any{
"input": map[string]any{ "input": map[string]any{
"id": documentID, "id": documentID,
"title": "Updated by Owner", "trustCenterVisibility": "PRIVATE",
}, },
}) })
require.NoError(t, err, "owner should be able to update document") require.NoError(t, err, "owner should be able to update document")
@@ -802,8 +794,8 @@ func TestDocument_RBAC(t *testing.T) {
} }
`, map[string]any{ `, map[string]any{
"input": map[string]any{ "input": map[string]any{
"id": documentID, "id": documentID,
"title": "Updated by Admin", "trustCenterVisibility": "PRIVATE",
}, },
}) })
require.NoError(t, err, "admin should be able to update document") require.NoError(t, err, "admin should be able to update document")
@@ -823,8 +815,8 @@ func TestDocument_RBAC(t *testing.T) {
} }
`, map[string]any{ `, map[string]any{
"input": map[string]any{ "input": map[string]any{
"id": documentID, "id": documentID,
"title": "Updated by Viewer", "trustCenterVisibility": "PRIVATE",
}, },
}) })
testutil.RequireForbiddenError(t, err, "viewer should not be able to update document") testutil.RequireForbiddenError(t, err, "viewer should not be able to update document")
@@ -894,15 +886,14 @@ func TestDocument_RBAC(t *testing.T) {
var result struct { var result struct {
Node *struct { Node *struct {
ID string `json:"id"` ID string `json:"id"`
Title string `json:"title"`
} `json:"node"` } `json:"node"`
} }
err := owner.Execute(` err := owner.Execute(`
query($id: ID!) { query($id: ID!) {
node(id: $id) { node(id: $id) {
... on Document { id title } ... on Document { id }
} }
} }
`, map[string]any{"id": documentID}, &result) `, map[string]any{"id": documentID}, &result)
@@ -918,15 +909,14 @@ func TestDocument_RBAC(t *testing.T) {
var result struct { var result struct {
Node *struct { Node *struct {
ID string `json:"id"` ID string `json:"id"`
Title string `json:"title"`
} `json:"node"` } `json:"node"`
} }
err := admin.Execute(` err := admin.Execute(`
query($id: ID!) { query($id: ID!) {
node(id: $id) { node(id: $id) {
... on Document { id title } ... on Document { id }
} }
} }
`, map[string]any{"id": documentID}, &result) `, map[string]any{"id": documentID}, &result)
@@ -942,15 +932,14 @@ func TestDocument_RBAC(t *testing.T) {
var result struct { var result struct {
Node *struct { Node *struct {
ID string `json:"id"` ID string `json:"id"`
Title string `json:"title"`
} `json:"node"` } `json:"node"`
} }
err := viewer.Execute(` err := viewer.Execute(`
query($id: ID!) { query($id: ID!) {
node(id: $id) { node(id: $id) {
... on Document { id title } ... on Document { id }
} }
} }
`, map[string]any{"id": documentID}, &result) `, map[string]any{"id": documentID}, &result)
@@ -990,21 +979,23 @@ func TestDocument_MaxLength_Validation(t *testing.T) {
assert.Contains(t, err.Error(), "title") assert.Contains(t, err.Error(), "title")
}) })
t.Run("update with long title", func(t *testing.T) { t.Run("update version", func(t *testing.T) {
documentID := factory.NewDocument(owner).WithTitle("Max Length Test").Create() doc := factory.NewDocument(owner).WithTitle("Max Length Test")
doc.Create()
versionID := doc.VersionID()
query := ` query := `
mutation UpdateDocument($input: UpdateDocumentInput!) { mutation UpdateDocumentVersion($input: UpdateDocumentVersionInput!) {
updateDocument(input: $input) { updateDocumentVersion(input: $input) {
document { id } documentVersion { id }
} }
} }
` `
_, err := owner.Do(query, map[string]any{ _, err := owner.Do(query, map[string]any{
"input": map[string]any{ "input": map[string]any{
"id": documentID, "documentVersionId": versionID,
"title": longTitle, "title": longTitle,
}, },
}) })
require.Error(t, err) require.Error(t, err)
@@ -1080,7 +1071,7 @@ func TestDocument_Pagination(t *testing.T) {
... on Organization { ... on Organization {
documents(first: 2) { documents(first: 2) {
edges { edges {
node { id title } node { id }
cursor cursor
} }
pageInfo { pageInfo {
@@ -1101,8 +1092,7 @@ func TestDocument_Pagination(t *testing.T) {
Documents struct { Documents struct {
Edges []struct { Edges []struct {
Node struct { Node struct {
ID string `json:"id"` ID string `json:"id"`
Title string `json:"title"`
} `json:"node"` } `json:"node"`
Cursor string `json:"cursor"` Cursor string `json:"cursor"`
} `json:"edges"` } `json:"edges"`
@@ -1131,7 +1121,7 @@ func TestDocument_Pagination(t *testing.T) {
... on Organization { ... on Organization {
documents(first: 2, after: $after) { documents(first: 2, after: $after) {
edges { edges {
node { id title } node { id }
} }
pageInfo { pageInfo {
hasNextPage hasNextPage
@@ -1148,8 +1138,7 @@ func TestDocument_Pagination(t *testing.T) {
Documents struct { Documents struct {
Edges []struct { Edges []struct {
Node struct { Node struct {
ID string `json:"id"` ID string `json:"id"`
Title string `json:"title"`
} `json:"node"` } `json:"node"`
} `json:"edges"` } `json:"edges"`
PageInfo testutil.PageInfo `json:"pageInfo"` PageInfo testutil.PageInfo `json:"pageInfo"`
@@ -1173,7 +1162,7 @@ func TestDocument_Pagination(t *testing.T) {
... on Organization { ... on Organization {
documents(last: 2) { documents(last: 2) {
edges { edges {
node { id title } node { id }
} }
pageInfo { pageInfo {
hasNextPage hasNextPage
@@ -1190,8 +1179,7 @@ func TestDocument_Pagination(t *testing.T) {
Documents struct { Documents struct {
Edges []struct { Edges []struct {
Node struct { Node struct {
ID string `json:"id"` ID string `json:"id"`
Title string `json:"title"`
} `json:"node"` } `json:"node"`
} `json:"edges"` } `json:"edges"`
PageInfo testutil.PageInfo `json:"pageInfo"` PageInfo testutil.PageInfo `json:"pageInfo"`
@@ -1222,7 +1210,6 @@ func TestDocument_TenantIsolation(t *testing.T) {
node(id: $id) { node(id: $id) {
... on Document { ... on Document {
id id
title
} }
} }
} }
@@ -1230,8 +1217,7 @@ func TestDocument_TenantIsolation(t *testing.T) {
var result struct { var result struct {
Node *struct { Node *struct {
ID string `json:"id"` ID string `json:"id"`
Title string `json:"title"`
} `json:"node"` } `json:"node"`
} }
@@ -1250,8 +1236,8 @@ func TestDocument_TenantIsolation(t *testing.T) {
_, err := org2Owner.Do(query, map[string]any{ _, err := org2Owner.Do(query, map[string]any{
"input": map[string]any{ "input": map[string]any{
"id": documentID, "id": documentID,
"title": "Hijacked Document", "trustCenterVisibility": "PRIVATE",
}, },
}) })
require.Error(t, err, "Should not be able to update document from another org") require.Error(t, err, "Should not be able to update document from another org")
@@ -1283,7 +1269,6 @@ func TestDocument_TenantIsolation(t *testing.T) {
edges { edges {
node { node {
id id
title
} }
} }
} }
@@ -1297,8 +1282,7 @@ func TestDocument_TenantIsolation(t *testing.T) {
Documents struct { Documents struct {
Edges []struct { Edges []struct {
Node struct { Node struct {
ID string `json:"id"` ID string `json:"id"`
Title string `json:"title"`
} `json:"node"` } `json:"node"`
} `json:"edges"` } `json:"edges"`
} `json:"documents"` } `json:"documents"`

View File

@@ -55,7 +55,6 @@ func TestEmployeeDocument_NodeAccess(t *testing.T) {
node(id: $id) { node(id: $id) {
... on Document { ... on Document {
id id
title
} }
} }
} }
@@ -71,7 +70,6 @@ func TestEmployeeDocument_NodeAccess(t *testing.T) {
node(id: $id) { node(id: $id) {
... on Document { ... on Document {
id id
title
} }
} }
} }

View File

@@ -627,7 +627,6 @@ func TestRisk_SubResolvers(t *testing.T) {
edges { edges {
node { node {
id id
title
} }
} }
} }
@@ -642,8 +641,7 @@ func TestRisk_SubResolvers(t *testing.T) {
Documents struct { Documents struct {
Edges []struct { Edges []struct {
Node struct { Node struct {
ID string `json:"id"` ID string `json:"id"`
Title string `json:"title"`
} `json:"node"` } `json:"node"`
} `json:"edges"` } `json:"edges"`
} `json:"documents"` } `json:"documents"`

View File

@@ -825,50 +825,10 @@ func (b *MeetingBuilder) Create() string {
return CreateMeeting(b.client, b.attrs) return CreateMeeting(b.client, b.attrs)
} }
func CreateDocument(c *testutil.Client, attrs ...Attrs) string {
c.T.Helper()
var a Attrs
if len(attrs) > 0 {
a = attrs[0]
}
const query = `
mutation($input: CreateDocumentInput!) {
createDocument(input: $input) {
documentEdge {
node { id }
}
}
}
`
input := map[string]any{
"organizationId": c.GetOrganizationID().String(),
"title": a.getString("title", SafeName("Document")),
"documentType": a.getString("documentType", "POLICY"),
"classification": a.getString("classification", "INTERNAL"),
}
var result struct {
CreateDocument struct {
DocumentEdge struct {
Node struct {
ID string `json:"id"`
} `json:"node"`
} `json:"documentEdge"`
} `json:"createDocument"`
}
err := c.Execute(query, map[string]any{"input": input}, &result)
require.NoError(c.T, err, "createDocument mutation failed")
return result.CreateDocument.DocumentEdge.Node.ID
}
type DocumentBuilder struct { type DocumentBuilder struct {
client *testutil.Client client *testutil.Client
attrs Attrs attrs Attrs
versionID string
} }
func NewDocument(c *testutil.Client) *DocumentBuilder { func NewDocument(c *testutil.Client) *DocumentBuilder {
@@ -895,8 +855,56 @@ func (b *DocumentBuilder) WithClassification(classification string) *DocumentBui
return b return b
} }
func (b *DocumentBuilder) VersionID() string {
return b.versionID
}
func (b *DocumentBuilder) Create() string { func (b *DocumentBuilder) Create() string {
return CreateDocument(b.client, b.attrs) b.client.T.Helper()
a := b.attrs
const query = `
mutation($input: CreateDocumentInput!) {
createDocument(input: $input) {
documentEdge {
node { id }
}
documentVersionEdge {
node { id }
}
}
}
`
input := map[string]any{
"organizationId": b.client.GetOrganizationID().String(),
"title": a.getString("title", SafeName("Document")),
"documentType": a.getString("documentType", "POLICY"),
"classification": a.getString("classification", "INTERNAL"),
}
var result struct {
CreateDocument struct {
DocumentEdge struct {
Node struct {
ID string `json:"id"`
} `json:"node"`
} `json:"documentEdge"`
DocumentVersionEdge struct {
Node struct {
ID string `json:"id"`
} `json:"node"`
} `json:"documentVersionEdge"`
} `json:"createDocument"`
}
err := b.client.Execute(query, map[string]any{"input": input}, &result)
require.NoError(b.client.T, err, "createDocument mutation failed")
b.versionID = result.CreateDocument.DocumentVersionEdge.Node.ID
return result.CreateDocument.DocumentEdge.Node.ID
} }
func CreateProcessingActivity(c *testutil.Client, attrs ...Attrs) string { func CreateProcessingActivity(c *testutil.Client, attrs ...Attrs) string {

View File

@@ -32,7 +32,6 @@ type (
Document struct { Document struct {
ID gid.GID `db:"id"` ID gid.GID `db:"id"`
OrganizationID gid.GID `db:"organization_id"` OrganizationID gid.GID `db:"organization_id"`
Title string `db:"title"`
CurrentPublishedMajor *int `db:"current_published_major"` CurrentPublishedMajor *int `db:"current_published_major"`
CurrentPublishedMinor *int `db:"current_published_minor"` CurrentPublishedMinor *int `db:"current_published_minor"`
TrustCenterVisibility TrustCenterVisibility `db:"trust_center_visibility"` TrustCenterVisibility TrustCenterVisibility `db:"trust_center_visibility"`
@@ -42,6 +41,7 @@ type (
UpdatedAt time.Time `db:"updated_at"` UpdatedAt time.Time `db:"updated_at"`
// ordering only // ordering only
Title string `db:"title"`
DocumentType DocumentType `db:"document_type"` DocumentType DocumentType `db:"document_type"`
} }
@@ -93,14 +93,13 @@ func (p *Document) LoadByID(
) error { ) error {
q := ` q := `
WITH latest_versions AS ( WITH latest_versions AS (
SELECT DISTINCT ON (document_id) document_id, document_type SELECT DISTINCT ON (document_id) document_id, title, document_type
FROM document_versions FROM document_versions
ORDER BY document_id, major DESC, minor DESC ORDER BY document_id, major DESC, minor DESC
) )
SELECT SELECT
documents.id, documents.id,
documents.organization_id, documents.organization_id,
documents.title,
documents.current_published_major, documents.current_published_major,
documents.current_published_minor, documents.current_published_minor,
documents.trust_center_visibility, documents.trust_center_visibility,
@@ -108,6 +107,7 @@ SELECT
documents.archived_at, documents.archived_at,
documents.created_at, documents.created_at,
documents.updated_at, documents.updated_at,
COALESCE(lv.title, '') AS title,
COALESCE(lv.document_type, 'OTHER') AS document_type COALESCE(lv.document_type, 'OTHER') AS document_type
FROM FROM
documents documents
@@ -152,14 +152,13 @@ func (p *Document) LoadByIDWithFilter(
) error { ) error {
q := ` q := `
WITH latest_versions AS ( WITH latest_versions AS (
SELECT DISTINCT ON (document_id) document_id, document_type SELECT DISTINCT ON (document_id) document_id, title, document_type
FROM document_versions FROM document_versions
ORDER BY document_id, major DESC, minor DESC ORDER BY document_id, major DESC, minor DESC
) )
SELECT SELECT
documents.id, documents.id,
documents.organization_id, documents.organization_id,
documents.title,
documents.current_published_major, documents.current_published_major,
documents.current_published_minor, documents.current_published_minor,
documents.trust_center_visibility, documents.trust_center_visibility,
@@ -167,6 +166,7 @@ SELECT
documents.archived_at, documents.archived_at,
documents.created_at, documents.created_at,
documents.updated_at, documents.updated_at,
COALESCE(lv.title, '') AS title,
COALESCE(lv.document_type, 'OTHER') AS document_type COALESCE(lv.document_type, 'OTHER') AS document_type
FROM FROM
documents documents
@@ -212,14 +212,13 @@ func (p *Documents) LoadByIDs(
) error { ) error {
q := ` q := `
WITH latest_versions AS ( WITH latest_versions AS (
SELECT DISTINCT ON (document_id) document_id, document_type SELECT DISTINCT ON (document_id) document_id, title, document_type
FROM document_versions FROM document_versions
ORDER BY document_id, major DESC, minor DESC ORDER BY document_id, major DESC, minor DESC
) )
SELECT SELECT
documents.id, documents.id,
documents.organization_id, documents.organization_id,
documents.title,
documents.current_published_major, documents.current_published_major,
documents.current_published_minor, documents.current_published_minor,
documents.trust_center_visibility, documents.trust_center_visibility,
@@ -227,6 +226,7 @@ SELECT
documents.archived_at, documents.archived_at,
documents.created_at, documents.created_at,
documents.updated_at, documents.updated_at,
COALESCE(lv.title, '') AS title,
COALESCE(lv.document_type, 'OTHER') AS document_type COALESCE(lv.document_type, 'OTHER') AS document_type
FROM FROM
documents documents
@@ -301,31 +301,33 @@ func (p *Documents) LoadByOrganizationID(
) error { ) error {
q := ` q := `
WITH latest_versions AS ( WITH latest_versions AS (
SELECT DISTINCT ON (document_id) document_id, document_type SELECT DISTINCT ON (document_id) document_id, title, document_type
FROM document_versions FROM document_versions
ORDER BY document_id, major DESC, minor DESC ORDER BY document_id, major DESC, minor DESC
),
base AS (
SELECT
documents.id,
documents.organization_id,
documents.current_published_major,
documents.current_published_minor,
documents.trust_center_visibility,
documents.status,
documents.archived_at,
documents.created_at,
documents.updated_at,
COALESCE(lv.title, '') AS title,
COALESCE(lv.document_type, 'OTHER') AS document_type
FROM
documents
LEFT JOIN latest_versions lv ON lv.document_id = documents.id
WHERE
%s
AND documents.deleted_at IS NULL
AND documents.organization_id = @organization_id
AND %s
) )
SELECT SELECT * FROM base WHERE %s
documents.id,
documents.organization_id,
documents.title,
documents.current_published_major,
documents.current_published_minor,
documents.trust_center_visibility,
documents.status,
documents.archived_at,
documents.created_at,
documents.updated_at,
COALESCE(lv.document_type, 'OTHER') AS document_type
FROM
documents
LEFT JOIN latest_versions lv ON lv.document_id = documents.id
WHERE
%s
AND documents.deleted_at IS NULL
AND documents.organization_id = @organization_id
AND %s
AND %s
` `
q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment(), cursor.SQLFragment()) q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment(), cursor.SQLFragment())
@@ -359,14 +361,13 @@ func (p *Documents) LoadAllByOrganizationID(
) error { ) error {
q := ` q := `
WITH latest_versions AS ( WITH latest_versions AS (
SELECT DISTINCT ON (document_id) document_id, document_type SELECT DISTINCT ON (document_id) document_id, title, document_type
FROM document_versions FROM document_versions
ORDER BY document_id, major DESC, minor DESC ORDER BY document_id, major DESC, minor DESC
) )
SELECT SELECT
documents.id, documents.id,
documents.organization_id, documents.organization_id,
documents.title,
documents.current_published_major, documents.current_published_major,
documents.current_published_minor, documents.current_published_minor,
documents.trust_center_visibility, documents.trust_center_visibility,
@@ -374,6 +375,7 @@ SELECT
documents.archived_at, documents.archived_at,
documents.created_at, documents.created_at,
documents.updated_at, documents.updated_at,
COALESCE(lv.title, '') AS title,
COALESCE(lv.document_type, 'OTHER') AS document_type COALESCE(lv.document_type, 'OTHER') AS document_type
FROM FROM
documents documents
@@ -417,43 +419,48 @@ func (p *Documents) LoadPublishedByOrganizationID(
) error { ) error {
q := ` q := `
WITH latest_versions AS ( WITH latest_versions AS (
SELECT DISTINCT ON (document_id) document_id, document_type SELECT DISTINCT ON (document_id) document_id, title, document_type
FROM document_versions FROM document_versions
ORDER BY document_id, major DESC, minor DESC ORDER BY document_id, major DESC, minor DESC
), ),
published_documents AS ( published_versions AS (
SELECT SELECT
d.*, dv.document_id,
dv.title AS published_title dv.title AS published_title
FROM FROM
documents d document_versions dv
LEFT JOIN document_versions dv INNER JOIN documents d
ON dv.document_id = d.id ON dv.document_id = d.id
AND dv.major = d.current_published_major AND dv.major = d.current_published_major
AND dv.minor = d.current_published_minor AND dv.minor = d.current_published_minor
WHERE WHERE
d.deleted_at IS NULL d.deleted_at IS NULL
AND d.organization_id = @organization_id AND d.organization_id = @organization_id
),
base AS (
SELECT
documents.id,
documents.organization_id,
documents.current_published_major,
documents.current_published_minor,
documents.trust_center_visibility,
documents.status,
documents.archived_at,
documents.created_at,
documents.updated_at,
COALESCE(pv.published_title, lv.title, '') AS title,
COALESCE(lv.document_type, 'OTHER') AS document_type
FROM
documents
LEFT JOIN latest_versions lv ON lv.document_id = documents.id
LEFT JOIN published_versions pv ON pv.document_id = documents.id
WHERE
%s
AND documents.deleted_at IS NULL
AND documents.organization_id = @organization_id
AND %s
) )
SELECT SELECT * FROM base WHERE %s
documents.id,
documents.organization_id,
COALESCE(documents.published_title, documents.title) AS title,
documents.current_published_major,
documents.current_published_minor,
documents.trust_center_visibility,
documents.status,
documents.archived_at,
documents.created_at,
documents.updated_at,
COALESCE(lv.document_type, 'OTHER') AS document_type
FROM
published_documents documents
LEFT JOIN latest_versions lv ON lv.document_id = documents.id
WHERE
%s
AND %s
AND %s
` `
q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment(), cursor.SQLFragment()) q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment(), cursor.SQLFragment())
@@ -488,7 +495,6 @@ INSERT INTO
tenant_id, tenant_id,
id, id,
organization_id, organization_id,
title,
current_published_major, current_published_major,
current_published_minor, current_published_minor,
trust_center_visibility, trust_center_visibility,
@@ -501,7 +507,6 @@ VALUES (
@tenant_id, @tenant_id,
@document_id, @document_id,
@organization_id, @organization_id,
@title,
@current_published_major, @current_published_major,
@current_published_minor, @current_published_minor,
@trust_center_visibility, @trust_center_visibility,
@@ -516,7 +521,6 @@ VALUES (
"tenant_id": scope.GetTenantID(), "tenant_id": scope.GetTenantID(),
"document_id": p.ID, "document_id": p.ID,
"organization_id": p.OrganizationID, "organization_id": p.OrganizationID,
"title": p.Title,
"current_published_major": p.CurrentPublishedMajor, "current_published_major": p.CurrentPublishedMajor,
"current_published_minor": p.CurrentPublishedMinor, "current_published_minor": p.CurrentPublishedMinor,
"trust_center_visibility": p.TrustCenterVisibility, "trust_center_visibility": p.TrustCenterVisibility,
@@ -575,7 +579,6 @@ func (p *Document) Update(
UPDATE UPDATE
documents documents
SET SET
title = @title,
current_published_major = @current_published_major, current_published_major = @current_published_major,
current_published_minor = @current_published_minor, current_published_minor = @current_published_minor,
trust_center_visibility = @trust_center_visibility, trust_center_visibility = @trust_center_visibility,
@@ -592,7 +595,6 @@ WHERE
args := pgx.StrictNamedArgs{ args := pgx.StrictNamedArgs{
"document_id": p.ID, "document_id": p.ID,
"updated_at": time.Now(), "updated_at": time.Now(),
"title": p.Title,
"current_published_major": p.CurrentPublishedMajor, "current_published_major": p.CurrentPublishedMajor,
"current_published_minor": p.CurrentPublishedMinor, "current_published_minor": p.CurrentPublishedMinor,
"trust_center_visibility": p.TrustCenterVisibility, "trust_center_visibility": p.TrustCenterVisibility,
@@ -655,7 +657,7 @@ func (p *Documents) LoadByControlID(
) error { ) error {
q := ` q := `
WITH latest_versions AS ( WITH latest_versions AS (
SELECT DISTINCT ON (document_id) document_id, document_type SELECT DISTINCT ON (document_id) document_id, title, document_type
FROM document_versions FROM document_versions
ORDER BY document_id, major DESC, minor DESC ORDER BY document_id, major DESC, minor DESC
), ),
@@ -665,24 +667,26 @@ scoped_documents AS (
WHERE %s WHERE %s
AND deleted_at IS NULL AND deleted_at IS NULL
AND %s AND %s
AND %s ),
base AS (
SELECT
sd.id,
sd.organization_id,
sd.current_published_major,
sd.current_published_minor,
sd.trust_center_visibility,
sd.status,
sd.archived_at,
sd.created_at,
sd.updated_at,
COALESCE(lv.title, '') AS title,
COALESCE(lv.document_type, 'OTHER') AS document_type
FROM scoped_documents sd
INNER JOIN controls_documents cp ON sd.id = cp.document_id
LEFT JOIN latest_versions lv ON lv.document_id = sd.id
WHERE cp.control_id = @control_id
) )
SELECT SELECT * FROM base WHERE %s
scoped_documents.id,
scoped_documents.organization_id,
scoped_documents.title,
scoped_documents.current_published_major,
scoped_documents.current_published_minor,
scoped_documents.trust_center_visibility,
scoped_documents.status,
scoped_documents.archived_at,
scoped_documents.created_at,
scoped_documents.updated_at,
COALESCE(lv.document_type, 'OTHER') AS document_type
FROM scoped_documents
INNER JOIN controls_documents cp ON scoped_documents.id = cp.document_id
LEFT JOIN latest_versions lv ON lv.document_id = scoped_documents.id
WHERE cp.control_id = @control_id
` `
q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment(), cursor.SQLFragment()) q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment(), cursor.SQLFragment())
@@ -752,7 +756,7 @@ func (p *Documents) LoadByRiskID(
) error { ) error {
q := ` q := `
WITH latest_versions AS ( WITH latest_versions AS (
SELECT DISTINCT ON (document_id) document_id, document_type SELECT DISTINCT ON (document_id) document_id, title, document_type
FROM document_versions FROM document_versions
ORDER BY document_id, major DESC, minor DESC ORDER BY document_id, major DESC, minor DESC
), ),
@@ -762,24 +766,26 @@ scoped_documents AS (
WHERE %s WHERE %s
AND deleted_at IS NULL AND deleted_at IS NULL
AND %s AND %s
AND %s ),
base AS (
SELECT
sd.id,
sd.organization_id,
sd.current_published_major,
sd.current_published_minor,
sd.trust_center_visibility,
sd.status,
sd.archived_at,
sd.created_at,
sd.updated_at,
COALESCE(lv.title, '') AS title,
COALESCE(lv.document_type, 'OTHER') AS document_type
FROM scoped_documents sd
INNER JOIN risks_documents rp ON sd.id = rp.document_id
LEFT JOIN latest_versions lv ON lv.document_id = sd.id
WHERE rp.risk_id = @risk_id
) )
SELECT SELECT * FROM base WHERE %s
scoped_documents.id,
scoped_documents.organization_id,
scoped_documents.title,
scoped_documents.current_published_major,
scoped_documents.current_published_minor,
scoped_documents.trust_center_visibility,
scoped_documents.status,
scoped_documents.archived_at,
scoped_documents.created_at,
scoped_documents.updated_at,
COALESCE(lv.document_type, 'OTHER') AS document_type
FROM scoped_documents
INNER JOIN risks_documents rp ON scoped_documents.id = rp.document_id
LEFT JOIN latest_versions lv ON lv.document_id = scoped_documents.id
WHERE rp.risk_id = @risk_id
` `
q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment(), cursor.SQLFragment()) q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment(), cursor.SQLFragment())
@@ -849,7 +855,7 @@ func (p *Documents) LoadByMeasureID(
) error { ) error {
q := ` q := `
WITH latest_versions AS ( WITH latest_versions AS (
SELECT DISTINCT ON (document_id) document_id, document_type SELECT DISTINCT ON (document_id) document_id, title, document_type
FROM document_versions FROM document_versions
ORDER BY document_id, major DESC, minor DESC ORDER BY document_id, major DESC, minor DESC
), ),
@@ -859,24 +865,26 @@ scoped_documents AS (
WHERE %s WHERE %s
AND deleted_at IS NULL AND deleted_at IS NULL
AND %s AND %s
AND %s ),
base AS (
SELECT
sd.id,
sd.organization_id,
sd.current_published_major,
sd.current_published_minor,
sd.trust_center_visibility,
sd.status,
sd.archived_at,
sd.created_at,
sd.updated_at,
COALESCE(lv.title, '') AS title,
COALESCE(lv.document_type, 'OTHER') AS document_type
FROM scoped_documents sd
INNER JOIN measures_documents md ON sd.id = md.document_id
LEFT JOIN latest_versions lv ON lv.document_id = sd.id
WHERE md.measure_id = @measure_id
) )
SELECT SELECT * FROM base WHERE %s
scoped_documents.id,
scoped_documents.organization_id,
scoped_documents.title,
scoped_documents.current_published_major,
scoped_documents.current_published_minor,
scoped_documents.trust_center_visibility,
scoped_documents.status,
scoped_documents.archived_at,
scoped_documents.created_at,
scoped_documents.updated_at,
COALESCE(lv.document_type, 'OTHER') AS document_type
FROM scoped_documents
INNER JOIN measures_documents md ON scoped_documents.id = md.document_id
LEFT JOIN latest_versions lv ON lv.document_id = scoped_documents.id
WHERE md.measure_id = @measure_id
` `
q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment(), cursor.SQLFragment()) q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment(), cursor.SQLFragment())

View File

@@ -0,0 +1,16 @@
-- Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
--
-- Permission to use, copy, modify, and/or distribute this software for any
-- purpose with or without fee is hereby granted, provided that the above
-- copyright notice and this permission notice appear in all copies.
--
-- THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
-- REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
-- AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
-- INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
-- LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
-- OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
-- PERFORMANCE OF THIS SOFTWARE.
-- TODO: drop the title column from documents.
ALTER TABLE documents ALTER COLUMN title DROP NOT NULL;

View File

@@ -89,13 +89,13 @@ type (
UpdateDocumentRequest struct { UpdateDocumentRequest struct {
DocumentID gid.GID DocumentID gid.GID
Title *string
TrustCenterVisibility *coredata.TrustCenterVisibility TrustCenterVisibility *coredata.TrustCenterVisibility
DefaultApproverIDs *[]gid.GID DefaultApproverIDs *[]gid.GID
} }
UpdateDocumentVersionRequest struct { UpdateDocumentVersionRequest struct {
ID gid.GID ID gid.GID
Title *string
Content *string Content *string
Classification *coredata.DocumentClassification Classification *coredata.DocumentClassification
DocumentType *coredata.DocumentType DocumentType *coredata.DocumentType
@@ -150,7 +150,6 @@ func (udr *UpdateDocumentRequest) Validate() error {
v := validator.New() v := validator.New()
v.Check(udr.DocumentID, "document_id", validator.Required(), validator.GID(coredata.DocumentEntityType)) v.Check(udr.DocumentID, "document_id", validator.Required(), validator.GID(coredata.DocumentEntityType))
v.Check(udr.Title, "title", validator.SafeTextNoNewLine(TitleMaxLength))
v.Check(udr.TrustCenterVisibility, "trust_center_visibility", validator.OneOfSlice(coredata.TrustCenterVisibilities())) v.Check(udr.TrustCenterVisibility, "trust_center_visibility", validator.OneOfSlice(coredata.TrustCenterVisibilities()))
if udr.DefaultApproverIDs != nil { if udr.DefaultApproverIDs != nil {
v.Check(len(*udr.DefaultApproverIDs), "default_approver_ids", validator.Max(100)) v.Check(len(*udr.DefaultApproverIDs), "default_approver_ids", validator.Max(100))
@@ -167,6 +166,7 @@ func (udvr *UpdateDocumentVersionRequest) Validate() error {
v := validator.New() v := validator.New()
v.Check(udvr.ID, "id", validator.Required(), validator.GID(coredata.DocumentVersionEntityType)) v.Check(udvr.ID, "id", validator.Required(), validator.GID(coredata.DocumentVersionEntityType))
v.Check(udvr.Title, "title", validator.SafeTextNoNewLine(TitleMaxLength))
v.Check(udvr.Classification, "classification", validator.OneOfSlice(coredata.DocumentClassifications())) v.Check(udvr.Classification, "classification", validator.OneOfSlice(coredata.DocumentClassifications()))
v.Check( v.Check(
udvr.Content, udvr.Content,
@@ -584,7 +584,6 @@ func (s *DocumentService) Create(
document := &coredata.Document{ document := &coredata.Document{
ID: documentID, ID: documentID,
Title: req.Title,
TrustCenterVisibility: coredata.TrustCenterVisibilityNone, TrustCenterVisibility: coredata.TrustCenterVisibilityNone,
Status: coredata.DocumentStatusActive, Status: coredata.DocumentStatusActive,
CreatedAt: now, CreatedAt: now,
@@ -863,7 +862,9 @@ func (s *DocumentService) UpdateVersion(
documentVersion.Content = content documentVersion.Content = content
} }
documentVersion.Title = document.Title if req.Title != nil {
documentVersion.Title = *req.Title
}
if req.Classification != nil { if req.Classification != nil {
documentVersion.Classification = *req.Classification documentVersion.Classification = *req.Classification
} }
@@ -1119,7 +1120,7 @@ func (s *DocumentService) CreateDraft(
draftVersion.ID = draftVersionID draftVersion.ID = draftVersionID
draftVersion.OrganizationID = document.OrganizationID draftVersion.OrganizationID = document.OrganizationID
draftVersion.DocumentID = documentID draftVersion.DocumentID = documentID
draftVersion.Title = document.Title draftVersion.Title = latestVersion.Title
draftVersion.Major = latestVersion.Major draftVersion.Major = latestVersion.Major
draftVersion.Minor = latestVersion.Minor + 1 draftVersion.Minor = latestVersion.Minor + 1
draftVersion.Classification = latestVersion.Classification draftVersion.Classification = latestVersion.Classification
@@ -1716,10 +1717,6 @@ func (s *DocumentService) Update(
return &ErrDocumentArchived{} return &ErrDocumentArchived{}
} }
if req.Title != nil {
document.Title = *req.Title
}
if req.TrustCenterVisibility != nil { if req.TrustCenterVisibility != nil {
document.TrustCenterVisibility = *req.TrustCenterVisibility document.TrustCenterVisibility = *req.TrustCenterVisibility
} }
@@ -1730,17 +1727,6 @@ func (s *DocumentService) Update(
return fmt.Errorf("cannot update document: %w", err) return fmt.Errorf("cannot update document: %w", err)
} }
draftVersion := &coredata.DocumentVersion{}
err := draftVersion.LoadLatestVersion(ctx, tx, s.svc.scope, req.DocumentID)
if err == nil && draftVersion.Status == coredata.DocumentVersionStatusDraft {
draftVersion.Title = document.Title
draftVersion.UpdatedAt = now
if err := draftVersion.Update(ctx, tx, s.svc.scope); err != nil {
return fmt.Errorf("cannot update draft version: %w", err)
}
}
if req.DefaultApproverIDs != nil { if req.DefaultApproverIDs != nil {
defaultApprovers := &coredata.DocumentDefaultApprovers{} defaultApprovers := &coredata.DocumentDefaultApprovers{}
if err := defaultApprovers.MergeByDocumentID(ctx, tx, s.svc.scope, req.DocumentID, document.OrganizationID, *req.DefaultApproverIDs); err != nil { if err := defaultApprovers.MergeByDocumentID(ctx, tx, s.svc.scope, req.DocumentID, document.OrganizationID, *req.DefaultApproverIDs); err != nil {

View File

@@ -2562,7 +2562,6 @@ type Evidence implements Node {
type Document implements Node { type Document implements Node {
id: ID! id: ID!
title: String!
description: String description: String
currentPublishedMajor: Int currentPublishedMajor: Int
currentPublishedMinor: Int currentPublishedMinor: Int
@@ -4676,8 +4675,6 @@ input CreateDocumentInput {
input UpdateDocumentInput { input UpdateDocumentInput {
id: ID! id: ID!
title: String
content: String
trustCenterVisibility: TrustCenterVisibility trustCenterVisibility: TrustCenterVisibility
defaultApproverIds: [ID!] defaultApproverIds: [ID!]
} }
@@ -5945,6 +5942,7 @@ input DeleteDraftDocumentVersionInput {
input UpdateDocumentVersionInput { input UpdateDocumentVersionInput {
documentVersionId: ID! documentVersionId: ID!
title: String
content: String content: String
classification: DocumentClassification classification: DocumentClassification
documentType: DocumentType documentType: DocumentType

View File

@@ -75,8 +75,7 @@ func NewDocumentEdge(document *coredata.Document, orderBy coredata.DocumentOrder
func NewDocument(document *coredata.Document) *Document { func NewDocument(document *coredata.Document) *Document {
return &Document{ return &Document{
ID: document.ID, ID: document.ID,
Title: document.Title,
Organization: &Organization{ Organization: &Organization{
ID: document.OrganizationID, ID: document.OrganizationID,
}, },

View File

@@ -5307,7 +5307,6 @@ func (r *mutationResolver) UpdateDocument(ctx context.Context, input types.Updat
ctx, ctx,
probo.UpdateDocumentRequest{ probo.UpdateDocumentRequest{
DocumentID: input.ID, DocumentID: input.ID,
Title: input.Title,
TrustCenterVisibility: input.TrustCenterVisibility, TrustCenterVisibility: input.TrustCenterVisibility,
DefaultApproverIDs: defaultApproverIDs, DefaultApproverIDs: defaultApproverIDs,
}, },
@@ -6101,6 +6100,7 @@ func (r *mutationResolver) UpdateDocumentVersion(ctx context.Context, input type
ctx, ctx,
probo.UpdateDocumentVersionRequest{ probo.UpdateDocumentVersionRequest{
ID: input.DocumentVersionID, ID: input.DocumentVersionID,
Title: input.Title,
Content: input.Content, Content: input.Content,
Classification: input.Classification, Classification: input.Classification,
DocumentType: input.DocumentType, DocumentType: input.DocumentType,

View File

@@ -2119,7 +2119,6 @@ func (r *Resolver) UpdateDocumentTool(ctx context.Context, req *mcp.CallToolRequ
ctx, ctx,
probo.UpdateDocumentRequest{ probo.UpdateDocumentRequest{
DocumentID: input.ID, DocumentID: input.ID,
Title: input.Title,
TrustCenterVisibility: input.TrustCenterVisibility, TrustCenterVisibility: input.TrustCenterVisibility,
DefaultApproverIDs: defaultApproverIDs, DefaultApproverIDs: defaultApproverIDs,
}, },
@@ -2224,6 +2223,7 @@ func (r *Resolver) UpdateDocumentVersionTool(ctx context.Context, req *mcp.CallT
ctx, ctx,
probo.UpdateDocumentVersionRequest{ probo.UpdateDocumentVersionRequest{
ID: input.DocumentVersionID, ID: input.DocumentVersionID,
Title: input.Title,
Content: content, Content: content,
Classification: input.Classification, Classification: input.Classification,
DocumentType: input.DocumentType, DocumentType: input.DocumentType,

View File

@@ -5243,7 +5243,6 @@ components:
required: required:
- id - id
- organization_id - organization_id
- title
- trust_center_visibility - trust_center_visibility
- status - status
- created_at - created_at
@@ -5255,9 +5254,6 @@ components:
organization_id: organization_id:
$ref: "#/components/schemas/GID" $ref: "#/components/schemas/GID"
description: Organization ID description: Organization ID
title:
type: string
description: Document title
current_published_major: current_published_major:
type: type:
- integer - integer
@@ -5522,9 +5518,6 @@ components:
id: id:
$ref: "#/components/schemas/GID" $ref: "#/components/schemas/GID"
description: Document ID description: Document ID
title:
type: string
description: Document title
trust_center_visibility: trust_center_visibility:
$ref: "#/components/schemas/TrustCenterVisibility" $ref: "#/components/schemas/TrustCenterVisibility"
description: Trust center visibility description: Trust center visibility
@@ -5655,6 +5648,9 @@ components:
document_version_id: document_version_id:
$ref: "#/components/schemas/GID" $ref: "#/components/schemas/GID"
description: Document version ID description: Document version ID
title:
type: string
description: Document version title
content: content:
type: string type: string
description: Document content in markdown format description: Document content in markdown format

View File

@@ -45,7 +45,6 @@ func NewDocument(d *coredata.Document) *Document {
return &Document{ return &Document{
ID: d.ID, ID: d.ID,
OrganizationID: d.OrganizationID, OrganizationID: d.OrganizationID,
Title: d.Title,
CurrentPublishedMajor: d.CurrentPublishedMajor, CurrentPublishedMajor: d.CurrentPublishedMajor,
CurrentPublishedMinor: d.CurrentPublishedMinor, CurrentPublishedMinor: d.CurrentPublishedMinor,
TrustCenterVisibility: d.TrustCenterVisibility, TrustCenterVisibility: d.TrustCenterVisibility,