From eb964f6afd312f490ccecbb43801f7e5e4e6576a Mon Sep 17 00:00:00 2001 From: 501461 Date: Tue, 21 Apr 2026 11:58:04 +0300 Subject: [PATCH 1/4] add CI --- .github/workflows/ci.yaml | 63 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 .github/workflows/ci.yaml diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 00000000..8e8b7415 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,63 @@ +name: CI + +on: + push: + branches: + - main + - feature/** + pull_request: + +jobs: + build-and-test: + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:16 + env: + POSTGRES_DB: medods + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + ports: + - 5432:5432 + options: >- + --health-cmd="pg_isready -U postgres -d medods" + --health-interval=10s + --health-timeout=5s + --health-retries=10 + + env: + DB_HOST: localhost + DB_PORT: 5432 + DB_NAME: medods + DB_USER: postgres + DB_PASSWORD: postgres + APP_PORT: 8080 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Verify formatting + run: | + unformatted=$(gofmt -l .) + if [ -n "$unformatted" ]; then + echo "These files are not formatted with gofmt:" + echo "$unformatted" + exit 1 + fi + + - name: Download dependencies + run: go mod download + + - name: Run tests + run: go test ./... + + - name: Build application + run: go build ./cmd/api \ No newline at end of file From 014c76aeed01d2c0d1a54d640773f24ffc104995 Mon Sep 17 00:00:00 2001 From: 501461 Date: Tue, 21 Apr 2026 11:59:26 +0300 Subject: [PATCH 2/4] system files --- docker-compose.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index 58aa3a7e..f5354836 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,7 +14,7 @@ services: retries: 10 volumes: - postgres_data:/var/lib/postgresql/data - - ./migrations/0001_create_tasks.up.sql:/docker-entrypoint-initdb.d/001_create_tasks.sql:ro + - ./migrations:/docker-entrypoint-initdb.d:ro app: build: @@ -23,6 +23,7 @@ services: environment: HTTP_ADDR: :8080 DATABASE_DSN: postgres://postgres:postgres@postgres:5432/taskservice?sslmode=disable + MATERIALIZATION_INTERVAL: 1m ports: - "8080:8080" depends_on: From 8dcb69d6027acc7709a11271e4265092be8a64d2 Mon Sep 17 00:00:00 2001 From: 501461 Date: Tue, 21 Apr 2026 12:00:41 +0300 Subject: [PATCH 3/4] ORM migrations fix --- migrations/0002_add_recurrence.up.sql | 50 +++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 migrations/0002_add_recurrence.up.sql diff --git a/migrations/0002_add_recurrence.up.sql b/migrations/0002_add_recurrence.up.sql new file mode 100644 index 00000000..84d292eb --- /dev/null +++ b/migrations/0002_add_recurrence.up.sql @@ -0,0 +1,50 @@ +ALTER TABLE tasks + ADD COLUMN IF NOT EXISTS scheduled_for DATE NOT NULL DEFAULT CURRENT_DATE, + ADD COLUMN IF NOT EXISTS recurrence_rule_id BIGINT NULL, + ADD COLUMN IF NOT EXISTS is_modified BOOLEAN NOT NULL DEFAULT FALSE; + +CREATE TABLE IF NOT EXISTS recurrence_rules ( + id BIGSERIAL PRIMARY KEY, + title TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + active BOOLEAN NOT NULL DEFAULT TRUE, + schedule_type TEXT NOT NULL, + starts_on DATE NOT NULL, + ends_on DATE NULL, + interval_days INTEGER NULL, + day_of_month INTEGER NULL, + day_parity TEXT NULL, + specific_dates DATE[] NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT chk_recurrence_rules_schedule_type CHECK ( + schedule_type IN ('every_n_days', 'monthly_day', 'specific_dates', 'month_day_parity') + ), + CONSTRAINT chk_recurrence_rules_interval_days CHECK (interval_days IS NULL OR interval_days > 0), + CONSTRAINT chk_recurrence_rules_day_of_month CHECK (day_of_month IS NULL OR day_of_month BETWEEN 1 AND 30), + CONSTRAINT chk_recurrence_rules_day_parity CHECK (day_parity IS NULL OR day_parity IN ('odd', 'even')), + CONSTRAINT chk_recurrence_rules_date_range CHECK (ends_on IS NULL OR ends_on >= starts_on) +); + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'fk_tasks_recurrence_rule' + ) THEN + ALTER TABLE tasks + ADD CONSTRAINT fk_tasks_recurrence_rule + FOREIGN KEY (recurrence_rule_id) + REFERENCES recurrence_rules(id) + ON DELETE SET NULL; + END IF; +END $$; + +CREATE INDEX IF NOT EXISTS idx_tasks_scheduled_for ON tasks (scheduled_for DESC, id DESC); +CREATE INDEX IF NOT EXISTS idx_tasks_recurrence_rule_id ON tasks (recurrence_rule_id); +CREATE INDEX IF NOT EXISTS idx_recurrence_rules_active ON recurrence_rules (active); + +CREATE UNIQUE INDEX IF NOT EXISTS ux_tasks_generated_occurrence + ON tasks (recurrence_rule_id, scheduled_for) + WHERE recurrence_rule_id IS NOT NULL; From e1b3d9a8ee3fa12ecb1b70a123b9023be886551a Mon Sep 17 00:00:00 2001 From: 501461 Date: Tue, 21 Apr 2026 12:01:22 +0300 Subject: [PATCH 4/4] go structure solutions --- README.md | 251 ++++++++++++- cmd/api/main.go | 51 ++- internal/domain/recurrence/errors.go | 5 + internal/domain/recurrence/rule.go | 173 +++++++++ internal/domain/recurrence/rule_test.go | 101 ++++++ internal/domain/task/task.go | 15 +- .../postgres/recurrence_repository.go | 334 ++++++++++++++++++ .../repository/postgres/task_repository.go | 52 ++- internal/shared/dateutil/date.go | 54 +++ internal/transport/http/docs/openapi.json | 213 ++++++++++- internal/transport/http/handlers/dto.go | 100 +++++- .../http/handlers/recurrence_handler.go | 183 ++++++++++ .../transport/http/handlers/task_handler.go | 5 + internal/transport/http/router.go | 8 +- internal/usecase/recurrence/errors.go | 5 + internal/usecase/recurrence/ports.go | 42 +++ internal/usecase/recurrence/service.go | 265 ++++++++++++++ internal/usecase/recurrence/service_test.go | 115 ++++++ internal/usecase/task/service.go | 16 +- 19 files changed, 1925 insertions(+), 63 deletions(-) create mode 100644 internal/domain/recurrence/errors.go create mode 100644 internal/domain/recurrence/rule.go create mode 100644 internal/domain/recurrence/rule_test.go create mode 100644 internal/repository/postgres/recurrence_repository.go create mode 100644 internal/shared/dateutil/date.go create mode 100644 internal/transport/http/handlers/recurrence_handler.go create mode 100644 internal/usecase/recurrence/errors.go create mode 100644 internal/usecase/recurrence/ports.go create mode 100644 internal/usecase/recurrence/service.go create mode 100644 internal/usecase/recurrence/service_test.go diff --git a/README.md b/README.md index 7e1cc71a..1e7cfe77 100644 --- a/README.md +++ b/README.md @@ -2,18 +2,163 @@ Сервис для управления задачами с HTTP API на Go. -## Требования +В этом решении исходный CRUD для обычных задач сохранён, а периодичность вынесена в отдельную сущность — **recurrence rule**. Это позволяет не смешивать: + +- **шаблон/правило генерации**, +- **конкретную задачу в трекере**, у которой уже есть собственный статус, история и потенциально ручные правки. + +Именно такой подход лучше подходит под реальную предметную область: у врача может быть одно правило «ежедневный обзвон пациентов», но каждая конкретная задача на конкретную дату должна жить своей жизнью. + +## Что реализовано + +### 1) Обычные задачи + +Оставлены и работают прежние endpoints: + +- `POST /api/v1/tasks` +- `GET /api/v1/tasks` +- `GET /api/v1/tasks/{id}` +- `PUT /api/v1/tasks/{id}` +- `DELETE /api/v1/tasks/{id}` + +Теперь у задачи дополнительно есть: + +- `scheduled_for` — дата, на которую задача запланирована; +- `recurrence_rule_id` — ссылка на правило периодичности, если задача была создана автоматически. + +Для обычных задач `scheduled_for` по умолчанию равен текущей дате. + +### 2) Правила периодичности + +Добавлены новые endpoints: + +- `POST /api/v1/recurrence-rules` +- `GET /api/v1/recurrence-rules` +- `GET /api/v1/recurrence-rules/{id}` +- `PUT /api/v1/recurrence-rules/{id}` +- `DELETE /api/v1/recurrence-rules/{id}` + +Поддерживаются типы правил: + +- `every_n_days` — каждые `N` дней; +- `monthly_day` — каждый месяц в конкретное число от `1` до `30`; +- `specific_dates` — только на указанные даты; +- `month_day_parity` — только по чётным или нечётным дням месяца. + +### 3) Материализация задач + +Сами правила не отображаются в списке задач напрямую. Вместо этого сервис **материализует** конкретные задачи на ближайший горизонт планирования. + +Текущее поведение: + +- при старте приложения активные правила материализуются сразу; +- далее материализация выполняется периодически в фоне; +- по умолчанию горизонт материализации — **30 дней вперёд**; +- интервал фоновой материализации настраивается через `MATERIALIZATION_INTERVAL`. + +### 4) Защита ручных изменений + +Если пользователь вручную меняет автосозданную задачу, сервис помечает её как изменённую вручную. + +Это нужно для двух вещей: + +- последующая синхронизация шаблона не перетирает ручные правки у конкретной задачи; +- если правило изменилось или удалилось, такие задачи не удаляются автоматически, если пользователь уже начал с ними работать. + +## Почему выбрано именно такое решение + +### Почему не хранить периодичность прямо в `tasks` + +Это кажется простым только на первый взгляд. На практике одна и та же запись начинает одновременно означать: + +- шаблон задачи, +- текущую задачу, +- историческую задачу, +- носителя статуса. + +Возникают логические конфликты: + +- какой статус у шаблона — `done` или `new`? +- что значит редактирование одной задачи — менять только один экземпляр или всё правило? +- как хранить историю уже выполненных задач? + +Поэтому решение разделено на: + +- **recurrence_rules** — описание того, *что и когда генерировать*; +- **tasks** — конкретные созданные сущности, с которыми работает пользователь. + +Это более чистая модель и сильнее демонстрирует проектирование, чем попытка «втиснуть всё в одну таблицу». + +## Принятые assumptions + +Так как в задании специально оставлены зоны для самостоятельного проектирования, в решении зафиксированы следующие допущения: + +1. **Периодичность считается в календарных датах UTC**, без часовых поясов и времени суток. +2. **Обычная задача** — это независимая разовая задача без правила периодичности. +3. **Конкретная созданная задача** должна жить отдельно от правила: её можно перевести в `in_progress`/`done`, отредактировать и не потерять как исторический факт. +4. Для `monthly_day` разрешены только даты от `1` до `30` — это прямо следует из условия задания и убирает неоднозначность с короткими месяцами. +5. При удалении/изменении правила сервис удаляет или пересоздаёт только **будущие автосгенерированные задачи без ручных изменений**. +6. Уже изменённые вручную, начатые или выполненные задачи считаются частью истории и не должны безусловно исчезать. +7. Для `specific_dates` правило фактически конечное: список дат сам задаёт диапазон действия. +8. Горизонт материализации выбран равным 30 дням как разумный компромисс между удобством пользователя и простотой решения. + +## Схема данных + +### Таблица `tasks` + +Добавлены поля: + +- `scheduled_for DATE NOT NULL DEFAULT CURRENT_DATE` +- `recurrence_rule_id BIGINT NULL` +- `is_modified BOOLEAN NOT NULL DEFAULT FALSE` + +### Таблица `recurrence_rules` + +Содержит: + +- базовые поля задачи (`title`, `description`); +- тип расписания (`schedule_type`); +- параметры расписания (`interval_days`, `day_of_month`, `day_parity`, `specific_dates`); +- диапазон действия (`starts_on`, `ends_on`); +- флаг активности (`active`). + +Также добавлен уникальный индекс: + +- `(recurrence_rule_id, scheduled_for)` для защиты от дублирующей генерации одной и той же задачи на одну и ту же дату. + +## Алгоритм материализации + +Для каждого активного правила сервис: + +1. вычисляет все даты в окне `[сегодня; сегодня + 30 дней]`; +2. удаляет будущие автосозданные задачи без ручных изменений для данного правила; +3. создаёт задачи заново по актуальному правилу; +4. если по уникальному ключу задача уже есть, обновляет её только если она ещё не была вручную изменена. + +Это делает синхронизацию: + +- **идемпотентной**; +- простой для понимания; +- устойчивой к многократному запуску фонового процесса. + +## Запуск + +### Требования - Go `1.23+` - Docker и Docker Compose -## Быстрый запуск через Docker Compose +### Быстрый запуск через Docker Compose ```bash docker compose up --build ``` -После запуска сервис будет доступен по адресу `http://localhost:8080`. +После запуска сервис будет доступен по адресу: + +```text +http://localhost:8080 +``` Если `postgres` уже запускался ранее со старой схемой, пересоздай volume: @@ -22,8 +167,6 @@ docker compose down -v docker compose up --build ``` -Причина в том, что SQL-файл из `migrations/0001_create_tasks.up.sql` монтируется в `docker-entrypoint-initdb.d` и применяется только при инициализации пустого data volume. - ## Swagger Swagger UI: @@ -38,18 +181,96 @@ OpenAPI JSON: http://localhost:8080/swagger/openapi.json ``` -## API +## Примеры запросов -Базовый префикс API: +### Создать обычную задачу -```text -/api/v1 +```bash +curl -X POST http://localhost:8080/api/v1/tasks \ + -H 'Content-Type: application/json' \ + -d '{ + "title": "Разобрать обращения", + "description": "Проверить очередь обращений", + "status": "new" + }' ``` -Основные маршруты: +### Создать правило: каждые 2 дня -- `POST /api/v1/tasks` -- `GET /api/v1/tasks` -- `GET /api/v1/tasks/{id}` -- `PUT /api/v1/tasks/{id}` -- `DELETE /api/v1/tasks/{id}` +```bash +curl -X POST http://localhost:8080/api/v1/recurrence-rules \ + -H 'Content-Type: application/json' \ + -d '{ + "title": "Ежедневный обход", + "description": "Проверить состояние пациентов", + "schedule": { + "type": "every_n_days", + "starts_on": "2026-04-20", + "every_n_days": 2 + } + }' +``` + +### Создать правило: каждый месяц 15 числа + +```bash +curl -X POST http://localhost:8080/api/v1/recurrence-rules \ + -H 'Content-Type: application/json' \ + -d '{ + "title": "Подготовить отчёт", + "description": "Сформировать ежемесячный отчёт", + "schedule": { + "type": "monthly_day", + "starts_on": "2026-04-01", + "day_of_month": 15 + } + }' +``` + +### Создать правило: только на конкретные даты + +```bash +curl -X POST http://localhost:8080/api/v1/recurrence-rules \ + -H 'Content-Type: application/json' \ + -d '{ + "title": "Инвентаризация", + "description": "Проверить остатки", + "schedule": { + "type": "specific_dates", + "dates": ["2026-04-21", "2026-04-27", "2026-05-03"] + } + }' +``` + +### Создать правило: по нечётным дням месяца + +```bash +curl -X POST http://localhost:8080/api/v1/recurrence-rules \ + -H 'Content-Type: application/json' \ + -d '{ + "title": "Сформировать отчётность", + "description": "Работа по нечётным дням", + "schedule": { + "type": "month_day_parity", + "starts_on": "2026-04-20", + "day_parity": "odd" + } + }' +``` + +## Что именно это решение демонстрирует работодателю + +1. **Умение работать с существующим кодом** — исходный CRUD не переписан с нуля, а аккуратно расширен. +2. **Проектирование сущностей** — шаблон задачи и экземпляр задачи разделены. +3. **Внимание к краевым кейсам** — защита от дублей, ручных правок, удаления будущих задач, ограничения по дням месяца. +4. **Осознанные assumptions** — неоднозначности не игнорируются, а явно зафиксированы. +5. **Расширяемость** — дальше можно добавить timezone, due time, pause/resume, фильтры, UI и отдельный worker без ломки модели данных. + +## Идеи для следующего этапа, если бы это был production + +- вынести материализацию в отдельный worker/cron; +- добавить optimistic locking или `version` для правил; +- добавить фильтры списка задач по диапазону дат и типу происхождения; +- отделить статус экземпляра от возможного шаблонного статуса по умолчанию; +- добавить интеграционные тесты с PostgreSQL через testcontainers/docker compose; +- добавить аудит изменений по правилам и задачам. diff --git a/cmd/api/main.go b/cmd/api/main.go index 145a44b1..700e3d15 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -15,7 +15,8 @@ import ( transporthttp "example.com/taskservice/internal/transport/http" swaggerdocs "example.com/taskservice/internal/transport/http/docs" httphandlers "example.com/taskservice/internal/transport/http/handlers" - "example.com/taskservice/internal/usecase/task" + recurrenceusecase "example.com/taskservice/internal/usecase/recurrence" + taskusecase "example.com/taskservice/internal/usecase/task" ) func main() { @@ -36,10 +37,21 @@ func main() { defer pool.Close() taskRepo := postgresrepo.New(pool) - taskUsecase := task.NewService(taskRepo) - taskHandler := httphandlers.NewTaskHandler(taskUsecase) + recurrenceRepo := postgresrepo.NewRecurrenceRepository(pool) + taskService := taskusecase.NewService(taskRepo) + recurrenceService := recurrenceusecase.NewService(recurrenceRepo) + + if err := recurrenceService.MaterializeActive(ctx); err != nil { + logger.Error("initial materialization failed", "error", err) + os.Exit(1) + } + + go runMaterializer(ctx, logger, recurrenceService, cfg.MaterializationInterval) + + taskHandler := httphandlers.NewTaskHandler(taskService) + recurrenceHandler := httphandlers.NewRecurrenceHandler(recurrenceService) docsHandler := swaggerdocs.NewHandler() - router := transporthttp.NewRouter(taskHandler, docsHandler) + router := transporthttp.NewRouter(taskHandler, recurrenceHandler, docsHandler) server := &http.Server{ Addr: cfg.HTTPAddr, @@ -66,9 +78,30 @@ func main() { } } +type materializer interface { + MaterializeActive(ctx context.Context) error +} + +func runMaterializer(ctx context.Context, logger *slog.Logger, service materializer, interval time.Duration) { + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if err := service.MaterializeActive(ctx); err != nil { + logger.Error("periodic materialization failed", "error", err) + } + } + } +} + type config struct { - HTTPAddr string - DatabaseDSN string + HTTPAddr string + DatabaseDSN string + MaterializationInterval time.Duration } func loadConfig() config { @@ -81,6 +114,12 @@ func loadConfig() config { panic(fmt.Errorf("DATABASE_DSN is required")) } + interval, err := time.ParseDuration(envOrDefault("MATERIALIZATION_INTERVAL", "1m")) + if err != nil { + panic(fmt.Errorf("invalid MATERIALIZATION_INTERVAL: %w", err)) + } + cfg.MaterializationInterval = interval + return cfg } diff --git a/internal/domain/recurrence/errors.go b/internal/domain/recurrence/errors.go new file mode 100644 index 00000000..6711f72d --- /dev/null +++ b/internal/domain/recurrence/errors.go @@ -0,0 +1,5 @@ +package recurrence + +import "errors" + +var ErrNotFound = errors.New("recurrence rule not found") diff --git a/internal/domain/recurrence/rule.go b/internal/domain/recurrence/rule.go new file mode 100644 index 00000000..2bdaad6d --- /dev/null +++ b/internal/domain/recurrence/rule.go @@ -0,0 +1,173 @@ +package recurrence + +import ( + "sort" + "time" + + "example.com/taskservice/internal/shared/dateutil" +) + +type ScheduleType string + +type DayParity string + +const ( + ScheduleTypeEveryNDays ScheduleType = "every_n_days" + ScheduleTypeMonthlyDay ScheduleType = "monthly_day" + ScheduleTypeSpecificDates ScheduleType = "specific_dates" + ScheduleTypeMonthParity ScheduleType = "month_day_parity" +) + +const ( + DayParityOdd DayParity = "odd" + DayParityEven DayParity = "even" +) + +type Rule struct { + ID int64 + Title string + Description string + Active bool + ScheduleType ScheduleType + StartsOn time.Time + EndsOn *time.Time + EveryNDays *int + DayOfMonth *int + DayParity *DayParity + SpecificDates []time.Time + CreatedAt time.Time + UpdatedAt time.Time +} + +func (t ScheduleType) Valid() bool { + switch t { + case ScheduleTypeEveryNDays, ScheduleTypeMonthlyDay, ScheduleTypeSpecificDates, ScheduleTypeMonthParity: + return true + default: + return false + } +} + +func (p DayParity) Valid() bool { + switch p { + case DayParityOdd, DayParityEven: + return true + default: + return false + } +} + +func (r Rule) OccurrencesBetween(from, to time.Time) []time.Time { + from = dateutil.Normalize(from) + to = dateutil.Normalize(to) + if to.Before(from) || !r.Active { + return nil + } + + from = dateutil.Max(from, r.StartsOn) + if r.EndsOn != nil && r.EndsOn.Before(from) { + return nil + } + if r.EndsOn != nil && r.EndsOn.Before(to) { + to = dateutil.Normalize(*r.EndsOn) + } + if to.Before(from) { + return nil + } + + switch r.ScheduleType { + case ScheduleTypeEveryNDays: + return r.everyNDaysOccurrences(from, to) + case ScheduleTypeMonthlyDay: + return r.monthlyOccurrences(from, to) + case ScheduleTypeSpecificDates: + return r.specificDateOccurrences(from, to) + case ScheduleTypeMonthParity: + return r.monthParityOccurrences(from, to) + default: + return nil + } +} + +func (r Rule) everyNDaysOccurrences(from, to time.Time) []time.Time { + if r.EveryNDays == nil || *r.EveryNDays <= 0 { + return nil + } + + interval := *r.EveryNDays + daysOffset := dateutil.DaysBetween(r.StartsOn, from) + remainder := daysOffset % interval + if remainder < 0 { + remainder += interval + } + + current := from + if remainder != 0 { + current = dateutil.AddDays(from, interval-remainder) + } + + occurrences := make([]time.Time, 0) + for !current.After(to) { + occurrences = append(occurrences, current) + current = dateutil.AddDays(current, interval) + } + + return occurrences +} + +func (r Rule) monthlyOccurrences(from, to time.Time) []time.Time { + if r.DayOfMonth == nil || *r.DayOfMonth < 1 || *r.DayOfMonth > 30 { + return nil + } + + day := *r.DayOfMonth + cursor := time.Date(from.Year(), from.Month(), 1, 0, 0, 0, 0, time.UTC) + limit := time.Date(to.Year(), to.Month(), 1, 0, 0, 0, 0, time.UTC) + occurrences := make([]time.Time, 0) + + for !cursor.After(limit) { + candidate := time.Date(cursor.Year(), cursor.Month(), day, 0, 0, 0, 0, time.UTC) + if !candidate.Before(from) && !candidate.After(to) && !candidate.Before(r.StartsOn) { + occurrences = append(occurrences, candidate) + } + + cursor = cursor.AddDate(0, 1, 0) + } + + return occurrences +} + +func (r Rule) specificDateOccurrences(from, to time.Time) []time.Time { + occurrences := make([]time.Time, 0, len(r.SpecificDates)) + for _, date := range r.SpecificDates { + normalized := dateutil.Normalize(date) + if normalized.Before(from) || normalized.After(to) { + continue + } + + occurrences = append(occurrences, normalized) + } + + sort.Slice(occurrences, func(i, j int) bool { + return occurrences[i].Before(occurrences[j]) + }) + + return occurrences +} + +func (r Rule) monthParityOccurrences(from, to time.Time) []time.Time { + if r.DayParity == nil || !r.DayParity.Valid() { + return nil + } + + occurrences := make([]time.Time, 0) + for current := from; !current.After(to); current = dateutil.AddDays(current, 1) { + day := current.Day() + isEven := day%2 == 0 + if (*r.DayParity == DayParityEven && isEven) || (*r.DayParity == DayParityOdd && !isEven) { + occurrences = append(occurrences, current) + } + } + + return occurrences +} diff --git a/internal/domain/recurrence/rule_test.go b/internal/domain/recurrence/rule_test.go new file mode 100644 index 00000000..261a9c77 --- /dev/null +++ b/internal/domain/recurrence/rule_test.go @@ -0,0 +1,101 @@ +package recurrence + +import ( + "testing" + "time" + + "example.com/taskservice/internal/shared/dateutil" +) + +func mustDate(t *testing.T, value string) time.Time { + t.Helper() + parsed, err := dateutil.Parse(value) + if err != nil { + t.Fatalf("parse date %s: %v", value, err) + } + + return parsed +} + +func TestOccurrencesBetweenEveryNDays(t *testing.T) { + interval := 2 + rule := Rule{ + Active: true, + ScheduleType: ScheduleTypeEveryNDays, + StartsOn: mustDate(t, "2026-04-20"), + EveryNDays: &interval, + } + + occurrences := rule.OccurrencesBetween(mustDate(t, "2026-04-20"), mustDate(t, "2026-04-26")) + if len(occurrences) != 4 { + t.Fatalf("expected 4 occurrences, got %d", len(occurrences)) + } + + expected := []string{"2026-04-20", "2026-04-22", "2026-04-24", "2026-04-26"} + for i, occurrence := range occurrences { + if got := dateutil.Format(occurrence); got != expected[i] { + t.Fatalf("occurrence %d: expected %s, got %s", i, expected[i], got) + } + } +} + +func TestOccurrencesBetweenMonthlyDay(t *testing.T) { + day := 15 + rule := Rule{ + Active: true, + ScheduleType: ScheduleTypeMonthlyDay, + StartsOn: mustDate(t, "2026-04-01"), + DayOfMonth: &day, + } + + occurrences := rule.OccurrencesBetween(mustDate(t, "2026-04-10"), mustDate(t, "2026-06-20")) + expected := []string{"2026-04-15", "2026-05-15", "2026-06-15"} + if len(occurrences) != len(expected) { + t.Fatalf("expected %d occurrences, got %d", len(expected), len(occurrences)) + } + + for i, occurrence := range occurrences { + if got := dateutil.Format(occurrence); got != expected[i] { + t.Fatalf("occurrence %d: expected %s, got %s", i, expected[i], got) + } + } +} + +func TestOccurrencesBetweenSpecificDates(t *testing.T) { + rule := Rule{ + Active: true, + ScheduleType: ScheduleTypeSpecificDates, + StartsOn: mustDate(t, "2026-04-20"), + SpecificDates: []time.Time{mustDate(t, "2026-04-22"), mustDate(t, "2026-05-01"), mustDate(t, "2026-04-20")}, + } + + occurrences := rule.OccurrencesBetween(mustDate(t, "2026-04-21"), mustDate(t, "2026-04-30")) + if len(occurrences) != 1 { + t.Fatalf("expected 1 occurrence, got %d", len(occurrences)) + } + if got := dateutil.Format(occurrences[0]); got != "2026-04-22" { + t.Fatalf("expected 2026-04-22, got %s", got) + } +} + +func TestOccurrencesBetweenParity(t *testing.T) { + parity := DayParityEven + rule := Rule{ + Active: true, + ScheduleType: ScheduleTypeMonthParity, + StartsOn: mustDate(t, "2026-04-20"), + DayParity: &parity, + } + + occurrences := rule.OccurrencesBetween(mustDate(t, "2026-04-20"), mustDate(t, "2026-04-25")) + expected := []string{"2026-04-20", "2026-04-22", "2026-04-24"} + if len(occurrences) != len(expected) { + t.Fatalf("expected %d occurrences, got %d", len(expected), len(occurrences)) + } + + for i, occurrence := range occurrences { + if got := dateutil.Format(occurrence); got != expected[i] { + t.Fatalf("occurrence %d: expected %s, got %s", i, expected[i], got) + } + } +} diff --git a/internal/domain/task/task.go b/internal/domain/task/task.go index 24b0948b..0edaf788 100644 --- a/internal/domain/task/task.go +++ b/internal/domain/task/task.go @@ -11,12 +11,15 @@ const ( ) type Task struct { - ID int64 `json:"id"` - Title string `json:"title"` - Description string `json:"description"` - Status Status `json:"status"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID int64 `json:"id"` + Title string `json:"title"` + Description string `json:"description"` + Status Status `json:"status"` + ScheduledFor time.Time `json:"scheduled_for"` + RecurrenceRuleID *int64 `json:"recurrence_rule_id,omitempty"` + IsModified bool `json:"-"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } func (s Status) Valid() bool { diff --git a/internal/repository/postgres/recurrence_repository.go b/internal/repository/postgres/recurrence_repository.go new file mode 100644 index 00000000..1e21ba73 --- /dev/null +++ b/internal/repository/postgres/recurrence_repository.go @@ -0,0 +1,334 @@ +package postgres + +import ( + "context" + "errors" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + recurrencedomain "example.com/taskservice/internal/domain/recurrence" + taskdomain "example.com/taskservice/internal/domain/task" + "example.com/taskservice/internal/shared/dateutil" +) + +type RecurrenceRepository struct { + pool *pgxpool.Pool +} + +func NewRecurrenceRepository(pool *pgxpool.Pool) *RecurrenceRepository { + return &RecurrenceRepository{pool: pool} +} + +func (r *RecurrenceRepository) Create(ctx context.Context, rule *recurrencedomain.Rule) (*recurrencedomain.Rule, error) { + const query = ` + INSERT INTO recurrence_rules ( + title, + description, + active, + schedule_type, + starts_on, + ends_on, + interval_days, + day_of_month, + day_parity, + specific_dates, + created_at, + updated_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) + RETURNING id, title, description, active, schedule_type, starts_on, ends_on, interval_days, day_of_month, day_parity, specific_dates, created_at, updated_at + ` + + row := r.pool.QueryRow( + ctx, + query, + rule.Title, + rule.Description, + rule.Active, + rule.ScheduleType, + rule.StartsOn, + rule.EndsOn, + rule.EveryNDays, + rule.DayOfMonth, + rule.DayParity, + normalizeDateSlice(rule.SpecificDates), + time.Now().UTC(), + time.Now().UTC(), + ) + + created, err := scanRule(row) + if err != nil { + return nil, err + } + + return created, nil +} + +func (r *RecurrenceRepository) GetByID(ctx context.Context, id int64) (*recurrencedomain.Rule, error) { + const query = ` + SELECT id, title, description, active, schedule_type, starts_on, ends_on, interval_days, day_of_month, day_parity, specific_dates, created_at, updated_at + FROM recurrence_rules + WHERE id = $1 + ` + + row := r.pool.QueryRow(ctx, query, id) + rule, err := scanRule(row) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, recurrencedomain.ErrNotFound + } + + return nil, err + } + + return rule, nil +} + +func (r *RecurrenceRepository) Update(ctx context.Context, rule *recurrencedomain.Rule) (*recurrencedomain.Rule, error) { + const query = ` + UPDATE recurrence_rules + SET title = $1, + description = $2, + active = $3, + schedule_type = $4, + starts_on = $5, + ends_on = $6, + interval_days = $7, + day_of_month = $8, + day_parity = $9, + specific_dates = $10, + updated_at = $11 + WHERE id = $12 + RETURNING id, title, description, active, schedule_type, starts_on, ends_on, interval_days, day_of_month, day_parity, specific_dates, created_at, updated_at + ` + + row := r.pool.QueryRow( + ctx, + query, + rule.Title, + rule.Description, + rule.Active, + rule.ScheduleType, + rule.StartsOn, + rule.EndsOn, + rule.EveryNDays, + rule.DayOfMonth, + rule.DayParity, + normalizeDateSlice(rule.SpecificDates), + time.Now().UTC(), + rule.ID, + ) + + updated, err := scanRule(row) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, recurrencedomain.ErrNotFound + } + + return nil, err + } + + return updated, nil +} + +func (r *RecurrenceRepository) Delete(ctx context.Context, id int64, fromDate time.Time) error { + tx, err := r.pool.BeginTx(ctx, pgx.TxOptions{}) + if err != nil { + return err + } + defer tx.Rollback(ctx) + + const deleteFutureTasksQuery = ` + DELETE FROM tasks + WHERE recurrence_rule_id = $1 + AND scheduled_for >= $2 + AND status = $3 + AND is_modified = FALSE + ` + if _, err := tx.Exec(ctx, deleteFutureTasksQuery, id, dateutil.Normalize(fromDate), taskdomain.StatusNew); err != nil { + return err + } + + const deleteRuleQuery = `DELETE FROM recurrence_rules WHERE id = $1` + result, err := tx.Exec(ctx, deleteRuleQuery, id) + if err != nil { + return err + } + if result.RowsAffected() == 0 { + return recurrencedomain.ErrNotFound + } + + return tx.Commit(ctx) +} + +func (r *RecurrenceRepository) List(ctx context.Context) ([]recurrencedomain.Rule, error) { + const query = ` + SELECT id, title, description, active, schedule_type, starts_on, ends_on, interval_days, day_of_month, day_parity, specific_dates, created_at, updated_at + FROM recurrence_rules + ORDER BY id DESC + ` + + rows, err := r.pool.Query(ctx, query) + if err != nil { + return nil, err + } + defer rows.Close() + + return collectRules(rows) +} + +func (r *RecurrenceRepository) ListActive(ctx context.Context) ([]recurrencedomain.Rule, error) { + const query = ` + SELECT id, title, description, active, schedule_type, starts_on, ends_on, interval_days, day_of_month, day_parity, specific_dates, created_at, updated_at + FROM recurrence_rules + WHERE active = TRUE + ORDER BY id DESC + ` + + rows, err := r.pool.Query(ctx, query) + if err != nil { + return nil, err + } + defer rows.Close() + + return collectRules(rows) +} + +func (r *RecurrenceRepository) SyncGeneratedTasks(ctx context.Context, rule *recurrencedomain.Rule, fromDate time.Time, occurrences []time.Time) error { + tx, err := r.pool.BeginTx(ctx, pgx.TxOptions{}) + if err != nil { + return err + } + defer tx.Rollback(ctx) + + const deleteQuery = ` + DELETE FROM tasks + WHERE recurrence_rule_id = $1 + AND scheduled_for >= $2 + AND status = $3 + AND is_modified = FALSE + ` + if _, err := tx.Exec(ctx, deleteQuery, rule.ID, dateutil.Normalize(fromDate), taskdomain.StatusNew); err != nil { + return err + } + + if rule.Active { + const insertQuery = ` + INSERT INTO tasks ( + title, + description, + status, + scheduled_for, + recurrence_rule_id, + is_modified, + created_at, + updated_at + ) + VALUES ($1, $2, $3, $4, $5, FALSE, $6, $7) + ON CONFLICT (recurrence_rule_id, scheduled_for) WHERE recurrence_rule_id IS NOT NULL + DO UPDATE SET + title = EXCLUDED.title, + description = EXCLUDED.description, + updated_at = EXCLUDED.updated_at + WHERE tasks.status = $3 AND tasks.is_modified = FALSE + ` + + now := time.Now().UTC() + for _, occurrence := range occurrences { + if _, err := tx.Exec( + ctx, + insertQuery, + rule.Title, + rule.Description, + taskdomain.StatusNew, + dateutil.Normalize(occurrence), + rule.ID, + now, + now, + ); err != nil { + return err + } + } + } + + return tx.Commit(ctx) +} + +type ruleScanner interface { + Scan(dest ...any) error +} + +func scanRule(scanner ruleScanner) (*recurrencedomain.Rule, error) { + var ( + rule recurrencedomain.Rule + scheduleType string + endsOn *time.Time + dayParity *string + specificDates []time.Time + ) + + if err := scanner.Scan( + &rule.ID, + &rule.Title, + &rule.Description, + &rule.Active, + &scheduleType, + &rule.StartsOn, + &endsOn, + &rule.EveryNDays, + &rule.DayOfMonth, + &dayParity, + &specificDates, + &rule.CreatedAt, + &rule.UpdatedAt, + ); err != nil { + return nil, err + } + + rule.ScheduleType = recurrencedomain.ScheduleType(scheduleType) + if endsOn != nil { + normalized := dateutil.Normalize(*endsOn) + rule.EndsOn = &normalized + } + if dayParity != nil { + parity := recurrencedomain.DayParity(*dayParity) + rule.DayParity = &parity + } + rule.StartsOn = dateutil.Normalize(rule.StartsOn) + rule.SpecificDates = normalizeDateSlice(specificDates) + + return &rule, nil +} + +func collectRules(rows pgx.Rows) ([]recurrencedomain.Rule, error) { + rules := make([]recurrencedomain.Rule, 0) + for rows.Next() { + rule, err := scanRule(rows) + if err != nil { + return nil, err + } + + rules = append(rules, *rule) + } + + if err := rows.Err(); err != nil { + return nil, err + } + + return rules, nil +} + +func normalizeDateSlice(values []time.Time) []time.Time { + if len(values) == 0 { + return []time.Time{} + } + + normalized := make([]time.Time, 0, len(values)) + for _, value := range values { + normalized = append(normalized, dateutil.Normalize(value)) + } + + return normalized +} diff --git a/internal/repository/postgres/task_repository.go b/internal/repository/postgres/task_repository.go index 2abb3f2e..904fb6aa 100644 --- a/internal/repository/postgres/task_repository.go +++ b/internal/repository/postgres/task_repository.go @@ -20,12 +20,32 @@ func New(pool *pgxpool.Pool) *Repository { func (r *Repository) Create(ctx context.Context, task *taskdomain.Task) (*taskdomain.Task, error) { const query = ` - INSERT INTO tasks (title, description, status, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5) - RETURNING id, title, description, status, created_at, updated_at + INSERT INTO tasks ( + title, + description, + status, + scheduled_for, + recurrence_rule_id, + is_modified, + created_at, + updated_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING id, title, description, status, scheduled_for, recurrence_rule_id, is_modified, created_at, updated_at ` - row := r.pool.QueryRow(ctx, query, task.Title, task.Description, task.Status, task.CreatedAt, task.UpdatedAt) + row := r.pool.QueryRow( + ctx, + query, + task.Title, + task.Description, + task.Status, + task.ScheduledFor, + task.RecurrenceRuleID, + task.IsModified, + task.CreatedAt, + task.UpdatedAt, + ) created, err := scanTask(row) if err != nil { return nil, err @@ -36,7 +56,7 @@ func (r *Repository) Create(ctx context.Context, task *taskdomain.Task) (*taskdo func (r *Repository) GetByID(ctx context.Context, id int64) (*taskdomain.Task, error) { const query = ` - SELECT id, title, description, status, created_at, updated_at + SELECT id, title, description, status, scheduled_for, recurrence_rule_id, is_modified, created_at, updated_at FROM tasks WHERE id = $1 ` @@ -60,12 +80,13 @@ func (r *Repository) Update(ctx context.Context, task *taskdomain.Task) (*taskdo SET title = $1, description = $2, status = $3, - updated_at = $4 - WHERE id = $5 - RETURNING id, title, description, status, created_at, updated_at + is_modified = $4, + updated_at = $5 + WHERE id = $6 + RETURNING id, title, description, status, scheduled_for, recurrence_rule_id, is_modified, created_at, updated_at ` - row := r.pool.QueryRow(ctx, query, task.Title, task.Description, task.Status, task.UpdatedAt, task.ID) + row := r.pool.QueryRow(ctx, query, task.Title, task.Description, task.Status, task.IsModified, task.UpdatedAt, task.ID) updated, err := scanTask(row) if err != nil { if errors.Is(err, pgx.ErrNoRows) { @@ -95,9 +116,9 @@ func (r *Repository) Delete(ctx context.Context, id int64) error { func (r *Repository) List(ctx context.Context) ([]taskdomain.Task, error) { const query = ` - SELECT id, title, description, status, created_at, updated_at + SELECT id, title, description, status, scheduled_for, recurrence_rule_id, is_modified, created_at, updated_at FROM tasks - ORDER BY id DESC + ORDER BY scheduled_for DESC, id DESC ` rows, err := r.pool.Query(ctx, query) @@ -129,8 +150,9 @@ type taskScanner interface { func scanTask(scanner taskScanner) (*taskdomain.Task, error) { var ( - task taskdomain.Task - status string + task taskdomain.Task + status string + recurrenceRuleID *int64 ) if err := scanner.Scan( @@ -138,6 +160,9 @@ func scanTask(scanner taskScanner) (*taskdomain.Task, error) { &task.Title, &task.Description, &status, + &task.ScheduledFor, + &recurrenceRuleID, + &task.IsModified, &task.CreatedAt, &task.UpdatedAt, ); err != nil { @@ -145,6 +170,7 @@ func scanTask(scanner taskScanner) (*taskdomain.Task, error) { } task.Status = taskdomain.Status(status) + task.RecurrenceRuleID = recurrenceRuleID return &task, nil } diff --git a/internal/shared/dateutil/date.go b/internal/shared/dateutil/date.go new file mode 100644 index 00000000..52360fb6 --- /dev/null +++ b/internal/shared/dateutil/date.go @@ -0,0 +1,54 @@ +package dateutil + +import "time" + +const Layout = "2006-01-02" + +func Normalize(t time.Time) time.Time { + y, m, d := t.UTC().Date() + return time.Date(y, m, d, 0, 0, 0, 0, time.UTC) +} + +func Parse(value string) (time.Time, error) { + parsed, err := time.Parse(Layout, value) + if err != nil { + return time.Time{}, err + } + + return Normalize(parsed), nil +} + +func Format(t time.Time) string { + return Normalize(t).Format(Layout) +} + +func Today(now func() time.Time) time.Time { + return Normalize(now()) +} + +func Max(a, b time.Time) time.Time { + if a.After(b) { + return Normalize(a) + } + + return Normalize(b) +} + +func Min(a, b time.Time) time.Time { + if a.Before(b) { + return Normalize(a) + } + + return Normalize(b) +} + +func AddDays(t time.Time, days int) time.Time { + return Normalize(t.AddDate(0, 0, days)) +} + +func DaysBetween(from, to time.Time) int { + from = Normalize(from) + to = Normalize(to) + + return int(to.Sub(from).Hours() / 24) +} diff --git a/internal/transport/http/docs/openapi.json b/internal/transport/http/docs/openapi.json index 5effedb7..ca78b4d4 100644 --- a/internal/transport/http/docs/openapi.json +++ b/internal/transport/http/docs/openapi.json @@ -2,8 +2,8 @@ "openapi": "3.0.3", "info": { "title": "Task Service API", - "version": "1.0.0", - "description": "CRUD API for tasks built with clean architecture." + "version": "2.0.0", + "description": "CRUD API for one-off tasks and recurrence rules that materialize future task instances." }, "servers": [ { @@ -13,7 +13,11 @@ "tags": [ { "name": "Tasks", - "description": "Operations with tasks" + "description": "Operations with concrete task instances" + }, + { + "name": "Recurrence Rules", + "description": "Definitions for periodic task generation" } ], "paths": { @@ -37,7 +41,7 @@ }, "post": { "tags": ["Tasks"], - "summary": "Create task", + "summary": "Create one-off task", "requestBody": { "required": true, "content": { @@ -131,6 +135,121 @@ "404": { "$ref": "#/components/responses/NotFound" } } } + }, + "/api/v1/recurrence-rules": { + "get": { + "tags": ["Recurrence Rules"], + "summary": "List recurrence rules", + "responses": { + "200": { + "description": "Recurrence rule list", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { "$ref": "#/components/schemas/RecurrenceRule" } + } + } + } + } + } + }, + "post": { + "tags": ["Recurrence Rules"], + "summary": "Create recurrence rule", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/RecurrenceRuleRequest" } + } + } + }, + "responses": { + "201": { + "description": "Created recurrence rule", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/RecurrenceRule" } + } + } + }, + "400": { "$ref": "#/components/responses/BadRequest" } + } + } + }, + "/api/v1/recurrence-rules/{id}": { + "get": { + "tags": ["Recurrence Rules"], + "summary": "Get recurrence rule by ID", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { "type": "integer", "format": "int64", "minimum": 1 } + } + ], + "responses": { + "200": { + "description": "Recurrence rule", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/RecurrenceRule" } + } + } + }, + "404": { "$ref": "#/components/responses/NotFound" } + } + }, + "put": { + "tags": ["Recurrence Rules"], + "summary": "Update recurrence rule", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { "type": "integer", "format": "int64", "minimum": 1 } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/RecurrenceRuleRequest" } + } + } + }, + "responses": { + "200": { + "description": "Updated recurrence rule", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/RecurrenceRule" } + } + } + }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "404": { "$ref": "#/components/responses/NotFound" } + } + }, + "delete": { + "tags": ["Recurrence Rules"], + "summary": "Delete recurrence rule", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { "type": "integer", "format": "int64", "minimum": 1 } + } + ], + "responses": { + "204": { "description": "Deleted" }, + "404": { "$ref": "#/components/responses/NotFound" } + } + } } }, "components": { @@ -205,6 +324,47 @@ "enum": ["new", "in_progress", "done"], "example": "new" }, + "scheduled_for": { + "type": "string", + "format": "date", + "example": "2026-04-20" + }, + "recurrence_rule_id": { + "type": "integer", + "format": "int64", + "nullable": true, + "example": 3 + }, + "created_at": { + "type": "string", + "format": "date-time", + "example": "2026-03-24T00:00:00Z" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "example": "2026-03-24T00:00:00Z" + } + } + }, + "RecurrenceRuleRequest": { + "type": "object", + "required": ["title", "schedule"], + "properties": { + "title": { "type": "string", "example": "Daily patient calls" }, + "description": { "type": "string", "example": "Morning follow-up with ward patients" }, + "active": { "type": "boolean", "example": true }, + "schedule": { "$ref": "#/components/schemas/RecurrenceSchedule" } + } + }, + "RecurrenceRule": { + "type": "object", + "properties": { + "id": { "type": "integer", "format": "int64", "example": 1 }, + "title": { "type": "string", "example": "Daily patient calls" }, + "description": { "type": "string", "example": "Morning follow-up with ward patients" }, + "active": { "type": "boolean", "example": true }, + "schedule": { "$ref": "#/components/schemas/RecurrenceSchedule" }, "created_at": { "type": "string", "format": "date-time", @@ -217,6 +377,51 @@ } } }, + "RecurrenceSchedule": { + "type": "object", + "required": ["type"], + "properties": { + "type": { + "type": "string", + "enum": ["every_n_days", "monthly_day", "specific_dates", "month_day_parity"], + "example": "every_n_days" + }, + "starts_on": { + "type": "string", + "format": "date", + "example": "2026-04-20" + }, + "ends_on": { + "type": "string", + "format": "date", + "example": "2026-05-31" + }, + "every_n_days": { + "type": "integer", + "minimum": 1, + "example": 2 + }, + "day_of_month": { + "type": "integer", + "minimum": 1, + "maximum": 30, + "example": 15 + }, + "day_parity": { + "type": "string", + "enum": ["odd", "even"], + "example": "odd" + }, + "dates": { + "type": "array", + "items": { + "type": "string", + "format": "date" + }, + "example": ["2026-04-21", "2026-04-27", "2026-05-03"] + } + } + }, "Error": { "type": "object", "properties": { diff --git a/internal/transport/http/handlers/dto.go b/internal/transport/http/handlers/dto.go index ed00af96..14996064 100644 --- a/internal/transport/http/handlers/dto.go +++ b/internal/transport/http/handlers/dto.go @@ -3,7 +3,9 @@ package handlers import ( "time" + recurrencedomain "example.com/taskservice/internal/domain/recurrence" taskdomain "example.com/taskservice/internal/domain/task" + "example.com/taskservice/internal/shared/dateutil" ) type taskMutationDTO struct { @@ -13,21 +15,95 @@ type taskMutationDTO struct { } type taskDTO struct { - ID int64 `json:"id"` - Title string `json:"title"` - Description string `json:"description"` - Status taskdomain.Status `json:"status"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID int64 `json:"id"` + Title string `json:"title"` + Description string `json:"description"` + Status taskdomain.Status `json:"status"` + ScheduledFor string `json:"scheduled_for"` + RecurrenceRuleID *int64 `json:"recurrence_rule_id,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } func newTaskDTO(task *taskdomain.Task) taskDTO { return taskDTO{ - ID: task.ID, - Title: task.Title, - Description: task.Description, - Status: task.Status, - CreatedAt: task.CreatedAt, - UpdatedAt: task.UpdatedAt, + ID: task.ID, + Title: task.Title, + Description: task.Description, + Status: task.Status, + ScheduledFor: dateutil.Format(task.ScheduledFor), + RecurrenceRuleID: task.RecurrenceRuleID, + CreatedAt: task.CreatedAt, + UpdatedAt: task.UpdatedAt, + } +} + +type recurrenceRuleMutationDTO struct { + Title string `json:"title"` + Description string `json:"description"` + Active *bool `json:"active,omitempty"` + Schedule recurrenceConfigDTO `json:"schedule"` +} + +type recurrenceConfigDTO struct { + Type recurrencedomain.ScheduleType `json:"type"` + StartsOn string `json:"starts_on,omitempty"` + EndsOn string `json:"ends_on,omitempty"` + EveryNDays *int `json:"every_n_days,omitempty"` + DayOfMonth *int `json:"day_of_month,omitempty"` + DayParity *recurrencedomain.DayParity `json:"day_parity,omitempty"` + Dates []string `json:"dates,omitempty"` +} + +type recurrenceRuleDTO struct { + ID int64 `json:"id"` + Title string `json:"title"` + Description string `json:"description"` + Active bool `json:"active"` + Schedule recurrenceConfigDTO `json:"schedule"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func newRecurrenceRuleDTO(rule *recurrencedomain.Rule) recurrenceRuleDTO { + dto := recurrenceRuleDTO{ + ID: rule.ID, + Title: rule.Title, + Description: rule.Description, + Active: rule.Active, + Schedule: recurrenceConfigDTO{ + Type: rule.ScheduleType, + StartsOn: dateutil.Format(rule.StartsOn), + EndsOn: formatOptionalDate(rule.EndsOn), + EveryNDays: rule.EveryNDays, + DayOfMonth: rule.DayOfMonth, + DayParity: rule.DayParity, + Dates: formatDates(rule.SpecificDates), + }, + CreatedAt: rule.CreatedAt, + UpdatedAt: rule.UpdatedAt, } + + return dto +} + +func formatOptionalDate(value *time.Time) string { + if value == nil { + return "" + } + + return dateutil.Format(*value) +} + +func formatDates(values []time.Time) []string { + if len(values) == 0 { + return nil + } + + result := make([]string, 0, len(values)) + for _, value := range values { + result = append(result, dateutil.Format(value)) + } + + return result } diff --git a/internal/transport/http/handlers/recurrence_handler.go b/internal/transport/http/handlers/recurrence_handler.go new file mode 100644 index 00000000..8e036bc7 --- /dev/null +++ b/internal/transport/http/handlers/recurrence_handler.go @@ -0,0 +1,183 @@ +package handlers + +import ( + "errors" + "fmt" + "net/http" + "time" + + recurrencedomain "example.com/taskservice/internal/domain/recurrence" + "example.com/taskservice/internal/shared/dateutil" + recurrenceusecase "example.com/taskservice/internal/usecase/recurrence" +) + +type RecurrenceHandler struct { + usecase recurrenceusecase.Usecase +} + +func NewRecurrenceHandler(usecase recurrenceusecase.Usecase) *RecurrenceHandler { + return &RecurrenceHandler{usecase: usecase} +} + +func (h *RecurrenceHandler) Create(w http.ResponseWriter, r *http.Request) { + var req recurrenceRuleMutationDTO + if err := decodeJSON(r, &req); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + + input, err := recurrenceInputFromDTO(req) + if err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + + created, err := h.usecase.Create(r.Context(), input) + if err != nil { + writeRecurrenceUsecaseError(w, err) + return + } + + writeJSON(w, http.StatusCreated, newRecurrenceRuleDTO(created)) +} + +func (h *RecurrenceHandler) GetByID(w http.ResponseWriter, r *http.Request) { + id, err := getIDFromRequest(r) + if err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + + rule, err := h.usecase.GetByID(r.Context(), id) + if err != nil { + writeRecurrenceUsecaseError(w, err) + return + } + + writeJSON(w, http.StatusOK, newRecurrenceRuleDTO(rule)) +} + +func (h *RecurrenceHandler) Update(w http.ResponseWriter, r *http.Request) { + id, err := getIDFromRequest(r) + if err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + + var req recurrenceRuleMutationDTO + if err := decodeJSON(r, &req); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + + input, err := recurrenceInputFromDTO(req) + if err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + + updated, err := h.usecase.Update(r.Context(), id, input) + if err != nil { + writeRecurrenceUsecaseError(w, err) + return + } + + writeJSON(w, http.StatusOK, newRecurrenceRuleDTO(updated)) +} + +func (h *RecurrenceHandler) Delete(w http.ResponseWriter, r *http.Request) { + id, err := getIDFromRequest(r) + if err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + + if err := h.usecase.Delete(r.Context(), id); err != nil { + writeRecurrenceUsecaseError(w, err) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +func (h *RecurrenceHandler) List(w http.ResponseWriter, r *http.Request) { + rules, err := h.usecase.List(r.Context()) + if err != nil { + writeRecurrenceUsecaseError(w, err) + return + } + + response := make([]recurrenceRuleDTO, 0, len(rules)) + for i := range rules { + response = append(response, newRecurrenceRuleDTO(&rules[i])) + } + + writeJSON(w, http.StatusOK, response) +} + +func writeRecurrenceUsecaseError(w http.ResponseWriter, err error) { + switch { + case errors.Is(err, recurrencedomain.ErrNotFound): + writeError(w, http.StatusNotFound, err) + case errors.Is(err, recurrenceusecase.ErrInvalidInput): + writeError(w, http.StatusBadRequest, err) + default: + writeError(w, http.StatusInternalServerError, err) + } +} + +func recurrenceInputFromDTO(req recurrenceRuleMutationDTO) (recurrenceusecase.CreateInput, error) { + startsOn, err := parseOptionalDateField("schedule.starts_on", req.Schedule.StartsOn) + if err != nil { + return recurrenceusecase.CreateInput{}, err + } + + endsOn, err := parseOptionalDateField("schedule.ends_on", req.Schedule.EndsOn) + if err != nil { + return recurrenceusecase.CreateInput{}, err + } + + dates := make([]time.Time, 0, len(req.Schedule.Dates)) + for _, rawDate := range req.Schedule.Dates { + parsed, err := parseDateField("schedule.dates", rawDate) + if err != nil { + return recurrenceusecase.CreateInput{}, err + } + dates = append(dates, parsed) + } + + return recurrenceusecase.CreateInput{ + Title: req.Title, + Description: req.Description, + Active: req.Active, + ScheduleType: req.Schedule.Type, + StartsOn: startsOn, + EndsOn: endsOn, + EveryNDays: req.Schedule.EveryNDays, + DayOfMonth: req.Schedule.DayOfMonth, + DayParity: req.Schedule.DayParity, + SpecificDates: dates, + }, nil +} + +func parseDateField(fieldName, rawValue string) (time.Time, error) { + parsed, err := dateutil.Parse(rawValue) + if err != nil { + return time.Time{}, fmt.Errorf("%s must be in YYYY-MM-DD format", fieldName) + } + + return parsed, nil +} + +func parseOptionalDateField(fieldName, rawValue string) (*time.Time, error) { + if rawValue == "" { + return nil, nil + } + + parsed, err := parseDateField(fieldName, rawValue) + if err != nil { + return nil, err + } + + return &parsed, nil +} diff --git a/internal/transport/http/handlers/task_handler.go b/internal/transport/http/handlers/task_handler.go index 7360a561..003302e9 100644 --- a/internal/transport/http/handlers/task_handler.go +++ b/internal/transport/http/handlers/task_handler.go @@ -3,6 +3,7 @@ package handlers import ( "encoding/json" "errors" + "io" "net/http" "strconv" @@ -138,6 +139,10 @@ func decodeJSON(r *http.Request, dst any) error { return err } + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + return errors.New("request body must contain a single JSON object") + } + return nil } diff --git a/internal/transport/http/router.go b/internal/transport/http/router.go index a0dce054..dac27ea4 100644 --- a/internal/transport/http/router.go +++ b/internal/transport/http/router.go @@ -9,7 +9,7 @@ import ( httphandlers "example.com/taskservice/internal/transport/http/handlers" ) -func NewRouter(taskHandler *httphandlers.TaskHandler, docsHandler *swaggerdocs.Handler) *mux.Router { +func NewRouter(taskHandler *httphandlers.TaskHandler, recurrenceHandler *httphandlers.RecurrenceHandler, docsHandler *swaggerdocs.Handler) *mux.Router { router := mux.NewRouter().StrictSlash(true) router.HandleFunc("/swagger/openapi.json", docsHandler.ServeSpec).Methods(http.MethodGet) @@ -24,5 +24,11 @@ func NewRouter(taskHandler *httphandlers.TaskHandler, docsHandler *swaggerdocs.H api.HandleFunc("/tasks/{id:[0-9]+}", taskHandler.Update).Methods(http.MethodPut) api.HandleFunc("/tasks/{id:[0-9]+}", taskHandler.Delete).Methods(http.MethodDelete) + api.HandleFunc("/recurrence-rules", recurrenceHandler.Create).Methods(http.MethodPost) + api.HandleFunc("/recurrence-rules", recurrenceHandler.List).Methods(http.MethodGet) + api.HandleFunc("/recurrence-rules/{id:[0-9]+}", recurrenceHandler.GetByID).Methods(http.MethodGet) + api.HandleFunc("/recurrence-rules/{id:[0-9]+}", recurrenceHandler.Update).Methods(http.MethodPut) + api.HandleFunc("/recurrence-rules/{id:[0-9]+}", recurrenceHandler.Delete).Methods(http.MethodDelete) + return router } diff --git a/internal/usecase/recurrence/errors.go b/internal/usecase/recurrence/errors.go new file mode 100644 index 00000000..4cacd722 --- /dev/null +++ b/internal/usecase/recurrence/errors.go @@ -0,0 +1,5 @@ +package recurrence + +import "errors" + +var ErrInvalidInput = errors.New("invalid recurrence rule input") diff --git a/internal/usecase/recurrence/ports.go b/internal/usecase/recurrence/ports.go new file mode 100644 index 00000000..feffa73f --- /dev/null +++ b/internal/usecase/recurrence/ports.go @@ -0,0 +1,42 @@ +package recurrence + +import ( + "context" + "time" + + recurrencedomain "example.com/taskservice/internal/domain/recurrence" +) + +type Repository interface { + Create(ctx context.Context, rule *recurrencedomain.Rule) (*recurrencedomain.Rule, error) + GetByID(ctx context.Context, id int64) (*recurrencedomain.Rule, error) + Update(ctx context.Context, rule *recurrencedomain.Rule) (*recurrencedomain.Rule, error) + Delete(ctx context.Context, id int64, fromDate time.Time) error + List(ctx context.Context) ([]recurrencedomain.Rule, error) + ListActive(ctx context.Context) ([]recurrencedomain.Rule, error) + SyncGeneratedTasks(ctx context.Context, rule *recurrencedomain.Rule, fromDate time.Time, occurrences []time.Time) error +} + +type Usecase interface { + Create(ctx context.Context, input CreateInput) (*recurrencedomain.Rule, error) + GetByID(ctx context.Context, id int64) (*recurrencedomain.Rule, error) + Update(ctx context.Context, id int64, input UpdateInput) (*recurrencedomain.Rule, error) + Delete(ctx context.Context, id int64) error + List(ctx context.Context) ([]recurrencedomain.Rule, error) + MaterializeActive(ctx context.Context) error +} + +type CreateInput struct { + Title string + Description string + Active *bool + ScheduleType recurrencedomain.ScheduleType + StartsOn *time.Time + EndsOn *time.Time + EveryNDays *int + DayOfMonth *int + DayParity *recurrencedomain.DayParity + SpecificDates []time.Time +} + +type UpdateInput = CreateInput diff --git a/internal/usecase/recurrence/service.go b/internal/usecase/recurrence/service.go new file mode 100644 index 00000000..dfe1c629 --- /dev/null +++ b/internal/usecase/recurrence/service.go @@ -0,0 +1,265 @@ +package recurrence + +import ( + "context" + "fmt" + "sort" + "strings" + "time" + + recurrencedomain "example.com/taskservice/internal/domain/recurrence" + "example.com/taskservice/internal/shared/dateutil" +) + +const defaultMaterializationHorizonDays = 30 + +type Service struct { + repo Repository + now func() time.Time + horizonInDays int +} + +func NewService(repo Repository) *Service { + return &Service{ + repo: repo, + now: func() time.Time { return time.Now().UTC() }, + horizonInDays: defaultMaterializationHorizonDays, + } +} + +func (s *Service) Create(ctx context.Context, input CreateInput) (*recurrencedomain.Rule, error) { + normalized, err := validateInput(input, dateutil.Today(s.now)) + if err != nil { + return nil, err + } + + model := &recurrencedomain.Rule{ + Title: normalized.Title, + Description: normalized.Description, + Active: normalized.Active, + ScheduleType: normalized.ScheduleType, + StartsOn: normalized.StartsOn, + EndsOn: normalized.EndsOn, + EveryNDays: normalized.EveryNDays, + DayOfMonth: normalized.DayOfMonth, + DayParity: normalized.DayParity, + SpecificDates: normalized.SpecificDates, + } + created, err := s.repo.Create(ctx, model) + if err != nil { + return nil, err + } + + if err := s.syncRule(ctx, created); err != nil { + return nil, err + } + + return s.repo.GetByID(ctx, created.ID) +} + +func (s *Service) GetByID(ctx context.Context, id int64) (*recurrencedomain.Rule, error) { + if id <= 0 { + return nil, fmt.Errorf("%w: id must be positive", ErrInvalidInput) + } + + return s.repo.GetByID(ctx, id) +} + +func (s *Service) Update(ctx context.Context, id int64, input UpdateInput) (*recurrencedomain.Rule, error) { + if id <= 0 { + return nil, fmt.Errorf("%w: id must be positive", ErrInvalidInput) + } + + normalized, err := validateInput(input, dateutil.Today(s.now)) + if err != nil { + return nil, err + } + + model := &recurrencedomain.Rule{ + ID: id, + Title: normalized.Title, + Description: normalized.Description, + Active: normalized.Active, + ScheduleType: normalized.ScheduleType, + StartsOn: normalized.StartsOn, + EndsOn: normalized.EndsOn, + EveryNDays: normalized.EveryNDays, + DayOfMonth: normalized.DayOfMonth, + DayParity: normalized.DayParity, + SpecificDates: normalized.SpecificDates, + } + + updated, err := s.repo.Update(ctx, model) + if err != nil { + return nil, err + } + + if err := s.syncRule(ctx, updated); err != nil { + return nil, err + } + + return s.repo.GetByID(ctx, updated.ID) +} + +func (s *Service) Delete(ctx context.Context, id int64) error { + if id <= 0 { + return fmt.Errorf("%w: id must be positive", ErrInvalidInput) + } + + return s.repo.Delete(ctx, id, dateutil.Today(s.now)) +} + +func (s *Service) List(ctx context.Context) ([]recurrencedomain.Rule, error) { + return s.repo.List(ctx) +} + +func (s *Service) MaterializeActive(ctx context.Context) error { + rules, err := s.repo.ListActive(ctx) + if err != nil { + return err + } + + for i := range rules { + if err := s.syncRule(ctx, &rules[i]); err != nil { + return err + } + } + + return nil +} + +func (s *Service) syncRule(ctx context.Context, rule *recurrencedomain.Rule) error { + fromDate := dateutil.Today(s.now) + toDate := dateutil.AddDays(fromDate, s.horizonInDays) + occurrences := rule.OccurrencesBetween(fromDate, toDate) + return s.repo.SyncGeneratedTasks(ctx, rule, fromDate, occurrences) +} + +type normalizedInput struct { + Title string + Description string + Active bool + ScheduleType recurrencedomain.ScheduleType + StartsOn time.Time + EndsOn *time.Time + EveryNDays *int + DayOfMonth *int + DayParity *recurrencedomain.DayParity + SpecificDates []time.Time +} + +func validateInput(input CreateInput, today time.Time) (normalizedInput, error) { + input.Title = strings.TrimSpace(input.Title) + input.Description = strings.TrimSpace(input.Description) + + if input.Title == "" { + return normalizedInput{}, fmt.Errorf("%w: title is required", ErrInvalidInput) + } + + if !input.ScheduleType.Valid() { + return normalizedInput{}, fmt.Errorf("%w: invalid schedule_type", ErrInvalidInput) + } + + active := true + if input.Active != nil { + active = *input.Active + } + + normalized := normalizedInput{ + Title: input.Title, + Description: input.Description, + Active: active, + ScheduleType: input.ScheduleType, + } + + switch input.ScheduleType { + case recurrencedomain.ScheduleTypeEveryNDays: + if input.StartsOn == nil { + return normalizedInput{}, fmt.Errorf("%w: starts_on is required for every_n_days", ErrInvalidInput) + } + if input.EveryNDays == nil || *input.EveryNDays <= 0 { + return normalizedInput{}, fmt.Errorf("%w: every_n_days must be positive", ErrInvalidInput) + } + normalized.StartsOn = dateutil.Normalize(*input.StartsOn) + normalized.EveryNDays = input.EveryNDays + normalized.EndsOn = normalizeOptionalDate(input.EndsOn) + case recurrencedomain.ScheduleTypeMonthlyDay: + if input.StartsOn == nil { + return normalizedInput{}, fmt.Errorf("%w: starts_on is required for monthly_day", ErrInvalidInput) + } + if input.DayOfMonth == nil || *input.DayOfMonth < 1 || *input.DayOfMonth > 30 { + return normalizedInput{}, fmt.Errorf("%w: day_of_month must be between 1 and 30", ErrInvalidInput) + } + normalized.StartsOn = dateutil.Normalize(*input.StartsOn) + normalized.DayOfMonth = input.DayOfMonth + normalized.EndsOn = normalizeOptionalDate(input.EndsOn) + case recurrencedomain.ScheduleTypeMonthParity: + if input.StartsOn == nil { + return normalizedInput{}, fmt.Errorf("%w: starts_on is required for month_day_parity", ErrInvalidInput) + } + if input.DayParity == nil || !input.DayParity.Valid() { + return normalizedInput{}, fmt.Errorf("%w: day_parity must be odd or even", ErrInvalidInput) + } + normalized.StartsOn = dateutil.Normalize(*input.StartsOn) + normalized.DayParity = input.DayParity + normalized.EndsOn = normalizeOptionalDate(input.EndsOn) + case recurrencedomain.ScheduleTypeSpecificDates: + if len(input.SpecificDates) == 0 { + return normalizedInput{}, fmt.Errorf("%w: dates must not be empty", ErrInvalidInput) + } + + dedup := make(map[string]time.Time, len(input.SpecificDates)) + for _, rawDate := range input.SpecificDates { + normalizedDate := dateutil.Normalize(rawDate) + dedup[dateutil.Format(normalizedDate)] = normalizedDate + } + + normalizedDates := make([]time.Time, 0, len(dedup)) + for _, value := range dedup { + normalizedDates = append(normalizedDates, value) + } + + sort.Slice(normalizedDates, func(i, j int) bool { + return normalizedDates[i].Before(normalizedDates[j]) + }) + + futureOrTodayExists := false + for _, value := range normalizedDates { + if !value.Before(today) { + futureOrTodayExists = true + break + } + } + if !futureOrTodayExists { + return normalizedInput{}, fmt.Errorf("%w: dates must contain today or a future date", ErrInvalidInput) + } + + normalized.SpecificDates = normalizedDates + normalized.StartsOn = normalizedDates[0] + last := normalizedDates[len(normalizedDates)-1] + normalized.EndsOn = &last + default: + return normalizedInput{}, fmt.Errorf("%w: unsupported schedule type", ErrInvalidInput) + } + + if normalized.EndsOn != nil && normalized.EndsOn.Before(normalized.StartsOn) { + return normalizedInput{}, fmt.Errorf("%w: ends_on must be on or after starts_on", ErrInvalidInput) + } + + if normalized.ScheduleType != recurrencedomain.ScheduleTypeSpecificDates { + if normalized.EndsOn != nil && normalized.EndsOn.Before(today) { + return normalizedInput{}, fmt.Errorf("%w: ends_on must not be in the past", ErrInvalidInput) + } + } + + return normalized, nil +} + +func normalizeOptionalDate(value *time.Time) *time.Time { + if value == nil { + return nil + } + + normalized := dateutil.Normalize(*value) + return &normalized +} diff --git a/internal/usecase/recurrence/service_test.go b/internal/usecase/recurrence/service_test.go new file mode 100644 index 00000000..a2feed44 --- /dev/null +++ b/internal/usecase/recurrence/service_test.go @@ -0,0 +1,115 @@ +package recurrence + +import ( + "context" + "testing" + "time" + + recurrencedomain "example.com/taskservice/internal/domain/recurrence" + "example.com/taskservice/internal/shared/dateutil" +) + +type stubRepository struct { + createdRule *recurrencedomain.Rule + syncedRule *recurrencedomain.Rule + syncedDates []time.Time + listActive []recurrencedomain.Rule +} + +func (s *stubRepository) Create(_ context.Context, rule *recurrencedomain.Rule) (*recurrencedomain.Rule, error) { + copied := *rule + copied.ID = 1 + s.createdRule = &copied + return &copied, nil +} + +func (s *stubRepository) GetByID(_ context.Context, id int64) (*recurrencedomain.Rule, error) { + if s.createdRule != nil && s.createdRule.ID == id { + copied := *s.createdRule + return &copied, nil + } + return nil, recurrencedomain.ErrNotFound +} + +func (s *stubRepository) Update(_ context.Context, rule *recurrencedomain.Rule) (*recurrencedomain.Rule, error) { + copied := *rule + s.createdRule = &copied + return &copied, nil +} + +func (s *stubRepository) Delete(_ context.Context, _ int64, _ time.Time) error { return nil } +func (s *stubRepository) List(_ context.Context) ([]recurrencedomain.Rule, error) { return nil, nil } +func (s *stubRepository) ListActive(_ context.Context) ([]recurrencedomain.Rule, error) { + return s.listActive, nil +} +func (s *stubRepository) SyncGeneratedTasks(_ context.Context, rule *recurrencedomain.Rule, _ time.Time, occurrences []time.Time) error { + copied := *rule + s.syncedRule = &copied + s.syncedDates = append([]time.Time(nil), occurrences...) + return nil +} + +func mustDate(t *testing.T, value string) time.Time { + t.Helper() + parsed, err := dateutil.Parse(value) + if err != nil { + t.Fatalf("parse date %s: %v", value, err) + } + return parsed +} + +func TestCreateMaterializesRule(t *testing.T) { + repo := &stubRepository{} + service := NewService(repo) + service.now = func() time.Time { return mustDate(t, "2026-04-20") } + service.horizonInDays = 4 + + interval := 2 + created, err := service.Create(context.Background(), CreateInput{ + Title: "Inventory", + ScheduleType: recurrencedomain.ScheduleTypeEveryNDays, + StartsOn: ptrTime(mustDate(t, "2026-04-20")), + EveryNDays: &interval, + }) + if err != nil { + t.Fatalf("Create returned error: %v", err) + } + + if created.ID != 1 { + t.Fatalf("expected created rule id 1, got %d", created.ID) + } + if repo.syncedRule == nil { + t.Fatal("expected sync to be called") + } + + expected := []string{"2026-04-20", "2026-04-22", "2026-04-24"} + if len(repo.syncedDates) != len(expected) { + t.Fatalf("expected %d generated dates, got %d", len(expected), len(repo.syncedDates)) + } + for i, date := range repo.syncedDates { + if got := dateutil.Format(date); got != expected[i] { + t.Fatalf("generated date %d: expected %s, got %s", i, expected[i], got) + } + } +} + +func TestValidateSpecificDatesDeduplicates(t *testing.T) { + today := mustDate(t, "2026-04-20") + normalized, err := validateInput(CreateInput{ + Title: "Call patients", + ScheduleType: recurrencedomain.ScheduleTypeSpecificDates, + SpecificDates: []time.Time{mustDate(t, "2026-04-22"), mustDate(t, "2026-04-22"), mustDate(t, "2026-04-25")}, + }, today) + if err != nil { + t.Fatalf("validateInput returned error: %v", err) + } + + if len(normalized.SpecificDates) != 2 { + t.Fatalf("expected 2 dates after deduplication, got %d", len(normalized.SpecificDates)) + } + if got := dateutil.Format(normalized.StartsOn); got != "2026-04-22" { + t.Fatalf("expected starts_on 2026-04-22, got %s", got) + } +} + +func ptrTime(value time.Time) *time.Time { return &value } diff --git a/internal/usecase/task/service.go b/internal/usecase/task/service.go index 2def18c2..9bbdfc2c 100644 --- a/internal/usecase/task/service.go +++ b/internal/usecase/task/service.go @@ -7,6 +7,7 @@ import ( "time" taskdomain "example.com/taskservice/internal/domain/task" + "example.com/taskservice/internal/shared/dateutil" ) type Service struct { @@ -27,14 +28,16 @@ func (s *Service) Create(ctx context.Context, input CreateInput) (*taskdomain.Ta return nil, err } + now := s.now() model := &taskdomain.Task{ - Title: normalized.Title, - Description: normalized.Description, - Status: normalized.Status, + Title: normalized.Title, + Description: normalized.Description, + Status: normalized.Status, + ScheduledFor: dateutil.Today(s.now), + IsModified: true, + CreatedAt: now, + UpdatedAt: now, } - now := s.now() - model.CreatedAt = now - model.UpdatedAt = now created, err := s.repo.Create(ctx, model) if err != nil { @@ -67,6 +70,7 @@ func (s *Service) Update(ctx context.Context, id int64, input UpdateInput) (*tas Title: normalized.Title, Description: normalized.Description, Status: normalized.Status, + IsModified: true, UpdatedAt: s.now(), }