working on meaningfull messages for task and project related events

Signed-off-by: Stephan Richter <s.richter@srsoftware.de>
This commit is contained in:
2026-01-19 21:09:25 +01:00
parent be8f21c734
commit a8d1b376a2
11 changed files with 148 additions and 32 deletions

View File

@@ -64,7 +64,7 @@ public class Application {
var server = HttpServer.create(new InetSocketAddress(port), 0);
try {
new Translations().bindPath("/api/translations").on(server);
new Translations(config).bindPath("/api/translations").on(server);
new JournalModule(config).bindPath("/api/journal").on(server);
new MessageApi().bindPath("/api/bus").on(server);
new MessageSystem(config).bindPath("/api/message").on(server);

View File

@@ -18,6 +18,7 @@ public abstract class Event<Payload extends Mappable> {
public enum EventType {
CREATE,
MEMBER_ADDED,
UPDATE,
DELETE;
}
@@ -59,7 +60,7 @@ public abstract class Event<Payload extends Mappable> {
}
public Optional<String> diff(){
return oldData == null ? empty() : of(Diff.MapDiff.diff(dropMarkdown(oldData),dropMarkdown(payload.toMap())));
return oldData == null ? empty() : of(Diff.MapDiff.diff(filter(oldData),filter(payload.toMap())));
}
@@ -67,6 +68,10 @@ public abstract class Event<Payload extends Mappable> {
return eventType;
}
protected Map<String,Object> filter(Map<String,Object> map){
return dropMarkdown(map);
}
public boolean isIntendedFor(UmbrellaUser user){
return audience().contains(user);
}
@@ -93,6 +98,10 @@ public abstract class Event<Payload extends Mappable> {
return module;
}
protected Map<String, Object> oldData() {
return oldData;
}
public Payload payload(){
return payload;
}

View File

@@ -1,23 +1,36 @@
/* © SRSoftware 2025 */
package de.srsoftware.umbrella.messagebus.events;
import static de.srsoftware.umbrella.core.constants.Field.OBJECT;
import static de.srsoftware.umbrella.core.constants.Field.USER;
import static de.srsoftware.umbrella.core.ModuleRegistry.projectService;
import static de.srsoftware.umbrella.core.ModuleRegistry.taskService;
import static de.srsoftware.umbrella.core.constants.Field.*;
import static de.srsoftware.umbrella.core.constants.Module.PROJECT;
import static de.srsoftware.umbrella.core.model.Translatable.t;
import static de.srsoftware.umbrella.messagebus.events.Event.EventType.MEMBER_ADDED;
import de.srsoftware.umbrella.core.constants.Field;
import de.srsoftware.umbrella.core.model.*;
import java.util.Collection;
import java.util.List;
import java.util.Map;
public class ProjectEvent extends Event<Project>{
private UmbrellaUser newMember;
public ProjectEvent(UmbrellaUser initiator, Project project, EventType type){
super(initiator, PROJECT, project, type);
newMember = null;
}
public ProjectEvent(UmbrellaUser initiator, Project project, Map<String, Object> oldData){
super(initiator, PROJECT, project, oldData);
newMember = null;
}
public ProjectEvent(UmbrellaUser initiator, Project project, UmbrellaUser newMember){
super(initiator, PROJECT, project, MEMBER_ADDED);
this.newMember = newMember;
}
@Override
@@ -27,10 +40,30 @@ public class ProjectEvent extends Event<Project>{
@Override
public Translatable describe() {
return diff().map(UnTranslatable::new).map(Translatable.class::cast)
.orElseGet(() -> t("[TODO: {object}.describe]",OBJECT,this.getClass().getSimpleName()));
return switch (eventType()){
case CREATE -> describeCreate();
case DELETE -> t("The project '{project}' has been deleted by {user}", Field.PROJECT, payload().name(), USER, initiator().name());
case MEMBER_ADDED -> describeMemberAdded();
case UPDATE -> describeUpdate();
};
}
private Translatable describeCreate() {
var head = t("You have been added to the new project '{project}', created by {user}':\n\n{body}", Field.PROJECT, payload().name(), BODY, payload().description(), USER, initiator().name());
return t("{head}\n\n{link}","head",head,"link",link());
}
private Translatable describeMemberAdded() {
var head = t("'{name}' has been added to '{project}' by '{user}'.",NAME,newMember.name(),Field.PROJECT,payload().name(),USER,initiator().name());
return t("{head}\n\n{link}","head",head,"link",link());
}
private Translatable describeUpdate() {
var head = t("Changes in project '{project}':\n\n{body}",Field.PROJECT,payload().name(),BODY,diff().orElse(""));
return t("{head}\n\n{link}","head",head,"link",link());
}
@Override
public boolean isIntendedFor(UmbrellaUser user) {
for (var member : payload().members().values()){
@@ -39,8 +72,13 @@ public class ProjectEvent extends Event<Project>{
return false;
}
private Translatable link() {
return t("You can view/edit this project at {base_url}/project/{id}/view",ID,payload().id());
}
@Override
public Translatable subject() {
return t("{user} edited {object}",USER,initiator(),OBJECT,payload());
return t("{user} edited {object}",USER,initiator(),OBJECT,payload().name());
}
}

View File

@@ -7,6 +7,7 @@ import static de.srsoftware.umbrella.core.ModuleRegistry.taskService;
import static de.srsoftware.umbrella.core.constants.Field.*;
import static de.srsoftware.umbrella.core.constants.Module.TASK;
import static de.srsoftware.umbrella.core.model.Translatable.t;
import static de.srsoftware.umbrella.messagebus.events.Event.EventType.MEMBER_ADDED;
import de.srsoftware.umbrella.core.ModuleRegistry;
import de.srsoftware.umbrella.core.constants.Field;
@@ -17,12 +18,21 @@ import java.util.Map;
public class TaskEvent extends Event<Task>{
private final UmbrellaUser newMember;
public TaskEvent(UmbrellaUser initiator, Task task, EventType type){
super(initiator, TASK, task, type);
newMember = null;
}
public TaskEvent(UmbrellaUser initiator, Task task, Map<String, Object> oldData){
super(initiator, TASK, task, oldData);
newMember = null;
}
public TaskEvent(UmbrellaUser initiator, Task task, UmbrellaUser newMember) {
super(initiator, TASK, task, MEMBER_ADDED);
this.newMember = newMember;
}
@Override
@@ -33,25 +43,45 @@ public class TaskEvent extends Event<Task>{
@Override
public Translatable describe() {
return switch (eventType()){
case CREATE -> {
String parentName = null;
var pid = payload().parentTaskId();
if (pid != null) {
var parent = taskService().load(List.of(pid)).get(pid);
if (parent != null) parentName = parent.name();
}
if (parentName == null){
var project = projectService().load(payload().projectId());
if (project != null) parentName = project.name();
}
if (parentName == null) parentName = "?";
yield t("'{task}' has been added to '{object}':\n\n{body}", Field.TASK, payload().name(), OBJECT, parentName, BODY, payload().description());
}
case CREATE -> describeCreate();
case DELETE -> t("The task '{task}' has been deleted by {user}",Field.TASK, payload().name(), USER, initiator().name());
case UPDATE -> t("Changes in task '{task}':\n\n{body}",Field.TASK,payload().name(),BODY,diff().orElse(""));
case MEMBER_ADDED -> describeMemberAdded();
case UPDATE -> describeUpdate();
};
}
private Translatable describeMemberAdded() {
var head = t("'{name}' has been added to '{task}' by '{user}'.",NAME,newMember.name(),Field.TASK,payload().name(),USER,initiator().name());
return t("{head}\n\n{link}","head",head,"link",link());
}
private Translatable describeCreate() {
String parentName = null;
var pid = payload().parentTaskId();
if (pid != null) {
var parent = taskService().load(List.of(pid)).get(pid);
if (parent != null) parentName = parent.name();
}
if (parentName == null){
var project = projectService().load(payload().projectId());
if (project != null) parentName = project.name();
}
if (parentName == null) parentName = "?";
var head = t("'{task}' has been added to '{object}':\n\n{body}", Field.TASK, payload().name(), OBJECT, parentName, BODY, payload().description());
return t("{head}\n\n{link}","head",head,"link",link());
}
private Translatable describeUpdate() {
var head = t("Changes in task '{task}':\n\n{body}",Field.TASK,payload().name(),BODY,diff().orElse(""));
return t("{head}\n\n{link}","head",head,"link",link());
}
@Override
protected Map<String, Object> filter(Map<String, Object> map) {
map.remove(MEMBERS);
return super.filter(map);
}
@Override
public boolean isIntendedFor(UmbrellaUser user) {
for (var member : payload().members().values()){
@@ -60,11 +90,15 @@ public class TaskEvent extends Event<Task>{
return false;
}
private Translatable link() {
return t("You can view/edit this task at {base_url}/task/{id}/view",ID,payload().id());
}
@Override
public Translatable subject() {
return switch (eventType()){
case CREATE -> t("The task '{task}' has been created", Field.TASK, payload().name());
case DELETE -> t("The task '{task}' has been deleted",Field.TASK, payload().name());
case UPDATE -> t("Task '{task} was edited",Field.TASK,payload().name());
case MEMBER_ADDED, UPDATE -> t("Task '{task}' was edited",Field.TASK,payload().name());
};
}}

View File

@@ -9,6 +9,7 @@ public class Field {
public static final String ATTACHMENTS = "attachments";
public static final String AUTHORIZATION = "Authorization";
public static final String BASE_URL = "umbrella.base_url";
public static final String BANK_ACCOUNT = "bank_account";
public static final String BODY = "body";
@@ -114,6 +115,7 @@ public class Field {
public static final String PRICE = "single_price";
public static final String PRICE_FORMAT = "price_format";
public static final String PRIORITY = "priority";
public static final String PROJECT = "project";
public static final String PROJECT_ID = "project_id";
public static final String PROPERTIES = "properties";

View File

@@ -50,11 +50,12 @@ public class ProjectModule extends BaseHandler implements ProjectService {
ModuleRegistry.add(this);
}
private void addMember(Project project, long userId) {
private UmbrellaUser addMember(Project project, long userId) {
var user = userService().loadUser(userId);
var member = new Member(user,READ_ONLY);
project.members().put(userId,member);
project.dirty(MEMBERS);
return user;
}
@Override
@@ -217,12 +218,13 @@ public class ProjectModule extends BaseHandler implements ProjectService {
if (!project.hasMember(user)) throw notAmember(t(PROJECT_WITH_ID,ID,project.name()));
var old = project.toMap();
var json = json(ex);
UmbrellaUser newMember = null;
if (json.has(DROP_MEMBER) && json.get(DROP_MEMBER) instanceof Number id) dropMember(project,id.longValue());
if (json.has(MEMBERS) && json.get(MEMBERS) instanceof JSONObject memberJson) patchMembers(project,memberJson);
if (json.has(NEW_MEMBER) && json.get(NEW_MEMBER) instanceof Number num) addMember(project,num.longValue());
if (json.has(NEW_MEMBER) && json.get(NEW_MEMBER) instanceof Number num) newMember = addMember(project,num.longValue());
project = projectDb.save(project.patch(json), user);
messageBus().dispatch(new ProjectEvent(user,project, old));
messageBus().dispatch(newMember != null ? new ProjectEvent(user,project,newMember) : new ProjectEvent(user,project, old));
return sendContent(ex,project.toMap());
}

View File

@@ -7,6 +7,7 @@ import static de.srsoftware.tools.jdbc.Query.SelectQuery.ALL;
import static de.srsoftware.umbrella.core.Errors.*;
import static de.srsoftware.umbrella.core.constants.Constants.TABLE_SETTINGS;
import static de.srsoftware.umbrella.core.constants.Field.*;
import static de.srsoftware.umbrella.core.constants.Field.PROJECT;
import static de.srsoftware.umbrella.core.constants.Field.TYPE;
import static de.srsoftware.umbrella.core.constants.Text.*;
import static de.srsoftware.umbrella.core.exceptions.UmbrellaException.*;

View File

@@ -7,6 +7,7 @@ import static de.srsoftware.umbrella.core.ModuleRegistry.*;
import static de.srsoftware.umbrella.core.ResponseCode.HTTP_NOT_IMPLEMENTED;
import static de.srsoftware.umbrella.core.Util.mapValues;
import static de.srsoftware.umbrella.core.constants.Field.*;
import static de.srsoftware.umbrella.core.constants.Field.PROJECT;
import static de.srsoftware.umbrella.core.constants.Field.TAGS;
import static de.srsoftware.umbrella.core.constants.Field.TASKS;
import static de.srsoftware.umbrella.core.constants.Module.TASK;
@@ -56,11 +57,12 @@ public class TaskModule extends BaseHandler implements TaskService {
ModuleRegistry.add(this);
}
private void addMember(Task task, long userId) {
private UmbrellaUser addMember(Task task, long userId) {
var user = userService().loadUser(userId);
var member = new Member(user, READ_ONLY);
task.members().put(userId, member);
task.dirty(MEMBERS);
return user;
}
private boolean deleteTask(HttpExchange ex, long taskId, UmbrellaUser user) throws IOException {
@@ -328,12 +330,15 @@ public class TaskModule extends BaseHandler implements TaskService {
var member = task.members().get(user.id());
if (member == null || member.permission() == READ_ONLY) throw forbidden("You are not a allowed to edit {object}!", OBJECT, task.name());
var json = json(ex);
if (json.has(PARENT_TASK_ID) && json.get(PARENT_TASK_ID) instanceof Number ptid && newParentIsSubtask(task, ptid.longValue())) throw forbidden("Task must not be sub-task of itself.");
UmbrellaUser newMember = null;
if (json.has(DROP_MEMBER) && json.get(DROP_MEMBER) instanceof Number id) dropMember(task, id.longValue());
if (json.has(MEMBERS) && json.get(MEMBERS) instanceof JSONObject memberJson) patchMembers(task, memberJson);
if (json.has(NEW_MEMBER) && json.get(NEW_MEMBER) instanceof Number num) addMember(task, num.longValue());
if (json.has(PARENT_TASK_ID) && json.get(PARENT_TASK_ID) instanceof Number ptid && newParentIsSubtask(task, ptid.longValue())) throw forbidden("Task must not be sub-task of itself.");
if (json.has(NEW_MEMBER) && json.get(NEW_MEMBER) instanceof Number num) newMember = addMember(task, num.longValue());
task = taskDb.save(task.patch(json)).tags(tagService().getTags(TASK, taskId, user));
messageBus().dispatch(new TaskEvent(user, task, old));
messageBus().dispatch(newMember != null ? new TaskEvent(user, task, newMember) : new TaskEvent(user, task, old));
return sendContent(ex, task);
}

View File

@@ -1,10 +1,13 @@
/* © SRSoftware 2025 */
package de.srsoftware.umbrella.translations;
import static de.srsoftware.umbrella.core.constants.Field.BASE_URL;
import static de.srsoftware.umbrella.core.exceptions.UmbrellaException.missingField;
import static java.lang.System.Logger.Level.WARNING;
import static java.nio.charset.StandardCharsets.UTF_8;
import com.sun.net.httpserver.HttpExchange;
import de.srsoftware.configuration.Configuration;
import de.srsoftware.tools.Path;
import de.srsoftware.tools.PathHandler;
import de.srsoftware.umbrella.core.ModuleRegistry;
@@ -14,15 +17,21 @@ import java.io.IOException;
import java.net.URL;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import org.json.JSONObject;
public class Translations extends PathHandler implements Translator {
private static final System.Logger LOG = System.getLogger("Translations");
private final String baseUrl;
private HashMap<String, JSONObject> translations = new HashMap<>();
public Translations() {
public Translations(Configuration config) {
ModuleRegistry.add(this);
Optional<String> baseUrl = config.get(BASE_URL);
if (baseUrl.isEmpty()) throw missingField(BASE_URL);
this.baseUrl = baseUrl.get();
}
@Override
@@ -70,7 +79,7 @@ public class Translations extends PathHandler implements Translator {
if (fills != null) for (var entry : fills.entrySet()) {
translated = translated.replaceAll("\\{"+entry.getKey()+"}",entry.getValue());
}
return translated;
return translated.replaceAll("\\{base_url}",baseUrl);
} catch (IOException e) {
return text;
}

View File

@@ -18,6 +18,7 @@
"by": "von",
"cancel": "abbrechen",
"Changes in task '{task}':\n\n{body}": "Änderungen an Aufgabe '{task}':\n\n{body}",
"choose_type": "Typ wählen",
"click_to_edit": "Anklicken zum Bearbeiten",
"client_id": "Client-ID",
@@ -218,6 +219,7 @@
"my files": "Meine Dateien",
"name": "Name",
"'{name}' has been added to '{task}' by '{user}'.": "'{name}' wurde von {user} zu '{task}' hinzugefügt.",
"net_price": "Nettopreis",
"net_sum": "Netto-Summe",
"new_contact": "neuer Kontakt",
@@ -332,6 +334,8 @@
"tags": "Tags",
"task": "Aufgabe",
"task_list": "Aufgabenliste",
"'{task}' has been added to '{object}':\n\n{body}": "'{task}' wurde zu '{object}' hinzugefügt:\n\n{body}",
"Task '{task}' was edited": "Aufgabe '{task}' wurde bearbeitet",
"tasks": "Aufgaben",
"tasks_for_tag": "Aufgaben mit Tag „{tag}“",
"tax_id": "Steuernummer",
@@ -339,6 +343,9 @@
"tax_rate": "Steuersatz",
"template": "Vorlage",
"theme": "Design",
"The task '{task}' has been created": "Die Aufgabe '{task}' wurde angelegt",
"The task '{task}' has been deleted": "Die Aufgabe '{task}' wurde gelöscht",
"The task '{task}' has been deleted by {user}": "Die Aufgabe '{task}' wurde von {user} bearbeitet",
"time ({id})": "Zeit ({id})",
"times": "Zeiten",
"timetracking": "Zeiterfassung",
@@ -385,6 +392,7 @@
"visible_to_guests": "Für Besucher sichtbar",
"year": "Jahr",
"You can view/edit this task at {base_url}/task/{id}/view": "Du kannst diese Aufgabe unter {base_url}/task/{id}/view ansehen/bearbeiten.",
"Your token to create a new password" : "Ihr Token zum Erstellen eines neuen Passworts",
"your_profile": "dein Profil"
}

View File

@@ -18,6 +18,7 @@
"by": "by",
"cancel": "cancel",
"Changes in task '{task}':\n\n{body}": "Changes in task '{task}':\n\n{body}",
"choose_type": "choose type",
"click_to_edit": "click to edit",
"client_id": "client ID",
@@ -218,6 +219,7 @@
"my files": "my files",
"name": "Name",
"'{name}' has been added to '{task}' by '{user}'.": "'{name}' has been added to '{task}' by '{user}'.",
"net_price": "net price",
"net_sum": "net sum",
"new_contact": "new contact",
@@ -332,6 +334,8 @@
"tags": "tags",
"task": "task",
"task_list": "task list",
"'{task}' has been added to '{object}':\n\n{body}": "'{task}' has been added to '{object}':\n\n{body}",
"Task '{task}' was edited": "Task '{task}' was edited",
"tasks": "tasks",
"tasks_for_tag": "tasks with tag „{tag}“",
"tax_id": "tax ID",
@@ -339,6 +343,9 @@
"tax_rate": "tax rate",
"template": "template",
"theme": "design",
"The task '{task}' has been created": "The task '{task}' has been created",
"The task '{task}' has been deleted": "The task '{task}' has been deleted",
"The task '{task}' has been deleted by {user}": "The task '{task}' has been deleted by {user}",
"time ({id})": "time ({id})",
"times": "times",
"timetracking": "time tracking",
@@ -385,6 +392,7 @@
"visible_to_guests": "visible to guests",
"year": "year",
"You can view/edit this task at {base_url}/task/{id}/view": "You can view/edit this task at {base_url}/task/{id}/view",
"Your token to create a new password" : "Your token to create a new password",
"your_profile": "your profile"
}