feat(poll): enforce governed participation ownership

This commit is contained in:
2026-07-21 21:17:04 +02:00
parent f839545605
commit 156486fcee
13 changed files with 3515 additions and 53 deletions
@@ -0,0 +1,104 @@
"""v0.1.10 governed signed participation
Revision ID: 4c5d6e7f8a9b
Revises: 3b4c5d6e7f8a
Create Date: 2026-07-21 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "4c5d6e7f8a9b"
down_revision = "3b4c5d6e7f8a"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"poll_invitations",
sa.Column("response_gateway", sa.JSON(), nullable=True),
)
op.add_column(
"poll_invitations",
sa.Column("participation_policy", sa.JSON(), nullable=True),
)
op.create_table(
"poll_participation_submissions",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("poll_id", sa.String(length=36), nullable=False),
sa.Column("invitation_id", sa.String(length=36), nullable=False),
sa.Column("response_id", sa.String(length=36), nullable=False),
sa.Column("idempotency_key", sa.String(length=255), nullable=False),
sa.Column("request_fingerprint", sa.String(length=64), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["poll_id"],
["poll_polls.id"],
name=op.f("fk_poll_participation_submissions_poll_id_poll_polls"),
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["invitation_id"],
["poll_invitations.id"],
name=op.f(
"fk_poll_participation_submissions_invitation_id_poll_invitations"
),
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["response_id"],
["poll_responses.id"],
name=op.f("fk_poll_participation_submissions_response_id_poll_responses"),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint(
"id",
name=op.f("pk_poll_participation_submissions"),
),
sa.UniqueConstraint(
"invitation_id",
"idempotency_key",
name="uq_poll_participation_submission_idempotency",
),
)
op.create_index(
op.f("ix_poll_participation_submissions_tenant_id"),
"poll_participation_submissions",
["tenant_id"],
unique=False,
)
op.create_index(
op.f("ix_poll_participation_submissions_poll_id"),
"poll_participation_submissions",
["poll_id"],
unique=False,
)
op.create_index(
op.f("ix_poll_participation_submissions_invitation_id"),
"poll_participation_submissions",
["invitation_id"],
unique=False,
)
op.create_index(
op.f("ix_poll_participation_submissions_response_id"),
"poll_participation_submissions",
["response_id"],
unique=False,
)
op.create_index(
"ix_poll_participation_submission_poll_created",
"poll_participation_submissions",
["tenant_id", "poll_id", "created_at"],
unique=False,
)
def downgrade() -> None:
op.drop_table("poll_participation_submissions")
op.drop_column("poll_invitations", "participation_policy")
op.drop_column("poll_invitations", "response_gateway")
@@ -0,0 +1,64 @@
"""v0.1.10 durable Poll participation owner
Revision ID: 5d6e7f8a9b0c
Revises: 4c5d6e7f8a9b
Create Date: 2026-07-21 00:00:01.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "5d6e7f8a9b0c"
down_revision = "4c5d6e7f8a9b"
branch_labels = None
depends_on = None
def _backfill_existing_governed_polls() -> None:
"""Retain the owner of installations that already used revision 4c5."""
polls = sa.table(
"poll_polls",
sa.column("id", sa.String(length=36)),
sa.column("participation_gateway", sa.JSON()),
)
invitations = sa.table(
"poll_invitations",
sa.column("id", sa.String(length=36)),
sa.column("poll_id", sa.String(length=36)),
sa.column("response_gateway", sa.JSON()),
)
connection = op.get_bind()
rows = connection.execute(
sa.select(
invitations.c.poll_id,
invitations.c.response_gateway,
).order_by(invitations.c.poll_id.asc(), invitations.c.id.asc())
).mappings()
claimed_poll_ids: set[str] = set()
for row in rows:
poll_id = row["poll_id"]
gateway = row["response_gateway"]
if poll_id in claimed_poll_ids or not isinstance(gateway, dict):
continue
connection.execute(
polls.update()
.where(polls.c.id == poll_id)
.where(polls.c.participation_gateway.is_(None))
.values(participation_gateway=gateway)
)
claimed_poll_ids.add(poll_id)
def upgrade() -> None:
op.add_column(
"poll_polls",
sa.Column("participation_gateway", sa.JSON(), nullable=True),
)
_backfill_existing_governed_polls()
def downgrade() -> None:
op.drop_column("poll_polls", "participation_gateway")