fix(lifecycle): OAuth pw 500, invite lockout, reminder re-arm, partial-unique
Drift-audit Group 8 (lifecycle gaps): - change_password no longer 500s for OAuth-only users: short-circuit when password_hash is None (verify_password would crash on None) so the route returns a clean 4xx instead of a 500. - register_with_invitation no longer locks the invitee out on a username collision: create the user FIRST, then mark the token used, so a failed creation (409) leaves the single-use invite valid for retry. - update_event re-arms reminder_sent_at when start_dt/reminder_minutes change, so a rescheduled event fires again instead of being permanently suppressed. - Migration 0061: uq_topic_per_rulebook / uq_rule_per_topic become PARTIAL unique indexes (WHERE deleted_at IS NULL). Trashing 'X' then recreating it no longer 500s on the dead row's title. Model __table_args__ updated to match. Deferred: per-occurrence reminders for recurring events (event_scheduler) — needs a per-occurrence reminder-state design, not a one-line gate tweak. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
"""partial-unique topic/rule titles (ignore soft-deleted rows)
|
||||
|
||||
Revision ID: 0061
|
||||
Revises: 0060
|
||||
Create Date: 2026-06-02
|
||||
|
||||
Topics and rules are soft-deleted (SoftDeleteMixin), but uq_topic_per_rulebook
|
||||
and uq_rule_per_topic were plain UNIQUE constraints. Trashing a topic/rule
|
||||
named "X" then creating a new "X" — or restoring into a reused title slot —
|
||||
collided with the dead row and raised an unhandled 500. Replace the full
|
||||
UNIQUE constraints with partial unique indexes that only consider live
|
||||
(deleted_at IS NULL) rows.
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "0061"
|
||||
down_revision = "0060"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.drop_constraint("uq_topic_per_rulebook", "rulebook_topics", type_="unique")
|
||||
op.create_index(
|
||||
"uq_topic_per_rulebook",
|
||||
"rulebook_topics",
|
||||
["rulebook_id", "title"],
|
||||
unique=True,
|
||||
postgresql_where=sa.text("deleted_at IS NULL"),
|
||||
)
|
||||
op.drop_constraint("uq_rule_per_topic", "rules", type_="unique")
|
||||
op.create_index(
|
||||
"uq_rule_per_topic",
|
||||
"rules",
|
||||
["topic_id", "title"],
|
||||
unique=True,
|
||||
postgresql_where=sa.text("deleted_at IS NULL"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("uq_rule_per_topic", table_name="rules")
|
||||
op.create_unique_constraint("uq_rule_per_topic", "rules", ["topic_id", "title"])
|
||||
op.drop_index("uq_topic_per_rulebook", table_name="rulebook_topics")
|
||||
op.create_unique_constraint(
|
||||
"uq_topic_per_rulebook", "rulebook_topics", ["rulebook_id", "title"]
|
||||
)
|
||||
Reference in New Issue
Block a user