From a8d1b376a22247d819fd3fb628ffc1e3acd1b6ef Mon Sep 17 00:00:00 2001 From: Stephan Richter Date: Mon, 19 Jan 2026 21:09:25 +0100 Subject: [PATCH] working on meaningfull messages for task and project related events Signed-off-by: Stephan Richter --- .../umbrella/backend/Application.java | 2 +- .../umbrella/messagebus/events/Event.java | 11 +++- .../messagebus/events/ProjectEvent.java | 48 ++++++++++++-- .../umbrella/messagebus/events/TaskEvent.java | 66 ++++++++++++++----- .../umbrella/core/constants/Field.java | 2 + .../umbrella/project/ProjectModule.java | 8 ++- .../srsoftware/umbrella/project/SqliteDb.java | 1 + .../srsoftware/umbrella/task/TaskModule.java | 13 ++-- .../umbrella/translations/Translations.java | 13 +++- translations/src/main/resources/de.json | 8 +++ translations/src/main/resources/en.json | 8 +++ 11 files changed, 148 insertions(+), 32 deletions(-) diff --git a/backend/src/main/java/de/srsoftware/umbrella/backend/Application.java b/backend/src/main/java/de/srsoftware/umbrella/backend/Application.java index 79bf255a..6e0b94f0 100644 --- a/backend/src/main/java/de/srsoftware/umbrella/backend/Application.java +++ b/backend/src/main/java/de/srsoftware/umbrella/backend/Application.java @@ -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); diff --git a/bus/src/main/java/de/srsoftware/umbrella/messagebus/events/Event.java b/bus/src/main/java/de/srsoftware/umbrella/messagebus/events/Event.java index 33192e9c..09b68d86 100644 --- a/bus/src/main/java/de/srsoftware/umbrella/messagebus/events/Event.java +++ b/bus/src/main/java/de/srsoftware/umbrella/messagebus/events/Event.java @@ -18,6 +18,7 @@ public abstract class Event { public enum EventType { CREATE, + MEMBER_ADDED, UPDATE, DELETE; } @@ -59,7 +60,7 @@ public abstract class Event { } public Optional 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 { return eventType; } + protected Map filter(Map map){ + return dropMarkdown(map); + } + public boolean isIntendedFor(UmbrellaUser user){ return audience().contains(user); } @@ -93,6 +98,10 @@ public abstract class Event { return module; } + protected Map oldData() { + return oldData; + } + public Payload payload(){ return payload; } diff --git a/bus/src/main/java/de/srsoftware/umbrella/messagebus/events/ProjectEvent.java b/bus/src/main/java/de/srsoftware/umbrella/messagebus/events/ProjectEvent.java index 02f56b6f..ca3a81a3 100644 --- a/bus/src/main/java/de/srsoftware/umbrella/messagebus/events/ProjectEvent.java +++ b/bus/src/main/java/de/srsoftware/umbrella/messagebus/events/ProjectEvent.java @@ -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{ + + 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 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{ @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{ 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()); } } diff --git a/bus/src/main/java/de/srsoftware/umbrella/messagebus/events/TaskEvent.java b/bus/src/main/java/de/srsoftware/umbrella/messagebus/events/TaskEvent.java index f5107ab3..b0fada07 100644 --- a/bus/src/main/java/de/srsoftware/umbrella/messagebus/events/TaskEvent.java +++ b/bus/src/main/java/de/srsoftware/umbrella/messagebus/events/TaskEvent.java @@ -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{ + 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 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{ @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 filter(Map 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{ 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()); }; }} diff --git a/core/src/main/java/de/srsoftware/umbrella/core/constants/Field.java b/core/src/main/java/de/srsoftware/umbrella/core/constants/Field.java index 1996d582..bdb9a2ae 100644 --- a/core/src/main/java/de/srsoftware/umbrella/core/constants/Field.java +++ b/core/src/main/java/de/srsoftware/umbrella/core/constants/Field.java @@ -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"; diff --git a/project/src/main/java/de/srsoftware/umbrella/project/ProjectModule.java b/project/src/main/java/de/srsoftware/umbrella/project/ProjectModule.java index 033d47c8..3d631c66 100644 --- a/project/src/main/java/de/srsoftware/umbrella/project/ProjectModule.java +++ b/project/src/main/java/de/srsoftware/umbrella/project/ProjectModule.java @@ -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()); } diff --git a/project/src/main/java/de/srsoftware/umbrella/project/SqliteDb.java b/project/src/main/java/de/srsoftware/umbrella/project/SqliteDb.java index 12673ad1..20abd387 100644 --- a/project/src/main/java/de/srsoftware/umbrella/project/SqliteDb.java +++ b/project/src/main/java/de/srsoftware/umbrella/project/SqliteDb.java @@ -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.*; diff --git a/task/src/main/java/de/srsoftware/umbrella/task/TaskModule.java b/task/src/main/java/de/srsoftware/umbrella/task/TaskModule.java index 32c83a9b..12902760 100644 --- a/task/src/main/java/de/srsoftware/umbrella/task/TaskModule.java +++ b/task/src/main/java/de/srsoftware/umbrella/task/TaskModule.java @@ -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); } diff --git a/translations/src/main/java/de/srsoftware/umbrella/translations/Translations.java b/translations/src/main/java/de/srsoftware/umbrella/translations/Translations.java index a874f567..01ca65a1 100644 --- a/translations/src/main/java/de/srsoftware/umbrella/translations/Translations.java +++ b/translations/src/main/java/de/srsoftware/umbrella/translations/Translations.java @@ -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 translations = new HashMap<>(); - public Translations() { + public Translations(Configuration config) { ModuleRegistry.add(this); + Optional 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; } diff --git a/translations/src/main/resources/de.json b/translations/src/main/resources/de.json index c2fcc090..054b9be4 100644 --- a/translations/src/main/resources/de.json +++ b/translations/src/main/resources/de.json @@ -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" } diff --git a/translations/src/main/resources/en.json b/translations/src/main/resources/en.json index 44874b6f..0e44e66d 100644 --- a/translations/src/main/resources/en.json +++ b/translations/src/main/resources/en.json @@ -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" }