Start working on widgets

This commit is contained in:
Miroslav Vasilev 2024-01-02 18:20:00 +02:00
parent 1a9dc36cf5
commit c6c44e1604
16 changed files with 443 additions and 36 deletions

View file

@ -2,21 +2,24 @@ package dev.mvvasilev.finances.controllers;
import dev.mvvasilev.common.controller.AbstractRestController;
import dev.mvvasilev.common.web.APIResponseDTO;
import dev.mvvasilev.finances.dtos.SpendingByCategoryDTO;
import dev.mvvasilev.finances.dtos.SpendingByCategoriesDTO;
import dev.mvvasilev.finances.dtos.SpendingOverTimeByCategoryDTO;
import dev.mvvasilev.finances.enums.TimePeriod;
import dev.mvvasilev.finances.services.StatisticsService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.time.LocalDateTime;
import java.util.Collection;
@RestController("/statistics")
@RestController
@RequestMapping("/statistics")
public class StatisticsController extends AbstractRestController {
private final StatisticsService statisticsService;
@ -27,23 +30,24 @@ public class StatisticsController extends AbstractRestController {
}
@GetMapping("/totalSpendingByCategory")
public ResponseEntity<APIResponseDTO<SpendingByCategoryDTO>> fetchSpendingByCategory(
@PreAuthorize("@authService.isOwner(#categoryId, T(dev.mvvasilev.finances.entity.RawStatement))")
public ResponseEntity<APIResponseDTO<SpendingByCategoriesDTO>> fetchSpendingByCategory(
Long[] categoryId,
@RequestParam(defaultValue = "1970-01-01T00:00:00") LocalDateTime from,
@RequestParam(defaultValue = "2099-01-01T00:00:00") LocalDateTime to,
Authentication authentication
@RequestParam(defaultValue = "2099-01-01T00:00:00") LocalDateTime to
) {
return ok(statisticsService.spendingByCategory(from, to, Integer.parseInt(authentication.getName())));
return ok(statisticsService.spendingByCategory(categoryId, from, to));
}
@GetMapping("/spendingOverTimeByCategory")
@PreAuthorize("@authService.isOwner(#categoryId, T(dev.mvvasilev.finances.entity.RawStatement))")
public ResponseEntity<APIResponseDTO<SpendingOverTimeByCategoryDTO>> fetchSpendingOverTimeByCategory(
Collection<Long> categoryId,
TimePeriod period,
Long[] categoryId,
@RequestParam(defaultValue = "DAILY") TimePeriod period,
@RequestParam(defaultValue = "1970-01-01T00:00:00") LocalDateTime from,
@RequestParam(defaultValue = "2099-01-01T00:00:00") LocalDateTime to,
Authentication authentication
@RequestParam(defaultValue = "2099-01-01T00:00:00") LocalDateTime to
) {
throw new UnsupportedOperationException();
return ok(statisticsService.spendingByCategoryOverTime(categoryId, period, from, to));
}
}

View file

@ -0,0 +1,20 @@
package dev.mvvasilev.finances.controllers;
import dev.mvvasilev.common.controller.AbstractRestController;
import dev.mvvasilev.common.web.APIResponseDTO;
import dev.mvvasilev.common.web.CrudResponseDTO;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/widgets")
public class WidgetsController extends AbstractRestController {
// <CreateDTO> ResponseEntity<APIResponseDTO<CrudResponseDTO>> create(CreateDTO dto);
//
// <UpdateDTO> ResponseEntity<APIResponseDTO<CrudResponseDTO>> update(Long id, UpdateDTO dto);
//
// ResponseEntity<APIResponseDTO<CrudResponseDTO>> delete(Long id);
}

View file

@ -0,0 +1,11 @@
package dev.mvvasilev.finances.dtos;
import java.util.Collection;
import java.util.Map;
public record SpendingByCategoriesDTO(
Collection<CategoryDTO> categories,
Map<Long, Double> spendingByCategory
) {
}

View file

@ -1,11 +1,11 @@
package dev.mvvasilev.finances.dtos;
import java.util.Collection;
import java.util.Map;
import java.time.LocalDateTime;
public record SpendingByCategoryDTO(
Collection<CategoryDTO> categories,
public record SpendingByCategoryDTO (
Long categoryId,
Map<Long, Double> spendingByCategory
) {
}
LocalDateTime timestamp,
Double amount
) {}

View file

@ -1,11 +1,16 @@
package dev.mvvasilev.finances.dtos;
import java.time.Duration;
import java.time.LocalDateTime;
import java.time.Period;
import java.util.Collection;
import java.util.Map;
public record SpendingOverTimeByCategoryDTO(
Collection<CategoryDTO> categories,
Map<Long, Collection<Double>> spendingOverTime
Period timePeriodDuration,
Collection<SpendingByCategoryDTO> spending
) {
}

View file

@ -0,0 +1,77 @@
package dev.mvvasilev.finances.entity;
import dev.mvvasilev.common.data.AbstractEntity;
import dev.mvvasilev.finances.enums.WidgetType;
import jakarta.persistence.Convert;
import jakarta.persistence.Converter;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
@Entity
@Table(schema = "widgets")
public class Widget extends AbstractEntity {
@Convert(converter = WidgetType.JpaConverter.class)
private WidgetType type;
private int positionX;
private int positionY;
private int sizeX;
private int sizeY;
private String name;
public Widget() {
}
public WidgetType getType() {
return type;
}
public void setType(WidgetType type) {
this.type = type;
}
public int getPositionX() {
return positionX;
}
public void setPositionX(int positionX) {
this.positionX = positionX;
}
public int getPositionY() {
return positionY;
}
public void setPositionY(int positionY) {
this.positionY = positionY;
}
public int getSizeX() {
return sizeX;
}
public void setSizeX(int sizeX) {
this.sizeX = sizeX;
}
public int getSizeY() {
return sizeY;
}
public void setSizeY(int sizeY) {
this.sizeY = sizeY;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}

View file

@ -0,0 +1,75 @@
package dev.mvvasilev.finances.entity;
import dev.mvvasilev.common.data.AbstractEntity;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
@Entity
@Table(schema = "widgets")
public class WidgetParameter extends AbstractEntity {
private Long widgetId;
private String name;
private String stringValue;
private Double numericValue;
private LocalDateTime timestampValue;
private Boolean booleanValue;
public WidgetParameter() {
}
public Long getWidgetId() {
return widgetId;
}
public void setWidgetId(Long widgetId) {
this.widgetId = widgetId;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getStringValue() {
return stringValue;
}
public void setStringValue(String stringValue) {
this.stringValue = stringValue;
}
public Double getNumericValue() {
return numericValue;
}
public void setNumericValue(Double numericValue) {
this.numericValue = numericValue;
}
public LocalDateTime getTimestampValue() {
return timestampValue;
}
public void setTimestampValue(LocalDateTime timestampValue) {
this.timestampValue = timestampValue;
}
public Boolean getBooleanValue() {
return booleanValue;
}
public void setBooleanValue(Boolean booleanValue) {
this.booleanValue = booleanValue;
}
}

View file

@ -1,13 +1,27 @@
package dev.mvvasilev.finances.enums;
import java.time.Duration;
import java.time.Period;
import java.time.temporal.ChronoUnit;
public enum TimePeriod {
SECONDLY,
MINUTELY,
HOURLY,
DAILY,
WEEKLY,
BIWEEKLY,
MONTHLY,
QUARTERLY,
YEARLY
// SECONDLY(Duration.of(1, ChronoUnit.SECONDS)),
// MINUTELY(Duration.of(1, ChronoUnit.MINUTES)),
// HOURLY(Duration.of(1, ChronoUnit.HOURS)),
DAILY(Period.ofDays(1)),
WEEKLY(Period.ofDays(7)),
BIWEEKLY(Period.ofDays(14)),
MONTHLY(Period.ofMonths(1)),
QUARTERLY(Period.ofMonths(3)),
YEARLY(Period.ofYears(1));
private final Period duration;
TimePeriod(Period duration) {
this.duration = duration;
}
public Period getDuration() {
return duration;
}
}

View file

@ -0,0 +1,20 @@
package dev.mvvasilev.finances.enums;
import dev.mvvasilev.common.data.AbstractEnumConverter;
import dev.mvvasilev.common.data.PersistableEnum;
public enum WidgetType implements PersistableEnum<String> {
TOTAL_SPENDING_PER_CATEGORY,
SPENDING_OVER_TIME_PER_CATEGORY;
@Override
public String value() {
return name();
}
public static class JpaConverter extends AbstractEnumConverter<WidgetType, String> {
public JpaConverter() {
super(WidgetType.class);
}
}
}

View file

@ -1,12 +1,18 @@
package dev.mvvasilev.finances.persistence;
import dev.mvvasilev.finances.dtos.SpendingOverTimeByCategoryDTO;
import dev.mvvasilev.finances.enums.TimePeriod;
import dev.mvvasilev.finances.persistence.dtos.SpendingOverTimeDTO;
import jakarta.persistence.EntityManager;
import jakarta.persistence.Query;
import jakarta.persistence.Tuple;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;
import java.sql.Timestamp;
import java.time.LocalDateTime;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@ -21,7 +27,7 @@ public class StatisticsRepository {
this.entityManager = entityManager;
}
public Map<Long, Double> fetchSpendingByCategory(LocalDateTime from, LocalDateTime to, List<Long> categoryIds) {
public Map<Long, Double> fetchSpendingByCategory(Long[] categoryId, LocalDateTime from, LocalDateTime to) {
Query nativeQuery = entityManager.createNativeQuery(
"""
SELECT ptc.category_id AS category_id, ROUND(CAST(SUM(pt.amount) AS NUMERIC), 2) AS total_spending
@ -37,7 +43,7 @@ public class StatisticsRepository {
Tuple.class
);
nativeQuery.setParameter(1, categoryIds);
nativeQuery.setParameter(1, categoryId);
nativeQuery.setParameter(2, from);
nativeQuery.setParameter(3, to);
@ -47,4 +53,21 @@ public class StatisticsRepository {
(Tuple tuple) -> ((Number) tuple.get("total_spending")).doubleValue()
));
}
public Collection<SpendingOverTimeDTO> fetchSpendingByCategoryOverTime(LocalDateTime from, LocalDateTime to, TimePeriod period, Long[] categoryId) {
Query nativeQuery = entityManager.createNativeQuery("SELECT * FROM statistics.spending_over_time(?1, ?2, ?3, ?4) ORDER BY period_beginning_timestamp;", Tuple.class);
nativeQuery.setParameter(1, categoryId);
nativeQuery.setParameter(2, period.toString());
nativeQuery.setParameter(3, from);
nativeQuery.setParameter(4, to);
//noinspection unchecked
return nativeQuery.getResultStream().map(r -> new SpendingOverTimeDTO(
((Tuple) r).get("category_id", Long.class),
((Tuple) r).get("amount_for_period", Double.class),
((Tuple) r).get("period_beginning_timestamp", Timestamp.class).toLocalDateTime()
)).toList();
}
}

View file

@ -0,0 +1,11 @@
package dev.mvvasilev.finances.persistence.dtos;
import java.time.LocalDateTime;
public record SpendingOverTimeDTO(
Long categoryId,
Double amountForPeriod,
LocalDateTime periodBeginningTimestamp
) {}

View file

@ -5,6 +5,8 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;
import java.util.Collection;
@Service("authService")
public class AuthorizationService {
@ -49,4 +51,8 @@ public class AuthorizationService {
return !entityManager.createQuery(finalQuery).setMaxResults(1).getResultList().isEmpty();
}
public boolean isOwner(Collection<Long> ids, Class<?> userOwnedEntity) {
return ids.stream().allMatch(id -> isOwner(id, userOwnedEntity));
}
}

View file

@ -2,12 +2,17 @@ package dev.mvvasilev.finances.services;
import dev.mvvasilev.common.data.AbstractEntity;
import dev.mvvasilev.finances.dtos.CategoryDTO;
import dev.mvvasilev.finances.dtos.SpendingByCategoriesDTO;
import dev.mvvasilev.finances.dtos.SpendingByCategoryDTO;
import dev.mvvasilev.finances.dtos.SpendingOverTimeByCategoryDTO;
import dev.mvvasilev.finances.enums.TimePeriod;
import dev.mvvasilev.finances.persistence.StatisticsRepository;
import dev.mvvasilev.finances.persistence.TransactionCategoryRepository;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
import java.util.*;
@Service
public class StatisticsService {
@ -21,17 +26,53 @@ public class StatisticsService {
this.statisticsRepository = statisticsRepository;
}
public SpendingByCategoryDTO spendingByCategory(LocalDateTime from, LocalDateTime to, int userId) {
final var categories = transactionCategoryRepository.fetchTransactionCategoriesWithUserId(userId);
public SpendingByCategoriesDTO spendingByCategory(Long[] categoryId, LocalDateTime from, LocalDateTime to) {
final var categories = transactionCategoryRepository.findAllById(Arrays.stream(categoryId).toList()).stream().map(c -> new CategoryDTO(c.getId(), c.getName(), null)).toList();
final var spendingByCategory = statisticsRepository.fetchSpendingByCategory(
categoryId,
from,
to,
categories.stream().map(AbstractEntity::getId).toList()
to
);
return new SpendingByCategoryDTO(
categories.stream().map(c -> new CategoryDTO(c.getId(), c.getName(), null)).toList(),
return new SpendingByCategoriesDTO(
categories,
spendingByCategory
);
}
public SpendingOverTimeByCategoryDTO spendingByCategoryOverTime(
Long[] categoryId,
TimePeriod period,
LocalDateTime from,
LocalDateTime to
) {
return new SpendingOverTimeByCategoryDTO(
transactionCategoryRepository.findAllById(Arrays.stream(categoryId).toList()).stream().map(c -> new CategoryDTO(c.getId(), c.getName(), null)).toList(),
period.getDuration(),
statisticsRepository.fetchSpendingByCategoryOverTime(from, to, period, categoryId).stream().map(dto -> new SpendingByCategoryDTO(
dto.categoryId(),
dto.periodBeginningTimestamp(),
dto.amountForPeriod()
)).toList()
);
}
// Impose limits if necessary
private boolean validatePeriodWithinLimits(LocalDateTime from, LocalDateTime to, TimePeriod period) {
return switch (period) {
// Can't request daily spending breakdown for a period longer than a month
case DAILY -> ChronoUnit.MONTHS.between(from, to) <= 1;
// Can't request weekly spending breakdown for a period longer than a year
case WEEKLY -> ChronoUnit.YEARS.between(from, to) <= 1;
// Can't request bi-weekly spending breakdown for a period longer than a year
case BIWEEKLY -> ChronoUnit.YEARS.between(from, to) <= 1;
// Can't request monthly spending breakdown for a period longer than 3 years
case MONTHLY -> ChronoUnit.YEARS.between(from, to) <= 3;
// Can't request quarterly spending breakdown for a period longer than 5 years
case QUARTERLY -> ChronoUnit.YEARS.between(from, to) <= 5;
// Can't request yearly spending breakdown for a period longer than 30 years
case YEARLY -> ChronoUnit.YEARS.between(from, to) <= 30;
};
}
}

View file

@ -0,0 +1,73 @@
CREATE OR REPLACE FUNCTION statistics.spending_over_time(
category_ids BIGINT[],
time_period TEXT,
from_date TIMESTAMP,
to_date TIMESTAMP
)
RETURNS TABLE (
category_id BIGINT,
amount_for_period FLOAT,
period_beginning_timestamp TIMESTAMP
)
LANGUAGE plpgsql
AS $$
DECLARE
time_interval interval;
BEGIN
time_interval := CASE
WHEN time_period = 'DAILY' THEN interval '1 day'
WHEN time_period = 'WEEKLY' THEN interval '1 week'
WHEN time_period = 'BIWEEKLY' THEN interval '2 weeks'
WHEN time_period = 'MONTHLY' THEN interval '1 month'
WHEN time_period = 'QUARTERLY' THEN interval '3 months'
WHEN time_period = 'YEARLY' THEN interval '1 year'
ELSE interval '1 day'
END;
RETURN QUERY WITH start_end AS (
SELECT
c.id as category_id,
generate_series(
from_date,
to_date,
time_interval
) as period_beginning_timestamp,
generate_series(
from_date,
to_date,
time_interval
) + time_interval AS period_ending_timestamp
FROM categories.transaction_category AS c
WHERE c.id = any(category_ids)
),
amounts AS (
SELECT
ptc.category_id,
SUM(pt.amount) AS amount_for_period,
start_end.period_beginning_timestamp
FROM start_end
JOIN categories.processed_transaction_category AS ptc ON ptc.category_id = start_end.category_id
JOIN transactions.processed_transaction AS pt ON ptc.processed_transaction_id = pt.id
WHERE
(
start_end.period_ending_timestamp > to_date
AND pt.timestamp BETWEEN start_end.period_beginning_timestamp AND to_date
)
OR (
start_end.period_ending_timestamp <= to_date
AND pt.timestamp BETWEEN start_end.period_beginning_timestamp AND start_end.period_ending_timestamp
)
GROUP BY (
start_end.period_beginning_timestamp,
start_end.period_ending_timestamp,
ptc.category_id
)
)
SELECT
start_end.category_id,
COALESCE(amounts.amount_for_period, 0) AS amount_for_period,
start_end.period_beginning_timestamp
FROM start_end
LEFT OUTER JOIN amounts ON amounts.category_id = start_end.category_id AND amounts.period_beginning_timestamp = start_end.period_beginning_timestamp;
END
$$;

View file

@ -0,0 +1,14 @@
CREATE SCHEMA IF NOT EXISTS widgets;
CREATE TABLE IF NOT EXISTS widgets.widget (
id BIGSERIAL,
type VARCHAR(255),
positionX INTEGER,
positionY INTEGER,
sizeX INTEGER,
sizeY INTEGER,
name VARCHAR(255),
time_created TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
time_last_modified TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
CONSTRAINT PK_widget PRIMARY KEY (id)
)

View file

@ -0,0 +1,13 @@
CREATE TABLE IF NOT EXISTS widgets.widget_parameter (
id BIGSERIAL,
widget_id BIGINT NOT NULL,
name VARCHAR(255) NOT NULL,
string_value VARCHAR(1024),
numeric_value FLOAT,
timestamp_value TIMESTAMP,
boolean_value BOOLEAN,
time_created TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
time_last_modified TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
CONSTRAINT PK_widget_parameter PRIMARY KEY (id),
CONSTRAINT FK_widget_parameter_widget FOREIGN KEY (widget_id) REFERENCES widgets.widget(id)
);