Files
saas_backend/alembic/versions/f8c3a6e1b72d_notification_preferences.py
T

89 lines
3.7 KiB
Python
Raw Normal View History

2026-08-31 20:04:12 -04:00
"""Letting somebody turn a notification off.
Notifications are addressed to a person, deduplicated, and raised only for things
worth raising — and none of that helps if the one you get every day is the one
you do not want. A bell somebody has learned to ignore is worse than no bell,
because it also hides the notice that mattered.
## Why a row means "changed from the default"
The absence of a row means **enabled**. Nothing is written when somebody accepts
the defaults, which is almost everybody, so the table stays roughly the size of
the number of people who have actually expressed an opinion.
The alternative — a row per person per kind per channel, written on account
creation — is thousands of rows saying "yes, the default" and a migration every
time a new kind is added. It also has a worse failure mode: a kind added without
backfilling the table is a notification nobody receives, and nothing reports it.
## Why the channel is part of the key
"Tell me in the product but do not email me" is the setting people actually want,
and it is not expressible without it. Today only the in-app channel exists; email
and webhook are named so the column does not have to change when they do.
Revision ID: f8c3a6e1b72d
Revises: e7b2d5c9a14f
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects.postgresql import UUID
revision: str = "f8c3a6e1b72d"
down_revision: Union[str, Sequence[str], None] = "e7b2d5c9a14f"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"notification_preferences",
sa.Column("id", UUID(as_uuid=True), primary_key=True,
server_default=sa.text("gen_random_uuid()")),
sa.Column("tenant_id", UUID(as_uuid=True),
sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=True),
sa.Column("user_id", UUID(as_uuid=True),
sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("notification_type", sa.String(100), nullable=False),
sa.Column("channel", sa.String(50), nullable=False, server_default="in_app"),
sa.Column("enabled", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False,
server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False,
server_default=sa.func.now(), onupdate=sa.func.now()),
sa.UniqueConstraint("user_id", "notification_type", "channel",
name="uq_notification_preference"),
)
op.create_index(
"ix_notification_preferences_user", "notification_preferences",
["user_id", "notification_type"],
)
op.execute("ALTER TABLE notification_preferences ENABLE ROW LEVEL SECURITY")
op.execute("ALTER TABLE notification_preferences FORCE ROW LEVEL SECURITY")
op.execute(
"""
CREATE POLICY tenant_isolation ON notification_preferences
USING (
current_setting('app.bypass_rls', true) = 'on'
OR tenant_id IS NULL
OR tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid
)
WITH CHECK (
current_setting('app.bypass_rls', true) = 'on'
OR tenant_id IS NULL
OR tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid
)
"""
)
def downgrade() -> None:
op.execute("DROP POLICY IF EXISTS tenant_isolation ON notification_preferences")
op.drop_index("ix_notification_preferences_user",
table_name="notification_preferences")
op.drop_table("notification_preferences")