17 changed files with 330 additions and 25 deletions
			
			
		@ -0,0 +1,15 @@
				@@ -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 @@
				@@ -0,0 +1,5 @@
					 | 
				
			||||
/* © SRSoftware 2025 */ | 
				
			||||
package de.srsoftware.umbrella.core.api; | 
				
			||||
 | 
				
			||||
public interface TimeService { | 
				
			||||
} | 
				
			||||
@ -1,12 +1,9 @@
				@@ -1,12 +1,9 @@
					 | 
				
			||||
/* © SRSoftware 2025 */ | 
				
			||||
package de.srsoftware.umbrella.task; | 
				
			||||
package de.srsoftware.umbrella.core.model; | 
				
			||||
 | 
				
			||||
import static de.srsoftware.tools.Optionals.nullIfEmpty; | 
				
			||||
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.task.Constants.*; | 
				
			||||
 | 
				
			||||
import de.srsoftware.tools.Mappable; | 
				
			||||
import java.sql.ResultSet; | 
				
			||||
@ -0,0 +1,77 @@
				@@ -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 @@
				@@ -1,18 +1,17 @@
					 | 
				
			||||
rootProject.name = "Umbrella25" | 
				
			||||
 | 
				
			||||
include("backend") | 
				
			||||
include("company") | 
				
			||||
include("contact") | 
				
			||||
include("core") | 
				
			||||
include("documents") | 
				
			||||
include("legacy") | 
				
			||||
include("items") | 
				
			||||
include("messages") | 
				
			||||
include("translations") | 
				
			||||
include("user") | 
				
			||||
include("web") | 
				
			||||
 | 
				
			||||
include("company") | 
				
			||||
include("contact") | 
				
			||||
 | 
				
			||||
include("markdown") | 
				
			||||
include("project") | 
				
			||||
include("items") | 
				
			||||
include("task") | 
				
			||||
include("time") | 
				
			||||
include("translations") | 
				
			||||
include("user") | 
				
			||||
include("web") | 
				
			||||
				 
					 | 
				
			||||
@ -0,0 +1,5 @@
				@@ -0,0 +1,5 @@
					 | 
				
			||||
description = "Umbrella : Timetracking" | 
				
			||||
 | 
				
			||||
dependencies{ | 
				
			||||
    implementation(project(":core")) | 
				
			||||
} | 
				
			||||
@ -0,0 +1,18 @@
				@@ -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 @@
				@@ -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 @@
				@@ -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 @@
				@@ -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