diff --git a/backend/build.gradle.kts b/backend/build.gradle.kts index 66d393b..39ce6b4 100644 --- a/backend/build.gradle.kts +++ b/backend/build.gradle.kts @@ -19,6 +19,7 @@ dependencies{ implementation(project(":legacy")) implementation(project(":markdown")) implementation(project(":messages")) + implementation(project(":notes")) implementation(project(":project")) implementation(project(":tags")) implementation(project(":task")) 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 aa17bb5..a0d2eb0 100644 --- a/backend/src/main/java/de/srsoftware/umbrella/backend/Application.java +++ b/backend/src/main/java/de/srsoftware/umbrella/backend/Application.java @@ -17,6 +17,7 @@ import de.srsoftware.umbrella.legacy.LegacyApi; import de.srsoftware.umbrella.markdown.MarkdownApi; import de.srsoftware.umbrella.message.MessageApi; import de.srsoftware.umbrella.message.MessageSystem; +import de.srsoftware.umbrella.notes.NoteModule; import de.srsoftware.umbrella.project.ProjectModule; import de.srsoftware.umbrella.tags.TagModule; import de.srsoftware.umbrella.task.TaskModule; @@ -67,6 +68,7 @@ public class Application { var markdownApi = new MarkdownApi(userModule); var messageApi = new MessageApi(messageSystem); var tagModule = new TagModule(config,userModule); + var notesModule = new NoteModule(config,userModule); var projectModule = new ProjectModule(config,companyModule,tagModule); var taskModule = new TaskModule(config,projectModule,tagModule); var timeModule = new TimeModule(config,taskModule); @@ -77,6 +79,7 @@ public class Application { itemApi .bindPath("/api/items") .on(server); markdownApi .bindPath("/api/markdown") .on(server); messageApi .bindPath("/api/messages") .on(server); + notesModule .bindPath("/api/notes") .on(server); projectModule .bindPath("/api/project") .on(server); tagModule .bindPath("/api/tags") .on(server); taskModule .bindPath("/api/task") .on(server); diff --git a/core/src/main/java/de/srsoftware/umbrella/core/Constants.java b/core/src/main/java/de/srsoftware/umbrella/core/Constants.java index c85e53b..17f64fa 100644 --- a/core/src/main/java/de/srsoftware/umbrella/core/Constants.java +++ b/core/src/main/java/de/srsoftware/umbrella/core/Constants.java @@ -19,9 +19,12 @@ public class Constants { public static final String COMPANY = "company"; public static final String COMPANY_ID = "company_id"; public static final String CONTENT_TYPE = "Content-Type"; + public static final String CUSTOMER_NUMBER_PREFIX = "customer_number_prefix"; public static final String DATA = "data"; public static final String DATE = "date"; + public static final String DECIMALS = "decimals"; + public static final String DECIMAL_SEPARATOR = "decimal_separator"; public static final String DEFAULT_LANGUAGE = "en"; public static final String DEFAULT_THEME = "winter"; public static final String DELETED = "deleted"; @@ -33,7 +36,7 @@ public class Constants { public static final String EMAIL = "email"; public static final String END_TIME = "end_time"; - + public static final String ENTITY_ID = "entity_id"; public static final String ERROR_FAILED_CREATE_TABLE = "Failed to create \"{0}\" table!"; public static final String ERROR_INVALID_FIELD = "Expected {0} to be {1}!"; public static final String ERROR_MISSING_CONFIG = "Config is missing value for {0}!"; @@ -41,19 +44,30 @@ public class Constants { public static final String ERROR_READ_TABLE = "Failed to read {0} from {1} table"; public static final String EST_TIME = "est_time"; public static final String ESTIMATED_TIME = "estimated_time"; - public static final String EXPIRATION = "expiration"; + public static final String FALLBACK_LANG = "de"; + public static final String FIELD_BANK_ACCOUNT = "bank_account"; + public static final String FIELD_COURT = "court"; + public static final String FIELD_CURRENCY = "currency"; + public static final String FIELD_PHONE = "phone"; + public static final String FIELD_TAX_NUMBER = "tax_number"; + public static final String GET = "GET"; public static final String ID = "id"; + public static final String JSONARRAY = "json array"; public static final String JSONOBJECT = "json object"; public static final String KEY = "key"; + public static final String LANGUAGE = "language"; + public static final String LAST_CUSTOMER_NUMBER = "last_customer_number"; public static final String LOGIN = "login"; + public static final String MEMBERS = "members"; public static final String MESSAGES = "messages"; + public static final String MODULE = "module"; public static final String NAME = "name"; public static final String NEW_MEMBER = "new_member"; @@ -90,8 +104,10 @@ public class Constants { public static final String TAGS = "tags"; public static final String TEMPLATE = "template"; public static final String TEXT = "text"; + public static final String THOUSANDS_SEPARATOR = "thousands_separator"; public static final String THEME = "theme"; public static final String TITLE = "title"; + public static final String TIMESTAMP = "timestamp"; public static final String TOKEN = "token"; public static final String UMBRELLA = "Umbrella"; @@ -100,16 +116,7 @@ public class Constants { public static final String USER_ID = "user_id"; public static final String USER_LIST = "user_list"; public static final String UTF8 = UTF_8.displayName(); + public static final String VALUE = "value"; - public static final String FIELD_COURT = "court"; - public static final String FIELD_TAX_NUMBER = "tax_number"; - public static final String FIELD_PHONE = "phone"; - public static final String DECIMAL_SEPARATOR = "decimal_separator"; - public static final String THOUSANDS_SEPARATOR = "thousands_separator"; - public static final String LAST_CUSTOMER_NUMBER = "last_customer_number"; - public static final String DECIMALS = "decimals"; - public static final String CUSTOMER_NUMBER_PREFIX = "customer_number_prefix"; - public static final String FIELD_CURRENCY = "currency"; - public static final String FIELD_BANK_ACCOUNT = "bank_account"; } diff --git a/core/src/main/java/de/srsoftware/umbrella/core/api/NoteService.java b/core/src/main/java/de/srsoftware/umbrella/core/api/NoteService.java new file mode 100644 index 0000000..d89b914 --- /dev/null +++ b/core/src/main/java/de/srsoftware/umbrella/core/api/NoteService.java @@ -0,0 +1,16 @@ +/* © SRSoftware 2025 */ +package de.srsoftware.umbrella.core.api; + +import de.srsoftware.umbrella.core.exceptions.UmbrellaException; +import de.srsoftware.umbrella.core.model.Note; +import de.srsoftware.umbrella.core.model.UmbrellaUser; + +import java.util.Collection; + +public interface NoteService { + void deleteEntity(String task, long taskId); + + Collection getNotes(String module, long entityId) throws UmbrellaException; + + Note save(Note note); +} diff --git a/core/src/main/java/de/srsoftware/umbrella/core/model/Note.java b/core/src/main/java/de/srsoftware/umbrella/core/model/Note.java new file mode 100644 index 0000000..6ccbbcc --- /dev/null +++ b/core/src/main/java/de/srsoftware/umbrella/core/model/Note.java @@ -0,0 +1,38 @@ +/* © SRSoftware 2025 */ +package de.srsoftware.umbrella.core.model; + +import de.srsoftware.tools.Mappable; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.time.LocalDateTime; +import java.util.Map; + +import static de.srsoftware.umbrella.core.Constants.*; +import static de.srsoftware.umbrella.core.Util.markdown; + +public record Note(long id, String module, long entityId, long authorId, String text, LocalDateTime timestamp) implements Mappable { + public static Note of(ResultSet rs) throws SQLException { + return new Note( + rs.getLong(ID), + rs.getString(MODULE), + rs.getLong(ENTITY_ID), + rs.getLong(USER_ID), + rs.getString(TEXT), + LocalDateTime.parse(rs.getString(TIMESTAMP)) + ); + } + + @Override + public Map toMap() { + return Map.of( + ID,id, + MODULE,module, + ENTITY_ID,entityId, + USER_ID,authorId, + TEXT,text, + RENDERED,markdown(text), + TIMESTAMP,timestamp + ); + } +} diff --git a/notes/build.gradle.kts b/notes/build.gradle.kts new file mode 100644 index 0000000..ba37f96 --- /dev/null +++ b/notes/build.gradle.kts @@ -0,0 +1,6 @@ +description = "Umbrella : Notes" + +dependencies{ + implementation(project(":core")) +} + diff --git a/notes/src/main/java/de/srsoftware/umbrella/notes/Constants.java b/notes/src/main/java/de/srsoftware/umbrella/notes/Constants.java new file mode 100644 index 0000000..c73e55e --- /dev/null +++ b/notes/src/main/java/de/srsoftware/umbrella/notes/Constants.java @@ -0,0 +1,11 @@ +/* © SRSoftware 2025 */ +package de.srsoftware.umbrella.notes; + +public class Constants { + private Constants(){} + + public static final String CONFIG_DATABASE = "umbrella.modules.notes.database"; + public static final String DB_VERSION = "notes_db_version"; + public static final String TABLE_NOTES = "notes"; + public static final String NOTE = "note"; +} diff --git a/notes/src/main/java/de/srsoftware/umbrella/notes/NoteModule.java b/notes/src/main/java/de/srsoftware/umbrella/notes/NoteModule.java new file mode 100644 index 0000000..5929c72 --- /dev/null +++ b/notes/src/main/java/de/srsoftware/umbrella/notes/NoteModule.java @@ -0,0 +1,135 @@ +/* © SRSoftware 2025 */ +package de.srsoftware.umbrella.notes; + +import static de.srsoftware.umbrella.core.ConnectionProvider.connect; +import static de.srsoftware.umbrella.core.Constants.USER_LIST; +import static de.srsoftware.umbrella.core.ResponseCode.HTTP_UNPROCESSABLE; +import static de.srsoftware.umbrella.core.exceptions.UmbrellaException.missingFieldException; +import static de.srsoftware.umbrella.core.exceptions.UmbrellaException.unprocessable; +import static de.srsoftware.umbrella.notes.Constants.CONFIG_DATABASE; +import static de.srsoftware.umbrella.notes.Constants.NOTE; +import static java.net.HttpURLConnection.HTTP_OK; + +import com.sun.net.httpserver.HttpExchange; +import de.srsoftware.configuration.Configuration; +import de.srsoftware.tools.Path; +import de.srsoftware.tools.SessionToken; +import de.srsoftware.umbrella.core.BaseHandler; +import de.srsoftware.umbrella.core.api.NoteService; +import de.srsoftware.umbrella.core.api.TagService; +import de.srsoftware.umbrella.core.api.UserService; +import de.srsoftware.umbrella.core.exceptions.UmbrellaException; +import de.srsoftware.umbrella.core.model.Note; +import de.srsoftware.umbrella.core.model.Token; +import de.srsoftware.umbrella.core.model.UmbrellaUser; +import java.io.IOException; +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.List; +import java.util.Optional; +import org.json.JSONArray; + +public class NoteModule extends BaseHandler implements NoteService { + private final NotesDb notesDb; + private final UserService users; + + public NoteModule(Configuration config, UserService userService) { + var dbFile = config.get(CONFIG_DATABASE).orElseThrow(() -> missingFieldException(CONFIG_DATABASE)); + notesDb = new SqliteDb(connect(dbFile)); + users = userService; + } + + @Override + public void deleteEntity(String module, long entityId) { + notesDb.deleteEntity(module,entityId); + } + + @Override + public boolean doDelete(Path path, HttpExchange ex) throws IOException { + addCors(ex); + try { + Optional token = SessionToken.from(ex).map(Token::of); + var user = users.loadUser(token); + if (user.isEmpty()) return unauthorized(ex); + var module = path.pop(); + if (module == null) throw unprocessable("Module missing in path."); + var head = path.pop(); + long noteId = Long.parseLong(head); + noteId = notesDb.delete(noteId,user.get().id()); + return sendContent(ex, noteId); + } catch (NumberFormatException e){ + return sendContent(ex,HTTP_UNPROCESSABLE,"Entity id missing in path."); + } catch (UmbrellaException e){ + return send(ex,e); + } + } + + @Override + public boolean doGet(Path path, HttpExchange ex) throws IOException { + addCors(ex); + try { + var user = users.refreshSession(ex); + if (user.isEmpty()) return unauthorized(ex); + var module = path.pop(); + if (module == null) throw unprocessable("Module missing in path."); + var head = path.pop(); + long entityId = Long.parseLong(head); + return sendContent(ex, getNotes(module,entityId)); + } catch (NumberFormatException e){ + return sendContent(ex,HTTP_UNPROCESSABLE,"Entity id missing in path."); + } catch (UmbrellaException e){ + return send(ex,e); + } + } + + @Override + public boolean doOptions(Path path, HttpExchange ex) throws IOException { + addCors(ex); + return sendEmptyResponse(HTTP_OK,ex); + } + + @Override + public boolean doPost(Path path, HttpExchange ex) throws IOException { + addCors(ex); + try { + Optional token = SessionToken.from(ex).map(Token::of); + var user = users.loadUser(token); + if (user.isEmpty()) return unauthorized(ex); + var module = path.pop(); + if (module == null) throw unprocessable("Module missing in path."); + var head = path.pop(); + long entityId = Long.parseLong(head); + var json = json(ex); + if (!(json.has(NOTE) && json.get(NOTE) instanceof String text)) throw missingFieldException(NOTE); + List userList = null; + if (!json.has(USER_LIST)) throw missingFieldException(USER_LIST); + var ul = json.isNull(USER_LIST) ? null : json.get(USER_LIST); + if (ul instanceof JSONArray arr){ + userList = arr.toList().stream() + .filter(elem -> elem instanceof Number) + .map(Number.class::cast) + .map(Number::longValue) + .toList(); + } else if (ul != null) throw unprocessable("User list must be NULL or array of user ids!"); + // userList == null → tag is shared with all users + if (userList != null && userList.isEmpty()) throw unprocessable("User list must not be an empty list!"); + var note = new Note(0,module,entityId,user.get().id(),text, LocalDateTime.now()); + note = save(note); + return sendContent(ex, note); + } catch (NumberFormatException e) { + return sendContent(ex, HTTP_UNPROCESSABLE, "Entity id missing in path."); + } catch (UmbrellaException e) { + return send(ex, e); + } + } + + public Collection getNotes(String module, long entityId) throws UmbrellaException{ + return notesDb.list(module,entityId); + } + + @Override + public Note save(Note note) { + return notesDb.save(note); + } + +} diff --git a/notes/src/main/java/de/srsoftware/umbrella/notes/NotesDb.java b/notes/src/main/java/de/srsoftware/umbrella/notes/NotesDb.java new file mode 100644 index 0000000..392cb50 --- /dev/null +++ b/notes/src/main/java/de/srsoftware/umbrella/notes/NotesDb.java @@ -0,0 +1,17 @@ +/* © SRSoftware 2025 */ +package de.srsoftware.umbrella.notes; + +import de.srsoftware.umbrella.core.model.Note; + +import java.util.Collection; +import java.util.Set; + +public interface NotesDb { + long delete(long noteId, long userId); + + void deleteEntity(String module, long entityId); + + Set list(String module, long entityId); + + Note save(Note note); +} diff --git a/notes/src/main/java/de/srsoftware/umbrella/notes/SqliteDb.java b/notes/src/main/java/de/srsoftware/umbrella/notes/SqliteDb.java new file mode 100644 index 0000000..31f8aa0 --- /dev/null +++ b/notes/src/main/java/de/srsoftware/umbrella/notes/SqliteDb.java @@ -0,0 +1,139 @@ +/* © SRSoftware 2025 */ +package de.srsoftware.umbrella.notes; + +import static de.srsoftware.tools.jdbc.Condition.equal; +import static de.srsoftware.tools.jdbc.Query.*; +import static de.srsoftware.tools.jdbc.Query.SelectQuery.ALL; +import static de.srsoftware.umbrella.core.Constants.*; +import static de.srsoftware.umbrella.notes.Constants.*; +import static java.lang.System.Logger.Level.*; +import static java.text.MessageFormat.format; + +import de.srsoftware.tools.jdbc.Query; +import de.srsoftware.umbrella.core.exceptions.UmbrellaException; +import de.srsoftware.umbrella.core.model.Note; +import java.sql.Connection; +import java.sql.SQLException; +import java.util.Collection; +import java.util.HashSet; +import java.util.Set; + +public class SqliteDb implements NotesDb { + private static final int INITIAL_DB_VERSION = 1; + private static final System.Logger LOG = System.getLogger("NotesDB"); + private final Connection db; + + public SqliteDb(Connection conn) { + this.db = conn; + init(); + } + + private int createTables() { + createNotesTables(); + return createSettingsTable(); + } + + private int createSettingsTable() { + var createTable = """ +CREATE TABLE IF NOT EXISTS {0} ( {1} VARCHAR(255) PRIMARY KEY, {2} VARCHAR(255) NOT NULL); +"""; + try { + var stmt = db.prepareStatement(format(createTable,TABLE_SETTINGS, KEY, VALUE)); + stmt.execute(); + stmt.close(); + } catch (SQLException e) { + LOG.log(ERROR,ERROR_FAILED_CREATE_TABLE,TABLE_SETTINGS,e); + throw new RuntimeException(e); + } + + Integer version = null; + try { + var rs = select(VALUE).from(TABLE_SETTINGS).where(KEY, equal(DB_VERSION)).exec(db); + if (rs.next()) version = rs.getInt(VALUE); + rs.close(); + if (version == null) { + version = INITIAL_DB_VERSION; + insertInto(TABLE_SETTINGS, KEY, VALUE).values(DB_VERSION,version).execute(db).close(); + } + + return version; + } catch (SQLException e) { + LOG.log(ERROR,ERROR_READ_TABLE,DB_VERSION,TABLE_SETTINGS,e); + throw new RuntimeException(e); + } + } + + private void createNotesTables() { + var createTable = """ +CREATE TABLE IF NOT EXISTS "{0}" ( + {1} INTEGER NOT NULL PRIMARY KEY, + {2} TEXT NOT NULL, + {3} VARCHAR(20) NOT NULL, + {4} INTEGER NOT NULL, + {5} INTEGER NOT NULL, + {6} DATETIME NOT NULL +)"""; + try { + var stmt = db.prepareStatement(format(createTable,TABLE_NOTES, ID, NOTE, MODULE, ENTITY_ID, USER_ID, TIMESTAMP)); + stmt.execute(); + stmt.close(); + } catch (SQLException e) { + LOG.log(ERROR,ERROR_FAILED_CREATE_TABLE,TABLE_NOTES,e); + throw new RuntimeException(e); + } + } + + @Override + public long delete(long noteId, long userId) { + LOG.log(WARNING,"Not checking whether deleted not belongs to user!"); + try { + Query.delete().from(TABLE_NOTES) + .where(ID,equal(noteId)) + .execute(db); + return noteId; + } catch (SQLException e){ + throw new UmbrellaException("Failed to delete note {0}",noteId); + } + } + + @Override + public void deleteEntity(String module, long entityId) { + try { + Query.delete().from(TABLE_NOTES) + .where(MODULE,equal(module)).where(ENTITY_ID,equal(entityId)) + .execute(db); + } catch (SQLException e){ + throw new UmbrellaException("Failed to delete notes of ({0} {1})",module,entityId); + } + } + + private void init(){ + var version = createTables(); + LOG.log(INFO,"Updated task db to version {0}",version); + } + + @Override + public Set list(String module, long entityId) { + try { + var tags = new HashSet(); + var rs = select(ALL).from(TABLE_NOTES).where(MODULE,equal(module)).where(ENTITY_ID,equal(entityId)).exec(db); + while (rs.next()) tags.add(Note.of(rs)); + rs.close(); + return tags; + } catch (SQLException e) { + throw new UmbrellaException("Failed to load tags"); + } + } + + @Override + public Note save(Note note) { + try { + replaceInto(TABLE_NOTES,USER_ID,MODULE,ENTITY_ID,NOTE,TIMESTAMP) + .values(note.authorId(),note.module(),note.entityId(),note.text(),note.timestamp()) + .execute(db).close(); + return note; + } catch (SQLException e){ + throw new UmbrellaException("Failed to save note: {0}",note.text()); + } + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 61ca819..a4ea6ea 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -9,6 +9,7 @@ include("legacy") include("items") include("messages") include("markdown") +include("notes") include("project") include("tags") include("task") diff --git a/tags/src/main/java/de/srsoftware/umbrella/tags/Constants.java b/tags/src/main/java/de/srsoftware/umbrella/tags/Constants.java index 4adcf2d..7bdf218 100644 --- a/tags/src/main/java/de/srsoftware/umbrella/tags/Constants.java +++ b/tags/src/main/java/de/srsoftware/umbrella/tags/Constants.java @@ -6,7 +6,6 @@ public class Constants { public static final String CONFIG_DATABASE = "umbrella.modules.tags.database"; public static final String DB_VERSION = "project_db_version"; - public static final String MODULE = "module"; public static final String TABLE_TAGS = "tags"; public static final String TAG = "tag"; } 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 b985235..6fbc9e9 100644 --- a/task/src/main/java/de/srsoftware/umbrella/task/TaskModule.java +++ b/task/src/main/java/de/srsoftware/umbrella/task/TaskModule.java @@ -29,7 +29,6 @@ import de.srsoftware.umbrella.core.model.Token; import de.srsoftware.umbrella.core.model.UmbrellaUser; import java.io.IOException; import java.util.*; - import org.json.JSONArray; import org.json.JSONObject;