updates for zmanagement
All checks were successful
Build Org Backend / build (push) Successful in 20s

This commit is contained in:
2026-06-10 12:47:57 +01:00
parent e9914e7fa8
commit 9df3e162ce
14 changed files with 620 additions and 0 deletions

View File

@@ -0,0 +1,65 @@
package org.zaine.app.controller;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.ResponseEntity;
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.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.zaine.app.dto.CalendarEventDTO;
import org.zaine.app.dto.CreateCalendarEventDTO;
import org.zaine.app.service.CalendarService;
import java.time.LocalDate;
import java.util.List;
@RestController
@RequestMapping("/api/calendar")
@Tag(name = "Calendar", description = "Calendar events exposed to clients")
public class CalendarController {
private final CalendarService calendarService;
public CalendarController(CalendarService calendarService) {
this.calendarService = calendarService;
}
@Operation(summary = "Get today's calendar events")
@GetMapping("/today")
public List<CalendarEventDTO> getTodayEvents() {
return calendarService.getTodayEvents();
}
@Operation(summary = "Get calendar events for a month")
@GetMapping("/month")
public List<CalendarEventDTO> getMonthEvents(
@RequestParam int year,
@RequestParam int month) {
return calendarService.getMonthEvents(year, month);
}
@Operation(summary = "Get calendar events for a date range")
@GetMapping("/range")
public List<CalendarEventDTO> getRangeEvents(
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to) {
return calendarService.getEventsForRange(from, to);
}
@Operation(summary = "Get one calendar event")
@GetMapping("/events/{id}")
public CalendarEventDTO getEvent(@PathVariable Long id) {
return calendarService.getEvent(id);
}
@Operation(summary = "Create a calendar event")
@PostMapping("/events")
public ResponseEntity<CalendarEventDTO> createEvent(@RequestBody CreateCalendarEventDTO dto) {
return ResponseEntity.ok(calendarService.createEvent(dto));
}
}

View File

@@ -0,0 +1,44 @@
package org.zaine.app.controller;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.http.ResponseEntity;
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.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.zaine.app.dto.EveningCheckinRequest;
import org.zaine.app.dto.MorningCheckinRequest;
import org.zaine.app.dto.TodayStatusDTO;
import org.zaine.app.service.CheckinService;
@RestController
@RequestMapping("/api/checkin")
@Tag(name = "Check-in", description = "Daily morning and evening check-ins")
public class CheckinController {
private final CheckinService checkinService;
public CheckinController(CheckinService checkinService) {
this.checkinService = checkinService;
}
@Operation(summary = "Get today's check-in status")
@GetMapping("/today-status")
public TodayStatusDTO getTodayStatus() {
return checkinService.getTodayStatus();
}
@Operation(summary = "Submit morning check-in")
@PostMapping("/morning")
public ResponseEntity<TodayStatusDTO> submitMorning(@RequestBody MorningCheckinRequest request) {
return ResponseEntity.ok(checkinService.saveMorning(request));
}
@Operation(summary = "Submit evening check-in")
@PostMapping("/evening")
public ResponseEntity<TodayStatusDTO> submitEvening(@RequestBody EveningCheckinRequest request) {
return ResponseEntity.ok(checkinService.saveEvening(request));
}
}

View File

@@ -0,0 +1,15 @@
package org.zaine.app.dto;
import java.time.OffsetDateTime;
public record CalendarEventDTO(
Long id,
String externalId,
String title,
String description,
String location,
OffsetDateTime startsAt,
OffsetDateTime endsAt,
boolean allDay,
String source) {
}

View File

@@ -0,0 +1,14 @@
package org.zaine.app.dto;
import java.time.OffsetDateTime;
public record CreateCalendarEventDTO(
String externalId,
String title,
String description,
String location,
OffsetDateTime startsAt,
OffsetDateTime endsAt,
boolean allDay,
String source) {
}

View File

@@ -0,0 +1,11 @@
package org.zaine.app.dto;
import java.time.LocalDate;
public record EveningCheckinRequest(
LocalDate date,
Integer mood,
Integer stressLevel,
String reflection,
String bestThingToday) {
}

View File

@@ -0,0 +1,12 @@
package org.zaine.app.dto;
import java.math.BigDecimal;
import java.time.LocalDate;
public record MorningCheckinRequest(
LocalDate date,
BigDecimal sleepHours,
Integer energyLevel,
Integer mood,
String note) {
}

View File

@@ -0,0 +1,18 @@
package org.zaine.app.dto;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.OffsetDateTime;
public record TodayStatusDTO(
LocalDate date,
boolean morningDone,
boolean eveningDone,
BigDecimal sleepHours,
Integer morningEnergyLevel,
Integer morningMoodLevel,
Integer eveningMoodLevel,
Integer stressLevel,
OffsetDateTime morningCompletedAt,
OffsetDateTime eveningCompletedAt) {
}

View File

@@ -0,0 +1,83 @@
package org.zaine.app.model;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.PrePersist;
import jakarta.persistence.PreUpdate;
import jakarta.persistence.Table;
import java.time.OffsetDateTime;
@Entity
@Table(name = "calendar_events")
public class CalendarEvent {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "external_id", unique = true)
private String externalId;
@Column(nullable = false)
private String title;
@Column
private String description;
@Column
private String location;
@Column(name = "starts_at", nullable = false)
private OffsetDateTime startsAt;
@Column(name = "ends_at", nullable = false)
private OffsetDateTime endsAt;
@Column(name = "all_day", nullable = false)
private boolean allDay;
@Column(nullable = false)
private String source = "manual";
@Column(name = "created_at", nullable = false, updatable = false)
private OffsetDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private OffsetDateTime updatedAt;
@PrePersist
void onInsert() {
OffsetDateTime now = OffsetDateTime.now();
this.createdAt = now;
this.updatedAt = now;
}
@PreUpdate
void onUpdate() {
this.updatedAt = OffsetDateTime.now();
}
public Long getId() { return id; }
public String getExternalId() { return externalId; }
public void setExternalId(String externalId) { this.externalId = externalId; }
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public String getLocation() { return location; }
public void setLocation(String location) { this.location = location; }
public OffsetDateTime getStartsAt() { return startsAt; }
public void setStartsAt(OffsetDateTime startsAt) { this.startsAt = startsAt; }
public OffsetDateTime getEndsAt() { return endsAt; }
public void setEndsAt(OffsetDateTime endsAt) { this.endsAt = endsAt; }
public boolean isAllDay() { return allDay; }
public void setAllDay(boolean allDay) { this.allDay = allDay; }
public String getSource() { return source; }
public void setSource(String source) { this.source = source; }
public OffsetDateTime getCreatedAt() { return createdAt; }
public OffsetDateTime getUpdatedAt() { return updatedAt; }
}

View File

@@ -0,0 +1,100 @@
package org.zaine.app.model;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.PrePersist;
import jakarta.persistence.PreUpdate;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.OffsetDateTime;
@Entity
@Table(name = "daily_checkins")
public class DailyCheckin {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private LocalDate date;
@Column(name = "sleep_hours", precision = 4, scale = 2)
private BigDecimal sleepHours;
@Column(name = "morning_energy_level")
private Integer morningEnergyLevel;
@Column(name = "morning_mood_level")
private Integer morningMoodLevel;
@Column(name = "morning_note")
private String morningNote;
@Column(name = "morning_completed_at")
private OffsetDateTime morningCompletedAt;
@Column(name = "evening_mood_level")
private Integer eveningMoodLevel;
@Column(name = "stress_level")
private Integer stressLevel;
@Column(name = "reflection", columnDefinition = "text")
private String reflection;
@Column(name = "best_thing_today")
private String bestThingToday;
@Column(name = "evening_completed_at")
private OffsetDateTime eveningCompletedAt;
@Column(name = "created_at", nullable = false, updatable = false)
private OffsetDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private OffsetDateTime updatedAt;
@PrePersist
void onInsert() {
OffsetDateTime now = OffsetDateTime.now();
this.createdAt = now;
this.updatedAt = now;
}
@PreUpdate
void onUpdate() {
this.updatedAt = OffsetDateTime.now();
}
public Long getId() { return id; }
public LocalDate getDate() { return date; }
public void setDate(LocalDate date) { this.date = date; }
public BigDecimal getSleepHours() { return sleepHours; }
public void setSleepHours(BigDecimal sleepHours) { this.sleepHours = sleepHours; }
public Integer getMorningEnergyLevel() { return morningEnergyLevel; }
public void setMorningEnergyLevel(Integer morningEnergyLevel) { this.morningEnergyLevel = morningEnergyLevel; }
public Integer getMorningMoodLevel() { return morningMoodLevel; }
public void setMorningMoodLevel(Integer morningMoodLevel) { this.morningMoodLevel = morningMoodLevel; }
public String getMorningNote() { return morningNote; }
public void setMorningNote(String morningNote) { this.morningNote = morningNote; }
public OffsetDateTime getMorningCompletedAt() { return morningCompletedAt; }
public void setMorningCompletedAt(OffsetDateTime morningCompletedAt) { this.morningCompletedAt = morningCompletedAt; }
public Integer getEveningMoodLevel() { return eveningMoodLevel; }
public void setEveningMoodLevel(Integer eveningMoodLevel) { this.eveningMoodLevel = eveningMoodLevel; }
public Integer getStressLevel() { return stressLevel; }
public void setStressLevel(Integer stressLevel) { this.stressLevel = stressLevel; }
public String getReflection() { return reflection; }
public void setReflection(String reflection) { this.reflection = reflection; }
public String getBestThingToday() { return bestThingToday; }
public void setBestThingToday(String bestThingToday) { this.bestThingToday = bestThingToday; }
public OffsetDateTime getEveningCompletedAt() { return eveningCompletedAt; }
public void setEveningCompletedAt(OffsetDateTime eveningCompletedAt) { this.eveningCompletedAt = eveningCompletedAt; }
public OffsetDateTime getCreatedAt() { return createdAt; }
public OffsetDateTime getUpdatedAt() { return updatedAt; }
}

View File

@@ -0,0 +1,13 @@
package org.zaine.app.repositories;
import org.springframework.data.jpa.repository.JpaRepository;
import org.zaine.app.model.CalendarEvent;
import java.time.OffsetDateTime;
import java.util.List;
public interface CalendarEventRepository extends JpaRepository<CalendarEvent, Long> {
List<CalendarEvent> findByStartsAtLessThanAndEndsAtGreaterThanOrderByStartsAtAsc(
OffsetDateTime rangeEnd,
OffsetDateTime rangeStart);
}

View File

@@ -0,0 +1,11 @@
package org.zaine.app.repositories;
import org.springframework.data.jpa.repository.JpaRepository;
import org.zaine.app.model.DailyCheckin;
import java.time.LocalDate;
import java.util.Optional;
public interface DailyCheckinRepository extends JpaRepository<DailyCheckin, Long> {
Optional<DailyCheckin> findByDate(LocalDate date);
}

View File

@@ -0,0 +1,99 @@
package org.zaine.app.service;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
import org.zaine.app.dto.CalendarEventDTO;
import org.zaine.app.dto.CreateCalendarEventDTO;
import org.zaine.app.model.CalendarEvent;
import org.zaine.app.repositories.CalendarEventRepository;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.time.ZoneId;
import java.time.temporal.TemporalAdjusters;
import java.util.List;
@Service
public class CalendarService {
private final CalendarEventRepository calendarEventRepository;
public CalendarService(CalendarEventRepository calendarEventRepository) {
this.calendarEventRepository = calendarEventRepository;
}
public List<CalendarEventDTO> getTodayEvents() {
LocalDate today = LocalDate.now();
return getEventsForRange(today, today.plusDays(1));
}
public List<CalendarEventDTO> getMonthEvents(int year, int month) {
LocalDate start = LocalDate.of(year, month, 1);
LocalDate end = start.with(TemporalAdjusters.firstDayOfNextMonth());
return getEventsForRange(start, end);
}
public List<CalendarEventDTO> getEventsForRange(LocalDate from, LocalDate to) {
if (to.isBefore(from)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "to must be on or after from");
}
ZoneId zone = ZoneId.systemDefault();
OffsetDateTime rangeStart = from.atStartOfDay(zone).toOffsetDateTime();
OffsetDateTime rangeEnd = to.atStartOfDay(zone).toOffsetDateTime();
return calendarEventRepository
.findByStartsAtLessThanAndEndsAtGreaterThanOrderByStartsAtAsc(rangeEnd, rangeStart)
.stream()
.map(this::toDto)
.toList();
}
public CalendarEventDTO getEvent(Long id) {
CalendarEvent event = calendarEventRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Calendar event not found"));
return toDto(event);
}
public CalendarEventDTO createEvent(CreateCalendarEventDTO dto) {
if (dto.title() == null || dto.title().isBlank()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "title is required");
}
if (dto.startsAt() == null || dto.endsAt() == null) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "startsAt and endsAt are required");
}
if (!dto.endsAt().isAfter(dto.startsAt())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "endsAt must be after startsAt");
}
CalendarEvent event = new CalendarEvent();
event.setExternalId(blankToNull(dto.externalId()));
event.setTitle(dto.title().trim());
event.setDescription(blankToNull(dto.description()));
event.setLocation(blankToNull(dto.location()));
event.setStartsAt(dto.startsAt());
event.setEndsAt(dto.endsAt());
event.setAllDay(dto.allDay());
event.setSource(dto.source() == null || dto.source().isBlank() ? "manual" : dto.source().trim());
return toDto(calendarEventRepository.save(event));
}
private CalendarEventDTO toDto(CalendarEvent event) {
return new CalendarEventDTO(
event.getId(),
event.getExternalId(),
event.getTitle(),
event.getDescription(),
event.getLocation(),
event.getStartsAt(),
event.getEndsAt(),
event.isAllDay(),
event.getSource());
}
private String blankToNull(String value) {
return value == null || value.isBlank() ? null : value.trim();
}
}

View File

@@ -0,0 +1,97 @@
package org.zaine.app.service;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
import org.zaine.app.dto.EveningCheckinRequest;
import org.zaine.app.dto.MorningCheckinRequest;
import org.zaine.app.dto.TodayStatusDTO;
import org.zaine.app.model.DailyCheckin;
import org.zaine.app.repositories.DailyCheckinRepository;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.OffsetDateTime;
@Service
public class CheckinService {
private final DailyCheckinRepository dailyCheckinRepository;
public CheckinService(DailyCheckinRepository dailyCheckinRepository) {
this.dailyCheckinRepository = dailyCheckinRepository;
}
public TodayStatusDTO getTodayStatus() {
return toStatus(getOrCreate(LocalDate.now()));
}
public TodayStatusDTO saveMorning(MorningCheckinRequest request) {
LocalDate date = request.date() == null ? LocalDate.now() : request.date();
validateScore(request.energyLevel(), "energyLevel");
validateScore(request.mood(), "mood");
validateSleepHours(request.sleepHours());
DailyCheckin checkin = getOrCreate(date);
checkin.setSleepHours(request.sleepHours());
checkin.setMorningEnergyLevel(request.energyLevel());
checkin.setMorningMoodLevel(request.mood());
checkin.setMorningNote(blankToNull(request.note()));
checkin.setMorningCompletedAt(OffsetDateTime.now());
return toStatus(dailyCheckinRepository.save(checkin));
}
public TodayStatusDTO saveEvening(EveningCheckinRequest request) {
LocalDate date = request.date() == null ? LocalDate.now() : request.date();
validateScore(request.mood(), "mood");
validateScore(request.stressLevel(), "stressLevel");
DailyCheckin checkin = getOrCreate(date);
checkin.setEveningMoodLevel(request.mood());
checkin.setStressLevel(request.stressLevel());
checkin.setReflection(blankToNull(request.reflection()));
checkin.setBestThingToday(blankToNull(request.bestThingToday()));
checkin.setEveningCompletedAt(OffsetDateTime.now());
return toStatus(dailyCheckinRepository.save(checkin));
}
private DailyCheckin getOrCreate(LocalDate date) {
return dailyCheckinRepository.findByDate(date).orElseGet(() -> {
DailyCheckin checkin = new DailyCheckin();
checkin.setDate(date);
return checkin;
});
}
private TodayStatusDTO toStatus(DailyCheckin checkin) {
return new TodayStatusDTO(
checkin.getDate(),
checkin.getMorningCompletedAt() != null,
checkin.getEveningCompletedAt() != null,
checkin.getSleepHours(),
checkin.getMorningEnergyLevel(),
checkin.getMorningMoodLevel(),
checkin.getEveningMoodLevel(),
checkin.getStressLevel(),
checkin.getMorningCompletedAt(),
checkin.getEveningCompletedAt());
}
private void validateScore(Integer value, String fieldName) {
if (value == null || value < 1 || value > 10) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, fieldName + " must be between 1 and 10");
}
}
private void validateSleepHours(BigDecimal sleepHours) {
if (sleepHours == null || sleepHours.compareTo(BigDecimal.ZERO) < 0 || sleepHours.compareTo(new BigDecimal("24")) > 0) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "sleepHours must be between 0 and 24");
}
}
private String blankToNull(String value) {
return value == null || value.isBlank() ? null : value.trim();
}
}

View File

@@ -0,0 +1,38 @@
CREATE TABLE IF NOT EXISTS calendar_events (
id BIGSERIAL PRIMARY KEY,
external_id VARCHAR(255) UNIQUE,
title VARCHAR(255) NOT NULL,
description TEXT,
location VARCHAR(255),
starts_at TIMESTAMPTZ NOT NULL,
ends_at TIMESTAMPTZ NOT NULL,
all_day BOOLEAN NOT NULL DEFAULT FALSE,
source VARCHAR(64) NOT NULL DEFAULT 'manual',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_calendar_events_range
ON calendar_events (starts_at, ends_at);
CREATE TABLE IF NOT EXISTS daily_checkins (
id BIGSERIAL PRIMARY KEY,
date DATE NOT NULL UNIQUE,
sleep_hours NUMERIC(4, 2),
morning_energy_level INTEGER,
morning_mood_level INTEGER,
morning_note VARCHAR(255),
morning_completed_at TIMESTAMPTZ,
evening_mood_level INTEGER,
stress_level INTEGER,
reflection TEXT,
best_thing_today VARCHAR(255),
evening_completed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT chk_daily_checkins_sleep_hours CHECK (sleep_hours IS NULL OR (sleep_hours >= 0 AND sleep_hours <= 24)),
CONSTRAINT chk_daily_checkins_morning_energy CHECK (morning_energy_level IS NULL OR (morning_energy_level BETWEEN 1 AND 10)),
CONSTRAINT chk_daily_checkins_morning_mood CHECK (morning_mood_level IS NULL OR (morning_mood_level BETWEEN 1 AND 10)),
CONSTRAINT chk_daily_checkins_evening_mood CHECK (evening_mood_level IS NULL OR (evening_mood_level BETWEEN 1 AND 10)),
CONSTRAINT chk_daily_checkins_stress CHECK (stress_level IS NULL OR (stress_level BETWEEN 1 AND 10))
);