Untitled

 avatar
4ae4d
plain_text
a month ago
151 kB
4
Indexable
f=services/bot/core/handlers/moderation.py
f=services/bot/core/handlers/events_ui.py
f=services/bot/core/handlers/request.py
f=services/bot/core/services/event.py
f=services/bot/core/services/requests.py
f=services/bot/core/clients/event.py
f=services/bot/core/clients/request.py
f=services/bot/core/markups/request.py
f=services/bot/core/markups/event.py
f=services/bot/core/schemas/request.py
f=services/bot/core/schemas/event.py
f=services/backend/internal/drivers/http/v1/volunteering/events/event_controller.go
f=services/backend/internal/drivers/http/v1/volunteering/volunteering_routes.go
f=services/backend/internal/drivers/http/v1/volunteering/requests/request_contoller.go
f=services/backend/internal/drivers/http/v1/volunteering/requests/request_requests.go
f=services/backend/internal/domain/volunteering/events/create_event_request.go
f=services/backend/internal/domain/volunteering/events/event_service.go
f=services/backend/internal/domain/volunteering/events/event_view.go
f=services/backend/internal/domain/volunteering/registry/create_type.go
f=services/backend/internal/domain/volunteering/registry/registry_service.go
f=services/backend/internal/domain/volunteering/requests/request_service.go
--- services/bot/core/handlers/moderation.py ---
import re
from datetime import datetime

from dialog_bot_sdk.entities.messaging import UpdateInteractiveMediaEvent, UpdateMessage
from dialog_bot_sdk.interactive_media import Button

from core.bot_kit.fsm import FSMContext, State, StatesGroup
from core.bot_kit.router import Router
from core.config import bot
from core.handlers.events_ui import (
    build_back_to_event_editing_keyboard,
    build_back_to_event_keyboard,
    get_ui,
    send_event_card,
    send_event_cards_page,
    send_my_events_page,
    split_event_value,
)
from core.markups import (
    back_to_moderation_keyboard,
    choices_from_backend_tags,
    event_edit_fields_keyboard,
    format_event_details,
    gosb_select_keyboard,
    moderation_menu_keyboard,
    tags_toggle_keyboard,
    tb_select_keyboard,
)
from core.schemas import EventCardSchema, TagSchema, UserSchema
from core.services import EventService, ReportService, TagService, TerbankService
from core.utils import (
    clear_context_keep_events_filters,
    delete_prev_message,
    delete_prev_message_by_peer,
    logger,
)

TAGS_MEDIA_ID = "event_tags_toggle"
TAGS_TOGGLE_PREFIX = "toggle:"
TAGS_DONE_VALUE = "done"
GOSB_FLOW_CREATE = "create"
GOSB_FLOW_EDIT = "edit"
# TODO убрать хардкод


events_rt = Router()


class EventEditTagsState(StatesGroup):  # legacy
    tags = State()


class EventEditState(StatesGroup):  # legacy
    wait_value = State()
    gosb = (
        State()
    )  # TODO унифицировать, сделать чтобы редактирование госба и тегов работало одинаково


class EventEnterCodeState(StatesGroup):  # legacy
    wait_code = State()


class EventViewState(StatesGroup):  # legacy
    wait_event_id = State()


class EventCreateState(StatesGroup):  # legacy
    name = State()
    date = State()
    time = State()
    # location удалён
    gosb = State()
    project = State()
    description = State()
    hours = State()
    tags = State()
    code = State()


@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 = str(event.data.value or "").strip()
    send_event_card(
        event.peer,
        event_id=event_id,
        event_service=event_service,
        user=user,
        ctx_name="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,
    )


@bot.di
def create_event_start_handler(
    event: UpdateInteractiveMediaEvent,
    context: FSMContext,
):
    """старт мастера создания мероприятия"""
    delete_prev_message_by_peer(bot, event.peer)

    context.clear()
    bot.messaging.send_message(
        peer=event.peer,
        text="Создание мероприятия.\n\nШаг 1/10: введи, пожалуйста, название мероприятия:",
        interactive_media_groups=back_to_moderation_keyboard(),
    )
    context.set_state(EventCreateState.name)


@events_rt.message(state=EventCreateState.name)
@bot.di
def event_create_name_step(message: UpdateMessage, context: FSMContext):
    delete_prev_message(bot, message)

    name = message.message.text_message.text.strip()
    context.update_data({"event_name": name})

    bot.messaging.send_message(
        peer=message.peer,
        text="Шаг 2/10: введи, пожалуйста, дату мероприятия например, в формате `20.02.2002`:",
        interactive_media_groups=back_to_moderation_keyboard(),
    )
    context.set_state(EventCreateState.date)


def _validate_date(text):  # throws ValueError
    formats = []
    for first_gap in ". -_":
        for second_gap in ". -_":
            formats.append(f"%d{first_gap}%m{second_gap}%Y")

    date_obj = None
    for format in formats:
        try:
            date_obj = datetime.strptime(text, format).date()
        except ValueError:
            continue

    if date_obj is None:
        raise ValueError

    return date_obj


@events_rt.message(state=EventCreateState.date)
@bot.di
def event_create_date_step(message: UpdateMessage, context: FSMContext):
    delete_prev_message(bot, message)

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

    try:
        date_obj = _validate_date(date)
    except ValueError:
        bot.messaging.send_message(
            peer=message.peer,
            text=(
                "⚠️ Я не смог распознать дату.\n"
                "Пожалуйста, введи дату, например, в формате `20.02.2002`:"
            ),
            interactive_media_groups=back_to_moderation_keyboard(),
        )
        return

    if date_obj <= datetime.now().date():
        bot.messaging.send_message(
            peer=message.peer,
            text=(
                "⚠️ Ты не можешь создать мероприятие раньше завтрашнего дня.\n"
                "Пожалуйста, введи дату, например, в формате `20.02.2002`:"
            ),
            interactive_media_groups=back_to_moderation_keyboard(),
        )
        return

    context.update_data({"event_date": date_obj})

    bot.messaging.send_message(
        peer=message.peer,
        text="Шаг 3/10: введи, пожалуйста, время мероприятия например, в формате `10:30`:",
        interactive_media_groups=back_to_moderation_keyboard(),
    )
    context.set_state(EventCreateState.time)


def _validate_time(text):  # throws ValueError
    time_obj = None
    for format in ["%H:%M", "%H %M", "%H-%M", "%H_%M"]:
        try:
            time_obj = datetime.strptime(text, format).time()
        except ValueError:
            continue

    if time_obj is None:
        raise ValueError

    return time_obj


@events_rt.message(state=EventCreateState.time)
@bot.di
def event_create_time_step(
    message: UpdateMessage, context: FSMContext, terbank_service: TerbankService
):
    delete_prev_message(bot, message)

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

    try:
        time_obj = _validate_time(time)
    except ValueError:
        bot.messaging.send_message(
            peer=message.peer,
            text=(
                "⚠️ Я не смог распознать время.\n"
                "Пожалуйста, введи в формате `HH:MM`, например: `10:30`:"
            ),
            interactive_media_groups=back_to_moderation_keyboard(),
        )
        return

    context.update_data({"event_time": time_obj})

    send_tb_select_menu(
        peer=message.peer,
        terbank_service=terbank_service,
        context=context,
        flow=GOSB_FLOW_CREATE,
    )


def send_tb_select_menu(
    *,
    peer,
    terbank_service: TerbankService,
    context: FSMContext,
    flow: str,
    event_id: str | None = None,
    ctx_name: str = "moderation",
):
    context.update_data(
        {
            "gosb_flow": flow,
            "tb_id": "",
            "gosb_id": "",
            "edit_event_id": event_id or context.get_data().get("edit_event_id"),
            "edit_event_ctx": ctx_name,
        }
    )

    if flow == GOSB_FLOW_CREATE:
        context.set_state(EventCreateState.gosb)
        title = "Шаг 4/10: Выбери Тебранк:"
        footer = back_to_moderation_keyboard()
    elif flow == GOSB_FLOW_EDIT:
        context.set_state(EventEditState.gosb)
        title = "Редактированине ГОСБ: сначала выбери тербанк:"
        footer = build_back_to_event_editing_keyboard(
            event_id=event_id or context.get_data().get("edit_event_id", ""),
            ctx_name=ctx_name,
        )
    else:
        logger.error("UNREACHABLE")
        bot.messaging.send_message(peer=peer, text="Произошла ошибка")
        return

    tbs = terbank_service.get_all_tbs(peer.id)

    bot.messaging.send_message(
        peer=peer,
        text=title,
        interactive_media_groups=tb_select_keyboard(tbs),
    )


@bot.di
def tb_update_selected(event: UpdateInteractiveMediaEvent, context: FSMContext):
    # keeping what user have selected in "tb_id"
    context.update_data({"tb_id": event.data.value})


@bot.di
def tb_approved(
    event: UpdateInteractiveMediaEvent, terbank_service: TerbankService, context: FSMContext
):
    # tb was selected and approved. now selecting gosb:
    delete_prev_message_by_peer(bot, event.peer)

    selected_tb_id = context.get_data().get("tb_id")
    if not isinstance(selected_tb_id, str) or not selected_tb_id:
        logger.error(
            "error while creating event: error while selecting tb: selected tb must be a string, got {selected_tb_id!r}"
        )
        bot.messaging.send_message(
            peer=event.peer,
            text="⚠️ Произошла ошибка. Попробуй позже.",
        )
        context.clear()
        return

    gosbs = terbank_service.get_gosbs_for_tb(tb_id=selected_tb_id, messenger_id=event.peer.id)

    flow = str(context.get_data().get("gosb_flow") or GOSB_FLOW_CREATE)
    text = (
        "Шаг 5/10: Выбери ГОСБ"
        if flow == GOSB_FLOW_CREATE
        else "Редактирвание ГОСБ: выбери новый ГОСБ"
    )

    bot.messaging.send_message(
        peer=event.peer,
        text=text,
        interactive_media_groups=gosb_select_keyboard(gosbs),
    )


@bot.di
def gosb_update_selected(event: UpdateInteractiveMediaEvent, context: FSMContext):
    # keeping what user have selected in "gosb_id"
    context.update_data({"gosb_id": event.data.value})


@bot.di
def gosb_approved(
    event: UpdateInteractiveMediaEvent,
    context: FSMContext,
    event_service: EventService,
):
    # gosb was selected and approved.
    delete_prev_message_by_peer(bot, event.peer)

    selected_gosb_id = context.get_data().get("gosb_id")
    if not isinstance(selected_gosb_id, str) or not selected_gosb_id:
        logger.error(
            "error while creating event: error while selecting gosb: selected gosb must be a string, got {selected_gosb_id!r}"
        )
        bot.messaging.send_message(
            peer=event.peer,
            text="⚠️ Произошла ошибка. Попробуй позже.",
        )
        context.clear()
        return

    data = context.get_data()
    flow = str(data.get("gosb_flow") or GOSB_FLOW_CREATE)

    if flow == GOSB_FLOW_EDIT:
        event_id = str(data.get("edit_event_id") or "")
        ctx_name = str(data.get("edit_event_ctx") or "moderation")

        if not event_id:
            logger.error("error while editing gosb: edit_event_id is empty")
            bot.messaging.send_message(
                peer=event.peer,
                text="⚠️ Сессия редактирования сброшена. Открой мероприятие заново.",
                interactive_media_groups=back_to_moderation_keyboard(),
            )
            context.clear()
            return

        updated = event_service.update_event(
            event_id=event_id,
            messenger_id=event.peer.id,
            payload={"gosb_id": selected_gosb_id},
        )

        context.set_state(None)

        _send_event_edit_menu(
            event.peer,
            event_id=event_id,
            event_service=event_service,
            note="✅ ГОСБ успешно изменён." if updated else "⚠️ Не удалось изменить ГОСБ.",
            event_obj=event_service.get_event_by_id(event_id, event.peer.id, mine=True),
            show_organizer=True,
            ctx_name=ctx_name,
        )
        return

    bot.messaging.send_message(
        peer=event.peer,
        text=(
            "Шаг 6/10: Введи id проекта, если хочешь создать мероприятие в проекте\n"
            "Введи `-`, если хочешь создать событийное мероприятие - мероприятие вне проекта."
        ),
        interactive_media_groups=back_to_moderation_keyboard(),
    )
    context.set_state(EventCreateState.project)


@events_rt.message(state=EventCreateState.gosb)
@bot.di
def event_create_gosb_step(message: UpdateMessage, context: FSMContext):
    delete_prev_message(bot, message)

    gosb = message.message.text_message.text.strip()
    context.update_data({"gosb_id": gosb})

    bot.messaging.send_message(
        peer=message.peer,
        text=(
            "Шаг 6/10: Введи id проекта, если хочешь создать мероприятие в проекте\n"
            "Введи `-`, если хочешь создать событийное мероприятие - мероприятие вне проекта."
        ),
        interactive_media_groups=back_to_moderation_keyboard(),
    )
    context.set_state(EventCreateState.project)


@events_rt.message(state=EventCreateState.project)
@bot.di
def event_create_project_step(message: UpdateMessage, context: FSMContext):
    delete_prev_message(bot, message)

    raw = message.message.text_message.text.strip()
    project_id = "" if raw == "-" else raw
    context.update_data({"project_id": project_id})

    bot.messaging.send_message(
        peer=message.peer,
        text="Шаг 7/10: Введи описание:",
        interactive_media_groups=back_to_moderation_keyboard(),
    )
    context.set_state(EventCreateState.description)


@events_rt.message(state=EventCreateState.description)
@bot.di
def event_create_description_step(message: UpdateMessage, context: FSMContext):
    delete_prev_message(bot, message)

    description = message.message.text_message.text.strip()
    context.update_data({"event_description": description})
    bot.messaging.send_message(
        peer=message.peer,
        text="Шаг 8/10: Введи количество волонтёрских часов, начисляемых за мероприятие:",
        interactive_media_groups=back_to_moderation_keyboard(),
    )
    context.set_state(EventCreateState.hours)


def _validate_hours_amount(text) -> int:  # throws ValueError
    if not isinstance(text, str) and not isinstance(text, str):
        logger.error("expected type str or int for hours amount, got '%s'", type(text))
        raise ValueError
    if not re.fullmatch(r"^[0-9]{1,2}$", text):
        logger.error("expected <= 2 long digit sequence for hours amount, got '%s'", text)
        raise ValueError
    hours = int(text)
    if hours <= 0 or hours >= 19:
        raise ValueError
    return hours


@events_rt.message(state=EventCreateState.hours)
@bot.di
def event_create_hours_step(
    message: UpdateMessage,
    context: FSMContext,
    tag_service: TagService,
):
    delete_prev_message(bot, message)

    text = message.message.text_message.text.strip()
    try:
        hours = _validate_hours_amount(text)
    except ValueError:
        bot.messaging.send_message(
            peer=message.peer,
            text="⚠️ Количество часов должно быть натуральным числом, меньшим 19. Попробуй ещё раз.",
            interactive_media_groups=back_to_moderation_keyboard(),
        )
        return

    context.update_data({"hours": hours})

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

    context.update_data(
        {
            "event_tag_keys": [],
            "all_tags": [{"id": t.id, "title": t.title} for t in all_tags],
        },
    )

    _send_tags_toggle_ui(
        peer=message.peer,
        title="Шаг 9/10: выбери направления, которым соответствует мероприятие, затем нажми «Готово»:",
        choices=choices,
        selected_keys=set(),
        footer_buttons=[Button(media_id="leave", value="moderation", label="⬅️ В меню модерации")],
    )

    context.set_state(EventCreateState.tags)


@bot.di
def event_tags_toggle_handler(
    event: UpdateInteractiveMediaEvent,
    event_service: EventService,
    context: FSMContext,
):
    state = context.get_state()
    is_create = state == EventCreateState.tags or str(state) == str(EventCreateState.tags)
    is_edit = state == EventEditTagsState.tags or str(state) == str(EventEditTagsState.tags)
    if not (is_create or is_edit):
        return

    raw = str(event.data.value or "")
    data = context.get_data()
    ctx_name = str(data.get("edit_event_ctx", "moderation"))

    key_field = "event_tag_keys" if is_create else "edit_tag_keys"
    selected = set(data.get(key_field) or [])

    all_tags = data.get("all_tags", [])
    if not all_tags:
        logger.info("No tags were taken while editing event tags. Please check seeding file.")

    if raw == TAGS_DONE_VALUE:
        title_to_id = {t["title"]: t["id"] for t in all_tags if t.get("title") and t.get("id")}
        app_tag_ids = [title_to_id[n] for n in selected if n in title_to_id]

        delete_prev_message_by_peer(bot, event.peer)

        if is_create:
            context.update_data({"event_app_tag_ids": app_tag_ids})
            bot.messaging.send_message(
                peer=event.peer,
                text="Шаг 10/10: введи код мероприятия:\nБез пробелов, не больше 16 символов. Например: WELCOME2025",
                interactive_media_groups=back_to_moderation_keyboard(),
            )
            context.set_state(EventCreateState.code)
            return

        if is_edit:
            current = set(data.get("edit_current_tag_keys") or [])
            event_id = str(data.get("edit_event_id"))

            if not event_id or event_id == "None":
                logger.error(
                    "Error while editing event tags: "
                    f"no event was found by saved id {event_id!r}, "
                    f"saved in data by the key 'edit_event_id'. "
                    "exiting editing session"
                )

                context.set_state(None)
                bot.messaging.send_message(
                    peer=event.peer,
                    text="⚠️ Сессия редактирования сброшена. Открой мероприятие заново.",
                    interactive_media_groups=back_to_moderation_keyboard(),
                )
                return

            to_add = selected - current
            to_remove = current - selected

            failed = False

            # add
            for title in to_add:
                tag_id = title_to_id.get(title)
                if not tag_id:
                    logger.error(
                        "Error while editing event tags: "
                        f"selected tag with title={title!r} "
                        f"have no entrance in all_tags={all_tags}. "
                        "skipping this tag."
                    )
                elif not event_service.add_tag(
                    event_id=event_id, tag_id=tag_id, messenger_id=event.peer.id
                ):
                    failed = True

            # remove
            for title in to_remove:
                tag_id = title_to_id.get(title)
                if not tag_id:
                    logger.error(
                        "Error while editing event tags: "
                        f"selected tag with title={title!r} "
                        f"have no entrance in all_tags={all_tags}. "
                        "skipping this tag."
                    )
                elif not event_service.remove_tag(
                    event_id=event_id, tag_id=tag_id, messenger_id=event.peer.id
                ):
                    failed = True

            context.set_state(None)

            _send_event_edit_menu(
                event.peer,
                event_id=event_id,
                event_service=event_service,
                note="⚠️ Ошибка изменения тегов! Некоторые теги могли не измениться."
                if failed
                else "✅ Теги успешно изменены.",
                event_obj=event_service.get_event_by_id(event_id, event.peer.id, mine=True),
                show_organizer=True,
                ctx_name=ctx_name,
            )

            return

    if raw.startswith(TAGS_TOGGLE_PREFIX):
        key = raw[len(TAGS_TOGGLE_PREFIX) :].strip()
        if key in selected:
            selected.remove(key)
        else:
            selected.add(key)

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

        if is_create:
            footer = [Button(media_id="leave", value="moderation", label="⬅️ В меню модерации")]
            title = "Шаг 9/10: выбери направления, которым соответствует мероприятие, затем нажми «Готово»:"
        else:
            event_id = data.get("edit_event_id") or ""
            footer = [
                Button(
                    media_id="event_menu_edit",
                    value=f"{event_id}|{ctx_name}",
                    label="⬅️ К редактированию мероприятия",
                )
            ]
            title = "Редактирование направления: выбери направления, которым соответствует мероприятие, затем нажми «Готово»:"

        normalized_tags: list[TagSchema] = []

        for tag in all_tags:
            if isinstance(tag, TagSchema):
                normalized_tags.append(tag)
            elif isinstance(tag, dict):
                normalized_tags.append(TagSchema.from_dict(tag))

        choices = choices_from_backend_tags(normalized_tags)

        _send_tags_toggle_ui(
            peer=event.peer,
            title=title,
            choices=choices,
            selected_keys=set(selected),
            footer_buttons=footer,
        )
        return


def _validate_verification_code(text) -> str:  # throws ValueError
    if not isinstance(text, str):
        logger.error("expected type str for verification code, got '%s'", type(text))
        raise ValueError
    if not re.fullmatch(r"^[A-Za-zА-Яа-я0-9]{1,16}$", text):
        logger.error("invalid verification code: '%s'", text)
        raise ValueError
    return text


@events_rt.message(state=EventCreateState.code)
@bot.di
def event_create_code_step(
    message: UpdateMessage,
    context: FSMContext,
    event_service: EventService,
):
    delete_prev_message(bot, message)

    try:
        code = _validate_verification_code(message.message.text_message.text.strip())
    except ValueError:
        bot.messaging.send_message(
            peer=message.peer,
            text=(
                "⚠️ Код должен быть комбинацией русских и латинских букв и цифр, "
                "длиной не больше 16 символов. Попробуй еще раз:"
            ),
            interactive_media_groups=back_to_moderation_keyboard(),
        )
        return

    context.update_data({"event_verification_code": code})
    data = context.get_data()
    try:
        created = event_service.create_from_wizard(data, message.peer)
    except Exception as e:
        logger.error("Error while creating an event via wizard: %s", e)
        bot.messaging.send_message(
            peer=message.peer,
            text="⚠️ Произошла ошибка.",
            interactive_media_groups=back_to_moderation_keyboard(),
        )
        context.clear()
        return

    context.clear()

    note = {
        "REQUEST": "✅ Заявка на мероприятие успешно создана.",
        "COMPLETELY": "✅ Мероприятие успешно создано.",
        "FAILED": "⚠️ Произошла ошибка: не удалось создать заявку/мероприятие.",
    }.get(
        created.get("TYPE", ""),
        "⚠️ Произошла ошибка: не получить статус заявки/мероприятия. Проверь результат в 'Своих заявках' или в 'Своих мероприятиях'.",
    )

    formatted_data = (
        str(data).strip("{}").replace("), ", "),\n").replace("], ", "],\n").replace("', ", "',\n")
    )
    bot.messaging.send_message(
        peer=message.peer,
        text=f"{note}\n{formatted_data}",
        interactive_media_groups=moderation_menu_keyboard(),
    )


@bot.di
def event_view_by_id_start(event: UpdateInteractiveMediaEvent, context: FSMContext):  # legacy
    """старт диалога - просим у модератора UUID мероприятия"""
    delete_prev_message_by_peer(bot, event.peer)

    bot.messaging.send_message(
        peer=event.peer,
        text="Введи UUID мероприятия, которое хочешь посмотреть.\n\n",
        interactive_media_groups=back_to_moderation_keyboard(),
    )
    context.set_state(EventViewState.wait_event_id)


@events_rt.message(state=EventViewState.wait_event_id)
@bot.di
def event_view_by_id_step(  # legacy
    message: UpdateMessage,
    context: FSMContext,
    event_service: EventService,
    user: UserSchema,
):
    """читаем UUID, тянем мероприятие из бэкенда и показываем информацию"""
    delete_prev_message(bot, message)
    raw_id = message.message.text_message.text.strip()
    event = event_service.get_event_by_id(raw_id, message.peer.id)

    if event is None:
        bot.messaging.send_message(
            peer=message.peer,
            text="⚠️ Мероприятие с таким UUID не найдено.\nПопробуй ещё раз. Пример UUID: `932cf1cb-1ee7-4a45-bc96-41a232f3f538`",
            interactive_media_groups=back_to_moderation_keyboard(),
        )
        return

    context.update_data({"current_event_id": event.id})
    context.set_state(None)

    send_event_card(
        message.peer,
        event_id=event.id,
        event_service=event_service,
        user=user,
        ctx_name="moderation_menu",
    )


@bot.di
def event_menu_sign_up_handler(
    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"
    ok = event_service.sign_up(event_id, event.peer.id)
    if not ok:
        note = "⚠️ Не удалось записаться на мероприятие."
    else:
        note = "✅ Запись на мероприятие прошла успешно!"

    send_event_card(
        event.peer,
        event_id=event_id,
        event_service=event_service,
        report_service=report_service,
        user=user,
        ctx_name=ctx_name,
        note=note,
    )


@bot.di
def event_menu_sign_out_handler(
    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"
    # TODO may be a source of bugs. do we really need to deafult context?
    status = event_service.get_participation_status(event_id, event.peer.id)

    if status is None:
        note = "⚠️ Не удалось отписаться: ты не записан на это мероприятие."
    elif status.state == "confirmed":
        note = "⚠️ Нельзя отписаться: часы уже получен за это участие."
    else:
        ok = event_service.sign_out_by_event(event_id, event.peer.id)
        if ok:
            note = "✅ Ты успешно отписался от мероприятия."
        else:
            note = "⚠️ Не удалось отписаться. Попробуй ещё раз."

    send_event_card(
        event.peer,
        event_id=event_id,
        event_service=event_service,
        report_service=report_service,
        user=user,
        ctx_name=ctx_name,
        note=note,
    )


@bot.di
def event_menu_enter_code_start_handler(
    event: UpdateInteractiveMediaEvent, context: FSMContext
):  # legacy
    delete_prev_message_by_peer(bot, event.peer)
    event_id, ctx = split_event_value(event.data.value)
    ctx_name = ctx or "moderation"
    context.update_data({"current_event_id": event_id, "current_event_ctx": ctx_name})
    context.set_state(EventEnterCodeState.wait_code)

    bot.messaging.send_message(
        peer=event.peer,
        text="🎟️ Введи бонус-код мероприятия (без пробелов):",
        interactive_media_groups=build_back_to_event_keyboard(event_id, ctx_name),
    )


@events_rt.message(state=EventEnterCodeState.wait_code)
@bot.di
def event_menu_enter_code_step(
    message: UpdateMessage,
    context: FSMContext,
    event_service: EventService,
    report_service: ReportService,
    user: UserSchema,
):
    delete_prev_message(bot, message)

    event_id = str(context.get_data().get("current_event_id"))
    # если event_id is None, то клиент не найдёт event с id == "None", и всё ок

    code = message.message.text_message.text.strip()
    note: str
    status = event_service.get_participation_status(event_id, message.peer.id)
    if status is None:
        note = "⚠️ Код не принят, сначала нужно записаться на мероприятие"
    elif status.state == "confirmed":
        note = "⚠️ Код не принят, баллы уже получены"
    else:
        ok = event_service.enter_code_by_event(event_id, message.peer.id, code)
        if not ok:
            note = "⚠️ Код не принят, проверь правильность кода"
        else:
            note = "✅ Код принят. Часы начислены."

    data = context.get_data()
    ctx_name = data.get("current_event_ctx") or "moderation"
    context.set_state(None)

    send_event_card(
        message.peer,
        event_id=event_id,
        event_service=event_service,
        report_service=report_service,
        user=user,
        ctx_name=ctx_name,
        note=note,
    )


@bot.di
def event_menu_delete_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"
    if ctx_name not in ("moderation", "moderation_menu"):
        ctx_name = "moderation"

    try:
        ok = event_service.delete_event(
            event_id,
            requester_messenger_id=event.peer.id,
        )
    except Exception:
        ok = False

    if ok:
        context.clear()
        if ctx_name == "moderation":
            send_event_cards_page(
                event.peer,
                offset=0,
                event_service=event_service,
                ctx_name="moderation",
                note="Мероприятие удалено.",
            )
        else:
            bot.messaging.send_message(
                peer=event.peer,
                text="Мероприятие удалено.\n\nТы в доме модерации",
                interactive_media_groups=moderation_menu_keyboard(),
            )
        return

    send_event_card(
        event.peer,
        event_id=event_id,
        event_service=event_service,
        report_service=report_service,
        user=user,
        ctx_name=ctx_name,
        note="⚠️ Не удалось удалить мероприятие",
    )


def _send_event_edit_menu(
    peer,
    *,
    event_id: str,
    event_service: EventService,
    note: str | None = None,
    event_obj=None,  # temporary remove type since it was fixed in another branch
    show_organizer: bool,
    ctx_name: str,
):
    ui = get_ui(ctx_name)
    refreshed = event_obj or event_service.get_event_by_id(event_id, peer.id, mine=True)
    base = (
        format_event_details(refreshed, show_code=True, show_organizer=show_organizer)
        if refreshed
        else "⚠️ Мероприятие не найдено"
    )

    prefix = f"{note}\n\n" if note else ""
    bot.messaging.send_message(
        peer=peer,
        text=(f"{prefix}Редактирование мероприятия\n\n{base}\n\nВыбери поле для Редактирования:"),
        interactive_media_groups=event_edit_fields_keyboard(
            event_id,
            leave_media_id=ui.list_open_media_id,
            leave_value=f"{event_id}|{ui.name}",
            leave_label="⬅️ К мероприятию",
        ),
    )


@bot.di
def event_menu_edit_handler(
    event: UpdateInteractiveMediaEvent,
    context: FSMContext,
    event_service: EventService,
):
    delete_prev_message_by_peer(bot, event.peer)
    context.set_state(None)
    event_id, ctx_name = split_event_value(event.data.value)
    ctx_name = "moderation" if ctx_name is None else ctx_name
    context.update_data(
        {
            "edit_event_id": event_id,
            "edit_event_ctx": ctx_name,
        }
    )

    _send_event_edit_menu(
        event.peer,
        event_id=event_id,
        event_service=event_service,
        show_organizer=True,
        ctx_name=ctx_name,
    )


def _get_field_label(field: str) -> str:
    return {
        "title": "Название",
        "date": "Дата",
        "time": "Время",
        "tags": "Теги",
        "gosb": "ГОСБ",
        "event_organizers": "Организаторы",
        "hours": "Часы",
        "description": "Описание",
        "code": "Код",
    }.get(field, field)


def _get_current_value(event_card: EventCardSchema, field: str) -> str:
    event_fields = {
        # valid fields
        "title",
        "description",
        "gosb_id",
        "terbank_id",
        "hours",
        #
        # TODO rename properly
        "verification_code",  # from code
        "event_organizers",  # from creator_id
    }

    match field:
        case "date":
            val = event_card.event.date_time.strftime("%d.%m.%Y")
        case "time":
            val = event_card.event.date_time.strftime("%H:%M")
        case "code":  # once 'code' is renamed, this will be deleted
            val = event_card.event.verification_code
        case "creator_id":  # once 'creator_id' is renamed, this will be deleted
            val = event_card.event.creator_id
        case "terbank" | "terbank_id":  # TODO убрать лишние проверки
            val = event_card.terbank.name
        case "gosb" | "gosb_id":  # TODO убрать лишние проверки
            val = event_card.gosb.name
        case _:
            val = (
                getattr(event_card.event, field, None)
                if field in event_fields
                else getattr(event_card, field, None)
            )

    return (
        ("\n" if len(str(val)) > 140 else "")
        + "_"
        + ("-" if val is None or val == "" else str(val)).replace("\n", "_\n_")
        + "_"
    )


def _send_tags_toggle_ui(
    *,
    peer,
    title: str,
    choices,
    selected_keys: set[str],
    footer_buttons: list[Button],
):
    bot.messaging.send_message(
        peer=peer,
        text=title,
        interactive_media_groups=tags_toggle_keyboard(
            choices=choices,
            selected_keys=selected_keys,
            media_id=TAGS_MEDIA_ID,
            toggle_value_prefix=TAGS_TOGGLE_PREFIX,
            done_value=TAGS_DONE_VALUE,
            done_label="✅ Готово",
            extra_footer=footer_buttons,
        ),
    )


@bot.di
def event_menu_edit_field_handler(
    event: UpdateInteractiveMediaEvent,
    context: FSMContext,
    event_service: EventService,
    tag_service: TagService,
    terbank_service: TerbankService,
):
    delete_prev_message_by_peer(bot, event.peer)
    raw = str(event.data.value or "")

    if "|" not in raw:
        logger.error("Got unexpected event.data.value={raw!r} while trying to edit event")
        bot.messaging.send_message(peer=event.peer, text="⚠️ Некорректная команда редактирования.")
        return

    event_id, field = raw.split("|", 1)
    field = field.strip()
    data = context.get_data()
    ctx_name = str(data.get("edit_event_ctx", "moderation"))

    edited_event_card = event_service.get_event_by_id(event_id, event.peer.id, mine=True)
    if not edited_event_card:
        bot.messaging.send_message(peer=event.peer, text="⚠️ Мероприятие не найдено.")
        return

    if field == "tags":
        # редактируем теги через toggle UI
        all_tags = tag_service.get_all_tags()
        choices = choices_from_backend_tags(all_tags)

        selected_titles: set[str] = set()
        for t in edited_event_card.tags or []:
            if isinstance(t, dict) and t.get("title"):
                selected_titles.add(str(t["title"]))
            else:
                title = getattr(t, "title", None)
                if title:
                    selected_titles.add(str(title))

        context.update_data(
            {
                "edit_event_id": event_id,
                "all_tags": [{"id": t.id, "title": t.title} for t in all_tags],
                "edit_tag_keys": list(selected_titles),
                "edit_current_tag_keys": list(selected_titles),
            }
        )
        context.set_state(EventEditTagsState.tags)

        _send_tags_toggle_ui(
            peer=event.peer,
            title="Редактирование направления: выбери направления, которым соответствует мероприятие, затем нажми «Готово»:",
            choices=choices,
            selected_keys=set(selected_titles),
            footer_buttons=[
                Button(
                    media_id="event_menu_edit",
                    value=f"{event_id}|{ctx_name}",
                    label="⬅️ К редактированию мероприятия",
                )
            ],
        )
        return

    if field in ("gosb", "gosb_id"):  # TODO сделать однозначно
        # редактируем госб через UI
        # вызываем клавиатуру выбора тербанка, дальше она сама вызовет
        # выбор ГОСБ и определит, редактируем мы или создаем мероприятие

        context.update_data(
            {
                "edit_event_id": event_id,
                "edit_event_ctx": ctx_name,
                "edit_field": "gosb_id",
            }
        )

        send_tb_select_menu(
            peer=event.peer,
            terbank_service=terbank_service,
            context=context,
            flow=GOSB_FLOW_EDIT,
            event_id=event_id,
            ctx_name=ctx_name,
        )
        return

    context.update_data({"edit_event_id": event_id, "edit_field": field})
    context.set_state(EventEditState.wait_value)

    field_label = _get_field_label(field)
    field_current_value = _get_current_value(edited_event_card, field)

    if field == "event_organizers":
        bot.messaging.send_message(
            peer=event.peer,
            text="Пока не реализовано",
            interactive_media_groups=build_back_to_event_editing_keyboard(
                event_id=event_id,
                ctx_name=ctx_name,
            ),
        )
        return
    if field == "date":
        hint = "*Введи дату в формате* `dd.mm.yyyy`, например: `20.02.2002`"
    elif field == "time":
        hint = "*Введи время в формате* `HH:MM`, например: `10:30`"
    else:
        hint = '*Введи новое значение.* Чтобы очистить поле - отправь: "-"'

    bot.messaging.send_message(
        peer=event.peer,
        text=f" ✍️ *Редактирование*: _{field_label}_\n\n*Текущее значение*: {field_current_value}\n\n{hint}",
        interactive_media_groups=build_back_to_event_editing_keyboard(
            event_id=event_id,
            ctx_name=ctx_name,
        ),
    )


@events_rt.message(state=EventEditState.wait_value)
@bot.di
def event_edit_value_step(
    message: UpdateMessage,
    context: FSMContext,
    event_service: EventService,
):
    delete_prev_message(bot, message)

    data = context.get_data()
    event_id: str = str(data.get("edit_event_id", ""))
    field: str = str(data.get("edit_field", ""))
    ctx_name = str(data.get("edit_event_ctx", "moderation"))

    if not event_id or not field:
        logger.error("expected values for event_id and field, got '%s' and '%s'", event_id, field)
        bot.messaging.send_message(
            peer=message.peer,
            text="⚠️ Сессия редактирования сброшена. Открой мероприятие заново.",
        )
        context.set_state(None)
        return

    raw = message.message.text_message.text.strip()
    value: str | int = "" if raw == "-" else raw

    if field == "event_organizers":
        bot.messaging.send_message(
            peer=message.peer,
            text="Пока не реализовано.",
            interactive_media_groups=build_back_to_event_editing_keyboard(
                event_id=event_id,
                ctx_name=ctx_name,
            ),
        )
        context.set_state(None)
        return

    if field == "hours":
        try:
            value = _validate_hours_amount(value)
        except ValueError:
            bot.messaging.send_message(
                peer=message.peer,
                text="⚠️ Количество часов должно быть натуральным числом, меньшим 19. Попробуй ещё раз.",
                interactive_media_groups=build_back_to_event_editing_keyboard(
                    event_id=event_id,
                    ctx_name=ctx_name,
                ),
            )
            return

    if field == "code":
        try:
            value = _validate_verification_code(value)
        except ValueError:
            bot.messaging.send_message(
                peer=message.peer,
                text=(
                    "⚠️ Код должен быть комбинацией русских и латинских букв и цифр, "
                    "длиной не больше 16 символов. Попробуй еще раз:"
                ),
                interactive_media_groups=build_back_to_event_editing_keyboard(
                    event_id=event_id,
                    ctx_name=ctx_name,
                ),
            )
            return

    if field == "date":
        try:
            date_obj = _validate_date(str(value))
        except ValueError:
            bot.messaging.send_message(
                peer=message.peer,
                text=(
                    "⚠️ Я не смог распознать дату.\n"
                    "Пожалуйста, введи дату в формате `dd.mm.yyyy`, например: `20.02.2002`:"
                ),
                interactive_media_groups=build_back_to_event_editing_keyboard(
                    event_id=event_id,
                    ctx_name=ctx_name,
                ),
            )
            return

        current_event_card = event_service.get_event_by_id(event_id, message.peer.id)
        if current_event_card is None:
            logger.error(
                "expected event card while editing date, got None for event_id=%s",
                event_id,
            )
            bot.messaging.send_message(
                peer=message.peer,
                text="⚠️ Мероприятие не найдено. Открой мероприятие заново.",
            )
            context.set_state(None)
            return

        current_dt = current_event_card.event.date_time
        new_dt = current_dt.replace(
            year=date_obj.year,
            month=date_obj.month,
            day=date_obj.day,
        )

        field = "date_time"
        value = new_dt.strftime("%Y-%m-%dT%H:%M:%SZ")

    if field == "time":
        try:
            time_obj = _validate_time(str(value))
        except ValueError:
            bot.messaging.send_message(
                peer=message.peer,
                text=(
                    "⚠️ Я не смог распознать время.\n"
                    "Пожалуйста, введи в формате `HH:MM`, например: `10:30`:"
                ),
                interactive_media_groups=build_back_to_event_editing_keyboard(
                    event_id=event_id,
                    ctx_name=ctx_name,
                ),
            )
            return

        current_event_card = event_service.get_event_by_id(event_id, message.peer.id)
        if current_event_card is None:
            logger.error(
                "expected event card while editing time, got None for event_id=%s",
                event_id,
            )
            bot.messaging.send_message(
                peer=message.peer,
                text="⚠️ Мероприятие не найдено. Открой мероприятие заново.",
            )
            context.set_state(None)
            return

        current_dt = current_event_card.event.date_time
        new_dt = current_dt.replace(
            hour=time_obj.hour,
            minute=time_obj.minute,
            second=0,
            microsecond=0,
        )

        field = "date_time"
        value = new_dt.strftime("%Y-%m-%dT%H:%M:%SZ")

    payload = {field: value}

    updated = event_service.update_event(
        event_id=event_id,
        messenger_id=message.peer.id,
        payload=payload,  # type(value) in (int, str)
    )
    context.set_state(None)

    _send_event_edit_menu(
        message.peer,
        event_id=event_id,
        event_service=event_service,
        note="⚠️ Не удалось обновить поле." if not updated else "✅ Поле обновлено.",
        event_obj=event_service.get_event_by_id(event_id, message.peer.id),
        show_organizer=True,
        ctx_name=ctx_name,
    )

--- 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, EventSchema, 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

ADMIN_CAN_MANAGE_OTHERS = True  # имеет ли администратор доступ ко всем мероприятиям?


@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(
        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(
        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(
        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(
        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_menu_sign_up",
        sign_out_media_id="event_menu_sign_out",
    ),
    # меню баллов -> мероприятия с начисленными баллами
    "points": EventUIContext(
        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(
        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_menu_sign_up",
        sign_out_media_id="event_menu_sign_out",
    ),
    # дом модерации -> мои мероприятия
    "my_events": EventUIContext(
        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_menu_sign_up",
        sign_out_media_id="event_menu_sign_out",
    ),
    # ответ нейронки -> карточка мероприятия
    "ai_dialog_event": EventUIContext(
        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="leave", value=ui.back_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 can_manage_event(event: EventSchema, user: UserSchema | None) -> bool:
    if user is not None and user.admin and ADMIN_CAN_MANAGE_OTHERS:
        logger.debug("[events_ui] can_manage_event admin -> True")
        return True
    uid = user.messenger_id if user is not None else None
    if not uid:
        logger.debug("[events_ui] can_manage_event no uid -> False")
        return False
    result = bool(event.creator_id) and (str(event.creator_id) == str(uid))
    logger.debug(
        "[events_ui] can_manage_event user=%s event=%s result=%s",
        user.__repr__(),
        event.__repr__(),
        result,
    )
    return result


def can_view_code(event: EventSchema, user: UserSchema | None) -> bool:
    # кто может управлять - тот видит код
    return can_manage_event(event, user)


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. Убрать проверку права редактирования в сервис
    # TODO: 2. Бекенд будет возвращать список организаторов события
    # TODO: 3. Редактировать может не creator_id, а волонтер, входящий в список организаторов
    can_manage = can_manage_event(card.event, user)
    if can_manage:
        managed_ev = event_service.get_event_by_id(event_id, peer.id, mine=True)
        if managed_ev is not None:
            card = managed_ev

    is_participant = card.participation is not None

    # TODO: Код не приходит, если его нет, эта логика на бекенде, подумать, как упростить это здесь (Optional?)
    show_code = can_view_code(card.event, user)

    can_access_reports = can_manage

    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

    can_create_report = bool(can_manage 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_manage=can_manage,
            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,
        ),
    )


# 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/request.py ---
from __future__ import annotations

from dataclasses import dataclass

from dialog_bot_sdk.entities.messaging import (
    InteractiveMediaGroup,
    UpdateInteractiveMediaEvent,
    UpdateMessage,
)
from dialog_bot_sdk.interactive_media import Button, MediaGroupBuilder

from core.bot_kit.fsm import FSMContext, State, StatesGroup
from core.bot_kit.router import Router
from core.config import bot
from core.markups import (
    format_request_details,
    requests_pagination_keyboard,
    requests_select_keyboard,
)
from core.schemas import RequestCardsPageSchema
from core.services import RequestService
from core.utils import delete_prev_message, delete_prev_message_by_peer

requests_rt = Router()


class RequestRejectState(StatesGroup):
    wait_reason = State()


@dataclass(frozen=True)
class RequestUIContext:
    name: str
    back_value: str
    back_label: str


_UI: dict[str, RequestUIContext] = {
    # модерация -> заявки
    "requests": RequestUIContext(
        name="requests",
        back_value="requests",
        back_label="⬅️ Назад",
    ),
    "my_requests": RequestUIContext(
        name="my_requests",
        back_value="my_requests",
        back_label="⬅️ Назад",
    ),
}


def get_ui(ctx_name: str) -> RequestUIContext:
    return _UI.get(ctx_name, _UI["requests"])


def build_context_back(*, label: str = "❌ Отмена", value: str = "", ctx: str = ""):
    ui = get_ui(ctx)  # noqa: F841


def build_back_keyboard(
    *, value: str = "", label: str = "❌ Отмена", media_id: str = "request_open"
) -> list[InteractiveMediaGroup]:
    return MediaGroupBuilder(
        [Button(media_id=media_id, value=value, label=label)],
    ).build()  # type: ignore


def _send_requests_list_screen(
    peer,
    *,
    ctx_name: str,
    offset: int,
    limit: int,
    page_data: RequestCardsPageSchema,
    header: str | None = None,
    empty_text: str | None = None,
    filter_summary: str | None = None,
    note: str | None = None,
):
    """Отправляет список заявок"""

    request_cards = page_data.request_cards
    has_prev = page_data.has_prev
    has_next = page_data.has_next

    header_part = header if header is not None else "📋 Заявки\n\n"
    header_part = f"{note}\n\n{header_part}" if note else header_part
    filter_part = f"\n\n{filter_summary}" if filter_summary else ""

    if not request_cards:
        text = (
            header_part
            + (
                empty_text
                or "⚠️ Заявок не найдено.\n\nПопробуй изменить критерии или вернись назад."
            )
            + filter_part
        )
        im: list[InteractiveMediaGroup] = []
        im += requests_pagination_keyboard(
            offset=offset,
            limit=limit,
            has_prev=has_prev,
            has_next=has_next,
            leave_value=ctx_name,
            leave_label="⬅️ Назад",
        )
        bot.messaging.send_message(peer=peer, text=text, interactive_media_groups=im)
        return

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

    im: list[InteractiveMediaGroup] = []
    im += requests_select_keyboard(
        request_cards,
        open_media_id="request_open",
        start_index=offset + 1,
        per_row=1,
    )

    im += requests_pagination_keyboard(
        offset=offset,
        limit=limit,
        has_prev=has_prev,
        has_next=has_next,
        leave_value=ctx_name,
        leave_label="🛡️ В меню модерации",
    )

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


def send_request_cards_page(
    peer,
    *,
    offset: int,
    request_service: RequestService,
    ctx_name: str,
    note: str | None = None,
    context: FSMContext | None = None,
):
    """Отправляет страницу с заявками"""
    limit = 10
    page = offset // limit + 1

    page_data = request_service.get_request_cards_page(
        requester_messenger_id=peer.id,
        page=page,
        limit=limit,
    )

    _send_requests_list_screen(
        peer,
        ctx_name=ctx_name,
        offset=offset,
        limit=limit,
        page_data=page_data,
        note=note,
    )


@bot.di
def all_requests_handler(
    event: UpdateInteractiveMediaEvent,
    context: FSMContext,
    request_service: RequestService,
):
    """Обработчик для просмотра всех заявок"""
    delete_prev_message_by_peer(bot, event.peer)

    send_request_cards_page(
        event.peer,
        offset=0,
        request_service=request_service,
        ctx_name="requests",
        context=context,
    )


@bot.di
def requests_page_handler(
    event: UpdateInteractiveMediaEvent,
    context: FSMContext,
    request_service: RequestService,
):
    """Обработчик пагинации заявок"""
    delete_prev_message_by_peer(bot, event.peer)

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

    send_request_cards_page(
        event.peer,
        offset=offset,
        request_service=request_service,
        ctx_name="requests",
        context=context,
    )


@bot.di
def request_open_handler(
    event: UpdateInteractiveMediaEvent,
    context: FSMContext,
    request_service: RequestService,
):
    """Обработчик открытия конкретной заявки"""
    delete_prev_message_by_peer(bot, event.peer)
    context.set_state(None)

    request_id = str(event.data.value or "").strip()

    # Получаем детали заявки
    request_card = request_service.get_request_by_id(request_id, event.peer.id)

    if request_card is None:
        bot.messaging.send_message(
            peer=event.peer,
            text="⚠️ Заявка не найдена.",
            interactive_media_groups=build_back_keyboard(media_id="requests"),
        )
        return

    # Формируем детальное сообщение
    text = format_request_details(request_card)

    # Клавиатура для действий с заявкой
    keyboard = _build_request_actions_keyboard(request_card, ctx_name="requests")

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


def _build_request_actions_keyboard(request_card, ctx_name: str):
    """Строит клавиатуру для действий с заявкой"""

    actions = []
    req = request_card.request

    actions.append(
        Button(
            media_id="leave",
            value=ctx_name,
            label="⬅️ Назад",
        )
    )

    if req.status.lower() == "pending":
        actions.append(
            Button(
                media_id="request_approve",
                value=req.id,
                label="✅ Одобрить",
            )
        )
        actions.append(
            Button(
                media_id="request_reject",
                value=req.id,
                label="❌ Отклонить",
            )
        )

    return MediaGroupBuilder(actions).build()


@bot.di
def requests_prev_page_handler(
    event: UpdateInteractiveMediaEvent,
    context: FSMContext,
    request_service: RequestService,
):
    """Обработчик предыдущей страницы заявок"""
    delete_prev_message_by_peer(bot, event.peer)

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

    send_request_cards_page(
        event.peer,
        offset=offset,
        request_service=request_service,
        ctx_name="moderation",
        context=context,
    )


@bot.di
def requests_next_page_handler(
    event: UpdateInteractiveMediaEvent,
    context: FSMContext,
    request_service: RequestService,
):
    """Обработчик следующей страницы заявок"""
    delete_prev_message_by_peer(bot, event.peer)

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

    send_request_cards_page(
        event.peer,
        offset=offset,
        request_service=request_service,
        ctx_name="moderation",
        context=context,
    )


@bot.di
def request_approve_handler(
    event: UpdateInteractiveMediaEvent,
    context: FSMContext,
    request_service: RequestService,
):
    """Обработчик одобрения заявки"""
    delete_prev_message_by_peer(bot, event.peer)

    request_id = str(event.data.value or "").strip()

    success = request_service.approve_request(request_id, event.peer.id)

    if success:
        note = "✅ Заявка успешно одобрена!"
    else:
        note = "⚠️ Не удалось одобрить заявку. Попробуй позже."

    send_request_cards_page(
        event.peer,
        offset=0,
        request_service=request_service,
        ctx_name="moderation",
        context=context,
        note=note,
    )


@bot.di
def request_reject_handler(
    event: UpdateInteractiveMediaEvent,
    context: FSMContext,
    request_service: RequestService,
):
    """Обработчик отклонения заявки - запрашивает причину"""
    delete_prev_message_by_peer(bot, event.peer)

    request_id = str(event.data.value or "").strip()

    context.update_data({"reject_request_id": request_id})
    context.set_state(RequestRejectState.wait_reason)

    bot.messaging.send_message(
        peer=event.peer,
        text="💬 Укажите причину отклонения заявки:",
        interactive_media_groups=build_back_keyboard(value=request_id, media_id="request_open"),
    )


@requests_rt.message(state=RequestRejectState.wait_reason)
@bot.di
def request_reject_reason_handler(
    message: UpdateMessage,
    context: FSMContext,
    request_service: RequestService,
):
    """Обработчик получения причины отклонения заявки"""
    delete_prev_message(bot, message)

    data = context.get_data()
    request_id = data.get("reject_request_id")
    reason = message.message.text_message.text.strip()

    note = ""
    if not reason:
        note = "⚠️ Причина не может быть пустой."
    else:
        success = request_service.reject_request(request_id, message.peer.id, reason)
        note = "ℹ️ Заявка успешно отклонена." if success else "⚠️ Не удалось отклонить заявку."

    context.set_state(None)
    context.update_data({"reject_request_id": None})

    send_request_cards_page(
        message.peer,
        offset=0,
        request_service=request_service,
        ctx_name="moderation",
        note=note,
    )

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

from requests import HTTPError

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,
            )
        )

        match resp.status_code:
            case 200:
                pass
            case 400:
                raise HTTPError("Error while getting event cards list: invalid parameters")
            case 401:
                raise HTTPError("Error while getting event cards list: unauthorized")
            case 500:
                raise HTTPError("Error while getting event cards list: internal server error")
            case _:
                raise HTTPError(
                    f"Error while getting event cards list: unexpected status {resp.status_code}"
                )

        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) -> bool:  # legacy
        """messenger_id = peer.id (как на бэкенде: user.get_by_messenger_id)"""
        resp = self.client.sign_up(event_id=event_id, messenger_id=messenger_id)
        return resp.status_code == 200

    def get_event_by_id(
        self,
        event_id: str,
        requester_messenger_id: int,
        *,
        mine: bool = False,
    ) -> EventCardSchema | None:
        logger.debug(
            "[EventService] get_event_by_id: event_id=%s requester=%s mine=%s",
            event_id,
            requester_messenger_id,
            mine,
        )
        resp = (
            self.client.get_my_event_info(
                event_id=event_id,
                requester_messenger_id=requester_messenger_id,
            )
            if mine
            else self.client.get_event_info(
                event_id=event_id,
                requester_messenger_id=requester_messenger_id,
            )
        )

        match resp.status_code:
            case 200:
                pass
            case 400:
                raise HTTPError("Error while getting event card: invalid parameters")
            case 401:
                raise HTTPError("Error while getting event card: unauthorized")
            case 404:
                return None
            case 500:
                raise HTTPError("Error while getting event card: internal server error")
            case _:
                raise HTTPError(
                    f"Error while getting event card: unexpected status {resp.status_code}"
                )

        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,
        )

        match resp.status_code:
            case 200:
                pass
            case 400:
                return None
            case 401:
                raise HTTPError("Error while confirming participation: unauthorized")
            case 404:
                return None
            case 500:
                raise HTTPError("Error while confirming participation: internal server error")
            case _:
                raise HTTPError(
                    f"Error while confirming participation: unexpected status {resp.status_code}"
                )

        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", "")

        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,
            requester_messenger_id=peer.id,
            tags=tags if tags else None,
        )

        match resp.status_code:
            case 200:
                pass
            case 401:
                raise HTTPError("Error while creating event: unauthorized")
            case 500:
                raise HTTPError("Error while creating event: internal server error")
            case _:
                raise HTTPError(f"Error while creating event: unexpected status {resp.status_code}")

        return {"TYPE": resp.json().get("code")}  # показывать карточку мероприятия

    def update_event(self, event_id: str, messenger_id: int, payload: dict[str, int | str]):
        resp = self.client.update_event(
            event_id=event_id,
            messenger_id=messenger_id,
            payload=payload,
        )

        match resp.status_code:
            case 200:
                return True
            case 401:
                raise HTTPError("Error while patching event: unauthorized")
            case 403:
                raise HTTPError("Error while patching event: forbidden")
            case 404:
                raise HTTPError("Error while patching event: event not found")
            case 500:
                raise HTTPError("Error while patching event: internal server error")
            case _:
                raise HTTPError(f"Error while patching event: unexpected status {resp.status_code}")

--- services/bot/core/services/requests.py ---
from core.clients import RequestClient
from core.schemas import RequestCardSchema, RequestCardsPageSchema
from core.services.base import BaseService
from core.services.registry import BaseServiceRegistry
from core.utils import logger


class RequestService(BaseService):
    """Сервис для работы с заявками"""

    def __init__(self, registry: BaseServiceRegistry, client: RequestClient):
        super().__init__(registry)
        self.client = client

    def get_request_cards_page(
        self,
        *,
        requester_messenger_id: int,
        page: int = 1,
        limit: int = 10,
        status: str | None = None,
    ) -> RequestCardsPageSchema:
        """Получает страницу с заявками"""
        resp = self.client.get_request_cards(
            requester_messenger_id=requester_messenger_id,
            page=page,
            limit=limit,
            status=status,
        )

        if resp.status_code != 200:
            logger.error(f"[RequestService] get_request_cards_page failed: {resp.status_code}")
            return RequestCardsPageSchema(request_cards=[], current_page=1, total_pages=1)

        try:
            return RequestCardsPageSchema.from_response(resp.json())
        except Exception as e:
            logger.error(f"[RequestService] Failed to parse response: {e}")
            return RequestCardsPageSchema(request_cards=[], current_page=1, total_pages=1)

    def get_request_by_id(
        self, request_id: str, requester_messenger_id: int
    ) -> RequestCardSchema | None:
        """Получает заявку по ID"""
        resp = self.client.get_request(request_id, requester_messenger_id)

        if resp.status_code == 404:
            return None
        if resp.status_code != 200:
            logger.error(f"[RequestService] get_request_by_id failed: {resp.status_code}")
            return None

        try:
            return RequestCardSchema.from_dict(resp.json())
        except Exception as e:
            logger.error(f"[RequestService] Failed to parse request: {e}")
            return None

    def approve_request(self, request_id: str, requester_messenger_id: int) -> bool:
        """Одобряет заявку"""
        resp = self.client.approve_request(request_id, requester_messenger_id)
        return resp.status_code == 200

    def reject_request(self, request_id: str, requester_messenger_id: int, reason: str) -> bool:
        """Отклоняет заявку с причиной"""
        resp = self.client.reject_request(request_id, requester_messenger_id, reason)
        return resp.status_code == 200

--- 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 get_my_event_info(self, event_id: str, requester_messenger_id: int):
        """GET /events/{event_id}/card/mine"""
        return self.get(
            f"/events/{event_id}/card/mine",
            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,
        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,
        }
        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/clients/request.py ---
from requests import Response

from core.clients.base import BaseApiClient


class RequestClient(BaseApiClient):
    """Клиент для работы с API заявок"""

    def get_request_cards(
        self,
        requester_messenger_id: int,
        page: int = 1,
        limit: int = 10,
        status: str | None = None,
    ) -> Response:
        """Получает список заявок"""
        url = "/requests/card/list"
        params = {
            "page": page,
            "limit": limit,
        }
        if status:
            params["status"] = status

        return self.get(
            url,
            headers={"Authorization": str(requester_messenger_id)},
            params=params,
        )

    def get_request(self, request_id: str, requester_messenger_id: int) -> Response:
        """Получает заявку по ID"""
        url = f"/requests/{request_id}/card"
        params = {"messenger_id": requester_messenger_id}

        return self.get(
            url,
            headers={"Authorization": str(requester_messenger_id)},
            params=params,
        )

    def approve_request(self, request_id: str, requester_messenger_id: int) -> Response:
        """Одобряет заявку"""
        url = f"/requests/{request_id}/approve"

        return self.post(url, headers={"Authorization": str(requester_messenger_id)})

    def reject_request(self, request_id: str, requester_messenger_id: int, reason: str) -> Response:
        """Отклоняет заявку с указанием причины"""
        url = f"/requests/{request_id}/reject"
        json_data = {
            "reject_reason": reason,
        }

        return self.post(
            url, headers={"Authorization": str(requester_messenger_id)}, json=json_data
        )

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

from core.markups.event import format_event_details
from core.markups.pagination import pagination_keyboard
from core.schemas import RequestCardSchema
from core.utils import format_datetime, wrap_text


def _get_status_emoji(status: str) -> str:

    status_emojis = {
        "pending": "⏳",  # на рассмотрении
        "approved": "✅",  # одобрено
        "rejected": "❌",  # отклонено
    }
    return status_emojis.get(status.lower(), "⚪")


def _get_status_label(status: str) -> str:
    status_label = {
        "pending": "на рассмотрении",
        "approved": "одобрено",
        "rejected": "отлконено",
    }
    return status_label.get(status.lower(), "-")


def _get_request_type_label(request_type: str) -> str:
    types = {
        "event": "Мероприятие вне проекта",
        "project_event": "Мероприятие в рамках",
        "project": "Проект",
    }
    return types.get(request_type.lower(), request_type.capitalize())


def _request_button_label(request_card: RequestCardSchema) -> str:
    """Формирует подпись для кнопки заявки (короткий вариант)"""
    req = request_card.request
    resource = request_card.resource

    request_type = _get_request_type_label(req.request_type)

    resource_name = "—"
    if resource.event:
        resource_name = resource.event.title or "—"

    status_emoji = _get_status_emoji(req.status)
    status_label = _get_status_label(req.status)

    return f"{request_type}\nНазвание: {resource_name}\nСтатус: {status_label} {status_emoji}"


def requests_select_keyboard(
    requests_cards: list[RequestCardSchema],
    *,
    open_media_id: str,
    start_index: int = 1,
    per_row: int = 1,
) -> list[InteractiveMediaGroup]:
    """
    Клавиатура для выбора заявки из списка (аналог events_select_keyboard)
    """
    groups: list[InteractiveMediaGroup] = []
    row: list[Button] = []

    idx = start_index
    for request_card in requests_cards:
        label = _request_button_label(request_card)
        row.append(Button(media_id=open_media_id, value=str(request_card.request.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 requests_pagination_keyboard(
    *,
    offset: int,
    limit: int,
    has_prev: bool,
    has_next: bool,
    prev_media_id: str = "requests_prev",
    next_media_id: str = "requests_next",
    leave_value: str = "moderation",
    leave_label: str = "🛡️ В меню модерации",
    keep_layout: bool = True,
) -> list[InteractiveMediaGroup]:
    """
    Клавиатура пагинации для списка заявок
    """
    return pagination_keyboard(
        offset=offset,
        limit=limit,
        has_prev=has_prev,
        has_next=has_next,
        prev_media_id=prev_media_id,
        next_media_id=next_media_id,
        leave_value=leave_value,
        leave_label=leave_label,
        keep_layout=keep_layout,
    )


def requests_filters_keyboard(
    ctx_name: str = "moderation",
    offset: int = 0,
) -> list[InteractiveMediaGroup]:
    """
    Клавиатура фильтров для заявок
    """
    return MediaGroupBuilder(
        [Button(media_id="requests_filters_open", value=f"{ctx_name}|{offset}", label="🔍 Фильтры")]
    ).build()


def format_request_details(request_card: RequestCardSchema) -> str:
    """Форматирует детальную информацию о заявке"""

    def _format_reject_reason(reject_reason: str) -> str:
        reject_reason = wrap_text(reject_reason, 40, "")
        return "❌ Причина отказа:\n```plain\n" + reject_reason + "\n```"

    req = request_card.request
    resource = request_card.resource

    status_emoji = _get_status_emoji(req.status)
    status_label = _get_status_label(req.status)

    lines = [
        f"📋 *Заявка* {status_emoji}",
        "",
        f"🆔 ID: `{req.id}`",
        f"📝 Тип: {_get_request_type_label(req.request_type)}",
        f"👤 Отправитель: `{req.messenger_id}`",
        f"📅 Создана: {format_datetime(req.created_at)}",
        f"🔄 Обновлена: {format_datetime(req.updated_at)}",
        f"📊 Статус: {status_label} {status_emoji}",
        "",
    ]

    if req.status.lower() == "rejected" and req.reject_reason:
        lines.append(_format_reject_reason(req.reject_reason))

    if req.request_type == "event" or req.request_type == "project_event":
        event_formated = format_event_details(
            resource, show_code=True, show_organizer=True, make_frame=True
        )
        lines.append(event_formated)

    return "\n".join(lines)


def request_actions_keyboard(
    request_id: str,
    *,
    status: str,
    back_value: str,
    back_label: str,
    approve_media_id: str = "request_approve",
    reject_media_id: str = "request_reject",
) -> list[InteractiveMediaGroup]:
    """
    Клавиатура для действий с заявкой (аналог event_actions_keyboard)
    """
    actions = []

    if status.lower() == "pending":
        actions.append(Button(media_id=approve_media_id, value=request_id, label="✅ Одобрить"))
        actions.append(Button(media_id=reject_media_id, value=request_id, label="❌ Отклонить"))

    actions.append(Button(media_id="leave", value=back_value, label=back_label))

    return MediaGroupBuilder(actions).build()

--- 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


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,
) -> 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.description or '—'}",
    ]

    if show_code:
        lines.append(f"🎟️ {make_bold('Код')}: {card.event.verification_code 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_manage: 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,
    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="❌ Отписаться"))
    else:
        actions.append(Button(media_id=sign_up_media_id, value=v, label="✅ Записаться"))

    if can_manage:
        actions.append(Button(media_id=edit_media_id, value=v, label="✏️ Редактировать"))
        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="leave", value=back_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="🏷️ Теги",
            ),
        ]
    )
    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_code",
                value=f"{event_id}|code",
                label="🎟️ Код",
            ),
            Button(
                media_id="event_menu_edit_gosb",
                value=f"{event_id}|gosb_id",
                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_value="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_value="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_value="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="1", label=label)]).build()

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

from pydantic import BaseModel

from core.schemas.event import EventCardSchema
from core.utils import parse_datetime_value, require_datetime


class RequestSchema(BaseModel):
    """Схема заявки"""

    id: str
    messenger_id: str
    request_type: str
    reason: str
    source_id: str
    status: str
    reject_reason: str
    reviewed_by: str
    reviewed_at: datetime | None
    created_at: datetime
    updated_at: datetime

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> "RequestSchema":
        reviewed_at = data.get("reviewed_at")
        if reviewed_at and reviewed_at.startswith("0001-01-01"):
            reviewed_at = None

        return cls(
            id=str(data.get("id", "")),
            messenger_id=str(data.get("messenger_id", "")),
            request_type=str(data.get("request_type", "")),
            reason=str(data.get("reason", "")),
            source_id=str(data.get("source_id", "")),
            status=str(data.get("status", "")),
            reject_reason=str(data.get("reject_reason", "")),
            reviewed_by=str(data.get("reviewed_by", "")),
            reviewed_at=parse_datetime_value(reviewed_at) if reviewed_at else None,
            created_at=require_datetime(data.get("created_at"), "participation.created_at"),
            updated_at=require_datetime(data.get("updated_at"), "participation.updated_at"),
        )


class RequestCardSchema(BaseModel):
    """Схема карточки заявки"""

    request: RequestSchema
    resource: EventCardSchema  # | ProjectCardSchema

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> "RequestCardSchema":
        request_data = data.get("request")
        if not isinstance(request_data, dict):
            raise ValueError("Invalid request contract: request_data is required")
        request = RequestSchema.from_dict(request_data)

        resource_data = data.get("resource")
        if not isinstance(resource_data, dict):
            raise ValueError("Invalid request contract: resource_data is required")
        resource = EventCardSchema.from_dict(resource_data)

        # TODO: Сделать обработку карточки проекта здесь

        return cls(
            request=request,
            resource=resource,
        )


class RequestCardsPageSchema(BaseModel):
    """Схема страницы с карточками заявок"""

    request_cards: list[RequestCardSchema]
    current_page: int
    total_pages: int

    @classmethod
    def from_response(cls, response_data: dict[str, Any]) -> "RequestCardsPageSchema":
        """Создаёт схему из ответа бэкенда"""
        cards_data = response_data.get("request_cards") or []
        if not isinstance(cards_data, list):
            raise ValueError("Invalid request card contract: cards_data must be list")

        page_meta = response_data.get("page_meta") or {}
        if not isinstance(page_meta, dict):
            raise ValueError("Invalid request card contract: page_meta must be dict")

        request_cards: list[RequestCardSchema] = []
        for card_data in cards_data:
            if not isinstance(card_data, dict):
                raise ValueError("Invalid request card contract: item must be dict")
            request_cards.append(RequestCardSchema.from_dict(card_data))

        return cls(
            request_cards=request_cards,
            current_page=int(page_meta.get("current_page", 1)),
            total_pages=int(page_meta.get("total_pages", 1)),
        )

    @property
    def has_prev(self) -> bool:
        return self.current_page > 1

    @property
    def has_next(self) -> bool:
        return self.current_page < self.total_pages

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

from pydantic import BaseModel, Field

from core.schemas.gosb import GosbSchema
from core.schemas.participation import ParticipationSchema
from core.schemas.tag import TagSchema
from core.schemas.terbank import TerbankSchema
from core.utils import logger, parse_datetime_value, require_datetime


class EventCardSchema(BaseModel):
    """Схема карточки мероприятия из списка"""

    event: "EventSchema"
    tags: list[TagSchema] = Field(default_factory=list)
    gosb: GosbSchema
    terbank: TerbankSchema
    participation: ParticipationSchema | None = None

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> "EventCardSchema":
        gosb_data = data.get("gosb")
        logger.info(f"DATA: {data}")
        if not isinstance(gosb_data, dict):
            logger.error(f"{gosb_data}, {type(gosb_data)}")
            raise ValueError("Invalid event card contract: gosb is required")
        gosb = GosbSchema.from_dict(gosb_data)

        terbank_data = data.get("terbank")
        if not isinstance(terbank_data, dict):
            raise ValueError("Invalid event card contract: terbank is required")
        terbank = TerbankSchema.from_dict(terbank_data)

        participation_data = data.get("participation")
        participation = None
        if participation_data is not None:
            if not isinstance(participation_data, dict):
                raise ValueError("Invalid event card contract: participation must be dict or None")
            participation = ParticipationSchema.from_dict(participation_data)

        tags_data = data.get("tags") or []
        tags = []
        for tag_data in tags_data:
            if not isinstance(tag_data, dict):
                raise ValueError("Invalid event card contract: item in 'tags_data' must be dict")
            tags.append(TagSchema.from_dict(tag_data))

        event_data = data.get("event")
        if not isinstance(event_data, dict):
            raise ValueError("Invalid event card contract: event is required")
        event = EventSchema(
            id=str(event_data.get("id", "")),
            title=str(event_data.get("title", "")),
            description=str(event_data.get("description", "")),
            date_time=require_datetime(event_data.get("date_time"), "event.date_time"),
            creator_id=str(event_data.get("creator_id", "")),
            gosb_id=str(event_data.get("gosb_id", "")),
            project_id=str(event_data.get("project_id", "")),
            archived_at=parse_datetime_value(event_data.get("archived_at"))
            if event_data.get("archived_at")
            else None,
            verification_code=str(event_data.get("verification_code"))
            if event_data.get("verification_code")
            else None,
            deleted=bool(event_data.get("deleted", False)),
            active=bool(event_data.get("active", True)),
            created_at=parse_datetime_value(event_data.get("created_at"))
            if event_data.get("created_at")
            else None,
            updated_at=parse_datetime_value(event_data.get("updated_at"))
            if event_data.get("updated_at")
            else None,
            hours=int(event_data.get("hours", 0)),
        )

        return cls(
            event=event,
            tags=tags,
            gosb=gosb,
            terbank=terbank,
            participation=participation,
        )


class EventSchema(BaseModel):
    """Полная схема мероприятия"""

    id: str
    title: str
    description: str
    date_time: datetime
    creator_id: str
    gosb_id: str
    project_id: str
    verification_code: str | None = None
    archived_at: datetime | None = None
    deleted: bool = False
    active: bool = True
    hours: int
    created_at: datetime | None = None
    updated_at: datetime | None = None


class EventCardsPageSchema(BaseModel):
    """Схема страницы с карточками мероприятий"""

    event_cards: list[EventCardSchema]  # добавляем поле для карточек
    current_page: int
    total_pages: int

    @classmethod
    def from_response(cls, response_data: dict[str, Any]) -> "EventCardsPageSchema":
        """Создаёт схему из ответа бэкенда"""
        cards_data = response_data.get("event_cards") or []
        if not isinstance(cards_data, list):
            raise ValueError("Invalid event card contract: cards_data must be list")

        page_meta = response_data.get("page_meta") or {}
        if not isinstance(page_meta, dict):
            raise ValueError("Invalid event card contract: page_meta must be dict")

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

        return cls(
            event_cards=event_cards,
            current_page=int(page_meta.get("current_page", 1)),
            total_pages=int(page_meta.get("total_pages", 1)),
        )

    @property
    def has_prev(self) -> bool:
        return self.current_page > 1

    @property
    def has_next(self) -> bool:
        return self.current_page < self.total_pages

--- services/backend/internal/drivers/http/v1/volunteering/events/event_controller.go ---
package events_http

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

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

type EventsController struct {
	eventService *events.EventService
	logger       pkg.Logger
}

func NewEventController(
	eventService *events.EventService,
	logger pkg.Logger,
) *EventsController {
	return &EventsController{
		eventService: eventService,
		logger:       logger,
	}
}

// UpdateEvent godoc
// @Summary      Update event partially
// @Description  Updates only the provided fields of an event. Fields not sent in the request body remain unchanged.
// @Tags         events
// @Accept       json
// @Produce      json
// @Security     BearerAuth
// @Param        event_id   path      string                true  "Event ID"
// @Param        request    body      events.EventUpdate    true  "Fields to update (only provided fields will be updated)"
// @Success      200        {object}  map[string]interface{}  "Event updated 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 not found"
// @Failure      500        {object}  map[string]interface{}  "Internal server error"
// @Router       /events/{event_id} [patch]
func (controller *EventsController) UpdateEvent(ctx *gin.Context) {
	eventId := ctx.Param("event_id")

	var update events.EventUpdate
	if err := ctx.ShouldBindJSON(&update); err != nil {
		ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}

	if err := controller.eventService.UpdateEvent(eventId, &update); err != nil {
		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": "event updated"})
}

--- 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"
	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
	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,
	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,
		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", r.eventCardController.GetEventCardList)
		eventsGroup.GET("/:event_id/card/mine", r.eventCardController.GetOrganizerEventCard)
		eventsGroup.GET("/:event_id/card", 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.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)
	}

	// 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())
	{
		participationGroup.POST("/signup", r.participationController.SignUp)
		participationGroup.POST("/confirm", r.participationController.Confirm)
		participationGroup.DELETE("/cancel", r.participationController.Cancel)
	}
}

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(registry_http.NewRegistryController),
)

--- services/backend/internal/drivers/http/v1/volunteering/requests/request_contoller.go ---
package requests_http

import (
	"errors"
	"main/internal/domain/volunteering/requests"
	"main/pkg"
	"net/http"

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

type RequestController struct {
	requestService *requests.RequestService
	logger         pkg.Logger
}

func NewRequestController(
	requestService *requests.RequestService,
	logger pkg.Logger,
) *RequestController {
	return &RequestController{
		requestService: requestService,
		logger:         logger,
	}
}

// Approve godoc
// @Summary      Approve resource
// @Description  Approves source (project, event).
// @Tags         requests
// @Accept       json
// @Produce      json
// @Security     BearerAuth
// @Param 		 request_id path string true "Request ID"
// @Success      200  {object}  map[string]interface{}  	  "Request approved successfully"
// @Failure      401  {object}  map[string]interface{}        "Unauthorized"
// @Failure      404  {object}  map[string]interface{}        "Request not found"
// @Failure      500  {object}  map[string]interface{}        "Internal server error"
// @Router       /requests/{request_id}/approve [post]
func (controller *RequestController) Approve(ctx *gin.Context) {
	userId := ctx.GetString("user_id")
	requestId := ctx.Param("request_id")

	if err := controller.requestService.Approve(userId, requestId); err != nil {
		if errors.Is(err, requests.ErrRequestNotFound) {
			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": "request approved successfully",
	})
}

// Reject godoc
// @Summary      Reject request
// @Description  Rejects request source (project, event).
// @Tags         requests
// @Accept       json
// @Produce      json
// @Security     BearerAuth
// @Param 		 request_id path string true "Request ID"
// @Param        request body RejectRequest true "Reject reason"
// @Success      200  {object}  map[string]interface{}  	  "Request rejected successfully"
// @Failure      401  {object}  map[string]interface{}        "Unauthorized"
// @Failure      404  {object}  map[string]interface{}        "Request not found"
// @Failure      500  {object}  map[string]interface{}        "Internal server error"
// @Router       /requests/{request_id}/reject [post]
func (controller *RequestController) Reject(ctx *gin.Context) {
	userId := ctx.GetString("user_id")
	requestId := ctx.Param("request_id")

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

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

		return
	}

	if err := controller.requestService.Reject(userId, requestId, req.Reason); err != nil {
		if errors.Is(err, requests.ErrRequestNotFound) {
			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": "resource rejected successfully",
	})
}

// Repeat godoc
// @Summary      Repeat request
// @Description  Repeats request (project, event).
// @Tags         requests
// @Accept       json
// @Produce      json
// @Security     BearerAuth
// @Param 		 request_id path string true "Request ID"
// @Success      200  {object}  map[string]interface{}  	  "Request repeated successfully"
// @Failure      401  {object}  map[string]interface{}        "Unauthorized"
// @Failure      404  {object}  map[string]interface{}        "Request not found"
// @Failure      500  {object}  map[string]interface{}        "Internal server error"
// @Router       /requests/{request_id}/repeat [post]
func (controller *RequestController) Repeat(ctx *gin.Context) {
	userId := ctx.GetString("user_id")
	requestId := ctx.Param("request_id")

	if err := controller.requestService.Repeat(userId, requestId); err != nil {
		if errors.Is(err, requests.ErrRequestNotFound) {
			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": "request repeated successfully",
	})
}

--- services/backend/internal/drivers/http/v1/volunteering/requests/request_requests.go ---
package requests_http

type RejectRequest struct {
	Reason string `json:"reject_reason"`
}

--- services/backend/internal/domain/volunteering/events/create_event_request.go ---
package events

import "time"

type CreateEventRequest struct {
	Title        string    `json:"title" example:"Название мероприятия"`
	Description  string    `json:"description" example:"Описание мероприятия"`
	DateTime     time.Time `json:"date_time" example:"2026-08-22T12:12:12Z"`
	GosbId       string    `json:"gosb_id" example:"gosb_spb_1"`
	ProjectId    string    `json:"project_id" example:""`
	ActivateCode string    `json:"activation_code" example:"LATIN2киррилиц"`
	Hours        int       `json:"hours" example:"3"`
	SelectedTags []string  `json:"selected_tags" example:"tag_soc_1,tag_eco_1"`
}

--- services/backend/internal/domain/volunteering/events/event_service.go ---
package events

import (
	"main/internal/domain"

	"time"
)

type EventService struct {
	eventRepo EventRepository
	eventBus  *domain.EventBus
}

func NewEventService(
	eventRepo EventRepository,
	eventBus *domain.EventBus,
) *EventService {
	return &EventService{
		eventRepo: eventRepo,
		eventBus:  eventBus,
	}
}

func (s *EventService) CreateEvent(creatorId string, request *CreateEventRequest) (*Event, error) {
	event := NewEvent(
		creatorId,
		request.Title,
		request.Description,
		request.DateTime,
		request.GosbId,
		request.ProjectId,
		request.ActivateCode,
		request.Hours)

	if err := s.eventRepo.CreateEvent(&event); err != nil {
		return nil, err
	}

	s.eventBus.Emit("events.create", EventCreated{
		CreatedBy:    event.CreatorId,
		EventId:      event.Id,
		OccurredAt:   event.CreatedAt,
		Active:       event.Active,
		IsSingle:     event.IsSingle(),
		ProjectId:    event.ProjectId,
		SelectedTags: request.SelectedTags,
	})

	return &event, nil
}

func (s *EventService) ActivateEvent(eventId string) error {
	event, err := s.eventRepo.GetEventById(eventId)
	if err != nil {
		return err
	}

	if err := event.Activate(); err != nil {
		return err
	}

	if err := s.eventRepo.UpdateEvent(event); err != nil {
		return err
	}

	if err := s.eventBus.Emit("events.activate", EventActivated{
		EventId:    event.Id,
		OccurredAt: time.Now(),
	}); err != nil {
		return err
	}

	return nil
}

func (s *EventService) EnsureEvent(eventId string) error {
	event, err := s.eventRepo.GetEventById(eventId)
	if err != nil {
		return err
	}

	if !event.Active {
		return ErrNotApproved
	}

	return nil
}

func (s *EventService) VerificationCode(eventId string, code string) (*VerificationResult, error) {
	event, err := s.eventRepo.GetEventById(eventId)
	if err != nil {
		return nil, err
	}

	if event.VerificationCode != code {
		return nil, ErrInvalidCode
	}

	return &VerificationResult{
		Hours: event.Hours,
	}, nil
}

func (s *EventService) UpdateEvent(eventId string, update *EventUpdate) error {
	event, err := s.eventRepo.GetEventById(eventId)
	if err != nil {
		return err
	}

	if err := event.Update(update); err != nil {
		return err
	}

	if err := s.eventRepo.UpdateEvent(event); err != nil {
		return err
	}

	if err := s.eventBus.Emit("events.update", EventUpdated{
		EventId:     event.Id,
		Title:       event.Title,
		Description: event.Description,
		GosbId:      event.GosbId,
		Code:        event.VerificationCode,
		Hours:       event.Hours,
		DateTime:    event.DateTime,
		OccurredAt:  time.Now(),
	}); err != nil {
		return err
	}

	return nil
}

func (s *EventService) Get(eventId string) (*Event, error) {
	event, err := s.eventRepo.GetEventById(eventId)
	if err != nil {
		return nil, err
	}

	return event, nil
}

--- services/backend/internal/domain/volunteering/events/event_view.go ---
package events

import "time"

type EventPublicView struct {
	Id          string    `json:"id"`
	Title       string    `json:"title"`
	Description string    `json:"description"`
	DateTime    time.Time `json:"date_time"`
	CreatorId   string    `json:"creator_id"`
	GosbId      string    `json:"gosb_id"`
	ProjectId   string    `json:"project_id"`
	Hours       int       `json:"hours"`
	Archived    bool      `json:"archived"`
	CreatedAt   time.Time `json:"created_at"`
	UpdatedAt   time.Time `json:"updated_at"`
}

func (e Event) ToPublicView() EventPublicView {
	return EventPublicView{
		Id:          e.Id,
		Title:       e.Title,
		Description: e.Description,
		DateTime:    e.DateTime,
		CreatorId:   e.CreatorId,
		GosbId:      e.GosbId,
		ProjectId:   e.ProjectId,
		Hours:       e.Hours,
		CreatedAt:   e.CreatedAt,
		UpdatedAt:   e.UpdatedAt,
		Archived:    e.ArchivedAt != nil,
	}
}

--- services/backend/internal/domain/volunteering/registry/create_type.go ---
package registry

type CreateType string

const (
	CreateTypeCompletly CreateType = "COMPLETELY"
	CreateTypeRequest   CreateType = "REQUEST"
	CreateTypeFailed    CreateType = "FAILED"
)

--- services/backend/internal/domain/volunteering/registry/registry_service.go ---
package registry

import (
	"main/internal/domain/volunteering/event_organizers"
	"main/internal/domain/volunteering/event_tags"
	"main/internal/domain/volunteering/events"
	"main/internal/domain/volunteering/projects"
	"main/internal/domain/volunteering/requests"
)

type RegistryService struct {
	eventService          *events.EventService
	requestService        *requests.RequestService
	eventTagService       *event_tags.EventTagService
	projectService        *projects.ProjectService
	eventOrganizerService *event_organizers.EventOrganizerService
}

func NewRegistryService(
	eventService *events.EventService,
	requestService *requests.RequestService,
	eventTagService *event_tags.EventTagService,
	projectService *projects.ProjectService,
	eventOrganizerService *event_organizers.EventOrganizerService,
) *RegistryService {
	return &RegistryService{
		eventService:          eventService,
		requestService:        requestService,
		eventTagService:       eventTagService,
		projectService:        projectService,
		eventOrganizerService: eventOrganizerService,
	}
}

func (s *RegistryService) RegisterEvent(
	messengerId string,
	request *events.CreateEventRequest,
	shouldActivateDirectly bool,
) (*CreateResult, error) {
	event, err := s.eventService.CreateEvent(messengerId, request)
	if err != nil {
		return CreateTypeFailed, err
	}

	if err := s.eventTagService.InitializeTags(event.Id, request.SelectedTags); err != nil {
		return CreateTypeFailed, err
	}

	organizers := []event_organizers.OrganizerRequest{
		{
			MessengerId: event.CreatorId,
			Role:        event_organizers.RoleCreator,
		},
	}

	if event.IsProjectPart() {
		leader, err := s.projectService.GetProjectLeader(event.ProjectId)
		if err != nil {
			return nil, err
		}
		organizers = append(organizers, event_organizers.OrganizerRequest{
			MessengerId: leader,
			Role:        event_organizers.RoleLeader,
		})
	}

	if err := s.eventOrganizerService.InitializeOrganizers(event.Id, organizers); err != nil {
		return nil, err
	}

	if shouldActivateDirectly {
		if err := s.eventService.ActivateEvent(event.Id); err != nil {
			return nil, err
		}

		return &CreateResult{
			CreateType: CreateTypeCompletly,
			EventId:    event.Id,
		}, nil
	}

	var requestType requests.RequestType
	if event.IsSingle() {
		requestType = requests.RequestTypeEvent
	} else {
		requestType = requests.RequestTypeProjectEvent
	}

	requestId, err := s.requestService.CreateRequest(event.CreatorId, requestType, event.Id)
	if err != nil {
		return nil, err
	}

	return &CreateResult{
		CreateType: CreateTypeCompletly,
		RequestId:  requestId,
	}, nil
}

--- services/backend/internal/domain/volunteering/requests/request_service.go ---
package requests

import (
	"time"
)

type ResourceActivator interface {
	Activate(resourceId string, resourseType RequestType) error
}

type RequestService struct {
	requestRepo       RequestRepository
	resourceActivator ResourceActivator
}

func NewRequestService(
	requestRepo RequestRepository,
	resourceActivator ResourceActivator,
) *RequestService {
	return &RequestService{
		requestRepo:       requestRepo,
		resourceActivator: resourceActivator,
	}
}

func (s *RequestService) CreateRequest(
	messengerId string,
	requestType RequestType,
	sourceId string,
) (string, error) {
	request := NewRequest(messengerId, requestType, sourceId)
	if err := s.requestRepo.Create(&request); err != nil {
		return "", err
	}

	return request.Id, nil
}

func (s *RequestService) IsRequestOwner(viewerId string, requestId string) (bool, error) {
	request, err := s.requestRepo.Get(requestId)
	if err != nil {
		return false, err
	}

	return request.MessengerId == viewerId, nil
}

func (s *RequestService) Approve(viewerId string, requestId string) error {
	request, err := s.requestRepo.Get(requestId)
	if err != nil {
		return err
	}

	if err := request.Approve(viewerId, time.Now()); err != nil {
		return err
	}

	if err := s.resourceActivator.Activate(request.SourceId, request.RequestType); err != nil {
		return err
	}

	if err := s.requestRepo.Update(request); err != nil {
		return err
	}

	return nil
}

func (s *RequestService) Reject(viewerId string, requestId string, rejectReason string) error {
	request, err := s.requestRepo.Get(requestId)
	if err != nil {
		return err
	}

	if err := request.Reject(viewerId, rejectReason, time.Now()); err != nil {
		return err
	}

	if err := s.requestRepo.Update(request); err != nil {
		return err
	}

	return nil
}

func (s *RequestService) Repeat(viewerId string, requestId string) error {
	request, err := s.requestRepo.Get(requestId)
	if err != nil {
		return err
	}

	if err := request.Repeat(); err != nil {
		return err
	}

	if err := s.requestRepo.Update(request); err != nil {
		return err
	}

	return nil
}

func (s *RequestService) Get(requestId string) (*Request, error) {
	request, err := s.requestRepo.Get(requestId)
	if err != nil {
		return nil, err
	}

	return request, nil
}

func (s *RequestService) MarkUpdatedByResource(resourceId string) error {
	request, err := s.requestRepo.GetByResourceId(resourceId)
	if err != nil {
		return err
	}

	if err := s.requestRepo.Update(request); err != nil {
		return err
	}

	return nil
}

--- services/backend/internal/domain/volunteering/request_card/request_card_service.go ---
package request_card

type RequestCardService struct {
	requestCardQuery RequestCardQuery
}

func NewRequestCardService(
	requestCardQuery RequestCardQuery,
) *RequestCardService {
	return &RequestCardService{
		requestCardQuery: requestCardQuery,
	}
}

func (s *RequestCardService) GetRequestCard(viewerId string, requestId string) (*RequestCard, error) {
	card, err := s.requestCardQuery.GetRequestCard(requestId)
	if err != nil {
		return nil, err
	}

	return card, nil
}

func (s *RequestCardService) GetOwnList(viewerId string, page int, limit int, filter *Filter) (*RequestCardPage, error) {
	list, err := s.requestCardQuery.GetVolunteerRequestCardList(viewerId, page, limit, filter)
	if err != nil {
		return nil, err
	}

	return list, nil
}

func (s *RequestCardService) GetList(viewerId string, page int, limit int, filter *Filter) (*RequestCardPage, error) {
	list, err := s.requestCardQuery.GetRequestCardList(viewerId, page, limit, filter)
	if err != nil {
		return nil, err
	}

	return list, nil
}

Editor is loading...
Leave a Comment