This commit is contained in:
2025-09-19 18:00:38 +02:00
commit 1f9f2dc21c
26 changed files with 1379 additions and 0 deletions
@@ -0,0 +1,11 @@
package de.champonthis.abi;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication()
public class AbiApplication {
public static void main(String[] args) {
SpringApplication.run(AbiApplication.class, args);
}
}
@@ -0,0 +1,50 @@
package de.champonthis.abi.buisinesslogic;
import java.util.HashMap;
import java.util.Map;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Component;
import de.champonthis.abi.entity.Contact;
import de.champonthis.abi.entity.ContactData;
import de.champonthis.abi.repository.ContactDataRepository;
@Component
public class ContactDataManager {
private final ContactDataRepository contactDataRepository;
private final EmailService mailService;
public ContactDataManager(ContactDataRepository contactDataRepository, EmailService mailService) {
this.contactDataRepository = contactDataRepository;
this.mailService = mailService;
}
public ContactData findByContactAndReportedBy(Long contact, Long reportedBy) {
return contactDataRepository.findByContactAndReportedBy(contact, reportedBy).orElse(null);
}
public ContactData findByinviteToken(String inviteToken) {
return contactDataRepository.findByinviteToken(inviteToken).orElse(null);
}
public void save(ContactData contactData) {
contactDataRepository.save(contactData);
}
public void sendInviteMail(Contact contact, ContactData contactData, Contact reportedBy) {
if (StringUtils.isNoneEmpty(contactData.getEmail()) && StringUtils.isNoneEmpty(contactData.getInviteToken())) {
Map<String, Object> variables = new HashMap<>();
variables.put("contact", contact);
variables.put("reportedBy", reportedBy);
variables.put("inviteToken", contactData.getInviteToken());
String to = (StringUtils.isNoneEmpty(contact.getUpdatedName()) ? contact.getUpdatedName()
: contact.getName()) + " <" + contactData.getEmail() + ">";
mailService.sendTemplateEmail(to, "Du wurdest zum Abi-Treffen eingeladen",
"invite", variables);
}
}
}
@@ -0,0 +1,124 @@
package de.champonthis.abi.buisinesslogic;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileInputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.text.similarity.LevenshteinDistance;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import de.champonthis.abi.entity.Contact;
import de.champonthis.abi.entity.ContactData;
import de.champonthis.abi.repository.ContactRepository;
import jakarta.annotation.PostConstruct;
@Component
public class ContactManager {
private Logger logger = LoggerFactory.getLogger(ContactManager.class);
private final ContactRepository contactRepository;
private final EmailService mailService;
private LevenshteinDistance levenshteinDistance = LevenshteinDistance.getDefaultInstance();
public ContactManager(ContactRepository contactRepository, EmailService mailService) {
this.contactRepository = contactRepository;
this.mailService = mailService;
}
@PostConstruct
public void loadCsv() {
try {
File resource = new File("namen.csv");
if (resource.exists() && resource.isFile()) {
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(new FileInputStream(resource), StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
line = line.trim();
if (StringUtils.isNoneEmpty(line) && !line.startsWith("#")
&& contactRepository.findByName(line).isEmpty()) {
Contact contact = new Contact();
contact.setName(line);
save(contact);
logger.info("Created contact: #" + contact.getId() + ": " + contact.getName());
}
}
}
} else {
logger.warn("No names.csv found!");
}
} catch (Exception e) {
logger.warn("Could not read names.csv!");
}
}
public Contact findById(Long id) {
return contactRepository.findById(id).orElse(null);
}
public Contact findByName(String name) {
return contactRepository.findByName(name).orElse(null);
}
public Contact findByToken(String token) {
return contactRepository.findByToken(token).orElse(null);
}
public Contact findByLevenshteinDistance(String name) {
return findByLevenshteinDistance(name, 3);
}
public Contact findByLevenshteinDistance(String name, int threshold) {
Contact result = null;
for (Contact contact : contactRepository.findAll()) {
int distance = levenshteinDistance.apply(name.toLowerCase(), contact.getName().toLowerCase());
if (distance <= threshold) {
result = contact;
break;
}
}
return result;
}
public void save(Contact contact) {
contactRepository.save(contact);
}
public void sendTokenMail(Contact contact, ContactData contactData) {
if (StringUtils.isNoneEmpty(contact.getToken()) && StringUtils.isNoneEmpty(contactData.getEmail())) {
Map<String, Object> variables = new HashMap<>();
variables.put("contact", contact);
variables.put("token", contact.getToken());
String to = (StringUtils.isNoneEmpty(contact.getUpdatedName()) ? contact.getUpdatedName()
: contact.getName()) + " <" + contactData.getEmail() + ">";
mailService.sendTemplateEmail(to, "Dein persönlicher Link für das Abi-Treffen",
"confirmation", variables);
}
}
private static final SecureRandom secureRandom = new SecureRandom();
private static final Base64.Encoder base64Encoder = Base64.getUrlEncoder().withoutPadding();
public static String generateToken() {
return generateToken(32);
}
public static String generateToken(int byteLength) {
byte[] randomBytes = new byte[byteLength];
secureRandom.nextBytes(randomBytes);
return base64Encoder.encodeToString(randomBytes);
}
}
@@ -0,0 +1,54 @@
package de.champonthis.abi.buisinesslogic;
import java.util.Map;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.stereotype.Service;
import org.thymeleaf.TemplateEngine;
import org.thymeleaf.context.Context;
import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage;
@Service
public class EmailService {
private final JavaMailSender mailSender;
private final TemplateEngine templateEngine;
@Value("${app.url:}")
private String url;
@Value("${app.mail.from:}")
private String from;
@Value("${app.mail.fromMail:}")
private String fromMail;
public EmailService(JavaMailSender mailSender, TemplateEngine templateEngine) {
this.mailSender = mailSender;
this.templateEngine = templateEngine;
}
public void sendTemplateEmail(String to, String subject, String templateName, Map<String, Object> variables) {
Context context = new Context();
variables.put("url", url);
context.setVariables(variables);
String htmlContent = templateEngine.process("email/" + templateName, context);
MimeMessage message = mailSender.createMimeMessage();
try {
MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");
helper.setFrom(from + " <" + fromMail + ">");
helper.setTo(to);
helper.setSubject(subject);
helper.setText(htmlContent, true);
mailSender.send(message);
} catch (MessagingException e) {
}
}
}
@@ -0,0 +1,88 @@
package de.champonthis.abi.controller;
import org.apache.commons.lang3.StringUtils;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import de.champonthis.abi.buisinesslogic.ContactDataManager;
import de.champonthis.abi.buisinesslogic.ContactManager;
import de.champonthis.abi.controller.request.ContactRequest;
import de.champonthis.abi.entity.Contact;
import de.champonthis.abi.entity.ContactData;
import jakarta.validation.Valid;
@RestController
@RequestMapping("/api/v1/contact")
public class ContactController {
private final ContactManager contactManager;
private final ContactDataManager contactDataManager;
public ContactController(ContactManager contactManager, ContactDataManager contactDataManager) {
this.contactManager = contactManager;
this.contactDataManager = contactDataManager;
}
@GetMapping()
public Contact find(@RequestHeader(name = HttpHeaders.AUTHORIZATION, required = false) String authToken,
@RequestParam String name) {
Contact contact = contactManager.findByLevenshteinDistance(name);
if (contact == null) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND);
}
if (StringUtils.isNoneEmpty(contact.getToken()) && !contact.getToken().equals(authToken)) {
throw new ResponseStatusException(HttpStatus.ALREADY_REPORTED);
}
return contact;
}
@PostMapping()
public void update(@RequestHeader(name = HttpHeaders.AUTHORIZATION, required = false) String authToken,
@Valid @RequestBody ContactRequest request) {
Contact contact = contactManager.findByName(request.getName());
if (contact == null) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND);
}
if (StringUtils.isNoneEmpty(contact.getToken()) && !contact.getToken().equals(authToken)) {
throw new ResponseStatusException(HttpStatus.ALREADY_REPORTED);
}
contact.setUpdatedName(request.getUpdatedName());
boolean sendToken = false;
if (request.getCommitted() != null) {
contact.setCommitted(request.getCommitted());
if (StringUtils.isEmpty(contact.getToken())) {
String token = ContactManager.generateToken();
while (contactManager.findByToken(token) != null) {
token = ContactManager.generateToken();
}
contact.setToken(token);
sendToken = true;
}
}
contactManager.save(contact);
if (sendToken) {
ContactData contactData = contactDataManager.findByContactAndReportedBy(contact.getId(), contact.getId());
if (contactData != null) {
contactManager.sendTokenMail(contact, contactData);
}
}
}
}
@@ -0,0 +1,79 @@
package de.champonthis.abi.controller;
import org.apache.commons.lang3.StringUtils;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
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 org.springframework.web.server.ResponseStatusException;
import de.champonthis.abi.buisinesslogic.ContactDataManager;
import de.champonthis.abi.buisinesslogic.ContactManager;
import de.champonthis.abi.controller.request.ContactDataRequest;
import de.champonthis.abi.entity.Contact;
import de.champonthis.abi.entity.ContactData;
import jakarta.validation.Valid;
@RestController
@RequestMapping("/api/v1/contact/data")
public class ContactDataController {
private final ContactManager contactManager;
private final ContactDataManager contactDataManager;
public ContactDataController(ContactManager contactManager, ContactDataManager contactDataManager) {
this.contactManager = contactManager;
this.contactDataManager = contactDataManager;
}
@PostMapping
public ResponseEntity<ContactData> create(@Valid @RequestBody ContactDataRequest request) {
Contact contact = contactManager.findByName(request.getName());
Contact reportedBy = contactManager.findByName(request.getReportedBy());
if (contact == null || reportedBy == null) {
throw new ResponseStatusException(HttpStatus.UNPROCESSABLE_ENTITY);
}
ContactData contactData = contactDataManager.findByContactAndReportedBy(contact.getId(), reportedBy.getId());
HttpStatus status = HttpStatus.OK;
boolean sendToken = false;
if (contactData == null) {
contactData = new ContactData();
contactData.setContact(contact.getId());
contactData.setReportedBy(reportedBy.getId());
status = HttpStatus.CREATED;
}
if (contact.getId().equals(reportedBy.getId())
&& StringUtils.isNotEmpty(contact.getToken())
&& (status.isSameCodeAs(HttpStatus.CREATED) || !request.getEmail().equals(contactData.getEmail()))) {
sendToken = true;
}
contactData.setEmail(request.getEmail());
contactData.setPhone(request.getPhone());
if (!contact.getId().equals(reportedBy.getId())
&& (contactData.getInviteToken() == null || StringUtils.isEmpty(contactData.getInviteToken()))) {
String inviteToken = ContactManager.generateToken();
while (contactDataManager.findByinviteToken(inviteToken) != null) {
inviteToken = ContactManager.generateToken();
}
contactData.setInviteToken(inviteToken);
contactDataManager.sendInviteMail(contact, contactData, reportedBy);
}
contactDataManager.save(contactData);
if (sendToken) {
contactManager.sendTokenMail(contact, contactData);
}
return ResponseEntity.status(status.value()).body(contactData);
}
}
@@ -0,0 +1,66 @@
package de.champonthis.abi.controller;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import de.champonthis.abi.buisinesslogic.ContactDataManager;
import de.champonthis.abi.buisinesslogic.ContactManager;
import de.champonthis.abi.entity.Contact;
import de.champonthis.abi.entity.ContactData;
@Controller
public class FrontendController {
private final ContactManager contactManager;
private final ContactDataManager contactDataManager;
public FrontendController(ContactManager contactManager, ContactDataManager contactDataManager) {
this.contactManager = contactManager;
this.contactDataManager = contactDataManager;
}
@GetMapping("/")
public String index(
@RequestParam(required = false) String token,
@RequestParam(required = false) String invite,
Model model) {
if (token != null && !token.isEmpty()) {
Contact contact = contactManager.findByToken(token);
if (contact != null) {
model.addAttribute("token", token);
model.addAttribute("userName", contact.getName());
model.addAttribute("altName", contact.getUpdatedName());
model.addAttribute("altName", contact.getUpdatedName());
ContactData contactData = contactDataManager.findByContactAndReportedBy(contact.getId(),
contact.getId());
if (contactData != null) {
model.addAttribute("email", contactData.getEmail());
model.addAttribute("phone", contactData.getPhone());
}
}
} else if (invite != null && !invite.isEmpty()) {
ContactData contactData = contactDataManager.findByinviteToken(invite);
if (contactData != null) {
Contact contact = contactManager.findById(contactData.getContact());
if (contact != null && StringUtils.isEmpty(contact.getToken())) {
model.addAttribute("userName", contact.getName());
model.addAttribute("altName", contact.getUpdatedName());
model.addAttribute("email", contactData.getEmail());
model.addAttribute("phone", contactData.getPhone());
}
}
}
return "index";
}
@GetMapping("/imprint")
public String imprint() {
return "imprint";
}
}
@@ -0,0 +1,22 @@
package de.champonthis.abi.controller.request;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import lombok.Data;
@Data
public class ContactDataRequest {
@NotBlank(message = "name must not be empty")
private String name;
@NotBlank(message = "email must not be empty")
@Email(message = "Invalid email format")
private String email;
// Accept empty or valid phone number
@Pattern(regexp = "^$|^(\\+49|0)[1-9][0-9]{7,14}$", message = "Invalid phone number")
private String phone;
@NotBlank(message = "reportedBy must not be empty")
private String reportedBy;
}
@@ -0,0 +1,14 @@
package de.champonthis.abi.controller.request;
import de.champonthis.abi.entity.Commitment;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
@Data
public class ContactRequest {
@NotBlank(message = "name must not be empty")
private String name;
private String updatedName;
private Commitment committed;
}
@@ -0,0 +1,5 @@
package de.champonthis.abi.entity;
public enum Commitment {
UNKNOWN, YES, NO
}
@@ -0,0 +1,29 @@
package de.champonthis.abi.entity;
import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import lombok.Data;
@Data
@Entity
public class Contact {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id", nullable = false)
@JsonIgnore
private Long id;
private String name;
private String updatedName;
@Enumerated(EnumType.STRING)
private Commitment committed = Commitment.UNKNOWN;
@JsonIgnore
private String token;
}
@@ -0,0 +1,28 @@
package de.champonthis.abi.entity;
import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import lombok.Data;
@Data
@Entity
public class ContactData {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id", nullable = false)
private Long id;
@Column(nullable = false)
private Long contact;
private String email;
private String phone;
@Column(nullable = false)
private Long reportedBy;
@JsonIgnore
private String inviteToken;
}
@@ -0,0 +1,16 @@
package de.champonthis.abi.repository;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import de.champonthis.abi.entity.ContactData;
@Repository
public interface ContactDataRepository extends JpaRepository<ContactData, Long> {
Optional<ContactData> findByContactAndReportedBy(Long contact, Long reportedBy);
Optional<ContactData> findByinviteToken(String inviteToken);
}
@@ -0,0 +1,16 @@
package de.champonthis.abi.repository;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import de.champonthis.abi.entity.Contact;
@Repository
public interface ContactRepository extends JpaRepository<Contact, Long> {
Optional<Contact> findByName(String name);
Optional<Contact> findByToken(String token);
}