diff --git a/backend/.gitignore b/backend/.gitignore
new file mode 100755
index 0000000..c860d75
--- /dev/null
+++ b/backend/.gitignore
@@ -0,0 +1,11 @@
+bin/
+target/
+.settings/
+.project
+.classpath
+hs_err*.log
+application.properties
+usernames.txt
+lucene
+
+.vscode
\ No newline at end of file
diff --git a/backend/pom.xml b/backend/pom.xml
new file mode 100644
index 0000000..8b3c354
--- /dev/null
+++ b/backend/pom.xml
@@ -0,0 +1,138 @@
+
+
+ 4.0.0
+ de.champonthis
+ buntspecht
+ ${revision}
+
+
+ UTF-8
+ 17
+
+ ${java.version}
+ ${java.version}
+ 5.1.0
+ 0.4.0
+
+
+
+ org.springframework.boot
+ spring-boot-starter-parent
+ 3.3.4
+
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-web
+
+
+
+ org.springframework.boot
+ spring-boot-starter-security
+
+
+
+ org.springframework.boot
+ spring-boot-starter-data-jpa
+
+
+
+ org.springframework.boot
+ spring-boot-starter-webflux
+
+
+
+ org.springframework.boot
+ spring-boot-starter-mail
+
+
+
+ org.springframework.session
+ spring-session-jdbc
+
+
+
+ org.springframework.boot
+ spring-boot-starter-oauth2-client
+
+
+
+
+ com.querydsl
+ querydsl-apt
+ ${querydsl.version}
+ jakarta
+
+
+
+ com.querydsl
+ querydsl-jpa
+ ${querydsl.version}
+ jakarta
+
+
+
+
+ commons-validator
+ commons-validator
+ 1.9.0
+
+
+
+ com.google.code.gson
+ gson
+
+
+
+ org.apache.commons
+ commons-lang3
+
+
+
+ org.bouncycastle
+ bcprov-jdk18on
+ 1.78.1
+
+
+
+ org.passay
+ passay
+ 1.6.5
+
+
+
+
+ org.postgresql
+ postgresql
+ runtime
+
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+ de.champonthis.buntspecht.Application
+ buntspecht
+ true
+ ZIP
+
+
+
+ build-info
+
+ build-info
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/backend/src/main/java/de/champonthis/buntspecht/Application.java b/backend/src/main/java/de/champonthis/buntspecht/Application.java
new file mode 100755
index 0000000..f9c9410
--- /dev/null
+++ b/backend/src/main/java/de/champonthis/buntspecht/Application.java
@@ -0,0 +1,16 @@
+package de.champonthis.buntspecht;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.boot.web.servlet.support.SpringBootServletInitializer;
+
+
+@SpringBootApplication
+public class Application extends SpringBootServletInitializer {
+
+
+ public static void main(String[] args) {
+ SpringApplication.run(Application.class, args);
+ }
+
+}
diff --git a/backend/src/main/java/de/champonthis/buntspecht/JPAConfig.java b/backend/src/main/java/de/champonthis/buntspecht/JPAConfig.java
new file mode 100644
index 0000000..3371d30
--- /dev/null
+++ b/backend/src/main/java/de/champonthis/buntspecht/JPAConfig.java
@@ -0,0 +1,24 @@
+package de.champonthis.buntspecht;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
+
+import com.querydsl.jpa.impl.JPAQueryFactory;
+
+import jakarta.persistence.EntityManager;
+
+@Configuration
+@EnableJpaAuditing
+public class JPAConfig {
+
+ @Autowired
+ private EntityManager em;
+
+ @Bean
+ public JPAQueryFactory jpaQueryFactory() {
+ return new JPAQueryFactory(em);
+ }
+
+}
diff --git a/backend/src/main/java/de/champonthis/buntspecht/businesslogic/SystemPropertyManager.java b/backend/src/main/java/de/champonthis/buntspecht/businesslogic/SystemPropertyManager.java
new file mode 100755
index 0000000..e129f29
--- /dev/null
+++ b/backend/src/main/java/de/champonthis/buntspecht/businesslogic/SystemPropertyManager.java
@@ -0,0 +1,74 @@
+package de.champonthis.buntspecht.businesslogic;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+import org.springframework.util.Assert;
+
+import de.champonthis.buntspecht.model.SystemProperty;
+import de.champonthis.buntspecht.repository.SystemPropertyRepository;
+
+@Component
+public class SystemPropertyManager {
+
+ @Autowired
+ private SystemPropertyRepository systemPropertyRepository;
+
+ public boolean has(String key) {
+ return systemPropertyRepository.existsById(key);
+ }
+
+ public String get(String key) {
+ return systemPropertyRepository.findById(key).orElse(new SystemProperty()).getValue();
+ }
+
+ public String get(String key, String defaultValue) {
+ return systemPropertyRepository.findById(key).orElse(new SystemProperty(key, defaultValue)).getValue();
+ }
+
+ public boolean getBoolean(String key) {
+ return getBoolean(key, false);
+ }
+
+ public boolean getBoolean(String key, boolean defaultValue) {
+ return Boolean.valueOf(get(key, String.valueOf(defaultValue)));
+ }
+
+ public int getInteger(String key) {
+ return getInteger(key, 0);
+ }
+
+ public int getInteger(String key, int defaultValue) {
+ return Integer.valueOf(get(key, String.valueOf(defaultValue)));
+ }
+
+ public long getLong(String key) {
+ return getLong(key, 0L);
+ }
+
+ public long getLong(String key, long defaultValue) {
+ return Long.valueOf(get(key, String.valueOf(defaultValue)));
+ }
+
+ public void add(String key, String value) {
+ Assert.isTrue(!systemPropertyRepository.existsById(key),
+ "System Property already exists, use update method to change value!");
+ systemPropertyRepository.save(new SystemProperty(key, value));
+ }
+
+ public void update(String key, String value) {
+ Assert.isTrue(systemPropertyRepository.existsById(key),
+ "System Property does not exists, use add method to add new!");
+ SystemProperty systemProperty = systemPropertyRepository.findById(key).get();
+ systemProperty.setValue(value);
+ systemPropertyRepository.save(systemProperty);
+ }
+
+ public void set(String key, String value) {
+ if (systemPropertyRepository.existsById(key)) {
+ update(key, value);
+ } else {
+ add(key, value);
+ }
+ }
+
+}
diff --git a/backend/src/main/java/de/champonthis/buntspecht/businesslogic/TurnoverManager.java b/backend/src/main/java/de/champonthis/buntspecht/businesslogic/TurnoverManager.java
new file mode 100644
index 0000000..81715cd
--- /dev/null
+++ b/backend/src/main/java/de/champonthis/buntspecht/businesslogic/TurnoverManager.java
@@ -0,0 +1,202 @@
+package de.champonthis.buntspecht.businesslogic;
+
+import java.util.List;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.util.StringUtils;
+
+import com.querydsl.core.BooleanBuilder;
+import com.querydsl.core.QueryResults;
+import com.querydsl.core.Tuple;
+import com.querydsl.core.types.Order;
+import com.querydsl.core.types.OrderSpecifier;
+import com.querydsl.core.types.Path;
+import com.querydsl.core.types.Predicate;
+import com.querydsl.jpa.impl.JPAQuery;
+import com.querydsl.jpa.impl.JPAQueryFactory;
+
+import de.champonthis.buntspecht.controller.model.TurnoverFilterModel;
+import de.champonthis.buntspecht.model.QTurnover;
+import de.champonthis.buntspecht.model.Turnover;
+import de.champonthis.buntspecht.repository.TurnoverRepository;
+
+@Service
+public class TurnoverManager {
+
+ @Autowired
+ private TurnoverRepository turnoverRepository;
+ @Autowired
+ private JPAQueryFactory jpaQueryFactory;
+
+ private QTurnover qTurnover = QTurnover.turnover;
+
+ public QueryResults fetch(long limit, long offset, String sortBy, boolean descending,
+ TurnoverFilterModel filter) {
+ return fetch(null, limit, offset, sortBy, descending, filter);
+ }
+
+ public QueryResults fetch(String username, long limit, long offset, String sortBy, boolean descending,
+ TurnoverFilterModel filter) {
+ BooleanBuilder builder = new BooleanBuilder();
+
+ if (StringUtils.hasText(username)) {
+ builder.and(qTurnover.username.eq(username));
+ }
+
+ builder.and(buildFilter(filter));
+
+ JPAQuery query = jpaQueryFactory.from(qTurnover).where(builder.getValue()).select(qTurnover);
+ Long total = query.clone().select(qTurnover.id.countDistinct()).fetchOne();
+
+ if (StringUtils.hasText(sortBy)) {
+ Path extends Comparable>> path = null;
+ switch (sortBy) {
+ case "created":
+ path = qTurnover.created;
+ break;
+ case "dueDate":
+ path = qTurnover.dueDate;
+ break;
+ case "updated":
+ path = qTurnover.updated;
+ break;
+ case "customer":
+ path = qTurnover.customer;
+ break;
+ case "price":
+ path = qTurnover.price;
+ break;
+ case "timeInvestment":
+ path = qTurnover.timeInvestment;
+ break;
+ }
+ if (path != null) {
+ query.orderBy(new OrderSpecifier<>(descending ? Order.DESC : Order.ASC, path));
+ }
+ }
+
+ List result = query.limit(limit).offset(offset).fetch();
+ return new QueryResults(result, limit, offset, total == null ? 0L : total);
+
+ }
+
+ protected Predicate buildFilter(TurnoverFilterModel filter) {
+ BooleanBuilder builder = new BooleanBuilder();
+
+ if (filter != null) {
+ if (filter.getCreated() != null) {
+ if (filter.getCreated().getMin() != null) {
+ builder.and(qTurnover.created.after(filter.getCreated().getMin()));
+ }
+ if (filter.getCreated().getMax() != null) {
+ builder.and(qTurnover.created.before(filter.getCreated().getMax()));
+ }
+ }
+ if (filter.getDueDate() != null) {
+ if (filter.getDueDate().getMin() != null) {
+ builder.and(qTurnover.dueDate.after(filter.getDueDate().getMin()));
+ }
+ if (filter.getDueDate().getMax() != null) {
+ builder.and(qTurnover.dueDate.before(filter.getDueDate().getMax()));
+ }
+ }
+ if (filter.getUpdated() != null) {
+ if (filter.getUpdated().getMin() != null) {
+ builder.and(qTurnover.updated.after(filter.getUpdated().getMin()));
+ }
+ if (filter.getUpdated().getMax() != null) {
+ builder.and(qTurnover.updated.before(filter.getUpdated().getMax()));
+ }
+ }
+ if (filter.getCustomer() != null) {
+ builder.and(qTurnover.customer.contains(filter.getCustomer()));
+ }
+ if (filter.getMotif() != null) {
+ builder.and(qTurnover.motif.contains(filter.getMotif()));
+ }
+ if (filter.getPrice() != null) {
+ if (filter.getPrice().getMin() != null) {
+ builder.and(qTurnover.price.goe(filter.getPrice().getMin()));
+ }
+ if (filter.getPrice().getMax() != null) {
+ builder.and(qTurnover.price.loe(filter.getPrice().getMax()));
+ }
+ }
+ if (filter.getTimeInvestment() != null) {
+ if (filter.getTimeInvestment().getMin() != null) {
+ builder.and(qTurnover.timeInvestment.goe(filter.getTimeInvestment().getMin()));
+ }
+ if (filter.getTimeInvestment().getMax() != null) {
+ builder.and(qTurnover.timeInvestment.loe(filter.getTimeInvestment().getMax()));
+ }
+ }
+ }
+
+ return builder.getValue();
+ }
+
+ public Turnover get(Long id) {
+ return turnoverRepository.findById(id).orElse(null);
+ }
+
+ public Turnover save(Turnover turnover) {
+ return turnoverRepository.save(turnover);
+ }
+
+ public boolean exists(Long id) {
+ return turnoverRepository.existsById(id);
+ }
+
+ public void delete(Turnover turnover) {
+ turnoverRepository.delete(turnover);
+ }
+
+ public void deleteById(Long id) {
+ turnoverRepository.deleteById(id);
+ }
+
+ public void deleteByUsername(String username) {
+ turnoverRepository.deleteAllInBatch(turnoverRepository.findAll(qTurnover.username.eq(username)));
+ }
+
+ public QueryResults overview(String username, long limit, long offset, String sortBy, boolean descending,
+ TurnoverFilterModel filter) {
+ BooleanBuilder builder = new BooleanBuilder();
+
+ if (StringUtils.hasText(username)) {
+ builder.and(qTurnover.username.eq(username));
+ }
+
+ builder.and(buildFilter(filter));
+
+ JPAQuery query = jpaQueryFactory.from(qTurnover).where(builder.getValue()).groupBy(qTurnover.username)
+ .select(qTurnover.username.as("username"), qTurnover.price.sum().as("price"),
+ qTurnover.timeInvestment.sum().as("timeInvestment"));
+ Long total = query.clone().select(qTurnover.username.countDistinct()).fetchOne();
+
+ if (StringUtils.hasText(sortBy)) {
+ Path extends Comparable>> path = null;
+ switch (sortBy) {
+ case "username":
+ path = qTurnover.username;
+ break;
+ case "price":
+ path = qTurnover.price;
+ break;
+ case "timeInvestment":
+ path = qTurnover.timeInvestment;
+ break;
+ }
+ if (path != null) {
+ query.orderBy(new OrderSpecifier<>(descending ? Order.DESC : Order.ASC, path));
+ }
+ }
+
+ List result = query.limit(limit).offset(offset)
+ .fetch();
+
+ return new QueryResults(result, limit, offset, total == null ? 0L : total);
+ }
+
+}
diff --git a/backend/src/main/java/de/champonthis/buntspecht/businesslogic/UserManager.java b/backend/src/main/java/de/champonthis/buntspecht/businesslogic/UserManager.java
new file mode 100644
index 0000000..d918cdf
--- /dev/null
+++ b/backend/src/main/java/de/champonthis/buntspecht/businesslogic/UserManager.java
@@ -0,0 +1,211 @@
+package de.champonthis.buntspecht.businesslogic;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.apache.commons.lang3.RandomStringUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.SmartInitializingSingleton;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.GrantedAuthority;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+import org.springframework.security.core.userdetails.UserDetails;
+import org.springframework.security.core.userdetails.UserDetailsService;
+import org.springframework.security.core.userdetails.UsernameNotFoundException;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
+import org.springframework.stereotype.Service;
+import org.springframework.util.Assert;
+import org.springframework.util.StringUtils;
+
+import com.querydsl.core.BooleanBuilder;
+import com.querydsl.core.QueryResults;
+import com.querydsl.core.types.Order;
+import com.querydsl.core.types.OrderSpecifier;
+import com.querydsl.core.types.Path;
+import com.querydsl.jpa.impl.JPAQuery;
+import com.querydsl.jpa.impl.JPAQueryFactory;
+
+import de.champonthis.buntspecht.model.QUser;
+import de.champonthis.buntspecht.model.User;
+import de.champonthis.buntspecht.repository.UserRepository;
+import de.champonthis.buntspecht.security.LocalUserDetails;
+import jakarta.transaction.Transactional;
+
+@Service
+public class UserManager implements UserDetailsService, SmartInitializingSingleton {
+
+ private Logger logger = LoggerFactory.getLogger(UserManager.class);
+
+ @Autowired
+ private UserRepository userRepository;
+ @Autowired
+ private PasswordEncoder passwordEncoder;
+ @Autowired
+ private JPAQueryFactory jpaQueryFactory;
+ @Autowired
+ private TurnoverManager turnoverManager;
+ private QUser qUser = QUser.user;
+
+ @Value("${admin.password:}")
+ private String adminPassword;
+
+ public QueryResults fetch(long limit, long offset, String sortBy, boolean descending, String usernameFilter) {
+ BooleanBuilder builder = new BooleanBuilder();
+
+ if (StringUtils.hasText(usernameFilter)) {
+ builder.and(qUser.username.contains(usernameFilter));
+ }
+
+ JPAQuery query = jpaQueryFactory.from(qUser).where(builder.getValue()).select(qUser);
+ Long total = query.clone().select(qUser.username.countDistinct()).fetchOne();
+
+ if (StringUtils.hasText(sortBy)) {
+ Path extends Comparable>> path = null;
+ switch (sortBy) {
+ case "username":
+ path = qUser.username;
+ break;
+ case "name":
+ path = qUser.name;
+ break;
+ }
+ if (path != null) {
+ query.orderBy(new OrderSpecifier<>(descending ? Order.DESC : Order.ASC, path));
+ }
+ }
+
+ List result = query.limit(limit).offset(offset).fetch();
+ return new QueryResults(result, limit, offset, total == null ? 0L : total);
+ }
+
+ @Override
+ @Transactional
+ public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
+ User user = getByUsername(username);
+
+ if (user == null) {
+ throw new UsernameNotFoundException(username);
+ }
+
+ List authorities = new ArrayList<>();
+ if (user.getRoles() != null) {
+ for (String role : user.getRoles()) {
+ authorities.add(new SimpleGrantedAuthority(role));
+ }
+ }
+
+ String passwordHash = user.getPasswordHash();
+
+ if (passwordHash == null) {
+ passwordHash = "";
+ }
+
+ LocalUserDetails userDetails = new LocalUserDetails(username, passwordHash, authorities);
+ return userDetails;
+ }
+
+ @Override
+ public void afterSingletonsInstantiated() {
+ if (!userRepository.exists(qUser.roles.contains("ROLE_ADMIN"))) {
+ if (!StringUtils.hasText(adminPassword)) {
+ adminPassword = RandomStringUtils.random(24, true, true);
+ logger.error("password for 'admin': " + adminPassword);
+ }
+ User admin = new User();
+ admin.setUsername("admin");
+ admin.setRoles(List.of("ROLE_ADMIN", "ROLE_DEBUG"));
+ admin.setPasswordHash(passwordEncoder.encode(adminPassword));
+ admin.setLocale("de");
+ userRepository.save(admin);
+ }
+ }
+
+ @Transactional
+ public User getByUsername(String username) {
+ return userRepository.findOne(qUser.username.equalsIgnoreCase(username)).orElse(null);
+ }
+
+ public User getByExternalId(String externalId) {
+ return userRepository.findOne(qUser.externalId.eq(externalId)).orElse(null);
+ }
+
+ public User getByAuth(Authentication authentication) {
+ if (authentication != null) {
+ if (authentication instanceof UsernamePasswordAuthenticationToken) {
+ UsernamePasswordAuthenticationToken token = (UsernamePasswordAuthenticationToken) authentication;
+ return getByUsername(token.getName());
+ } else if (authentication instanceof OAuth2AuthenticationToken) {
+ OAuth2AuthenticationToken token = (OAuth2AuthenticationToken) authentication;
+ String externalId = token.getAuthorizedClientRegistrationId() + "-" +
+ token.getName();
+ User user = getByExternalId(externalId);
+ if (user == null) {
+ user = new User();
+ user.setExternalId(externalId);
+ String tmpUsername = token.getPrincipal().getAttribute("preferred_username");
+ if (!StringUtils.hasText(tmpUsername)) {
+ tmpUsername = token.getPrincipal().getAttribute("username");
+ }
+ if (!StringUtils.hasText(tmpUsername)) {
+ tmpUsername = token.getPrincipal().getAttribute("name");
+ } else {
+ user.setName(token.getPrincipal().getAttribute("name"));
+ }
+ if (!StringUtils.hasText(tmpUsername)) {
+ tmpUsername = token.getName();
+ }
+ if (!StringUtils.hasText(tmpUsername)) {
+ tmpUsername = "user";
+ }
+ int count = 1;
+ String username = tmpUsername;
+ while (userRepository.exists(qUser.username.equalsIgnoreCase(username))) {
+ username = tmpUsername + "-" + count;
+ count++;
+ }
+
+ user.setUsername(username);
+ user.setEmail(token.getPrincipal().getAttribute("email"));
+ user = userRepository.save(user);
+ }
+ return user;
+ }
+ }
+ return null;
+ }
+
+ public User save(User user) {
+ if (exists(user.getUsername())) {
+ user.setPasswordHash(this.getPasswordHash(user.getUsername()));
+ }
+
+ return userRepository.save(user);
+ }
+
+ public boolean exists(String username) {
+ return userRepository.exists(qUser.username.equalsIgnoreCase(username));
+ }
+
+ public void delete(User user) {
+ turnoverManager.deleteByUsername(user.getUsername());
+ userRepository.delete(user);
+ }
+
+ @Transactional
+ public String getPasswordHash(String username) {
+ Assert.isTrue(userRepository.existsById(username), "User with username '" + username + "' not exists!");
+ return userRepository.findById(username).get().getPasswordHash();
+ }
+
+ public User setPassword(String username, String password) {
+ Assert.isTrue(userRepository.existsById(username), "User with username '" + username + "' not exists!");
+ User user = userRepository.findById(username).get();
+ user.setPasswordHash(passwordEncoder.encode(password));
+ return userRepository.save(user);
+ }
+}
diff --git a/backend/src/main/java/de/champonthis/buntspecht/controller/AuthenticationController.java b/backend/src/main/java/de/champonthis/buntspecht/controller/AuthenticationController.java
new file mode 100755
index 0000000..9c8a654
--- /dev/null
+++ b/backend/src/main/java/de/champonthis/buntspecht/controller/AuthenticationController.java
@@ -0,0 +1,85 @@
+package de.champonthis.buntspecht.controller;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.core.ResolvableType;
+import org.springframework.http.HttpStatus;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.security.oauth2.client.registration.ClientRegistration;
+import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import de.champonthis.buntspecht.controller.support.EntityResponseStatusException;
+import de.champonthis.buntspecht.security.LocalUserDetails;
+
+@RestController
+@RequestMapping("/auth")
+public class AuthenticationController extends BaseController {
+
+ private static String authorizationRequestBaseUri = "oauth2/authorization";
+
+ @Autowired(required = false)
+ private ClientRegistrationRepository clientRegistrationRepository;
+
+ @GetMapping
+ public Object me() {
+ Authentication auth = SecurityContextHolder.getContext().getAuthentication();
+ if (auth != null && auth.getPrincipal() instanceof LocalUserDetails) {
+ return (LocalUserDetails) auth.getPrincipal();
+ }
+
+ throw new EntityResponseStatusException(HttpStatus.UNAUTHORIZED);
+ }
+
+ @SuppressWarnings("unchecked")
+ @GetMapping("external")
+ public List getExternalLoginUrls() {
+ List clients = new ArrayList<>();
+ if (clientRegistrationRepository != null) {
+ Iterable clientRegistrations = null;
+ ResolvableType type = ResolvableType.forInstance(clientRegistrationRepository).as(Iterable.class);
+ if (type != ResolvableType.NONE && ClientRegistration.class.isAssignableFrom(type.resolveGenerics()[0])) {
+ clientRegistrations = (Iterable) clientRegistrationRepository;
+ clientRegistrations.forEach(registration -> clients.add(new Client(registration.getRegistrationId(),
+ authorizationRequestBaseUri + "/" + registration.getRegistrationId())));
+ }
+ }
+
+ return clients;
+ }
+
+ protected static class Client {
+
+ private String id;
+
+ private String loginUrl;
+
+ public Client(String id, String loginUrl) {
+ super();
+ this.id = id;
+ this.loginUrl = loginUrl;
+ }
+
+ public String getId() {
+ return id;
+ }
+
+ public void setId(String id) {
+ this.id = id;
+ }
+
+ public String getLoginUrl() {
+ return loginUrl;
+ }
+
+ public void setLoginUrl(String loginUrl) {
+ this.loginUrl = loginUrl;
+ }
+
+ }
+}
diff --git a/backend/src/main/java/de/champonthis/buntspecht/controller/BaseController.java b/backend/src/main/java/de/champonthis/buntspecht/controller/BaseController.java
new file mode 100644
index 0000000..9d546f5
--- /dev/null
+++ b/backend/src/main/java/de/champonthis/buntspecht/controller/BaseController.java
@@ -0,0 +1,32 @@
+package de.champonthis.buntspecht.controller;
+
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+import org.springframework.security.core.context.SecurityContextHolder;
+
+import de.champonthis.buntspecht.security.LocalUserDetails;
+
+public class BaseController {
+
+ protected boolean authenticated() {
+ Authentication auth = SecurityContextHolder.getContext().getAuthentication();
+ return auth != null && auth.isAuthenticated();
+ }
+
+ protected String getCurrentUsername() {
+ LocalUserDetails userDetails = getLocalUserDetails();
+ return userDetails != null ? userDetails.getUsername() : null;
+ }
+
+ protected boolean hasRole(String role) {
+ LocalUserDetails userDetails = getLocalUserDetails();
+ return userDetails != null ? userDetails.getAuthorities().contains(new SimpleGrantedAuthority(role)) : false;
+ }
+
+ protected LocalUserDetails getLocalUserDetails() {
+ Authentication auth = SecurityContextHolder.getContext().getAuthentication();
+ return (auth != null && auth.getPrincipal() instanceof LocalUserDetails)
+ ? (LocalUserDetails) auth.getPrincipal()
+ : null;
+ }
+}
diff --git a/backend/src/main/java/de/champonthis/buntspecht/controller/TurnoverController.java b/backend/src/main/java/de/champonthis/buntspecht/controller/TurnoverController.java
new file mode 100644
index 0000000..8c7f69d
--- /dev/null
+++ b/backend/src/main/java/de/champonthis/buntspecht/controller/TurnoverController.java
@@ -0,0 +1,174 @@
+package de.champonthis.buntspecht.controller;
+
+import java.time.Instant;
+import java.util.List;
+import java.util.Optional;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.HttpStatus;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.util.StringUtils;
+import org.springframework.validation.Errors;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PatchMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import com.querydsl.core.QueryResults;
+import com.querydsl.core.Tuple;
+
+import de.champonthis.buntspecht.businesslogic.TurnoverManager;
+import de.champonthis.buntspecht.businesslogic.UserManager;
+import de.champonthis.buntspecht.controller.model.TurnoverFilterModel;
+import de.champonthis.buntspecht.controller.model.TurnoverFilterModel.MinMax;
+import de.champonthis.buntspecht.controller.support.EntityResponseStatusException;
+import de.champonthis.buntspecht.controller.support.RequestBodyErrors;
+import de.champonthis.buntspecht.controller.validation.TurnoverValidator;
+import de.champonthis.buntspecht.model.Turnover;
+import jakarta.transaction.Transactional;
+
+@RestController
+@RequestMapping("/turnovers")
+public class TurnoverController extends BaseController {
+
+ @Autowired
+ private TurnoverManager turnoverManager;
+ @Autowired
+ private UserManager userManager;
+ @Autowired
+ private TurnoverValidator turnoverValidator;
+
+ @PreAuthorize("isAuthenticated()")
+ @GetMapping
+ @Transactional
+ public QueryResults fetch(
+ @RequestParam("limit") Optional limitParameter,
+ @RequestParam("offset") Optional offsetParameter,
+ @RequestParam("sort") Optional sort,
+ @RequestParam("descending") Optional descending,
+ @RequestParam("from") Optional from,
+ @RequestParam("to") Optional to,
+ @RequestParam("created_from") Optional fromCreated,
+ @RequestParam("created_to") Optional toCreated,
+ @RequestParam("customer") Optional customer,
+ @RequestParam("motif") Optional motif) {
+
+ TurnoverFilterModel filter = new TurnoverFilterModel();
+ filter.setDueDate(new MinMax(from.orElse(null), to.orElse(null)));
+ filter.setCreated(new MinMax(fromCreated.orElse(null), toCreated.orElse(null)));
+ filter.setCustomer(customer.orElse(null));
+ filter.setMotif(motif.orElse(null));
+
+ return turnoverManager.fetch(getCurrentUsername(), limitParameter.orElse(15L), offsetParameter.orElse(0L),
+ sort.orElse("dueDate"), descending.orElse(false), filter);
+ }
+
+ @PreAuthorize("isAuthenticated()")
+ @GetMapping("/overview")
+ @Transactional
+ public Tuple overview(
+ @RequestParam("limit") Optional limitParameter,
+ @RequestParam("offset") Optional offsetParameter,
+ @RequestParam("sort") Optional sort,
+ @RequestParam("descending") Optional descending,
+ @RequestParam("from") Optional from,
+ @RequestParam("to") Optional to,
+ @RequestParam("created_from") Optional fromCreated,
+ @RequestParam("created_to") Optional toCreated,
+ @RequestParam("customer") Optional customer,
+ @RequestParam("motif") Optional motif) {
+
+ TurnoverFilterModel filter = new TurnoverFilterModel();
+ filter.setDueDate(new MinMax(from.orElse(null), to.orElse(null)));
+ filter.setCreated(new MinMax(fromCreated.orElse(null), toCreated.orElse(null)));
+ filter.setCustomer(customer.orElse(null));
+ filter.setMotif(motif.orElse(null));
+
+ List result = turnoverManager.overview(getCurrentUsername(), limitParameter.orElse(15L),
+ offsetParameter.orElse(0L), sort.orElse("username"),
+ descending.orElse(false), filter).getResults();
+
+ if (result.isEmpty()) {
+ return null;
+ }
+
+ return result.get(0);
+ }
+
+ @PreAuthorize("isAuthenticated()")
+ @GetMapping("/{id}")
+ @Transactional
+ public Turnover get(@PathVariable("id") Long id) {
+ Turnover turnover = turnoverManager.get(id);
+
+ if (turnover == null || !getCurrentUsername().equals(turnover.getUsername())) {
+ throw new EntityResponseStatusException(HttpStatus.FORBIDDEN);
+ }
+
+ return turnover;
+ }
+
+ @PreAuthorize("isAuthenticated()")
+ @PostMapping
+ @Transactional
+ public Turnover create(@RequestBody Turnover turnover) {
+ Errors errors = new RequestBodyErrors(turnover);
+ turnoverValidator.validate(turnover, errors);
+
+ if (errors.hasErrors()) {
+ throw new EntityResponseStatusException(errors.getAllErrors(), HttpStatus.CONFLICT);
+ }
+
+ if (!hasRole("ROLE_ADMIN") || !StringUtils.hasText(turnover.getUsername())
+ || !userManager.exists(turnover.getUsername())) {
+ turnover.setUsername(getCurrentUsername());
+ }
+ turnover.setCreated(Instant.now());
+ turnover.setUpdated(turnover.getCreated());
+ if (turnover.getDueDate() == null) {
+ turnover.setDueDate(turnover.getCreated());
+ }
+
+ return turnoverManager.save(turnover);
+ }
+
+ @PreAuthorize("isAuthenticated()")
+ @PatchMapping
+ @Transactional
+ public Turnover update(@RequestBody Turnover turnover) {
+ Errors errors = new RequestBodyErrors(turnover);
+ turnoverValidator.validate(turnover, errors);
+
+ if (errors.hasErrors() || turnover.getId() == null || turnover.getId() == 0L
+ || !turnoverManager.exists(turnover.getId())) {
+ throw new EntityResponseStatusException(errors.getAllErrors(), HttpStatus.CONFLICT);
+ }
+
+ if (!hasRole("ROLE_ADMIN")) {
+ Turnover existing = turnoverManager.get(turnover.getId());
+ if (!getCurrentUsername().equals(existing.getUsername())) {
+ throw new EntityResponseStatusException(HttpStatus.FORBIDDEN);
+ }
+ turnover.setUsername(getCurrentUsername());
+ }
+
+ Turnover existing = turnoverManager.get(turnover.getId());
+
+ if (existing.equals(turnover)) {
+ throw new EntityResponseStatusException(HttpStatus.NOT_MODIFIED);
+ }
+
+ if (turnover.getDueDate() == null) {
+ turnover.setDueDate(turnover.getCreated());
+ }
+
+ turnover.setUpdated(Instant.now());
+
+ return turnoverManager.save(turnover);
+ }
+}
diff --git a/backend/src/main/java/de/champonthis/buntspecht/controller/UserController.java b/backend/src/main/java/de/champonthis/buntspecht/controller/UserController.java
new file mode 100644
index 0000000..fb2f08f
--- /dev/null
+++ b/backend/src/main/java/de/champonthis/buntspecht/controller/UserController.java
@@ -0,0 +1,84 @@
+package de.champonthis.buntspecht.controller;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.HttpStatus;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.util.StringUtils;
+import org.springframework.validation.Errors;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PatchMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import de.champonthis.buntspecht.businesslogic.UserManager;
+import de.champonthis.buntspecht.controller.model.UserPasswordModel;
+import de.champonthis.buntspecht.controller.support.EntityResponseStatusException;
+import de.champonthis.buntspecht.controller.support.RequestBodyErrors;
+import de.champonthis.buntspecht.controller.validation.PasswordModelValidator;
+import de.champonthis.buntspecht.model.User;
+import jakarta.transaction.Transactional;
+
+@RestController
+@RequestMapping("/users")
+public class UserController extends BaseController {
+
+ @Autowired
+ private UserManager userManager;
+ @Autowired
+ private PasswordEncoder passwordEncoder;
+ @Autowired
+ private PasswordModelValidator passwordModelValidator;
+
+ @PreAuthorize("isAuthenticated()")
+ @GetMapping("/user")
+ @Transactional
+ public User get() {
+ return userManager.getByUsername(getCurrentUsername());
+ }
+
+ @PreAuthorize("isAuthenticated()")
+ @PatchMapping("/user")
+ @Transactional
+ public User updateUser(@RequestBody User user) {
+ if (!getCurrentUsername().equals(user.getUsername())) {
+ throw new EntityResponseStatusException(HttpStatus.UNPROCESSABLE_ENTITY);
+ }
+
+ User orgUser = userManager.getByUsername(user.getUsername());
+
+ orgUser.setName(user.getName());
+ orgUser.setAbout(user.getAbout());
+ orgUser.setDarkTheme(user.isDarkTheme());
+ orgUser.setEmail(user.getEmail());
+ orgUser.setLocale(user.getLocale());
+
+ user = userManager.save(orgUser);
+ return user;
+ }
+
+ @PreAuthorize("isAuthenticated()")
+ @PostMapping("/password")
+ public void changePassword(@RequestBody UserPasswordModel passwordModel) {
+
+ Errors errors = new RequestBodyErrors(passwordModel);
+
+ User user = userManager.getByUsername(getCurrentUsername());
+
+ if (!StringUtils.hasText(passwordModel.getOld())
+ || !passwordEncoder.matches(passwordModel.getOld(), userManager.getPasswordHash(user.getUsername()))) {
+ errors.rejectValue("old", "UNAUTHORIZED");
+ }
+
+ passwordModelValidator.validate(passwordModel, errors);
+
+ if (errors.hasErrors()) {
+ throw new EntityResponseStatusException(errors.getAllErrors(), HttpStatus.CONFLICT);
+ }
+
+ userManager.setPassword(user.getUsername(), passwordModel.getPassword());
+ }
+
+}
diff --git a/backend/src/main/java/de/champonthis/buntspecht/controller/admin/DebugController.java b/backend/src/main/java/de/champonthis/buntspecht/controller/admin/DebugController.java
new file mode 100644
index 0000000..a641c42
--- /dev/null
+++ b/backend/src/main/java/de/champonthis/buntspecht/controller/admin/DebugController.java
@@ -0,0 +1,118 @@
+package de.champonthis.buntspecht.controller.admin;
+
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+import java.util.SplittableRandom;
+
+import org.apache.commons.lang3.RandomStringUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import de.champonthis.buntspecht.model.QUser;
+import de.champonthis.buntspecht.model.Turnover;
+import de.champonthis.buntspecht.model.User;
+import de.champonthis.buntspecht.repository.TurnoverRepository;
+import de.champonthis.buntspecht.repository.UserRepository;
+
+@RestController
+@RequestMapping("/debug")
+public class DebugController {
+
+ private Logger logger = LoggerFactory.getLogger(DebugController.class);
+
+ @Autowired
+ private PasswordEncoder passwordEncoder;
+ @Autowired
+ private UserRepository userRepository;
+ @Autowired
+ private TurnoverRepository turnoverRepository;
+
+ SplittableRandom splittableRandom = new SplittableRandom();
+
+ @PreAuthorize("hasRole('ROLE_DEBUG')")
+ @GetMapping("/random")
+ public void random(
+ @RequestParam("users") Optional usersParameter,
+ @RequestParam("minEntries") Optional minEntriesParameter,
+ @RequestParam("maxEntries") Optional maxEntriesParameter,
+ @RequestParam("days") Optional daysParameter) {
+ logger.warn("start random generation");
+
+ long userCount = userRepository.count(QUser.user.username.startsWith("Tätowier"));
+
+ List newUser = new ArrayList<>();
+
+ for (long i = userCount + 1; i <= userCount + usersParameter.orElse(5); i++) {
+ User user = new User();
+ String username = (splittableRandom.nextBoolean() ? "Tätowiererin " : "Tätowierer ") + i;
+ String name = "Random " + RandomStringUtils.randomAlphanumeric(splittableRandom.nextInt(4, 8));
+ user.setUsername(username);
+ user.setName(name);
+ user.setPasswordHash(passwordEncoder.encode(username));
+ user = userRepository.save(user);
+ logger.trace("Created user: '" + username + "'");
+ newUser.add(user.getUsername());
+ }
+
+ logger.info("Created " + usersParameter.orElse(5) + " users");
+
+ Instant startInclusive = Instant.now().minus(daysParameter.orElse(350), ChronoUnit.DAYS);
+ Instant endExclusive = Instant.now();
+
+ for (String username : newUser) {
+ long numEntries = splittableRandom.nextLong(minEntriesParameter.orElse(3), maxEntriesParameter.orElse(20));
+ for (int i = 0; i < numEntries; i++) {
+ Turnover turnover = new Turnover();
+ turnover.setUsername(username);
+ turnover.setCreated(randomDate(startInclusive, endExclusive));
+ turnover.setUpdated(splittableRandom.nextBoolean() ? turnover.getCreated()
+ : randomDate(turnover.getCreated(), endExclusive));
+ turnover.setDueDate(splittableRandom.nextBoolean() ? turnover.getCreated()
+ : randomDate(startInclusive, turnover.getCreated()));
+ turnover.setCustomer(RandomStringUtils.randomAlphabetic(splittableRandom.nextInt(3, 10)));
+
+ turnover.setMotif(RandomStringUtils.randomAlphabetic(splittableRandom.nextInt(5, 30)));
+
+ turnover.setPrice(Float.valueOf(String.format("%.2f", splittableRandom.nextFloat(0.01f, 2000f))));
+
+ if (splittableRandom.nextBoolean()) {
+ turnover.setTimeInvestment(
+ Float.valueOf(String.format("%.2f", splittableRandom.nextFloat(0.01f, 20f))));
+ }
+
+ if (splittableRandom.nextBoolean()) {
+ turnover.setRemark(RandomStringUtils.randomAlphabetic(splittableRandom.nextInt(10, 50)));
+ }
+
+ if (splittableRandom.nextInt(5) < 3) {
+ turnover.setMaterialConsumption(
+ RandomStringUtils.randomAlphabetic(splittableRandom.nextInt(10, 250)));
+ }
+
+ turnover = turnoverRepository.save(turnover);
+ logger.trace("Created turnover: '" + turnover.getId() + "'");
+ }
+ logger.info("Created " + numEntries + " turnovers of '" + username + "'");
+ }
+
+ logger.warn("finished random generation");
+ }
+
+ protected Instant randomDate(Instant startInclusive, Instant endExclusive) {
+ long startSeconds = startInclusive.getEpochSecond();
+ long endSeconds = endExclusive.getEpochSecond();
+ long random = splittableRandom.nextLong(startSeconds, endSeconds);
+
+ return Instant.ofEpochSecond(random);
+ }
+}
diff --git a/backend/src/main/java/de/champonthis/buntspecht/controller/admin/SystemPropertiesController.java b/backend/src/main/java/de/champonthis/buntspecht/controller/admin/SystemPropertiesController.java
new file mode 100644
index 0000000..3712cac
--- /dev/null
+++ b/backend/src/main/java/de/champonthis/buntspecht/controller/admin/SystemPropertiesController.java
@@ -0,0 +1,78 @@
+package de.champonthis.buntspecht.controller.admin;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.data.domain.Sort;
+import org.springframework.http.HttpStatus;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import de.champonthis.buntspecht.controller.BaseController;
+import de.champonthis.buntspecht.controller.support.EntityResponseStatusException;
+import de.champonthis.buntspecht.model.SystemProperty;
+import de.champonthis.buntspecht.repository.SystemPropertyRepository;
+
+@RestController
+@RequestMapping("/system/properties")
+public class SystemPropertiesController extends BaseController {
+
+ @Autowired
+ private SystemPropertyRepository systemPropertyRepository;
+
+ @PreAuthorize("hasRole('ROLE_ADMIN')")
+ @GetMapping()
+ public List getProperties(@RequestParam("page") Optional pageParameter,
+ @RequestParam("size") Optional sizeParameter) {
+ Sort sort = Sort.by("key").ascending();
+ return systemPropertyRepository.findAll(PageRequest.of(pageParameter.orElse(0), sizeParameter.orElse(10), sort))
+ .getContent();
+ }
+
+ @PreAuthorize("hasRole('ROLE_ADMIN')")
+ @GetMapping("/{key}")
+ public SystemProperty getProperty(@PathVariable("key") String key) {
+ if (!systemPropertyRepository.existsById(key)) {
+ throw new EntityResponseStatusException(HttpStatus.NO_CONTENT);
+ }
+
+ return systemPropertyRepository.findById(key).get();
+ }
+
+ @PreAuthorize("hasRole('ROLE_ADMIN')")
+ @PostMapping("")
+ public SystemProperty createOrUpdate(@RequestBody SystemProperty systemProperty) {
+ return systemPropertyRepository.save(systemProperty);
+ }
+
+ @PreAuthorize("hasRole('ROLE_ADMIN')")
+ @PostMapping("/list")
+ public List createOrUpdateList(@RequestBody List systemProperties) {
+ List result = new ArrayList<>();
+ for (SystemProperty systemProperty : systemProperties) {
+ result.add(
+ systemPropertyRepository.save(systemProperty));
+ }
+ return result;
+ }
+
+ @PreAuthorize("hasRole('ROLE_ADMIN')")
+ @DeleteMapping("/{key}")
+ public void deleteProperty(@PathVariable("key") String key) {
+ if (!systemPropertyRepository.existsById(key)) {
+ throw new EntityResponseStatusException(HttpStatus.NOT_MODIFIED);
+ }
+
+ systemPropertyRepository.deleteById(key);
+ }
+}
diff --git a/backend/src/main/java/de/champonthis/buntspecht/controller/admin/TurnoverManagementController.java b/backend/src/main/java/de/champonthis/buntspecht/controller/admin/TurnoverManagementController.java
new file mode 100644
index 0000000..144c138
--- /dev/null
+++ b/backend/src/main/java/de/champonthis/buntspecht/controller/admin/TurnoverManagementController.java
@@ -0,0 +1,139 @@
+package de.champonthis.buntspecht.controller.admin;
+
+import java.time.Instant;
+import java.util.Optional;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.HttpStatus;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.validation.Errors;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PatchMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import com.querydsl.core.QueryResults;
+import com.querydsl.core.Tuple;
+
+import de.champonthis.buntspecht.businesslogic.TurnoverManager;
+import de.champonthis.buntspecht.controller.BaseController;
+import de.champonthis.buntspecht.controller.model.TurnoverFilterModel;
+import de.champonthis.buntspecht.controller.model.TurnoverFilterModel.MinMax;
+import de.champonthis.buntspecht.controller.support.EntityResponseStatusException;
+import de.champonthis.buntspecht.controller.support.RequestBodyErrors;
+import de.champonthis.buntspecht.controller.validation.TurnoverValidator;
+import de.champonthis.buntspecht.model.Turnover;
+import jakarta.transaction.Transactional;
+
+@RestController
+@RequestMapping("/turnovers/manage")
+public class TurnoverManagementController extends BaseController {
+
+ @Autowired
+ private TurnoverManager turnoverManager;
+ @Autowired
+ private TurnoverValidator turnoverValidator;
+
+ @PreAuthorize("hasRole('ROLE_ADMIN')")
+ @GetMapping
+ @Transactional
+ public QueryResults fetch(
+ @RequestParam("username") Optional usernameParameter,
+ @RequestParam("limit") Optional limitParameter,
+ @RequestParam("offset") Optional offsetParameter,
+ @RequestParam("sort") Optional sort,
+ @RequestParam("descending") Optional descending,
+ @RequestParam("from") Optional from,
+ @RequestParam("to") Optional to,
+ @RequestParam("created_from") Optional fromCreated,
+ @RequestParam("created_to") Optional toCreated,
+ @RequestParam("customer") Optional customer,
+ @RequestParam("motif") Optional motif) {
+
+ TurnoverFilterModel filter = new TurnoverFilterModel();
+ filter.setDueDate(new MinMax(from.orElse(null), to.orElse(null)));
+ filter.setCreated(new MinMax(fromCreated.orElse(null), toCreated.orElse(null)));
+ filter.setCustomer(customer.orElse(null));
+ filter.setMotif(motif.orElse(null));
+
+ return turnoverManager.fetch(usernameParameter.orElse(null), limitParameter.orElse(15L),
+ offsetParameter.orElse(0L), sort.orElse("dueDate"), descending.orElse(false), filter);
+ }
+
+ @PreAuthorize("hasRole('ROLE_ADMIN')")
+ @GetMapping("/overview")
+ @Transactional
+ public QueryResults overview(
+ @RequestParam("username") Optional usernameParameter,
+ @RequestParam("limit") Optional limitParameter,
+ @RequestParam("offset") Optional offsetParameter,
+ @RequestParam("sort") Optional sort,
+ @RequestParam("descending") Optional descending,
+ @RequestParam("from") Optional from,
+ @RequestParam("to") Optional to,
+ @RequestParam("created_from") Optional fromCreated,
+ @RequestParam("created_to") Optional toCreated,
+ @RequestParam("customer") Optional customer,
+ @RequestParam("motif") Optional motif) {
+
+ TurnoverFilterModel filter = new TurnoverFilterModel();
+ filter.setDueDate(new MinMax(from.orElse(null), to.orElse(null)));
+ filter.setCreated(new MinMax(fromCreated.orElse(null), toCreated.orElse(null)));
+ filter.setCustomer(customer.orElse(null));
+ filter.setMotif(motif.orElse(null));
+ return turnoverManager.overview(usernameParameter.orElse(null), limitParameter.orElse(15L),
+ offsetParameter.orElse(0L), sort.orElse("username"),
+ descending.orElse(false), filter);
+ }
+
+ @PreAuthorize("hasRole('ROLE_ADMIN')")
+ @GetMapping("/{id}")
+ @Transactional
+ public Turnover getById(@PathVariable("id") Long id) {
+ if (!turnoverManager.exists(id)) {
+ throw new EntityResponseStatusException(HttpStatus.NO_CONTENT);
+ }
+
+ return turnoverManager.get(id);
+ }
+
+ @PreAuthorize("hasRole('ROLE_ADMIN')")
+ @PatchMapping
+ @Transactional
+ public Turnover update(@RequestBody Turnover turnover) {
+ Errors errors = new RequestBodyErrors(turnover);
+ turnoverValidator.validate(turnover, errors);
+
+ if (errors.hasErrors()) {
+ throw new EntityResponseStatusException(errors.getAllErrors(), HttpStatus.CONFLICT);
+ }
+
+ Turnover existing = turnoverManager.get(turnover.getId());
+ if (existing.equals(turnover)) {
+ throw new EntityResponseStatusException(HttpStatus.NOT_MODIFIED);
+ }
+
+ if (turnover.getDueDate() == null) {
+ turnover.setDueDate(turnover.getCreated());
+ }
+
+ turnover.setUpdated(Instant.now());
+
+ return turnoverManager.save(turnover);
+ }
+
+ @PreAuthorize("hasRole('ROLE_ADMIN')")
+ @DeleteMapping("/{id}")
+ @Transactional
+ public void deleteById(@PathVariable("id") Long id) {
+ if (!turnoverManager.exists(id)) {
+ throw new EntityResponseStatusException(HttpStatus.NO_CONTENT);
+ }
+
+ turnoverManager.deleteById(id);
+ }
+}
diff --git a/backend/src/main/java/de/champonthis/buntspecht/controller/admin/UserManagementController.java b/backend/src/main/java/de/champonthis/buntspecht/controller/admin/UserManagementController.java
new file mode 100644
index 0000000..425596a
--- /dev/null
+++ b/backend/src/main/java/de/champonthis/buntspecht/controller/admin/UserManagementController.java
@@ -0,0 +1,148 @@
+package de.champonthis.buntspecht.controller.admin;
+
+import java.util.List;
+import java.util.Optional;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.HttpStatus;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.validation.Errors;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PatchMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import com.querydsl.core.QueryResults;
+
+import de.champonthis.buntspecht.businesslogic.UserManager;
+import de.champonthis.buntspecht.controller.BaseController;
+import de.champonthis.buntspecht.controller.admin.validation.UserValidator;
+import de.champonthis.buntspecht.controller.model.UserPasswordModel;
+import de.champonthis.buntspecht.controller.support.EntityResponseStatusException;
+import de.champonthis.buntspecht.controller.support.RequestBodyErrors;
+import de.champonthis.buntspecht.controller.validation.PasswordModelValidator;
+import de.champonthis.buntspecht.model.User;
+import jakarta.transaction.Transactional;
+
+@RestController
+@RequestMapping("/users/manage")
+public class UserManagementController extends BaseController {
+
+ @Autowired
+ private UserManager userManager;
+ @Autowired
+ private UserValidator userValidator;
+ @Autowired
+ private PasswordModelValidator passwordModelValidator;
+
+ @PreAuthorize("hasRole('ROLE_ADMIN')")
+ @GetMapping
+ @Transactional
+ public QueryResults fetch(
+ @RequestParam("limit") Optional limitParameter,
+ @RequestParam("offset") Optional offsetParameter,
+ @RequestParam("sort") Optional sort,
+ @RequestParam("descending") Optional descending,
+ @RequestParam("filter") Optional search) {
+ return userManager.fetch(limitParameter.orElse(15L), offsetParameter.orElse(0L), sort.orElse("username"),
+ descending.orElse(false), search.orElse(""));
+ }
+
+ @PreAuthorize("hasRole('ROLE_ADMIN')")
+ @GetMapping("/pick")
+ @Transactional
+ public List pick(
+ @RequestParam("filter") Optional search) {
+ return userManager.fetch(5L, 0L, "", false, search.orElse("")).getResults();
+ }
+
+ @PreAuthorize("hasRole('ROLE_ADMIN')")
+ @GetMapping("/{username}")
+ @Transactional
+ public User get(@PathVariable("username") String username) {
+ User user = userManager.getByUsername(username);
+
+ if (user == null) {
+ throw new EntityResponseStatusException(HttpStatus.NO_CONTENT);
+ }
+
+ return user;
+ }
+
+ @PreAuthorize("hasRole('ROLE_ADMIN')")
+ @PostMapping
+ @Transactional
+ public User create(@RequestBody User user) {
+ Errors errors = new RequestBodyErrors(user);
+ userValidator.validateNew(user, errors);
+
+ if (errors.hasErrors()) {
+ throw new EntityResponseStatusException(errors.getAllErrors(), HttpStatus.CONFLICT);
+ }
+
+ if (user.getLocale() == null) {
+ user.setLocale("de");
+ }
+
+ return userManager.save(user);
+ }
+
+ @PreAuthorize("hasRole('ROLE_ADMIN')")
+ @PatchMapping
+ @Transactional
+ public User update(@RequestBody User user) {
+ Errors errors = new RequestBodyErrors(user);
+ userValidator.validate(user, errors);
+
+ if (errors.hasErrors()) {
+ throw new EntityResponseStatusException(errors.getAllErrors(), HttpStatus.CONFLICT);
+ }
+
+ if (getCurrentUsername().equals(user.getUsername()) && user.getRoles().indexOf("ROLE_ADMIN") == -1) {
+ user.getRoles().add("ROLE_ADMIN");
+ }
+
+ return userManager.save(user);
+ }
+
+ @PreAuthorize("hasRole('ROLE_ADMIN')")
+ @DeleteMapping("/{username}")
+ @Transactional
+ public void delete(@PathVariable("username") String username) {
+ User user = userManager.getByUsername(username);
+
+ if (user == null || getCurrentUsername().equals(username)) {
+ throw new EntityResponseStatusException(HttpStatus.NOT_MODIFIED);
+ }
+
+ userManager.delete(user);
+ }
+
+ @PreAuthorize("hasRole('ROLE_ADMIN')")
+ @PostMapping("/{username}/password")
+ public void password(@PathVariable("username") String username,
+ @RequestBody UserPasswordModel passwordModel) {
+
+ Errors errors = new RequestBodyErrors(passwordModel);
+
+ User user = userManager.getByUsername(username);
+
+ if (user == null) {
+ throw new EntityResponseStatusException(HttpStatus.NO_CONTENT);
+ }
+
+ passwordModelValidator.validate(passwordModel, errors);
+
+ if (errors.hasErrors()) {
+ throw new EntityResponseStatusException(errors.getAllErrors(), HttpStatus.CONFLICT);
+ }
+
+ userManager.setPassword(user.getUsername(), passwordModel.getPassword());
+ }
+
+}
diff --git a/backend/src/main/java/de/champonthis/buntspecht/controller/admin/validation/UserValidator.java b/backend/src/main/java/de/champonthis/buntspecht/controller/admin/validation/UserValidator.java
new file mode 100644
index 0000000..c308ebf
--- /dev/null
+++ b/backend/src/main/java/de/champonthis/buntspecht/controller/admin/validation/UserValidator.java
@@ -0,0 +1,46 @@
+package de.champonthis.buntspecht.controller.admin.validation;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+import org.springframework.util.StringUtils;
+import org.springframework.validation.Errors;
+import org.springframework.validation.Validator;
+
+import de.champonthis.buntspecht.businesslogic.UserManager;
+import de.champonthis.buntspecht.model.User;
+
+@Component
+public class UserValidator implements Validator {
+
+ @Autowired
+ private UserManager userManager;
+
+ @Override
+ public boolean supports(Class> clazz) {
+ return clazz.isAssignableFrom(User.class);
+ }
+
+ @Override
+ public void validate(Object target, Errors errors) {
+ User user = (User) target;
+ if (!StringUtils.hasText(user.getUsername())) {
+ errors.rejectValue("username", "REQUIRED");
+ return;
+ }
+ }
+
+ public void validateNew(Object target, Errors errors) {
+ validate(target, errors);
+
+ if (errors.hasErrors()) {
+ return;
+ }
+
+ User user = (User) target;
+ if (userManager.exists(user.getUsername())) {
+ errors.rejectValue("username", "ALREADY_EXISTS");
+ return;
+ }
+ }
+
+}
diff --git a/backend/src/main/java/de/champonthis/buntspecht/controller/model/TupleSerializer.java b/backend/src/main/java/de/champonthis/buntspecht/controller/model/TupleSerializer.java
new file mode 100644
index 0000000..bee2d6f
--- /dev/null
+++ b/backend/src/main/java/de/champonthis/buntspecht/controller/model/TupleSerializer.java
@@ -0,0 +1,28 @@
+package de.champonthis.buntspecht.controller.model;
+
+import java.io.IOException;
+
+import org.springframework.boot.jackson.JsonComponent;
+
+import com.fasterxml.jackson.core.JsonGenerator;
+import com.fasterxml.jackson.databind.JsonSerializer;
+import com.fasterxml.jackson.databind.SerializerProvider;
+import com.querydsl.core.Tuple;
+
+@JsonComponent
+public class TupleSerializer extends JsonSerializer {
+
+ @Override
+ public void serialize(Tuple value, JsonGenerator gen, SerializerProvider serializers) throws IOException {
+ if (value.toArray().length > 1) {
+ gen.writeStartArray();
+ }
+ for (Object object : value.toArray()) {
+ gen.writeObject(object);
+ }
+ if (value.toArray().length > 1) {
+ gen.writeEndArray();
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/backend/src/main/java/de/champonthis/buntspecht/controller/model/TurnoverFilterModel.java b/backend/src/main/java/de/champonthis/buntspecht/controller/model/TurnoverFilterModel.java
new file mode 100644
index 0000000..accabd2
--- /dev/null
+++ b/backend/src/main/java/de/champonthis/buntspecht/controller/model/TurnoverFilterModel.java
@@ -0,0 +1,98 @@
+package de.champonthis.buntspecht.controller.model;
+
+import java.time.Instant;
+
+public class TurnoverFilterModel {
+
+ private MinMax created;
+ private MinMax dueDate;
+ private MinMax updated;
+ private String customer;
+ private String motif;
+ private MinMax price;
+ private MinMax timeInvestment;
+
+ public MinMax getCreated() {
+ return created;
+ }
+
+ public void setCreated(MinMax created) {
+ this.created = created;
+ }
+
+ public MinMax getDueDate() {
+ return dueDate;
+ }
+
+ public void setDueDate(MinMax dueDate) {
+ this.dueDate = dueDate;
+ }
+
+ public MinMax getUpdated() {
+ return updated;
+ }
+
+ public void setUpdated(MinMax updated) {
+ this.updated = updated;
+ }
+
+ public String getCustomer() {
+ return customer;
+ }
+
+ public void setCustomer(String customer) {
+ this.customer = customer;
+ }
+
+ public String getMotif() {
+ return motif;
+ }
+
+ public void setMotif(String motif) {
+ this.motif = motif;
+ }
+
+ public MinMax getPrice() {
+ return price;
+ }
+
+ public void setPrice(MinMax price) {
+ this.price = price;
+ }
+
+ public MinMax getTimeInvestment() {
+ return timeInvestment;
+ }
+
+ public void setTimeInvestment(MinMax timeInvestment) {
+ this.timeInvestment = timeInvestment;
+ }
+
+ public static class MinMax {
+
+ private T min;
+ private T max;
+
+ public MinMax(T min, T max) {
+ this.min = min;
+ this.max = max;
+ }
+
+ public T getMin() {
+ return min;
+ }
+
+ public void setMin(T min) {
+ this.min = min;
+ }
+
+ public T getMax() {
+ return max;
+ }
+
+ public void setMax(T max) {
+ this.max = max;
+ }
+
+ }
+}
diff --git a/backend/src/main/java/de/champonthis/buntspecht/controller/model/UserPasswordModel.java b/backend/src/main/java/de/champonthis/buntspecht/controller/model/UserPasswordModel.java
new file mode 100644
index 0000000..cd27fc7
--- /dev/null
+++ b/backend/src/main/java/de/champonthis/buntspecht/controller/model/UserPasswordModel.java
@@ -0,0 +1,32 @@
+package de.champonthis.buntspecht.controller.model;
+
+public class UserPasswordModel {
+
+ private String old;
+ private String password;
+ private String password2;
+
+ public String getOld() {
+ return old;
+ }
+
+ public void setOld(String old) {
+ this.old = old;
+ }
+
+ public String getPassword() {
+ return password;
+ }
+
+ public void setPassword(String password) {
+ this.password = password;
+ }
+
+ public String getPassword2() {
+ return password2;
+ }
+
+ public void setPassword2(String password2) {
+ this.password2 = password2;
+ }
+}
diff --git a/backend/src/main/java/de/champonthis/buntspecht/controller/support/ControllerExceptionHandler.java b/backend/src/main/java/de/champonthis/buntspecht/controller/support/ControllerExceptionHandler.java
new file mode 100644
index 0000000..d81ed18
--- /dev/null
+++ b/backend/src/main/java/de/champonthis/buntspecht/controller/support/ControllerExceptionHandler.java
@@ -0,0 +1,23 @@
+package de.champonthis.buntspecht.controller.support;
+
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.ControllerAdvice;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.context.request.WebRequest;
+import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
+
+
+@ControllerAdvice
+public class ControllerExceptionHandler extends ResponseEntityExceptionHandler {
+
+
+ @ExceptionHandler(value = { EntityResponseStatusException.class })
+ protected ResponseEntity