Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .claude/settings.local.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"permissions": {
"allow": [
"Bash(find /T/coding/projects/PERSONAL/Exence -type f \\\\\\(-name *.sql -o -name V*.sql \\\\\\))",
"Bash(find /T/coding/projects/PERSONAL/Exence/backend/exence/src/main/java -type f -name *Setting* -o -name *Preference* -o -name *Configuration*)",
"Bash(./gradlew spotlessApply)",
"Bash(./gradlew build:*)",
"Bash(./gradlew test:*)"
]
}
}
89 changes: 89 additions & 0 deletions backend/exence/CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Commands

```bash
# Build
./gradlew build

# Run application
./gradlew bootRun

# Run all tests
./gradlew test

# Run a single test class
./gradlew test --tests "com.exence.finance.validators.PasswordValidatorTest"

# Format code (Palantir Java Format via Spotless)
./gradlew spotlessApply

# Check formatting without applying
./gradlew spotlessCheck

# Run Checkstyle
./gradlew checkstyleMain
```

The Docker Compose file for the PostgreSQL database is at `technical/exence-docker/docker-compose.yml`.

## Architecture

This is a Spring Boot 3.4.1 / Java 23 REST API for a personal finance application. The codebase uses virtual threads, QueryDSL for dynamic queries, Liquibase for migrations, and MapStruct for DTO mapping.

### Module structure

All code lives under `com.exence.finance`, split into:

- **`modules/`** — Feature modules, each self-contained:
- `auth` — Authentication, sessions, users, JWT tokens, password management
- `category` — Transaction categories
- `transaction` — Financial transactions with dynamic filtering via QueryDSL
- `statistics` — Dashboard widgets and data providers (35+ widget types)
- `email` — Email sending, templating, and logging
- **`common/`** — Shared: annotations, validators, exceptions, DTOs, aspects, converters, utils
- **`config/`** — Spring configuration classes and `@ConfigurationProperties`
- **`security/`** — JWT filter, auth entry point, email verification interceptor

### Layering pattern

Each module follows: `Controller (interface) → ControllerImpl → Service (interface) → ServiceImpl → Repository → Entity`

Controllers are defined as interfaces; implementations are in an `impl/` subpackage. This pattern applies to both controllers and services.

### Key conventions

- **Entities** extend `BaseAuditableEntity` (createdAt, updatedAt, createdBy, updatedBy via Spring Data auditing).
- **Mappers** use MapStruct with `componentModel = "spring"`.
- **Validation** uses custom constraint annotations (e.g., `@ValidPassword`, `@UniqueEmail`, `@ValidColor`) backed by validators in `common/validators/`. Regex patterns and length limits live in `ValidationConstants`.
- **Exceptions** are custom classes that produce `ProblemDetail` responses, handled centrally in `GlobalExceptionHandler`.
- **Transactions**: services annotate class-level `@Transactional(readOnly = true)` and override write methods with `@Transactional`.
- **Logging**: AOP-based via `ServiceLoggingAspect` using `@ServiceLogDocument`.

### Statistics / Widget system

`statistics` is the most complex module. Widgets are stored in the DB and each has a `WidgetType` enum value. Data is computed by `WidgetDataProvider` implementations (one per widget type, ~35 providers in `service/provider/`). Providers query `DailyCategoryStat`, a PostgreSQL materialized view refreshed via Spring events (`MaterializedViewRefreshEvent`). Dynamic filtering uses `StatisticsPredicateBuilder` (QueryDSL).

### Security

- JWT-based auth with access + refresh tokens stored in HTTP-only cookies.
- Password hashing with Argon2id (custom `Argon2PasswordEncoder`).
- `JwtAuthenticationFilter` validates tokens on each request.
- `EmailVerificationInterceptor` gates endpoints requiring verified email.
- Token types: `ACCESS`, `REFRESH`, `PASSWORD_RESET`, `EMAIL_VERIFICATION`.

### Database

- PostgreSQL 16 via Docker.
- Liquibase changelogs in `src/main/resources/db/changelog/`, versioned (`v1.0.0/`, `v1.1.0/`), YAML format.
- Dev profile loads test data from `data/test-data.yaml`.
- Native PostgreSQL enums are used for `transaction_type` and `category_type`.

### Code quality

- **Formatting**: Palantir Java Format enforced via Spotless — run `spotlessApply` before committing.
- **Checkstyle**: Max line length 120, max cyclomatic complexity 20.
- **Lombok** is used extensively (`@Slf4j`, `@SuperBuilder`, `@Data`, `@RequiredArgsConstructor`).
- **QueryDSL** requires APT code generation (happens during build via `annotationProcessor`).
4 changes: 1 addition & 3 deletions backend/exence/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -109,9 +109,6 @@ dependencies {
annotationProcessor("jakarta.annotation:jakarta.annotation-api")
annotationProcessor("jakarta.persistence:jakarta.persistence-api")

// Development tools
developmentOnly(libs.spring.boot.devtools)

// Testing
testImplementation(libs.spring.boot.starter.test)
testImplementation(libs.spring.security.test)
Expand All @@ -120,6 +117,7 @@ dependencies {

tasks.withType<JavaCompile> {
options.compilerArgs.add("-Amapstruct.defaultComponentModel=spring")
options.generatedSourceOutputDirectory.set(file("build/generated/sources/annotationProcessor/java/main"))
}

tasks.withType<Test> {
Expand Down
11 changes: 11 additions & 0 deletions backend/exence/config/checkstyle/checkstyle.xml
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,17 @@
<property name="checks" value="MagicNumber"/>
<property name="files" value=".*[\\/]src[\\/]test[\\/].*"/>
</module>
<!-- JDBC ResultSet column indices in query service -->
<module name="SuppressionXpathSingleFilter">
<property name="checks" value="MagicNumber"/>
<property name="files" value="StatisticsQueryService\.java"/>
</module>

<!-- QueryDSL Q-type aliases use lowerCamelCase by convention -->
<module name="SuppressionXpathSingleFilter">
<property name="checks" value="ConstantName"/>
<property name="message" value="'(dailyCategoryStat|transaction|category|user)'"/>
</module>

<module name="SuppressionXpathSingleFilter">
<property name="checks" value="MethodName"/>
Expand Down
3 changes: 0 additions & 3 deletions backend/exence/gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ liquibase = "4.31.0"
mapstruct = "1.6.3"
lombok = "1.18.38"
lombok-mapstruct-binding = "0.2.0"
emoji-java = "5.1.1"
postgresql = "42.7.7"
mockito = "5.15.2"
querydsl = "5.1.0"
Expand All @@ -24,7 +23,6 @@ spring-boot-starter-web = { module = "org.springframework.boot:spring-boot-start
spring-boot-starter-mail = { module = "org.springframework.boot:spring-boot-starter-mail" }
spring-boot-starter-validation = { module = "org.springframework.boot:spring-boot-starter-validation" }
spring-boot-starter-test = { module = "org.springframework.boot:spring-boot-starter-test" }
spring-boot-devtools = { module = "org.springframework.boot:spring-boot-devtools" }
spring-security-test = { module = "org.springframework.security:spring-security-test" }
jjwt-api = { module = "io.jsonwebtoken:jjwt-api", version.ref = "jjwt" }
jjwt-impl = { module = "io.jsonwebtoken:jjwt-impl", version.ref = "jjwt" }
Expand All @@ -34,7 +32,6 @@ mapstruct = { module = "org.mapstruct:mapstruct", version.ref = "mapstruct" }
mapstruct-processor = { module = "org.mapstruct:mapstruct-processor", version.ref = "mapstruct" }
lombok = { module = "org.projectlombok:lombok", version.ref = "lombok" }
lombok-mapstruct-binding = { module = "org.projectlombok:lombok-mapstruct-binding", version.ref = "lombok-mapstruct-binding" }
emoji-java = { module = "com.vdurmont:emoji-java", version.ref = "emoji-java" }
postgresql = { module = "org.postgresql:postgresql", version.ref = "postgresql" }
mockito-core = { module = "org.mockito:mockito-core", version.ref = "mockito" }
querydsl-jpa = { module = "com.querydsl:querydsl-jpa", version.ref = "querydsl" }
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.exence.finance.common.annotations;

import com.exence.finance.common.validators.ValidCurrencyValidator;
import jakarta.validation.Constraint;
import jakarta.validation.Payload;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Constraint(validatedBy = ValidCurrencyValidator.class)
@Target({ElementType.FIELD, ElementType.RECORD_COMPONENT})
@Retention(RetentionPolicy.RUNTIME)
public @interface ValidCurrency {
String message() default "Invalid currency code. Must be a valid ISO 4217 code (e.g., HUF, EUR)";

Class<?>[] groups() default {};

Class<? extends Payload>[] payload() default {};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.exence.finance.common.annotations;

import com.exence.finance.common.validators.ValidLanguageValidator;
import jakarta.validation.Constraint;
import jakarta.validation.Payload;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Constraint(validatedBy = ValidLanguageValidator.class)
@Target({ElementType.FIELD, ElementType.RECORD_COMPONENT})
@Retention(RetentionPolicy.RUNTIME)
public @interface ValidLanguage {
String message() default "Invalid language code. Must be a valid ISO 639-1 code (e.g., en, hu)";

Class<?>[] groups() default {};

Class<? extends Payload>[] payload() default {};
}
Original file line number Diff line number Diff line change
Expand Up @@ -311,4 +311,16 @@ public ResponseEntity<ProblemDetail> handleWidgetTypeMismatchException(
problemDetail.setProperty("timestamp", Instant.now());
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(problemDetail);
}

@ExceptionHandler(InvalidWidgetSettingException.class)
public ResponseEntity<ProblemDetail> handleInvalidWidgetSettingException(
InvalidWidgetSettingException ex, WebRequest request) {
log.warn("Invalid widget setting: {}", ex.getMessage());

ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, ex.getMessage());
problemDetail.setType(URI.create(PROBLEM_BASE_URI + "invalid-widget-setting"));
problemDetail.setTitle("Invalid Widget Setting");
problemDetail.setProperty("timestamp", Instant.now());
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(problemDetail);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.exence.finance.common.exception;

public class InvalidWidgetSettingException extends RuntimeException {
public InvalidWidgetSettingException() {
super();
}

public InvalidWidgetSettingException(String message) {
super(message);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ public final class ValidationConstants {
public static final String PASSWORD_DIGIT_PATTERN = ".*\\d.*";
public static final String PASSWORD_SPECIAL_CHAR_PATTERN = ".*[!@#$%^&*()_+\\-=\\[\\]{};':\"\\\\|,.<>/?].*";

// User settings validations
public static final int LANGUAGE_CODE_LENGTH = 2;
public static final int CURRENCY_CODE_LENGTH = 3;

// Category validations
public static final int CATEGORY_NAME_MIN_LENGTH = 1;
public static final int CATEGORY_NAME_MAX_LENGTH = 25;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package com.exence.finance.common.validators;

import com.exence.finance.common.annotations.ValidCurrency;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
import java.util.Currency;
import java.util.Locale;
import java.util.Set;
import java.util.stream.Collectors;
import org.springframework.stereotype.Component;

@Component
public class ValidCurrencyValidator implements ConstraintValidator<ValidCurrency, String> {

private static final Set<String> ISO_CURRENCIES = Currency.getAvailableCurrencies().stream()
.map(Currency::getCurrencyCode)
.collect(Collectors.toUnmodifiableSet());

@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
if (value == null) {
return true;
}
return ISO_CURRENCIES.contains(value.toUpperCase(Locale.ROOT));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.exence.finance.common.validators;

import com.exence.finance.common.annotations.ValidLanguage;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
import java.util.Locale;
import java.util.Set;
import org.springframework.stereotype.Component;

@Component
public class ValidLanguageValidator implements ConstraintValidator<ValidLanguage, String> {

private static final Set<String> ISO_LANGUAGES = Set.of(Locale.getISOLanguages());

@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
if (value == null) {
return true;
}
return ISO_LANGUAGES.contains(value.toLowerCase(Locale.ROOT));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.exence.finance.config;

import com.querydsl.jpa.impl.JPAQueryFactory;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class QueryDslConfig {

@PersistenceContext
private EntityManager entityManager;

@Bean
public JPAQueryFactory jpaQueryFactory() {
return new JPAQueryFactory(entityManager);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.exence.finance.modules.auth.controller;

import com.exence.finance.modules.auth.dto.request.UpdateUserSettingsRequest;
import com.exence.finance.modules.auth.dto.response.UserSettingsResponse;
import org.springframework.http.ResponseEntity;

public interface UserSettingsController {
ResponseEntity<UserSettingsResponse> getUserSettings();

ResponseEntity<UserSettingsResponse> updateUserSettings(UpdateUserSettingsRequest request);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package com.exence.finance.modules.auth.controller.impl;

import com.exence.finance.common.util.ResponseFactory;
import com.exence.finance.modules.auth.controller.UserSettingsController;
import com.exence.finance.modules.auth.dto.request.UpdateUserSettingsRequest;
import com.exence.finance.modules.auth.dto.response.UserSettingsResponse;
import com.exence.finance.modules.auth.service.UserSettingsService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/user/settings")
@CrossOrigin(origins = "http://localhost:4200")
@RequiredArgsConstructor
public class UserSettingsControllerImpl implements UserSettingsController {
private final UserSettingsService userSettingsService;

@Override
@GetMapping
public ResponseEntity<UserSettingsResponse> getUserSettings() {
return ResponseFactory.ok(userSettingsService.getCurrentUserSettings());
}

@Override
@PatchMapping
public ResponseEntity<UserSettingsResponse> updateUserSettings(
@Valid @RequestBody UpdateUserSettingsRequest request) {
return ResponseFactory.ok(userSettingsService.updateCurrentUserSettings(request));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.exence.finance.modules.auth.dto;

import java.util.Arrays;
import lombok.Getter;
import lombok.RequiredArgsConstructor;

@Getter
@RequiredArgsConstructor
public enum Theme {
LIGHT("LIGHT"),
DARK("DARK"),
BLUE_DOLPHIN("BLUE_DOLPHIN");

private final String value;

public static Theme fromValue(String v) {
return Arrays.stream(Theme.values())
.filter(x -> x.value.equals(v))
.findFirst()
.orElseThrow(() -> new IllegalArgumentException(v));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.exence.finance.modules.auth.dto.request;

import com.exence.finance.common.annotations.ValidCurrency;
import com.exence.finance.common.annotations.ValidLanguage;
import com.exence.finance.modules.auth.dto.Theme;

public record UpdateUserSettingsRequest(
@ValidLanguage String language, Theme primaryTheme, Theme secondaryTheme, @ValidCurrency String baseCurrency) {}
Loading
Loading