from datetime import datetime from enum import StrEnum from typing import Annotated, Any, ClassVar, Literal, Self, final import sqlalchemy as sa from pydantic import BaseModel, Field from sqlalchemy.orm import Mapped, mapped_column, relationship from core.workflow.nodes.human_input.entities import ( DeliveryMethodType, EmailRecipientType, HumanInputFormStatus, ) from libs.helper import generate_string from .base import Base, DefaultFieldsMixin from .types import EnumText, StringUUID _token_length = 22 # A 32-character string can store a base64-encoded value with 192 bits of entropy # or a base62-encoded value with over 180 bits of entropy, providing sufficient # uniqueness for most use cases. _token_field_length = 32 _email_field_length = 330 def _generate_token() -> str: return generate_string(_token_length) class HumanInputForm(DefaultFieldsMixin, Base): __tablename__ = "human_input_forms" tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False) workflow_run_id: Mapped[str] = mapped_column(StringUUID, nullable=False) # The human input node the current form corresponds to. node_id: Mapped[str] = mapped_column(sa.String(60), nullable=False) form_definition: Mapped[str] = mapped_column(sa.Text, nullable=False) rendered_content: Mapped[str] = mapped_column(sa.Text, nullable=False) status: Mapped[HumanInputFormStatus] = mapped_column( EnumText(HumanInputFormStatus), nullable=False, default=HumanInputFormStatus.WAITING, ) expiration_time: Mapped[datetime] = mapped_column( sa.DateTime, nullable=False, ) # Submission-related fields (nullable until a submission happens). selected_action_id: Mapped[str | None] = mapped_column(sa.String(200), nullable=True) submitted_data: Mapped[str | None] = mapped_column(sa.Text, nullable=True) submitted_at: Mapped[datetime | None] = mapped_column(sa.DateTime, nullable=True) submission_user_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True) submission_end_user_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True) completed_by_recipient_id: Mapped[str | None] = mapped_column( StringUUID, sa.ForeignKey("human_input_recipients.id"), nullable=True, ) deliveries: Mapped[list["HumanInputDelivery"]] = relationship( "HumanInputDelivery", back_populates="form", lazy="raise", ) completed_by_recipient: Mapped["HumanInputRecipient | None"] = relationship( "HumanInputRecipient", primaryjoin="HumanInputForm.completed_by_recipient_id == HumanInputRecipient.id", lazy="raise", viewonly=True, ) class HumanInputDelivery(DefaultFieldsMixin, Base): __tablename__ = "human_input_deliveries" form_id: Mapped[str] = mapped_column( StringUUID, sa.ForeignKey("human_input_forms.id"), nullable=False, ) delivery_method_type: Mapped[DeliveryMethodType] = mapped_column( EnumText(DeliveryMethodType), nullable=False, ) delivery_config_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True) channel_payload: Mapped[None] = mapped_column(sa.Text, nullable=True) form: Mapped[HumanInputForm] = relationship( "HumanInputForm", back_populates="deliveries", lazy="raise", ) recipients: Mapped[list["HumanInputRecipient"]] = relationship( "HumanInputRecipient", back_populates="delivery", cascade="all, delete-orphan", lazy="raise", ) class RecipientType(StrEnum): # EMAIL_MEMBER member means that the EMAIL_MEMBER = "email_member" EMAIL_EXTERNAL = "email_external" WEBAPP = "webapp" @final class EmailMemberRecipientPayload(BaseModel): TYPE: Literal[RecipientType.EMAIL_MEMBER] = RecipientType.EMAIL_MEMBER user_id: str # The `email` field here is only used for mail sending. email: str @final class EmailExternalRecipientPayload(BaseModel): TYPE: Literal[RecipientType.EMAIL_EXTERNAL] = RecipientType.EMAIL_EXTERNAL email: str @final class WebAppRecipientPayload(BaseModel): TYPE: Literal[RecipientType.WEBAPP] = RecipientType.WEBAPP RecipientPayload = Annotated[ EmailMemberRecipientPayload | EmailExternalRecipientPayload | WebAppRecipientPayload, Field(discriminator="TYPE"), ] class HumanInputRecipient(DefaultFieldsMixin, Base): __tablename__ = "human_input_recipients" form_id: Mapped[str] = mapped_column( StringUUID, nullable=False, ) delivery_id: Mapped[str] = mapped_column( StringUUID, nullable=False, ) recipient_type: Mapped["RecipientType"] = mapped_column(EnumText(RecipientType), nullable=False) recipient_payload: Mapped[str] = mapped_column(sa.Text, nullable=False) # Token primarily used for authenticated resume links (email, etc.). access_token: Mapped[str | None] = mapped_column( sa.VARCHAR(_token_field_length), nullable=True, default=_generate_token, ) delivery: Mapped[HumanInputDelivery] = relationship( "HumanInputDelivery", back_populates="recipients", lazy="raise", ) @classmethod def new( cls, form_id: str, delivery_id: str, payload: RecipientPayload, ) -> Self: recipient_model = cls( form_id=form_id, delivery_id=delivery_id, recipient_type=payload.TYPE, recipient_payload=payload.model_dump_json(), access_token=_generate_token(), ) return recipient_model