Untitled

 avatar
4ae4d
plain_text
13 days ago
102 kB
3
Indexable
f=services/bot/core/clients/event.py
f=services/bot/core/services/event.py
f=services/bot/core/schemas/participation.py
f=services/bot/core/markups/event.py
f=services/bot/core/markups/pagination.py
f=services/bot/core/handlers/events_ui.py
f=services/bot/core/handlers/moderation_menu.py
f=services/bot/core/handlers/__init__.py
f=services/bot/main.py
f=services/backend/internal/drivers/http/v1/volunteering/participation_card/participation_card_controller.go
f=services/backend/internal/drivers/http/v1/volunteering/participation_card/participation_card_request.go
f=services/backend/internal/drivers/http/v1/volunteering/volunteering_routes.go
f=services/backend/internal/domain/volunteering/participation_card/participation_card.go
f=services/backend/internal/domain/volunteering/participation_card/participation_card_page.go
f=services/backend/internal/domain/volunteering/participation_card/participation_card_query.go
f=services/backend/internal/domain/volunteering/participation_card/page_meta.go
f=services/backend/internal/adapters/volunteering/participation_card/participation_card_mapper.go
f=services/backend/internal/adapters/volunteering/participation_card/participation_card_query.go
f=services/backend/internal/adapters/volunteering/participation_card/participation_card_schema.go
f=services/backend/internal/drivers/http/v1/volunteering/participation/participation_controller.go
f=services/backend/internal/drivers/http/v1/volunteering/participation/participation_requests.go
f=services/backend/internal/domain/volunteering/participations/participation.go
f=services/backend/internal/domain/volunteering/participations/participation_service.go
f=services/backend/internal/domain/volunteering/participations/participation_repository.go
f=services/backend/internal/adapters/volunteering/participation/participation_repo.go
--- services/bot/core/clients/event.py ---
from core.clients.base import BaseApiClient


class EventClient(BaseApiClient):
    def get_event_cards_page(
        self,
        *,
        requester_messenger_id: int,
        page: int = 1,
        limit: int = 10,
        title_str: str | None = None,
        tag_ids: list[str] | None = None,
    ):
        params: dict = {
            "page": page,
            "limit": limit,
        }
        if title_str:
            params["title_str"] = title_str
        if tag_ids:
            params["tag_id"] = tag_ids

        return self.get(
            "/events/card/list",
            headers={"Authorization": str(requester_messenger_id)},
            params=params,
        )

    def get_my_event_cards_page(
        self,
        *,
        requester_messenger_id: int,
        page: int = 1,
        limit: int = 10,
        title_str: str | None = None,
        tag_ids: list[str] | None = None,
    ):
        params: dict = {
            "page": page,
            "limit": limit,
        }
        if title_str:
            params["title_str"] = title_str
        if tag_ids:
            params["tag_id"] = tag_ids

        return self.get(
            "/events/card/list/mine",
            headers={"Authorization": str(requester_messenger_id)},
            params=params,
        )

    def get_event_info(self, event_id: str, requester_messenger_id: int):
        """GET /events/{event_id}/card"""
        return self.get(
            f"/events/{event_id}/card",
            headers={"Authorization": str(requester_messenger_id)},
        )

    def sign_up(self, *, event_id: str, messenger_id: int):
        """POST /events/{event_id}/participations/signup"""
        return self.post(
            f"/events/{event_id}/participations/signup",
            headers={"Authorization": str(messenger_id)},
        )

    def sign_out(self, *, event_id: str, messenger_id: int):
        """DELETE /events/{event_id}/participations/cancel"""
        return self.delete(
            f"/events/{event_id}/participations/cancel",
            headers={"Authorization": str(messenger_id)},
        )

    def confirm_participation(self, *, event_id: str, messenger_id: int, code: str):
        """POST /events/{event_id}/participations/confirm"""
        return self.post(
            f"/events/{event_id}/participations/confirm",
            headers={"Authorization": str(messenger_id)},
            json={"code": code},
        )

    def add_tag(self, *, event_id: str, tag_id: str, messenger_id: int):
        """POST /events/{event_id}/tags"""
        return self.post(
            f"/events/{event_id}/tags",
            headers={"Authorization": str(messenger_id)},
            json={"tag_id": tag_id},
        )

    def remove_tag(self, *, event_id: str, tag_id: str, messenger_id: int):
        """DELETE /events/{event_id}/tags"""
        return self.delete(
            f"/events/{event_id}/tags",
            headers={"Authorization": str(messenger_id)},
            json={"tag_id": tag_id},
        )

    def create_event(
        self,
        *,
        title: str,
        description: str,
        date_time: str,
        gosb_id: str,
        project_id: str = "",
        verification_code: str,
        hours: int,
        participation_limit: int,
        requester_messenger_id: int,
        tags: list[str] | None = None,
    ):
        """POST /events"""
        payload = {
            "activation_code": verification_code,
            "hours": hours,
            "date_time": date_time,
            "description": description,
            "gosb_id": gosb_id,
            "project_id": project_id,
            "title": title,
            "participation_limit": participation_limit,
        }
        if tags:
            payload["selected_tags"] = tags
        return self.post(
            "/events",
            headers={"Authorization": str(requester_messenger_id)},
            json=payload,
        )

    def update_event(self, event_id: str, messenger_id: int, payload: dict[str, int | str]):
        """PATCH /events/{event_id}"""
        return self.patch(
            f"/events/{event_id}",
            headers={"Authorization": str(messenger_id)},
            json=payload,
        )

--- services/bot/core/services/event.py ---
from datetime import datetime, timezone
from typing import Any

from core.clients import EventClient
from core.schemas import EventCardSchema, ParticipationSchema
from core.services.base import BaseService
from core.services.registry import BaseServiceRegistry
from core.utils import logger


class EventService(BaseService):  # legacy
    """
    сервис мероприятий на стороне бота.

    на данный момент:
    - ходит в backend через EventClient
    - умеет получать список мероприятий по городу
    - умеет получать одно мероприятие по id
    - создает мероприятие через клиент используя мастера создания
    """

    def __init__(self, registry: BaseServiceRegistry, client: EventClient):  # legacy
        # DI может вызвать клиент без аргументов.
        super().__init__(registry)
        self.client = client

    def get_event_cards_page(
        self,
        *,
        requester_messenger_id: int,
        page: int = 1,
        limit: int = 10,
        title_str: str | None = None,
        tag_ids: list[str] | None = None,
        mine: bool = False,
    ) -> tuple[list[EventCardSchema], int, int]:  # меняем тип возвращаемого списка
        resp = (
            self.client.get_my_event_cards_page(
                requester_messenger_id=requester_messenger_id,
                page=page,
                limit=limit,
                title_str=title_str,
                tag_ids=tag_ids,
            )
            if mine
            else self.client.get_event_cards_page(
                requester_messenger_id=requester_messenger_id,
                page=page,
                limit=limit,
                title_str=title_str,
                tag_ids=tag_ids,
            )
        )

        self._check_response(resp, ctx="getting event card list")

        body = resp.json() or {}
        items = body.get("event_cards") or []
        page_meta = body.get("page_meta") or {}

        result: list[EventCardSchema] = []
        for item in items:
            if not isinstance(item, dict):
                raise ValueError("Invalid event card contract: item must be dict")
            result.append(EventCardSchema.from_dict(item))

        current_page = int(page_meta.get("current_page") or page)
        total_pages = int(page_meta.get("total_pages") or current_page)

        return result, current_page, total_pages

    def sign_up(self, event_id: str, messenger_id: int) -> str | None:
        """messenger_id = peer.id (как на бэкенде: user.get_by_messenger_id)"""
        resp = self.client.sign_up(event_id=event_id, messenger_id=messenger_id)
        if resp.status_code == 409:
            return resp.json()["error"]
        self._check_response(resp, ctx="signing up")
        return None

    def get_event_by_id(
        self,
        event_id: str,
        requester_messenger_id: int,
    ) -> EventCardSchema | None:
        logger.debug(
            "[EventService] get_event_by_id: event_id=%s requester=%s",
            event_id,
            requester_messenger_id,
        )
        resp = self.client.get_event_info(
            event_id=event_id,
            requester_messenger_id=requester_messenger_id,
        )
        if resp.status_code == 400:
            return None
        self._check_response(resp, ctx="getting event card")

        logger.debug(
            "[EventService] get_event_by_id: body=%s",
            resp.json(),
        )

        return EventCardSchema.from_dict(resp.json() or {})

    def sign_out_by_event(self, event_id: str, messenger_id: int) -> bool:
        resp = self.client.sign_out(event_id=event_id, messenger_id=messenger_id)
        return resp.status_code == 200

    def enter_code_by_event(
        self,
        event_id: str,
        messenger_id: int,
        code: str,
    ) -> ParticipationSchema | None:
        resp = self.client.confirm_participation(
            event_id=event_id,
            messenger_id=messenger_id,
            code=code,
        )
        if resp.status_code in {400, 404}:
            return None
        self._check_response(resp, ctx="confirming participation")

        return self.get_participation_status(
            event_id=event_id,
            requester_messenger_id=messenger_id,
        )

    def get_participation_status(self, event_id: str, requester_messenger_id: int):
        resp = self.client.get_event_info(event_id, requester_messenger_id)
        card = EventCardSchema.from_dict(resp.json() or {})
        return card.participation

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

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

    def create_from_wizard(self, data: dict[str, Any], peer) -> dict[str, Any]:
        title = data.get("event_name", "")
        description = data.get("event_description", "")
        date_obj = data.get("event_date", "")
        time_obj = data.get("event_time", "")
        gosb_id = data.get("gosb_id", "")
        project_id = data.get("project_id", "")
        hours = data.get("hours", "")
        participation_limit = data.get("event_participation_limit", "")

        verification_code = data.get("event_verification_code", "")
        # Теги: массив ID, сформированный в хендлере
        tags: list[str] = data.get("event_app_tag_ids") or []
        # === Формирование RFC3339-совместимой строки ===
        event_dt = datetime.combine(date_obj, time_obj).replace(tzinfo=timezone.utc)
        date_time = event_dt.isoformat().replace("+00:00", "Z")

        resp = self.client.create_event(
            title=title,
            description=description,
            date_time=date_time,
            gosb_id=gosb_id,
            project_id=project_id,
            verification_code=verification_code,
            hours=hours,
            participation_limit=participation_limit,
            requester_messenger_id=peer.id,
            tags=tags if tags else None,
        )
        self._check_response(resp, ctx="creating event")

        body = resp.json() or {}
        return {
            "TYPE": resp.json().get("create_type"),
            "event_id": body.get("event_id"),
            "request_id": body.get("request_id"),
        }

    def update_event(self, event_id: str, messenger_id: int, payload: dict[str, int | str]) -> bool:
        resp = self.client.update_event(
            event_id=event_id,
            messenger_id=messenger_id,
            payload=payload,
        )
        self._check_response(resp, ctx="patching event")
        return True

--- services/bot/core/schemas/participation.py ---
from datetime import datetime
from typing import Any

from pydantic import BaseModel

from core.utils import require_datetime


class ParticipationSchema(BaseModel):
    """Схема участия пользователя в мероприятии"""

    messenger_id: str
    event_id: str
    state: str
    created_at: datetime
    updated_at: datetime

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> "ParticipationSchema":
        return cls(
            messenger_id=str(data.get("messenger_id", "")),
            event_id=str(data.get("event_id", "")),
            state=str(data.get("state", "")),
            created_at=require_datetime(data.get("created_at"), "participation.created_at"),
            updated_at=require_datetime(data.get("updated_at"), "participation.updated_at"),
        )

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

from core.markups.pagination import pagination_keyboard
from core.schemas import EventCardSchema
from core.utils import format_datetime, wrap_text


def format_event_details(
    card: EventCardSchema,
    *,
    show_code: bool,
    show_organizer: bool,
    show_report_status: bool = False,
    report_exists: bool | None = None,
    show_active: bool = False,
    make_frame: bool = False,
    wrap: bool = False,
) -> str:
    """формирует читаемое описание из EventCardSchema"""

    def make_bold(string: str) -> str:
        return f"*{string}*" if not make_frame else string

    def make_block(string: str) -> str:
        return f"`{string}`" if not make_frame else string

    def make_title(string: str) -> str:
        return f"{string}\n" if make_frame else f"{string}\n\n"

    lines: list[str] = [
        f"📣 {make_bold('Название')}: {card.event.title}",
        f"⏰ {make_bold('Когда')}: {format_datetime(card.event.date_time)}",
        f"🏷️ {make_bold('Теги')}: {_format_tags(card.tags)}",
        f"🏆 {make_bold('Часы')}: {card.event.hours}",
        f"🏛️ {make_bold('ГОСБ')}: {card.gosb.name}",
        f"👥 {make_bold('Участники')}: {card.event.participation_count}/{card.event.participation_limit}",
    ]

    if show_code:
        lines.append(f"🎟️ {make_bold('Код')}: {card.event.verification_code or '—'}")

    lines.extend(
        [
            f"📝 {make_bold('Описание')}: {wrap_text(card.event.description or '—')}"
            if wrap
            else f"📝 {make_bold('Описание')}: {card.event.description or '—'}",
        ]
    )

    if show_active:
        lines.append("")
        lines.append("✅ Согласовано" if card.event.active else "⏳ На согласовании")

    lines.extend(["", "🔧 Техническая информация:"])
    if show_report_status:
        status = "✅ Создан" if report_exists else "Ещё не создан"
        lines.append(f"Отчёт: {make_block(status)}")
    if show_organizer:
        lines.append(f"Creator id: {make_block(card.event.creator_id or '—')}")
    lines.append(f"Project ID: {make_block(card.event.project_id or '—')}")
    lines.append(f"ID: {make_block(card.event.id)}")

    result = "\n".join(lines)

    return make_title("📅 *Мероприятие*") + (
        "```plain\n" + result + "\n```" if make_frame else result
    )


def _format_tags(
    tags: list | None,
) -> str:  # TODO add type to 'tags' and simplify func depending on type
    if not tags:
        return "—"
    try:
        titles = [str(getattr(t, "title", "")).strip() for t in tags]
        titles = [t for t in titles if t]
        return ", ".join(titles) if titles else "—"
    except Exception:
        return str(tags)


def event_actions_keyboard(  # legacy
    event_id: str,
    *,
    is_participant: bool,
    can_edit: bool,
    back_value: str | None,
    back_label: str,
    enter_code_media_id: str,
    sign_up_media_id: str,
    sign_out_media_id: str,
    edit_media_id: str = "event_menu_edit",
    delete_media_id: str = "event_menu_delete",
    can_create_report: bool = False,
    can_view_report: bool = False,
    can_sign_up: bool = False,
    ctx: str | None = None,
) -> list[InteractiveMediaGroup]:
    actions = []
    v = f"{event_id}|{ctx}" if ctx else event_id

    actions.append(Button(media_id=enter_code_media_id, value=v, label="🏅 Ввести код"))

    if is_participant:
        actions.append(Button(media_id=sign_out_media_id, value=v, label="❌ Отписаться"))
    elif can_sign_up:
        actions.append(Button(media_id=sign_up_media_id, value=v, label="✅ Записаться"))
    else:
        actions.append(Button(media_id=sign_up_media_id, value=v, label="⚠️ Записаться"))

    if can_edit:
        actions.append(Button(media_id=edit_media_id, value=v, label="✏️ Редактировать"))
        # TODO: separate permissions
        actions.append(Button(media_id="event_participants_open", value=v, label="👥 Участники"))
        actions.append(Button(media_id=delete_media_id, value=v, label="🗑 Удалить"))

    if can_create_report:
        actions.append(Button(media_id="event_report_create", value=v, label="📄 Создать отчёт"))
    if can_view_report:
        actions.append(Button(media_id="event_report_view", value=v, label="📄 Посмотреть отчёт"))
    if back_value is not None:
        actions.append(Button(media_id=back_value, value="", label=back_label))

    return MediaGroupBuilder(actions).build()


def event_edit_fields_keyboard(  # legacy
    event_id: str,
    *,
    leave_media_id: str = "leave",
    leave_value: str = "moderation",
    leave_label: str = "⬅️ В мастерскую",
) -> list[InteractiveMediaGroup]:
    group_builder_1 = MediaGroupBuilder(
        [
            Button(
                media_id="event_menu_edit_name",
                value=f"{event_id}|title",
                label="📣 Название",
            ),
            Button(
                media_id="event_menu_edit_description",
                value=f"{event_id}|description",
                label="📝 Описание",
            ),
            Button(
                media_id="event_menu_edit_date",
                value=f"{event_id}|date",
                label="📅 Дата",
            ),
            Button(
                media_id="event_menu_edit_time",
                value=f"{event_id}|time",
                label="⏰ Время",
            ),
            Button(
                media_id="event_menu_edit_tags",
                value=f"{event_id}|tags",
                label="🏷️ Теги",
            ),
            Button(
                media_id="event_menu_edit_code",
                value=f"{event_id}|code",
                label="🎟️ Код",
            ),
        ]
    )
    group_builder_2 = MediaGroupBuilder(
        [
            Button(
                media_id="event_menu_edit_organizers",
                value=f"{event_id}|event_organizers",
                label="🤓 Организаторы",
            ),
            Button(
                media_id="event_menu_edit_hours",
                value=f"{event_id}|hours",
                label="🏆 Часы",
            ),
            Button(
                media_id="event_menu_edit_gosb",
                value=f"{event_id}|gosb_id",
                label="🏛️ ГОСБ",
            ),
            Button(
                media_id="event_menu_edit_participation_limit",
                value=f"{event_id}|participation_limit",
                label="👥 Кол-во волонтеров",
            ),
            Button(
                media_id=leave_media_id,
                value=leave_value,
                label=leave_label,
            ),
        ]
    )
    return group_builder_1.merge([group_builder_2])


def user_events_pagination_keyboard(  # legacy
    *, offset: int, limit: int, has_prev: bool, has_next: bool
) -> list[InteractiveMediaGroup]:
    return pagination_keyboard(
        offset=offset,
        limit=limit,
        has_prev=has_prev,
        has_next=has_next,
        prev_media_id="user_events_prev",
        next_media_id="user_events_next",
        leave_media_id="volunteer_home",
        leave_label="🏡 В дом волонтёра",
        keep_layout=True,
        add_view_by_uuid_button=True,
        view_by_uuid_value="events",
    )


def part_events_pagination_keyboard(  # legacy
    *, offset: int, limit: int, has_prev: bool, has_next: bool
) -> list[InteractiveMediaGroup]:
    return pagination_keyboard(
        offset=offset,
        limit=limit,
        has_prev=has_prev,
        has_next=has_next,
        prev_media_id="part_events_prev",
        next_media_id="part_events_next",
        leave_media_id="volunteer_home",
        leave_label="🏡 В дом волонтёра",
        keep_layout=True,
        add_view_by_uuid_button=True,
        view_by_uuid_value="part_events",
    )


def my_events_pagination_keyboard(  # legacy
    *, offset: int, limit: int, has_prev: bool, has_next: bool
) -> list[InteractiveMediaGroup]:
    return pagination_keyboard(
        offset=offset,
        limit=limit,
        has_prev=has_prev,
        has_next=has_next,
        prev_media_id="my_events_prev",
        next_media_id="my_events_next",
        leave_media_id="moderation",
        leave_label="🛡️ В мастерскую",
        keep_layout=True,
        add_view_by_uuid_button=True,
        view_by_uuid_value="my_events",
    )


def _event_button_label(event_card: EventCardSchema, ctx: str | None = None) -> str:
    """Формирует подпись для кнопки мероприятия"""
    ev = event_card.event
    participation = event_card.participation
    date_str = format_datetime(getattr(ev, "date_time", None))
    title = getattr(ev, "title", "—")

    if ctx == "my_events":
        status = "на согласовании ⏳"
        if event_card.event.active:
            status = "согласовано ✅"
        return f"{title} | {date_str} — {status}"

    participation_emoji = "⚪"
    if participation:
        state = getattr(participation, "state", "")
        if state == "signed_up":
            participation_emoji = "📝"  # записан
        elif state == "confirmed":
            participation_emoji = "✅"  # подтверждено
        elif state == "cancelled":
            participation_emoji = "❌"  # отменено (не пришел)

    return f"{title} | {date_str} {participation_emoji}"


def events_select_keyboard(
    events_cards: list[EventCardSchema],
    *,
    open_media_id: str,
    start_index: int = 1,
    per_row: int = 1,
    label_max: int = 120,
    ctx: str | None = None,
) -> list[InteractiveMediaGroup]:
    """
    строит список кнопок по мероприятиям
    нажатие вызывает handler по _id  = open_media_id, value = event.id
    """
    groups: list[InteractiveMediaGroup] = []
    row: list[Button] = []

    idx = start_index
    for event_card in events_cards:
        label = _event_button_label(event_card, ctx)
        row.append(
            Button(
                media_id=open_media_id, value=str(getattr(event_card.event, "id", "")), label=label
            )
        )
        idx += 1

        if len(row) >= per_row:
            groups.extend(MediaGroupBuilder(row).build())
            row = []

    if row:
        groups.extend(MediaGroupBuilder(row).build())

    return groups


def view_by_uuid_keyboard(*, media_id: str, label: str) -> list[InteractiveMediaGroup]:  # legacy
    return MediaGroupBuilder([Button(media_id=media_id, value="", label=label)]).build()


def all_events_pagination_keyboard(
    *, offset: int, limit: int, has_prev: bool, has_next: bool
):  # legacy
    return pagination_keyboard(
        offset=offset,
        limit=limit,
        has_prev=has_prev,
        has_next=has_next,
        prev_media_id="all_events_prev",
        next_media_id="all_events_next",
        leave_media_id="moderation",
        leave_label="🛡️ В мастерскую",
        keep_layout=True,
        add_view_by_uuid_button=True,
        view_by_uuid_value="moderation",
    )

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

from typing import cast

from dialog_bot_sdk.interactive_media import Button, InteractiveMediaGroup, MediaGroupBuilder


def pagination_keyboard(  # legacy
    *,
    offset: int,
    limit: int,
    has_prev: bool,
    has_next: bool,
    prev_media_id: str,
    next_media_id: str,
    prev_label: str = "⬅️ Назад",
    next_label: str = "➡️ Вперёд",
    include_leave: bool = True,
    leave_media_id: str | None = None,
    leave_value: str | None = "",
    leave_label: str | None = "⬅️ Назад",
    keep_layout: bool = False,
    placeholder_media_id: str = "noop",
    placeholder_value: str = "noop",
    placeholder_label: str = " ",
    hide_when_all_placeholders: bool = False,
    add_view_by_uuid_button: bool = False,
    view_by_uuid_media_id: str = "view_by_uuid",
    view_by_uuid_value: str = "events",
    view_by_uuid_label: str = "🔎 По UUID",
) -> list[InteractiveMediaGroup]:
    """
    унифицированная пагинация.

    keep_layout=True:
      если add_view_by_uuid_button=True:
        - если include_leave=True: 4 слота [prev|ph] [leave|ph] [uuid] [next|ph]
        - если include_leave=False: 3 слота [prev|ph] [uuid] [next|ph]
      если add_view_by_uuid_button=False:
        - если include_leave=True: 3 слота [prev|ph] [leave|ph] [next|ph]
        - если include_leave=False: 2 слота [prev|ph] [next|ph]

    hide_when_all_placeholders=True: если из кнопок только ph - то возвращаем пустой лист.
    """

    if leave_value is None:
        leave_value = ""
    if leave_label is None:
        leave_label = "⬅️ Назад"

    def _btn(media_id: str, value: str, label: str) -> Button:  # legacy
        return Button(media_id=media_id, value=value, label=label)

    def _ph(slot: str) -> Button:  # legacy
        return _btn(
            placeholder_media_id + "_" + slot,
            placeholder_value,
            placeholder_label,
        )

    def _prev() -> Button:  # legacy
        return _btn(prev_media_id, str(max(offset - limit, 0)), prev_label)

    def _next() -> Button:  # legacy
        return _btn(next_media_id, str(offset + limit), next_label)

    def _view_by_uuid() -> Button:  # legacy
        return _btn(
            view_by_uuid_media_id,
            str(view_by_uuid_value),
            str(view_by_uuid_label),
        )

    def _leave() -> Button | None:  # legacy
        if not include_leave or leave_media_id is None:
            return None
        return _btn(leave_media_id, leave_value, leave_label)

    # --- 3-slot layout for include_leave
    if include_leave and keep_layout:
        buttons: list[Button] = []
        leave_btn = _leave()

        buttons.append(_prev() if has_prev else _ph("prev"))
        buttons.append(leave_btn if leave_btn is not None else _ph("mid"))

        # --- 4-slot: [prev] [leave] [uuid] [next]
        if add_view_by_uuid_button:
            buttons.append(_view_by_uuid())

        buttons.append(_next() if has_next else _ph("next"))

        if hide_when_all_placeholders and all(
            str(getattr(b, "media_id", "")).startswith(placeholder_media_id + "_") for b in buttons
        ):
            return []
        return cast(list[InteractiveMediaGroup], MediaGroupBuilder(buttons).build())

    # --- default linear layout (как было)
    buttons = []
    if has_prev:
        buttons.append(_prev())
    elif keep_layout:
        buttons.append(_ph("prev"))

    leave_btn = _leave()
    if leave_btn is not None:
        buttons.append(leave_btn)

    if add_view_by_uuid_button:
        buttons.append(_view_by_uuid())

    if has_next:
        buttons.append(_next())
    elif keep_layout:
        buttons.append(_ph("next"))

    if hide_when_all_placeholders and all(
        str(getattr(b, "media_id", "")).startswith(placeholder_media_id + "_") for b in buttons
    ):
        return []

    return cast(list[InteractiveMediaGroup], MediaGroupBuilder(buttons).build() if buttons else [])

--- services/bot/core/handlers/events_ui.py ---
from __future__ import annotations

from collections.abc import Callable
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any

from dialog_bot_sdk.interactive_media import Button, InteractiveMediaGroup, MediaGroupBuilder

from core.bot_kit.fsm import FSMContext
from core.config import bot
from core.handlers.events_pages import EVENTS_PAGE_SIZE, fetch_events_page
from core.markups import (
    all_events_pagination_keyboard,
    event_actions_keyboard,
    events_filters_button_keyboard,
    events_select_keyboard,
    format_event_details,
    my_events_pagination_keyboard,
    part_events_pagination_keyboard,
    points_events_pagination_keyboard,
    points_menu_keyboard,
    user_events_pagination_keyboard,
)
from core.schemas import EventCardsPageSchema, UserSchema
from core.services import EventService, ReportService
from core.utils import format_events_filter_summary, get_events_filter, logger

if TYPE_CHECKING:
    from core.schemas.event import EventCardSchema


@dataclass(frozen=True)
class EventUIContext:  # legacy
    name: str
    # list
    list_open_media_id: str
    pagination_builder: Callable[..., list[InteractiveMediaGroup]]
    extra_groups_builder: Callable[[], list[InteractiveMediaGroup]] | None
    # card
    back_value: str | None
    back_label: str
    enter_code_media_id: str
    sign_up_media_id: str
    sign_out_media_id: str


_UI: dict[str, EventUIContext] = {
    # дом волонтёра -> все мероприятия
    "events": EventUIContext(  # legacy
        name="events",
        list_open_media_id="user_events_open",
        pagination_builder=user_events_pagination_keyboard,
        extra_groups_builder=None,
        back_value="events",
        back_label="⬅️ К списку мероприятий",
        enter_code_media_id="event_user_enter_code",
        sign_up_media_id="event_user_sign_up",
        sign_out_media_id="event_user_sign_out",
    ),
    # дом волонтёра -> мои меропрития
    "part_events": EventUIContext(  # legacy
        name="part_events",
        list_open_media_id="user_events_open",
        pagination_builder=part_events_pagination_keyboard,
        extra_groups_builder=None,
        back_value="part_events",
        back_label="⬅️ К списку мероприятий",
        enter_code_media_id="event_user_enter_code",
        sign_up_media_id="event_user_sign_up",
        sign_out_media_id="event_user_sign_out",
    ),
    # дом волонтёра -> посмотреть по uuid
    "volunteer_home": EventUIContext(  # legacy
        name="volunteer_home",
        list_open_media_id="user_events_open",  # не используется в этом контексте
        pagination_builder=user_events_pagination_keyboard,
        extra_groups_builder=None,
        back_value="volunteer_home",
        back_label="⬅️ В дом волонтёра",
        enter_code_media_id="event_user_enter_code",
        sign_up_media_id="event_user_sign_up",
        sign_out_media_id="event_user_sign_out",
    ),
    # дом модерации -> все мероприятия
    "moderation": EventUIContext(  # legacy
        name="moderation",
        list_open_media_id="all_events_open",
        pagination_builder=all_events_pagination_keyboard,
        extra_groups_builder=None,
        back_value="all_events",
        back_label="⬅️ К списку мероприятий",
        enter_code_media_id="event_menu_enter_code",
        sign_up_media_id="event_user_sign_up",
        sign_out_media_id="event_user_sign_out",
    ),
    # меню баллов -> мероприятия с начисленными баллами
    "points": EventUIContext(  # legacy
        name="points",
        list_open_media_id="points_event_open",
        pagination_builder=points_events_pagination_keyboard,
        extra_groups_builder=points_menu_keyboard,  # лидерборд + назад
        back_value="points",
        back_label="⬅️ К баллам",
        enter_code_media_id="event_user_enter_code",
        sign_up_media_id="event_user_sign_up",
        sign_out_media_id="event_user_sign_out",
    ),
    # дом модерации -> посмотреть по uuid
    "moderation_menu": EventUIContext(  # legacy
        name="moderation_menu",
        list_open_media_id="all_events_open",  # не используется в этом контексте
        pagination_builder=all_events_pagination_keyboard,
        extra_groups_builder=None,
        back_value="moderation",
        back_label="⬅️ В мастерскую",
        enter_code_media_id="event_menu_enter_code",
        sign_up_media_id="event_user_sign_up",
        sign_out_media_id="event_user_sign_out",
    ),
    # дом модерации -> мои мероприятия
    "my_events": EventUIContext(  # legacy
        name="my_events",
        list_open_media_id="my_events_open",
        pagination_builder=my_events_pagination_keyboard,
        extra_groups_builder=None,
        back_value="my_events",
        back_label="⬅️ К моим мероприятиям",
        enter_code_media_id="event_menu_enter_code",
        sign_up_media_id="event_user_sign_up",
        sign_out_media_id="event_user_sign_out",
    ),
    # ответ нейронки -> карточка мероприятия
    "ai_dialog_event": EventUIContext(  # legacy
        name="ai_dialog_event",
        list_open_media_id="user_events_open",
        pagination_builder=user_events_pagination_keyboard,
        extra_groups_builder=None,
        back_value=None,
        back_label="",
        enter_code_media_id="event_user_enter_code",
        sign_up_media_id="event_user_sign_up",
        sign_out_media_id="event_user_sign_out",
    ),
}


def get_ui(ctx_name: str) -> EventUIContext:  # legacy
    return _UI.get(ctx_name, _UI["events"])


def build_back_keyboard(ctx_name: str) -> list[InteractiveMediaGroup]:
    ui = get_ui(ctx_name)
    if not ui.back_value:
        return []
    return MediaGroupBuilder(
        [Button(media_id=ui.back_value, value="", label=ui.back_label)]
    ).build()


def split_event_value(raw: Any) -> tuple[str, str | None]:  # legacy
    """
    ожидаем:
      - "<event_id>"
      - "<event_id>|<ctx>"
    """
    s = str(raw or "").strip()
    if "|" in s:
        ev_id, ctx = s.split("|", 1)
        return ev_id.strip(), (ctx.strip() or None)
    return s, None


def _send_events_list_screen(  # legacy
    peer,
    *,
    ui: EventUIContext,
    ctx_name: str,
    offset: int,
    limit: int,
    page_data: EventCardsPageSchema,
    note: str | None = None,
    header: str | None = None,
    empty_text: str | None = None,
    empty_groups: list[InteractiveMediaGroup] | None = None,
    filter_summary: str | None = None,
    show_active: bool = False,
):
    # ИСПРАВЛЕНО: используем event_cards вместо events
    event_cards = page_data.event_cards  # list[EventCardSchema]
    has_prev = page_data.has_prev
    has_next = page_data.has_next

    prefix = f"{note}\n\n" if note else ""
    header_part = (header if header is not None else "📅 Мероприятия") + "\n\n"
    filter_part = f"\n\n{filter_summary}" if filter_summary else ""

    if not event_cards:  # проверяем карточки
        text = (
            prefix
            + header_part
            + (
                empty_text
                or "⚠️ Мероприятий не найдено.\n\nПопробуй изменить критерии или вернись назад."
            )
            + filter_part
        )
        im: list[InteractiveMediaGroup] = []
        footer: list[InteractiveMediaGroup] = []
        if empty_groups is not None:
            footer = empty_groups
        elif ui.extra_groups_builder:
            footer = ui.extra_groups_builder()
        im += events_filters_button_keyboard(ctx_name=ctx_name, offset=offset)
        im += ui.pagination_builder(
            offset=offset,
            limit=limit,
            has_prev=has_prev,
            has_next=has_next,
        )
        im += footer
        bot.messaging.send_message(peer=peer, text=text, interactive_media_groups=im)
        return

    text = (
        prefix
        + header_part
        + f"Страница: {page_data.current_page} / {page_data.total_pages}\n\n"
        + "Выбери мероприятие:"
        + filter_part
    )

    im: list[InteractiveMediaGroup] = []
    im += events_select_keyboard(
        event_cards,
        ctx=ctx_name,
        open_media_id=ui.list_open_media_id,
        start_index=offset + 1,
        per_row=1,
    )
    im += events_filters_button_keyboard(ctx_name=ctx_name, offset=offset)
    im += ui.pagination_builder(offset=offset, limit=limit, has_prev=has_prev, has_next=has_next)

    if ui.extra_groups_builder:
        im += ui.extra_groups_builder()

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


def send_event_cards_page(
    peer,
    *,
    offset: int,
    event_service: EventService,
    ctx_name: str,
    note: str | None = None,
    context: FSMContext | None = None,
):
    ui = get_ui(ctx_name)
    limit = EVENTS_PAGE_SIZE

    flt = get_events_filter(context, ctx_name=ctx_name) if context is not None else None
    summary = format_events_filter_summary(flt) if context is not None else None

    page_data = fetch_events_page(
        event_service=event_service,
        offset=offset,
        limit=limit,
        requester_messenger_id=peer.id,
        filters=flt,
    )

    _send_events_list_screen(
        peer,
        ui=ui,
        ctx_name=ctx_name,
        offset=offset,
        limit=limit,
        page_data=page_data,
        note=note,
        filter_summary=summary,
    )


def send_part_events_page(  # legacy
    peer,
    *,
    offset: int,
    event_service: EventService,
    context: FSMContext | None = None,
    note: str | None = None,
):
    bot.messaging.send_message(
        peer=peer,
        text=("f{note}\n\n" if note else "Мои мероприятия\n\nПока не реализовано"),
        interactive_media_groups=build_back_keyboard("part_events"),
    )


def send_points_page(  # legacy
    peer,
    *,
    offset: int,
    event_service: EventService,
    context: FSMContext | None = None,
):
    bot.messaging.send_message(
        peer=peer,
        text=("Меорприятия за которые я получил баллы\n\nПока не реализовано"),
        interactive_media_groups=build_back_keyboard("part_events"),
    )


def send_my_events_page(  # legacy
    peer,
    *,
    offset: int,
    event_service: EventService,
    requester_messenger_id: int,
    ctx_name: str,
    note: str | None = None,
    context: FSMContext | None = None,
):
    ui = get_ui(ctx_name)
    limit = EVENTS_PAGE_SIZE

    flt = get_events_filter(context, ctx_name=ctx_name) if context is not None else None
    summary = format_events_filter_summary(flt) if context is not None else None

    page_data = fetch_events_page(
        event_service=event_service,
        offset=offset,
        limit=limit,
        requester_messenger_id=peer.id,
        filters=flt,
        mine=True,
    )

    _send_events_list_screen(
        peer,
        ui=ui,
        ctx_name=ctx_name,
        offset=offset,
        limit=limit,
        page_data=page_data,
        note=note,
        header="👤 Мои мероприятия",
        filter_summary=summary,
        show_active=True,
    )


def send_event_card(
    peer,
    *,
    event_id: str,
    event_service: EventService,
    report_service: ReportService | None = None,
    user: UserSchema | None,
    ctx_name: str,
    note: str | None = None,
    show_organizer: bool = True,
):
    ui = get_ui(ctx_name)

    card: EventCardSchema | None = event_service.get_event_by_id(event_id, peer.id)
    if card is None:
        text = f"{note}\n\n⚠️ Мероприятие не найдено." if note else "⚠️ Мероприятие не найдено."
        bot.messaging.send_message(
            peer=peer,
            text=text,
            interactive_media_groups=build_back_keyboard(ctx_name),
        )
        return

    # TODO: 1. Убрать проверку права редактирования в сервис
    can_edit = card.allowed_actions.can_edit if card.allowed_actions else False
    is_participant = card.participation is not None
    show_code = card.allowed_actions.can_view_code if card.allowed_actions else False
    # TODO: 2. Отдельная проверка на право доступа к отчёту
    can_access_reports = can_edit

    can_sign_up = card.allowed_actions.can_sign_up if card.allowed_actions else False

    logger.debug(f"""
        for event with id {event_id}:
        can_sign_up = {can_sign_up}
            card.event.participation_count < card.event.participation_limit is {card.event.participation_count < card.event.participation_limit}
            card.event.archived_at is not None is {card.event.archived_at is not None}
            not card.event.deleted is {not card.event.deleted}
            card.event.active is {card.event.active}
                 """)

    report_exists: bool | None = None
    if can_access_reports and report_service is not None:
        report_exists = report_service.report_exists(
            event_id=str(card.event.id),
            requester_messenger_id=peer.id,
        )
    else:
        report_exists = None

    # TODO: 3 Separete permissions
    can_create_report = bool(can_edit and (report_exists is False))
    can_view_report = bool(can_access_reports and (report_exists is True))

    show_active = ctx_name == "my_events"

    base = format_event_details(
        card,
        show_active=show_active,
        show_code=show_code,
        show_organizer=show_organizer,
        show_report_status=bool(can_access_reports and report_exists is not None),
        report_exists=bool(report_exists) if report_exists is not None else None,
    )
    text = f"{note}\n\n{base}" if note else base

    bot.messaging.send_message(
        peer=peer,
        text=text,
        interactive_media_groups=event_actions_keyboard(
            card.event.id,
            is_participant=is_participant,
            can_edit=can_edit,
            back_value=ui.back_value,
            back_label=ui.back_label,
            enter_code_media_id=ui.enter_code_media_id,
            sign_up_media_id=ui.sign_up_media_id,
            sign_out_media_id=ui.sign_out_media_id,
            ctx=ui.name,  # важно: чтобы value был "<event_id>|<ctx>"
            can_create_report=can_create_report,
            can_view_report=can_view_report,
            can_sign_up=can_sign_up,
        ),
    )


# TODO перенести клавиатуру туда где ей место
def build_back_to_event_keyboard(
    event_id: str, ctx_name: str
) -> list[InteractiveMediaGroup]:  # legacy
    ui = get_ui(ctx_name)
    return MediaGroupBuilder(
        [
            Button(
                media_id=ui.list_open_media_id,
                value=f"{event_id}|{ui.name}",
                label="⬅️ К мероприятию",
            )
        ]
    ).build()


# TODO перенести клавиатуру туда где ей место
def build_back_to_event_editing_keyboard(
    event_id: str, ctx_name: str
) -> list[InteractiveMediaGroup]:  # legacy
    return MediaGroupBuilder(
        [
            Button(
                media_id="event_menu_edit",
                value=f"{event_id}|{ctx_name}",
                label="⬅️ К редактированию мероприятия",
            )
        ]
    ).build()

--- services/bot/core/handlers/moderation_menu.py ---
from dialog_bot_sdk.entities.messaging import UpdateInteractiveMediaEvent

from core.bot_kit.fsm import FSMContext
from core.bot_kit.router import Router
from core.config import bot
from core.handlers.events_ui import (
    send_event_card,
    send_event_cards_page,
    send_my_events_page,
    split_event_value,
)
from core.markups import moderation_menu_keyboard
from core.schemas import UserSchema
from core.services import EventService, ReportService
from core.utils import clear_context_keep_events_filters, delete_prev_message_by_peer

moderation_menu_rt = Router()


@bot.di
def moderation_menu_handler(event: UpdateInteractiveMediaEvent, context: FSMContext):  # legacy
    clear_context_keep_events_filters(context)
    delete_prev_message_by_peer(bot, event.peer)

    bot.messaging.send_message(
        peer=event.peer,
        text="Ты в мастерской!\n\nВыбери, что хочешь сделать 👇",
        interactive_media_groups=moderation_menu_keyboard(),
    )


@bot.di
def all_events_handler(  # legacy
    event: UpdateInteractiveMediaEvent,
    context: FSMContext,
    event_service: EventService,
):
    delete_prev_message_by_peer(bot, event.peer)
    clear_context_keep_events_filters(context)
    send_event_cards_page(
        event.peer,
        offset=0,
        event_service=event_service,
        ctx_name="moderation",
        context=context,
    )


@bot.di
def my_events_handler(  # legacy
    event: UpdateInteractiveMediaEvent,
    context: FSMContext,
    event_service: EventService,
):
    delete_prev_message_by_peer(bot, event.peer)
    clear_context_keep_events_filters(context)
    send_my_events_page(
        event.peer,
        offset=0,
        event_service=event_service,
        requester_messenger_id=event.peer.id,
        ctx_name="my_events",
        context=context,
    )


@bot.di
def my_events_page_handler(  # legacy
    event: UpdateInteractiveMediaEvent,
    context: FSMContext,
    event_service: EventService,
):
    delete_prev_message_by_peer(bot, event.peer)
    clear_context_keep_events_filters(context)

    try:
        offset = int(event.data.value)
        if offset < 0:
            offset = 0
    except Exception:
        offset = 0

    send_my_events_page(
        event.peer,
        offset=offset,
        event_service=event_service,
        requester_messenger_id=event.peer.id,
        ctx_name="my_events",
        context=context,
    )


@bot.di
def my_events_open_handler(  # legacy
    event: UpdateInteractiveMediaEvent,
    context: FSMContext,
    event_service: EventService,
    report_service: ReportService,
    user: UserSchema | None,
):
    delete_prev_message_by_peer(bot, event.peer)
    context.set_state(None)

    event_id, ctx_name = split_event_value(event.data.value)
    send_event_card(
        event.peer,
        event_id=event_id,
        event_service=event_service,
        user=user,
        ctx_name=ctx_name or "my_events",
        report_service=report_service,
    )


@bot.di
def all_events_page_handler(  # legacy
    event: UpdateInteractiveMediaEvent,
    context: FSMContext,
    event_service: EventService,
):
    delete_prev_message_by_peer(bot, event.peer)
    clear_context_keep_events_filters(context)

    try:
        offset = int(event.data.value)
        if offset < 0:
            offset = 0
    except Exception:
        offset = 0

    send_event_cards_page(
        event.peer,
        offset=offset,
        event_service=event_service,
        ctx_name="moderation",
        context=context,
    )


@bot.di
def all_events_open_handler(  # legacy
    event: UpdateInteractiveMediaEvent,
    context: FSMContext,
    event_service: EventService,
    report_service: ReportService,
    user: UserSchema | None,
):
    delete_prev_message_by_peer(bot, event.peer)
    context.set_state(None)

    event_id, ctx = split_event_value(event.data.value)
    ctx_name = ctx or "moderation"
    send_event_card(
        event.peer,
        event_id=event_id,
        event_service=event_service,
        user=user,
        ctx_name=ctx_name,
        report_service=report_service,
    )

--- services/bot/core/handlers/__init__.py ---
from core.bot_kit.router import Router

from .admin import admin_menu_handler, admin_rt, del_moderator_handler, set_moderator_handler
from .ai import ai_assistant_handler, ai_rt, event_source_callback_handler
from .event import (
    event_participants_open_handler,
    event_user_enter_code_start_handler,
    event_user_sign_out_handler,
    event_user_sign_up_handler,
    events_menu_handler,
    part_events_menu_handler,
    part_events_page_handler,
    user_events_open_handler,
    user_events_page_handler,
    user_events_rt,
)
from .events_filters import (
    events_filters_apply_handler,
    events_filters_back_handler,
    events_filters_mode_open_handler,
    events_filters_mode_select_handler,
    events_filters_not_implemented_handler,
    events_filters_open_handler,
    events_filters_reset_handler,
    events_filters_tags_open_handler,
    events_filters_tags_toggle_handler,
)
from .main import main_menu_handler, start_handler
from .moderation_create import (
    create_event_start_handler,
    create_rt,
    event_create_back_to_time_handler,
    project_input_dash_handler,
)
from .moderation_edit import (
    edit_rt,
    event_menu_delete_handler,
    event_menu_edit_field_handler,
    event_menu_edit_handler,
    event_menu_enter_code_start_handler,
    event_tags_toggle_handler,
    gosb_approved,
    gosb_update_selected,
    tb_approved,
    tb_reselect_handler,
    tb_update_selected,
)
from .moderation_menu import (
    all_events_handler,
    all_events_open_handler,
    all_events_page_handler,
    moderation_menu_handler,
    moderation_menu_rt,
    my_events_handler,
    my_events_open_handler,
    my_events_page_handler,
)
from .noop import noop_handler
from .points import points_event_open_handler, points_menu_handler, points_page_handler, points_rt
from .profile import my_profile_handler, profile_tags_toggle_handler, update_user_interests_start
from .profile import rt as update_user_rt
from .report import (
    report_create_start_handler,
    report_rt,
    report_view_handler,
    reports_export_handler,
)
from .request import (
    all_requests_handler,
    my_requests_handler,
    my_requests_page_handler,
    request_approve_handler,
    request_cancel_handler,
    request_open_handler,
    request_reject_handler,
    request_repeat_handler,
    requests_page_handler,
    requests_rt,
)
from .view_by_uuid import rt as view_by_uuid_rt
from .view_by_uuid import view_by_uuid_start_handler
from .volunteer_home import points_leaderboard_handler, volunteer_home_handler

events_rt = Router()
events_rt.register(moderation_menu_rt)
events_rt.register(create_rt)
events_rt.register(edit_rt)

__all__ = [
    "admin_menu_handler",
    "admin_rt",
    "ai_assistant_handler",
    "ai_rt",
    "all_events_handler",
    "all_events_open_handler",
    "all_events_page_handler",
    "all_requests_handler",
    "create_event_start_handler",
    "create_rt",
    "del_moderator_handler",
    "edit_rt",
    "event_create_back_to_time_handler",
    "event_menu_delete_handler",
    "event_menu_edit_field_handler",
    "event_menu_edit_handler",
    "event_menu_enter_code_start_handler",
    "event_participants_open_handler",
    "event_source_callback_handler",
    "event_tags_toggle_handler",
    "event_user_enter_code_start_handler",
    "event_user_sign_out_handler",
    "event_user_sign_up_handler",
    "events_filters_apply_handler",
    "events_filters_back_handler",
    "events_filters_mode_open_handler",
    "events_filters_mode_select_handler",
    "events_filters_not_implemented_handler",
    "events_filters_open_handler",
    "events_filters_reset_handler",
    "events_filters_tags_open_handler",
    "events_filters_tags_toggle_handler",
    "events_menu_handler",
    "events_rt",
    "gosb_approved",
    "gosb_update_selected",
    "main_menu_handler",
    "moderation_menu_handler",
    "moderation_menu_rt",
    "my_events_handler",
    "my_events_open_handler",
    "my_events_page_handler",
    "my_profile_handler",
    "my_requests_handler",
    "my_requests_page_handler",
    "noop_handler",
    "part_events_menu_handler",
    "part_events_page_handler",
    "points_event_open_handler",
    "points_leaderboard_handler",
    "points_menu_handler",
    "points_page_handler",
    "points_rt",
    "profile_tags_toggle_handler",
    "project_input_dash_handler",
    "report_create_start_handler",
    "report_rt",
    "report_view_handler",
    "reports_export_handler",
    "request_approve_handler",
    "request_cancel_handler",
    "request_open_handler",
    "request_reject_handler",
    "request_repeat_handler",
    "requests_page_handler",
    "requests_rt",
    "set_moderator_handler",
    "start_handler",
    "tb_approved",
    "tb_reselect_handler",
    "tb_update_selected",
    "update_user_interests_start",
    "update_user_rt",
    "user_events_open_handler",
    "user_events_page_handler",
    "user_events_rt",
    "view_by_uuid_rt",
    "view_by_uuid_start_handler",
    "volunteer_home_handler",
]

--- services/bot/main.py ---
from core.bot_kit.router import Router
from core.config import bot
from core.handlers import (
    admin_menu_handler,
    admin_rt,
    ai_assistant_handler,
    ai_rt,
    all_events_handler,
    all_events_open_handler,
    all_events_page_handler,
    all_requests_handler,
    create_event_start_handler,
    create_rt,
    del_moderator_handler,
    edit_rt,
    event_create_back_to_time_handler,
    event_menu_delete_handler,
    event_menu_edit_field_handler,
    event_menu_edit_handler,
    event_menu_enter_code_start_handler,
    event_participants_open_handler,
    event_source_callback_handler,
    event_tags_toggle_handler,
    event_user_enter_code_start_handler,
    event_user_sign_out_handler,
    event_user_sign_up_handler,
    events_filters_apply_handler,
    events_filters_back_handler,
    events_filters_mode_open_handler,
    events_filters_mode_select_handler,
    events_filters_not_implemented_handler,
    events_filters_open_handler,
    events_filters_reset_handler,
    events_filters_tags_open_handler,
    events_filters_tags_toggle_handler,
    events_menu_handler,
    events_rt,
    gosb_approved,
    gosb_update_selected,
    main_menu_handler,
    moderation_menu_handler,
    moderation_menu_rt,
    my_events_handler,
    my_events_open_handler,
    my_events_page_handler,
    my_profile_handler,
    my_requests_handler,
    my_requests_page_handler,
    noop_handler,
    part_events_menu_handler,
    part_events_page_handler,
    points_event_open_handler,
    points_leaderboard_handler,
    points_menu_handler,
    points_page_handler,
    points_rt,
    profile_tags_toggle_handler,
    project_input_dash_handler,
    report_create_start_handler,
    report_rt,
    report_view_handler,
    reports_export_handler,
    request_approve_handler,
    request_cancel_handler,
    request_open_handler,
    request_reject_handler,
    request_repeat_handler,
    requests_page_handler,
    requests_rt,
    set_moderator_handler,
    start_handler,
    tb_approved,
    tb_reselect_handler,
    tb_update_selected,
    update_user_interests_start,
    update_user_rt,
    user_events_open_handler,
    user_events_page_handler,
    user_events_rt,
    view_by_uuid_rt,
    view_by_uuid_start_handler,
    volunteer_home_handler,
)
from dialog_bot_sdk.entities.messaging import CommandHandler, EventHandler


def handlers_setting() -> None:  # legacy
    router = Router()
    router.register(edit_rt)
    router.register(moderation_menu_rt)
    router.register(create_rt)
    router.register(update_user_rt)
    router.register(events_rt)
    router.register(user_events_rt)
    router.register(points_rt)
    router.register(view_by_uuid_rt)
    router.register(report_rt)
    router.register(ai_rt)
    router.register(requests_rt)
    router.register(admin_rt)

    router.subscribe(bot)
    bot.messaging.command_handler(
        [
            CommandHandler(
                function=start_handler,
                command="start",
                description="Расскажу о себе",
            ),
            CommandHandler(
                function=moderation_menu_handler,
                command="workship",
                description="Мастерская",
            ),
            CommandHandler(
                function=create_event_start_handler,
                command="create_event",
                description="Создать мероприятие",
            ),
            CommandHandler(
                function=all_events_handler,
                command="all_events",
                description="Показать все мероприятия",
            ),
            CommandHandler(
                function=reports_export_handler,
                command="reports_export",
                description="Выгрузка отчётов",
            ),
        ]
    )

    bot.messaging.event_handler(
        [
            EventHandler(function=volunteer_home_handler, _id="volunteer_home"),
            EventHandler(function=main_menu_handler, _id="main_menu"),
            EventHandler(function=ai_assistant_handler, _id="ai_assistant"),
            EventHandler(function=my_profile_handler, _id="my_profile"),
            EventHandler(function=update_user_interests_start, _id="update_user_interests"),
            EventHandler(function=profile_tags_toggle_handler, _id="profile_tags_toggle"),
            EventHandler(function=points_menu_handler, _id="points"),
            EventHandler(function=points_leaderboard_handler, _id="points_leaderboard"),
            EventHandler(function=moderation_menu_handler, _id="moderation"),
            EventHandler(function=reports_export_handler, _id="download_reports_list"),
            EventHandler(function=all_events_handler, _id="all_events"),
            EventHandler(function=events_menu_handler, _id="events"),
            EventHandler(function=part_events_menu_handler, _id="part_events"),
            EventHandler(function=user_events_page_handler, _id="user_events_prev"),
            EventHandler(function=user_events_page_handler, _id="user_events_next"),
            EventHandler(function=part_events_page_handler, _id="part_events_prev"),
            EventHandler(function=part_events_page_handler, _id="part_events_next"),
            EventHandler(function=event_user_sign_up_handler, _id="event_user_sign_up"),
            EventHandler(function=event_user_sign_out_handler, _id="event_user_sign_out"),
            EventHandler(function=event_user_enter_code_start_handler, _id="event_user_enter_code"),
            EventHandler(function=event_tags_toggle_handler, _id="event_tags_toggle"),
            EventHandler(function=event_participants_open_handler, _id="event_participants_open"),
            EventHandler(function=report_create_start_handler, _id="event_report_create"),
            EventHandler(function=report_view_handler, _id="event_report_view"),
            EventHandler(function=all_events_page_handler, _id="all_events_prev"),
            EventHandler(function=all_events_page_handler, _id="all_events_next"),
            EventHandler(function=my_events_handler, _id="my_events"),
            EventHandler(function=my_events_page_handler, _id="my_events_prev"),
            EventHandler(function=my_events_page_handler, _id="my_events_next"),
            EventHandler(function=my_events_open_handler, _id="my_events_open"),
            EventHandler(function=create_event_start_handler, _id="create_event"),
            EventHandler(function=admin_menu_handler, _id="administration"),
            EventHandler(function=event_menu_enter_code_start_handler, _id="event_menu_enter_code"),
            EventHandler(function=event_menu_delete_handler, _id="event_menu_delete"),
            EventHandler(function=event_menu_edit_handler, _id="event_menu_edit"),
            EventHandler(function=view_by_uuid_start_handler, _id="view_by_uuid"),
            EventHandler(function=event_menu_edit_field_handler, _id="event_menu_edit_name"),
            EventHandler(function=event_menu_edit_field_handler, _id="event_menu_edit_description"),
            EventHandler(function=event_menu_edit_field_handler, _id="event_menu_edit_date"),
            EventHandler(function=event_menu_edit_field_handler, _id="event_menu_edit_time"),
            EventHandler(function=event_menu_edit_field_handler, _id="event_menu_edit_tags"),
            EventHandler(function=event_menu_edit_field_handler, _id="event_menu_edit_organizers"),
            EventHandler(function=event_menu_edit_field_handler, _id="event_menu_edit_hours"),
            EventHandler(function=event_menu_edit_field_handler, _id="event_menu_edit_code"),
            EventHandler(function=event_menu_edit_field_handler, _id="event_menu_edit_gosb"),
            EventHandler(
                function=event_menu_edit_field_handler, _id="event_menu_edit_participation_limit"
            ),
            EventHandler(function=user_events_open_handler, _id="user_events_open"),
            EventHandler(function=all_events_open_handler, _id="all_events_open"),
            EventHandler(function=points_page_handler, _id="points_prev"),
            EventHandler(function=points_page_handler, _id="points_next"),
            EventHandler(function=points_event_open_handler, _id="points_event_open"),
            EventHandler(function=points_event_open_handler, _id="points_events_open"),
            EventHandler(function=events_filters_open_handler, _id="events_filters_open"),
            EventHandler(function=events_filters_apply_handler, _id="events_filters_apply"),
            EventHandler(function=events_filters_reset_handler, _id="events_filters_reset"),
            EventHandler(function=events_filters_back_handler, _id="events_filters_back"),
            EventHandler(function=events_filters_tags_open_handler, _id="events_filters_tags"),
            EventHandler(
                function=events_filters_tags_toggle_handler, _id="events_filters_tags_toggle"
            ),
            EventHandler(
                function=events_filters_not_implemented_handler, _id="events_filters_date"
            ),
            EventHandler(function=events_filters_mode_open_handler, _id="events_filters_mode"),
            EventHandler(
                function=events_filters_mode_select_handler, _id="events_filters_mode_select"
            ),
            EventHandler(function=all_requests_handler, _id="requests"),
            EventHandler(function=my_requests_handler, _id="my_requests"),
            EventHandler(function=requests_page_handler, _id="requests_prev"),
            EventHandler(function=my_requests_page_handler, _id="my_requests_prev"),
            EventHandler(function=requests_page_handler, _id="requests_next"),
            EventHandler(function=my_requests_page_handler, _id="my_requests_next"),
            EventHandler(function=request_open_handler, _id="request_open"),
            EventHandler(function=request_open_handler, _id="my_request_open"),
            EventHandler(function=request_approve_handler, _id="request_approve"),
            EventHandler(function=request_reject_handler, _id="request_reject"),
            EventHandler(function=request_repeat_handler, _id="request_repeat"),
            EventHandler(function=request_cancel_handler, _id="request_cancel"),
            # TODO почистить
            EventHandler(function=project_input_dash_handler, _id="project_input_dash"),
            EventHandler(function=noop_handler, _id="noop_prev"),
            EventHandler(function=noop_handler, _id="noop_mid"),
            EventHandler(function=noop_handler, _id="noop_next"),
            EventHandler(function=noop_handler, _id="noop_uuid"),
            EventHandler(function=tb_approved, _id="tb_approved"),
            EventHandler(function=gosb_approved, _id="gosb_approved"),
            EventHandler(function=tb_update_selected, _id="tb_select"),
            EventHandler(
                function=event_create_back_to_time_handler, _id="event_create_back_to_time"
            ),
            EventHandler(function=tb_reselect_handler, _id="tb_reselect"),
            EventHandler(function=gosb_update_selected, _id="gosb_select"),
            EventHandler(function=event_source_callback_handler, _id="ai_event_card"),
            EventHandler(function=set_moderator_handler, _id="add_moderator"),
            EventHandler(function=del_moderator_handler, _id="delete_moderator"),
        ]
    )


def main() -> None:  # legacy
    handlers_setting()
    bot.profile.edit_about_sync("Волонтёрский бот, который помогает сотрудникам Сбера нести добро!")
    bot.updates.on_updates(do_read_message=True, do_register_commands=True)


if __name__ == "__main__":
    main()

--- services/backend/internal/drivers/http/v1/volunteering/participation_card/participation_card_controller.go ---
package participation_card_http

import (
	"main/internal/domain/volunteering/participation_card"
	"main/pkg"
	"net/http"

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

type ParticipationCardController struct {
	query  participation_card.ParticipationCardQuery
	logger pkg.Logger
}

func NewParticipationCardController(
	query participation_card.ParticipationCardQuery,
	logger pkg.Logger,
) *ParticipationCardController {
	return &ParticipationCardController{
		query:  query,
		logger: logger,
	}
}

// GetParticipationList godoc
// @Summary      Get participation card list
// @Description  Returns paginated list of event participations
// @Tags         participations
// @Accept       json
// @Produce      json
// @Security     BearerAuth
// @Param        event_id   path      string                true  "Event ID"
// @Param        request    query     GetParticipationsRequest  false  "Participations parameters"
// @Success      200  {object}  participation_card.ParticipationCardPage   "Participations cards retrieved successfully"
// @Failure      400  {object}  map[string]interface{}     "Invalid parameters"
// @Failure      401  {object}  map[string]interface{}     "Unauthorized"
// @Failure      500  {object}  map[string]interface{}     "Internal server error"
// @Router       /events/{event_id}/participations [get]
func (controller *ParticipationCardController) GetParticipationList(ctx *gin.Context) {
	event_id := ctx.Param("event_id")

	var req GetParticipationsRequest
	if err := ctx.ShouldBindQuery(&req); err != nil {
		ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid query parameters: " + err.Error()})
		return
	}

	if req.Page < 1 {
		req.Page = 1
	}
	if req.Limit < 1 {
		req.Limit = 20
	}

	list, err := controller.query.GetParticipationList(event_id, req.Page, req.Limit)
	if err != nil {
		ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}

	ctx.JSON(http.StatusOK, list)
}

--- services/backend/internal/drivers/http/v1/volunteering/participation_card/participation_card_request.go ---
package participation_card_http

type GetParticipationsRequest struct {
	Page  int `form:"page" binding:"omitempty,min=1"`
	Limit int `form:"limit" binding:"omitempty,min=1,max=100"`
}

--- services/backend/internal/drivers/http/v1/volunteering/volunteering_routes.go ---
package volunteering_http

import (
	"main/internal/drivers/http/middlewares"
	event_card_http "main/internal/drivers/http/v1/volunteering/event_card"
	event_organizers_http "main/internal/drivers/http/v1/volunteering/event_organizers"
	event_tags_http "main/internal/drivers/http/v1/volunteering/event_tags"
	events_http "main/internal/drivers/http/v1/volunteering/events"
	gosbs_http "main/internal/drivers/http/v1/volunteering/gosbs"
	participation_http "main/internal/drivers/http/v1/volunteering/participation"
	participation_card_http "main/internal/drivers/http/v1/volunteering/participation_card"
	registry_http "main/internal/drivers/http/v1/volunteering/registry"
	request_card_http "main/internal/drivers/http/v1/volunteering/request_card"
	requests_http "main/internal/drivers/http/v1/volunteering/requests"
	tags_http "main/internal/drivers/http/v1/volunteering/tags"
	terbank_moderators_http "main/internal/drivers/http/v1/volunteering/terbank_moderators"
	terbanks_http "main/internal/drivers/http/v1/volunteering/terbanks"
	volunteer_http "main/internal/drivers/http/v1/volunteering/volunteer"
	volunteer_card_http "main/internal/drivers/http/v1/volunteering/volunteer_card"
	volunteer_tag_http "main/internal/drivers/http/v1/volunteering/volunteer_tag"
	"main/pkg"

	"go.uber.org/fx"
)

type VolunteeringRoutes struct {
	handler pkg.RequestHandler

	authMiddleware     *middlewares.AuthMiddleware
	testAuthMiddleware *middlewares.TestAuthMiddleware
	contextMiddleware  *middlewares.ContextMiddleware
	roleMiddleware     *middlewares.RoleMiddleware
	policyMiddleware   *middlewares.PolicyMiddleware

	tagsController              *tags_http.TagsController
	volunteerController         *volunteer_http.VolunteerController
	volunteerTagsController     *volunteer_tag_http.VolunteerTagController
	volunteerCardController     *volunteer_card_http.VolunteerCardController
	eventController             *events_http.EventsController
	eventTagController          *event_tags_http.EventTagController
	eventCardController         *event_card_http.EventCardController
	eventOrganizerController    *event_organizers_http.EventOrganizerController
	participationController     *participation_http.ParticipationController
	participationCardController *participation_card_http.ParticipationCardController
	requestController           *requests_http.RequestController
	requestCardController       *request_card_http.RequestCardController
	terbankController           *terbanks_http.TerbankController
	terbankModeratorController  *terbank_moderators_http.TerbankModeratorController
	gosbController              *gosbs_http.GosbController
	registryController          *registry_http.RegistryController
}

func NewVolunteeringRoutes(
	handler pkg.RequestHandler,
	authMiddleware *middlewares.AuthMiddleware,
	roleMiddleware *middlewares.RoleMiddleware,
	contextMiddleware *middlewares.ContextMiddleware,
	policyMiddleware *middlewares.PolicyMiddleware,
	testAuthMiddleware *middlewares.TestAuthMiddleware,
	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,
	requestController *requests_http.RequestController,
	requestCardController *request_card_http.RequestCardController,
	terbankController *terbanks_http.TerbankController,
	terbankModeratorController *terbank_moderators_http.TerbankModeratorController,
	gosbController *gosbs_http.GosbController,
	eventOrganizerController *event_organizers_http.EventOrganizerController,
	participationController *participation_http.ParticipationController,
	participationCardController *participation_card_http.ParticipationCardController,
	registryController *registry_http.RegistryController,
) *VolunteeringRoutes {
	return &VolunteeringRoutes{
		handler:                     handler,
		authMiddleware:              authMiddleware,
		contextMiddleware:           contextMiddleware,
		roleMiddleware:              roleMiddleware,
		policyMiddleware:            policyMiddleware,
		testAuthMiddleware:          testAuthMiddleware,
		volunteerController:         volunteerController,
		tagsController:              tagsController,
		volunteerTagsController:     volunteerTagsController,
		volunteerCardController:     volunteerCardController,
		eventController:             eventController,
		eventTagController:          eventTagController,
		eventCardController:         eventCardController,
		eventOrganizerController:    eventOrganizerController,
		requestController:           requestController,
		requestCardController:       requestCardController,
		terbankController:           terbankController,
		terbankModeratorController:  terbankModeratorController,
		gosbController:              gosbController,
		participationController:     participationController,
		participationCardController: participationCardController,
		registryController:          registryController,
	}
}

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

	// 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()).
		Use(r.contextMiddleware.Handler()).
		Use(r.roleMiddleware.Handler())
	{
		eventsGroup.GET("/card/list/mine", r.eventCardController.GetOrganizerEventCardList)
		eventsGroup.GET("/card/list/participations", r.eventCardController.GetParticiapatedEventCardList)
		eventsGroup.GET("/card/list", r.eventCardController.GetEventCardList)

		eventsGroup.GET("/:event_id/card",
			r.policyMiddleware.OptionalPermissions("event:viewCode", "event:edit"),
			r.eventCardController.GetEventCard)

		eventsGroup.POST("/:event_id/tags",
			r.policyMiddleware.RequirePermission("event:edit"),
			r.eventTagController.SetTag)

		eventsGroup.DELETE("/:event_id/tags",
			r.policyMiddleware.RequirePermission("event:edit"),
			r.eventTagController.RemoveTag)

		eventsGroup.POST("/:event_id/organizers",
			r.policyMiddleware.RequirePermission("event:handleOrganizers"),
			r.eventOrganizerController.AddOrganizer)

		eventsGroup.DELETE("/:event_id/organizers",
			r.policyMiddleware.RequirePermission("event:handleOrganizers"),
			r.eventOrganizerController.RemoveOrganizer)

		eventsGroup.PATCH("/:event_id",
			r.policyMiddleware.RequirePermission("event:edit"),
			r.eventController.UpdateEvent)

		eventsGroup.POST("/",
			r.policyMiddleware.OptionalPermission("event:autoActivate"),
			r.registryController.RegisterEvent)
	}

	// Requests
	requestsGroup := api.Group("/requests").
		Use(r.testAuthMiddleware.Handler()).
		Use(r.contextMiddleware.Handler()).
		Use(r.roleMiddleware.Handler())
	{
		requestsGroup.GET("/card/list/mine", r.requestCardController.GetVolunteerRequestCardList)
		requestsGroup.GET("/card/list", r.requestCardController.GetRequestCardList)

		requestsGroup.GET("/:request_id/card",
			r.policyMiddleware.RequirePermission("request:view"),
			r.policyMiddleware.OptionalPermissions(
				"request:handle",
				"request:repeat",
				"request:edit",
				"request:cancel"),
			r.requestCardController.GetRequestCard)

		requestsGroup.POST("/:request_id/approve",
			r.policyMiddleware.RequirePermission("request:handle"),
			r.requestController.Approve)

		requestsGroup.POST("/:request_id/reject",
			r.policyMiddleware.RequirePermission("request:handle"),
			r.requestController.Reject)

		requestsGroup.POST("/:request_id/repeat",
			r.policyMiddleware.RequirePermission("request:repeat"),
			r.requestController.Repeat)

		requestsGroup.POST("/:request_id/cancel",
			r.policyMiddleware.RequirePermission("request:cancel"),
			r.requestController.Cancel)
	}

	// Terbanks
	terbanksGroup := api.Group("/terbanks").
		Use(r.testAuthMiddleware.Handler()).
		Use(r.contextMiddleware.Handler()).
		Use(r.roleMiddleware.Handler())
	{
		terbanksGroup.POST("/:terbank_id/moderators",
			r.policyMiddleware.RequirePermission("terbank:editModerators"),
			r.terbankModeratorController.SetModerator)
		terbanksGroup.DELETE("/:terbank_id/moderators",
			r.policyMiddleware.RequirePermission("terbank:editModerators"),
			r.terbankModeratorController.RemoveModerator)
		terbanksGroup.GET("/:terbank_id/gosbs", r.gosbController.GetAllActualGosbs)
		terbanksGroup.GET("/", r.terbankController.GetAllActualTerbanks)
	}

	// Participations
	participationGroup := api.Group("/events/:event_id/participations").
		Use(r.testAuthMiddleware.Handler()).
		Use(r.contextMiddleware.Handler()).
		Use(r.roleMiddleware.Handler())
	{
		participationGroup.POST("/signup", r.participationController.SignUp)
		participationGroup.POST("/confirm", r.participationController.Confirm)
		participationGroup.DELETE("/cancel", r.participationController.Cancel)

		participationGroup.POST("/hours",
			r.policyMiddleware.RequirePermission("participation:addHours"),
			r.participationController.AddHours)

		participationGroup.GET("/",
			r.policyMiddleware.RequirePermission("participation:viewList"),
			r.participationCardController.GetParticipationList)
	}
}

var Module = fx.Options(
	fx.Provide(NewVolunteeringRoutes),
	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),
	fx.Provide(requests_http.NewRequestController),
	fx.Provide(request_card_http.NewRequestCardController),
	fx.Provide(terbanks_http.NewTerbankController),
	fx.Provide(terbank_moderators_http.NewTerbankModeratorController),
	fx.Provide(gosbs_http.NewGosbController),
	fx.Provide(event_organizers_http.NewEventOrganizerController),
	fx.Provide(participation_http.NewParticipationController),
	fx.Provide(participation_card_http.NewParticipationCardController),
	fx.Provide(registry_http.NewRegistryController),
)

--- services/backend/internal/domain/volunteering/participation_card/participation_card.go ---
package participation_card

import (
	"main/internal/domain/volunteering/participations"
	"main/internal/domain/volunteering/volunteer_card"
)

type ParticipationCard struct {
	Participation *participations.Participation `json:"participation"`
	VolunteerCard *volunteer_card.VolunteerCard `json:"volunteer_card"`
}

--- services/backend/internal/domain/volunteering/participation_card/participation_card_page.go ---
package participation_card

type ParticipationCardPage struct {
	ParticipationCards []*ParticipationCard `json:"participation_cards"`
	PageMeta           *PageMeta            `json:"page_meta"`
}

--- services/backend/internal/domain/volunteering/participation_card/participation_card_query.go ---
package participation_card

type ParticipationCardQuery interface {
	GetParticipationList(eventId string, page int, limit int) (*ParticipationCardPage, error)
}

--- services/backend/internal/domain/volunteering/participation_card/page_meta.go ---
package participation_card

type PageMeta struct {
	CurrentPage int `json:"current_page"`
	TotalPages  int `json:"total_pages"`
}

--- services/backend/internal/adapters/volunteering/participation_card/participation_card_mapper.go ---
package participation_card_adapters

import (
	participation_adapters "main/internal/adapters/volunteering/participation"
	volunteer_card_adapters "main/internal/adapters/volunteering/volunteer_card"
	"main/internal/domain/volunteering/participation_card"
)

func ToDomainParticipationCard(schema *ParticipationCardSchema) *participation_card.ParticipationCard {
	card := &participation_card.ParticipationCard{}

	if schema.ParticipationSchema != (participation_adapters.ParticipationSchema{}) {
		card.Participation = participation_adapters.ToDomainParticipation(&schema.ParticipationSchema)
	}

	if schema.VolunteerCard != nil {
		card.VolunteerCard = volunteer_card_adapters.ToVolunteerCardDomain(schema.VolunteerCard)
	}

	return card
}

--- services/backend/internal/adapters/volunteering/participation_card/participation_card_query.go ---
package participation_card_adapters

import (
	"main/internal/domain/volunteering/participation_card"
	"main/pkg"
)

type ParticipationCardQuery struct {
	gormDb pkg.GormDB
}

func NewParticipationCardQuery(gormDb pkg.GormDB) participation_card.ParticipationCardQuery {
	return &ParticipationCardQuery{
		gormDb: gormDb,
	}
}

func (p *ParticipationCardQuery) GetParticipationList(eventId string, page int, limit int) (*participation_card.ParticipationCardPage, error) {
	query := p.gormDb.
		Preload("VolunteerCard.Tags").
		Where("event_id = ?", eventId)

	var schemas []ParticipationCardSchema
	paginationResult, err := pkg.PaginateSimple(query, &schemas, page, limit)
	if err != nil {
		return nil, err
	}

	participationCards := make([]*participation_card.ParticipationCard, 0, len(schemas))
	for i := range schemas {
		participationCards = append(participationCards, ToDomainParticipationCard(&schemas[i]))
	}

	return &participation_card.ParticipationCardPage{
		ParticipationCards: participationCards,
		PageMeta: &participation_card.PageMeta{
			CurrentPage: paginationResult.Page,
			TotalPages:  paginationResult.TotalPages,
		},
	}, nil
}

--- services/backend/internal/adapters/volunteering/participation_card/participation_card_schema.go ---
package participation_card_adapters

import (
	participation_adapters "main/internal/adapters/volunteering/participation"
	volunteer_card_adapters "main/internal/adapters/volunteering/volunteer_card"
)

type ParticipationCardSchema struct {
	participation_adapters.ParticipationSchema

	VolunteerCard *volunteer_card_adapters.VolunteerCardSchema `gorm:"foreignKey:MessengerId;references:MessengerId"`
}

func (ParticipationCardSchema) TableName() string {
	return "participations"
}

--- services/backend/internal/drivers/http/v1/volunteering/participation/participation_controller.go ---
package participation_http

import (
	"errors"
	"fmt"
	"main/internal/domain/volunteering/events"
	"main/internal/domain/volunteering/participations"
	"main/pkg"
	"net/http"

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

type ParticipationController struct {
	participationService *participations.ParticipationService
	logger               pkg.Logger
}

func NewParticipationController(
	participationService *participations.ParticipationService,
	logger pkg.Logger,
) *ParticipationController {
	return &ParticipationController{
		participationService: participationService,
		logger:               logger,
	}
}

// SignUp godoc
// @Summary      Sign up for event
// @Description  User signs up for a specific event
// @Tags         participations
// @Accept       json
// @Produce      json
// @Security     BearerAuth
// @Param        event_id   path      string  true  "Event ID"
// @Success      200        {object}  map[string]interface{}  "Successfully signed up"
// @Failure      409        {object}  map[string]interface{}  "Conflict"
// @Failure      401 		{object}  map[string]interface{}  "Unauthorized"
// @Failure      404        {object}  map[string]interface{}  "Event not found"
// @Failure      500        {object}  map[string]interface{}  "Internal server error"
// @Router       /events/{event_id}/participations/signup [post]
func (controller *ParticipationController) SignUp(ctx *gin.Context) {
	userId := ctx.GetString("user_id")
	eventId := ctx.Param("event_id")

	if err := controller.participationService.SignUp(userId, eventId); err != nil {
		if errors.Is(err, participations.ErrInvalidState) ||
			errors.Is(err, participations.ErrAlreadyParticipating) ||
			errors.Is(err, events.ErrInvalidParticipantsCount) ||
			errors.Is(err, events.ErrArchived) ||
			errors.Is(err, events.ErrNotActive) {
			ctx.JSON(http.StatusConflict, gin.H{"error": err.Error()})
			return
		}

		if errors.Is(err, events.ErrEventNotFound) {
			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": "signed up",
	})
}

// Cancel godoc
// @Summary      Cancel participation
// @Description  User cancels their participation in an event
// @Tags         participations
// @Accept       json
// @Produce      json
// @Security     BearerAuth
// @Param        event_id   path      string  true  "Event ID"
// @Success      200        {object}  map[string]interface{}  "Successfully canceled"
// @Failure      400        {object}  map[string]interface{}  "Bad request"
// @Failure      401 		{object}  map[string]interface{}  "Unauthorized"
// @Failure      404        {object}  map[string]interface{}  "Event not found or participation not found"
// @Failure      500        {object}  map[string]interface{}  "Internal server error"
// @Router       /events/{event_id}/participations/cancel [delete]
func (controller *ParticipationController) Cancel(ctx *gin.Context) {
	userId := ctx.GetString("user_id")
	eventId := ctx.Param("event_id")

	if err := controller.participationService.Cancel(userId, eventId); err != nil {
		if errors.Is(err, participations.ErrInvalidState) ||
			errors.Is(err, events.ErrNotActive) {
			ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
			return
		}

		if errors.Is(err, events.ErrEventNotFound) ||
			errors.Is(err, participations.ErrParticipationNotFound) {
			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": "canceled",
	})
}

// Confirm godoc
// @Summary      Confirm participation
// @Description  User confirms their participation in an event using activation code
// @Tags         participations
// @Accept       json
// @Produce      json
// @Security     BearerAuth
// @Param        event_id   path      string          true  "Event ID"
// @Param        request    body      ConfirmRequest  true  "Confirmation code"
// @Success      200        {object}  map[string]interface{}  "Successfully confirmed"
// @Failure      401 		{object}  map[string]interface{}  "Unauthorized"
// @Failure      400        {object}  map[string]interface{}  "Bad request"
// @Failure      404        {object}  map[string]interface{}  "Event not found or participation not found"
// @Failure      500        {object}  map[string]interface{}  "Internal server error"
// @Router       /events/{event_id}/participations/confirm [post]
func (controller *ParticipationController) Confirm(ctx *gin.Context) {
	userId := ctx.GetString("user_id")
	eventId := ctx.Param("event_id")

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

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

		return
	}

	if err := controller.participationService.Confirm(userId, eventId, req.Code); err != nil {
		if errors.Is(err, participations.ErrInvalidState) ||
			errors.Is(err, events.ErrInvalidCode) ||
			errors.Is(err, events.ErrNotActive) {
			ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
			return
		}

		if errors.Is(err, events.ErrEventNotFound) ||
			errors.Is(err, participations.ErrParticipationNotFound) {
			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": "confirmed",
	})
}

// AddHours godoc
// @Summary      Add hours to participation
// @Description  Add volunteer hours to confirmed participation. Hours cannot exceed event limit.
// @Tags         participations
// @Accept       json
// @Produce      json
// @Security     BearerAuth
// @Param        event_id   path      string           true  "Event ID"
// @Param        request    body      AddHoursRequest  true  "Hours to add"
// @Success      200        {object}  map[string]interface{}  "Hours added successfully"
// @Failure      400        {object}  map[string]interface{}  "Invalid request format"
// @Failure      401        {object}  map[string]interface{}  "Unauthorized"
// @Failure      403        {object}  map[string]interface{}  "Forbidden"
// @Failure      404        {object}  map[string]interface{}  "Event or participation not found"
// @Failure      409        {object}  map[string]interface{}  "Conflict "
// @Failure      422        {object}  map[string]interface{}  "Unprocessable entity"
// @Failure      500        {object}  map[string]interface{}  "Internal server error"
// @Router       /events/{event_id}/participations/hours [post]
func (controller *ParticipationController) AddHours(ctx *gin.Context) {
	eventId := ctx.Param("event_id")

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

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

		return
	}

	if err := controller.participationService.AddHours(req.MessengerId, eventId, req.Hours); err != nil {
		if errors.Is(err, participations.ErrInvalidState) {
			ctx.JSON(http.StatusConflict, gin.H{"error": err.Error()})
			return
		}

		if errors.Is(err, participations.ErrInvalidHoursCount) {
			ctx.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()})
			return
		}

		if errors.Is(err, events.ErrEventNotFound) ||
			errors.Is(err, participations.ErrParticipationNotFound) {
			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": fmt.Sprintf("add %d hours", req.Hours),
	})
}

--- services/backend/internal/drivers/http/v1/volunteering/participation/participation_requests.go ---
package participation_http

type ConfirmRequest struct {
	Code string `json:"code"`
}

type AddHoursRequest struct {
	Hours       int    `json:"hours"`
	MessengerId string `json:"messenger_id"`
}

--- services/backend/internal/domain/volunteering/participations/participation.go ---
package participations

import "time"

type ParticipiationState string

const (
	StateNone      ParticipiationState = "none"
	StateSignedUp  ParticipiationState = "signed_up"
	StateConfirmed ParticipiationState = "confirmed"
	StateCanceled  ParticipiationState = "canceled"
)

type Participation struct {
	MessengerId string              `json:"messenger_id"`
	EventId     string              `json:"event_id"`
	State       ParticipiationState `json:"state"`
	Hours       int                 `json:"hours"`
	CreatedAt   time.Time           `json:"created_at"`
	UpdatedAt   time.Time           `json:"updated_at"`
}

func (p *Participation) Confirm() error {
	if p.State != StateSignedUp {
		return ErrInvalidState
	}

	p.State = StateConfirmed
	return nil
}

func (p *Participation) Cancel() error {
	if p.State != StateSignedUp {
		return ErrInvalidState
	}

	p.State = StateCanceled
	return nil
}

func (p *Participation) SetHours(hours int) error {
	if p.State != StateConfirmed {
		return ErrInvalidState
	}

	p.Hours += hours
	return nil
}

func (p *Participation) AddHours(hours int, maxHours int) error {
	if p.State != StateConfirmed {
		return ErrInvalidState
	}

	if p.Hours+hours > maxHours {
		return ErrInvalidHoursCount
	}

	p.Hours += hours
	return nil
}

--- services/backend/internal/domain/volunteering/participations/participation_service.go ---
package participations

import (
	"main/internal/domain/volunteering/events"
	"main/internal/domain/volunteering/volunteers"
)

type ParticipationService struct {
	participationRepo ParticipationRepository
	eventService      *events.EventService
	volunteerService  *volunteers.VolunteerService
	eventCfg          events.EventConfig
}

func NewParticipationService(
	participationRepo ParticipationRepository,
	eventService *events.EventService,
	volunteerService *volunteers.VolunteerService,
	eventCfgProvider events.EventConfigProvider,
) *ParticipationService {
	return &ParticipationService{
		participationRepo: participationRepo,
		eventService:      eventService,
		volunteerService:  volunteerService,
		eventCfg:          eventCfgProvider.Provide(),
	}
}

func (s *ParticipationService) SignUp(messengerId string, eventId string) error {
	count, err := s.participationRepo.Count(eventId)
	if err != nil {
		return err
	}

	newCount := count + 1
	if err := s.eventService.UpdateParticipantCount(eventId, newCount); err != nil {
		return err
	}

	if err := s.participationRepo.Create(eventId, messengerId); err != nil {
		return err
	}

	return nil
}

func (s *ParticipationService) Cancel(messengerId string, eventId string) error {
	count, err := s.participationRepo.Count(eventId)
	if err != nil {
		return err
	}

	newCount := count - 1
	if err := s.eventService.UpdateParticipantCount(eventId, newCount); err != nil {
		return err
	}

	participation, err := s.participationRepo.Get(eventId, messengerId)
	if err != nil {
		return err
	}

	if err := participation.Cancel(); err != nil {
		return err
	}

	if err := s.participationRepo.Delete(eventId, messengerId); err != nil {
		return err
	}

	return nil
}

func (s *ParticipationService) Confirm(messengerId string, eventId string, verificationCode string) error {
	result, err := s.eventService.VerificationCode(eventId, verificationCode)
	if err != nil {
		return err
	}

	participation, err := s.participationRepo.Get(eventId, messengerId)
	if err != nil {
		return err
	}

	if err := participation.Confirm(); err != nil {
		return err
	}

	if err := participation.SetHours(result.Hours); err != nil {
		return err
	}

	if err := s.volunteerService.AccureHours(messengerId, result.Hours); err != nil {
		return err
	}

	if err := s.participationRepo.Update(participation); err != nil {
		return err
	}

	return nil
}

func (s *ParticipationService) AddHours(messengerId string, eventId string, hours int) error {
	participation, err := s.participationRepo.Get(eventId, messengerId)
	if err != nil {
		return err
	}

	if err := participation.AddHours(hours, s.eventCfg.Hours.Max); err != nil {
		return err
	}

	if err := s.volunteerService.AccureHours(messengerId, hours); err != nil {
		return err
	}

	if err := s.participationRepo.Update(participation); err != nil {
		return err
	}

	return nil
}

--- services/backend/internal/domain/volunteering/participations/participation_repository.go ---
package participations

type ParticipationRepository interface {
	Create(eventId string, messengerId string) error
	Delete(eventId string, messengerId string) error
	Update(participiation *Participation) error
	Get(eventId string, messengerId string) (*Participation, error)
	Exists(eventId string, messengerId string) (bool, error)
	Count(eventId string) (int, error)
}

--- services/backend/internal/adapters/volunteering/participation/participation_repo.go ---
package participation_adapters

import (
	"context"
	"errors"
	participations_domain "main/internal/domain/volunteering/participations"
	"main/pkg"
	"time"

	"gorm.io/gorm"
)

type GormParticipationRepository struct {
	db pkg.GormDB
}

func NewGormParticipationRepository(db pkg.GormDB) participations_domain.ParticipationRepository {
	return &GormParticipationRepository{
		db: db,
	}
}

func (g *GormParticipationRepository) Create(eventId string, messengerId string) error {
	return g.db.WithTransaction(context.Background(), func(tx *gorm.DB) error {
		participation := &ParticipationSchema{
			EventId:     eventId,
			MessengerId: messengerId,
			State:       string(participations_domain.StateSignedUp),
			Hours:       0,
			CreatedAt:   time.Now(),
			UpdatedAt:   time.Now(),
		}

		err := tx.Create(participation).Error
		if err != nil {
			if errors.Is(err, gorm.ErrDuplicatedKey) {
				return participations_domain.ErrAlreadyParticipating
			}
			return err
		}

		return nil
	})
}

func (g *GormParticipationRepository) Delete(eventId string, messengerId string) error {
	return g.db.WithTransaction(context.Background(), func(tx *gorm.DB) error {
		result := tx.Where("event_id = ? AND messenger_id = ?", eventId, messengerId).
			Delete(&ParticipationSchema{})

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

		if result.RowsAffected == 0 {
			return participations_domain.ErrParticipationNotFound
		}

		return nil
	})
}

func (g *GormParticipationRepository) Update(participation *participations_domain.Participation) error {
	return g.db.WithTransaction(context.Background(), func(tx *gorm.DB) error {
		participation.UpdatedAt = time.Now()
		schema := ToParticipationSchema(participation)

		result := tx.Model(&ParticipationSchema{}).
			Where("event_id = ? AND messenger_id = ?", schema.EventId, schema.MessengerId).
			Updates(map[string]interface{}{
				"state":               schema.State,
				"participation_hours": schema.Hours,
				"updated_at":          schema.UpdatedAt,
			})

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

		if result.RowsAffected == 0 {
			return participations_domain.ErrParticipationNotFound
		}

		return nil
	})
}

func (g *GormParticipationRepository) Get(eventId string, messengerId string) (*participations_domain.Participation, error) {
	var schema ParticipationSchema

	err := g.db.DB.
		Where("event_id = ? AND messenger_id = ?", eventId, messengerId).
		First(&schema).Error

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

	return ToDomainParticipation(&schema), nil
}

func (g *GormParticipationRepository) Exists(eventId string, messengerId string) (bool, error) {
	var count int64

	err := g.db.DB.
		Model(&ParticipationSchema{}).
		Where("event_id = ? AND messenger_id = ?", eventId, messengerId).
		Count(&count).Error

	if err != nil {
		return false, err
	}

	return count > 0, nil
}

func (g *GormParticipationRepository) Count(eventId string) (int, error) {
	var count int64

	err := g.db.DB.
		Model(&ParticipationSchema{}).
		Where("event_id = ?", eventId).
		Count(&count).Error

	if err != nil {
		return 0, err
	}

	return int(count), nil
}

--- services/backend/internal/adapters/volunteering/participation/participation_schema.go ---
package participation_adapters

import (
	participations_domain "main/internal/domain/volunteering/participations"
	"time"
)

type ParticipationSchema struct {
	MessengerId string    `gorm:"column:messenger_id;primaryKey"`
	EventId     string    `gorm:"column:event_id;primaryKey"`
	State       string    `gorm:"column:state;type:varchar(50);not null;default:'signed_up'"`
	Hours       int       `gorm:"column:participation_hours;type:int;default:0"`
	CreatedAt   time.Time `gorm:"column:created_at;autoCreateTime"`
	UpdatedAt   time.Time `gorm:"column:updated_at;autoUpdateTime"`
}

func (ParticipationSchema) TableName() string {
	return "participations"
}

func ToParticipationSchema(participation *participations_domain.Participation) *ParticipationSchema {
	return &ParticipationSchema{
		MessengerId: participation.MessengerId,
		EventId:     participation.EventId,
		State:       string(participation.State),
		CreatedAt:   participation.CreatedAt,
		UpdatedAt:   participation.UpdatedAt,
	}
}

func ToDomainParticipation(schema *ParticipationSchema) *participations_domain.Participation {
	return &participations_domain.Participation{
		MessengerId: schema.MessengerId,
		EventId:     schema.EventId,
		State:       participations_domain.ParticipiationState(schema.State),
		CreatedAt:   schema.CreatedAt,
		UpdatedAt:   schema.UpdatedAt,
	}
}

Editor is loading...
Leave a Comment