Files
konstruct/migrations/versions/012_push_subscriptions.py
Adolfo Delorenzo 7d3a393758 feat(08-03): push notification backend — DB model, migration, API router, VAPID setup
- Add PushSubscription ORM model with unique(user_id, endpoint) constraint
- Add Alembic migration 012 for push_subscriptions table
- Add push router (subscribe, unsubscribe, send) in shared/api/push.py
- Mount push router in gateway/main.py
- Add pywebpush to gateway dependencies for server-side VAPID delivery
- Wire push trigger into WebSocket handler (fires when client disconnects mid-stream)
- Add VAPID keys to .env / .env.example
- Add push/install i18n keys in en/es/pt message files
2026-03-25 21:26:51 -06:00

92 lines
2.8 KiB
Python

"""Push subscriptions table for Web Push notifications
Revision ID: 012
Revises: 011
Create Date: 2026-03-26
Creates the push_subscriptions table so the gateway can store browser
push subscriptions and deliver Web Push notifications when an AI employee
responds and the user's WebSocket is not connected.
No RLS policy is applied — the API filters by user_id at the application
layer (push subscriptions are portal-user-scoped, not tenant-scoped).
"""
from __future__ import annotations
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision: str = "012"
down_revision: Union[str, None] = "011"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"push_subscriptions",
sa.Column(
"id",
postgresql.UUID(as_uuid=True),
server_default=sa.text("gen_random_uuid()"),
nullable=False,
),
sa.Column(
"user_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("portal_users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"tenant_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("tenants.id", ondelete="SET NULL"),
nullable=True,
comment="Optional tenant scope for notification routing",
),
sa.Column(
"endpoint",
sa.Text,
nullable=False,
comment="Push service URL (browser-provided)",
),
sa.Column(
"p256dh",
sa.Text,
nullable=False,
comment="ECDH public key for payload encryption",
),
sa.Column(
"auth",
sa.Text,
nullable=False,
comment="Auth secret for payload encryption",
),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("user_id", "endpoint", name="uq_push_user_endpoint"),
)
op.create_index("ix_push_subscriptions_user_id", "push_subscriptions", ["user_id"])
op.create_index("ix_push_subscriptions_tenant_id", "push_subscriptions", ["tenant_id"])
def downgrade() -> None:
op.drop_index("ix_push_subscriptions_tenant_id", table_name="push_subscriptions")
op.drop_index("ix_push_subscriptions_user_id", table_name="push_subscriptions")
op.drop_table("push_subscriptions")