Implement backend for contact form with gateway integration

This commit is contained in:
2025-05-04 12:56:55 +02:00
parent a4f1a58f15
commit eb17e24511
21 changed files with 573 additions and 26 deletions

View File

@@ -1,11 +0,0 @@
package dev.rheinsw.server;
/**
*
* @author Thatsaphorn Atchariyaphap
* @since 04.05.25
*/public class Main {
public static void main(String[] args) {
System.out.println("Hello, World!");
}
}

View File

@@ -0,0 +1,21 @@
package dev.rheinsw.server;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableAsync;
/**
* @author Thatsaphorn Atchariyaphap
* @since 04.05.25
*/
@EnableAsync
@SpringBootApplication(
scanBasePackages = {"dev.rheinsw"}
)
public class ServerApplication {
public static void main(String[] args) {
SpringApplication.run(ServerApplication.class, args);
}
}

View File

@@ -0,0 +1,12 @@
package dev.rheinsw.server;
import org.springframework.context.annotation.Configuration;
/**
* @author Thatsaphorn Atchariyaphap
* @since 04.05.25
*/
@Configuration
public class ServerConfig {
}

View File

@@ -0,0 +1,34 @@
package dev.rheinsw.server.controller.contact;
import dev.rheinsw.server.domain.contact.model.ContactRequestDto;
import dev.rheinsw.server.usecase.contact.SubmitContactUseCase;
import lombok.AllArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
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;
/**
* REST controller to handle contact form submissions.
*
* @author Thatsaphorn Atchariyaphap
* @since 21.04.25
*/
@RestController
@AllArgsConstructor
@RequestMapping("/contact")
public class ContactController {
private static final Logger log = LoggerFactory.getLogger(ContactController.class);
private final SubmitContactUseCase submitContactUseCase;
@PostMapping
public ResponseEntity<String> submitContact(@RequestBody ContactRequestDto request) {
log.info("Received contact form from: {}", request.name());
return submitContactUseCase.submitContact(request);
}
}

View File

@@ -0,0 +1,55 @@
package dev.rheinsw.server.controller.contact;
import dev.rheinsw.server.domain.contact.config.HCaptchaConfig;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import java.util.Map;
/**
* @author Thatsaphorn Atchariyaphap
* @since 21.04.25
*/
@Service
public class HCaptchaValidator {
private static final Logger log = LoggerFactory.getLogger(HCaptchaValidator.class);
private final HCaptchaConfig config;
private final RestTemplate restTemplate;
public HCaptchaValidator(HCaptchaConfig config, RestTemplate restTemplate) {
this.config = config;
this.restTemplate = restTemplate;
}
public boolean isValid(String token) {
if (token == null || token.isBlank()) {
log.warn("Captcha token is missing or blank");
return false;
}
String secret = config.getSecret();
if (secret == null || secret.isBlank()) {
log.error("Captcha secret is missing");
return false;
}
try {
var response = restTemplate.postForObject(
"https://api.hcaptcha.com/siteverify",
new org.springframework.util.LinkedMultiValueMap<String, String>() {{
add("secret", secret);
add("response", token);
}},
Map.class
);
return response != null && Boolean.TRUE.equals(response.get("success"));
} catch (Exception e) {
log.error("Failed to verify hCaptcha", e);
return false;
}
}
}

View File

@@ -0,0 +1,19 @@
package dev.rheinsw.server.domain.contact.config;
import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
/**
* @author Thatsaphorn Atchariyaphap
* @since 21.04.25
*/
@Setter
@Getter
@Configuration
@ConfigurationProperties(prefix = "hcaptcha")
public class HCaptchaConfig {
private String secret;
}

View File

@@ -0,0 +1,95 @@
package dev.rheinsw.server.domain.contact.model;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.Size;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* @author Thatsaphorn Atchariyaphap
* @since 22.04.25
*/
@Entity
@Data
@NoArgsConstructor
@Table(name = "contact_requests")
public class ContactRequest {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Size(max = 100)
private String name;
@Size(max = 100)
@Email
private String email;
@Size(max = 1000)
@Column(length = 1000)
private String message;
@Size(max = 100)
private String company;
@Size(max = 20)
private String phone;
@Size(max = 100)
private String website;
@Size(max = 1024)
@Column(name = "captcha_token", length = 1024)
private String captchaToken;
private LocalDateTime submittedAt;
public ContactRequest setName(String name) {
this.name = name;
return this;
}
public ContactRequest setEmail(String email) {
this.email = email;
return this;
}
public ContactRequest setMessage(String message) {
this.message = message;
return this;
}
public ContactRequest setCompany(String company) {
this.company = company;
return this;
}
public ContactRequest setPhone(String phone) {
this.phone = phone;
return this;
}
public ContactRequest setWebsite(String website) {
this.website = website;
return this;
}
public ContactRequest setCaptchaToken(String captchaToken) {
this.captchaToken = captchaToken;
return this;
}
public ContactRequest setSubmittedAt(LocalDateTime submittedAt) {
this.submittedAt = submittedAt;
return this;
}
}

View File

@@ -0,0 +1,15 @@
package dev.rheinsw.server.domain.contact.model;
import dev.rheinsw.shared.transport.Dto;
/**
* @param company optional
* @param phone optional
* @param website optional
* @param captcha required for hCaptcha validation
* @author Bummsa / BoomerHD / Thatsaphorn Atchariyaphap
* @since 21.04.25
*/
public record ContactRequestDto(String name, String email, String message, String company, String phone, String website,
String captcha) implements Dto {
}

View File

@@ -0,0 +1,12 @@
package dev.rheinsw.server.repository.contact;
import dev.rheinsw.server.domain.contact.model.ContactRequest;
import org.springframework.data.jpa.repository.JpaRepository;
/**
* @author Thatsaphorn Atchariyaphap
* @since 22.04.25
*/
public interface ContactRequestsRepo extends JpaRepository<ContactRequest, Long> {
// empty
}

View File

@@ -0,0 +1,12 @@
package dev.rheinsw.server.usecase.contact;
import dev.rheinsw.server.domain.contact.model.ContactRequestDto;
import org.springframework.http.ResponseEntity;
/**
* @author Thatsaphorn Atchariyaphap
* @since 04.05.25
*/
public interface SubmitContactUseCase {
ResponseEntity<String> submitContact(ContactRequestDto request);
}

View File

@@ -0,0 +1,114 @@
package dev.rheinsw.server.usecase.contact;
import dev.rheinsw.server.domain.contact.model.ContactRequest;
import dev.rheinsw.server.domain.contact.model.ContactRequestDto;
import dev.rheinsw.server.repository.contact.ContactRequestsRepo;
import dev.rheinsw.shared.mail.MailServiceClient;
import dev.rheinsw.server.controller.contact.HCaptchaValidator;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
/**
* @author Thatsaphorn Atchariyaphap
* @since 04.05.25
*/
@Service
public class SubmitContactUseCaseImpl implements SubmitContactUseCase {
private static final Logger log = LoggerFactory.getLogger(SubmitContactUseCaseImpl.class);
private final HCaptchaValidator captchaValidator;
private final ContactRequestsRepo contactRepository;
private final MailServiceClient mailServiceClient;
public SubmitContactUseCaseImpl(HCaptchaValidator captchaValidator, ContactRequestsRepo contactRepository, MailServiceClient mailServiceClient) {
this.captchaValidator = captchaValidator;
this.contactRepository = contactRepository;
this.mailServiceClient = mailServiceClient;
}
@Override
public ResponseEntity<String> submitContact(ContactRequestDto request) {
log.info("Received contact form from: {}", request.name());
log.debug("Captcha token: {}", request.captcha());
log.info("Message: {}", request.message());
if (request.email() != null) {
log.info("Reply to: {} ({})", request.email(), request.name());
}
if (!isValidCaptcha(request.captcha())) {
log.warn("Captcha verification failed for {}", request.email());
return ResponseEntity.status(HttpStatus.FORBIDDEN).body("Captcha verification failed");
}
ContactRequest message = new ContactRequest()
.setName(request.name())
.setEmail(request.email())
.setMessage(request.message())
.setCompany(request.company())
.setPhone(request.phone())
.setWebsite(request.website())
.setCaptchaToken(request.captcha())
.setSubmittedAt(LocalDateTime.now());
contactRepository.save(message);
notifyContactAndTeam(request);
return ResponseEntity.ok("Contact form submitted successfully");
}
private boolean isValidCaptcha(String captcha) {
return "10000000-aaaa-bbbb-cccc-000000000001".equals(captcha) || captchaValidator.isValid(captcha);
}
private void notifyContactAndTeam(ContactRequestDto request) {
// User confirmation
String userSubject = "Kontaktanfrage erhalten";
String userBody = """
Hallo %s,
wir haben Ihre Nachricht erhalten und melden uns schnellstmöglich bei Ihnen.
Ihre Nachricht:
%s
Mit freundlichen Grüßen
Rhein Software
""".formatted(request.name(), request.message());
mailServiceClient.sendMail(request.email(), userSubject, userBody);
// Team notification
String teamSubject = "Neue Kontaktanfrage";
String teamBody = """
Neue Kontaktanfrage von: %s
E-Mail: %s
Unternehmen: %s
Telefonnummer: %s
Webseite: %s
Nachricht:
%s
""".formatted(
request.name(),
request.email(),
safe(request.company()),
safe(request.phone()),
safe(request.website()),
request.message()
);
mailServiceClient.sendMail("rhein.software@gmail.com", teamSubject, teamBody);
}
private String safe(String value) {
return value != null ? value : "-";
}
}

View File

@@ -0,0 +1,31 @@
server:
port: 0 # random port
spring:
application:
name: server
datasource:
url: jdbc:postgresql://localhost:5432/rheinsw_dev
username: rheinsw
password: rheinsw
jpa:
hibernate:
ddl-auto: none
show-sql: true
properties:
hibernate:
format_sql: true
eureka:
client:
service-url:
defaultZone: http://localhost:8761/eureka/
hcaptcha:
secret: ES_ff59a664dc764f92870bf2c7b4eab7c5
logging:
level:
org.hibernate.SQL: DEBUG
org.hibernate.type.descriptor.sql.BasicBinder: TRACE