init
This commit is contained in:
14
backend/src/main/docker/Dockerfile.jvm
Normal file
14
backend/src/main/docker/Dockerfile.jvm
Normal file
@@ -0,0 +1,14 @@
|
||||
FROM eclipse-temurin:21-jre
|
||||
|
||||
ENV LANGUAGE='de_DE:de'
|
||||
ENV JAVA_OPTS_APPEND="-Dquarkus.http.host=0.0.0.0 -Djava.util.logging.manager=org.jboss.logmanager.LogManager"
|
||||
|
||||
COPY target/quarkus-app/lib/ /deployments/lib/
|
||||
COPY target/quarkus-app/*.jar /deployments/
|
||||
COPY target/quarkus-app/app/ /deployments/app/
|
||||
COPY target/quarkus-app/quarkus/ /deployments/quarkus/
|
||||
|
||||
EXPOSE 8080
|
||||
USER 185
|
||||
|
||||
ENTRYPOINT ["java", "-jar", "/deployments/quarkus-run.jar"]
|
||||
@@ -0,0 +1,3 @@
|
||||
package de.strichliste.dto;
|
||||
|
||||
public record AccessLinkCreateRequest(String role, Long companyId, String description) {}
|
||||
@@ -0,0 +1,3 @@
|
||||
package de.strichliste.dto;
|
||||
|
||||
public record AccessLinkDto(Long id, String token, String role, Long companyId, String description, boolean active) {}
|
||||
@@ -0,0 +1,3 @@
|
||||
package de.strichliste.dto;
|
||||
|
||||
public record CompanyCreateRequest(String name) {}
|
||||
3
backend/src/main/java/de/strichliste/dto/CompanyDto.java
Normal file
3
backend/src/main/java/de/strichliste/dto/CompanyDto.java
Normal file
@@ -0,0 +1,3 @@
|
||||
package de.strichliste.dto;
|
||||
|
||||
public record CompanyDto(Long id, String name, boolean active) {}
|
||||
@@ -0,0 +1,3 @@
|
||||
package de.strichliste.dto;
|
||||
|
||||
public record EmployeeCreateRequest(String firstName, String lastName) {}
|
||||
@@ -0,0 +1,3 @@
|
||||
package de.strichliste.dto;
|
||||
|
||||
public record EmployeeDto(Long id, Long companyId, String firstName, String lastName, boolean active) {}
|
||||
@@ -0,0 +1,20 @@
|
||||
package de.strichliste.dto;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record MonthlyReportDto(
|
||||
String month,
|
||||
Long companyId,
|
||||
String companyName,
|
||||
List<EmployeeReportLine> employees,
|
||||
Long totalCents
|
||||
) {
|
||||
public record EmployeeReportLine(
|
||||
Long employeeId,
|
||||
String firstName,
|
||||
String lastName,
|
||||
Long totalCount,
|
||||
Long totalCents,
|
||||
List<MonthlyTallyDto> products
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package de.strichliste.dto;
|
||||
|
||||
public record MonthlyTallyDto(
|
||||
String productName,
|
||||
Integer priceCents,
|
||||
Long count,
|
||||
Long totalCents
|
||||
) {}
|
||||
@@ -0,0 +1,3 @@
|
||||
package de.strichliste.dto;
|
||||
|
||||
public record ProductCreateRequest(String name, int priceCents, String iconPlaceholder) {}
|
||||
3
backend/src/main/java/de/strichliste/dto/ProductDto.java
Normal file
3
backend/src/main/java/de/strichliste/dto/ProductDto.java
Normal file
@@ -0,0 +1,3 @@
|
||||
package de.strichliste.dto;
|
||||
|
||||
public record ProductDto(Long id, String name, int priceCents, String iconPlaceholder, boolean active) {}
|
||||
@@ -0,0 +1,3 @@
|
||||
package de.strichliste.dto;
|
||||
|
||||
public record TallyRequest(Long employeeId, Long productId) {}
|
||||
45
backend/src/main/java/de/strichliste/entity/AccessLink.java
Normal file
45
backend/src/main/java/de/strichliste/entity/AccessLink.java
Normal file
@@ -0,0 +1,45 @@
|
||||
package de.strichliste.entity;
|
||||
|
||||
import io.quarkus.hibernate.orm.panache.PanacheEntityBase;
|
||||
import jakarta.persistence.*;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Optional;
|
||||
|
||||
@Entity
|
||||
@Table(name = "access_link")
|
||||
public class AccessLink extends PanacheEntityBase {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
public Long id;
|
||||
|
||||
@Column(nullable = false, unique = true, length = 64)
|
||||
public String token;
|
||||
|
||||
@Column(nullable = false, length = 20)
|
||||
public String role;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "company_id")
|
||||
public Company company;
|
||||
|
||||
public String description;
|
||||
|
||||
@Column(nullable = false)
|
||||
public boolean active = true;
|
||||
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
public LocalDateTime createdAt = LocalDateTime.now();
|
||||
|
||||
public static Optional<AccessLink> findByToken(String token) {
|
||||
return find("token = ?1 and active = true", token).firstResultOptional();
|
||||
}
|
||||
|
||||
public boolean isProviderAdmin() {
|
||||
return "PROVIDER_ADMIN".equals(role);
|
||||
}
|
||||
|
||||
public boolean isCompanyAdmin() {
|
||||
return "COMPANY_ADMIN".equals(role);
|
||||
}
|
||||
}
|
||||
31
backend/src/main/java/de/strichliste/entity/Company.java
Normal file
31
backend/src/main/java/de/strichliste/entity/Company.java
Normal file
@@ -0,0 +1,31 @@
|
||||
package de.strichliste.entity;
|
||||
|
||||
import io.quarkus.hibernate.orm.panache.PanacheEntityBase;
|
||||
import jakarta.persistence.*;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
@Entity
|
||||
@Table(name = "company")
|
||||
public class Company extends PanacheEntityBase {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
public Long id;
|
||||
|
||||
@Column(nullable = false)
|
||||
public String name;
|
||||
|
||||
@Column(nullable = false)
|
||||
public boolean active = true;
|
||||
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
public LocalDateTime createdAt = LocalDateTime.now();
|
||||
|
||||
@OneToMany(mappedBy = "company", fetch = FetchType.LAZY)
|
||||
public List<Employee> employees;
|
||||
|
||||
public static List<Company> findAllActive() {
|
||||
return find("active", true).list();
|
||||
}
|
||||
}
|
||||
39
backend/src/main/java/de/strichliste/entity/Employee.java
Normal file
39
backend/src/main/java/de/strichliste/entity/Employee.java
Normal file
@@ -0,0 +1,39 @@
|
||||
package de.strichliste.entity;
|
||||
|
||||
import io.quarkus.hibernate.orm.panache.PanacheEntityBase;
|
||||
import jakarta.persistence.*;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
@Entity
|
||||
@Table(name = "employee")
|
||||
public class Employee extends PanacheEntityBase {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
public Long id;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "company_id", nullable = false)
|
||||
public Company company;
|
||||
|
||||
@Column(name = "first_name", nullable = false)
|
||||
public String firstName;
|
||||
|
||||
@Column(name = "last_name", nullable = false)
|
||||
public String lastName;
|
||||
|
||||
@Column(nullable = false)
|
||||
public boolean active = true;
|
||||
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
public LocalDateTime createdAt = LocalDateTime.now();
|
||||
|
||||
public static List<Employee> findActiveByCompany(Long companyId) {
|
||||
return find("company.id = ?1 and active = true", companyId).list();
|
||||
}
|
||||
|
||||
public static List<Employee> findAllByCompany(Long companyId) {
|
||||
return find("company.id", companyId).list();
|
||||
}
|
||||
}
|
||||
34
backend/src/main/java/de/strichliste/entity/Product.java
Normal file
34
backend/src/main/java/de/strichliste/entity/Product.java
Normal file
@@ -0,0 +1,34 @@
|
||||
package de.strichliste.entity;
|
||||
|
||||
import io.quarkus.hibernate.orm.panache.PanacheEntityBase;
|
||||
import jakarta.persistence.*;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
@Entity
|
||||
@Table(name = "product")
|
||||
public class Product extends PanacheEntityBase {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
public Long id;
|
||||
|
||||
@Column(nullable = false)
|
||||
public String name;
|
||||
|
||||
@Column(name = "price_cents", nullable = false)
|
||||
public int priceCents;
|
||||
|
||||
@Column(name = "icon_placeholder")
|
||||
public String iconPlaceholder = "coffee";
|
||||
|
||||
@Column(nullable = false)
|
||||
public boolean active = true;
|
||||
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
public LocalDateTime createdAt = LocalDateTime.now();
|
||||
|
||||
public static List<Product> findAllActive() {
|
||||
return find("active", true).list();
|
||||
}
|
||||
}
|
||||
38
backend/src/main/java/de/strichliste/entity/TallyEntry.java
Normal file
38
backend/src/main/java/de/strichliste/entity/TallyEntry.java
Normal file
@@ -0,0 +1,38 @@
|
||||
package de.strichliste.entity;
|
||||
|
||||
import io.quarkus.hibernate.orm.panache.PanacheEntityBase;
|
||||
import jakarta.persistence.*;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
|
||||
@Entity
|
||||
@Table(name = "tally_entry")
|
||||
public class TallyEntry extends PanacheEntityBase {
|
||||
|
||||
private static final DateTimeFormatter MONTH_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM");
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
public Long id;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "employee_id", nullable = false)
|
||||
public Employee employee;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "product_id", nullable = false)
|
||||
public Product product;
|
||||
|
||||
@Column(name = "month_key", nullable = false, length = 7)
|
||||
public String monthKey;
|
||||
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
public LocalDateTime createdAt = LocalDateTime.now();
|
||||
|
||||
@PrePersist
|
||||
public void setMonthKey() {
|
||||
if (monthKey == null) {
|
||||
monthKey = LocalDateTime.now().format(MONTH_FORMAT);
|
||||
}
|
||||
}
|
||||
}
|
||||
74
backend/src/main/java/de/strichliste/filter/AuthFilter.java
Normal file
74
backend/src/main/java/de/strichliste/filter/AuthFilter.java
Normal file
@@ -0,0 +1,74 @@
|
||||
package de.strichliste.filter;
|
||||
|
||||
import de.strichliste.entity.AccessLink;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.ws.rs.container.ContainerRequestContext;
|
||||
import jakarta.ws.rs.container.ContainerRequestFilter;
|
||||
import jakarta.ws.rs.container.ResourceInfo;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import jakarta.ws.rs.ext.Provider;
|
||||
import jakarta.annotation.Priority;
|
||||
import jakarta.ws.rs.Priorities;
|
||||
import jakarta.ws.rs.NameBinding;
|
||||
import java.lang.annotation.*;
|
||||
|
||||
@Provider
|
||||
@AuthFilter.Secured
|
||||
@Priority(Priorities.AUTHENTICATION)
|
||||
public class AuthFilter implements ContainerRequestFilter {
|
||||
|
||||
@NameBinding
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Target({ElementType.TYPE, ElementType.METHOD})
|
||||
public @interface Secured {
|
||||
String[] roles() default {};
|
||||
}
|
||||
|
||||
@Inject
|
||||
ResourceInfo resourceInfo;
|
||||
|
||||
@Override
|
||||
public void filter(ContainerRequestContext ctx) {
|
||||
String token = ctx.getUriInfo().getQueryParameters().getFirst("token");
|
||||
if (token == null) {
|
||||
token = ctx.getHeaderString("X-Auth-Token");
|
||||
}
|
||||
|
||||
if (token == null || token.isBlank()) {
|
||||
ctx.abortWith(Response.status(Response.Status.UNAUTHORIZED).build());
|
||||
return;
|
||||
}
|
||||
|
||||
var linkOpt = AccessLink.findByToken(token);
|
||||
if (linkOpt.isEmpty()) {
|
||||
ctx.abortWith(Response.status(Response.Status.UNAUTHORIZED).build());
|
||||
return;
|
||||
}
|
||||
|
||||
AccessLink link = linkOpt.get();
|
||||
|
||||
Secured secured = resourceInfo.getResourceMethod().getAnnotation(Secured.class);
|
||||
if (secured == null) {
|
||||
secured = resourceInfo.getResourceClass().getAnnotation(Secured.class);
|
||||
}
|
||||
if (secured != null && secured.roles().length > 0) {
|
||||
boolean hasRole = false;
|
||||
for (String role : secured.roles()) {
|
||||
if (role.equals(link.role)) {
|
||||
hasRole = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!hasRole) {
|
||||
ctx.abortWith(Response.status(Response.Status.FORBIDDEN).build());
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
ctx.setProperty("accessLink", link);
|
||||
ctx.setProperty("role", link.role);
|
||||
if (link.company != null) {
|
||||
ctx.setProperty("companyId", link.company.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package de.strichliste.resource;
|
||||
|
||||
import de.strichliste.dto.*;
|
||||
import de.strichliste.entity.*;
|
||||
import de.strichliste.filter.AuthFilter.Secured;
|
||||
import jakarta.transaction.Transactional;
|
||||
import jakarta.ws.rs.*;
|
||||
import jakarta.ws.rs.core.Context;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import org.jboss.resteasy.reactive.RestResponse;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Path("/api/admin/company")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Secured(roles = {"COMPANY_ADMIN"})
|
||||
public class CompanyAdminResource {
|
||||
|
||||
private static final DateTimeFormatter MONTH_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM");
|
||||
|
||||
@GET
|
||||
@Path("/employees")
|
||||
public Response getEmployees(@QueryParam("token") String token) {
|
||||
AccessLink link = AccessLink.findByToken(token).orElse(null);
|
||||
if (link == null || link.company == null) {
|
||||
return Response.status(Response.Status.FORBIDDEN).build();
|
||||
}
|
||||
|
||||
List<EmployeeDto> employees = Employee.findAllByCompany(link.company.id).stream()
|
||||
.map(e -> new EmployeeDto(e.id, e.company.id, e.firstName, e.lastName, e.active))
|
||||
.toList();
|
||||
|
||||
return Response.ok(employees).build();
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("/employees")
|
||||
@Transactional
|
||||
public Response createEmployee(@QueryParam("token") String token, EmployeeCreateRequest request) {
|
||||
AccessLink link = AccessLink.findByToken(token).orElse(null);
|
||||
if (link == null || link.company == null) {
|
||||
return Response.status(Response.Status.FORBIDDEN).build();
|
||||
}
|
||||
|
||||
Employee employee = new Employee();
|
||||
employee.company = link.company;
|
||||
employee.firstName = request.firstName();
|
||||
employee.lastName = request.lastName();
|
||||
employee.persist();
|
||||
|
||||
return Response.status(Response.Status.CREATED)
|
||||
.entity(new EmployeeDto(employee.id, employee.company.id, employee.firstName, employee.lastName, employee.active))
|
||||
.build();
|
||||
}
|
||||
|
||||
@PUT
|
||||
@Path("/employees/{id}")
|
||||
@Transactional
|
||||
public Response updateEmployee(@QueryParam("token") String token, @PathParam("id") Long id, EmployeeCreateRequest request) {
|
||||
AccessLink link = AccessLink.findByToken(token).orElse(null);
|
||||
if (link == null || link.company == null) {
|
||||
return Response.status(Response.Status.FORBIDDEN).build();
|
||||
}
|
||||
|
||||
Employee employee = Employee.findById(id);
|
||||
if (employee == null || !employee.company.id.equals(link.company.id)) {
|
||||
return Response.status(Response.Status.NOT_FOUND).build();
|
||||
}
|
||||
|
||||
employee.firstName = request.firstName();
|
||||
employee.lastName = request.lastName();
|
||||
|
||||
return Response.ok(new EmployeeDto(employee.id, employee.company.id, employee.firstName, employee.lastName, employee.active)).build();
|
||||
}
|
||||
|
||||
@PUT
|
||||
@Path("/employees/{id}/toggle")
|
||||
@Transactional
|
||||
public Response toggleEmployee(@QueryParam("token") String token, @PathParam("id") Long id) {
|
||||
AccessLink link = AccessLink.findByToken(token).orElse(null);
|
||||
if (link == null || link.company == null) {
|
||||
return Response.status(Response.Status.FORBIDDEN).build();
|
||||
}
|
||||
|
||||
Employee employee = Employee.findById(id);
|
||||
if (employee == null || !employee.company.id.equals(link.company.id)) {
|
||||
return Response.status(Response.Status.NOT_FOUND).build();
|
||||
}
|
||||
|
||||
employee.active = !employee.active;
|
||||
|
||||
return Response.ok(new EmployeeDto(employee.id, employee.company.id, employee.firstName, employee.lastName, employee.active)).build();
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/report")
|
||||
public Response getMonthlyReport(@QueryParam("token") String token, @QueryParam("month") String month) {
|
||||
AccessLink link = AccessLink.findByToken(token).orElse(null);
|
||||
if (link == null || link.company == null) {
|
||||
return Response.status(Response.Status.FORBIDDEN).build();
|
||||
}
|
||||
|
||||
String monthKey = month != null ? month : LocalDateTime.now().format(MONTH_FORMAT);
|
||||
return Response.ok(buildCompanyReport(link.company.id, monthKey)).build();
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/report/employee/{employeeId}")
|
||||
public Response getEmployeeReport(
|
||||
@QueryParam("token") String token,
|
||||
@PathParam("employeeId") Long employeeId,
|
||||
@QueryParam("month") String month) {
|
||||
|
||||
AccessLink link = AccessLink.findByToken(token).orElse(null);
|
||||
if (link == null || link.company == null) {
|
||||
return Response.status(Response.Status.FORBIDDEN).build();
|
||||
}
|
||||
|
||||
Employee employee = Employee.findById(employeeId);
|
||||
if (employee == null || !employee.company.id.equals(link.company.id)) {
|
||||
return Response.status(Response.Status.NOT_FOUND).build();
|
||||
}
|
||||
|
||||
String monthKey = month != null ? month : LocalDateTime.now().format(MONTH_FORMAT);
|
||||
|
||||
List<MonthlyTallyDto> tallies = TallyEntry.find(
|
||||
"SELECT t.product.name, t.product.priceCents, COUNT(t), COUNT(t) * t.product.priceCents " +
|
||||
"FROM TallyEntry t WHERE t.employee.id = ?1 AND t.monthKey = ?2 " +
|
||||
"GROUP BY t.product.id, t.product.name, t.product.priceCents",
|
||||
employeeId, monthKey)
|
||||
.project(MonthlyTallyDto.class)
|
||||
.list();
|
||||
|
||||
long totalCents = tallies.stream().mapToLong(MonthlyTallyDto::totalCents).sum();
|
||||
long totalCount = tallies.stream().mapToLong(MonthlyTallyDto::count).sum();
|
||||
|
||||
var line = new MonthlyReportDto.EmployeeReportLine(
|
||||
employee.id, employee.firstName, employee.lastName, totalCount, totalCents, tallies);
|
||||
|
||||
return Response.ok(line).build();
|
||||
}
|
||||
|
||||
static MonthlyReportDto buildCompanyReport(Long companyId, String monthKey) {
|
||||
Company company = Company.findById(companyId);
|
||||
List<Employee> employees = Employee.findAllByCompany(companyId);
|
||||
List<MonthlyReportDto.EmployeeReportLine> lines = new ArrayList<>();
|
||||
long companyTotal = 0;
|
||||
|
||||
for (Employee emp : employees) {
|
||||
List<MonthlyTallyDto> tallies = TallyEntry.find(
|
||||
"SELECT t.product.name, t.product.priceCents, COUNT(t), COUNT(t) * t.product.priceCents " +
|
||||
"FROM TallyEntry t WHERE t.employee.id = ?1 AND t.monthKey = ?2 " +
|
||||
"GROUP BY t.product.id, t.product.name, t.product.priceCents",
|
||||
emp.id, monthKey)
|
||||
.project(MonthlyTallyDto.class)
|
||||
.list();
|
||||
|
||||
long totalCents = tallies.stream().mapToLong(MonthlyTallyDto::totalCents).sum();
|
||||
long totalCount = tallies.stream().mapToLong(MonthlyTallyDto::count).sum();
|
||||
companyTotal += totalCents;
|
||||
|
||||
if (totalCount > 0) {
|
||||
lines.add(new MonthlyReportDto.EmployeeReportLine(
|
||||
emp.id, emp.firstName, emp.lastName, totalCount, totalCents, tallies));
|
||||
}
|
||||
}
|
||||
|
||||
return new MonthlyReportDto(monthKey, companyId, company != null ? company.name : "", lines, companyTotal);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package de.strichliste.resource;
|
||||
|
||||
import de.strichliste.dto.*;
|
||||
import de.strichliste.entity.*;
|
||||
import de.strichliste.filter.AuthFilter.Secured;
|
||||
import jakarta.transaction.Transactional;
|
||||
import jakarta.ws.rs.*;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@Path("/api/admin/provider")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Secured(roles = {"PROVIDER_ADMIN"})
|
||||
public class ProviderAdminResource {
|
||||
|
||||
private static final DateTimeFormatter MONTH_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM");
|
||||
|
||||
// --- Companies ---
|
||||
|
||||
@GET
|
||||
@Path("/companies")
|
||||
public List<CompanyDto> getAllCompanies() {
|
||||
return Company.findAll().list().stream()
|
||||
.map(obj -> {
|
||||
Company c = (Company) obj;
|
||||
return new CompanyDto(c.id, c.name, c.active);
|
||||
})
|
||||
.toList();
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("/companies")
|
||||
@Transactional
|
||||
public Response createCompany(CompanyCreateRequest request) {
|
||||
Company company = new Company();
|
||||
company.name = request.name();
|
||||
company.persist();
|
||||
return Response.status(Response.Status.CREATED)
|
||||
.entity(new CompanyDto(company.id, company.name, company.active))
|
||||
.build();
|
||||
}
|
||||
|
||||
@PUT
|
||||
@Path("/companies/{id}")
|
||||
@Transactional
|
||||
public Response updateCompany(@PathParam("id") Long id, CompanyCreateRequest request) {
|
||||
Company company = Company.findById(id);
|
||||
if (company == null) {
|
||||
return Response.status(Response.Status.NOT_FOUND).build();
|
||||
}
|
||||
company.name = request.name();
|
||||
return Response.ok(new CompanyDto(company.id, company.name, company.active)).build();
|
||||
}
|
||||
|
||||
@PUT
|
||||
@Path("/companies/{id}/toggle")
|
||||
@Transactional
|
||||
public Response toggleCompany(@PathParam("id") Long id) {
|
||||
Company company = Company.findById(id);
|
||||
if (company == null) {
|
||||
return Response.status(Response.Status.NOT_FOUND).build();
|
||||
}
|
||||
company.active = !company.active;
|
||||
return Response.ok(new CompanyDto(company.id, company.name, company.active)).build();
|
||||
}
|
||||
|
||||
// --- Products ---
|
||||
|
||||
@GET
|
||||
@Path("/products")
|
||||
public List<ProductDto> getAllProducts() {
|
||||
return Product.findAll().list().stream()
|
||||
.map(obj -> {
|
||||
Product p = (Product) obj;
|
||||
return new ProductDto(p.id, p.name, p.priceCents, p.iconPlaceholder, p.active);
|
||||
})
|
||||
.toList();
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("/products")
|
||||
@Transactional
|
||||
public Response createProduct(ProductCreateRequest request) {
|
||||
Product product = new Product();
|
||||
product.name = request.name();
|
||||
product.priceCents = request.priceCents();
|
||||
product.iconPlaceholder = request.iconPlaceholder() != null ? request.iconPlaceholder() : "coffee";
|
||||
product.persist();
|
||||
return Response.status(Response.Status.CREATED)
|
||||
.entity(new ProductDto(product.id, product.name, product.priceCents, product.iconPlaceholder, product.active))
|
||||
.build();
|
||||
}
|
||||
|
||||
@PUT
|
||||
@Path("/products/{id}")
|
||||
@Transactional
|
||||
public Response updateProduct(@PathParam("id") Long id, ProductCreateRequest request) {
|
||||
Product product = Product.findById(id);
|
||||
if (product == null) {
|
||||
return Response.status(Response.Status.NOT_FOUND).build();
|
||||
}
|
||||
product.name = request.name();
|
||||
product.priceCents = request.priceCents();
|
||||
if (request.iconPlaceholder() != null) {
|
||||
product.iconPlaceholder = request.iconPlaceholder();
|
||||
}
|
||||
return Response.ok(new ProductDto(product.id, product.name, product.priceCents, product.iconPlaceholder, product.active)).build();
|
||||
}
|
||||
|
||||
@PUT
|
||||
@Path("/products/{id}/toggle")
|
||||
@Transactional
|
||||
public Response toggleProduct(@PathParam("id") Long id) {
|
||||
Product product = Product.findById(id);
|
||||
if (product == null) {
|
||||
return Response.status(Response.Status.NOT_FOUND).build();
|
||||
}
|
||||
product.active = !product.active;
|
||||
return Response.ok(new ProductDto(product.id, product.name, product.priceCents, product.iconPlaceholder, product.active)).build();
|
||||
}
|
||||
|
||||
// --- Reports ---
|
||||
|
||||
@GET
|
||||
@Path("/report")
|
||||
public Response getOverallReport(@QueryParam("month") String month) {
|
||||
String monthKey = month != null ? month : LocalDateTime.now().format(MONTH_FORMAT);
|
||||
|
||||
List<Company> companies = Company.findAll().list();
|
||||
List<MonthlyReportDto> reports = new ArrayList<>();
|
||||
|
||||
for (Company company : companies) {
|
||||
MonthlyReportDto report = CompanyAdminResource.buildCompanyReport(company.id, monthKey);
|
||||
if (!report.employees().isEmpty()) {
|
||||
reports.add(report);
|
||||
}
|
||||
}
|
||||
|
||||
long grandTotal = reports.stream().mapToLong(MonthlyReportDto::totalCents).sum();
|
||||
|
||||
var result = new ProviderReportDto(monthKey, reports, grandTotal);
|
||||
return Response.ok(result).build();
|
||||
}
|
||||
|
||||
// --- Access Links ---
|
||||
|
||||
@GET
|
||||
@Path("/access-links")
|
||||
public List<AccessLinkDto> getAccessLinks() {
|
||||
return AccessLink.findAll().list().stream()
|
||||
.map(obj -> {
|
||||
AccessLink a = (AccessLink) obj;
|
||||
return new AccessLinkDto(a.id, a.token, a.role, a.company != null ? a.company.id : null, a.description, a.active);
|
||||
})
|
||||
.toList();
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("/access-links")
|
||||
@Transactional
|
||||
public Response createAccessLink(AccessLinkCreateRequest request) {
|
||||
AccessLink link = new AccessLink();
|
||||
link.token = UUID.randomUUID().toString().replace("-", "");
|
||||
link.role = request.role();
|
||||
link.description = request.description();
|
||||
|
||||
if ("COMPANY_ADMIN".equals(request.role()) && request.companyId() != null) {
|
||||
Company company = Company.findById(request.companyId());
|
||||
if (company == null) {
|
||||
return Response.status(Response.Status.BAD_REQUEST).build();
|
||||
}
|
||||
link.company = company;
|
||||
}
|
||||
|
||||
link.persist();
|
||||
return Response.status(Response.Status.CREATED)
|
||||
.entity(new AccessLinkDto(link.id, link.token, link.role, link.company != null ? link.company.id : null, link.description, link.active))
|
||||
.build();
|
||||
}
|
||||
|
||||
public record ProviderReportDto(String month, List<MonthlyReportDto> companies, long grandTotalCents) {}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package de.strichliste.resource;
|
||||
|
||||
import de.strichliste.dto.*;
|
||||
import de.strichliste.entity.*;
|
||||
import jakarta.transaction.Transactional;
|
||||
import jakarta.ws.rs.*;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.List;
|
||||
|
||||
@Path("/api")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
public class PublicResource {
|
||||
|
||||
private static final DateTimeFormatter MONTH_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM");
|
||||
|
||||
@GET
|
||||
@Path("/companies")
|
||||
public List<CompanyDto> getActiveCompanies() {
|
||||
return Company.findAllActive().stream()
|
||||
.map(c -> new CompanyDto(c.id, c.name, c.active))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/companies/{id}/employees")
|
||||
public List<EmployeeDto> getEmployeesByCompany(@PathParam("id") Long companyId) {
|
||||
return Employee.findActiveByCompany(companyId).stream()
|
||||
.map(e -> new EmployeeDto(e.id, e.company.id, e.firstName, e.lastName, e.active))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/products")
|
||||
public List<ProductDto> getActiveProducts() {
|
||||
return Product.findAllActive().stream()
|
||||
.map(p -> new ProductDto(p.id, p.name, p.priceCents, p.iconPlaceholder, p.active))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("/tally")
|
||||
@Transactional
|
||||
public Response createTally(TallyRequest request) {
|
||||
Employee employee = Employee.findById(request.employeeId());
|
||||
if (employee == null) {
|
||||
return Response.status(Response.Status.NOT_FOUND).build();
|
||||
}
|
||||
Product product = Product.findById(request.productId());
|
||||
if (product == null) {
|
||||
return Response.status(Response.Status.NOT_FOUND).build();
|
||||
}
|
||||
|
||||
TallyEntry entry = new TallyEntry();
|
||||
entry.employee = employee;
|
||||
entry.product = product;
|
||||
entry.persist();
|
||||
|
||||
return Response.status(Response.Status.CREATED).build();
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/tally/monthly/{employeeId}")
|
||||
public List<MonthlyTallyDto> getMonthlyTally(
|
||||
@PathParam("employeeId") Long employeeId,
|
||||
@QueryParam("month") String month) {
|
||||
|
||||
String monthKey = month != null ? month : LocalDateTime.now().format(MONTH_FORMAT);
|
||||
|
||||
return TallyEntry.find(
|
||||
"SELECT t.product.name, t.product.priceCents, COUNT(t), COUNT(t) * t.product.priceCents " +
|
||||
"FROM TallyEntry t WHERE t.employee.id = ?1 AND t.monthKey = ?2 " +
|
||||
"GROUP BY t.product.id, t.product.name, t.product.priceCents",
|
||||
employeeId, monthKey)
|
||||
.project(MonthlyTallyDto.class)
|
||||
.list();
|
||||
}
|
||||
}
|
||||
14
backend/src/main/resources/application.properties
Normal file
14
backend/src/main/resources/application.properties
Normal file
@@ -0,0 +1,14 @@
|
||||
# Datasource
|
||||
quarkus.datasource.db-kind=mariadb
|
||||
quarkus.datasource.username=strichliste
|
||||
quarkus.datasource.password=strichliste
|
||||
quarkus.datasource.jdbc.url=jdbc:mariadb://localhost:3306/strichliste
|
||||
|
||||
# Hibernate
|
||||
quarkus.hibernate-orm.database.generation=none
|
||||
|
||||
# Flyway
|
||||
quarkus.flyway.migrate-at-start=true
|
||||
|
||||
# CORS ist deaktiviert, da alle Anfragen über den SvelteKit-Proxy laufen
|
||||
quarkus.http.cors=false
|
||||
@@ -0,0 +1,49 @@
|
||||
CREATE TABLE company (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE employee (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
company_id BIGINT NOT NULL,
|
||||
first_name VARCHAR(255) NOT NULL,
|
||||
last_name VARCHAR(255) NOT NULL,
|
||||
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_employee_company FOREIGN KEY (company_id) REFERENCES company(id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE product (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
price_cents INT NOT NULL DEFAULT 0,
|
||||
icon_placeholder VARCHAR(50) DEFAULT 'coffee',
|
||||
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE tally_entry (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
employee_id BIGINT NOT NULL,
|
||||
product_id BIGINT NOT NULL,
|
||||
month_key VARCHAR(7) NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_tally_employee FOREIGN KEY (employee_id) REFERENCES employee(id),
|
||||
CONSTRAINT fk_tally_product FOREIGN KEY (product_id) REFERENCES product(id),
|
||||
INDEX idx_tally_month (month_key),
|
||||
INDEX idx_tally_employee_month (employee_id, month_key)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE access_link (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
token VARCHAR(64) NOT NULL UNIQUE,
|
||||
role VARCHAR(20) NOT NULL,
|
||||
company_id BIGINT,
|
||||
description VARCHAR(255),
|
||||
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_access_link_company FOREIGN KEY (company_id) REFERENCES company(id),
|
||||
CONSTRAINT chk_role CHECK (role IN ('COMPANY_ADMIN', 'PROVIDER_ADMIN'))
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
25
backend/src/main/resources/db/migration/V2__seed_data.sql
Normal file
25
backend/src/main/resources/db/migration/V2__seed_data.sql
Normal file
@@ -0,0 +1,25 @@
|
||||
-- Beispiel-Firmen
|
||||
INSERT INTO company (name) VALUES ('Musterfirma GmbH');
|
||||
INSERT INTO company (name) VALUES ('Beispiel AG');
|
||||
|
||||
-- Beispiel-Mitarbeiter
|
||||
INSERT INTO employee (company_id, first_name, last_name) VALUES (1, 'Max', 'Mustermann');
|
||||
INSERT INTO employee (company_id, first_name, last_name) VALUES (1, 'Erika', 'Musterfrau');
|
||||
INSERT INTO employee (company_id, first_name, last_name) VALUES (2, 'Hans', 'Beispiel');
|
||||
|
||||
-- Beispiel-Produkte
|
||||
INSERT INTO product (name, price_cents, icon_placeholder) VALUES ('Kaffee', 50, 'coffee');
|
||||
INSERT INTO product (name, price_cents, icon_placeholder) VALUES ('Cappuccino', 80, 'coffee');
|
||||
INSERT INTO product (name, price_cents, icon_placeholder) VALUES ('Espresso', 40, 'coffee');
|
||||
INSERT INTO product (name, price_cents, icon_placeholder) VALUES ('Kakao', 60, 'chocolate');
|
||||
INSERT INTO product (name, price_cents, icon_placeholder) VALUES ('Tee', 30, 'tea');
|
||||
|
||||
-- Zugangslinks
|
||||
INSERT INTO access_link (token, role, company_id, description)
|
||||
VALUES ('company1-admin-token', 'COMPANY_ADMIN', 1, 'Admin-Zugang Musterfirma GmbH');
|
||||
|
||||
INSERT INTO access_link (token, role, company_id, description)
|
||||
VALUES ('company2-admin-token', 'COMPANY_ADMIN', 2, 'Admin-Zugang Beispiel AG');
|
||||
|
||||
INSERT INTO access_link (token, role, company_id, description)
|
||||
VALUES ('provider-admin-token', 'PROVIDER_ADMIN', NULL, 'Anbieter-Admin Zugang');
|
||||
Reference in New Issue
Block a user