Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
@@ -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
251 changes: 236 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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:
Expand All @@ -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;
- добавить аудит изменений по правилам и задачам.
Loading