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
Original file line number Diff line number Diff line change
Expand Up @@ -287,4 +287,16 @@ public ResponseEntity<ProblemDetail> handleInvalidPasswordException(

return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(problemDetail);
}

@ExceptionHandler(WidgetNotFoundException.class)
public ResponseEntity<ProblemDetail> handleWidgetNotFoundException(WidgetNotFoundException ex, WebRequest request) {
log.warn("Widget not found for request: {}", request.getDescription(false));

ProblemDetail problemDetail =
ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, "Widget could not be found.");
problemDetail.setType(URI.create(PROBLEM_BASE_URI + "widget-not-found"));
problemDetail.setTitle("Widget Not Found");
problemDetail.setProperty("timestamp", Instant.now());
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(problemDetail);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.exence.finance.common.exception;

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

public WidgetNotFoundException(String context) {
super(context);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package com.exence.finance.common.util;

import java.time.DayOfWeek;
import java.time.Instant;
import java.time.LocalDate;
import java.time.Month;
import java.time.YearMonth;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.time.format.TextStyle;
import java.time.temporal.ChronoUnit;
import java.time.temporal.IsoFields;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import lombok.experimental.UtilityClass;

// TODO: timezone AND Locale settings should be calculated from the user's profile settings
@UtilityClass
public final class DateUtils {

public static final ZoneId DISPLAY_ZONE = ZoneId.of("Europe/Budapest");
public static final int DAYS_PER_WEEK = 7;

public static final int DECEMBER = 12;
public static final int LAST_WEEK_DECEMBER_DAY = 28; // dec 28 is always in the last week of the year

public static String toDisplayDate(Instant instant) {
return instant.atZone(DISPLAY_ZONE).toLocalDate().toString();
}

public static List<YearMonth> getMonthsInRange(Instant startDate, Instant endDate) {
List<YearMonth> months = new ArrayList<>();

YearMonth current = YearMonth.from(startDate.atZone(DISPLAY_ZONE));
YearMonth last = YearMonth.from(endDate.atZone(DISPLAY_ZONE));

while (!current.isAfter(last)) {
months.add(current);
current = current.plusMonths(1);
}

return months;
}

public static String getMonthName(int monthNum) {
return Month.of(monthNum).getDisplayName(TextStyle.SHORT, Locale.ENGLISH);
}

/** @param isoDayOfWeek ISO day-of-week (1 = Monday, 7 = Sunday) */
public static String getDayName(int isoDayOfWeek) {
return DayOfWeek.of(isoDayOfWeek).getDisplayName(TextStyle.SHORT, Locale.ENGLISH);
}

public static int getIsoWeekCount(Instant instant) {
int year = instant.atZone(DISPLAY_ZONE).getYear();
return LocalDate.of(year, DECEMBER, LAST_WEEK_DECEMBER_DAY).get(IsoFields.WEEK_OF_WEEK_BASED_YEAR);
}

public static long countMonths(Instant start, Instant end) {
YearMonth startMonth = YearMonth.from(start.atZone(ZoneOffset.UTC));
YearMonth endMonth = YearMonth.from(end.atZone(ZoneOffset.UTC));
return Math.max(1, startMonth.until(endMonth, ChronoUnit.MONTHS) + 1);
}

public static long countDaysBetween(Instant start, Instant end) {
return Math.max(1, ChronoUnit.DAYS.between(start, end) + 1);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.exence.finance.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;

@Configuration
@EnableAsync
public class AsyncConfig {
// Virtual thread executor is auto-configured by Spring Boot
// via spring.threads.virtual.enabled=true in application.yml
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
@Mapper(componentModel = "spring")
public interface UserMapper {

@Mapping(target = "isVerified", source = "emailVerified")
@Mapping(target = "username", source = "displayUsername")
UserDTO mapToUserDto(User user);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,11 @@
import com.exence.finance.modules.category.mapper.CategoryMapper;
import com.exence.finance.modules.category.repository.CategoryRepository;
import com.exence.finance.modules.category.service.CategoryService;
import com.exence.finance.modules.statistics.event.MaterializedViewRefreshEvent;
import com.exence.finance.modules.transaction.dto.request.CategoryFilter;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

Expand All @@ -23,6 +25,7 @@ public class CategoryServiceImpl implements CategoryService {
private final CategoryRepository categoryRepository;
private final CategoryMapper categoryMapper;
private final UserService userService;
private final ApplicationEventPublisher eventPublisher;

public CategoryDTO getCategoryById(Long id) {
Category category = categoryRepository.find(id).orElseThrow(CategoryNotFoundException::new);
Expand Down Expand Up @@ -58,6 +61,7 @@ public CategoryDTO updateCategory(CategoryDTO categoryDTO) {
categoryMapper.updateCategoryFromDto(categoryDTO, category);
Category updatedCategory = categoryRepository.save(category);

eventPublisher.publishEvent(new MaterializedViewRefreshEvent());
return categoryMapper.mapToCategoryDTO(updatedCategory);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.exence.finance.modules.statistics.annotations;

import com.exence.finance.modules.statistics.validators.AtLeastOneNotEmptyValidator;
import jakarta.validation.Constraint;
import jakarta.validation.Payload;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = AtLeastOneNotEmptyValidator.class)
@Documented
public @interface AtLeastOneNotEmpty {
String message() default "At least one of statCards or charts must be provided";

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

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

import com.exence.finance.modules.statistics.validators.WidgetLayoutValidator;
import jakarta.validation.Constraint;
import jakarta.validation.Payload;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = WidgetLayoutValidator.class)
@Documented
public @interface ValidWidgetLayout {
String message() default
"Invalid widget layout: StatCard requires displayOrder only, graph widgets require position (x, y)"
+ " fields";

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

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

import com.exence.finance.modules.statistics.dto.Timeframe;
import com.exence.finance.modules.statistics.dto.UpdateLayoutRequest;
import com.exence.finance.modules.statistics.dto.WidgetDTO;
import com.exence.finance.modules.statistics.dto.response.WidgetDataResponse;
import com.exence.finance.modules.statistics.dto.response.WidgetLayoutResponse;
import org.springframework.http.ResponseEntity;

public interface WidgetController {

ResponseEntity<WidgetLayoutResponse> getLayout();

ResponseEntity<WidgetDataResponse> getWidgetData(Long widgetId, Timeframe timeframe);

ResponseEntity<WidgetLayoutResponse> createWidget(WidgetDTO widgetDTO);

ResponseEntity<WidgetLayoutResponse> updateLayout(UpdateLayoutRequest request);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package com.exence.finance.modules.statistics.controller.impl;

import com.exence.finance.common.util.ResponseFactory;
import com.exence.finance.modules.statistics.controller.WidgetController;
import com.exence.finance.modules.statistics.dto.Timeframe;
import com.exence.finance.modules.statistics.dto.UpdateLayoutRequest;
import com.exence.finance.modules.statistics.dto.WidgetDTO;
import com.exence.finance.modules.statistics.dto.response.WidgetDataResponse;
import com.exence.finance.modules.statistics.dto.response.WidgetLayoutResponse;
import com.exence.finance.modules.statistics.service.WidgetService;
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.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/statistics/widgets")
@CrossOrigin(origins = "http://localhost:4200")
@RequiredArgsConstructor
public class WidgetControllerImpl implements WidgetController {

private final WidgetService widgetService;

@Override
@GetMapping("/layout")
public ResponseEntity<WidgetLayoutResponse> getLayout() {
return ResponseFactory.ok(widgetService.getLayout());
}

@Override
@GetMapping("/{widgetId}/data")
public ResponseEntity<WidgetDataResponse> getWidgetData(
@PathVariable Long widgetId, @RequestParam(required = false) Timeframe timeframe) {
return ResponseFactory.ok(widgetService.getWidgetData(widgetId, timeframe));
Comment on lines +41 to +42
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The timeframe request param is bound directly to the Timeframe enum, so Spring will only accept values like ONE_WEEK, not the short codes (1W, YTD, etc.). If the API is meant to accept the codes, accept a String here and parse via Timeframe.fromCode(...), or register a Converter<String, Timeframe>.

Suggested change
@PathVariable Long widgetId, @RequestParam(required = false) Timeframe timeframe) {
return ResponseFactory.ok(widgetService.getWidgetData(widgetId, timeframe));
@PathVariable Long widgetId, @RequestParam(required = false) String timeframe) {
Timeframe parsedTimeframe = timeframe != null ? Timeframe.fromCode(timeframe) : null;
return ResponseFactory.ok(widgetService.getWidgetData(widgetId, parsedTimeframe));

Copilot uses AI. Check for mistakes.
}

@Override
@PostMapping
public ResponseEntity<WidgetLayoutResponse> createWidget(@Valid @RequestBody WidgetDTO widgetDTO) {
return ResponseFactory.ok(widgetService.createWidget(widgetDTO));
}

@Override
@PutMapping("/layout")
public ResponseEntity<WidgetLayoutResponse> updateLayout(@Valid @RequestBody UpdateLayoutRequest request) {
return ResponseFactory.ok(widgetService.updateLayout(request));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.exence.finance.modules.statistics.dto;

import jakarta.validation.constraints.NotNull;

public record ChartLayoutItem(@NotNull Long id, @NotNull Integer x, @NotNull Integer y) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.exence.finance.modules.statistics.dto;

import jakarta.validation.constraints.NotNull;

public record StatCardLayoutItem(@NotNull Long id, @NotNull Integer displayOrder) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package com.exence.finance.modules.statistics.dto;

import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.time.temporal.ChronoUnit;

public enum Timeframe {
ONE_WEEK("1W", 7, ChronoUnit.DAYS),
ONE_MONTH("1M", 1, ChronoUnit.MONTHS),
THREE_MONTHS("3M", 3, ChronoUnit.MONTHS),
SIX_MONTHS("6M", 6, ChronoUnit.MONTHS),
ONE_YEAR("1Y", 1, ChronoUnit.YEARS),
YTD("YTD", 0, null),
ALL_TIME("ALL", Integer.MAX_VALUE, ChronoUnit.YEARS);

private final String code;
private final int amount;
private final ChronoUnit unit;

Timeframe(String code, int amount, ChronoUnit unit) {
this.code = code;
this.amount = amount;
this.unit = unit;
}

public Instant toStartDate() {
if (this == ALL_TIME) {
return Instant.EPOCH;
}
if (this == YTD) {
return LocalDate.now(ZoneOffset.UTC)
.withDayOfYear(1)
.atStartOfDay(ZoneOffset.UTC)
.toInstant();
}
return ZonedDateTime.now(ZoneOffset.UTC).minus(amount, unit).toInstant();
}

// fallback to 1y if null or unrecognized
public static Timeframe fromCode(String code) {
if (code == null) return YTD;
for (Timeframe t : values()) {
if (t.code.equalsIgnoreCase(code)) return t;
Comment on lines +41 to +45
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment says “fallback to 1y if null or unrecognized”, but fromCode currently returns YTD in those cases. Either update the comment or change the fallback behavior to match the intended default.

Copilot uses AI. Check for mistakes.
}

return YTD;
}

/**
* Computes the start of the previous period for trend comparison.
* For YTD: same dates shifted back 1 year.
* For fixed durations: mirrors the duration before currentStart.
* All time has no previous period, so returns null.
*/
public Instant previousPeriodStart(Instant currentStart, Instant currentEnd) {
if (this == ALL_TIME) {
return null;
}
if (this == YTD) {
return currentStart.atZone(ZoneOffset.UTC).minusYears(1).toInstant();
}
return ZonedDateTime.ofInstant(currentStart, ZoneOffset.UTC).minus(amount, unit).toInstant();
}

/**
* Computes the end of the previous period for trend comparison.
* For YTD: same end date shifted back 1 year.
* For others: the current period's start.
*/
public Instant previousPeriodEnd(Instant currentStart, Instant currentEnd) {
if (this == YTD) {
return currentEnd.atZone(ZoneOffset.UTC).minusYears(1).toInstant();
}
return currentStart;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.exence.finance.modules.statistics.dto;

import com.exence.finance.modules.statistics.annotations.AtLeastOneNotEmpty;
import jakarta.validation.Valid;
import java.util.List;

@AtLeastOneNotEmpty
public record UpdateLayoutRequest(@Valid List<StatCardLayoutItem> statCards, @Valid List<ChartLayoutItem> charts) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.exence.finance.modules.statistics.dto;

import com.exence.finance.modules.statistics.annotations.ValidWidgetLayout;
import jakarta.validation.constraints.NotNull;
import java.util.Map;

@ValidWidgetLayout
public record WidgetDTO(
Long id,
@NotNull WidgetType type,
String title,
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

title is nullable in the API DTO, but the DB column is nullable = false and the entity also requires it. This allows invalid requests that fail at persistence time instead of returning a validation error. Add @NotNull (and potentially size constraints) to WidgetDTO.title.

Suggested change
String title,
@NotNull String title,

Copilot uses AI. Check for mistakes.
Timeframe timeframe,
Integer displayOrder,
Integer x,
Integer y,
Map<WidgetSetting, Object> settings) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.exence.finance.modules.statistics.dto;

import java.time.Instant;
import java.util.Map;

public record WidgetRequest(
Instant startDate, Instant endDate, Timeframe timeframe, Map<WidgetSetting, Object> settings) {}
Loading
Loading