Files
qbit-document-generation/foundation/src/main/java/com/kingsrook/qbits/documentgeneration/DocumentDefinitionService.java
T
james.maesandClaude Fable 5.1 313f081026
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1e25bb5a243dfdcd95d4b9a4f780200ff8c8778a] (pull_request) Successful in 12s
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 12s
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Failing after 9s
QRun-IO/ci-policy: ci / ci-result (pull_request) Failing after 11s
feat(authoring): rename document types, stock and templates natively
Add DocumentDefinitionService.rename and the native authoring actions
document_generation_{type,stock,template}_author_rename. The form loads
the current name and saves only the trimmed name; names must be nonblank,
at most 200 characters and free of control characters. The configure
permission is checked at discovery and again at save, and the native
UpdateAction keeps the host's field-level audit. GenerationStore.update
now admits an id+name-only write for type and stock rows, so geometry,
bindings, drafts, published revisions and queued pins stay immutable.
No schema change.

Carried locally by GG Forge as definition-rename.patch (pin
0.1.0-forge.ee782b7d6ea8-rename1); upstreamed so a published release can
replace that pin. The prepare-step selection message is generalised to
cover type and stock rows. Docs: AUTHORING.md describes the action,
README and RELEASE-STATUS record the addition and its verification
(./mvnw -pl foundation -am verify: 232 tests, Checkstyle and coverage
gates met, including the Docker MySQL and PostgreSQL suites).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-06 08:59:39 -06:00

713 lines
39 KiB
Java

/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.documentgeneration;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
import com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** User-defined document configuration. Saving drafts never changes publication;
** publication freezes source, dimensions, script identity and asset versions.
*******************************************************************************/
public class DocumentDefinitionService
{
private final DocumentGenerationQBitConfig config;
/*******************************************************************************
** Unpublished user-authored sources and a versioned asset manifest.
*******************************************************************************/
public record Draft(String pdfSource, String zplSource, String scriptArtifact, List<Integer> assetIds, String outputBaseName, String outputNameContextKey)
{
/***************************************************************************
** Existing direct callers keep the historical output naming convention.
***************************************************************************/
public Draft(String pdfSource, String zplSource, String scriptArtifact, List<Integer> assetIds)
{
this(pdfSource, zplSource, scriptArtifact, assetIds, null, null);
}
}
/*******************************************************************************
** Source editor snapshot with an optimistic revision token, not a mutable row.
*******************************************************************************/
public record DraftView(Integer templateId, Integer stockId, String sourceTable, Draft draft, String fingerprint) {}
/*******************************************************************************
** Inject the host's isolated validation provider.
*******************************************************************************/
public DocumentDefinitionService(DocumentGenerationQBitConfig config)
{
this.config = Objects.requireNonNull(config);
}
/***************************************************************************
** Current draft/active and unfinished-work context pins for cold maintenance.
** The owner enforces its explicit metadata authority, never caller row access.
***************************************************************************/
public List<String> publishedContextReferences(Set<String> targetTables, int limit) throws QException
{
return (GenerationWarmInventory.read(config, targetTables, limit));
}
/*******************************************************************************
** Create a business document type without a code deployment.
*******************************************************************************/
public Integer createType(String name) throws QException
{
GenerationSecurity.require("configure");
return (GenerationStore.insert(GenerationTables.TYPE, new QRecord().withValue("name", text(name, 200))).getValueInteger("id"));
}
/***************************************************************************
** Rename a definition without changing its identity, geometry or revisions.
** Native UpdateAction retains the host's ordinary field-change audit path.
***************************************************************************/
public void rename(String table, Integer id, String name) throws QException
{
GenerationSecurity.require("configure");
if(table == null || !List.of(GenerationTables.TYPE, GenerationTables.STOCK, GenerationTables.TEMPLATE).contains(table))
{
throw new QBadRequestException("Only document types, stock and templates can be renamed.");
}
String label = text(name, 200).strip();
if(label.codePoints().anyMatch(Character::isISOControl))
{
throw new QBadRequestException("Document names cannot contain control characters.");
}
GenerationStore.atomic(List.of(table), () ->
{
GenerationStore.get(table, id);
GenerationStore.update(table, new QRecord().withValue("id", id).withValue("name", label));
return (null);
});
}
/*******************************************************************************
** Stock geometry is immutable; changing a draft selects a new stock row.
*******************************************************************************/
public Integer createStock(String name, GenerationPorts.Stock stock) throws QException
{
GenerationSecurity.require("configure");
validateStock(stock);
QRecord row = stockRow(stock).withValue("name", text(name, 200));
return (GenerationStore.insert(GenerationTables.STOCK, row).getValueInteger("id"));
}
/*******************************************************************************
** Bind a type to an actual host table with explicit permitted output formats.
*******************************************************************************/
public Integer bind(Integer typeId, String table, Boolean pdf, Boolean zpl) throws QException
{
GenerationSecurity.require("configure");
GenerationStore.get(GenerationTables.TYPE, typeId);
if(table == null || table.startsWith("document_generation_") || QContext.getQInstance().getTable(table) == null
|| !(Boolean.TRUE.equals(pdf) || Boolean.TRUE.equals(zpl)))
{
throw (new QBadRequestException("A source table and at least one format are required."));
}
return (GenerationStore.insert(GenerationTables.BINDING, new QRecord().withValue("typeId", typeId).withValue("tableName", table)
.withValue("allowPdf", Boolean.TRUE.equals(pdf)).withValue("allowZpl", Boolean.TRUE.equals(zpl))).getValueInteger("id"));
}
/*******************************************************************************
** Stable template identities are permanently bound to their document type.
*******************************************************************************/
public Integer createTemplate(String name, Integer typeId, Integer stockId, String table) throws QException
{
GenerationSecurity.require("configure");
binding(typeId, table);
GenerationStore.get(GenerationTables.STOCK, stockId);
return (GenerationStore.insert(GenerationTables.TEMPLATE, new QRecord().withValue("name", text(name, 200)).withValue("typeId", typeId)
.withValue("stockId", stockId).withValue("tableName", table).withValue("outputBaseName", "document")).getValueInteger("id"));
}
/*******************************************************************************
** Create a usable PDF draft and its owned setup rows in one transaction. No
** worker runs and nothing is published. Existing templates remain unchanged.
*******************************************************************************/
public Integer createPackingSlip(String name, String table, GenerationPorts.Stock stock) throws QException
{
GenerationSecurity.require("configure");
text(name, 200);
validateStock(stock);
if(!config.getSourceTables().contains(table))
{
throw new QBadRequestException("Choose a configured source table.");
}
GenerationSecurity.requireRead(table);
PermissionsHelper.checkTablePermissionThrowing(new QueryInput(table), TablePermissionSubType.READ);
return (GenerationStore.atomic(List.of(GenerationTables.TYPE, GenerationTables.STOCK, GenerationTables.BINDING, GenerationTables.TEMPLATE), () ->
{
Integer type = createType(name);
Integer paper = createStock(name, stock);
bind(type, table, true, false);
Integer template = createTemplate(name, type, paper, table);
GenerationStore.update(GenerationTables.TEMPLATE, new QRecord().withValue("id", template)
.withValue("pdfSource", packingSlipStarter()));
return (template);
}));
}
/*******************************************************************************
** The editable starter for NEW packing slips: a reference, optional sender, ship-to,
** notes and a line table from the context. Existing templates are never rewritten.
** It names its few supported fields and never enumerates the source record.
*******************************************************************************/
static String packingSlipStarter() throws QException
{
try(var input = DocumentDefinitionService.class.getResourceAsStream("/documentgeneration/packing-slip-starter.html"))
{
if(input == null)
{
throw new IllegalStateException("Packing slip starter resource is absent.");
}
return (new String(input.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8));
}
catch(java.io.IOException | IllegalStateException failure)
{
throw new QException("Packing slip starter is unavailable.");
}
}
/*******************************************************************************
** Asset content belongs to Record Files; this is an immutable named reference.
** The renderer adapter reauthorizes asset versions for validation and use.
*******************************************************************************/
public Integer addAsset(Integer templateId, GenerationPorts.Asset asset) throws QException
{
GenerationSecurity.require("configure");
GenerationStore.get(GenerationTables.TEMPLATE, templateId);
if(asset == null || asset.name() == null || !asset.name().matches("[A-Za-z0-9][A-Za-z0-9._-]{0,199}")
|| asset.sha256() == null || !asset.sha256().matches("[a-f0-9]{64}"))
{
throw (new QBadRequestException("Asset name and SHA-256 are invalid."));
}
asset.validateFont();
return (GenerationStore.insert(GenerationTables.ASSET, new QRecord().withValue("templateId", templateId).withValue("name", asset.name())
.withValue("fileVersionId", text(asset.fileVersionId(), 255)).withValue("sha256", asset.sha256())
.withValue("mediaType", text(asset.mediaType(), 128)).withValue("fontFamily", asset.fontFamily())
.withValue("fontWeight", asset.fontWeight()).withValue("fontStyle", asset.fontStyle())).getValueInteger("id"));
}
/***************************************************************************
** Resolve retained metadata with the current actor, then atomically register
** and include the named asset in the exact draft the author inspected.
***************************************************************************/
public Integer addSelectedAsset(Integer templateId, String name, String versionId, String family, Integer weight, String style, String expectedFingerprint) throws QException
{
GenerationSecurity.require("configure");
GenerationStore.get(GenerationTables.TEMPLATE, templateId);
var choice = GenerationAssetChoices.resolve(config, versionId);
var asset = new GenerationPorts.Asset(name, choice.versionId(), choice.sha256(), choice.mediaType(), blank(family) ? null : family, weight, blank(style) ? null : style);
asset.validateFont();
return (GenerationStore.atomic(List.of(GenerationTables.TEMPLATE, GenerationTables.ASSET), () ->
{
GenerationStore.lockTemplate(templateId);
requireFingerprint(GenerationStore.get(GenerationTables.TEMPLATE, templateId), expectedFingerprint);
DraftView current = loadDraft(templateId);
List<Integer> ids = new java.util.ArrayList<>(current.draft().assetIds());
if(ids.size() >= 100)
{
throw new QBadRequestException("The template already contains the maximum asset count.");
}
Integer id = addAsset(templateId, asset);
ids.add(id);
saveDraft(templateId, current.stockId(), new Draft(current.draft().pdfSource(), current.draft().zplSource(), current.draft().scriptArtifact(), ids, current.draft().outputBaseName(), current.draft().outputNameContextKey()));
return (id);
}));
}
/*******************************************************************************
** Save bounded draft sources without activating them on generation requests.
*******************************************************************************/
public void saveDraft(Integer templateId, Integer stockId, Draft draft) throws QException
{
GenerationSecurity.require("configure");
GenerationStore.get(GenerationTables.TEMPLATE, templateId);
GenerationStore.get(GenerationTables.STOCK, stockId);
if(draft == null || (blank(draft.pdfSource()) && blank(draft.zplSource()))
|| length(draft.pdfSource()) > 262144 || length(draft.zplSource()) > 262144 || length(draft.scriptArtifact()) > 255
|| draft.assetIds() == null || draft.assetIds().size() > 100)
{
throw (new QBadRequestException("Draft sources or asset manifest exceed the supported bounds."));
}
GenerationFileNames.validatePolicy(draft.outputBaseName(), draft.outputNameContextKey(), draft.scriptArtifact());
List<Integer> ids = List.copyOf(draft.assetIds());
assets(templateId, ids);
GenerationStore.update(GenerationTables.TEMPLATE, new QRecord().withValue("id", templateId).withValue("stockId", stockId)
.withValue("pdfSource", draft.pdfSource()).withValue("zplSource", draft.zplSource())
.withValue("outputBaseName", draft.outputBaseName()).withValue("outputNameContextKey", draft.outputNameContextKey())
.withValue("scriptArtifact", blank(draft.scriptArtifact()) ? null : draft.scriptArtifact())
.withValue("assetIds", String.join(",", ids.stream().map(Object::toString).toList())));
}
/***************************************************************************
** Native source editing cannot submit an internal context reference. Preserve
** the current pin under the same optimistic draft lock as the source update.
***************************************************************************/
public void saveSources(Integer templateId, Integer stockId, String pdf, String zpl, String expectedFingerprint) throws QException
{
GenerationSecurity.require("configure");
GenerationStore.atomic(List.of(GenerationTables.TEMPLATE), () ->
{
GenerationStore.lockTemplate(templateId);
requireFingerprint(GenerationStore.get(GenerationTables.TEMPLATE, templateId), expectedFingerprint);
Draft current = loadDraft(templateId).draft();
saveDraft(templateId, stockId, new Draft(pdf, zpl, current.scriptArtifact(), current.assetIds(), current.outputBaseName(), current.outputNameContextKey()));
return (null);
});
}
/***************************************************************************
** Edit output naming independently, preserving sources/assets under the lock.
***************************************************************************/
public void saveOutputNaming(Integer templateId, String baseName, String contextKey, String expectedFingerprint) throws QException
{
GenerationSecurity.require("configure");
GenerationStore.atomic(List.of(GenerationTables.TEMPLATE), () ->
{
GenerationStore.lockTemplate(templateId);
requireFingerprint(GenerationStore.get(GenerationTables.TEMPLATE, templateId), expectedFingerprint);
var current = loadDraft(templateId);
var draft = current.draft();
saveDraft(templateId, current.stockId(), new Draft(draft.pdfSource(), draft.zplSource(), draft.scriptArtifact(), draft.assetIds(),
blank(baseName) ? null : baseName, blank(contextKey) ? null : contextKey));
return (null);
});
}
/***************************************************************************
** Included immutable metadata for a bounded native selector, never file bytes.
***************************************************************************/
public record DraftAsset(Integer id, GenerationPorts.Asset asset) {}
/***************************************************************************
** Only this template's current included references enter the native form.
***************************************************************************/
public List<DraftAsset> includedAssets(Integer templateId) throws QException
{
GenerationSecurity.require("configure");
List<Integer> ids = loadDraft(templateId).draft().assetIds();
if(ids.size() > 100)
{
throw new QBadRequestException("The included asset manifest exceeds its bounds.");
}
List<GenerationPorts.Asset> values = assets(templateId, ids);
List<DraftAsset> result = new ArrayList<>();
for(int index = 0; index < ids.size(); index++)
{
result.add(new DraftAsset(ids.get(index), values.get(index)));
}
return (List.copyOf(result));
}
/***************************************************************************
** Remove only draft membership. Old asset rows/originals/revisions are retained.
***************************************************************************/
public void removeIncludedAsset(Integer templateId, Integer assetId, String expectedFingerprint) throws QException
{
GenerationSecurity.require("configure");
GenerationStore.atomic(List.of(GenerationTables.TEMPLATE), () ->
{
GenerationStore.lockTemplate(templateId);
requireFingerprint(GenerationStore.get(GenerationTables.TEMPLATE, templateId), expectedFingerprint);
DraftView current = loadDraft(templateId);
requireIncluded(current, assetId);
List<Integer> ids = new ArrayList<>(current.draft().assetIds());
ids.remove(assetId);
saveDraft(templateId, current.stockId(), new Draft(current.draft().pdfSource(), current.draft().zplSource(), current.draft().scriptArtifact(), ids, current.draft().outputBaseName(), current.draft().outputNameContextKey()));
return (null);
});
}
/***************************************************************************
** Reauthorize replacement bytes before the transaction, then recheck membership
** and draft identity under lock. Preserve alias/font face and manifest position.
***************************************************************************/
public Integer replaceIncludedAsset(Integer templateId, Integer assetId, String versionId, String expectedFingerprint) throws QException
{
GenerationSecurity.require("configure");
requireIncluded(loadDraft(templateId), assetId);
var choice = GenerationAssetChoices.resolve(config, versionId);
return (GenerationStore.atomic(List.of(GenerationTables.TEMPLATE, GenerationTables.ASSET), () ->
{
GenerationStore.lockTemplate(templateId);
requireFingerprint(GenerationStore.get(GenerationTables.TEMPLATE, templateId), expectedFingerprint);
DraftView current = loadDraft(templateId);
requireIncluded(current, assetId);
GenerationPorts.Asset previous = assets(templateId, List.of(assetId)).getFirst();
if("font/ttf".equals(previous.mediaType()) != "font/ttf".equals(choice.mediaType()))
{
throw new QBadRequestException("Replace an image with an image, or a TrueType font with a TrueType font.");
}
Integer replacement = addAsset(templateId, new GenerationPorts.Asset(previous.name(), choice.versionId(), choice.sha256(), choice.mediaType(),
previous.fontFamily(), previous.fontWeight(), previous.fontStyle()));
List<Integer> ids = new ArrayList<>(current.draft().assetIds());
ids.set(ids.indexOf(assetId), replacement);
saveDraft(templateId, current.stockId(), new Draft(current.draft().pdfSource(), current.draft().zplSource(), current.draft().scriptArtifact(), ids, current.draft().outputBaseName(), current.draft().outputNameContextKey()));
return (replacement);
}));
}
/***************************************************************************
** Old, removed and other-template references are not current draft authority.
***************************************************************************/
private static void requireIncluded(DraftView draft, Integer assetId) throws QBadRequestException
{
if(assetId == null || !draft.draft().assetIds().contains(assetId))
{
throw new QBadRequestException("Select an asset currently included in this draft.");
}
}
/***************************************************************************
** Resolve current source-owner authority before the owned draft transaction.
** Clear is explicit; never infer it from an omitted or failed selection.
***************************************************************************/
public void selectContextScript(Integer templateId, String selectionId, boolean clear, String expectedFingerprint) throws QException
{
GenerationSecurity.require("configure");
GenerationStore.get(GenerationTables.TEMPLATE, templateId);
if(clear == !blank(selectionId))
{
throw new QBadRequestException("Select a published context script or explicitly clear it.");
}
String reference = clear ? null : GenerationContextChoices.resolve(config, selectionId).reference();
GenerationStore.atomic(List.of(GenerationTables.TEMPLATE), () ->
{
GenerationSecurity.require("configure");
GenerationStore.lockTemplate(templateId);
requireFingerprint(GenerationStore.get(GenerationTables.TEMPLATE, templateId), expectedFingerprint);
DraftView current = loadDraft(templateId);
saveDraft(templateId, current.stockId(), new Draft(current.draft().pdfSource(), current.draft().zplSource(), reference, current.draft().assetIds(), current.draft().outputBaseName(), current.draft().outputNameContextKey()));
return (null);
});
}
/*******************************************************************************
** Load the current draft for the native source editor under configure authority.
*******************************************************************************/
public DraftView loadDraft(Integer templateId) throws QException
{
if(QContext.getQSession() != null && QContext.getQSession().hasPermission("document-generation.publish"))
{
GenerationSecurity.require("publish");
}
else
{
GenerationSecurity.require("configure");
}
QRecord row = GenerationStore.get(GenerationTables.TEMPLATE, templateId);
String manifest = row.getValueString("assetIds");
List<Integer> ids = blank(manifest) ? List.of() : java.util.Arrays.stream(manifest.split(",")).map(Integer::valueOf).toList();
return (new DraftView(templateId, row.getValueInteger("stockId"), row.getValueString("tableName"),
new Draft(row.getValueString("pdfSource"), row.getValueString("zplSource"), row.getValueString("scriptArtifact"), ids, row.getValueString("outputBaseName"), row.getValueString("outputNameContextKey")), fingerprint(row)));
}
/*******************************************************************************
** Native editors cannot silently overwrite a draft changed since it was opened.
*******************************************************************************/
public void saveDraft(Integer templateId, Integer stockId, Draft draft, String expectedFingerprint) throws QException
{
GenerationSecurity.require("configure");
GenerationStore.atomic(List.of(GenerationTables.TEMPLATE), () ->
{
GenerationStore.lockTemplate(templateId);
requireFingerprint(GenerationStore.get(GenerationTables.TEMPLATE, templateId), expectedFingerprint);
saveDraft(templateId, stockId, draft);
return (null);
});
}
/*******************************************************************************
** Validate in an isolated provider, then insert an immutable revision and
** atomically switch the active reference after locking and comparing its draft;
** generation pins the selected id and is unaffected by later publication.
*******************************************************************************/
public Integer publish(Integer templateId) throws QException
{
return (freeze(templateId, null, true));
}
/*******************************************************************************
** Publish exactly the editor revision the user reviewed.
*******************************************************************************/
public Integer publish(Integer templateId, String expectedFingerprint) throws QException
{
text(expectedFingerprint, 64);
return (freeze(templateId, expectedFingerprint, true));
}
/*******************************************************************************
** Preview uses an immutable revision and never changes active publication.
*******************************************************************************/
Integer previewRevision(Integer templateId, String expectedFingerprint) throws QException
{
text(expectedFingerprint, 64);
return (freeze(templateId, expectedFingerprint, false));
}
/*******************************************************************************
** Validate outside the application JVM, then atomically bind the unchanged
** draft to its immutable source/stock/assets/build snapshot.
*******************************************************************************/
private Integer freeze(Integer templateId, String expectedFingerprint, boolean publish) throws QException
{
String actor = GenerationSecurity.require(publish ? "publish" : "configure");
QRecord draft = GenerationStore.get(GenerationTables.TEMPLATE, templateId);
String captured = fingerprint(draft);
if(expectedFingerprint != null)
{
requireFingerprint(draft, expectedFingerprint);
}
QRecord stock = GenerationStore.get(GenerationTables.STOCK, draft.getValueInteger("stockId"));
if(blank(draft.getValueString("pdfSource")) && blank(draft.getValueString("zplSource")))
{
throw (new QBadRequestException("Save a template source before publication."));
}
GenerationPorts.Revision candidate = snapshot(null, templateId, draft, stock);
String assetManifest = draft.getValueString("assetIds");
validateStock(candidate.stock());
GenerationFileNames.validatePolicy(candidate.outputBaseName(), candidate.outputNameContextKey(), candidate.scriptArtifact());
if(candidate.scriptArtifact() != null)
{
config.getContextExecution().validatePublished(actor, candidate.scriptArtifact());
}
config.getRenderer().validate(actor, candidate);
QRecord row = stockRow(candidate.stock()).withValue("templateId", templateId).withValue("tableName", candidate.sourceTable())
.withValue("pdfSource", candidate.pdfSource()).withValue("zplSource", candidate.zplSource()).withValue("scriptArtifact", candidate.scriptArtifact())
.withValue("outputBaseName", candidate.outputBaseName()).withValue("outputNameContextKey", candidate.outputNameContextKey())
.withValue("assetIds", assetManifest).withValue("publishedBy", actor).withValue("purpose", publish ? "PUBLISHED" : "PREVIEW").withValue("draftFingerprint", captured);
return (GenerationStore.atomic(List.of(GenerationTables.TEMPLATE, GenerationTables.REVISION), () ->
{
GenerationStore.lockTemplate(templateId);
if(!actor.equals(GenerationSecurity.require(publish ? "publish" : "configure")))
{
throw (new QException("Publishing actor changed during validation."));
}
requireFingerprint(GenerationStore.get(GenerationTables.TEMPLATE, templateId), captured);
Integer id = GenerationStore.insert(GenerationTables.REVISION, row).getValueInteger("id");
if(publish)
{
GenerationStore.update(GenerationTables.TEMPLATE, new QRecord().withValue("id", templateId).withValue("publishedRevisionId", id));
}
return (id);
}));
}
/*******************************************************************************
** Length framing binds every editable source field without JSON ambiguity.
*******************************************************************************/
private static String fingerprint(QRecord row)
{
StringBuilder text = new StringBuilder();
for(String field : List.of("id", "stockId", "tableName", "pdfSource", "zplSource", "scriptArtifact", "assetIds"))
{
String value = row.getValueString(field);
text.append(value == null ? -1 : value.length()).append(':');
if(value != null)
{
text.append(value);
}
}
if(row.getValueString("outputBaseName") != null || row.getValueString("outputNameContextKey") != null)
{
for(String field : List.of("outputBaseName", "outputNameContextKey"))
{
String value = row.getValueString(field);
text.append(value == null ? -1 : value.length()).append(':');
if(value != null)
{
text.append(value);
}
}
}
try
{
return (java.util.HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(text.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8))));
}
catch(java.security.NoSuchAlgorithmException impossible)
{
throw new IllegalStateException(impossible);
}
}
/*******************************************************************************
** Reject a missing token as well as stale values on editor-originated saves.
*******************************************************************************/
private static void requireFingerprint(QRecord row, String expected) throws QBadRequestException
{
if(expected == null || !Objects.equals(expected, fingerprint(row)))
{
throw new QBadRequestException("Template draft changed; reload before saving, previewing or publishing.");
}
}
/*******************************************************************************
** Pin the published immutable identity exactly once for each batch.
*******************************************************************************/
GenerationPorts.Revision published(Integer templateId) throws QException
{
QRecord template = GenerationStore.get(GenerationTables.TEMPLATE, templateId);
Integer id = template.getValueInteger("publishedRevisionId");
if(id == null)
{
throw (new QBadRequestException("This template has no published revision."));
}
return (revision(id));
}
/*******************************************************************************
** Resume a batch from its originally captured published revision.
*******************************************************************************/
GenerationPorts.Revision revision(Integer id) throws QException
{
QRecord row = GenerationStore.get(GenerationTables.REVISION, id);
return (snapshot(id, row.getValueInteger("templateId"), row, row));
}
/*******************************************************************************
** Current binding policy is checked independently from revision pinning.
*******************************************************************************/
static QRecord binding(Integer typeId, String table) throws QException
{
List<QRecord> rows = GenerationStore.query(GenerationTables.BINDING, new QQueryFilter().withCriteria("typeId", QCriteriaOperator.EQUALS, typeId)
.withCriteria("tableName", QCriteriaOperator.EQUALS, table).withLimit(1));
if(rows.isEmpty())
{
throw (new QBadRequestException("Document type is not bound to this source table."));
}
return (rows.getFirst());
}
/*******************************************************************************
** Published sources and stock are copied, never resolved through live drafts.
*******************************************************************************/
private GenerationPorts.Revision snapshot(Integer id, Integer templateId, QRecord row, QRecord stock) throws QException
{
String manifest = row.getValueString("assetIds");
List<Integer> assetIds = blank(manifest) ? List.of() : java.util.Arrays.stream(manifest.split(",")).map(Integer::valueOf).toList();
return (new GenerationPorts.Revision(id, templateId, row.getValueString("tableName"), readStock(stock), row.getValueString("pdfSource"),
row.getValueString("zplSource"), row.getValueString("scriptArtifact"), assets(templateId, assetIds), row.getValueString("outputBaseName"), row.getValueString("outputNameContextKey")));
}
/*******************************************************************************
** Require ownership of each asset reference and unique names in the manifest.
*******************************************************************************/
private List<GenerationPorts.Asset> assets(Integer templateId, List<Integer> ids) throws QException
{
List<GenerationPorts.Asset> assets = new ArrayList<>();
HashSet<String> names = new HashSet<>();
for(Integer id : ids)
{
QRecord row = GenerationStore.get(GenerationTables.ASSET, id);
if(!Objects.equals(templateId, row.getValueInteger("templateId")) || !names.add(row.getValueString("name")))
{
throw (new QBadRequestException("Asset manifest contains another template's asset or a duplicate name."));
}
assets.add(new GenerationPorts.Asset(row.getValueString("name"), row.getValueString("fileVersionId"), row.getValueString("sha256"), row.getValueString("mediaType"),
row.getValueString("fontFamily"), row.getValueInteger("fontWeight"), row.getValueString("fontStyle")));
}
return (List.copyOf(assets));
}
/*******************************************************************************
** Physical limits are explicit; dimensions are integers to avoid NaN/rounding.
*******************************************************************************/
static void validateStock(GenerationPorts.Stock stock) throws QBadRequestException
{
if(stock == null || stock.widthMicrons() == null || stock.heightMicrons() == null || stock.topMicrons() == null
|| stock.rightMicrons() == null || stock.bottomMicrons() == null || stock.leftMicrons() == null || stock.dpi() == null
|| stock.widthMicrons() < 1000 || stock.widthMicrons() > 2000000 || stock.heightMicrons() < 1000 || stock.heightMicrons() > 2000000
|| stock.topMicrons() < 0 || stock.rightMicrons() < 0 || stock.bottomMicrons() < 0 || stock.leftMicrons() < 0
|| (long) stock.topMicrons() + stock.bottomMicrons() >= stock.heightMicrons()
|| (long) stock.leftMicrons() + stock.rightMicrons() >= stock.widthMicrons() || stock.dpi() < 72 || stock.dpi() > 1200)
{
throw (new QBadRequestException("Invalid stock dimensions, margins or DPI."));
}
}
/*******************************************************************************
** Store exact micrometre dimensions on the immutable revision.
*******************************************************************************/
private QRecord stockRow(GenerationPorts.Stock stock)
{
return (new QRecord().withValue("widthMicrons", stock.widthMicrons()).withValue("heightMicrons", stock.heightMicrons())
.withValue("topMicrons", stock.topMicrons()).withValue("rightMicrons", stock.rightMicrons()).withValue("bottomMicrons", stock.bottomMicrons())
.withValue("leftMicrons", stock.leftMicrons()).withValue("dpi", stock.dpi()));
}
/*******************************************************************************
** Reconstruct a physical snapshot from native metadata fields.
*******************************************************************************/
private GenerationPorts.Stock readStock(QRecord row)
{
return (new GenerationPorts.Stock(row.getValueInteger("widthMicrons"), row.getValueInteger("heightMicrons"), row.getValueInteger("topMicrons"),
row.getValueInteger("rightMicrons"), row.getValueInteger("bottomMicrons"), row.getValueInteger("leftMicrons"), row.getValueInteger("dpi")));
}
/*******************************************************************************
** Names and opaque references have a nonempty bounded representation.
*******************************************************************************/
static String text(String value, int maximum) throws QBadRequestException
{
if(blank(value) || value.length() > maximum)
{
throw (new QBadRequestException("A required document value is missing or too long."));
}
return (value);
}
/*******************************************************************************
** Treat absent or whitespace-only source as empty.
*******************************************************************************/
private static boolean blank(String value)
{
return (value == null || value.isBlank());
}
/*******************************************************************************
** Measure optional source without treating absence as an error.
*******************************************************************************/
private static int length(String value)
{
return (value == null ? 0 : value.length());
}
}