Untitled

4ae4d avatar
4ae4d
plain_text
04/14/2026 9:37 AM
96.4 KB
7
Indexable
f=docs/integration-notes.md
f=docs/domain-model.md
f=docs/buisiness-model.md
f=services/backend/internal/drivers/http/v1/volunteer_card/volunteer_card_controller.go
f=services/backend/internal/drivers/http/v1/volunteer_tag/volunteer_tag_controller.go
f=services/backend/internal/drivers/http/v1/volunteer_tag/volunteer_tag_requests.go
f=services/backend/internal/drivers/http/v1/routes.go
f=services/backend/internal/domain/volunteer_card/volunteer_card.go
f=services/backend/internal/domain/volunteer_card/volunteer_card_service.go
f=services/backend/internal/domain/volunteer_tags/volunteer_tag.go
f=services/backend/internal/domain/volunteer_tags/volunteer_tag_service.go
f=services/backend/internal/adapters/volunteer_card/volunteer_card_query.go
f=services/backend/internal/adapters/volunteer_tags/volunteer_tag_schema.go
f=services/backend/internal/adapters/volunteer_tags/volunteer_tag_repo.go
f=services/backend/internal/adapters/tags/tag_schema.go
f=services/bot/core/clients/tag.py
f=services/bot/core/services/tag.py
f=services/bot/core/handlers/profile.py
f=services/bot/core/markups/profile.py
f=services/bot/core/markups/tags.py
f=services/bot/core/utils/tags_toggle_ui.py
f=services/bot/core/schemas/tag.py
f=services/bot/core/schemas/user.py
f=services/bot/core/dependencies/tag.py
--- docs/integration-notes.md ---
# Этот файл описывает процесс перехода с Python backend на Go backend

- файл описывает изменения на ветке `integration/go-backend`
- эту ветку потом планируется вливать в `main`, а текущий оставить как legacy python mvp
- в связи с объёмным кодом, слоёной архитектурой и изменением множества параметров в ТЗ, почти весь старый фронт не работает, за исключением переделанных методов. не рабочие методы в следующем коммите буду помечены комментарием # untested

## рабочие методы и сценарии:

1. `ensure_exists` on /start
- дергает эндпоинт `put /volunteers`
2. `my_profile_handler` при нажатии на кнопку с id `my_profile`
- дергает эндпоинт `get /volunteers`
- также был изменён класс `UserSchema`, что наверняка поломает почти всё, что его использует.

--- docs/domain-model.md ---
        ## роли (от младшего к старшему)

### волонтёр

#### чем является

* базовая роль для всех сотрудников, использующих бота

#### может

* видеть ленту мероприятий (по умолчанию по фактическому рабочему месту)
* взаимодействовать с мероприятием (открывать карточку, записываться/отписываться, вводить код)
* видеть свой прогресс (волонтёро-часы, участие в мероприятиях)
* создавать заявки:
  * на разовое мероприятие (вне проекта) → адресат: модератор ТБ
  * на мероприятие в рамках проекта → адресат: лидер проекта
  * на создание проекта / я — лидер проекта → адресат: модератор ТБ

* если волонтёр назначен организатором конкретного мероприятия, то он получает расширенные права организатора этого мероприятия
* организатор мероприятия — это не отдельная глобальная роль пользователя, а контекстная роль в рамках конкретного мероприятия
* организатор мероприятия может:
  * редактировать карточку мероприятия
  * удалять мероприятие
  * видеть список участников и их статус подтверждения
  * создавать отчёт по мероприятию
  * если он является автором заявки на создание этого мероприятия и стал его организатором, то он может добавить не более двух дополнительных организаторов
  * управлять кодом подтверждения мероприятия:
    * код для мероприятия один общий для всех участников

#### доступ к отчётам

* персональная история участия (свои мероприятия/часы)
* отчёты по своим мероприятиям только если он организатор этих мероприятий

#### ответственность

* за себя и свои действия
* за свои мероприятия, в которых он организатор

---

### лидер проекта

#### чем является

* проверенный волонтёр, ведущий долгосрочное направление (проект)
* назначается модератором ТБ
* роль остаётся у человека даже без активных проектов

#### может (помимо волонтёра)

* автономно создавать любые мероприятия (включая разовые) без одобрения модератора
* одобрять заявки на создание мероприятий в рамках своего проекта
* подавать заявку на создание ещё одного проекта (к модератору)
* по мероприятию в рамках проекта, созданному по заявке, является равноправным организатором вместе с подавшим заявку

#### доступ к отчётам

* по своим проектам

#### ответственность

* за проекты, где он назначен лидером
* за обработку заявок на мероприятия в этих проектах

---

### модератор ТБ

#### чем является

* роль на уровне тербанка (ТБ)
* основной куратор в ТБ

#### может (помимо лидера проекта)

* обрабатывать заявки:
  * на создание проекта (по одобрению создаётся проект и назначается лидер проекта)
  * на создание разовых мероприятий от волонтёров
  * на создание проекта от текущих лидеров проектов

* назначать/снимать лидеров проектов
* выгружать статистику/отчёты по своему ТБ
* отвечать за корректное разделение Москва/Среднерусский/ЦА, если относится к его зоне
* иметь доступ к архивным мероприятиям своего ТБ

#### доступ к отчётам

* по своему тербанку
* по архивным мероприятиям своего ТБ

#### ответственность

* за модерацию в своём ТБ
* при наличии нескольких модераторов ТБ ответственность между ними равная

---

### владелец продукта

#### чем является

* высший уровень доступа

#### может (помимо модератора ТБ)

* назначать/снимать модераторов по всем ТБ

#### доступ к отчётам

* по всему банку

#### ответственность

* за иерархию доступов и контроль ролей

---

## термины и сущности (по возможности от “младшего” к “старшего”)

### заявка

#### чем является

* механизм согласования действий, требующих одобрения

#### кто создаёт

* волонтёр:

  * создать разовое мероприятие (без проекта) → модератор ТБ
  * создать мероприятие в проекте → лидер проекта
  * создать проект / я — лидер проекта → модератор ТБ
* лидер проекта:

  * создать новый проект → модератор ТБ

#### содержит поля

* тип заявки: создать проект / создать разовое мероприятие / создать мероприятие в проекте
* автор заявки
* адресат (модератор ТБ или лидер проекта)
* данные создаваемой сущности (поля проекта или мероприятия)
* статус: на согласовании / принята / отклонена / ждёт вашего ответа
* комментарий ответственного (опционально)
* дата создания, дата обработки

#### результат обработки

* для заявки на проект: создаётся проект; автору назначается роль лидер проекта; автор становится лидером созданного проекта
* для заявки на мероприятие: создаётся мероприятие; автор становится организатором созданного мероприятия
* для заявки на мероприятие в рамках проекта: лидер проекта и автор заявки становятся равноправными организаторами мероприятия

---

### участие

#### чем является

* связь волонтёр <-> мероприятие, фиксирующая запись и подтверждение участия

#### содержит поля

* волонтёр
* мероприятие
* статус: записан / подтверждено кодом
* использованный код (если подтверждено)
* начисленные часы (из кода; фиксируются при подтверждении)
* даты: запись / подтверждение

#### ограничения

* для пары волонтёр + мероприятие существует максимум одна запись участия
* после подтверждения запрещён ввод любых кодов по этому мероприятию
* доступ к архивному мероприятию для обычного пользователя определяется наличием записи участия в этом мероприятии

---

### код подтверждения

#### чем является

* механизм верификации участия после мероприятия
* содержит стоимость в часах, это источник истины по начислению часов
* для каждого мероприятия используется один общий код подтверждения для всех участников
* код не должен совпадать с кодами, использовавшимися в предыдущие 2 месяца
* при создании кода система должна предлагать сгенерировать код, иначе давать подсказку по его составлению

#### содержит поля

* код
* мероприятие
* часы, начисляемые при вводе этого кода

---

### отчёт

#### чем является

* закрывающий отчёт по мероприятию
* после создания отчёта мероприятие считается завершённым и переводится в архив

#### содержит поля

* количество благополучателей
* описание как прошло
* дата закрытия
* уточнить, какие еще поля нужны

---

### мероприятие

#### чем является

* карточка события, на которое записываются волонтёры
* после создания доступно для отображения в ленте
* после создания отчёта переводится в архив

#### содержит поля

* название
* описание
* дата и время проведения (начало; при необходимости конец)
* место/адрес
* ГОСП
* количество требуемых волонтёров
* список организаторов
* проект (может отсутствовать для разового мероприятия)
* признак архивности
* код подтверждения (1)

#### поведение

* волонтёр: записаться/отписаться; ввести код (один раз на мероприятие)
* организатор: редактировать/удалять; смотреть участников; создавать отчёт
* подавший заявку и ставший организатором может добавить не более двух дополнительных организаторов
* для мероприятия в рамках проекта лидер проекта и подавший заявку являются равноправными организаторами
* модератор имеет доступ к архивным мероприятиям
* остальные имеют доступ к архивным мероприятиям только если у них есть участие в этом мероприятии

---

### проект

#### чем является

* долгосрочное направление, объединяющее серию мероприятий
* принадлежит одному тербанку
* если проект нужен в нескольких тербанках, создаются независимые проекты без связи

#### содержит поля

* название
* описание
* лидер проекта
* тербанк-владелец проекта
* состояние актива

#### правила

* у проекта всегда один текущий лидер
* роль “лидер проекта” как роль пользователя и назначение пользователя лидером конкретного проекта — не одно и то же
* лидер проекта создаёт мероприятия без премодерации
* волонтёры создают мероприятия в проекте через заявку лидеру проекта
* проектная отчётность строится по мероприятиям проекта
* должна поддерживаться архивация, деархивация и передача лидерства проекта

---

### ГОСП

#### чем является

* основная единица привязки мероприятий
* ключевой уровень локальности для ленты

#### содержит поля

* название/код (из справочника)
* привязка к тербанку
* город/адрес (опционально)

---

### тербанк (ТБ)

#### чем является

* территориальная единица банка

#### содержит поля

* название
* территория/регион
* модераторы ТБ (0..\*)

---

### банк + особые сущности Москвы

#### чем является

* совокупность тербанков
* Москва разделена на 3 независимые сущности отчётности: Московский банк, Среднерусский банк, Центральный аппарат (ЦА)

#### статус формализации

* требуется уточнить, являются ли Московский банк / Среднерусский банк / ЦА отдельными сущностями модели или специальным разрезом отчётности

---

## ключевая логика (подтверждение и статистика)

### часы

* единого норматива нет
* часы задаются на уровне кода подтверждения (источник истины)

### верификация

* после мероприятия организатор выдаёт код
* ввод кода:

  * подтверждает участие
  * начисляет часы в профиль
  * запрещает ввод любых других кодов по этому мероприятию

### ключевые метрики (минимум)

* уникальные волонтёры
* суммарные волонтёро-часы
* благополучатели
* количество мероприятий

---

## связи и кратности

* банк (1) — тербанк (1..\*)
* тербанк (1) — ГОСП (1..\*)
* ГОСП (1) — мероприятие (0..\*)
* проект (1) — мероприятие (0..\*)
  (у мероприятия проект может отсутствовать; если проект указан, то он один)
* мероприятие (1) — код подтверждения (1)
* мероприятие (1) — отчёт (0..1)
* волонтёр (1) — участие (0..\*)
* мероприятие (1) — участие (0..\*)
* код подтверждения (1) — участие (0..\*)
  (код появляется в участии только при подтверждении; один код мероприятия может быть использован многими подтверждёнными участиями этого мероприятия)
* волонтёр (1) — заявка (0..\*)
* проект (1) — лидер проекта (1)
* тербанк (1) — модератор ТБ (0..\*)
* волонтёр (0..\*) — мероприятие (0..\*)
  (связь “организатор мероприятия”)

---

## места, которые надо уточнить

* конкретное максимальное количество организаторов мероприятия
* считать ли признак архивности самостоятельным полем или производным признаком от факта создания отчёта
* нужна ли отдельная сущность/журнал истории изменений мероприятия, если в выгрузке должны отражаться все состояния данных
* формальная модель Москва/Среднерусский/ЦА: отдельные сущности модели или специальный разрез отчётности



--- docs/buisiness-model.md ---
# Существующие роли

> [!NOTE]
>
> в документе рассматриваем роли в таком контексте: \
> ***Модератор*** в своём ТБ; \
> ***Организатор*** в мероприятии, которое он организует; \
> вне этих контекстов и ***Модератор*** и ***Организатор*** -- обычные волонтёры.

## 1. Волонтёр
## 2. Организатор
## 3. Лидер проекта
## 4. Модератор ТБ
## 5. Владелец

---

# Описание бизнесс процессов бота `Волонтёрам ПомогAI`

---

## 1. Запись на мероприятие

### Акторы:

#### Основной: Волонтёр
#### Дополнительные: -

### Описание:

***Волонтёр*** выбирает мероприятие из множества доступных, заходит в карточку мероприятия, нажимает "записаться".

### Последующие возможные процессы:

- Подтверждение участия через код

---

## 2. Подтверждение участия через код

### Акторы:

#### Основной: Волонтёр
#### Дополнительные: Организатор

### Описание:

При посещении мероприятия ***Волонтёром*** (state, не контролируемый ботом) может получить код от ***Оргазизатора***, которым ***Волонтёр*** может подтвердить участие в мероприятии.

### Последующие возможные процессы:

- Получение баллов ***Волонтёром*** за соответствующее мероприятие

---

## 3. Создание заявки на разовое мероприятие

### Акторы:

#### Основной: Волонтёр
#### Дополнительные: Модератор

### Описание:

***Волонтёр*** может подать заявку на создание разового мероприятия (т.е. не в рамках проекта), которую будет обрабатывать ***Модератор***

### Последующие возможные процессы:

- Обработка заявки на разовое мероприятие

---

## 4. Обработка заявки на разовое мероприятие

### Акторы:

#### Основной: Модератор
#### Дополнительные: Волонтёр

### Описание:

***Модераторы*** имеют доступ к актуальным заявкам, могут выбрать интересующую заявку из списка, и обработать её.

Возможные состояния заявки на разовое мероприятие: `на согласовании` / `принята` / `отклонена` / `ждёт вашего ответа`; подавший заявку видит её статус

Заявка считается обработанной, когда попадает в состояние `принята` / `отклонена`

### Последующие возможные процессы:

- Создание мероприятия с полями, соответствующими указанным в заявке
- Отказ в создании мероприятия

---

## 5. Создание заявки на мероприятия в рамках проекта

### Акторы:

#### Основной: Волонтёр
#### Дополнительные: Лидер проекта

### Описание:

***Волонтёр*** может подать заявку на создание мероприятия в рамках проекта, которую будет обрабатывать ***Лидер проекта*** (соответствующего)

### Последующие возможные процессы:

- Обработка заявки на мероприятие в рамках проекта
- Смена роли ***Волонтёр*** -> ***Организатор*** в случае одобрения (контекстно)

---

## 6. Обработка заявки на мероприятие в рамках проекта

### Акторы:

#### Основной: Лидер проекта
#### Дополнительные: Волонтёр

### Описание:

***Лидер проекта*** имеет доступ к актуальным заявкам, может выбрать интересующую заявку из списка, и обработать её.

Возможные состояния заявки на мероприятие в рамках проекта: `на согласовании` / `принята` / `отклонена` / `ждёт вашего ответа`; подавший заявку видит её статус

Заявка считается обработанной, когда попадает в состояние `принята` / `отклонена`

В случае одобрения, ***Лидер проекта*** автоматически назначается ***Организатором*** мероприятия (на ряду с заявившим)

### Последующие возможные процессы:

- Создание мероприятия с полями, соответствующими указанным в заявке
- Смена роли ***Волонтёр*** -> ***Организатор*** в случае одобрения (контекстно)
- Отказ в создании мероприятия

---

## 7. Создание заявки на проект

### Акторы:

#### Основной: Волонтёр / Лидер проекта
#### Дополнительные: Модератор

### Описание:

***Волонтёр*** / ***Лидер проекта*** может подать заявку на создание проекта, которую будет обрабатывать ***Модератор***

### Последующие возможные процессы:

- Обработка заявки на создание проекта

---

## 8. Обработка заявки на создание проекта

### Акторы:

#### Основной: Модератор
#### Дополнительные: Волонтёр / Лидер проекта

### Описание:

***Модератор*** имеет доступ к актуальным заявкам, может выбрать интересующую заявку из списка, и обработать её.

Возможные состояния заявки на проект: `на согласовании` / `принята` / `отклонена` / `ждёт вашего ответа`; подавший заявку видит её статус

Заявка считается обработанной, когда попадает в состояние `принята` / `отклонена`

### Последующие возможные процессы:

- Отказ в создании проекта
- Создание проекта с полями, соответствующими указанным в заявке
- Смена роли ***Волонтёр*** -> ***Лидер проекта***
  - ЕСЛИ заявка одобрена
  - И роль заявителя - ***Волонтёр***

> [!NOTE]
>
> ***Лидер проекта*** - это не контекстная роль. т.е. в таком случае пользователь будет ***Лидером проекта*** всегда и глобально.

---

## 9. Управление мероприятием

### Акторы:

#### Основной: Организатор
#### Дополнительные: -

### Описание:

***Организатор*** может менять все доступные для изменения поля, в соответствии с domain model, также может удалить мероприятие

Например:
  - Изменение имени
  - Изменение описания
  - Изменение состава организаторов
  - Создание отчёта по мероприятию

### Последующие возможные процессы:

- Отмена мероприятия
- Изменение мероприятия
- Процесс завершения мероприятия: создание отчёта и помещение его в архив

---

## 10. Управление проектом

### Акторы:

#### Основной: Лидер проекта
#### Дополнительные: -

### Описание:

***Лидер проекта*** может менять все доступные для изменения поля, в соответствии с domain model, также может удалить мероприятие


### Последующие возможные процессы:

Например:
  - Изменение имени
  - Изменение описания
  - Перевод проекта в инактив

---

## 11. Управление составом Лидеров проекта

### Акторы:

#### Основной: Модератор
#### Дополнительные: -

### Описание:

***Модератор*** может пометить проект как неактивный в случае необходимости, например, в случае невозможностью продолжения ведения проекта ***Лидером проекта***

---

## 12. Управление составом Модераторов ТБ

### Акторы:

#### Основной: Владелец
#### Дополнительные: Волонтёр / Модератор

### Описание:

***Владелец*** может назначать и снимать Модераторов ТБ для любой пары:
  - ***Волонтёр*** <=> ТБ;
  - ***Модератор ТБ*** <=> ТБ.


### Последующие возможные процессы:

Назначение и снятие ***Модератора ТБ***.

---

## 13. Создание мероприятия Лидером проекта

### Акторы:

#### Основной: Лидер проекта
#### Дополнительные: -

### Описание:

***Лидер проекта*** может создавать мероприятия как в рамках проекта, так и вне проекта, без создания заявок и другого рода одобрений.

### Последующие возможные процессы:

- Создание мероприятия в рамках проекта
- Создание мероприятия не в рамках проекта

---

## 14. Доступ к отчётам

### Акторы:

#### Основной: Организатор / Лидер проекта / Модератор / Владелец
#### Дополнительные: -

### Описание:

Пользователь имеет доступ к отчётам по мероприятиям в соответствии со своими правами:
  - ***Организатор*** -- к мероприятиям, которые организует / организовал;
  - ***Лидер проекта*** -- к мероприятиям, которые организует / организовал;
  - ***Модератор*** -- к мероприятиям его ТБ + к мероприятиям, которые организует / организовал;
  - ***Владелец*** -- ко всем мероприятиям.

> [!NOTE]
>
> ***Лидер проекта*** является ***Организатором*** во всех мероприятиях своего проекта \
> Доступ к отчетам значит возможность создавать/изменять/просматривать отчеты

### Последующие возможные процессы:

- Получение отчётов по мероприятиям
  - В файле .xlsx
  - В карточке мероприятия

---

## 14. Доступ к архивным мероприятиям

### Акторы:

#### Основной: Волонтёр / Организатор / Лидер проекта / Модератор / Владелец
#### Дополнительные: -

### Описание:

Пользователь имеет доступ к карточке мероприятия в соответствии со своими правами:
  - ***Волонтёр*** -- к мероприятиям, в которых участвовал
  - ***Организатор*** -- к мероприятиям, которые организовал + к мероприятиям, в которых участвовал;
  - ***Лидер проекта*** -- к мероприятиям, которые организовал + к мероприятиям, в которых участвовал;
  - ***Модератор*** -- к мероприятиям его ТБ + к мероприятиям, которые организовал + к мероприятиям, в которых участвовал;
  - ***Владелец*** -- ко всем мероприятиям.

> [!NOTE]
>
> ***Лидер проекта*** является ***Организатором*** во всех мероприятиях своего проекта

### Последующие возможные процессы:

- Получение информации по мероприятиям в формате доступа к карточке мероприятия



--- services/backend/internal/drivers/http/v1/volunteer_card/volunteer_card_controller.go ---
package volunteer_card_http

import (
	"errors"
	volunteer_domain "main/internal/domain/volunteer"
	"main/internal/domain/volunteer_card"
	"main/pkg"
	"net/http"

	"github.com/gin-gonic/gin"
)

type VolunteerCardController struct {
	volunteerCardService *volunteer_card.VolunteerCardService
	logger               pkg.Logger
}

func NewVolunteerCardController(
	volunteerCardService *volunteer_card.VolunteerCardService,
	logger pkg.Logger,
) *VolunteerCardController {
	return &VolunteerCardController{
		volunteerCardService: volunteerCardService,
		logger:               logger,
	}
}

// GetVolunteerCard godoc
// @Summary      Get volunteer card
// @Description  Returns volunteer card.
// @Tags         volunteers
// @Accept       json
// @Produce      json
// @Security     BearerAuth
// @Success      200  {object}  volunteer_card.VolunteerCard  "Volunteer card retrieved successfully"
// @Failure      401  {object}  map[string]interface{}        "Unauthorized"
// @Failure      404  {object}  map[string]interface{}        "Volunteer not found"
// @Failure      500  {object}  map[string]interface{}        "Internal server error"
// @Router       /volunteers/card [get]
func (controller *VolunteerCardController) GetVolunteerCard(ctx *gin.Context) {
	userId := ctx.GetString("user_id")

	card, err := controller.volunteerCardService.GetVolunteerCard(userId)
	if err != nil {
		if errors.Is(err, volunteer_domain.ErrVolunteerNotFound) {
			ctx.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
			return
		}

		ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}

	ctx.JSON(http.StatusOK, card)
}

--- services/backend/internal/drivers/http/v1/volunteer_tag/volunteer_tag_controller.go ---
package volunteer_tag_http

import (
	"errors"
	volunteer_tag "main/internal/domain/volunteer_tags"
	"main/pkg"
	"net/http"

	"github.com/gin-gonic/gin"
)

type VolunteerTagController struct {
	volunteerTagService *volunteer_tag.VolunteerTagService
	logger              pkg.Logger
}

func NewVolunteerTagController(
	volunteerTagService *volunteer_tag.VolunteerTagService,
	logger pkg.Logger,
) *VolunteerTagController {
	return &VolunteerTagController{
		volunteerTagService: volunteerTagService,
		logger:              logger,
	}
}

// SetTag godoc
// @Summary      Add tag to volunteer
// @Description  Adds a tag to the volunteer's profile. The volunteer is identified by the JWT token.
// @Tags         volunteers
// @Accept       json
// @Produce      json
// @Security     BearerAuth
// @Param        request body SetTagRequest true "Tag ID to add"
// @Success      200 {object} map[string]interface{} "Tag added successfully"
// @Failure      400 {object} map[string]interface{} "Bad request (tag already set or invalid data)"
// @Failure      401 {object} map[string]interface{} "Unauthorized"
// @Failure      404 {object} map[string]interface{} "Tag not found"
// @Failure      500 {object} map[string]interface{} "Internal server error"
// @Router       /volunteers/tags [post]
func (controller *VolunteerTagController) SetTag(ctx *gin.Context) {
	userId := ctx.GetString("user_id")

	var req *SetTagRequest
	if err := ctx.ShouldBindJSON(&req); err != nil {
		controller.logger.Error(err)

		ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})

		return
	}

	if err := controller.volunteerTagService.SetTag(userId, req.TagId); err != nil {
		if errors.Is(err, volunteer_tag.ErrTagAlreadySet) ||
			errors.Is(err, volunteer_tag.ErrTagNotSet) {
			ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
			return
		}

		if errors.Is(err, volunteer_tag.ErrTagNotFound) {
			ctx.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
			return
		}

		ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}

	ctx.JSON(http.StatusOK, gin.H{
		"message": "tag set",
	})
}

// RemoveTag godoc
// @Summary      Remove tag from volunteer
// @Description  Removes a tag from the volunteer's profile. The volunteer is identified by the JWT token.
// @Tags         volunteers
// @Accept       json
// @Produce      json
// @Security     BearerAuth
// @Param        request body RemoveTagRequest true "Tag ID to remove"
// @Success      200 {object} map[string]interface{} "Tag removed successfully"
// @Failure      400 {object} map[string]interface{} "Bad request (tag not set or invalid data)"
// @Failure      401 {object} map[string]interface{} "Unauthorized"
// @Failure      404 {object} map[string]interface{} "Tag not found"
// @Failure      500 {object} map[string]interface{} "Internal server error"
// @Router       /volunteers/tags [delete]
func (controller *VolunteerTagController) RemoveTag(ctx *gin.Context) {
	userId := ctx.GetString("user_id")

	var req *RemoveTagRequest
	if err := ctx.ShouldBindJSON(&req); err != nil {
		controller.logger.Error(err)

		ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})

		return
	}

	if err := controller.volunteerTagService.RemoveTag(userId, req.TagId); err != nil {
		if errors.Is(err, volunteer_tag.ErrTagAlreadySet) ||
			errors.Is(err, volunteer_tag.ErrTagNotSet) {
			ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
			return
		}

		if errors.Is(err, volunteer_tag.ErrTagNotFound) {
			ctx.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
			return
		}

		ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}

	ctx.JSON(http.StatusOK, gin.H{
		"message": "tag removed",
	})
}

--- services/backend/internal/drivers/http/v1/volunteer_tag/volunteer_tag_requests.go ---
package volunteer_tag_http

type SetTagRequest struct {
	TagId string `json:"tag_id"`
}

type RemoveTagRequest struct {
	TagId string `json:"tag_id"`
}

--- services/backend/internal/drivers/http/v1/routes.go ---
package http_v1

import (
	_ "main/docs"
	"main/internal/drivers/http/middlewares"
	event_card_http "main/internal/drivers/http/v1/event_card"
	event_tags_http "main/internal/drivers/http/v1/event_tags"
	events_http "main/internal/drivers/http/v1/events"
	health_http "main/internal/drivers/http/v1/health"
	tags_http "main/internal/drivers/http/v1/tags"
	volunteer_http "main/internal/drivers/http/v1/volunteer"
	volunteer_card_http "main/internal/drivers/http/v1/volunteer_card"
	volunteer_tag_http "main/internal/drivers/http/v1/volunteer_tag"
	"main/pkg"

	"go.uber.org/fx"

	swaggerFiles "github.com/swaggo/files"
	ginSwagger "github.com/swaggo/gin-swagger"
)

type Routes struct {
	handler                 pkg.RequestHandler
	authMiddleware          *middlewares.AuthMiddleware
	testAuthMiddleware      *middlewares.TestAuthMiddleware
	healthController        *health_http.HealthController
	volunteerController     *volunteer_http.VolunteerController
	tagsController          *tags_http.TagsController
	volunteerTagsController *volunteer_tag_http.VolunteerTagController
	volunteerCardController *volunteer_card_http.VolunteerCardController
	eventController         *events_http.EventsController
	eventTagController      *event_tags_http.EventTagController
	eventCardController     *event_card_http.EventCardController
}

func NewRoutes(
	handler pkg.RequestHandler,
	authMiddleware *middlewares.AuthMiddleware,
	testAuthMiddleware *middlewares.TestAuthMiddleware,
	healthController *health_http.HealthController,
	volunteerController *volunteer_http.VolunteerController,
	tagsController *tags_http.TagsController,
	volunteerTagsController *volunteer_tag_http.VolunteerTagController,
	volunteerCardController *volunteer_card_http.VolunteerCardController,
	eventController *events_http.EventsController,
	eventTagController *event_tags_http.EventTagController,
	eventCardController *event_card_http.EventCardController,
) *Routes {
	return &Routes{
		handler:                 handler,
		authMiddleware:          authMiddleware,
		testAuthMiddleware:      testAuthMiddleware,
		healthController:        healthController,
		volunteerController:     volunteerController,
		tagsController:          tagsController,
		volunteerTagsController: volunteerTagsController,
		volunteerCardController: volunteerCardController,
		eventController:         eventController,
		eventTagController:      eventTagController,
		eventCardController:     eventCardController,
	}
}

func (r *Routes) Setup() {
	api := r.handler.Gin.Group("/api/v1/")

	// Swagger
	api.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))

	// Health
	api.GET("/health", r.healthController.Health)

	// Volunteer
	volunteerGroup := api.Group("/volunteers").Use(r.testAuthMiddleware.Handler())
	{
		volunteerGroup.GET("/card", r.volunteerCardController.GetVolunteerCard)
		volunteerGroup.POST("/tags", r.volunteerTagsController.SetTag)
		volunteerGroup.DELETE("/tags", r.volunteerTagsController.RemoveTag)
		volunteerGroup.GET("/", r.volunteerController.GetVolunteer)
		volunteerGroup.PUT("/", r.volunteerController.EnsureVolunteerExists)
	}

	// Tags
	tagsGroup := api.Group("tags")
	{
		tagsGroup.GET("/", r.tagsController.GetAllActualTags)
	}

	// Events
	eventsGroup := api.Group("/events").Use(r.testAuthMiddleware.Handler())
	{
		eventsGroup.GET("/card/list", r.eventCardController.GetEventCardList)
		eventsGroup.GET("/:event_id/card", r.eventCardController.GetEventCard)
		eventsGroup.POST("/:event_id/tags", r.eventTagController.SetTag)
		eventsGroup.DELETE("/:event_id/tags", r.eventTagController.RemoveTag)
		eventsGroup.POST("/", r.eventController.CreateEvent)
	}
}

var Module = fx.Options(
	fx.Provide(NewRoutes),
	fx.Provide(health_http.NewHealthController),
	fx.Provide(volunteer_http.NewVolunteerController),
	fx.Provide(tags_http.NewTagsController),
	fx.Provide(volunteer_tag_http.NewVolunteerTagController),
	fx.Provide(volunteer_card_http.NewVolunteerCardController),
	fx.Provide(events_http.NewEventController),
	fx.Provide(event_tags_http.NewEventTagController),
	fx.Provide(event_card_http.NewEventCardController),
)

--- services/backend/internal/domain/volunteer_card/volunteer_card.go ---
package volunteer_card

import (
	"main/internal/domain/tags"
	"main/internal/domain/volunteer"
)

type VolunteerCard struct {
	Volunteer *volunteer.Volunteer `json:"volunteer"`
	Tags      []*tags.Tag          `json:"tags"`
}

--- services/backend/internal/domain/volunteer_card/volunteer_card_service.go ---
package volunteer_card

type VolunteerCardService struct {
	query VolunteerCardQuery
}

func NewVolunteerCardService(query VolunteerCardQuery) *VolunteerCardService {
	return &VolunteerCardService{
		query: query,
	}
}

func (s *VolunteerCardService) GetVolunteerCard(messengerId string) (*VolunteerCard, error) {
	card, err := s.query.GetVolunteerCardByMessengerId(messengerId)
	if err != nil {
		return nil, err
	}

	return card, nil
}

--- services/backend/internal/domain/volunteer_tags/volunteer_tag.go ---
package volunteer_tags

import "time"

type VolunteerTag struct {
	TagId       string    `json:"tag_id"`
	MessengerId string    `json:"messenger_id"`
	CreatedAt   time.Time `json:"created_at"`
}

--- services/backend/internal/domain/volunteer_tags/volunteer_tag_service.go ---
package volunteer_tags

type VolunteerTagService struct {
	volunteerTagRepo VolunteerTagRepository
}

func NewVolunteerTagService(
	volunteerTagRepo VolunteerTagRepository,
) *VolunteerTagService {
	return &VolunteerTagService{
		volunteerTagRepo: volunteerTagRepo,
	}
}

func (s *VolunteerTagService) SetTag(messengerId string, tagId string) error {
	if err := s.volunteerTagRepo.Create(messengerId, tagId); err != nil {
		return err
	}

	return nil
}

func (s *VolunteerTagService) RemoveTag(messengerId string, tagId string) error {
	if err := s.volunteerTagRepo.Delete(messengerId, tagId); err != nil {
		return err
	}

	return nil
}

func (s *VolunteerTagService) GetVolunteerTags(messengerId string) ([]*VolunteerTag, error) {
	tags, err := s.volunteerTagRepo.GetVolunteerTags(messengerId)
	if err != nil {
		return nil, err
	}

	return tags, nil
}

--- services/backend/internal/adapters/volunteer_card/volunteer_card_query.go ---
package volunteer_card_adapters

import (
	"errors"
	tags_adapters "main/internal/adapters/tags"
	volunteer_apadters "main/internal/adapters/volunteer"
	"main/internal/domain/tags"
	volunteer_domain "main/internal/domain/volunteer"
	"main/internal/domain/volunteer_card"
	"main/pkg"

	"gorm.io/gorm"
)

type GormVolunteerCardQuery struct {
	gormDb pkg.GormDB
}

func NewGormVolunteerCardQuery(gormDb pkg.GormDB) volunteer_card.VolunteerCardQuery {
	return &GormVolunteerCardQuery{gormDb: gormDb}
}

func (g *GormVolunteerCardQuery) GetVolunteerCardByMessengerId(messengerId string) (*volunteer_card.VolunteerCard, error) {
	var volunteerSchema volunteer_apadters.VolunteerSchema
	err := g.gormDb.
		Where("messenger_id = ?", messengerId).
		First(&volunteerSchema).Error

	if err != nil {
		if errors.Is(err, gorm.ErrRecordNotFound) {
			return nil, volunteer_domain.ErrVolunteerNotFound
		}
		return nil, err
	}

	var tagSchemas []tags_adapters.TagSchema
	err = g.gormDb.
		Table("tags").
		Select("tags.*").
		Joins("JOIN volunteer_tags ON volunteer_tags.tag_id = tags.id").
		Where("volunteer_tags.messenger_id = ?", messengerId).
		Find(&tagSchemas).Error

	if err != nil {
		return nil, err
	}

	volunteerDomain := volunteer_apadters.ToDomainVolunteer(&volunteerSchema)

	tagsDomain := make([]*tags.Tag, 0, len(tagSchemas))
	for _, ts := range tagSchemas {
		tagsDomain = append(tagsDomain, tags_adapters.ToDomainTag(&ts))
	}

	return &volunteer_card.VolunteerCard{
		Volunteer: volunteerDomain,
		Tags:      tagsDomain,
	}, nil
}

--- services/backend/internal/adapters/volunteer_tags/volunteer_tag_schema.go ---
package volunteer_tag_adapters

import (
	"time"
)

type VolunteerTagSchema struct {
	MessengerId string    `gorm:"column:messenger_id;primaryKey;type:varchar(255);not null"`
	TagId       string    `gorm:"column:tag_id;primaryKey;type:varchar(255);not null"`
	CreatedAt   time.Time `gorm:"column:created_at"`
}

func (VolunteerTagSchema) TableName() string {
	return "volunteer_tags"
}

--- services/backend/internal/adapters/volunteer_tags/volunteer_tag_repo.go ---
package volunteer_tag_adapters

import (
	"errors"
	"main/internal/domain/volunteer_tags"
	"main/pkg"
	"time"

	"gorm.io/gorm"
)

type GormVolunteerTagRepository struct {
	gormDb pkg.GormDB
}

func NewGormVolunteerTagRepository(gormDb pkg.GormDB) volunteer_tags.VolunteerTagRepository {
	return &GormVolunteerTagRepository{
		gormDb: gormDb,
	}
}

func (r *GormVolunteerTagRepository) Create(messengerId, tagId string) error {
	schema := &VolunteerTagSchema{
		MessengerId: messengerId,
		TagId:       tagId,
		CreatedAt:   time.Now(),
	}

	err := r.gormDb.Create(schema).Error
	if err != nil {
		if errors.Is(err, gorm.ErrDuplicatedKey) {
			return volunteer_tags.ErrTagAlreadySet
		}
		return err
	}

	return nil
}

func (r *GormVolunteerTagRepository) Delete(messengerId, tagId string) error {
	result := r.gormDb.
		Where("messenger_id = ? AND tag_id = ?", messengerId, tagId).
		Delete(&VolunteerTagSchema{})

	if result.Error != nil {
		return result.Error
	}

	if result.RowsAffected == 0 {
		return volunteer_tags.ErrTagNotFound
	}

	return nil
}

func (r *GormVolunteerTagRepository) GetVolunteerTags(messengerId string) ([]*volunteer_tags.VolunteerTag, error) {
	var schemas []VolunteerTagSchema

	err := r.gormDb.
		Where("messenger_id = ?", messengerId).
		Find(&schemas).Error

	if err != nil {
		return nil, err
	}

	tags := make([]*volunteer_tags.VolunteerTag, 0, len(schemas))
	for _, schema := range schemas {
		tags = append(tags, ToVolunteerTagDomain(&schema))
	}

	return tags, nil
}

--- services/backend/internal/adapters/tags/tag_schema.go ---
package tags_adapters

import "time"

type TagSchema struct {
	Id          string `gorm:"column:id;primaryKey;type:varchar(255)"`
	Title       string `gorm:"column:title;not null;size:255"`
	Description string `gorm:"column:description;not null;size:255"`
	Depricated  bool   `gorm:"column:depricated;not null;default:false"`

	CreatedAt time.Time `gorm:"column:created_at"`
	UpdatedAt time.Time `gorm:"column:updated_at"`
}

func (TagSchema) TableName() string {
	return "tags"
}

--- services/bot/core/clients/tag.py ---
from core.clients.base import BaseApiClient


class TagClient(BaseApiClient):  # untested
    """
    Class representing user interests
    """

    def get_all_tags(self, offset=0, limit=10):  # untested
        return self.get("/tags/app/all", params={"offset": offset, "limit": limit})

    def sign_up(self, tag_id: str, messenger_id: int):  # untested
        return self.post(
            "/tags/app/sign-up", json={"app_tag_id": tag_id, "messenger_id": messenger_id}
        )

    def sign_out(self, user_tag_id: str):  # untested
        return self.delete("/tags/app/sign-out", json={"user_tag_id": user_tag_id})

    def get_user_tags(self, messenger_id: int):  # untested
        return self.get("/tags/my", params={"messenger_id": messenger_id})

--- services/bot/core/services/tag.py ---
from core.clients import TagClient
from core.schemas import TagSchema, UserTagSchema
from core.services.base import BaseService
from core.services.registry import BaseServiceRegistry


class TagService(BaseService):  # untested
    def __init__(self, registry: BaseServiceRegistry, client: TagClient):  # untested
        super().__init__(registry)
        self.client = client

    def get_all_tags(self) -> list[TagSchema]:  # untested
        resp = self.client.get_all_tags()
        return [
            TagSchema(id=item.get("id"), name=item.get("name"), description=item.get("description"))
            for item in resp.json()
        ]

    def get_user_tags(self, messenger_id: int) -> list[UserTagSchema]:  # untested
        resp = self.client.get_user_tags(messenger_id)
        if resp.status_code == 404:
            return []

        return [
            UserTagSchema(
                user_tag_id=item.get("user_tag_id"),
                id=item.get("app_tag").get("id"),
                name=item.get("app_tag").get("name"),
                description=item.get("app_tag").get("description"),
            )
            for item in resp.json()
        ]

    def sign_up(self, tag_id: str, messenger_id: int) -> bool:  # untested
        resp = self.client.sign_up(tag_id, messenger_id)
        return resp.status_code == 200

    def sign_out(self, user_tag_id: str) -> bool:  # untested
        resp = self.client.sign_out(user_tag_id)
        return resp.status_code == 200

--- services/bot/core/handlers/profile.py ---
from dialog_bot_sdk.entities.messaging import UpdateInteractiveMediaEvent, UpdateMessage
from dialog_bot_sdk.interactive_media import Button

from core.bot_kit.fsm import FSMContext, State, StatesGroup
from core.bot_kit.router import Router
from core.config import bot
from core.markups import (
    back_to_update_profile_keyboard,
    back_to_volunteer_home_keyboard,
    profile_keyboard,
    update_profile_keyboard,
)
from core.markups.tags import choices_from_backend_tags
from core.schemas import UserSchema
from core.services import UserService
from core.services.tag import TagService
from core.utils import delete_prev_message, delete_prev_message_by_peer
from core.utils.logger import logger
from core.utils.tags_toggle_ui import TagsToggleCfg, is_done, parse_toggle_key, send_tags_toggle_ui

rt = Router()


class EditProfileState(StatesGroup):  # untested
    wait_value = State()


class ProfileTagsState(StatesGroup):  # untested
    tags = State()


PROFILE_TAGS_CFG = TagsToggleCfg(
    media_id="profile_tags_toggle", toggle_prefix="toggle:", done_value="done"
)


@bot.di
def _load_user(
    peer_id: int,
    user_service: UserService,
):
    logger.debug("[ProfileHandler]: _load_user is called")
    return user_service.get_user_by_user_data(messenger_id=peer_id)


def _get_field_label(field: str) -> str:  # untested
    return {"name": "Имя", "city": "Город", "interests": "Интересы"}.get(field, field)


def _get_current_value(user, field: str) -> str:  # untested
    val = getattr(user, field, None)
    if val is None or val == "" or val == []:
        return "-"
    if field == "interests":
        try:
            return ", ".join(map(str, val))
        except Exception:
            return str(val)
    return str(val)


def _send_profile_edit_menu(  # untested
    peer, user_service: UserService, *, note: str | None = None
):
    user = _load_user(peer.id, user_service)
    if user is None:
        bot.messaging.send_message(
            peer=peer,
            text="Не удалось загрузить профиль. Попробуй позже.",  # TODO возможно не стоит однозначно опираться на данные из сберчата, а дать пользователю создать аккаунт с нуля.
            interactive_media_groups=back_to_update_profile_keyboard(),
        )
        return

    prefix = f"{note}\n\n" if note else ""
    bot.messaging.send_message(
        peer=peer,
        text=(
            f"{prefix}"
            f"Редактирование профиля\n\n"
            f"Текущие данные:\n\n"
            f"{user!s}\n"
            f"Выбери поле для редактирования:"
        ),
        interactive_media_groups=update_profile_keyboard(),
    )


@bot.di
def my_profile_handler(
    event: UpdateInteractiveMediaEvent,
    user_service: UserService,
):
    logger.debug("[ProfileHandler]: my_profile_handler is called")
    delete_prev_message_by_peer(bot, event.peer)
    user = _load_user(event.peer.id, user_service)
    show_profile(event.peer, user)


# Показать профиль пользователя в доме волонтера
def show_profile(peer, user: UserSchema | None) -> None:
    if user is None:
        text = "⚠️ Не удалось загрузить твой профиль. Попробуй позже."
        keyboard = back_to_volunteer_home_keyboard()
    else:
        text = f"👤 Твой профиль:\n\n{user!s}"
        keyboard = profile_keyboard()

    bot.messaging.send_message(
        peer=peer,
        text=text,
        interactive_media_groups=keyboard,
    )


def ask_for_region(peer, context: FSMContext) -> None:  # untested
    bot.messaging.send_message(
        peer=peer,
        text=("⚠️ Я не смог определить твой регион.\nПожалуйста, введи свои регион:"),
        interactive_media_groups=back_to_update_profile_keyboard(),
    )
    context.update_data({"profile_edit_field": "city"})
    context.set_state(EditProfileState.wait_value)


@bot.di
def update_user_menu_handler(  # untested
    event: UpdateInteractiveMediaEvent,
    context: FSMContext,
    user_service: UserService,
):
    delete_prev_message_by_peer(bot, event.peer)
    context.clear()
    _send_profile_edit_menu(event.peer, user_service, note=None)


def _start_edit_field(  # untested
    event: UpdateInteractiveMediaEvent,
    context: FSMContext,
    user_service: UserService,
    field: str,
):
    delete_prev_message_by_peer(bot, event.peer)

    user = _load_user(event.peer.id, user_service)
    if user is None:
        bot.messaging.send_message(
            peer=event.peer,
            text="Не удалось загрузить профиль. Попробуй позже.",  # TODO возможно не стоит однозначно опираться на данные из сберчата, а дать пользователю создать аккаунт с нуля.
            interactive_media_groups=back_to_update_profile_keyboard(),
        )
        return

    label = _get_field_label(field)
    cur = _get_current_value(user, field)

    hint = "Введи новое значение."
    if field == "name":
        hint += "\nЧтобы очистить поле - отправь минус '-'"

    context.update_data({"profile_edit_field": field})
    context.set_state(EditProfileState.wait_value)

    bot.messaging.send_message(
        peer=event.peer,
        text=(f"Редактирование: {label}\nТекущее значение: {cur}\n\n{hint}"),
        interactive_media_groups=back_to_update_profile_keyboard(),
    )


@bot.di
def update_user_name_start(  # untested
    event: UpdateInteractiveMediaEvent,
    context: FSMContext,
    user_service: UserService,
):
    _start_edit_field(event, context, user_service, "name")


@bot.di
def update_user_region_start(  # untested
    event: UpdateInteractiveMediaEvent,
    context: FSMContext,
    user_service: UserService,
):
    _start_edit_field(event, context, user_service, "city")


@bot.di
def update_user_interests_start(  # untested
    event: UpdateInteractiveMediaEvent,
    context: FSMContext,
    tag_service: TagService,
):
    # вместо ввода строкой — toggle UI
    delete_prev_message_by_peer(bot, event.peer)

    all_tags = tag_service.get_all_tags()
    choices = choices_from_backend_tags(all_tags)

    user_tags = tag_service.get_user_tags(event.peer.id)
    selected_names = {t.name for t in user_tags if getattr(t, "title", None)}

    # для add/remove на done
    name_to_id = {
        t.name: t.id for t in all_tags if getattr(t, "name", None) and getattr(t, "id", None)
    }
    name_to_user_tag_id = {
        t.name: t.user_tag_id
        for t in user_tags
        if getattr(t, "name", None) and getattr(t, "user_tag_id", None)
    }

    context.clear()
    context.update_data(
        {
            "profile_tags_selected": list(selected_names),
            "profile_tags_current": list(selected_names),
            "profile_tags_name_to_id": name_to_id,
            "profile_tags_name_to_user_tag_id": name_to_user_tag_id,
        }
    )
    context.set_state(ProfileTagsState.tags)

    send_tags_toggle_ui(
        peer=event.peer,
        title="Выбери предпочитаемые теги, затем нажми «Готово»:",
        choices=choices,
        selected_keys=set(selected_names),
        cfg=PROFILE_TAGS_CFG,
        footer_buttons=[
            Button(
                media_id="update_profile_menu",
                value="update_profile_menu",
                label="⬅️ К редактированию профиля",
            )
        ],
        done_label="✅ Готово",
    )


@bot.di
def profile_tags_toggle_handler(  # untested
    event: UpdateInteractiveMediaEvent,
    context: FSMContext,
    user_service: UserService,
    tag_service: TagService,
):
    state = context.get_state()
    if not (state == ProfileTagsState.tags or str(state) == str(ProfileTagsState.tags)):
        return

    raw_value = str(event.data.value or "")
    data = context.get_data()

    selected = set(data.get("profile_tags_selected") or [])
    current = set(data.get("profile_tags_current") or [])
    name_to_id: dict = data.get("profile_tags_name_to_id") or {}
    name_to_user_tag_id: dict = data.get("profile_tags_name_to_user_tag_id") or {}

    # DONE: применяем diff через backend
    if is_done(raw_value, PROFILE_TAGS_CFG):
        delete_prev_message_by_peer(bot, event.peer)

        to_add = selected - current
        to_remove = current - selected

        # add
        for name in to_add:
            tag_id = name_to_id.get(name)
            if tag_id:
                tag_service.sign_up(tag_id, event.peer.id)

        # remove
        for name in to_remove:
            user_tag_id = name_to_user_tag_id.get(name)
            if user_tag_id:
                tag_service.sign_out(user_tag_id)

        context.clear()
        _send_profile_edit_menu(event.peer, user_service, note="✅ Теги обновлены.")
        return

    # TOGGLE
    key = parse_toggle_key(raw_value, PROFILE_TAGS_CFG)
    if not key:
        return

    if key in selected:
        selected.remove(key)
    else:
        selected.add(key)

    context.update_data({"profile_tags_selected": list(selected)})
    delete_prev_message_by_peer(bot, event.peer)

    # перерисовываем UI (choices подтянем снова, чтобы не хранить их в контексте)
    all_tags = tag_service.get_all_tags()
    choices = choices_from_backend_tags(all_tags)

    send_tags_toggle_ui(
        peer=event.peer,
        title="Выбери предпочитаемые теги, затем нажми «Готово»:",
        choices=choices,
        selected_keys=set(selected),
        cfg=PROFILE_TAGS_CFG,
        footer_buttons=[
            Button("update_profile_menu", "update_profile_menu", "⬅️ К редактированию профиля")
        ],
        done_label="✅ Готово",
    )


@rt.message(state=EditProfileState.wait_value)
@bot.di
def profile_edit_value_step(  # untested
    message: UpdateMessage,
    context: FSMContext,
    user_service: UserService,
):
    delete_prev_message(bot, message)

    field = context.get_data().get("profile_edit_field")
    if not field:
        context.set_state(None)
        _send_profile_edit_menu(
            message.peer, user_service, note="✅ Сессия редактирования сброшена."
        )
        return

    raw = message.message.text_message.text.strip()

    try:
        if field == "name":
            value = "" if raw == "-" else raw
            user_service.update_name(value)

        elif field == "city":
            updated = user_service.update_region(raw)
            if updated is None:
                bot.messaging.send_message(
                    peer=message.peer,
                    text="⚠️ Регион не найден. Попробуй еще раз.",
                    interactive_media_groups=back_to_update_profile_keyboard(),
                )
                context.set_state(EditProfileState.wait_value)
                return

        else:
            bot.messaging.send_message(
                peer=message.peer,
                text="⚠️ Некорректное поле для редактирования.",
                interactive_media_groups=back_to_update_profile_keyboard(),
            )
            context.set_state(None)
            return

    except Exception:
        context.set_state(None)
        _send_profile_edit_menu(message.peer, user_service, note="⚠️ Не удалось обновить поле.")
        return

    context.set_state(None)
    _send_profile_edit_menu(message.peer, user_service, note="✅ Поле обновлено.")

--- services/bot/core/markups/profile.py ---
from dialog_bot_sdk.interactive_media import Button, MediaGroupBuilder


def profile_keyboard():  # untested
    group_builder_1 = MediaGroupBuilder([Button("1", "update_profile_menu", "⚙️ Изменить профиль")])
    group_builder_2 = MediaGroupBuilder([Button("2", "volunteer_home", "⬅️ В дом волонтёра")])
    return group_builder_1.merge([group_builder_2])


def back_to_update_profile_keyboard():  # untested
    group_builder_1 = MediaGroupBuilder(
        [Button("1", "update_profile_menu", "⬅️ К редактированию профиля")]
    )
    return group_builder_1.build()


def update_profile_keyboard():  # untested
    group_builder_1 = MediaGroupBuilder(
        [
            # Button("update_user_name", "name", "👤 Изменить имя"),
            Button("update_user_interests", "interests", "🎯 Изменить интересы"),
            Button("update_user_region", "region", "📍 Изменить регион"),
        ]
    )
    group_builder_2 = MediaGroupBuilder([Button("4", "volunteer_home", "⬅️ В дом волонтёра")])
    return group_builder_1.merge([group_builder_2])


def update_interests_keyboard():  # untested
    back = MediaGroupBuilder([Button("1", "update_profile_menu", "⬅️ К редактированию профиля")])
    return back.build()


def ask_region_keyboard():  # untested
    group = MediaGroupBuilder(
        [
            Button("1", "update_profile_menu", "Изменить профиль"),
            Button("2", "volunteer_home", "В дом волонтёра"),
        ]
    )
    return group.build()

--- services/bot/core/markups/tags.py ---
from __future__ import annotations

import re
from collections.abc import Iterable
from dataclasses import dataclass

from dialog_bot_sdk.interactive_media import Button, InteractiveMediaGroup, MediaGroupBuilder

from core.schemas.tag import TagSchema

VOLUNTEER_TAGS: list[str] = [
    "Экологическое",
    "Социальное",
    "Событийное",
    "Интеллектуальное",
    "Донорское",
]


@dataclass(frozen=True)
class TagChoice:  # untested
    key: str
    label: str


def default_tag_choices() -> list[TagChoice]:  # untested
    return [TagChoice(key=t, label=t) for t in VOLUNTEER_TAGS]


def choices_from_backend_tags(tags: list[TagSchema | dict]) -> list[TagChoice]:  # untested
    out: list[TagChoice] = []
    for t in tags or []:
        name = t.get("name") if isinstance(t, dict) else getattr(t, "name", None)
        if name:
            out.append(TagChoice(key=str(name), label=str(name)))
    return out


def parse_tags_text(raw: str | None) -> set[str]:  # untested
    if not raw:
        return set()
    parts = re.split(r"[,\n;]+", raw)
    return {p.strip() for p in parts if p.strip()}


def format_tags_text(  # untested
    selected: Iterable[str], *, ordered_by: list[TagChoice] | None = None
) -> str | None:
    s = {str(x).strip() for x in selected if str(x).strip()}
    if not s:
        return None

    if ordered_by:
        order = [c.key for c in ordered_by]
        known = [k for k in order if k in s]
        unknown = sorted([k for k in s if k not in set(order)])
        return ", ".join(known + unknown)

    return ", ".join(sorted(s))


def tags_toggle_keyboard(  # untested
    *,
    choices: list[TagChoice],
    selected_keys: set[str],
    media_id: str,
    toggle_value_prefix: str,
    done_value: str,
    done_label: str = "✅ Готово",
    extra_footer: list[Button] | None = None,
) -> list[InteractiveMediaGroup]:
    groups: list[InteractiveMediaGroup] = []

    for ch in choices:
        active = ch.key in selected_keys
        label = ("☑️ " if active else "▪️ ") + ch.label
        btn = Button(media_id=media_id, value=f"{toggle_value_prefix}{ch.key}", label=label)
        groups.extend(MediaGroupBuilder([btn]).build())

    footer = [Button(media_id=media_id, value=done_value, label=done_label)]
    if extra_footer:
        footer.extend(extra_footer)

    groups.extend(MediaGroupBuilder(footer).build())
    return groups

--- services/bot/core/utils/tags_toggle_ui.py ---
from __future__ import annotations

from dataclasses import dataclass

from dialog_bot_sdk.interactive_media import Button

from core.config import bot
from core.markups.tags import TagChoice, tags_toggle_keyboard


@dataclass(frozen=True)
class TagsToggleCfg:  # untested
    media_id: str
    toggle_prefix: str = "toggle:"
    done_value: str = "done"


def is_done(value: str | None, cfg: TagsToggleCfg) -> bool:  # untested
    return str(value or "") == cfg.done_value


def parse_toggle_key(value: str | None, cfg: TagsToggleCfg) -> str | None:  # untested
    raw = str(value or "")
    if not raw.startswith(cfg.toggle_prefix):
        return None
    return raw[len(cfg.toggle_prefix) :].strip()


def send_tags_toggle_ui(  # untested
    *,
    peer,
    title: str,
    choices: list[TagChoice],
    selected_keys: set[str],
    cfg: TagsToggleCfg,
    footer_buttons: list[Button] | None = None,
    done_label: str = "✅ Готово",
):
    bot.messaging.send_message(
        peer=peer,
        text=title,
        interactive_media_groups=tags_toggle_keyboard(
            choices=choices,
            selected_keys=selected_keys,
            media_id=cfg.media_id,
            toggle_value_prefix=cfg.toggle_prefix,
            done_value=cfg.done_value,
            done_label=done_label,
            extra_footer=footer_buttons,
        ),
    )

--- services/bot/core/schemas/tag.py ---
from pydantic import BaseModel


class TagSchema(BaseModel):
    created_at: str
    depricated: bool # rename to deprecated
    description: str
    id: str
    title: str
    updated_at: str

    def __str__(self) -> str:
        return self.title


class UserTagSchema(TagSchema):  # untested
    """Tag user signed up with"""

    user_tag_id: str

--- services/bot/core/schemas/user.py ---
from pydantic import BaseModel

from core.schemas.tag import TagSchema


class UserSchema(BaseModel):
    id: str
    role: str  # admin, leader, volunteer
    messenger_id: int
    name: str
    hours: int
    created_at: str
    updated_at: str
    tags: list[TagSchema]

    @staticmethod
    def _display(value):
        if isinstance(value, list):
            return ", ".join(str(tag) for tag in value) if value != [] else "—"
        return value

    def __str__(self):
        return (
            f"😊 *Имя*: {self._display(self.name)}\n"
            f"🏆 *Часы*: {self._display(self.hours)}\n"
            f"🪪 *Роль*: {self._display(self.role)}\n"
            f"🎯 *Интересы*: {self._display(self.tags)}\n"
        )

    def __repr__(self):
        return (
            f"UserSchema("
            f"id = {self.id}, "
            f"role = {self.role}, "
            f"messenger_id = {self.messenger_id}, "
            f"name = {self.name}, "
            f"hours = {self.hours}, "
            f"created_at = {self.created_at}, "
            f"updated_at = {self.updated_at}, "
            f"tags = [{', '.join(self.tags)}]"
            f")"
        )


class UserUpdateSchema(BaseModel):  # untested
    role: str | None = None
    full_name: str | None = None
    city_id: str | None = None
    interests: list | None = None

--- services/bot/core/dependencies/tag.py ---
from dishka import Scope, provide

from core.clients import TagClient
from core.config.settings import settings
from core.dependencies.base import BaseMessengerProvider
from core.services import TagService
from core.services.registry import BaseServiceRegistry


class TagProvider(BaseMessengerProvider):  # untested
    @provide(scope=Scope.APP)
    def get_tag_service(
        self, registry: BaseServiceRegistry, client: TagClient
    ) -> TagService:  # untested
        return TagService(registry, client)

    @provide(scope=Scope.APP)
    def get_tag_client(self) -> TagClient:  # untested
        return TagClient(base_url=f"{settings.API_URL}/api/v1")

--- services/bot/core/services/registry/registry.py ---
from core.services.city import CityService
from core.services.event import EventService
from core.services.registry.base import BaseServiceRegistry
from core.services.tag import TagService
from core.services.user import UserService


class ServiceRegistry(BaseServiceRegistry):  # untested
    def city(self) -> "CityService":  # untested
        return CityService(self, self.city_client)

    def event(self) -> "EventService":  # untested
        return EventService(self, self.event_client)

    def user(self, messenger_id: int) -> "UserService":  # untested
        return UserService(self, messenger_id, self.user_client)

    def tag(self) -> "TagService":  # untested
        return TagService(self, self.tag_client)

Editor is loading...
Leave a Comment