17 changed files with 330 additions and 25 deletions
@ -0,0 +1,15 @@ |
|||||||
|
/* © SRSoftware 2025 */ |
||||||
|
package de.srsoftware.umbrella.core.api; |
||||||
|
|
||||||
|
import de.srsoftware.umbrella.core.exceptions.UmbrellaException; |
||||||
|
import de.srsoftware.umbrella.core.model.Task; |
||||||
|
import java.util.Collection; |
||||||
|
|
||||||
|
public interface TaskService { |
||||||
|
CompanyService companyService(); |
||||||
|
Collection<Task> listCompanyTasks(long companyId) throws UmbrellaException; |
||||||
|
ProjectService projectService(); |
||||||
|
|
||||||
|
UserService userService(); |
||||||
|
|
||||||
|
} |
||||||
@ -0,0 +1,5 @@ |
|||||||
|
/* © SRSoftware 2025 */ |
||||||
|
package de.srsoftware.umbrella.core.api; |
||||||
|
|
||||||
|
public interface TimeService { |
||||||
|
} |
||||||
@ -1,12 +1,9 @@ |
|||||||
/* © SRSoftware 2025 */ |
/* © SRSoftware 2025 */ |
||||||
package de.srsoftware.umbrella.task; |
package de.srsoftware.umbrella.core.model; |
||||||
|
|
||||||
import static de.srsoftware.tools.Optionals.nullIfEmpty; |
import static de.srsoftware.tools.Optionals.nullIfEmpty; |
||||||
import static de.srsoftware.umbrella.core.Constants.*; |
import static de.srsoftware.umbrella.core.Constants.*; |
||||||
import static de.srsoftware.umbrella.core.Constants.SHOW_CLOSED; |
|
||||||
import static de.srsoftware.umbrella.core.Constants.STATUS; |
|
||||||
import static de.srsoftware.umbrella.core.Util.markdown; |
import static de.srsoftware.umbrella.core.Util.markdown; |
||||||
import static de.srsoftware.umbrella.task.Constants.*; |
|
||||||
|
|
||||||
import de.srsoftware.tools.Mappable; |
import de.srsoftware.tools.Mappable; |
||||||
import java.sql.ResultSet; |
import java.sql.ResultSet; |
||||||
@ -0,0 +1,77 @@ |
|||||||
|
/* © SRSoftware 2025 */ |
||||||
|
package de.srsoftware.umbrella.core.model; |
||||||
|
|
||||||
|
import de.srsoftware.tools.Mappable; |
||||||
|
|
||||||
|
import static de.srsoftware.umbrella.core.Constants.*; |
||||||
|
import static de.srsoftware.umbrella.core.Util.dateTimeOf; |
||||||
|
|
||||||
|
import java.sql.ResultSet; |
||||||
|
import java.sql.SQLException; |
||||||
|
import java.time.LocalDateTime; |
||||||
|
import java.util.HashMap; |
||||||
|
import java.util.HashSet; |
||||||
|
import java.util.Map; |
||||||
|
import java.util.Set; |
||||||
|
|
||||||
|
public record Time(long id, long userId, String subject, String description, LocalDateTime start, LocalDateTime end, State state, Set<Long> taskIds) implements Mappable { |
||||||
|
public enum State{ |
||||||
|
Started(10), |
||||||
|
Open(20), |
||||||
|
Pending(40), |
||||||
|
Complete(60), |
||||||
|
Cancelled(100); |
||||||
|
|
||||||
|
private int code; |
||||||
|
|
||||||
|
State(int code){ |
||||||
|
this.code = code; |
||||||
|
} |
||||||
|
|
||||||
|
public int code(){ |
||||||
|
return code; |
||||||
|
} |
||||||
|
|
||||||
|
public static State of(int code){ |
||||||
|
return switch (code){ |
||||||
|
case 10 -> Started; |
||||||
|
case 20 -> Open; |
||||||
|
case 40 -> Pending; |
||||||
|
case 60 -> Complete; |
||||||
|
case 100 -> Cancelled; |
||||||
|
default -> throw new IllegalArgumentException(); |
||||||
|
}; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
@Override |
||||||
|
public Map<String, Object> toMap() { |
||||||
|
var map = new HashMap<String,Object>(); |
||||||
|
map.put(ID,id); |
||||||
|
map.put(USER_ID,userId); |
||||||
|
map.put(SUBJECT,subject); |
||||||
|
map.put(DESCRIPTION,description); |
||||||
|
map.put(START_TIME,start); |
||||||
|
map.put(END_TIME,end); |
||||||
|
map.put(STATE,Map.of(STATUS_CODE,state.code,NAME,state.name())); |
||||||
|
return map; |
||||||
|
} |
||||||
|
|
||||||
|
public static Time of(ResultSet rs) throws SQLException { |
||||||
|
var startTimestamp = rs.getLong(START_TIME); |
||||||
|
var start = startTimestamp == 0 ? null : dateTimeOf(startTimestamp); |
||||||
|
var endTimestamp = rs.getLong(END_TIME); |
||||||
|
var end = endTimestamp == 0 ? null : dateTimeOf(endTimestamp); |
||||||
|
|
||||||
|
return new Time( |
||||||
|
rs.getLong(ID), |
||||||
|
rs.getLong(USER_ID), |
||||||
|
rs.getString(SUBJECT), |
||||||
|
rs.getString(DESCRIPTION), |
||||||
|
start, |
||||||
|
end, |
||||||
|
State.of(rs.getInt(STATE)), |
||||||
|
new HashSet<>() |
||||||
|
); |
||||||
|
} |
||||||
|
} |
||||||
@ -1,18 +1,17 @@ |
|||||||
rootProject.name = "Umbrella25" |
rootProject.name = "Umbrella25" |
||||||
|
|
||||||
include("backend") |
include("backend") |
||||||
|
include("company") |
||||||
|
include("contact") |
||||||
include("core") |
include("core") |
||||||
include("documents") |
include("documents") |
||||||
include("legacy") |
include("legacy") |
||||||
|
include("items") |
||||||
include("messages") |
include("messages") |
||||||
include("translations") |
|
||||||
include("user") |
|
||||||
include("web") |
|
||||||
|
|
||||||
include("company") |
|
||||||
include("contact") |
|
||||||
|
|
||||||
include("markdown") |
include("markdown") |
||||||
include("project") |
include("project") |
||||||
include("items") |
|
||||||
include("task") |
include("task") |
||||||
|
include("time") |
||||||
|
include("translations") |
||||||
|
include("user") |
||||||
|
include("web") |
||||||
|
|||||||
@ -0,0 +1,5 @@ |
|||||||
|
description = "Umbrella : Timetracking" |
||||||
|
|
||||||
|
dependencies{ |
||||||
|
implementation(project(":core")) |
||||||
|
} |
||||||
@ -0,0 +1,18 @@ |
|||||||
|
/* © SRSoftware 2025 */ |
||||||
|
package de.srsoftware.umbrella.time; |
||||||
|
|
||||||
|
public class Constants { |
||||||
|
private Constants(){} |
||||||
|
|
||||||
|
|
||||||
|
public static final String CONFIG_DATABASE = "umbrella.modules.time.database"; |
||||||
|
|
||||||
|
public static final String TABLE_TASK_TIMES = "task_times"; |
||||||
|
public static final String TABLE_TIMES = "times"; |
||||||
|
public static final String TASK_ID = "task_id"; |
||||||
|
public static final String TASKS = "tasks"; |
||||||
|
public static final String TIME_ID = "time_id"; |
||||||
|
public static final String TIMES = "times"; |
||||||
|
public static final String CHILDREN = "children"; |
||||||
|
|
||||||
|
} |
||||||
@ -0,0 +1,50 @@ |
|||||||
|
/* © SRSoftware 2025 */ |
||||||
|
package de.srsoftware.umbrella.time; |
||||||
|
|
||||||
|
import static de.srsoftware.tools.jdbc.Condition.in; |
||||||
|
import static de.srsoftware.tools.jdbc.Query.select; |
||||||
|
import static de.srsoftware.umbrella.core.Constants.ID; |
||||||
|
import static de.srsoftware.umbrella.time.Constants.*; |
||||||
|
|
||||||
|
import de.srsoftware.umbrella.core.exceptions.UmbrellaException; |
||||||
|
import de.srsoftware.umbrella.core.model.Time; |
||||||
|
import java.sql.Connection; |
||||||
|
import java.sql.SQLException; |
||||||
|
import java.util.Collection; |
||||||
|
import java.util.HashMap; |
||||||
|
import java.util.HashSet; |
||||||
|
import java.util.List; |
||||||
|
|
||||||
|
public class SqliteDb implements TimeDb { |
||||||
|
|
||||||
|
private final Connection db; |
||||||
|
|
||||||
|
public SqliteDb(Connection connection) { |
||||||
|
db = connection; |
||||||
|
} |
||||||
|
|
||||||
|
@Override |
||||||
|
public Collection<Time> listTimes(Collection<Long> taskIds) throws UmbrellaException { |
||||||
|
try { |
||||||
|
var rs = select("*").from(TABLE_TASK_TIMES).where(TASK_ID,in(taskIds.toArray())).exec(db); |
||||||
|
var mapFromTimesToTasks = new HashMap<Long,HashSet<Long>>(); |
||||||
|
while (rs.next()){ |
||||||
|
var timeId = rs.getLong(TIME_ID); |
||||||
|
var taskId = rs.getLong(TASK_ID); |
||||||
|
mapFromTimesToTasks.computeIfAbsent(timeId, k -> new HashSet<>()).add(taskId); |
||||||
|
} |
||||||
|
rs.close(); |
||||||
|
rs = select("*").from(TABLE_TIMES).where(ID,in(mapFromTimesToTasks.keySet().toArray())).exec(db); |
||||||
|
var times = new HashSet<Time>(); |
||||||
|
while (rs.next()) { |
||||||
|
var time = Time.of(rs); |
||||||
|
time.taskIds().addAll(mapFromTimesToTasks.get(time.id())); |
||||||
|
times.add(time); |
||||||
|
} |
||||||
|
rs.close(); |
||||||
|
return times; |
||||||
|
} catch (SQLException e) { |
||||||
|
throw new UmbrellaException("Failed to load times for task list"); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,11 @@ |
|||||||
|
/* © SRSoftware 2025 */ |
||||||
|
package de.srsoftware.umbrella.time; |
||||||
|
|
||||||
|
import de.srsoftware.umbrella.core.exceptions.UmbrellaException; |
||||||
|
import de.srsoftware.umbrella.core.model.Time; |
||||||
|
import java.util.Collection; |
||||||
|
import java.util.List; |
||||||
|
|
||||||
|
public interface TimeDb { |
||||||
|
Collection<Time> listTimes(Collection<Long> taskIds) throws UmbrellaException; |
||||||
|
} |
||||||
@ -0,0 +1,92 @@ |
|||||||
|
/* © SRSoftware 2025 */ |
||||||
|
package de.srsoftware.umbrella.time; |
||||||
|
|
||||||
|
import static de.srsoftware.umbrella.core.ConnectionProvider.connect; |
||||||
|
import static de.srsoftware.umbrella.core.Constants.*; |
||||||
|
import static de.srsoftware.umbrella.core.Paths.LIST; |
||||||
|
import static de.srsoftware.umbrella.core.exceptions.UmbrellaException.missingFieldException; |
||||||
|
import static de.srsoftware.umbrella.time.Constants.*; |
||||||
|
|
||||||
|
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.*; |
||||||
|
import de.srsoftware.umbrella.core.exceptions.UmbrellaException; |
||||||
|
import de.srsoftware.umbrella.core.model.*; |
||||||
|
|
||||||
|
import java.io.IOException; |
||||||
|
import java.util.*; |
||||||
|
import java.util.stream.Collectors; |
||||||
|
|
||||||
|
public class TimeModule extends BaseHandler implements TimeService { |
||||||
|
private final UserService users; |
||||||
|
private final TimeDb timeDb; |
||||||
|
private final TaskService tasks; |
||||||
|
private final CompanyService companies; |
||||||
|
private final ProjectService projects; |
||||||
|
|
||||||
|
public TimeModule(Configuration config, TaskService taskService) throws UmbrellaException { |
||||||
|
companies = taskService.companyService(); |
||||||
|
projects = taskService.projectService(); |
||||||
|
tasks = taskService; |
||||||
|
users = tasks.userService(); |
||||||
|
var dbFile = config.get(CONFIG_DATABASE).orElseThrow(() -> missingFieldException(CONFIG_DATABASE)); |
||||||
|
timeDb = new SqliteDb(connect(dbFile)); |
||||||
|
} |
||||||
|
|
||||||
|
@Override |
||||||
|
public boolean doPost(Path path, HttpExchange ex) throws IOException { |
||||||
|
addCors(ex); |
||||||
|
try { |
||||||
|
Optional<Token> token = SessionToken.from(ex).map(Token::of); |
||||||
|
var user = users.loadUser(token); |
||||||
|
if (user.isEmpty()) return unauthorized(ex); |
||||||
|
var head = path.pop(); |
||||||
|
return switch (head) { |
||||||
|
case LIST -> listTimes(ex,user.get()); |
||||||
|
default -> super.doPost(path,ex); |
||||||
|
}; |
||||||
|
} catch (UmbrellaException e){ |
||||||
|
return send(ex,e); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
private boolean listTimes(HttpExchange ex, UmbrellaUser user) throws IOException, UmbrellaException { |
||||||
|
var json = json(ex); |
||||||
|
if (!(json.has(COMPANY_ID) && json.get(COMPANY_ID) instanceof Number cid)) throw missingFieldException(COMPANY_ID); |
||||||
|
long companyId = cid.longValue(); |
||||||
|
if (!companies.membership(companyId,user.id())) throw UmbrellaException.forbidden("You are not a member of compayn {0}",companyId); |
||||||
|
var projectMap = projects.listProjects(companyId,false).stream().collect(Collectors.toMap(Project::id,p -> p)); |
||||||
|
var taskMap = tasks.listCompanyTasks(companyId).stream().collect(Collectors.toMap(Task::id,Task::toMap)); |
||||||
|
var timesList = timeDb.listTimes(taskMap.keySet()); |
||||||
|
var tasksWithTime = new HashMap<Long,Map<String,Object>>(); |
||||||
|
var tree = new HashMap<Long,Map<String,Object>>(); |
||||||
|
for (var time : timesList) { |
||||||
|
if (time.state().code() >= 60) continue; |
||||||
|
var timeMap = time.toMap(); |
||||||
|
for (var taskId : time.taskIds()) { |
||||||
|
var task = tasksWithTime.computeIfAbsent(taskId, k -> taskMap.get(taskId)); |
||||||
|
@SuppressWarnings("unchecked") |
||||||
|
HashMap<Long,Map<String, Object>> taskTimes = (HashMap<Long,Map<String, Object>>) task.computeIfAbsent(TIMES, k -> new HashMap<Long,Map<String, Object>>()); |
||||||
|
taskTimes.put(time.id(),timeMap); |
||||||
|
while (task.get(PARENT_TASK_ID) instanceof Long parentTaskId){ |
||||||
|
var parentTask = taskMap.get(parentTaskId); |
||||||
|
@SuppressWarnings("unchecked") |
||||||
|
HashMap<Long,Map<String, Object>> children = (HashMap<Long,Map<String, Object>>) parentTask.computeIfAbsent(CHILDREN, k -> new HashMap<Long,Map<String, Object>>()); |
||||||
|
children.put(taskId,task); |
||||||
|
task = parentTask; |
||||||
|
taskId = parentTaskId; |
||||||
|
} |
||||||
|
if (task.get(PROJECT_ID) instanceof Long projectId){ |
||||||
|
var project = tree.computeIfAbsent(projectId,k -> new HashMap<>(projectMap.get(projectId).toMap())); |
||||||
|
@SuppressWarnings("unchecked") |
||||||
|
HashMap<Long,Map<String,Object>> projectTasks = (HashMap<Long,Map<String, Object>>) project.computeIfAbsent(TASKS, k -> new HashMap<Long,Map<String,Object>>()); |
||||||
|
projectTasks.put(taskId,task); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
return sendContent(ex,tree); |
||||||
|
} |
||||||
|
} |
||||||
Loading…
Reference in new issue