diff --git a/HISTORY.md b/HISTORY.md new file mode 100644 index 00000000..864b7b6d --- /dev/null +++ b/HISTORY.md @@ -0,0 +1,76 @@ +# для чего нам нужны эти импорты + +``` +taskdomain "example.com/taskservice/internal/domain/task" +taskusecase "example.com/taskservice/internal/usecase/task" +``` + +Эти импорты подключают два внутренних пакета и дают им удобные псевдонимы: + +* `taskdomain` — пакет домена (entities/ошибки). В нём, например, определено `ErrNotFound` и типы сущности задачи. +* `taskusecase` — пакет бизнес-логики (use-cases). В нём объявлен интерфейс `Usecase`, структуры входных данных (`CreateInput`, `UpdateInput`) и ошибки уровня usecase (`ErrInvalidInput`). + +Зачем нужны: + +* В `TaskHandler` поле `usecase` имеет тип `taskusecase.Usecase`. +* В методах создаются/обновляются данные через `taskusecase.CreateInput` / `UpdateInput`. +* В `writeUsecaseError` сравниваются ошибки `taskdomain.ErrNotFound` и `taskusecase.ErrInvalidInput`. + +Псевдонимы нужны, чтобы: + +* избежать конфликта имён (оба пакета могут быть `task`), +* сделать код более явным (чётко видно, откуда тип/ошибка). + +--- + +# Сгенерируй body для post запросов + +Example JSON bodies for `POST /api/v1/tasks` (create task). Fields used: `title` (required), `description`, `dueDate` (RFC3339), `completed` (bool), `priority` (int), `tags` (array of strings). + +### minimal + +```json +{ + "title": "Buy groceries" +} +``` + +### full + +```json +{ + "title": "Finish report", + "description": "Complete monthly sales report and send to stakeholders.", + "dueDate": "2026-05-10T17:00:00Z", + "completed": false, + "priority": 2, + "tags": ["work", "reports"] +} +``` + +### with_due_date_only + +```json +{ + "title": "Pay rent", + "dueDate": "2026-05-01T12:00:00Z" +} +``` + +### with_tags + +```json +{ + "title": "Plan team offsite", + "description": "Choose date and venue, prepare agenda.", + "tags": ["team", "planning", "events"] +} +``` + +### invalid_missing_title + +```json +{ + "description": "This body is invalid because title is required." +} +``` diff --git a/internal/domain/task/task.go b/internal/domain/task/task.go index 24b0948b..9f9948a8 100644 --- a/internal/domain/task/task.go +++ b/internal/domain/task/task.go @@ -10,13 +10,32 @@ const ( StatusDone Status = "done" ) +type RepeatInterval string + +const ( + RepeatNever RepeatInterval = "never" + RepeatDaily RepeatInterval = "daily" + RepeatMonthly RepeatInterval = "monthly" + RepeatCustom RepeatInterval = "custom" + RepeatEven RepeatInterval = "even_days" + RepeatOdd RepeatInterval = "odd_days" +) + +type RepeatConfig struct { + DailyInterval *int `json:"daily_interval,omitempty"` + MonthDay *int `json:"month_day,omitempty"` + SpecificDates []time.Time `json:"specific_dates,omitempty"` +} + 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"` + Repeat RepeatInterval `json:"repeat"` + Config *RepeatConfig `json:"config,omitempty"` + Status Status `json:"status"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } func (s Status) Valid() bool { @@ -27,3 +46,12 @@ func (s Status) Valid() bool { return false } } + +func (r RepeatInterval) Valid() bool { + switch r { + case RepeatNever, RepeatDaily, RepeatMonthly, RepeatCustom, RepeatEven, RepeatOdd: + return true + default: + return false + } +} diff --git a/internal/repository/postgres/task_repository.go b/internal/repository/postgres/task_repository.go index 2abb3f2e..387a3ab9 100644 --- a/internal/repository/postgres/task_repository.go +++ b/internal/repository/postgres/task_repository.go @@ -20,12 +20,12 @@ 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, repeat, repeat_config, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING id, title, description, status, repeat, repeat_config, 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.Repeat, task.Config, task.CreatedAt, task.UpdatedAt) created, err := scanTask(row) if err != nil { return nil, err @@ -36,7 +36,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, repeat, repeat_config, created_at, updated_at FROM tasks WHERE id = $1 ` @@ -60,12 +60,14 @@ 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 + repeat = $4, + repeat_config = $5, + updated_at = $6 + WHERE id = $7 + RETURNING id, title, description, status, repeat, repeat_config, 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.Repeat, task.Config, task.UpdatedAt, task.ID) updated, err := scanTask(row) if err != nil { if errors.Is(err, pgx.ErrNoRows) { @@ -95,7 +97,7 @@ 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, repeat, repeat_config, created_at, updated_at FROM tasks ORDER BY id DESC ` @@ -131,6 +133,7 @@ func scanTask(scanner taskScanner) (*taskdomain.Task, error) { var ( task taskdomain.Task status string + repeat string ) if err := scanner.Scan( @@ -138,6 +141,8 @@ func scanTask(scanner taskScanner) (*taskdomain.Task, error) { &task.Title, &task.Description, &status, + &repeat, + &task.Config, &task.CreatedAt, &task.UpdatedAt, ); err != nil { @@ -145,6 +150,7 @@ func scanTask(scanner taskScanner) (*taskdomain.Task, error) { } task.Status = taskdomain.Status(status) + task.Repeat = taskdomain.RepeatInterval(repeat) return &task, nil } diff --git a/internal/transport/http/docs/openapi.json b/internal/transport/http/docs/openapi.json index 5effedb7..9142e3b6 100644 --- a/internal/transport/http/docs/openapi.json +++ b/internal/transport/http/docs/openapi.json @@ -169,6 +169,22 @@ "type": "string", "enum": ["new", "in_progress", "done"], "example": "new" + }, + "repeat": { + "type": "string", + "enum": ["never", "daily", "monthly", "custom", "even_days", "odd_days"], + "example": "daily" + }, + "config": { + "type": "object", + "properties": { + "daily_interval": { "type": "integer" }, + "month_day": { "type": "integer" }, + "specific_dates": { + "type": "array", + "items": { "type": "string", "format": "date-time" } + } + } } } }, @@ -188,6 +204,22 @@ "type": "string", "enum": ["new", "in_progress", "done"], "example": "in_progress" + }, + "repeat": { + "type": "string", + "enum": ["never", "daily", "monthly", "custom", "even_days", "odd_days"], + "example": "daily" + }, + "config": { + "type": "object", + "properties": { + "daily_interval": { "type": "integer" }, + "month_day": { "type": "integer" }, + "specific_dates": { + "type": "array", + "items": { "type": "string", "format": "date-time" } + } + } } } }, @@ -205,6 +237,22 @@ "enum": ["new", "in_progress", "done"], "example": "new" }, + "repeat": { + "type": "string", + "enum": ["never", "daily", "monthly", "custom", "even_days", "odd_days"], + "example": "never" + }, + "config": { + "type": "object", + "properties": { + "daily_interval": { "type": "integer" }, + "month_day": { "type": "integer" }, + "specific_dates": { + "type": "array", + "items": { "type": "string", "format": "date-time" } + } + } + }, "created_at": { "type": "string", "format": "date-time", diff --git a/internal/transport/http/handlers/dto.go b/internal/transport/http/handlers/dto.go index ed00af96..078fd494 100644 --- a/internal/transport/http/handlers/dto.go +++ b/internal/transport/http/handlers/dto.go @@ -7,18 +7,22 @@ import ( ) type taskMutationDTO struct { - Title string `json:"title"` - Description string `json:"description"` - Status taskdomain.Status `json:"status"` + Title string `json:"title"` + Description string `json:"description"` + Status taskdomain.Status `json:"status"` + Repeat taskdomain.RepeatInterval `json:"repeat,omitempty"` + Config *taskdomain.RepeatConfig `json:"config,omitempty"` } 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"` + Repeat taskdomain.RepeatInterval `json:"repeat,omitempty"` + Config *taskdomain.RepeatConfig `json:"config,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } func newTaskDTO(task *taskdomain.Task) taskDTO { @@ -27,6 +31,8 @@ func newTaskDTO(task *taskdomain.Task) taskDTO { Title: task.Title, Description: task.Description, Status: task.Status, + Repeat: task.Repeat, + Config: task.Config, CreatedAt: task.CreatedAt, UpdatedAt: task.UpdatedAt, } diff --git a/internal/transport/http/handlers/task_handler.go b/internal/transport/http/handlers/task_handler.go index 7360a561..9684621b 100644 --- a/internal/transport/http/handlers/task_handler.go +++ b/internal/transport/http/handlers/task_handler.go @@ -31,6 +31,8 @@ func (h *TaskHandler) Create(w http.ResponseWriter, r *http.Request) { Title: req.Title, Description: req.Description, Status: req.Status, + Repeat: req.Repeat, + Config: req.Config, }) if err != nil { writeUsecaseError(w, err) @@ -73,6 +75,8 @@ func (h *TaskHandler) Update(w http.ResponseWriter, r *http.Request) { Title: req.Title, Description: req.Description, Status: req.Status, + Repeat: req.Repeat, + Config: req.Config, }) if err != nil { writeUsecaseError(w, err) diff --git a/internal/usecase/task/ports.go b/internal/usecase/task/ports.go index 23bb1645..ed745398 100644 --- a/internal/usecase/task/ports.go +++ b/internal/usecase/task/ports.go @@ -26,10 +26,14 @@ type CreateInput struct { Title string Description string Status taskdomain.Status + Repeat taskdomain.RepeatInterval + Config *taskdomain.RepeatConfig } type UpdateInput struct { Title string Description string Status taskdomain.Status + Repeat taskdomain.RepeatInterval + Config *taskdomain.RepeatConfig } diff --git a/internal/usecase/task/service.go b/internal/usecase/task/service.go index 2def18c2..be8bac7c 100644 --- a/internal/usecase/task/service.go +++ b/internal/usecase/task/service.go @@ -31,6 +31,8 @@ func (s *Service) Create(ctx context.Context, input CreateInput) (*taskdomain.Ta Title: normalized.Title, Description: normalized.Description, Status: normalized.Status, + Repeat: normalized.Repeat, + Config: normalized.Config, } now := s.now() model.CreatedAt = now @@ -67,6 +69,8 @@ func (s *Service) Update(ctx context.Context, id int64, input UpdateInput) (*tas Title: normalized.Title, Description: normalized.Description, Status: normalized.Status, + Repeat: normalized.Repeat, + Config: normalized.Config, UpdatedAt: s.now(), } @@ -106,6 +110,14 @@ func validateCreateInput(input CreateInput) (CreateInput, error) { return CreateInput{}, fmt.Errorf("%w: invalid status", ErrInvalidInput) } + if input.Repeat == "" { + input.Repeat = taskdomain.RepeatNever + } + + if !input.Repeat.Valid() { + return CreateInput{}, fmt.Errorf("%w: invalid repeat type", ErrInvalidInput) + } + return input, nil } @@ -121,5 +133,13 @@ func validateUpdateInput(input UpdateInput) (UpdateInput, error) { return UpdateInput{}, fmt.Errorf("%w: invalid status", ErrInvalidInput) } + if input.Repeat == "" { + input.Repeat = taskdomain.RepeatNever + } + + if !input.Repeat.Valid() { + return UpdateInput{}, fmt.Errorf("%w: invalid repeat type", ErrInvalidInput) + } + return input, nil }