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>
553 lines
12 KiB
Go
553 lines
12 KiB
Go
// Copyright (c) 2025-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.
|
|
|
|
package coredata
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"maps"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgconn"
|
|
"go.gearno.de/kit/pg"
|
|
"go.probo.inc/probo/pkg/gid"
|
|
"go.probo.inc/probo/pkg/page"
|
|
)
|
|
|
|
type (
|
|
DocumentVersion struct {
|
|
ID gid.GID `db:"id"`
|
|
OrganizationID gid.GID `db:"organization_id"`
|
|
DocumentID gid.GID `db:"document_id"`
|
|
Title string `db:"title"`
|
|
Major int `db:"major"`
|
|
Minor int `db:"minor"`
|
|
Classification DocumentClassification `db:"classification"`
|
|
Content string `db:"content"`
|
|
Changelog string `db:"changelog"`
|
|
Status DocumentVersionStatus `db:"status"`
|
|
PublishedAt *time.Time `db:"published_at"`
|
|
CreatedAt time.Time `db:"created_at"`
|
|
UpdatedAt time.Time `db:"updated_at"`
|
|
}
|
|
|
|
DocumentVersions []*DocumentVersion
|
|
)
|
|
|
|
// AuthorizationAttributes returns the authorization attributes for policy evaluation.
|
|
func (dv *DocumentVersion) AuthorizationAttributes(ctx context.Context, conn pg.Conn) (map[string]string, error) {
|
|
q := `
|
|
WITH document_version AS (
|
|
SELECT id, document_id, organization_id, status AS version_status
|
|
FROM document_versions
|
|
WHERE id = $1
|
|
LIMIT 1
|
|
),
|
|
document AS (
|
|
SELECT d.id, d.status
|
|
FROM documents d
|
|
INNER JOIN document_version ON d.id = document_version.document_id
|
|
),
|
|
last_quorum AS (
|
|
SELECT
|
|
q.version_id,
|
|
q.status::text AS status
|
|
FROM document_version_approval_quorums q
|
|
INNER JOIN document_version ON q.version_id = document_version.id
|
|
ORDER BY q.created_at DESC
|
|
LIMIT 1
|
|
)
|
|
SELECT
|
|
document_version.organization_id,
|
|
document.status,
|
|
document_version.version_status,
|
|
COALESCE(lq.status, '')
|
|
FROM document_version
|
|
INNER JOIN document ON document.id = document_version.document_id
|
|
LEFT JOIN last_quorum lq ON lq.version_id = document_version.id;
|
|
`
|
|
|
|
var (
|
|
organizationID gid.GID
|
|
documentStatus DocumentStatus
|
|
documentVersionStatus DocumentVersionStatus
|
|
lastQuorumStatus string
|
|
)
|
|
|
|
if err := conn.QueryRow(ctx, q, dv.ID).Scan(&organizationID, &documentStatus, &documentVersionStatus, &lastQuorumStatus); err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, ErrResourceNotFound
|
|
}
|
|
return nil, fmt.Errorf("cannot query document version authorization attributes: %w", err)
|
|
}
|
|
|
|
return map[string]string{
|
|
"organization_id": organizationID.String(),
|
|
"document_status": documentStatus.String(),
|
|
"version_status": documentVersionStatus.String(),
|
|
"last_quorum_status": lastQuorumStatus,
|
|
}, nil
|
|
}
|
|
|
|
func (dv *DocumentVersions) LoadByDocumentID(
|
|
ctx context.Context,
|
|
conn pg.Conn,
|
|
scope Scoper,
|
|
documentID gid.GID,
|
|
cursor *page.Cursor[DocumentVersionOrderField],
|
|
filter *DocumentVersionFilter,
|
|
) error {
|
|
q := `
|
|
SELECT
|
|
id,
|
|
organization_id,
|
|
document_id,
|
|
title,
|
|
major,
|
|
minor,
|
|
classification,
|
|
content,
|
|
changelog,
|
|
status,
|
|
published_at,
|
|
created_at,
|
|
updated_at
|
|
FROM
|
|
document_versions
|
|
WHERE
|
|
%s
|
|
AND document_id = @document_id
|
|
AND %s
|
|
AND %s
|
|
`
|
|
q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment(), cursor.SQLFragment())
|
|
|
|
args := pgx.StrictNamedArgs{
|
|
"document_id": documentID,
|
|
}
|
|
maps.Copy(args, scope.SQLArguments())
|
|
maps.Copy(args, cursor.SQLArguments())
|
|
maps.Copy(args, filter.SQLArguments())
|
|
|
|
rows, err := conn.Query(ctx, q, args)
|
|
if err != nil {
|
|
return fmt.Errorf("cannot query document versions: %w", err)
|
|
}
|
|
|
|
documentVersions, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[DocumentVersion])
|
|
if err != nil {
|
|
return fmt.Errorf("cannot collect document versions: %w", err)
|
|
}
|
|
|
|
*dv = documentVersions
|
|
|
|
return nil
|
|
}
|
|
|
|
func (dv DocumentVersion) CursorKey(orderBy DocumentVersionOrderField) page.CursorKey {
|
|
switch orderBy {
|
|
case DocumentVersionOrderFieldCreatedAt:
|
|
return page.NewCursorKey(dv.ID, dv.CreatedAt)
|
|
}
|
|
|
|
panic(fmt.Sprintf("unsupported order by: %s", orderBy))
|
|
}
|
|
|
|
func (dv *DocumentVersion) LoadByID(
|
|
ctx context.Context,
|
|
conn pg.Conn,
|
|
scope Scoper,
|
|
documentVersionID gid.GID,
|
|
) error {
|
|
q := `
|
|
SELECT
|
|
id,
|
|
organization_id,
|
|
document_id,
|
|
title,
|
|
major,
|
|
minor,
|
|
classification,
|
|
content,
|
|
changelog,
|
|
status,
|
|
published_at,
|
|
created_at,
|
|
updated_at
|
|
FROM
|
|
document_versions
|
|
WHERE
|
|
%s
|
|
AND id = @document_version_id
|
|
LIMIT 1;
|
|
`
|
|
|
|
q = fmt.Sprintf(q, scope.SQLFragment())
|
|
|
|
args := pgx.StrictNamedArgs{
|
|
"document_version_id": documentVersionID,
|
|
}
|
|
maps.Copy(args, scope.SQLArguments())
|
|
|
|
rows, err := conn.Query(ctx, q, args)
|
|
if err != nil {
|
|
return fmt.Errorf("cannot query document versions: %w", err)
|
|
}
|
|
|
|
documentVersion, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[DocumentVersion])
|
|
if err != nil {
|
|
return fmt.Errorf("cannot collect document version: %w", err)
|
|
}
|
|
|
|
*dv = documentVersion
|
|
|
|
return nil
|
|
}
|
|
|
|
func (dv DocumentVersion) Insert(
|
|
ctx context.Context,
|
|
conn pg.Conn,
|
|
scope Scoper,
|
|
) error {
|
|
q := `
|
|
INSERT INTO document_versions (
|
|
tenant_id,
|
|
id,
|
|
organization_id,
|
|
document_id,
|
|
title,
|
|
major,
|
|
minor,
|
|
classification,
|
|
content,
|
|
changelog,
|
|
status,
|
|
created_at,
|
|
updated_at
|
|
)
|
|
VALUES (
|
|
@tenant_id,
|
|
@id,
|
|
@organization_id,
|
|
@document_id,
|
|
@title,
|
|
@major,
|
|
@minor,
|
|
@classification,
|
|
@content,
|
|
@changelog,
|
|
@status,
|
|
@created_at,
|
|
@updated_at
|
|
)
|
|
`
|
|
args := pgx.StrictNamedArgs{
|
|
"tenant_id": scope.GetTenantID(),
|
|
"id": dv.ID,
|
|
"organization_id": dv.OrganizationID,
|
|
"document_id": dv.DocumentID,
|
|
"title": dv.Title,
|
|
"major": dv.Major,
|
|
"minor": dv.Minor,
|
|
"classification": dv.Classification,
|
|
"content": dv.Content,
|
|
"changelog": dv.Changelog,
|
|
"status": dv.Status,
|
|
"created_at": dv.CreatedAt,
|
|
"updated_at": dv.UpdatedAt,
|
|
}
|
|
|
|
_, err := conn.Exec(ctx, q, args)
|
|
if err != nil {
|
|
var pgErr *pgconn.PgError
|
|
if errors.As(err, &pgErr) {
|
|
if pgErr.Code == "23505" {
|
|
if pgErr.ConstraintName == "document_versions_document_id_major_minor_key" || pgErr.ConstraintName == "document_one_draft_version_idx" {
|
|
return ErrResourceAlreadyExists
|
|
}
|
|
}
|
|
}
|
|
return fmt.Errorf("error creating document version: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (dv *DocumentVersion) LoadByDocumentIDAndVersion(
|
|
ctx context.Context,
|
|
conn pg.Conn,
|
|
scope Scoper,
|
|
documentID gid.GID,
|
|
major int,
|
|
minor int,
|
|
) error {
|
|
q := `
|
|
SELECT
|
|
id,
|
|
organization_id,
|
|
document_id,
|
|
title,
|
|
major,
|
|
minor,
|
|
classification,
|
|
content,
|
|
changelog,
|
|
status,
|
|
published_at,
|
|
created_at,
|
|
updated_at
|
|
FROM
|
|
document_versions
|
|
WHERE
|
|
%s
|
|
AND document_id = @document_id
|
|
AND major = @major
|
|
AND minor = @minor
|
|
LIMIT 1;
|
|
`
|
|
|
|
q = fmt.Sprintf(q, scope.SQLFragment())
|
|
|
|
args := pgx.StrictNamedArgs{
|
|
"document_id": documentID,
|
|
"major": major,
|
|
"minor": minor,
|
|
}
|
|
maps.Copy(args, scope.SQLArguments())
|
|
|
|
rows, err := conn.Query(ctx, q, args)
|
|
if err != nil {
|
|
return fmt.Errorf("cannot query document versions: %w", err)
|
|
}
|
|
|
|
documentVersion, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[DocumentVersion])
|
|
if err != nil {
|
|
return fmt.Errorf("cannot collect document version: %w", err)
|
|
}
|
|
|
|
*dv = documentVersion
|
|
|
|
return nil
|
|
}
|
|
|
|
func (dv *DocumentVersion) LoadLatestVersion(
|
|
ctx context.Context,
|
|
conn pg.Conn,
|
|
scope Scoper,
|
|
documentID gid.GID,
|
|
) error {
|
|
q := `
|
|
SELECT
|
|
id,
|
|
organization_id,
|
|
document_id,
|
|
title,
|
|
major,
|
|
minor,
|
|
classification,
|
|
content,
|
|
changelog,
|
|
status,
|
|
published_at,
|
|
created_at,
|
|
updated_at
|
|
FROM
|
|
document_versions
|
|
WHERE
|
|
%s
|
|
AND document_id = @document_id
|
|
ORDER BY created_at DESC
|
|
LIMIT 1;
|
|
`
|
|
q = fmt.Sprintf(q, scope.SQLFragment())
|
|
|
|
args := pgx.StrictNamedArgs{
|
|
"document_id": documentID,
|
|
}
|
|
maps.Copy(args, scope.SQLArguments())
|
|
|
|
rows, err := conn.Query(ctx, q, args)
|
|
if err != nil {
|
|
return fmt.Errorf("cannot query document versions: %w", err)
|
|
}
|
|
|
|
documentVersion, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[DocumentVersion])
|
|
if err != nil {
|
|
return fmt.Errorf("cannot collect document version: %w", err)
|
|
}
|
|
|
|
*dv = documentVersion
|
|
|
|
return nil
|
|
}
|
|
|
|
func (dv *DocumentVersion) LoadLatestPublishedVersion(
|
|
ctx context.Context,
|
|
conn pg.Conn,
|
|
scope Scoper,
|
|
documentID gid.GID,
|
|
) error {
|
|
q := `
|
|
SELECT
|
|
id,
|
|
organization_id,
|
|
document_id,
|
|
title,
|
|
major,
|
|
minor,
|
|
classification,
|
|
content,
|
|
changelog,
|
|
status,
|
|
published_at,
|
|
created_at,
|
|
updated_at
|
|
FROM
|
|
document_versions
|
|
WHERE
|
|
%s
|
|
AND document_id = @document_id
|
|
AND status = @status
|
|
ORDER BY published_at DESC
|
|
LIMIT 1;
|
|
`
|
|
q = fmt.Sprintf(q, scope.SQLFragment())
|
|
|
|
args := pgx.StrictNamedArgs{
|
|
"document_id": documentID,
|
|
"status": DocumentVersionStatusPublished,
|
|
}
|
|
maps.Copy(args, scope.SQLArguments())
|
|
|
|
rows, err := conn.Query(ctx, q, args)
|
|
if err != nil {
|
|
return fmt.Errorf("cannot query document versions: %w", err)
|
|
}
|
|
|
|
documentVersion, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[DocumentVersion])
|
|
if err != nil {
|
|
return fmt.Errorf("cannot collect document version: %w", err)
|
|
}
|
|
|
|
*dv = documentVersion
|
|
|
|
return nil
|
|
}
|
|
|
|
func (dv DocumentVersion) Update(
|
|
ctx context.Context,
|
|
conn pg.Conn,
|
|
scope Scoper,
|
|
) error {
|
|
q := `
|
|
UPDATE document_versions SET
|
|
title = @title,
|
|
major = @major,
|
|
minor = @minor,
|
|
changelog = @changelog,
|
|
status = @status,
|
|
content = @content,
|
|
published_at = @published_at,
|
|
classification = @classification,
|
|
updated_at = @updated_at
|
|
WHERE %s
|
|
AND id = @document_version_id
|
|
`
|
|
|
|
q = fmt.Sprintf(q, scope.SQLFragment())
|
|
|
|
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,
|
|
"published_at": dv.PublishedAt,
|
|
"classification": dv.Classification,
|
|
"updated_at": dv.UpdatedAt,
|
|
}
|
|
maps.Copy(args, scope.SQLArguments())
|
|
|
|
_, err := conn.Exec(ctx, q, args)
|
|
if err != nil {
|
|
return fmt.Errorf("cannot update document version: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (dv DocumentVersion) Delete(
|
|
ctx context.Context,
|
|
conn pg.Conn,
|
|
scope Scoper,
|
|
) error {
|
|
q := `
|
|
DELETE FROM document_versions
|
|
WHERE %s
|
|
AND id = @document_version_id
|
|
`
|
|
q = fmt.Sprintf(q, scope.SQLFragment())
|
|
|
|
args := pgx.NamedArgs{
|
|
"document_version_id": dv.ID,
|
|
}
|
|
|
|
maps.Copy(args, scope.SQLArguments())
|
|
|
|
_, err := conn.Exec(ctx, q, args)
|
|
if err != nil {
|
|
return fmt.Errorf("cannot delete document version: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (dv *DocumentVersions) CountByDocumentID(
|
|
ctx context.Context,
|
|
conn pg.Conn,
|
|
scope Scoper,
|
|
documentID gid.GID,
|
|
filter *DocumentVersionFilter,
|
|
) (int, error) {
|
|
q := `
|
|
SELECT
|
|
COUNT(id)
|
|
FROM
|
|
document_versions
|
|
WHERE
|
|
%s
|
|
AND document_id = @document_id
|
|
AND %s
|
|
`
|
|
|
|
q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment())
|
|
|
|
args := pgx.NamedArgs{"document_id": documentID}
|
|
maps.Copy(args, scope.SQLArguments())
|
|
maps.Copy(args, filter.SQLArguments())
|
|
|
|
row := conn.QueryRow(ctx, q, args)
|
|
var count int
|
|
if err := row.Scan(&count); err != nil {
|
|
return 0, fmt.Errorf("cannot scan count: %w", err)
|
|
}
|
|
|
|
return count, nil
|
|
}
|