Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,20 @@ public final class NotificationSupport {
*/
static final String INBOX_URL_TOKEN = "inboxUrl";

/**
* The {@code escalation.<field>} scope - the level a schedule's days-past-due ladder placed the row
* at (issue #7276), reachable from the message text so the wording can differ per level ("a
* friendly reminder" at the first, "final notice before collection" at the last). It is also the
* NAME of the local the generated job holds that level in, which is what keeps the rendered access
* and the declaration in step.
*
* <p>
* Placeholders only, exactly like {@link NotifySupport#RECORD_SCOPE}: a recipient is a person, and
* a ladder of settings has no mailbox. One field of the level, never a walk on - a second hop would
* be a load per message, and the composed value belongs on the level itself.
*/
public static final String ESCALATION_LOCAL = "escalation";

private NotificationSupport() {}

/**
Expand Down Expand Up @@ -228,10 +242,39 @@ public static Plan plan(NotificationIntent notification, EntityIntent eventEntit
*/
public static Plan plan(NotificationIntent notification, EntityIntent eventEntity, Map<String, EntityIntent> byName,
Map<String, String> compositionParents, CrossModelLookup crossModel) {
return plan(notification, eventEntity, null, byName, compositionParents, crossModel);
}

/**
* The same translation with an escalation LADDER in scope: the message text may read one field of
* the level a schedule's {@code escalate:} placed the row at, through {@code {escalation.<field>}}
* (issue #7276). Pass {@code null} for the ladder everywhere an escalation cannot apply - a
* placeholder then stays unresolvable and degrades to its own literal text, as every unknown
* placeholder does.
*
* @param notification the notification
* @param eventEntity the entity whose event fires it
* @param escalation the escalation ladder entity, or {@code null}
* @param byName all LOCAL entities by name (to resolve same-model relation targets)
* @param compositionParents composition-parent map (to resolve a target's perspective)
* @param crossModel resolver for a cross-model relation's owner facts, or {@code null}
* @return the plan, or {@code null} if the {@code to} recipient cannot be resolved
*/
public static Plan plan(NotificationIntent notification, EntityIntent eventEntity, EntityIntent escalation,
Map<String, EntityIntent> byName, Map<String, String> compositionParents, CrossModelLookup crossModel) {
Object when = notification.getEvent()
.get("when");
return plan(notification.getTo(), notification.getSubject(), notification.getBody(), when, eventEntity, byName, compositionParents,
crossModel);
Resolver resolver = new Resolver(eventEntity, null, escalation, byName, compositionParents, crossModel);
String recipient = resolver.value(notification.getTo());
if (recipient == null) {
return null; // an unresolvable recipient relation.field - skip rather than email garbage
}
// Rendered BEFORE the loads are read: a placeholder is what registers most one-hop loads, and an
// argument list evaluated left to right would snapshot the loads before the text added any.
String subjectExpression = resolver.text(notification.getSubject());
String bodyExpression = resolver.text(notification.getBody());
return new Plan(resolver.loads(), guard(when, eventEntity, byName), recipient, subjectExpression, bodyExpression,
resolver.usesRecordUrl(), resolver.usesInboxUrl());
}

/**
Expand Down Expand Up @@ -276,7 +319,7 @@ public static Plan plan(String to, String subject, String body, Object when, Ent
*/
public static Plan plan(String to, String subject, String body, Object when, EntityIntent entity, EntityIntent anchor,
Map<String, EntityIntent> byName, Map<String, String> compositionParents, CrossModelLookup crossModel) {
Resolver resolver = new Resolver(entity, anchor, byName, compositionParents, crossModel);
Resolver resolver = new Resolver(entity, anchor, null, byName, compositionParents, crossModel);
String recipient = resolver.value(to);
if (recipient == null) {
return null; // an unresolvable recipient relation.field - skip rather than email garbage
Expand Down Expand Up @@ -394,14 +437,15 @@ static String quote(String value) {
*/
static Resolver resolver(EntityIntent entity, Map<String, EntityIntent> byName, Map<String, String> compositionParents,
CrossModelLookup crossModel) {
return new Resolver(entity, null, byName, compositionParents, crossModel);
return new Resolver(entity, null, null, byName, compositionParents, crossModel);
}

/** Resolves values/text against the event entity, accumulating the relation loads they require. */
static final class Resolver {

private final EntityIntent entity;
private final EntityIntent anchor;
private final EntityIntent escalation;
private final Map<String, EntityIntent> byName;
private final Map<String, String> compositionParents;
private final Set<String> settingEntities;
Expand All @@ -410,10 +454,11 @@ static final class Resolver {
private boolean usesRecordUrl;
private boolean usesInboxUrl;

Resolver(EntityIntent entity, EntityIntent anchor, Map<String, EntityIntent> byName, Map<String, String> compositionParents,
CrossModelLookup crossModel) {
Resolver(EntityIntent entity, EntityIntent anchor, EntityIntent escalation, Map<String, EntityIntent> byName,
Map<String, String> compositionParents, CrossModelLookup crossModel) {
this.entity = entity;
this.anchor = anchor;
this.escalation = escalation;
this.byName = byName;
this.compositionParents = compositionParents;
this.settingEntities = IntentEntities.settingEntities(byName.values());
Expand Down Expand Up @@ -503,6 +548,15 @@ String access(String path, boolean recordScope) {
usesInboxUrl = true;
return INBOX_URL_TOKEN;
}
if (recordScope && escalation != null && path.startsWith(ESCALATION_LOCAL + ".")) {
// The escalation level this row was placed at, already loaded by the generated job: one
// field of it, never a walk on - the same rule the anchor scope below states.
String field = path.substring(ESCALATION_LOCAL.length() + 1);
if (field.isEmpty() || field.indexOf('.') >= 0 || fieldOf(escalation, field) == null) {
return null;
}
return ESCALATION_LOCAL + "." + IntentNaming.pascalCase(field);
}
if (recordScope && anchor != null && path.startsWith(NotifySupport.RECORD_SCOPE + ".")) {
// The anchor record of a fan-out, already loaded by the generated code: one field of it,
// never a walk on (that would need a second load per message, and the composed value
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/*
* Copyright (c) 2010-2026 Eclipse Dirigible contributors
*
* All rights reserved. This program and the accompanying materials are made available under the
* terms of the Eclipse Public License v2.0 which accompanies this distribution, and is available at
* http://www.eclipse.org/legal/epl-v20.html
*
* SPDX-FileCopyrightText: Eclipse Dirigible contributors SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.dirigible.components.intent.model;

/**
* A days-past-due <b>escalation ladder</b> over a schedule's matched row (issue #7276): pick the
* level that applies to how overdue the row is, and let the tick advance through the ladder as the
* document ages.
*
* <p>
* The ladder is an ordinary entity of the model - the {@code kind: setting} table a dunning module
* already has ({@code ReminderLevel}: "First reminder" after 3 days, "Second reminder" after 14,
* "Final notice" after 30). {@link #getAfter()} names the integer threshold on it and
* {@link #getSince()} the date on the ROW the threshold is measured from; the level applied to a
* row is the <b>highest</b> whose threshold has been passed, and a row that has passed none is left
* alone this tick rather than mailed at the bottom level.
*
* <p>
* An escalation always accompanies a {@code generate}: the chosen level is written onto the
* generated record through {@link #getInto()}, and that record - with the level in its
* {@code generate.unique:} natural key - is what makes each level go out exactly <b>once</b>.
* Without a record there is nothing to tell a level already sent from one still due, which is why a
* {@code notify}-only escalation is refused rather than silently re-sending every tick.
*
* <p>
* Inside the accompanying {@code notify}, the chosen level's own fields are reachable as
* {@code {escalation.<field>}} placeholders - the per-level wording ("a friendly reminder" vs
* "final notice before collection") that a flat schedule cannot express.
*/
public class EscalateIntent {

/**
* The entity holding the levels - a local entity of this model, normally a {@code kind: setting}.
*/
private String ladder;

/** The integer property of {@link #ladder} holding the days-past-{@code since} threshold. */
private String after;

/** The {@code date} property of the queried row the threshold is measured from. */
private String since;

/** The property of the {@code generate} target that receives the chosen level. */
private String into;

public String getLadder() {
return ladder;
}

public void setLadder(String ladder) {
this.ladder = ladder;
}

public String getAfter() {
return after;
}

public void setAfter(String after) {
this.after = after;
}

public String getSince() {
return since;
}

public void setSince(String since) {
this.since = since;
}

public String getInto() {
return into;
}

public void setInto(String into) {
this.into = into;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,15 @@
* reused {@link GeneratesIntent#getFrom()} is the schedule's {@link #entity}; item cloning is out
* of scope here (use an on-demand {@code generates} action for document-to-document cloning).</li>
* </ul>
* Exactly one of {@code notify} / {@code generate} must be set.
* At least one of {@code notify} / {@code generate} must be set, and they may be declared
* <b>together</b> (issue #7276): one tick then both creates the record and mails about it, with the
* {@code generate}'s {@code unique:} natural key gating the send as well - so the history is a
* record of what was actually sent, and the same (document, level) is never mailed twice. A
* combined block without that key is refused: it would re-mail every matched row on every tick.
*
* <p>
* {@link #getEscalate()} adds the other half of real dunning - the days-past-due ladder that picks
* WHICH level a row is at, so the tick advances First -> Second -> Final as the document ages.
*/
public class ScheduleIntent {

Expand All @@ -50,6 +58,12 @@ public class ScheduleIntent {
private NotificationIntent notify;
private GeneratesIntent generate;

/**
* Optional days-past-due escalation ladder (issue #7276). Requires {@link #generate} - the created
* record, keyed on the chosen level, is what makes a level go out once.
*/
private EscalateIntent escalate;

public String getName() {
return name;
}
Expand Down Expand Up @@ -105,4 +119,12 @@ public GeneratesIntent getGenerate() {
public void setGenerate(GeneratesIntent generate) {
this.generate = generate;
}

public EscalateIntent getEscalate() {
return escalate;
}

public void setEscalate(EscalateIntent escalate) {
this.escalate = escalate;
}
}
Loading
Loading