commit a1beeb949efaa549d57dbe89f5c2a5650d73c263 Author: Kayashov.SM Date: Thu Aug 27 12:50:22 2026 +0400 initial commit diff --git a/README.md b/README.md new file mode 100644 index 0000000..00b6230 --- /dev/null +++ b/README.md @@ -0,0 +1,67 @@ +# schedule-service + +Self-hosted сервис адаптивного расписания дня, синхронизируемого с CalDAV-календарём (Baikal → нативная синхронизация с Календарём iPhone). + +## Идея + +- Шаблон дня хранится в Postgres как набор блоков двух типов: + - **RELATIVE** — смещение от времени пробуждения (например, "завтрак: wake+105 мин") + - **FIXED** — жёсткая привязка к часу и дню недели (например, дейли в 10:15 по будням) +- По триггеру "проснулся" (через iOS Shortcuts → `POST /api/wake`) сервис вычисляет реальные времена на сегодня и создаёт/обновляет события в Baikal через CalDAV. +- `POST /api/shift` пересчитывает и сдвигает ещё не начавшиеся сегодняшние события. +- Список покупок (`shopping_item`) пополняется голосом через кастомный навык Алисы (вебхук `/api/alice/webhook`), читается бэкендом при наступлении блока категории `SHOPPING_TRIP`. +- Фронт на React для ручного администрирования шаблона — собирается в один jar вместе с бэкендом. + +## Стек + +- Spring Boot 3.3, Java 17 +- Spring Data JPA + PostgreSQL (`ddl-auto: update`, без Flyway — осознанное упрощение для домашнего проекта) +- RestTemplate + ical4j для CalDAV-синхронизации с Baikal +- React (сборка встраивается в `classpath:/static`) + +## Текущий статус + +Готово: +- [x] Entities: `ScheduleBlock`, `ShoppingItem` +- [x] Repositories +- [x] `CaldavProperties` / `RestTemplateConfig` +- [x] Базовый `application.yml` + +В работе / следующие шаги: +- [ ] `ScheduleCalculationService` — расчёт конкретных времён на день из шаблона +- [ ] `CaldavSyncService` — построение VEVENT (ical4j) и PUT в Baikal, детерминированный UID (например `sha256(date + title)`) для идемпотентного обновления +- [ ] `WakeController` — `POST /api/wake`, `POST /api/shift` +- [ ] `ScheduleBlockController`, `ShoppingController` — CRUD для фронта +- [ ] `AliceWebhookController` — приём команд от навыка Алисы +- [ ] React-фронт для администрирования +- [ ] Dockerfile + манифесты Deployment/Service для k3s (namespace `media`) + +## Локальный запуск + +```bash +# 1. Прокинуть Postgres из кластера +kubectl port-forward -n media svc/postgres-postgresql 5432:5432 + +# 2. Создать БД и пользователя (один раз) +psql -h localhost -U postgres -c "CREATE DATABASE schedule;" +psql -h localhost -U postgres -c "CREATE USER schedule_user WITH PASSWORD 'changeme';" +psql -h localhost -U postgres -c "GRANT ALL PRIVILEGES ON DATABASE schedule TO schedule_user;" + +# 3. Поправить src/main/resources/application.yml (пароли caldav/db) + +# 4. Запуск +mvn spring-boot:run +``` + +Baikal доступен напрямую по NodePort (`192.168.1.100:30019`), отдельный port-forward не нужен. + +## Референсные данные шаблона (обсуждены, вводятся вручную через фронт/API после его готовности) + +Пробуждение ~9:00, отбой ~23:30. Полный список блоков и офсетов — см. историю разработки проекта, часть значений помечена как оценочная и подлежит уточнению по факту использования. + +Статические события: +| Событие | Время | Дни | +|---|---|---| +| Дейли НСИ | 10:15 | будни | +| Дейли Mesh | 12:30 | будни | +| Созвон НСИ→ПГ | 15:00 | вторник | diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..f4787f3 --- /dev/null +++ b/pom.xml @@ -0,0 +1,118 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.3.4 + + + + ru.kayashov + schedule-service + 0.1.0 + schedule-service + Adaptive daily schedule service with CalDAV sync + + + 17 + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.postgresql + postgresql + runtime + + + org.springframework.boot + spring-boot-starter-validation + + + org.projectlombok + lombok + true + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + + + + + + diff --git a/src/main/java/ru/kayashov/schedule/ScheduleServiceApplication.java b/src/main/java/ru/kayashov/schedule/ScheduleServiceApplication.java new file mode 100644 index 0000000..0e89023 --- /dev/null +++ b/src/main/java/ru/kayashov/schedule/ScheduleServiceApplication.java @@ -0,0 +1,11 @@ +package ru.kayashov.schedule; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class ScheduleServiceApplication { + public static void main(String[] args) { + SpringApplication.run(ScheduleServiceApplication.class, args); + } +} diff --git a/src/main/java/ru/kayashov/schedule/config/CaldavProperties.java b/src/main/java/ru/kayashov/schedule/config/CaldavProperties.java new file mode 100644 index 0000000..e6cf423 --- /dev/null +++ b/src/main/java/ru/kayashov/schedule/config/CaldavProperties.java @@ -0,0 +1,17 @@ +package ru.kayashov.schedule.config; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Configuration +@ConfigurationProperties(prefix = "caldav") +@Getter +@Setter +public class CaldavProperties { + /** Например: http://192.168.1.100:30019/dav.php/calendars/sergey/default */ + private String baseUrl; + private String username; + private String password; +} diff --git a/src/main/java/ru/kayashov/schedule/config/RestTemplateConfig.java b/src/main/java/ru/kayashov/schedule/config/RestTemplateConfig.java new file mode 100644 index 0000000..309705a --- /dev/null +++ b/src/main/java/ru/kayashov/schedule/config/RestTemplateConfig.java @@ -0,0 +1,17 @@ +package ru.kayashov.schedule.config; + +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestTemplate; + +@Configuration +public class RestTemplateConfig { + + @Bean + public RestTemplate caldavRestTemplate(RestTemplateBuilder builder, CaldavProperties props) { + return builder + .basicAuthentication(props.getUsername(), props.getPassword()) + .build(); + } +} diff --git a/src/main/java/ru/kayashov/schedule/domain/DailyState.java b/src/main/java/ru/kayashov/schedule/domain/DailyState.java new file mode 100644 index 0000000..147dc68 --- /dev/null +++ b/src/main/java/ru/kayashov/schedule/domain/DailyState.java @@ -0,0 +1,36 @@ +package ru.kayashov.schedule.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; + +/** + * Состояние текущего дня: время пробуждения и накопленный сдвиг расписания. + * По одной записи на дату — обновляется при /wake (создание) и /shift (правка shiftMinutes). + */ +@Entity +@Table(name = "daily_state") +@Getter +@Setter +public class DailyState { + + @Id + private LocalDate date; + + @Column(nullable = false) + private LocalTime wakeTime; + + /** Суммарный сдвиг в минутах, накопленный за счёт вызовов /shift в течение дня. */ + @Column(nullable = false) + private Integer shiftMinutes = 0; + + @Column(nullable = false) + private LocalDateTime updatedAt = LocalDateTime.now(); +} \ No newline at end of file diff --git a/src/main/java/ru/kayashov/schedule/domain/EventOccurrence.java b/src/main/java/ru/kayashov/schedule/domain/EventOccurrence.java new file mode 100644 index 0000000..02628ad --- /dev/null +++ b/src/main/java/ru/kayashov/schedule/domain/EventOccurrence.java @@ -0,0 +1,80 @@ +package ru.kayashov.schedule.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** + * Конкретный экземпляр блока расписания на конкретную дату — источник истории + * и будущей статистики. Хранит и плановые времена (из шаблона на момент расчёта), + * и фактические (заполняются позже — вручную, по голосовой команде Алисе, кнопкой + * в Shortcuts и т.д.), поэтому actualStart/actualEnd nullable. + *

+ * Название блока и категория денормализованы (blockTitleSnapshot) — если шаблон + * потом переименуют или удалят, история и статистика по старым записям не потеряются. + */ +@Entity +@Table(name = "event_occurrence", indexes = { + @Index(name = "idx_occurrence_date", columnList = "occurrenceDate"), + @Index(name = "idx_occurrence_title", columnList = "blockTitleSnapshot") +}) +@Getter +@Setter +public class EventOccurrence { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + /** + * Ссылка на исходный блок шаблона. Nullable и без каскадного удаления — + * если блок в шаблоне удалят, история occurrence'ов должна пережить это + * (title/category уже денормализованы отдельно). + */ + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "block_id") + private ScheduleBlock block; + + @Column(nullable = false) + private String blockTitleSnapshot; + + @Column(nullable = false) + private String category; + + @Column(nullable = false) + private int reminderMinutesBefore = 0; + + @Column(nullable = false) + private LocalDate occurrenceDate; + + @Column(nullable = false) + private LocalDateTime plannedStart; + + @Column(nullable = false) + private LocalDateTime plannedEnd; + + /** Заполняется позже, когда появится механизм отметки факта (голос/кнопка). */ + private LocalDateTime actualStart; + + /** Заполняется позже, когда появится механизм отметки факта (голос/кнопка). */ + private LocalDateTime actualEnd; + + /** UID события в Baikal — для корреляции и последующего удаления при необходимости. */ + @Column(nullable = false) + private String caldavUid; + + @Column(nullable = false) + private LocalDateTime createdAt = LocalDateTime.now(); +} \ No newline at end of file diff --git a/src/main/java/ru/kayashov/schedule/domain/ScheduleBlock.java b/src/main/java/ru/kayashov/schedule/domain/ScheduleBlock.java new file mode 100644 index 0000000..c24e2f1 --- /dev/null +++ b/src/main/java/ru/kayashov/schedule/domain/ScheduleBlock.java @@ -0,0 +1,71 @@ +package ru.kayashov.schedule.domain; + +import jakarta.persistence.CollectionTable; +import jakarta.persistence.Column; +import jakarta.persistence.ElementCollection; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalTime; +import java.util.List; + +@Entity +@Table(name = "schedule_block") +@Getter +@Setter +public class ScheduleBlock { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String title; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private ScheduleType scheduleType; + + /** Минут от момента пробуждения. Заполняется только для RELATIVE. */ + private Integer startOffsetMin; + + /** Длительность блока в минутах. */ + private Integer durationMin; + + /** Точное время начала. Заполняется только для FIXED. */ + private LocalTime fixedTime; + + /** + * Дни недели, когда блок актуален. Заполняется только для FIXED. + * Значения: MON, TUE, WED, THU, FRI, SAT, SUN либо специальное "WEEKDAY". + */ + @ElementCollection + @CollectionTable(name = "schedule_block_days", joinColumns = @JoinColumn(name = "block_id")) + @Column(name = "day_of_week") + private List daysOfWeek; + + /** + * За сколько минут до начала показывать уведомление на устройстве. + * 0 — уведомление ровно в момент начала. null трактуется как 0. + */ + private Integer reminderMinutesBefore = 0; + + /** Категория блока, например REGULAR, SHOPPING_TRIP, WORK — используется для триггеров. */ + @Column(nullable = false) + private String category = "REGULAR"; + + @Column(nullable = false) + private boolean active = true; + + /** Порядок отображения на фронте (не влияет на расчёт времени). */ + @Column(nullable = false) + private Integer sortOrder = 0; +} diff --git a/src/main/java/ru/kayashov/schedule/domain/ScheduleType.java b/src/main/java/ru/kayashov/schedule/domain/ScheduleType.java new file mode 100644 index 0000000..03cdeca --- /dev/null +++ b/src/main/java/ru/kayashov/schedule/domain/ScheduleType.java @@ -0,0 +1,8 @@ +package ru.kayashov.schedule.domain; + +public enum ScheduleType { + /** Смещение от времени пробуждения (например, "завтрак: wake+105 минут") */ + RELATIVE, + /** Жёсткая привязка к конкретному часу и дню недели (например, дейли в 10:15 по будням) */ + FIXED +} diff --git a/src/main/java/ru/kayashov/schedule/domain/ShoppingItem.java b/src/main/java/ru/kayashov/schedule/domain/ShoppingItem.java new file mode 100644 index 0000000..6862032 --- /dev/null +++ b/src/main/java/ru/kayashov/schedule/domain/ShoppingItem.java @@ -0,0 +1,32 @@ +package ru.kayashov.schedule.domain; + +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 lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "shopping_item") +@Getter +@Setter +public class ShoppingItem { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String item; + + @Column(nullable = false) + private LocalDateTime addedAt = LocalDateTime.now(); + + @Column(nullable = false) + private boolean bought = false; +} diff --git a/src/main/java/ru/kayashov/schedule/dto/ErrorResponse.java b/src/main/java/ru/kayashov/schedule/dto/ErrorResponse.java new file mode 100644 index 0000000..5d694a3 --- /dev/null +++ b/src/main/java/ru/kayashov/schedule/dto/ErrorResponse.java @@ -0,0 +1,17 @@ +package ru.kayashov.schedule.dto; + +import java.time.LocalDateTime; + +/** + * Единый формат ответа об ошибке для всех эндпоинтов API. + */ +public record ErrorResponse( + LocalDateTime timestamp, + int status, + String error, + String message +) { + public static ErrorResponse of(int status, String error, String message) { + return new ErrorResponse(LocalDateTime.now(), status, error, message); + } +} \ No newline at end of file diff --git a/src/main/java/ru/kayashov/schedule/dto/ScheduleResponse.java b/src/main/java/ru/kayashov/schedule/dto/ScheduleResponse.java new file mode 100644 index 0000000..6aac7c0 --- /dev/null +++ b/src/main/java/ru/kayashov/schedule/dto/ScheduleResponse.java @@ -0,0 +1,17 @@ +package ru.kayashov.schedule.dto; + +import java.util.List; + +/** + * Ответ /api/wake и /api/shift — что реально было рассчитано и отправлено в Baikal, + * чтобы Shortcuts (или сам пользователь при отладке) сразу видел результат, + * не заходя отдельно в календарь. + */ +public record ScheduleResponse( + int totalEvents, + List events +) { + public static ScheduleResponse of(List events) { + return new ScheduleResponse(events.size(), events); + } +} \ No newline at end of file diff --git a/src/main/java/ru/kayashov/schedule/dto/ScheduledEvent.java b/src/main/java/ru/kayashov/schedule/dto/ScheduledEvent.java new file mode 100644 index 0000000..7806b38 --- /dev/null +++ b/src/main/java/ru/kayashov/schedule/dto/ScheduledEvent.java @@ -0,0 +1,23 @@ +package ru.kayashov.schedule.dto; + +import java.time.LocalDateTime; + +/** + * Результат расчёта: конкретный блок расписания, привязанный к реальной дате и времени. + * + * @param uid детерминированный идентификатор события для CalDAV + * @param title название блока + * @param start время начала + * @param end время окончания (start + duration) + * @param category категория блока (REGULAR, SHOPPING_TRIP, WORK и т.д.) + * @param reminderMinutesBefore за сколько минут до start показывать уведомление (0 — в момент начала) + */ +public record ScheduledEvent( + String uid, + String title, + LocalDateTime start, + LocalDateTime end, + String category, + int reminderMinutesBefore +) { +} \ No newline at end of file diff --git a/src/main/java/ru/kayashov/schedule/dto/ShiftRequest.java b/src/main/java/ru/kayashov/schedule/dto/ShiftRequest.java new file mode 100644 index 0000000..95ebe73 --- /dev/null +++ b/src/main/java/ru/kayashov/schedule/dto/ShiftRequest.java @@ -0,0 +1,14 @@ +package ru.kayashov.schedule.dto; + +import jakarta.validation.constraints.NotNull; + +/** + * Тело запроса POST /api/shift — сдвинуть ещё не начавшиеся сегодняшние блоки. + * + * @param minutes на сколько минут сдвинуть; отрицательное значение сдвигает назад + * (например, если что-то освободилось раньше ожидаемого) + */ +public record ShiftRequest( + @NotNull Integer minutes +) { +} \ No newline at end of file diff --git a/src/main/java/ru/kayashov/schedule/dto/WakeRequest.java b/src/main/java/ru/kayashov/schedule/dto/WakeRequest.java new file mode 100644 index 0000000..14f9879 --- /dev/null +++ b/src/main/java/ru/kayashov/schedule/dto/WakeRequest.java @@ -0,0 +1,23 @@ +package ru.kayashov.schedule.dto; + +import jakarta.validation.constraints.NotNull; + +import java.time.LocalDate; +import java.time.LocalTime; + +/** + * Тело запроса POST /api/wake — сигнал "проснулся" от iOS Shortcuts. + * + * @param wakeTime время пробуждения (обязательно) + * @param date дата, на которую считаем расписание; если не передана — берём сегодняшнюю + * (поле необязательно специально для простоты вызова из Shortcuts, где + * удобнее передавать только время) + */ +public record WakeRequest( + @NotNull LocalTime wakeTime, + LocalDate date +) { + public LocalDate resolvedDate() { + return date != null ? date : LocalDate.now(); + } +} \ No newline at end of file diff --git a/src/main/java/ru/kayashov/schedule/repository/DailyStateRepository.java b/src/main/java/ru/kayashov/schedule/repository/DailyStateRepository.java new file mode 100644 index 0000000..adab875 --- /dev/null +++ b/src/main/java/ru/kayashov/schedule/repository/DailyStateRepository.java @@ -0,0 +1,9 @@ +package ru.kayashov.schedule.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import ru.kayashov.schedule.domain.DailyState; + +import java.time.LocalDate; + +public interface DailyStateRepository extends JpaRepository { +} \ No newline at end of file diff --git a/src/main/java/ru/kayashov/schedule/repository/EventOccurrenceRepository.java b/src/main/java/ru/kayashov/schedule/repository/EventOccurrenceRepository.java new file mode 100644 index 0000000..c098dfd --- /dev/null +++ b/src/main/java/ru/kayashov/schedule/repository/EventOccurrenceRepository.java @@ -0,0 +1,36 @@ +package ru.kayashov.schedule.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import ru.kayashov.schedule.domain.EventOccurrence; +import ru.kayashov.schedule.repository.projection.AverageDuration; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +public interface EventOccurrenceRepository extends JpaRepository { + + List findByOccurrenceDate(LocalDate date); + + /** Блоки на сегодня, которые ещё не начались (для /shift). */ + List findByOccurrenceDateAndPlannedStartAfterAndActualStartIsNull( + LocalDate date, LocalDateTime now); + + /** + * Средняя фактическая длительность (в минутах) по каждому названию блока — + * основа для будущей автокоррекции шаблона на базе реальной статистики. + * Учитываются только occurrence'ы, где факт уже отмечен (actual_start/actual_end заполнены). + */ + @Query(value = """ + SELECT block_title_snapshot AS title, + AVG(EXTRACT(EPOCH FROM (actual_end - actual_start)) / 60) AS avgMinutes, + COUNT(*) AS sampleCount + FROM event_occurrence + WHERE actual_start IS NOT NULL AND actual_end IS NOT NULL + GROUP BY block_title_snapshot + """, nativeQuery = true) + List findAverageDurationsByTitle(); + + java.util.Optional findByOccurrenceDateAndBlockTitleSnapshot(LocalDate date, String title); +} \ No newline at end of file diff --git a/src/main/java/ru/kayashov/schedule/repository/ScheduleBlockRepository.java b/src/main/java/ru/kayashov/schedule/repository/ScheduleBlockRepository.java new file mode 100644 index 0000000..72ab0af --- /dev/null +++ b/src/main/java/ru/kayashov/schedule/repository/ScheduleBlockRepository.java @@ -0,0 +1,10 @@ +package ru.kayashov.schedule.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import ru.kayashov.schedule.domain.ScheduleBlock; + +import java.util.List; + +public interface ScheduleBlockRepository extends JpaRepository { + List findByActiveTrueOrderBySortOrderAsc(); +} diff --git a/src/main/java/ru/kayashov/schedule/repository/ShoppingItemRepository.java b/src/main/java/ru/kayashov/schedule/repository/ShoppingItemRepository.java new file mode 100644 index 0000000..39230c7 --- /dev/null +++ b/src/main/java/ru/kayashov/schedule/repository/ShoppingItemRepository.java @@ -0,0 +1,10 @@ +package ru.kayashov.schedule.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import ru.kayashov.schedule.domain.ShoppingItem; + +import java.util.List; + +public interface ShoppingItemRepository extends JpaRepository { + List findByBoughtFalseOrderByAddedAtAsc(); +} diff --git a/src/main/java/ru/kayashov/schedule/repository/projection/AverageDuration.java b/src/main/java/ru/kayashov/schedule/repository/projection/AverageDuration.java new file mode 100644 index 0000000..09574be --- /dev/null +++ b/src/main/java/ru/kayashov/schedule/repository/projection/AverageDuration.java @@ -0,0 +1,11 @@ +package ru.kayashov.schedule.repository.projection; + +/** + * Проекция для нативного запроса статистики средней длительности блоков. + * Spring Data сам замаппит колонки алиасов (title, avgMinutes, sampleCount) на геттеры. + */ +public interface AverageDuration { + String getTitle(); + Double getAvgMinutes(); + Long getSampleCount(); +} \ No newline at end of file diff --git a/src/main/java/ru/kayashov/schedule/service/CaldavSyncService.java b/src/main/java/ru/kayashov/schedule/service/CaldavSyncService.java new file mode 100644 index 0000000..ae169cf --- /dev/null +++ b/src/main/java/ru/kayashov/schedule/service/CaldavSyncService.java @@ -0,0 +1,193 @@ +package ru.kayashov.schedule.service; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestTemplate; +import ru.kayashov.schedule.config.CaldavProperties; +import ru.kayashov.schedule.dto.ScheduledEvent; + +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.List; + +/** + * Синхронизирует вычисленные события ({@link ScheduledEvent}) с CalDAV-календарём Baikal. + *

+ * iCalendar-текст формируется вручную по RFC 5545, без сторонней библиотеки — для + * нашего простого случая (одно событие, без повторений/вложений) это надёжнее, чем + * подстраиваться под нестабильный между версиями API ical4j. + *

+ * Каждое событие кладётся отдельным HTTP PUT по адресу {baseUrl}/{uid}.ics — SabreDAV + * (на котором построен Baikal) создаёт ресурс, если его ещё нет, и перезаписывает, если + * он уже существует. Поскольку UID детерминирован (см. {@link ScheduleCalculationService}), + * повторный вызов на ту же дату идемпотентно обновляет существующее событие. + */ +@Service +@Slf4j +public class CaldavSyncService { + + /** Часовой пояс, в котором вычисляется расписание. Совпадает с TZ сервисов в кластере. */ + private static final ZoneId ZONE = ZoneId.of("Europe/Samara"); + + /** Формат времени в iCalendar для абсолютного UTC-момента: 20260824T090000Z */ + private static final DateTimeFormatter ICAL_UTC_FORMAT = + DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'"); + + /** DTSTAMP формируется в момент генерации файла, тоже в UTC. */ + private static final DateTimeFormatter ICAL_UTC_STAMP_FORMAT = ICAL_UTC_FORMAT; + + private final RestTemplate caldavRestTemplate; + private final CaldavProperties caldavProperties; + + public CaldavSyncService(RestTemplate caldavRestTemplate, CaldavProperties caldavProperties) { + this.caldavRestTemplate = caldavRestTemplate; + this.caldavProperties = caldavProperties; + } + + /** + * Синхронизирует весь список событий. Продолжает синхронизацию остальных событий, + * даже если одно из них не удалось отправить — ошибка логируется, но не прерывает цикл. + * Если не удалось отправить ни одного события при непустом списке — это, вероятнее всего, + * означает, что Baikal целиком недоступен, и тогда бросаем исключение, чтобы клиент + * получил внятный 502 через GlobalExceptionHandler, а не тихий "успех". + */ + public void syncEvents(List events) { + if (events.isEmpty()) { + return; + } + + int success = 0; + Exception lastError = null; + for (ScheduledEvent event : events) { + try { + syncEvent(event); + success++; + } catch (Exception e) { + lastError = e; + log.error("Не удалось синхронизировать событие '{}' (uid={}): {}", + event.title(), event.uid(), e.getMessage(), e); + } + } + + log.info("Синхронизация завершена: {}/{} событий отправлено в Baikal", success, events.size()); + + if (success == 0) { + throw new RestClientException( + "Не удалось отправить ни одного события в Baikal (" + events.size() + " попыток), " + + "похоже, календарь недоступен", lastError); + } + } + + /** + * Отправляет одно событие в Baikal через CalDAV PUT. + */ + public void syncEvent(ScheduledEvent event) { + String ics = buildIcs(event); + String url = buildEventUrl(event.uid()); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.parseMediaType("text/calendar;charset=UTF-8")); + + HttpEntity request = new HttpEntity<>(ics, headers); + caldavRestTemplate.exchange(url, HttpMethod.PUT, request, Void.class); + + log.debug("Событие '{}' отправлено: {}", event.title(), url); + } + + /** + * Удаляет событие из Baikal — пригодится, когда блок стал неактивным + * или пользователь вручную убрал его из шаблона после того, как он уже был засинкан. + */ + public void deleteEvent(String uid) { + String url = buildEventUrl(uid); + try { + caldavRestTemplate.delete(url); + log.debug("Событие удалено: {}", url); + } catch (Exception e) { + log.warn("Не удалось удалить событие {} (возможно, его и не было): {}", url, e.getMessage()); + } + } + + private String buildEventUrl(String uid) { + String base = caldavProperties.getBaseUrl(); + if (base.endsWith("/")) { + base = base.substring(0, base.length() - 1); + } + return base + "/" + uid + ".ics"; + } + + /** + * Собирает валидный по RFC 5545 .ics-файл с одним VEVENT. + * Строки в iCalendar должны разделяться CRLF ("\r\n"), не просто "\n". + */ + private String buildIcs(ScheduledEvent event) { + String nowUtc = LocalDateTime.now(ZONE).atZone(ZONE) + .withZoneSameInstant(ZoneId.of("UTC")) + .format(ICAL_UTC_STAMP_FORMAT); + + StringBuilder sb = new StringBuilder(); + sb.append("BEGIN:VCALENDAR\r\n"); + sb.append("VERSION:2.0\r\n"); + sb.append("PRODID:-//kayashov//schedule-service//RU\r\n"); + sb.append("CALSCALE:GREGORIAN\r\n"); + sb.append("BEGIN:VEVENT\r\n"); + sb.append("UID:").append(event.uid()).append("\r\n"); + sb.append("DTSTAMP:").append(nowUtc).append("\r\n"); + sb.append("DTSTART:").append(toIcalUtc(event.start())).append("\r\n"); + sb.append("DTEND:").append(toIcalUtc(event.end())).append("\r\n"); + sb.append("SUMMARY:").append(escapeText(event.title())).append("\r\n"); + sb.append(buildValarm(event)); + sb.append("END:VEVENT\r\n"); + sb.append("END:VCALENDAR\r\n"); + return sb.toString(); + } + + /** + * Компонент напоминания по RFC 5545 §3.6.6. TRIGGER задаётся отрицательной + * длительностью относительно начала события: -PT10M — за 10 минут до начала, + * PT0S — ровно в момент начала. + */ + private String buildValarm(ScheduledEvent event) { + String trigger = event.reminderMinutesBefore() <= 0 + ? "PT0S" + : "-PT" + event.reminderMinutesBefore() + "M"; + + StringBuilder sb = new StringBuilder(); + sb.append("BEGIN:VALARM\r\n"); + sb.append("ACTION:DISPLAY\r\n"); + sb.append("DESCRIPTION:").append(escapeText(event.title())).append("\r\n"); + sb.append("TRIGGER:").append(trigger).append("\r\n"); + sb.append("END:VALARM\r\n"); + return sb.toString(); + } + + /** + * Переводит локальное время расписания в абсолютное UTC-время формата iCalendar + * (с суффиксом Z). Такой подход не требует прикладывать компонент VTIMEZONE — + * валидно по RFC 5545 и корректно отображается в Календаре iPhone с автоматическим + * пересчётом в текущий часовой пояс устройства. + */ + private String toIcalUtc(LocalDateTime local) { + return local.atZone(ZONE) + .withZoneSameInstant(ZoneId.of("UTC")) + .format(ICAL_UTC_FORMAT); + } + + /** + * Экранирование спецсимволов в TEXT-значениях по RFC 5545 §3.3.11: + * обратный слэш, запятая, точка с запятой и перевод строки. + */ + private String escapeText(String text) { + return text + .replace("\\", "\\\\") + .replace(";", "\\;") + .replace(",", "\\,") + .replace("\n", "\\n"); + } +} \ No newline at end of file diff --git a/src/main/java/ru/kayashov/schedule/service/DailyScheduleService.java b/src/main/java/ru/kayashov/schedule/service/DailyScheduleService.java new file mode 100644 index 0000000..9970094 --- /dev/null +++ b/src/main/java/ru/kayashov/schedule/service/DailyScheduleService.java @@ -0,0 +1,203 @@ +package ru.kayashov.schedule.service; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import ru.kayashov.schedule.domain.DailyState; +import ru.kayashov.schedule.domain.EventOccurrence; +import ru.kayashov.schedule.dto.ScheduledEvent; +import ru.kayashov.schedule.repository.DailyStateRepository; +import ru.kayashov.schedule.repository.EventOccurrenceRepository; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.Comparator; +import java.util.List; +import java.util.Optional; + +/** + * Оркестратор дня: связывает чистый расчёт расписания ({@link ScheduleCalculationService}), + * персистентность истории ({@link EventOccurrence}/{@link DailyState}) и синхронизацию + * с календарём ({@link CaldavSyncService}). + */ +@Service +@Slf4j +public class DailyScheduleService { + + private final ScheduleCalculationService calculationService; + private final CaldavSyncService caldavSyncService; + private final DailyStateRepository dailyStateRepository; + private final EventOccurrenceRepository eventOccurrenceRepository; + + public DailyScheduleService(ScheduleCalculationService calculationService, + CaldavSyncService caldavSyncService, + DailyStateRepository dailyStateRepository, + EventOccurrenceRepository eventOccurrenceRepository) { + this.calculationService = calculationService; + this.caldavSyncService = caldavSyncService; + this.dailyStateRepository = dailyStateRepository; + this.eventOccurrenceRepository = eventOccurrenceRepository; + } + + /** + * Обрабатывает сигнал "проснулся": фиксирует время пробуждения на сегодня, + * рассчитывает полное расписание дня, сохраняет/обновляет occurrence'ы в БД + * и синхронизирует всё с Baikal. + *

+ * Идемпотентен: повторный вызов на ту же дату обновляет существующие occurrence'ы + * (по дате+названию блока), а не создаёт дубликаты, и повторно синкает те же + * детерминированные UID в Baikal. + */ + @Transactional + public List wake(LocalDate date, LocalTime wakeTime) { + upsertDailyState(date, wakeTime); + + List events = calculationService.calculateForDay(date, wakeTime); + persistOccurrences(date, events); + caldavSyncService.syncEvents(events); + + log.info("Wake обработан: date={}, wakeTime={}, событий={}", date, wakeTime, events.size()); + return events; + } + + /** + * Сдвигает ещё не начавшиеся сегодняшние блоки на заданное число минут. + * Требует, чтобы на эту дату уже был вызван {@link #wake}. + * + * @throws IllegalStateException если на дату ещё не было пробуждения + */ + @Transactional + public List shift(LocalDate date, int minutes) { + DailyState state = dailyStateRepository.findById(date) + .orElseThrow(() -> new IllegalStateException( + "Нет записи о пробуждении на " + date + " — сначала вызовите /api/wake")); + + state.setShiftMinutes(state.getShiftMinutes() + minutes); + state.setUpdatedAt(LocalDateTime.now()); + dailyStateRepository.save(state); + + LocalDateTime now = LocalDateTime.now(); + + List allToday = eventOccurrenceRepository.findByOccurrenceDate(date).stream() + .sorted(Comparator.comparing(EventOccurrence::getPlannedStart)) + .toList(); + + List notStarted = allToday.stream() + .filter(o -> o.getPlannedStart().isAfter(now) && o.getActualStart() == null) + .toList(); + + for (EventOccurrence occurrence : notStarted) { + occurrence.setPlannedStart(occurrence.getPlannedStart().plusMinutes(minutes)); + occurrence.setPlannedEnd(occurrence.getPlannedEnd().plusMinutes(minutes)); + } + eventOccurrenceRepository.saveAll(notStarted); + + closeGap(allToday, notStarted); + + List fullDay = eventOccurrenceRepository.findByOccurrenceDate(date).stream() + .sorted(Comparator.comparing(EventOccurrence::getPlannedStart)) + .map(this::toScheduledEvent) + .toList(); + caldavSyncService.syncEvents(fullDay); + + log.info("Shift обработан: date={}, minutes={}, затронуто блоков={}", date, minutes, notStarted.size()); + return fullDay; + } + + /** + * Принудительная пересинхронизация с Baikal без пересчёта расписания — берёт текущее + * состояние occurrence'ов из БД как есть (полезно после ручных правок в БД, + * или чтобы повторить попытку, если Baikal был недоступен при /wake или /shift). + */ + @Transactional(readOnly = true) + public List resync(LocalDate date) { + List events = eventOccurrenceRepository.findByOccurrenceDate(date).stream() + .map(this::toScheduledEvent) + .toList(); + + if (events.isEmpty()) { + throw new IllegalStateException("Нет occurrence'ов на " + date + " — сначала вызовите /api/wake"); + } + + caldavSyncService.syncEvents(events); + log.info("Ручная пересинхронизация: date={}, событий={}", date, events.size()); + return events; + } + + /** + * Устраняет разрыв (или наложение) в расписании: последний блок непосредственно + * перед первым сдвинутым получает новый plannedEnd, равный новому plannedStart + * этого сдвинутого блока — работает симметрично для положительного и + * отрицательного сдвига. + */ + private void closeGap(List allToday, List shifted) { + if (shifted.isEmpty()) { + return; + } + EventOccurrence firstFuture = shifted.get(0); + + EventOccurrence previous = null; + for (EventOccurrence o : allToday) { + if (o.getId().equals(firstFuture.getId())) { + break; + } + previous = o; + } + + if (previous == null) { + return; + } + + LocalDateTime newBoundary = firstFuture.getPlannedStart(); + // Защита от отрицательной длительности при сильном отрицательном сдвиге + if (newBoundary.isAfter(previous.getPlannedStart())) { + previous.setPlannedEnd(newBoundary); + eventOccurrenceRepository.save(previous); + } + } + + private void upsertDailyState(LocalDate date, LocalTime wakeTime) { + DailyState state = dailyStateRepository.findById(date).orElseGet(DailyState::new); + state.setDate(date); + state.setWakeTime(wakeTime); + if (state.getShiftMinutes() == null) { + state.setShiftMinutes(0); + } + state.setUpdatedAt(LocalDateTime.now()); + dailyStateRepository.save(state); + } + + private void persistOccurrences(LocalDate date, List events) { + for (ScheduledEvent event : events) { + Optional existing = eventOccurrenceRepository + .findByOccurrenceDateAndBlockTitleSnapshot(date, event.title()); + + EventOccurrence occurrence = existing.orElseGet(EventOccurrence::new); + occurrence.setOccurrenceDate(date); + occurrence.setBlockTitleSnapshot(event.title()); + occurrence.setCategory(event.category()); + occurrence.setPlannedStart(event.start()); + occurrence.setReminderMinutesBefore(event.reminderMinutesBefore()); + occurrence.setPlannedEnd(event.end()); + occurrence.setCaldavUid(event.uid()); + // block (ManyToOne) намеренно не проставляем здесь: ScheduledEvent — эфемерный DTO + // без ссылки на исходную сущность; денормализованных title/category достаточно + // для истории и статистики, а связь с block нужна только для future-фич, + // где это можно будет добавить отдельно при необходимости. + + eventOccurrenceRepository.save(occurrence); + } + } + + private ScheduledEvent toScheduledEvent(EventOccurrence occurrence) { + return new ScheduledEvent( + occurrence.getCaldavUid(), + occurrence.getBlockTitleSnapshot(), + occurrence.getPlannedStart(), + occurrence.getPlannedEnd(), + occurrence.getCategory(), + occurrence.getReminderMinutesBefore() + ); + } +} \ No newline at end of file diff --git a/src/main/java/ru/kayashov/schedule/service/ScheduleCalculationService.java b/src/main/java/ru/kayashov/schedule/service/ScheduleCalculationService.java new file mode 100644 index 0000000..faefd50 --- /dev/null +++ b/src/main/java/ru/kayashov/schedule/service/ScheduleCalculationService.java @@ -0,0 +1,117 @@ +package ru.kayashov.schedule.service; + +import org.springframework.stereotype.Service; +import ru.kayashov.schedule.domain.ScheduleBlock; +import ru.kayashov.schedule.dto.ScheduledEvent; +import ru.kayashov.schedule.repository.ScheduleBlockRepository; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.Comparator; +import java.util.HexFormat; +import java.util.List; +import java.util.Set; + +@Service +public class ScheduleCalculationService { + + private static final String WEEKDAY = "WEEKDAY"; + + private static final Set WEEKDAYS = Set.of( + DayOfWeek.MONDAY, DayOfWeek.TUESDAY, DayOfWeek.WEDNESDAY, + DayOfWeek.THURSDAY, DayOfWeek.FRIDAY + ); + + private final ScheduleBlockRepository scheduleBlockRepository; + + public ScheduleCalculationService(ScheduleBlockRepository scheduleBlockRepository) { + this.scheduleBlockRepository = scheduleBlockRepository; + } + + public List calculateForDay(LocalDate date, LocalTime wakeTime) { + List blocks = scheduleBlockRepository.findByActiveTrueOrderBySortOrderAsc(); + return calculateForDay(blocks, date, wakeTime); + } + + public List calculateForDay(List blocks, LocalDate date, LocalTime wakeTime) { + return blocks.stream() + .map(block -> toScheduledEvent(block, date, wakeTime)) + .filter(java.util.Objects::nonNull) + .sorted(Comparator.comparing(ScheduledEvent::start)) + .toList(); + } + + private ScheduledEvent toScheduledEvent(ScheduleBlock block, LocalDate date, LocalTime wakeTime) { + return switch (block.getScheduleType()) { + case RELATIVE -> toRelativeEvent(block, date, wakeTime); + case FIXED -> toFixedEvent(block, date); + }; + } + + private ScheduledEvent toRelativeEvent(ScheduleBlock block, LocalDate date, LocalTime wakeTime) { + if (block.getStartOffsetMin() == null) { + return null; + } + LocalDateTime wakeDateTime = LocalDateTime.of(date, wakeTime); + LocalDateTime start = wakeDateTime.plusMinutes(block.getStartOffsetMin()); + LocalDateTime end = start.plusMinutes(durationOrZero(block)); + return buildEvent(block, date, start, end); + } + + private ScheduledEvent toFixedEvent(ScheduleBlock block, LocalDate date) { + if (block.getFixedTime() == null || !matchesDayOfWeek(block, date.getDayOfWeek())) { + return null; + } + LocalDateTime start = LocalDateTime.of(date, block.getFixedTime()); + LocalDateTime end = start.plusMinutes(durationOrZero(block)); + return buildEvent(block, date, start, end); + } + + private boolean matchesDayOfWeek(ScheduleBlock block, DayOfWeek today) { + List days = block.getDaysOfWeek(); + if (days == null || days.isEmpty()) { + return false; + } + for (String day : days) { + if (WEEKDAY.equalsIgnoreCase(day) && WEEKDAYS.contains(today)) { + return true; + } + if (today.name().startsWith(day.toUpperCase())) { + return true; + } + } + return false; + } + + private int durationOrZero(ScheduleBlock block) { + return block.getDurationMin() == null ? 0 : block.getDurationMin(); + } + + private ScheduledEvent buildEvent(ScheduleBlock block, LocalDate date, LocalDateTime start, LocalDateTime end) { + int reminder = block.getReminderMinutesBefore() == null ? 0 : block.getReminderMinutesBefore(); + return new ScheduledEvent( + deterministicUid(date, block.getTitle()), + block.getTitle(), + start, + end, + block.getCategory(), + reminder + ); + } + + private String deterministicUid(LocalDate date, String title) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + String raw = date + "|" + title; + byte[] hash = digest.digest(raw.getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(hash) + "@schedule-service"; + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException(e); + } + } +} \ No newline at end of file diff --git a/src/main/java/ru/kayashov/schedule/web/GlobalExceptionHandler.java b/src/main/java/ru/kayashov/schedule/web/GlobalExceptionHandler.java new file mode 100644 index 0000000..419b07f --- /dev/null +++ b/src/main/java/ru/kayashov/schedule/web/GlobalExceptionHandler.java @@ -0,0 +1,81 @@ +package ru.kayashov.schedule.web; + +import jakarta.validation.ConstraintViolationException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.client.RestClientException; +import ru.kayashov.schedule.dto.ErrorResponse; + +/** + * Централизованная обработка ошибок для всех REST-контроллеров: клиент (Shortcuts, + * фронт, навык Алисы) всегда получает понятный JSON со статусом и описанием причины, + * а не голый 500 без деталей. + */ +@RestControllerAdvice +@Slf4j +public class GlobalExceptionHandler { + + /** + * Ожидаемые бизнес-ошибки уровня "клиент сделал что-то не по порядку" + * (например, /shift без предварительного /wake) — 400, без стектрейса в теле ответа. + */ + @ExceptionHandler(IllegalStateException.class) + public ResponseEntity handleIllegalState(IllegalStateException e) { + log.warn("Некорректное состояние: {}", e.getMessage()); + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body(ErrorResponse.of(400, "Bad Request", e.getMessage())); + } + + /** + * Ошибки валидации тела запроса (@Valid на WakeRequest/ShiftRequest) — + * например, отсутствует обязательное поле wakeTime. + */ + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleValidation(MethodArgumentNotValidException e) { + String message = e.getBindingResult().getFieldErrors().stream() + .map(f -> f.getField() + ": " + f.getDefaultMessage()) + .findFirst() + .orElse("Некорректные данные запроса"); + log.warn("Ошибка валидации: {}", message); + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body(ErrorResponse.of(400, "Validation Error", message)); + } + + @ExceptionHandler(ConstraintViolationException.class) + public ResponseEntity handleConstraintViolation(ConstraintViolationException e) { + log.warn("Нарушение ограничений: {}", e.getMessage()); + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body(ErrorResponse.of(400, "Validation Error", e.getMessage())); + } + + /** + * Baikal недоступен, таймаут, неверные креды и т.п. — отдаём 502 Bad Gateway, + * подчёркивая, что проблема во внешней зависимости, а не в самом сервисе. + */ + @ExceptionHandler(RestClientException.class) + public ResponseEntity handleCaldavFailure(RestClientException e) { + log.error("Ошибка обращения к Baikal (CalDAV): {}", e.getMessage(), e); + return ResponseEntity + .status(HttpStatus.BAD_GATEWAY) + .body(ErrorResponse.of(502, "Bad Gateway", "Не удалось связаться с календарём: " + e.getMessage())); + } + + /** + * Всё остальное непредвиденное — не раскрываем внутренние детали (стектрейс, имена + * классов) клиенту, только логируем полностью на сервере. + */ + @ExceptionHandler(Exception.class) + public ResponseEntity handleUnexpected(Exception e) { + log.error("Непредвиденная ошибка: {}", e.getMessage(), e); + return ResponseEntity + .status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(ErrorResponse.of(500, "Internal Server Error", "Внутренняя ошибка сервиса")); + } +} \ No newline at end of file diff --git a/src/main/java/ru/kayashov/schedule/web/WakeController.java b/src/main/java/ru/kayashov/schedule/web/WakeController.java new file mode 100644 index 0000000..e9387c7 --- /dev/null +++ b/src/main/java/ru/kayashov/schedule/web/WakeController.java @@ -0,0 +1,49 @@ +package ru.kayashov.schedule.web; + +import jakarta.validation.Valid; +import lombok.extern.slf4j.Slf4j; +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 ru.kayashov.schedule.dto.ScheduleResponse; +import ru.kayashov.schedule.dto.ShiftRequest; +import ru.kayashov.schedule.dto.WakeRequest; +import ru.kayashov.schedule.service.DailyScheduleService; + +import java.time.LocalDate; + +/** + * Точка входа для iOS Shortcuts: сигнал пробуждения и сдвиг оставшегося дня. + */ +@RestController +@RequestMapping("/api") +@Slf4j +public class WakeController { + + private final DailyScheduleService dailyScheduleService; + + public WakeController(DailyScheduleService dailyScheduleService) { + this.dailyScheduleService = dailyScheduleService; + } + + @PostMapping("/wake") + public ScheduleResponse wake(@Valid @RequestBody WakeRequest request) { + LocalDate date = request.resolvedDate(); + var events = dailyScheduleService.wake(date, request.wakeTime()); + return ScheduleResponse.of(events); + } + + @PostMapping("/shift") + public ScheduleResponse shift(@Valid @RequestBody ShiftRequest request) { + var events = dailyScheduleService.shift(LocalDate.now(), request.minutes()); + return ScheduleResponse.of(events); + } + + @PostMapping("/sync") + public ScheduleResponse resync(@RequestParam(required = false) java.time.LocalDate date) { + var events = dailyScheduleService.resync(date != null ? date : LocalDate.now()); + return ScheduleResponse.of(events); + } +} \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..6b0c930 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,30 @@ +spring: + application: + name: schedule-service + datasource: + url: jdbc:postgresql://localhost:5433/baykal + username: baykal + password: kayash73 + jpa: + hibernate: + ddl-auto: update + show-sql: true + properties: + hibernate: + format_sql: false + +server: + port: 8080 + +caldav: + base-url: http://192.168.1.100:30019/dav.php/calendars/sergey/main + username: sergey + password: kayash73 + +# Для локального запуска: +# 1. kubectl port-forward -n media svc/postgres-postgresql 5432:5432 +# 2. Создать БД и пользователя в Postgres, если ещё не созданы: +# CREATE DATABASE schedule; +# CREATE USER schedule_user WITH PASSWORD 'changeme'; +# GRANT ALL PRIVILEGES ON DATABASE schedule TO schedule_user; +# 3. Заменить пароль caldav.password на реальный пароль пользователя Baikal diff --git a/src/main/resources/db-notes.md b/src/main/resources/db-notes.md new file mode 100644 index 0000000..5c32dd8 --- /dev/null +++ b/src/main/resources/db-notes.md @@ -0,0 +1,10 @@ +# Схема БД (актуально на текущий момент) + +Таблицы создаются автоматически через `hibernate.ddl-auto=update`. + +- `schedule_block` — шаблон дня (RELATIVE/FIXED блоки) +- `schedule_block_days` — @ElementCollection для daysOfWeek +- `shopping_item` — список покупок +- `daily_state` — состояние текущего дня (wake_time, накопленный shift) +- `event_occurrence` — история конкретных экземпляров блоков по дням, + источник будущей статистики (plannedStart/End vs actualStart/End) \ No newline at end of file