Add report templates and unify template storage

This commit is contained in:
2026-03-13 19:20:54 +01:00
parent 5fd349dee2
commit e01afb9a10
13 changed files with 833 additions and 57 deletions

View File

@@ -0,0 +1,16 @@
package de.svencarstensen.muh.domain;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.mapping.Document;
import java.time.LocalDateTime;
import java.util.List;
@Document("reportTemplates")
public record ReportTemplate(
@Id String userId,
List<InvoiceTemplateElement> elements,
LocalDateTime createdAt,
LocalDateTime updatedAt
) {
}

View File

@@ -0,0 +1,20 @@
package de.svencarstensen.muh.domain;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.index.CompoundIndex;
import org.springframework.data.mongodb.core.mapping.Document;
import java.time.LocalDateTime;
import java.util.List;
@Document("templates")
@CompoundIndex(name = "user_template_type_unique", def = "{'userId': 1, 'type': 1}", unique = true)
public record Template(
@Id String id,
String userId,
TemplateType type,
List<InvoiceTemplateElement> elements,
LocalDateTime createdAt,
LocalDateTime updatedAt
) {
}

View File

@@ -0,0 +1,6 @@
package de.svencarstensen.muh.domain;
public enum TemplateType {
INVOICE,
REPORT
}

View File

@@ -0,0 +1,7 @@
package de.svencarstensen.muh.repository;
import de.svencarstensen.muh.domain.ReportTemplate;
import org.springframework.data.mongodb.repository.MongoRepository;
public interface ReportTemplateRepository extends MongoRepository<ReportTemplate, String> {
}

View File

@@ -0,0 +1,12 @@
package de.svencarstensen.muh.repository;
import de.svencarstensen.muh.domain.Template;
import de.svencarstensen.muh.domain.TemplateType;
import org.springframework.data.mongodb.repository.MongoRepository;
import java.util.Optional;
public interface TemplateRepository extends MongoRepository<Template, String> {
Optional<Template> findByUserIdAndType(String userId, TemplateType type);
}

View File

@@ -3,8 +3,11 @@ package de.svencarstensen.muh.service;
import de.svencarstensen.muh.domain.AppUser;
import de.svencarstensen.muh.domain.InvoiceTemplate;
import de.svencarstensen.muh.domain.InvoiceTemplateElement;
import de.svencarstensen.muh.domain.Template;
import de.svencarstensen.muh.domain.TemplateType;
import de.svencarstensen.muh.repository.AppUserRepository;
import de.svencarstensen.muh.repository.InvoiceTemplateRepository;
import de.svencarstensen.muh.repository.TemplateRepository;
import org.springframework.http.HttpStatus;
import org.springframework.lang.NonNull;
import org.springframework.stereotype.Service;
@@ -12,40 +15,52 @@ import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
@Service
public class InvoiceTemplateService {
private static final TemplateType TEMPLATE_TYPE = TemplateType.INVOICE;
private final AppUserRepository appUserRepository;
private final TemplateRepository templateRepository;
private final InvoiceTemplateRepository invoiceTemplateRepository;
public InvoiceTemplateService(
AppUserRepository appUserRepository,
TemplateRepository templateRepository,
InvoiceTemplateRepository invoiceTemplateRepository
) {
this.appUserRepository = appUserRepository;
this.templateRepository = templateRepository;
this.invoiceTemplateRepository = invoiceTemplateRepository;
}
public InvoiceTemplateResponse currentTemplate(String actorId) {
String userId = requireActorId(actorId);
requireActiveUser(userId);
return invoiceTemplateRepository.findById(userId)
return templateRepository.findByUserIdAndType(userId, TEMPLATE_TYPE)
.map(this::toResponse)
.or(() -> invoiceTemplateRepository.findById(userId).map(this::toLegacyResponse))
.orElseGet(() -> new InvoiceTemplateResponse(false, List.of(), null));
}
public InvoiceTemplateResponse saveTemplate(String actorId, List<InvoiceTemplateElementPayload> payloadElements) {
String userId = requireActorId(actorId);
requireActiveUser(userId);
InvoiceTemplate existing = invoiceTemplateRepository.findById(userId).orElse(null);
Template existing = templateRepository.findByUserIdAndType(userId, TEMPLATE_TYPE).orElse(null);
InvoiceTemplate legacyTemplate = existing == null
? invoiceTemplateRepository.findById(userId).orElse(null)
: null;
LocalDateTime now = LocalDateTime.now();
InvoiceTemplate saved = invoiceTemplateRepository.save(new InvoiceTemplate(
Template saved = templateRepository.save(new Template(
existing != null ? existing.id() : templateId(userId),
userId,
TEMPLATE_TYPE,
sanitizeElements(payloadElements),
existing != null ? existing.createdAt() : now,
existing != null ? existing.createdAt() : legacyTemplate != null ? legacyTemplate.createdAt() : now,
now
));
return toResponse(saved);
@@ -108,11 +123,12 @@ public class InvoiceTemplateService {
);
}
private InvoiceTemplateResponse toResponse(InvoiceTemplate template) {
List<InvoiceTemplateElementPayload> elements = template.elements() == null
? List.of()
: template.elements().stream().map(this::toPayload).toList();
return new InvoiceTemplateResponse(true, elements, template.updatedAt());
private InvoiceTemplateResponse toResponse(Template template) {
return toResponse(template.elements(), template.updatedAt());
}
private InvoiceTemplateResponse toLegacyResponse(InvoiceTemplate template) {
return toResponse(template.elements(), template.updatedAt());
}
private InvoiceTemplateElementPayload toPayload(InvoiceTemplateElement element) {
@@ -152,6 +168,17 @@ public class InvoiceTemplateService {
return value == null ? "" : value;
}
private InvoiceTemplateResponse toResponse(List<InvoiceTemplateElement> elements, LocalDateTime updatedAt) {
List<InvoiceTemplateElementPayload> payloads = elements == null
? List.of()
: elements.stream().map(this::toPayload).toList();
return new InvoiceTemplateResponse(true, payloads, updatedAt);
}
private String templateId(String userId) {
return userId + ":" + TEMPLATE_TYPE.name().toLowerCase(Locale.ROOT);
}
public record InvoiceTemplateElementPayload(
String id,
String paletteId,

View File

@@ -0,0 +1,189 @@
package de.svencarstensen.muh.service;
import de.svencarstensen.muh.domain.AppUser;
import de.svencarstensen.muh.domain.InvoiceTemplateElement;
import de.svencarstensen.muh.domain.ReportTemplate;
import de.svencarstensen.muh.domain.Template;
import de.svencarstensen.muh.domain.TemplateType;
import de.svencarstensen.muh.repository.AppUserRepository;
import de.svencarstensen.muh.repository.ReportTemplateRepository;
import de.svencarstensen.muh.repository.TemplateRepository;
import org.springframework.http.HttpStatus;
import org.springframework.lang.NonNull;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
@Service
public class ReportTemplateService {
private static final TemplateType TEMPLATE_TYPE = TemplateType.REPORT;
private final AppUserRepository appUserRepository;
private final TemplateRepository templateRepository;
private final ReportTemplateRepository reportTemplateRepository;
public ReportTemplateService(
AppUserRepository appUserRepository,
TemplateRepository templateRepository,
ReportTemplateRepository reportTemplateRepository
) {
this.appUserRepository = appUserRepository;
this.templateRepository = templateRepository;
this.reportTemplateRepository = reportTemplateRepository;
}
public InvoiceTemplateService.InvoiceTemplateResponse currentTemplate(String actorId) {
String userId = requireActorId(actorId);
requireActiveUser(userId);
return templateRepository.findByUserIdAndType(userId, TEMPLATE_TYPE)
.map(this::toResponse)
.or(() -> reportTemplateRepository.findById(userId).map(this::toLegacyResponse))
.orElseGet(() -> new InvoiceTemplateService.InvoiceTemplateResponse(false, List.of(), null));
}
public InvoiceTemplateService.InvoiceTemplateResponse saveTemplate(
String actorId,
List<InvoiceTemplateService.InvoiceTemplateElementPayload> payloadElements
) {
String userId = requireActorId(actorId);
requireActiveUser(userId);
Template existing = templateRepository.findByUserIdAndType(userId, TEMPLATE_TYPE).orElse(null);
ReportTemplate legacyTemplate = existing == null
? reportTemplateRepository.findById(userId).orElse(null)
: null;
LocalDateTime now = LocalDateTime.now();
Template saved = templateRepository.save(new Template(
existing != null ? existing.id() : templateId(userId),
userId,
TEMPLATE_TYPE,
sanitizeElements(payloadElements),
existing != null ? existing.createdAt() : legacyTemplate != null ? legacyTemplate.createdAt() : now,
now
));
return toResponse(saved);
}
private AppUser requireActiveUser(@NonNull String actorId) {
return appUserRepository.findById(actorId)
.filter(AppUser::active)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.FORBIDDEN, "Nicht berechtigt"));
}
private @NonNull String requireActorId(String actorId) {
if (isBlank(actorId)) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Nicht berechtigt");
}
String sanitized = Objects.requireNonNull(actorId).trim();
return Objects.requireNonNull(sanitized);
}
private List<InvoiceTemplateElement> sanitizeElements(
List<InvoiceTemplateService.InvoiceTemplateElementPayload> payloadElements
) {
if (payloadElements == null || payloadElements.isEmpty()) {
return List.of();
}
return payloadElements.stream()
.map(this::sanitizeElement)
.filter(Objects::nonNull)
.toList();
}
private InvoiceTemplateElement sanitizeElement(InvoiceTemplateService.InvoiceTemplateElementPayload payload) {
if (payload == null
|| isBlank(payload.id())
|| isBlank(payload.paletteId())
|| isBlank(payload.kind())
|| payload.x() == null
|| payload.y() == null
|| payload.width() == null
|| payload.fontSize() == null) {
return null;
}
return new InvoiceTemplateElement(
payload.id().trim(),
payload.paletteId().trim(),
payload.kind().trim(),
trimOrEmpty(payload.label()),
nullToEmpty(payload.content()),
payload.x(),
payload.y(),
payload.width(),
payload.height(),
payload.fontSize(),
payload.fontWeight(),
blankToNull(payload.textAlign()),
blankToNull(payload.lineOrientation()),
blankToNull(payload.imageSrc()),
payload.imageNaturalWidth(),
payload.imageNaturalHeight()
);
}
private InvoiceTemplateService.InvoiceTemplateResponse toResponse(Template template) {
return toResponse(template.elements(), template.updatedAt());
}
private InvoiceTemplateService.InvoiceTemplateResponse toLegacyResponse(ReportTemplate template) {
return toResponse(template.elements(), template.updatedAt());
}
private InvoiceTemplateService.InvoiceTemplateElementPayload toPayload(InvoiceTemplateElement element) {
return new InvoiceTemplateService.InvoiceTemplateElementPayload(
element.id(),
element.paletteId(),
element.kind(),
element.label(),
element.content(),
element.x(),
element.y(),
element.width(),
element.height(),
element.fontSize(),
element.fontWeight(),
element.textAlign(),
element.lineOrientation(),
element.imageSrc(),
element.imageNaturalWidth(),
element.imageNaturalHeight()
);
}
private boolean isBlank(String value) {
return value == null || value.isBlank();
}
private String blankToNull(String value) {
return isBlank(value) ? null : value.trim();
}
private String trimOrEmpty(String value) {
return value == null ? "" : value.trim();
}
private String nullToEmpty(String value) {
return value == null ? "" : value;
}
private InvoiceTemplateService.InvoiceTemplateResponse toResponse(
List<InvoiceTemplateElement> elements,
LocalDateTime updatedAt
) {
List<InvoiceTemplateService.InvoiceTemplateElementPayload> payloads = elements == null
? List.of()
: elements.stream().map(this::toPayload).toList();
return new InvoiceTemplateService.InvoiceTemplateResponse(true, payloads, updatedAt);
}
private String templateId(String userId) {
return userId + ":" + TEMPLATE_TYPE.name().toLowerCase(Locale.ROOT);
}
}

View File

@@ -2,6 +2,7 @@ package de.svencarstensen.muh.web;
import de.svencarstensen.muh.service.CatalogService;
import de.svencarstensen.muh.service.InvoiceTemplateService;
import de.svencarstensen.muh.service.ReportTemplateService;
import de.svencarstensen.muh.security.SecuritySupport;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
@@ -19,15 +20,18 @@ public class SessionController {
private final CatalogService catalogService;
private final InvoiceTemplateService invoiceTemplateService;
private final ReportTemplateService reportTemplateService;
private final SecuritySupport securitySupport;
public SessionController(
CatalogService catalogService,
InvoiceTemplateService invoiceTemplateService,
ReportTemplateService reportTemplateService,
SecuritySupport securitySupport
) {
this.catalogService = catalogService;
this.invoiceTemplateService = invoiceTemplateService;
this.reportTemplateService = reportTemplateService;
this.securitySupport = securitySupport;
}
@@ -41,6 +45,11 @@ public class SessionController {
return invoiceTemplateService.currentTemplate(securitySupport.currentUser().id());
}
@GetMapping("/report-template")
public InvoiceTemplateService.InvoiceTemplateResponse currentReportTemplate() {
return reportTemplateService.currentTemplate(securitySupport.currentUser().id());
}
@PostMapping("/password-login")
public CatalogService.SessionResponse passwordLogin(@RequestBody PasswordLoginRequest request) {
return catalogService.loginWithPassword(request.email(), request.password());
@@ -62,7 +71,7 @@ public class SessionController {
@PutMapping("/invoice-template")
public InvoiceTemplateService.InvoiceTemplateResponse saveInvoiceTemplate(
@RequestBody InvoiceTemplateRequest request
@RequestBody TemplateRequest request
) {
return invoiceTemplateService.saveTemplate(
securitySupport.currentUser().id(),
@@ -70,6 +79,16 @@ public class SessionController {
);
}
@PutMapping("/report-template")
public InvoiceTemplateService.InvoiceTemplateResponse saveReportTemplate(
@RequestBody TemplateRequest request
) {
return reportTemplateService.saveTemplate(
securitySupport.currentUser().id(),
request.elements()
);
}
public record PasswordLoginRequest(@NotBlank String email, @NotBlank String password) {
}
@@ -85,7 +104,7 @@ public class SessionController {
) {
}
public record InvoiceTemplateRequest(
public record TemplateRequest(
List<InvoiceTemplateService.InvoiceTemplateElementPayload> elements
) {
}