2528 lines
89 KiB
Markdown
2528 lines
89 KiB
Markdown
# Tenant Isolation — Phase 1 Schema Migrations
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Add `account_id` to every tenant table that lacks it, backfill from existing FK chains, enforce NOT NULL, and create the global content tables (`template_trees`, `platform_steps`) that replace the legacy `is_default`/`visibility='public'` patterns.
|
|
|
|
**Architecture:** Each task is one Alembic migration file covering one logical domain group. Every migration follows the non-negotiable sequence: ADD nullable → backfill → verify zero NULLs → SET NOT NULL → CREATE INDEX. Any migration that cannot zero-out NULLs at step 3 must roll back in full — no partial state. RLS is NOT enabled in this phase. `get_db()` is NOT modified. Schema only.
|
|
|
|
**Tech Stack:** Python 3.11 · FastAPI · SQLAlchemy 2.0 async · Alembic · PostgreSQL 16 · pytest-asyncio
|
|
|
|
**Spec:** `docs/superpowers/specs/2026-04-09-tenant-data-isolation-design.md`
|
|
|
|
**Prerequisite:** Phase 0 merged to `main` (PRs #131 + #132 ✓). Alembic current head: `b8d2f4a6c091`.
|
|
|
|
**Task ordering note:** Task 9 (global content separation) runs before Task 10 (SET NOT NULL on trees/categories/tags/steps). This is a dependency: `is_default=TRUE` trees have `account_id=NULL` and cannot satisfy the zero-NULL check until they are moved to `template_trees`.
|
|
|
|
---
|
|
|
|
## File Map
|
|
|
|
| File | Action | Group |
|
|
|---|---|---|
|
|
| `backend/alembic/versions/<hash>_add_account_id_core_sessions.py` | Create | 1 |
|
|
| `backend/alembic/versions/<hash>_add_account_id_ai_branching.py` | Create | 2 |
|
|
| `backend/alembic/versions/<hash>_add_account_id_step_ratings.py` | Create | 3 |
|
|
| `backend/alembic/versions/<hash>_add_account_id_user_personalization.py` | Create | 4 |
|
|
| `backend/alembic/versions/<hash>_add_account_id_psa_notifications.py` | Create | 5 |
|
|
| `backend/alembic/versions/<hash>_add_account_id_maintenance.py` | Create | 6 |
|
|
| `backend/alembic/versions/<hash>_add_account_id_script_tables.py` | Create | 7 |
|
|
| `backend/alembic/versions/<hash>_add_account_id_target_lists.py` | Create | 8 |
|
|
| `backend/alembic/versions/<hash>_create_global_content_tables.py` | Create | 9 |
|
|
| `backend/alembic/versions/<hash>_set_not_null_account_id_phase1.py` | Create | 10 |
|
|
| `backend/app/models/session.py` | Modify | 1 |
|
|
| `backend/app/models/attachment.py` | Modify | 1 |
|
|
| `backend/app/models/supporting_data.py` | Modify | 1 |
|
|
| `backend/app/models/session_resolution_output.py` | Modify | 1 |
|
|
| `backend/app/models/session_branch.py` | Modify | 2 |
|
|
| `backend/app/models/session_handoff.py` | Modify | 2 |
|
|
| `backend/app/models/fork_point.py` | Modify | 2 |
|
|
| `backend/app/models/ai_session_step.py` | Modify | 2 |
|
|
| `backend/app/models/ai_suggestion.py` | Modify | 2 |
|
|
| `backend/app/models/step_library.py` | Modify | 3 (StepRating, StepUsageLog) |
|
|
| `backend/app/models/folder.py` | Modify | 4 |
|
|
| `backend/app/models/user_pinned_tree.py` | Modify | 4 |
|
|
| `backend/app/models/psa_post_log.py` | Modify | 5 |
|
|
| `backend/app/models/psa_member_mapping.py` | Modify | 5 |
|
|
| `backend/app/models/notification_log.py` | Modify | 5 |
|
|
| `backend/app/models/maintenance_schedule.py` | Modify | 6 |
|
|
| `backend/app/models/script_builder_session.py` | Modify | 7 |
|
|
| `backend/app/models/script_template.py` | Modify | 7 (ScriptTemplate, ScriptGeneration) |
|
|
| `backend/app/models/target_list.py` | Modify | 8 |
|
|
| `backend/app/models/template_tree.py` | Create | 9 |
|
|
| `backend/app/models/platform_step.py` | Create | 9 |
|
|
| `backend/app/models/user.py` | Modify | 10 |
|
|
| `backend/app/models/tree.py` | Modify | 10 |
|
|
| `backend/app/models/category.py` | Modify | 10 |
|
|
| `backend/app/models/tag.py` | Modify | 10 |
|
|
| `backend/app/models/step_category.py` | Modify | 10 |
|
|
| `backend/app/models/step_library.py` | Modify | 10 (StepLibrary account_id NOT NULL) |
|
|
| `backend/app/models/tree_embedding.py` | Modify | 10 |
|
|
| `backend/app/models/feedback.py` | Modify | 10 |
|
|
| `backend/tests/test_phase1_migrations.py` | Create | all tasks |
|
|
|
|
---
|
|
|
|
## Task 1: Group 1 — Core sessions
|
|
|
|
**Tables:** `sessions`, `attachments`, `session_supporting_data`, `session_resolution_outputs`
|
|
|
|
**Backfill paths:**
|
|
- `sessions`: `sessions.user_id → users.account_id`
|
|
- `attachments`: `attachments.session_id → sessions.account_id` (chain — sessions must be backfilled first in same migration)
|
|
- `session_supporting_data`: same chain as attachments
|
|
- `session_resolution_outputs`: `session_resolution_outputs.session_id → ai_sessions.account_id` (FK is to `ai_sessions`, not `sessions`)
|
|
|
|
**Files:**
|
|
- Create: `backend/alembic/versions/<hash>_add_account_id_core_sessions.py`
|
|
- Modify: `backend/app/models/session.py`, `attachment.py`, `supporting_data.py`, `session_resolution_output.py`
|
|
- Test: `backend/tests/test_phase1_migrations.py`
|
|
|
|
---
|
|
|
|
- [ ] **Step 1.1: Create the branch**
|
|
|
|
```bash
|
|
git checkout main && git pull origin main
|
|
git checkout -b feat/tenant-isolation-phase-1
|
|
```
|
|
|
|
- [ ] **Step 1.2: Write the failing test**
|
|
|
|
Create `backend/tests/test_phase1_migrations.py`:
|
|
|
|
```python
|
|
"""Phase 1 migration tests — verify account_id backfill correctness.
|
|
|
|
These tests create objects via ORM (which uses the updated models),
|
|
then verify account_id is populated correctly. They run against a
|
|
real PostgreSQL test DB (same as all other integration tests).
|
|
"""
|
|
import pytest
|
|
import uuid
|
|
from httpx import AsyncClient
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy import select, text
|
|
|
|
from app.models.account import Account
|
|
from app.models.user import User
|
|
from app.models.tree import Tree
|
|
from app.models.session import Session
|
|
from app.models.attachment import Attachment
|
|
from app.models.supporting_data import SessionSupportingData
|
|
from app.models.session_resolution_output import SessionResolutionOutput
|
|
from app.models.ai_session import AISession
|
|
from app.core.security import get_password_hash
|
|
|
|
|
|
# ── Helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
async def _make_account_and_user(db: AsyncSession, suffix: str) -> tuple[Account, User]:
|
|
account = Account(name=f"Corp {suffix}", display_code=uuid.uuid4().hex[:8])
|
|
db.add(account)
|
|
await db.flush()
|
|
user = User(
|
|
email=f"user-{suffix}-{uuid.uuid4().hex[:6]}@example.com",
|
|
name=f"User {suffix}",
|
|
password_hash=get_password_hash("TestPass123!"),
|
|
is_active=True,
|
|
account_id=account.id,
|
|
account_role="engineer",
|
|
)
|
|
db.add(user)
|
|
await db.flush()
|
|
return account, user
|
|
|
|
|
|
async def _make_tree(db: AsyncSession, account: Account, user: User) -> Tree:
|
|
tree = Tree(
|
|
name=f"Tree {uuid.uuid4().hex[:6]}",
|
|
account_id=account.id,
|
|
author_id=user.id,
|
|
visibility="team",
|
|
tree_type="troubleshooting",
|
|
tree_structure={"id": "root", "type": "start", "children": []},
|
|
is_active=True,
|
|
status="published",
|
|
)
|
|
db.add(tree)
|
|
await db.flush()
|
|
return tree
|
|
|
|
|
|
async def _make_session(db: AsyncSession, account: Account, user: User, tree: Tree) -> Session:
|
|
s = Session(
|
|
tree_id=tree.id,
|
|
user_id=user.id,
|
|
account_id=account.id,
|
|
tree_snapshot={},
|
|
)
|
|
db.add(s)
|
|
await db.flush()
|
|
return s
|
|
|
|
|
|
# ── Group 1: Core sessions ────────────────────────────────────────────────────
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_session_account_id_matches_user(test_db: AsyncSession):
|
|
"""sessions.account_id must equal the user's account_id."""
|
|
account, user = await _make_account_and_user(test_db, "s1")
|
|
tree = await _make_tree(test_db, account, user)
|
|
session = await _make_session(test_db, account, user, tree)
|
|
await test_db.commit()
|
|
|
|
result = await test_db.execute(select(Session).where(Session.id == session.id))
|
|
row = result.scalar_one()
|
|
assert row.account_id == account.id, f"Expected {account.id}, got {row.account_id}"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_attachment_account_id_matches_session(test_db: AsyncSession):
|
|
"""attachments.account_id must match the parent session's account_id."""
|
|
account, user = await _make_account_and_user(test_db, "att1")
|
|
tree = await _make_tree(test_db, account, user)
|
|
session = await _make_session(test_db, account, user, tree)
|
|
|
|
attachment = Attachment(
|
|
session_id=session.id,
|
|
account_id=account.id,
|
|
file_name="test.png",
|
|
file_type="image/png",
|
|
)
|
|
test_db.add(attachment)
|
|
await test_db.commit()
|
|
|
|
result = await test_db.execute(select(Attachment).where(Attachment.id == attachment.id))
|
|
row = result.scalar_one()
|
|
assert row.account_id == account.id
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_session_supporting_data_account_id(test_db: AsyncSession):
|
|
"""session_supporting_data.account_id must match parent session's account_id."""
|
|
account, user = await _make_account_and_user(test_db, "sd1")
|
|
tree = await _make_tree(test_db, account, user)
|
|
session = await _make_session(test_db, account, user, tree)
|
|
|
|
sd = SessionSupportingData(
|
|
session_id=session.id,
|
|
account_id=account.id,
|
|
label="Log snippet",
|
|
data_type="text_snippet",
|
|
content="error: connection refused",
|
|
)
|
|
test_db.add(sd)
|
|
await test_db.commit()
|
|
|
|
result = await test_db.execute(
|
|
select(SessionSupportingData).where(SessionSupportingData.id == sd.id)
|
|
)
|
|
row = result.scalar_one()
|
|
assert row.account_id == account.id
|
|
```
|
|
|
|
- [ ] **Step 1.3: Run test to confirm it fails (model doesn't have account_id yet)**
|
|
|
|
```bash
|
|
cd backend && python -m pytest tests/test_phase1_migrations.py::test_session_account_id_matches_user -v --override-ini="addopts="
|
|
```
|
|
|
|
Expected: FAIL — `Session` model has no `account_id` attribute.
|
|
|
|
- [ ] **Step 1.4: Generate the Alembic migration file**
|
|
|
|
```bash
|
|
cd backend && alembic revision -m "add_account_id_core_sessions"
|
|
```
|
|
|
|
This prints a path like `alembic/versions/xxxx_add_account_id_core_sessions.py`. Open that file and replace its contents with:
|
|
|
|
```python
|
|
"""add account_id to core session tables
|
|
|
|
Revision ID: <GENERATED — keep as-is from file>
|
|
Revises: b8d2f4a6c091
|
|
Create Date: <keep as-is>
|
|
"""
|
|
from typing import Sequence, Union
|
|
from alembic import op
|
|
import sqlalchemy as sa
|
|
|
|
revision: str = '<GENERATED>'
|
|
down_revision: Union[str, None] = 'b8d2f4a6c091'
|
|
branch_labels: Union[str, Sequence[str], None] = None
|
|
depends_on: Union[str, Sequence[str], None] = None
|
|
|
|
|
|
def upgrade() -> None:
|
|
# ── Step 1: ADD COLUMN (nullable) ────────────────────────────────────────
|
|
for table in ('sessions', 'attachments', 'session_supporting_data',
|
|
'session_resolution_outputs'):
|
|
op.add_column(table, sa.Column('account_id', sa.UUID(), nullable=True))
|
|
op.create_foreign_key(
|
|
f'fk_{table}_account_id',
|
|
table, 'accounts',
|
|
['account_id'], ['id'],
|
|
ondelete='CASCADE',
|
|
)
|
|
|
|
# ── Step 2: BACKFILL ─────────────────────────────────────────────────────
|
|
# sessions: direct join to users
|
|
op.execute("""
|
|
UPDATE sessions s
|
|
SET account_id = u.account_id
|
|
FROM users u
|
|
WHERE s.user_id = u.id
|
|
AND s.account_id IS NULL
|
|
""")
|
|
|
|
# attachments: chain through sessions (now backfilled above)
|
|
op.execute("""
|
|
UPDATE attachments a
|
|
SET account_id = s.account_id
|
|
FROM sessions s
|
|
WHERE a.session_id = s.id
|
|
AND a.account_id IS NULL
|
|
""")
|
|
|
|
# session_supporting_data: same chain
|
|
op.execute("""
|
|
UPDATE session_supporting_data sd
|
|
SET account_id = s.account_id
|
|
FROM sessions s
|
|
WHERE sd.session_id = s.id
|
|
AND sd.account_id IS NULL
|
|
""")
|
|
|
|
# session_resolution_outputs: FK is to ai_sessions, not sessions
|
|
op.execute("""
|
|
UPDATE session_resolution_outputs sro
|
|
SET account_id = ai.account_id
|
|
FROM ai_sessions ai
|
|
WHERE sro.session_id = ai.id
|
|
AND sro.account_id IS NULL
|
|
""")
|
|
|
|
# ── Step 3: VERIFY zero NULLs — raises if any remain ────────────────────
|
|
for table in ('sessions', 'attachments', 'session_supporting_data',
|
|
'session_resolution_outputs'):
|
|
result = op.get_bind().execute(
|
|
sa.text(f"SELECT COUNT(*) FROM {table} WHERE account_id IS NULL")
|
|
)
|
|
count = result.scalar()
|
|
if count > 0:
|
|
raise RuntimeError(
|
|
f"ROLLBACK: {count} NULL account_id rows remain in {table}. "
|
|
f"Fix the backfill before re-running."
|
|
)
|
|
|
|
# ── Step 4: SET NOT NULL ─────────────────────────────────────────────────
|
|
for table in ('sessions', 'attachments', 'session_supporting_data',
|
|
'session_resolution_outputs'):
|
|
op.alter_column(table, 'account_id', nullable=False)
|
|
|
|
# ── Step 5: CREATE INDEX ─────────────────────────────────────────────────
|
|
for table in ('sessions', 'attachments', 'session_supporting_data',
|
|
'session_resolution_outputs'):
|
|
op.create_index(f'ix_{table}_account_id', table, ['account_id'])
|
|
|
|
|
|
def downgrade() -> None:
|
|
for table in ('sessions', 'attachments', 'session_supporting_data',
|
|
'session_resolution_outputs'):
|
|
op.drop_index(f'ix_{table}_account_id', table_name=table)
|
|
op.drop_constraint(f'fk_{table}_account_id', table, type_='foreignkey')
|
|
op.drop_column(table, 'account_id')
|
|
```
|
|
|
|
**Important:** The `down_revision` must be `b8d2f4a6c091` (current head). Do NOT change the auto-generated `revision` value at the top.
|
|
|
|
- [ ] **Step 1.5: Run the migration against the test database**
|
|
|
|
```bash
|
|
cd backend && alembic upgrade head
|
|
```
|
|
|
|
Expected: `Running upgrade b8d2f4a6c091 -> <new_hash>, add account_id to core session tables`
|
|
|
|
If it errors with "NULL rows remain", investigate the backfill SQL — there are rows whose users have NULL account_id.
|
|
|
|
- [ ] **Step 1.6: Verify zero NULLs manually**
|
|
|
|
```bash
|
|
cd backend && python -c "
|
|
import asyncio
|
|
from sqlalchemy.ext.asyncio import create_async_engine
|
|
from sqlalchemy import text
|
|
import os
|
|
|
|
async def check():
|
|
url = os.environ.get('DATABASE_URL', 'postgresql+asyncpg://postgres:postgres@localhost:5432/resolutionflow_test')
|
|
engine = create_async_engine(url)
|
|
async with engine.connect() as conn:
|
|
for t in ('sessions', 'attachments', 'session_supporting_data', 'session_resolution_outputs'):
|
|
r = await conn.execute(text(f'SELECT COUNT(*) FROM {t} WHERE account_id IS NULL'))
|
|
print(f'{t}: {r.scalar()} NULLs')
|
|
await engine.dispose()
|
|
|
|
asyncio.run(check())
|
|
"
|
|
```
|
|
|
|
Expected output (all zeros):
|
|
```
|
|
sessions: 0 NULLs
|
|
attachments: 0 NULLs
|
|
session_supporting_data: 0 NULLs
|
|
session_resolution_outputs: 0 NULLs
|
|
```
|
|
|
|
- [ ] **Step 1.7: Update SQLAlchemy models**
|
|
|
|
In `backend/app/models/session.py`, add after the `user_id` column (around line 33):
|
|
|
|
```python
|
|
account_id: Mapped[uuid.UUID] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("accounts.id", ondelete="CASCADE"),
|
|
nullable=False,
|
|
index=True,
|
|
)
|
|
```
|
|
|
|
In `backend/app/models/attachment.py`, add after the `session_id` column (around line 22):
|
|
|
|
```python
|
|
account_id: Mapped[uuid.UUID] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("accounts.id", ondelete="CASCADE"),
|
|
nullable=False,
|
|
index=True,
|
|
)
|
|
```
|
|
|
|
In `backend/app/models/supporting_data.py`, add after `session_id` (around line 16):
|
|
|
|
```python
|
|
account_id: Mapped[uuid.UUID] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("accounts.id", ondelete="CASCADE"),
|
|
nullable=False,
|
|
index=True,
|
|
)
|
|
```
|
|
|
|
In `backend/app/models/session_resolution_output.py`, add after `session_id` (around line 25):
|
|
|
|
```python
|
|
account_id: Mapped[uuid.UUID] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("accounts.id", ondelete="CASCADE"),
|
|
nullable=False,
|
|
index=True,
|
|
)
|
|
```
|
|
|
|
Each model also needs `from app.models.account import Account` added if missing from TYPE_CHECKING block.
|
|
|
|
- [ ] **Step 1.8: Run tests**
|
|
|
|
```bash
|
|
cd backend && python -m pytest tests/test_phase1_migrations.py -k "test_session or test_attachment or test_session_supporting" -v --override-ini="addopts="
|
|
```
|
|
|
|
Expected: all 3 tests PASS.
|
|
|
|
- [ ] **Step 1.9: Run full test suite**
|
|
|
|
```bash
|
|
cd backend && python -m pytest --override-ini="addopts="
|
|
```
|
|
|
|
Expected: all tests pass (no regressions from model changes).
|
|
|
|
- [ ] **Step 1.10: Commit**
|
|
|
|
```bash
|
|
git add backend/alembic/versions/*add_account_id_core_sessions* \
|
|
backend/app/models/session.py \
|
|
backend/app/models/attachment.py \
|
|
backend/app/models/supporting_data.py \
|
|
backend/app/models/session_resolution_output.py \
|
|
backend/tests/test_phase1_migrations.py
|
|
git commit -m "feat: Phase 1 Group 1 — add account_id to core session tables
|
|
|
|
Migration sequence: add nullable → backfill via user_id/ai_session chain
|
|
→ verify zero NULLs → SET NOT NULL → CREATE INDEX.
|
|
|
|
Tables: sessions, attachments, session_supporting_data,
|
|
session_resolution_outputs
|
|
|
|
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 2: Group 2 — AI & branching
|
|
|
|
**Tables:** `session_branches`, `session_handoffs`, `fork_points`, `ai_session_steps`, `ai_suggestions`
|
|
|
|
**Backfill paths:**
|
|
- `session_branches`, `session_handoffs`, `fork_points`, `ai_session_steps`: all have `session_id → ai_sessions.account_id`
|
|
- `ai_suggestions`: `user_id → users.account_id`
|
|
|
|
**Files:**
|
|
- Create: `backend/alembic/versions/<hash>_add_account_id_ai_branching.py`
|
|
- Modify: `backend/app/models/session_branch.py`, `session_handoff.py`, `fork_point.py`, `ai_session_step.py`, `ai_suggestion.py`
|
|
- Test: `backend/tests/test_phase1_migrations.py`
|
|
|
|
---
|
|
|
|
- [ ] **Step 2.1: Write the failing tests**
|
|
|
|
Append to `backend/tests/test_phase1_migrations.py`:
|
|
|
|
```python
|
|
# ── Group 2: AI & branching ───────────────────────────────────────────────────
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_session_branch_account_id_matches_ai_session(test_db: AsyncSession):
|
|
"""session_branches.account_id must match parent ai_session.account_id."""
|
|
from app.models.session_branch import SessionBranch
|
|
|
|
account, user = await _make_account_and_user(test_db, "sb1")
|
|
ai_session = AISession(
|
|
user_id=user.id,
|
|
account_id=account.id,
|
|
problem_summary="test",
|
|
problem_domain="networking",
|
|
status="active",
|
|
)
|
|
test_db.add(ai_session)
|
|
await test_db.flush()
|
|
|
|
branch = SessionBranch(
|
|
session_id=ai_session.id,
|
|
account_id=account.id,
|
|
label="Branch A",
|
|
branch_order=1,
|
|
conversation_messages=[],
|
|
)
|
|
test_db.add(branch)
|
|
await test_db.commit()
|
|
|
|
result = await test_db.execute(
|
|
select(SessionBranch).where(SessionBranch.id == branch.id)
|
|
)
|
|
row = result.scalar_one()
|
|
assert row.account_id == account.id
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ai_suggestion_account_id_matches_user(test_db: AsyncSession):
|
|
"""ai_suggestions.account_id must match the creating user's account_id."""
|
|
from app.models.ai_suggestion import AISuggestion
|
|
|
|
account, user = await _make_account_and_user(test_db, "ais1")
|
|
tree = await _make_tree(test_db, account, user)
|
|
|
|
suggestion = AISuggestion(
|
|
tree_id=tree.id,
|
|
user_id=user.id,
|
|
account_id=account.id,
|
|
action_type="add_node",
|
|
changes_json={},
|
|
status="pending",
|
|
)
|
|
test_db.add(suggestion)
|
|
await test_db.commit()
|
|
|
|
result = await test_db.execute(
|
|
select(AISuggestion).where(AISuggestion.id == suggestion.id)
|
|
)
|
|
row = result.scalar_one()
|
|
assert row.account_id == account.id
|
|
```
|
|
|
|
- [ ] **Step 2.2: Run tests to confirm they fail**
|
|
|
|
```bash
|
|
cd backend && python -m pytest tests/test_phase1_migrations.py::test_session_branch_account_id_matches_ai_session -v --override-ini="addopts="
|
|
```
|
|
|
|
Expected: FAIL — `SessionBranch` has no `account_id`.
|
|
|
|
- [ ] **Step 2.3: Generate migration**
|
|
|
|
```bash
|
|
cd backend && alembic revision -m "add_account_id_ai_branching"
|
|
```
|
|
|
|
Replace the generated file content with:
|
|
|
|
```python
|
|
"""add account_id to AI branching tables
|
|
|
|
Revision ID: <GENERATED>
|
|
Revises: <HASH_FROM_TASK_1>
|
|
Create Date: <keep>
|
|
"""
|
|
from typing import Sequence, Union
|
|
from alembic import op
|
|
import sqlalchemy as sa
|
|
|
|
revision: str = '<GENERATED>'
|
|
down_revision: Union[str, None] = '<HASH_FROM_TASK_1>'
|
|
branch_labels: Union[str, Sequence[str], None] = None
|
|
depends_on: Union[str, Sequence[str], None] = None
|
|
|
|
|
|
def upgrade() -> None:
|
|
# Step 1: ADD COLUMN (nullable)
|
|
ai_tables = ('session_branches', 'session_handoffs', 'fork_points',
|
|
'ai_session_steps')
|
|
for table in ai_tables:
|
|
op.add_column(table, sa.Column('account_id', sa.UUID(), nullable=True))
|
|
op.create_foreign_key(
|
|
f'fk_{table}_account_id', table, 'accounts',
|
|
['account_id'], ['id'], ondelete='CASCADE',
|
|
)
|
|
|
|
op.add_column('ai_suggestions', sa.Column('account_id', sa.UUID(), nullable=True))
|
|
op.create_foreign_key(
|
|
'fk_ai_suggestions_account_id', 'ai_suggestions', 'accounts',
|
|
['account_id'], ['id'], ondelete='CASCADE',
|
|
)
|
|
|
|
# Step 2: BACKFILL
|
|
# session_branches, session_handoffs, fork_points, ai_session_steps
|
|
# all FK to ai_sessions via session_id
|
|
for table in ai_tables:
|
|
op.execute(f"""
|
|
UPDATE {table} t
|
|
SET account_id = ai.account_id
|
|
FROM ai_sessions ai
|
|
WHERE t.session_id = ai.id
|
|
AND t.account_id IS NULL
|
|
""")
|
|
|
|
# ai_suggestions: user_id → users.account_id
|
|
op.execute("""
|
|
UPDATE ai_suggestions s
|
|
SET account_id = u.account_id
|
|
FROM users u
|
|
WHERE s.user_id = u.id
|
|
AND s.account_id IS NULL
|
|
""")
|
|
|
|
# Step 3: VERIFY
|
|
for table in ai_tables + ('ai_suggestions',):
|
|
result = op.get_bind().execute(
|
|
sa.text(f"SELECT COUNT(*) FROM {table} WHERE account_id IS NULL")
|
|
)
|
|
count = result.scalar()
|
|
if count > 0:
|
|
raise RuntimeError(
|
|
f"ROLLBACK: {count} NULL account_id rows in {table}."
|
|
)
|
|
|
|
# Step 4: SET NOT NULL
|
|
for table in ai_tables + ('ai_suggestions',):
|
|
op.alter_column(table, 'account_id', nullable=False)
|
|
|
|
# Step 5: CREATE INDEX
|
|
for table in ai_tables + ('ai_suggestions',):
|
|
op.create_index(f'ix_{table}_account_id', table, ['account_id'])
|
|
|
|
|
|
def downgrade() -> None:
|
|
for table in ('session_branches', 'session_handoffs', 'fork_points',
|
|
'ai_session_steps', 'ai_suggestions'):
|
|
op.drop_index(f'ix_{table}_account_id', table_name=table)
|
|
op.drop_constraint(f'fk_{table}_account_id', table, type_='foreignkey')
|
|
op.drop_column(table, 'account_id')
|
|
```
|
|
|
|
**Note:** Replace `<HASH_FROM_TASK_1>` with the actual revision hash generated in Task 1 (check the file that was created: `revision: str = '...'`).
|
|
|
|
- [ ] **Step 2.4: Run migration**
|
|
|
|
```bash
|
|
cd backend && alembic upgrade head
|
|
```
|
|
|
|
- [ ] **Step 2.5: Verify zero NULLs**
|
|
|
|
```bash
|
|
cd backend && python -c "
|
|
import asyncio
|
|
from sqlalchemy.ext.asyncio import create_async_engine
|
|
from sqlalchemy import text
|
|
import os
|
|
|
|
async def check():
|
|
url = os.environ.get('DATABASE_URL', 'postgresql+asyncpg://postgres:postgres@localhost:5432/resolutionflow_test')
|
|
engine = create_async_engine(url)
|
|
async with engine.connect() as conn:
|
|
for t in ('session_branches', 'session_handoffs', 'fork_points', 'ai_session_steps', 'ai_suggestions'):
|
|
r = await conn.execute(text(f'SELECT COUNT(*) FROM {t} WHERE account_id IS NULL'))
|
|
print(f'{t}: {r.scalar()} NULLs')
|
|
await engine.dispose()
|
|
|
|
asyncio.run(check())
|
|
"
|
|
```
|
|
|
|
Expected: all zeros.
|
|
|
|
- [ ] **Step 2.6: Update SQLAlchemy models**
|
|
|
|
In each of these files, add `account_id` as NOT NULL after the `session_id` or `user_id` column:
|
|
|
|
**`backend/app/models/session_branch.py`** — add after `session_id` column (line 37):
|
|
```python
|
|
account_id: Mapped[uuid.UUID] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("accounts.id", ondelete="CASCADE"),
|
|
nullable=False,
|
|
index=True,
|
|
)
|
|
```
|
|
|
|
**`backend/app/models/session_handoff.py`** — add after `session_id` column (line 29):
|
|
```python
|
|
account_id: Mapped[uuid.UUID] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("accounts.id", ondelete="CASCADE"),
|
|
nullable=False,
|
|
index=True,
|
|
)
|
|
```
|
|
|
|
**`backend/app/models/fork_point.py`** — add after `session_id` column (line 25):
|
|
```python
|
|
account_id: Mapped[uuid.UUID] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("accounts.id", ondelete="CASCADE"),
|
|
nullable=False,
|
|
index=True,
|
|
)
|
|
```
|
|
|
|
**`backend/app/models/ai_session_step.py`** — add after `session_id` column (line 52):
|
|
```python
|
|
account_id: Mapped[uuid.UUID] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("accounts.id", ondelete="CASCADE"),
|
|
nullable=False,
|
|
index=True,
|
|
comment="Denormalized from ai_sessions.account_id for direct tenant filtering.",
|
|
)
|
|
```
|
|
|
|
**`backend/app/models/ai_suggestion.py`** — add after `user_id` column (line 29):
|
|
```python
|
|
account_id: Mapped[uuid.UUID] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("accounts.id", ondelete="CASCADE"),
|
|
nullable=False,
|
|
index=True,
|
|
)
|
|
```
|
|
|
|
- [ ] **Step 2.7: Run tests, full suite, commit**
|
|
|
|
```bash
|
|
cd backend && python -m pytest tests/test_phase1_migrations.py -k "branch or suggestion" -v --override-ini="addopts="
|
|
cd backend && python -m pytest --override-ini="addopts="
|
|
git add backend/alembic/versions/*add_account_id_ai_branching* \
|
|
backend/app/models/session_branch.py \
|
|
backend/app/models/session_handoff.py \
|
|
backend/app/models/fork_point.py \
|
|
backend/app/models/ai_session_step.py \
|
|
backend/app/models/ai_suggestion.py \
|
|
backend/tests/test_phase1_migrations.py
|
|
git commit -m "feat: Phase 1 Group 2 — add account_id to AI branching tables
|
|
|
|
Tables: session_branches, session_handoffs, fork_points,
|
|
ai_session_steps, ai_suggestions
|
|
Backfill: session_id → ai_sessions.account_id (all except
|
|
ai_suggestions which uses user_id → users.account_id)
|
|
|
|
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 3: Group 3 — Steps & ratings
|
|
|
|
**Tables:** `step_ratings`, `step_usage_log`
|
|
|
|
**Note:** `session_ratings` ALREADY has `account_id NOT NULL` — do not touch it.
|
|
|
|
**Backfill paths:** Both use `user_id → users.account_id` (the rating user's account, per design).
|
|
|
|
**Table name:** `step_usage_log` (singular, not plural — check `StepUsageLog.__tablename__`).
|
|
|
|
**Files:**
|
|
- Create: `backend/alembic/versions/<hash>_add_account_id_step_ratings.py`
|
|
- Modify: `backend/app/models/step_library.py` (StepRating and StepUsageLog classes)
|
|
- Test: `backend/tests/test_phase1_migrations.py`
|
|
|
|
---
|
|
|
|
- [ ] **Step 3.1: Write the failing tests**
|
|
|
|
Append to `backend/tests/test_phase1_migrations.py`:
|
|
|
|
```python
|
|
# ── Group 3: Steps & ratings ──────────────────────────────────────────────────
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_step_rating_account_id_is_rater_account(test_db: AsyncSession):
|
|
"""step_ratings.account_id must be the RATER's account, not the step's account."""
|
|
from app.models.step_library import StepLibrary, StepRating
|
|
|
|
account_a, user_a = await _make_account_and_user(test_db, "sr-rater")
|
|
account_b, user_b = await _make_account_and_user(test_db, "sr-step-owner")
|
|
|
|
# Step owned by account_b
|
|
step = StepLibrary(
|
|
title="A step",
|
|
step_type="action",
|
|
content={"text": "do something"},
|
|
created_by=user_b.id,
|
|
account_id=account_b.id,
|
|
visibility="public",
|
|
)
|
|
test_db.add(step)
|
|
await test_db.flush()
|
|
|
|
# user_a (account_a) rates the step
|
|
rating = StepRating(
|
|
step_id=step.id,
|
|
user_id=user_a.id,
|
|
account_id=account_a.id, # rater's account, not step owner's
|
|
was_helpful=True,
|
|
is_verified_use=False,
|
|
is_visible=True,
|
|
)
|
|
test_db.add(rating)
|
|
await test_db.commit()
|
|
|
|
result = await test_db.execute(select(StepRating).where(StepRating.id == rating.id))
|
|
row = result.scalar_one()
|
|
assert row.account_id == account_a.id, (
|
|
f"account_id should be rater's account ({account_a.id}), got {row.account_id}"
|
|
)
|
|
```
|
|
|
|
- [ ] **Step 3.2: Run test to confirm fail**
|
|
|
|
```bash
|
|
cd backend && python -m pytest tests/test_phase1_migrations.py::test_step_rating_account_id_is_rater_account -v --override-ini="addopts="
|
|
```
|
|
|
|
Expected: FAIL — `StepRating` has no `account_id`.
|
|
|
|
- [ ] **Step 3.3: Generate migration**
|
|
|
|
```bash
|
|
cd backend && alembic revision -m "add_account_id_step_ratings"
|
|
```
|
|
|
|
Replace file content:
|
|
|
|
```python
|
|
"""add account_id to step_ratings and step_usage_log
|
|
|
|
Revision ID: <GENERATED>
|
|
Revises: <HASH_FROM_TASK_2>
|
|
Create Date: <keep>
|
|
"""
|
|
from typing import Sequence, Union
|
|
from alembic import op
|
|
import sqlalchemy as sa
|
|
|
|
revision: str = '<GENERATED>'
|
|
down_revision: Union[str, None] = '<HASH_FROM_TASK_2>'
|
|
branch_labels: Union[str, Sequence[str], None] = None
|
|
depends_on: Union[str, Sequence[str], None] = None
|
|
|
|
|
|
def upgrade() -> None:
|
|
for table in ('step_ratings', 'step_usage_log'):
|
|
op.add_column(table, sa.Column('account_id', sa.UUID(), nullable=True))
|
|
op.create_foreign_key(
|
|
f'fk_{table}_account_id', table, 'accounts',
|
|
['account_id'], ['id'], ondelete='CASCADE',
|
|
)
|
|
# Backfill: from the RATER/LOGGER user's account (not the step's account)
|
|
op.execute(f"""
|
|
UPDATE {table} t
|
|
SET account_id = u.account_id
|
|
FROM users u
|
|
WHERE t.user_id = u.id
|
|
AND t.account_id IS NULL
|
|
""")
|
|
result = op.get_bind().execute(
|
|
sa.text(f"SELECT COUNT(*) FROM {table} WHERE account_id IS NULL")
|
|
)
|
|
count = result.scalar()
|
|
if count > 0:
|
|
raise RuntimeError(f"ROLLBACK: {count} NULL account_id rows in {table}.")
|
|
op.alter_column(table, 'account_id', nullable=False)
|
|
op.create_index(f'ix_{table}_account_id', table, ['account_id'])
|
|
|
|
|
|
def downgrade() -> None:
|
|
for table in ('step_ratings', 'step_usage_log'):
|
|
op.drop_index(f'ix_{table}_account_id', table_name=table)
|
|
op.drop_constraint(f'fk_{table}_account_id', table, type_='foreignkey')
|
|
op.drop_column(table, 'account_id')
|
|
```
|
|
|
|
- [ ] **Step 3.4: Run migration and verify**
|
|
|
|
```bash
|
|
cd backend && alembic upgrade head
|
|
```
|
|
|
|
```bash
|
|
cd backend && python -c "
|
|
import asyncio
|
|
from sqlalchemy.ext.asyncio import create_async_engine
|
|
from sqlalchemy import text
|
|
import os
|
|
|
|
async def check():
|
|
url = os.environ.get('DATABASE_URL', 'postgresql+asyncpg://postgres:postgres@localhost:5432/resolutionflow_test')
|
|
engine = create_async_engine(url)
|
|
async with engine.connect() as conn:
|
|
for t in ('step_ratings', 'step_usage_log'):
|
|
r = await conn.execute(text(f'SELECT COUNT(*) FROM {t} WHERE account_id IS NULL'))
|
|
print(f'{t}: {r.scalar()} NULLs')
|
|
await engine.dispose()
|
|
|
|
asyncio.run(check())
|
|
"
|
|
```
|
|
|
|
- [ ] **Step 3.5: Update SQLAlchemy models in `backend/app/models/step_library.py`**
|
|
|
|
In the `StepRating` class (starts around line 125), add after the `user_id` column:
|
|
|
|
```python
|
|
account_id: Mapped[uuid.UUID] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("accounts.id", ondelete="CASCADE"),
|
|
nullable=False,
|
|
index=True,
|
|
comment="Account of the RATER (not the step owner).",
|
|
)
|
|
```
|
|
|
|
In the `StepUsageLog` class (starts around line 172), add after the `user_id` column:
|
|
|
|
```python
|
|
account_id: Mapped[uuid.UUID] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("accounts.id", ondelete="CASCADE"),
|
|
nullable=False,
|
|
index=True,
|
|
comment="Account of the user who logged this usage.",
|
|
)
|
|
```
|
|
|
|
- [ ] **Step 3.6: Run tests, full suite, commit**
|
|
|
|
```bash
|
|
cd backend && python -m pytest tests/test_phase1_migrations.py::test_step_rating_account_id_is_rater_account -v --override-ini="addopts="
|
|
cd backend && python -m pytest --override-ini="addopts="
|
|
git add backend/alembic/versions/*add_account_id_step_ratings* \
|
|
backend/app/models/step_library.py \
|
|
backend/tests/test_phase1_migrations.py
|
|
git commit -m "feat: Phase 1 Group 3 — add account_id to step_ratings and step_usage_log
|
|
|
|
Backfill from rater/user's account_id (not the step's account_id).
|
|
This is an explicit design decision — step rating data is attributed
|
|
to the account that performed the rating.
|
|
|
|
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 4: Group 4 — User personalization
|
|
|
|
**Tables:** `user_folders`, `user_pinned_trees`
|
|
|
|
**Backfill:** `user_id → users.account_id`
|
|
|
|
**Files:**
|
|
- Create: `backend/alembic/versions/<hash>_add_account_id_user_personalization.py`
|
|
- Modify: `backend/app/models/folder.py`, `backend/app/models/user_pinned_tree.py`
|
|
- Test: `backend/tests/test_phase1_migrations.py`
|
|
|
|
---
|
|
|
|
- [ ] **Step 4.1: Write failing tests**
|
|
|
|
Append to `backend/tests/test_phase1_migrations.py`:
|
|
|
|
```python
|
|
# ── Group 4: User personalization ────────────────────────────────────────────
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_user_folder_account_id_matches_user(test_db: AsyncSession):
|
|
"""user_folders.account_id must match the owning user's account_id."""
|
|
from app.models.folder import UserFolder
|
|
|
|
account, user = await _make_account_and_user(test_db, "uf1")
|
|
folder = UserFolder(
|
|
user_id=user.id,
|
|
account_id=account.id,
|
|
name="My Folder",
|
|
color="#6366f1",
|
|
icon="folder",
|
|
display_order=0,
|
|
)
|
|
test_db.add(folder)
|
|
await test_db.commit()
|
|
|
|
result = await test_db.execute(select(UserFolder).where(UserFolder.id == folder.id))
|
|
row = result.scalar_one()
|
|
assert row.account_id == account.id
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_user_pinned_tree_account_id_matches_user(test_db: AsyncSession):
|
|
"""user_pinned_trees.account_id must match the pinning user's account_id."""
|
|
from app.models.user_pinned_tree import UserPinnedTree
|
|
|
|
account, user = await _make_account_and_user(test_db, "pt1")
|
|
tree = await _make_tree(test_db, account, user)
|
|
pin = UserPinnedTree(
|
|
user_id=user.id,
|
|
tree_id=tree.id,
|
|
account_id=account.id,
|
|
display_order=0,
|
|
)
|
|
test_db.add(pin)
|
|
await test_db.commit()
|
|
|
|
result = await test_db.execute(select(UserPinnedTree).where(UserPinnedTree.id == pin.id))
|
|
row = result.scalar_one()
|
|
assert row.account_id == account.id
|
|
```
|
|
|
|
- [ ] **Step 4.2: Generate migration**
|
|
|
|
```bash
|
|
cd backend && alembic revision -m "add_account_id_user_personalization"
|
|
```
|
|
|
|
```python
|
|
"""add account_id to user personalization tables
|
|
|
|
Revision ID: <GENERATED>
|
|
Revises: <HASH_FROM_TASK_3>
|
|
Create Date: <keep>
|
|
"""
|
|
from typing import Sequence, Union
|
|
from alembic import op
|
|
import sqlalchemy as sa
|
|
|
|
revision: str = '<GENERATED>'
|
|
down_revision: Union[str, None] = '<HASH_FROM_TASK_3>'
|
|
branch_labels: Union[str, Sequence[str], None] = None
|
|
depends_on: Union[str, Sequence[str], None] = None
|
|
|
|
|
|
def upgrade() -> None:
|
|
for table in ('user_folders', 'user_pinned_trees'):
|
|
op.add_column(table, sa.Column('account_id', sa.UUID(), nullable=True))
|
|
op.create_foreign_key(
|
|
f'fk_{table}_account_id', table, 'accounts',
|
|
['account_id'], ['id'], ondelete='CASCADE',
|
|
)
|
|
op.execute(f"""
|
|
UPDATE {table} t
|
|
SET account_id = u.account_id
|
|
FROM users u
|
|
WHERE t.user_id = u.id
|
|
AND t.account_id IS NULL
|
|
""")
|
|
result = op.get_bind().execute(
|
|
sa.text(f"SELECT COUNT(*) FROM {table} WHERE account_id IS NULL")
|
|
)
|
|
count = result.scalar()
|
|
if count > 0:
|
|
raise RuntimeError(f"ROLLBACK: {count} NULL account_id rows in {table}.")
|
|
op.alter_column(table, 'account_id', nullable=False)
|
|
op.create_index(f'ix_{table}_account_id', table, ['account_id'])
|
|
|
|
|
|
def downgrade() -> None:
|
|
for table in ('user_folders', 'user_pinned_trees'):
|
|
op.drop_index(f'ix_{table}_account_id', table_name=table)
|
|
op.drop_constraint(f'fk_{table}_account_id', table, type_='foreignkey')
|
|
op.drop_column(table, 'account_id')
|
|
```
|
|
|
|
- [ ] **Step 4.3: Run migration, verify, update models, test, commit**
|
|
|
|
```bash
|
|
cd backend && alembic upgrade head
|
|
```
|
|
|
|
In `backend/app/models/folder.py`, add to `UserFolder` after `user_id`:
|
|
```python
|
|
account_id: Mapped[uuid.UUID] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("accounts.id", ondelete="CASCADE"),
|
|
nullable=False,
|
|
index=True,
|
|
)
|
|
```
|
|
|
|
In `backend/app/models/user_pinned_tree.py`, add after `user_id`:
|
|
```python
|
|
account_id: Mapped[uuid.UUID] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("accounts.id", ondelete="CASCADE"),
|
|
nullable=False,
|
|
index=True,
|
|
)
|
|
```
|
|
|
|
```bash
|
|
cd backend && python -m pytest tests/test_phase1_migrations.py -k "folder or pinned" -v --override-ini="addopts="
|
|
cd backend && python -m pytest --override-ini="addopts="
|
|
git add backend/alembic/versions/*add_account_id_user_personalization* \
|
|
backend/app/models/folder.py \
|
|
backend/app/models/user_pinned_tree.py \
|
|
backend/tests/test_phase1_migrations.py
|
|
git commit -m "feat: Phase 1 Group 4 — add account_id to user_folders and user_pinned_trees
|
|
|
|
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 5: Group 5 — PSA & notifications
|
|
|
|
**Tables:** `psa_post_log`, `psa_member_mappings`, `notification_logs`
|
|
|
|
**Backfill paths:**
|
|
- `psa_post_log`: `psa_connection_id → psa_connections.account_id`. If `psa_connection_id` is NULL, fall back to `posted_by → users.account_id`.
|
|
- `psa_member_mappings`: `psa_connection_id → psa_connections.account_id`
|
|
- `notification_logs`: `notification_config_id → notification_configs.account_id`
|
|
|
|
**Pre-check:** `psa_connections.account_id` is already NOT NULL ✓. `notification_configs.account_id` must also be NOT NULL — verify before running.
|
|
|
|
**Files:**
|
|
- Create: `backend/alembic/versions/<hash>_add_account_id_psa_notifications.py`
|
|
- Modify: `backend/app/models/psa_post_log.py`, `psa_member_mapping.py`, `notification_log.py`
|
|
- Test: `backend/tests/test_phase1_migrations.py`
|
|
|
|
---
|
|
|
|
- [ ] **Step 5.1: Write failing tests**
|
|
|
|
Append to `backend/tests/test_phase1_migrations.py`:
|
|
|
|
```python
|
|
# ── Group 5: PSA & notifications ─────────────────────────────────────────────
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_psa_member_mapping_account_id_matches_connection(test_db: AsyncSession):
|
|
"""psa_member_mappings.account_id must match psa_connection's account_id."""
|
|
from app.models.psa_connection import PsaConnection
|
|
from app.models.psa_member_mapping import PsaMemberMapping
|
|
|
|
account, user = await _make_account_and_user(test_db, "psa1")
|
|
conn = PsaConnection(
|
|
account_id=account.id,
|
|
provider="connectwise",
|
|
display_name="Test CW",
|
|
site_url="https://cw.example.com",
|
|
company_id="TEST",
|
|
credentials_encrypted="placeholder",
|
|
)
|
|
test_db.add(conn)
|
|
await test_db.flush()
|
|
|
|
mapping = PsaMemberMapping(
|
|
psa_connection_id=conn.id,
|
|
user_id=user.id,
|
|
account_id=account.id,
|
|
external_member_id="cw-123",
|
|
external_member_name="Test User",
|
|
matched_by="manual_admin",
|
|
)
|
|
test_db.add(mapping)
|
|
await test_db.commit()
|
|
|
|
result = await test_db.execute(
|
|
select(PsaMemberMapping).where(PsaMemberMapping.id == mapping.id)
|
|
)
|
|
row = result.scalar_one()
|
|
assert row.account_id == account.id
|
|
```
|
|
|
|
- [ ] **Step 5.2: Generate migration**
|
|
|
|
```bash
|
|
cd backend && alembic revision -m "add_account_id_psa_notifications"
|
|
```
|
|
|
|
```python
|
|
"""add account_id to PSA and notification tables
|
|
|
|
Revision ID: <GENERATED>
|
|
Revises: <HASH_FROM_TASK_4>
|
|
Create Date: <keep>
|
|
"""
|
|
from typing import Sequence, Union
|
|
from alembic import op
|
|
import sqlalchemy as sa
|
|
|
|
revision: str = '<GENERATED>'
|
|
down_revision: Union[str, None] = '<HASH_FROM_TASK_4>'
|
|
branch_labels: Union[str, Sequence[str], None] = None
|
|
depends_on: Union[str, Sequence[str], None] = None
|
|
|
|
|
|
def upgrade() -> None:
|
|
# Step 1: ADD COLUMN
|
|
for table in ('psa_post_log', 'psa_member_mappings', 'notification_logs'):
|
|
op.add_column(table, sa.Column('account_id', sa.UUID(), nullable=True))
|
|
op.create_foreign_key(
|
|
f'fk_{table}_account_id', table, 'accounts',
|
|
['account_id'], ['id'], ondelete='CASCADE',
|
|
)
|
|
|
|
# Step 2: BACKFILL
|
|
# psa_post_log: prefer psa_connection → fallback to posted_by user
|
|
op.execute("""
|
|
UPDATE psa_post_log ppl
|
|
SET account_id = COALESCE(pc.account_id, u.account_id)
|
|
FROM users u
|
|
LEFT JOIN psa_connections pc ON pc.id = ppl.psa_connection_id
|
|
WHERE ppl.posted_by = u.id
|
|
AND ppl.account_id IS NULL
|
|
""")
|
|
|
|
# psa_member_mappings: via psa_connection
|
|
op.execute("""
|
|
UPDATE psa_member_mappings pmm
|
|
SET account_id = pc.account_id
|
|
FROM psa_connections pc
|
|
WHERE pmm.psa_connection_id = pc.id
|
|
AND pmm.account_id IS NULL
|
|
""")
|
|
|
|
# notification_logs: via notification_config
|
|
op.execute("""
|
|
UPDATE notification_logs nl
|
|
SET account_id = nc.account_id
|
|
FROM notification_configs nc
|
|
WHERE nl.notification_config_id = nc.id
|
|
AND nl.account_id IS NULL
|
|
""")
|
|
|
|
# Step 3: VERIFY
|
|
for table in ('psa_post_log', 'psa_member_mappings', 'notification_logs'):
|
|
result = op.get_bind().execute(
|
|
sa.text(f"SELECT COUNT(*) FROM {table} WHERE account_id IS NULL")
|
|
)
|
|
count = result.scalar()
|
|
if count > 0:
|
|
raise RuntimeError(f"ROLLBACK: {count} NULL account_id rows in {table}.")
|
|
|
|
# Step 4: SET NOT NULL
|
|
for table in ('psa_post_log', 'psa_member_mappings', 'notification_logs'):
|
|
op.alter_column(table, 'account_id', nullable=False)
|
|
|
|
# Step 5: CREATE INDEX
|
|
for table in ('psa_post_log', 'psa_member_mappings', 'notification_logs'):
|
|
op.create_index(f'ix_{table}_account_id', table, ['account_id'])
|
|
|
|
|
|
def downgrade() -> None:
|
|
for table in ('psa_post_log', 'psa_member_mappings', 'notification_logs'):
|
|
op.drop_index(f'ix_{table}_account_id', table_name=table)
|
|
op.drop_constraint(f'fk_{table}_account_id', table, type_='foreignkey')
|
|
op.drop_column(table, 'account_id')
|
|
```
|
|
|
|
- [ ] **Step 5.3: Run migration, verify, update models, test, commit**
|
|
|
|
```bash
|
|
cd backend && alembic upgrade head
|
|
```
|
|
|
|
Add `account_id` (NOT NULL, FK to accounts) to:
|
|
- `backend/app/models/psa_post_log.py` — after `ai_session_id` column
|
|
- `backend/app/models/psa_member_mapping.py` — after `psa_connection_id` column
|
|
- `backend/app/models/notification_log.py` — after `notification_config_id` column
|
|
|
|
Each follows the same pattern:
|
|
```python
|
|
account_id: Mapped[uuid.UUID] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("accounts.id", ondelete="CASCADE"),
|
|
nullable=False,
|
|
index=True,
|
|
)
|
|
```
|
|
|
|
```bash
|
|
cd backend && python -m pytest tests/test_phase1_migrations.py -k "psa" -v --override-ini="addopts="
|
|
cd backend && python -m pytest --override-ini="addopts="
|
|
git add backend/alembic/versions/*add_account_id_psa_notifications* \
|
|
backend/app/models/psa_post_log.py \
|
|
backend/app/models/psa_member_mapping.py \
|
|
backend/app/models/notification_log.py \
|
|
backend/tests/test_phase1_migrations.py
|
|
git commit -m "feat: Phase 1 Group 5 — add account_id to PSA and notification tables
|
|
|
|
psa_post_log: backfill via psa_connection, fallback to posted_by user
|
|
psa_member_mappings: backfill via psa_connection
|
|
notification_logs: backfill via notification_config
|
|
|
|
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 6: Group 6 — Maintenance
|
|
|
|
**Table:** `maintenance_schedules`
|
|
|
|
**Backfill path:** `tree_id → trees.account_id`. Note: `trees.account_id` is still nullable at this point. Any maintenance schedule whose tree has `account_id=NULL` (i.e., is_default=TRUE) will not backfill. Fall back to `created_by → users.account_id` for those rows.
|
|
|
|
**Files:**
|
|
- Create: `backend/alembic/versions/<hash>_add_account_id_maintenance.py`
|
|
- Modify: `backend/app/models/maintenance_schedule.py`
|
|
- Test: `backend/tests/test_phase1_migrations.py`
|
|
|
|
---
|
|
|
|
- [ ] **Step 6.1: Write failing test**
|
|
|
|
Append to `backend/tests/test_phase1_migrations.py`:
|
|
|
|
```python
|
|
# ── Group 6: Maintenance ──────────────────────────────────────────────────────
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_maintenance_schedule_account_id_matches_tree(test_db: AsyncSession):
|
|
"""maintenance_schedules.account_id must match the tree's account_id."""
|
|
from app.models.maintenance_schedule import MaintenanceSchedule
|
|
|
|
account, user = await _make_account_and_user(test_db, "ms1")
|
|
tree = Tree(
|
|
name="Maintenance Flow",
|
|
account_id=account.id,
|
|
author_id=user.id,
|
|
visibility="team",
|
|
tree_type="maintenance",
|
|
tree_structure={"id": "root", "type": "start", "children": []},
|
|
is_active=True,
|
|
status="published",
|
|
)
|
|
test_db.add(tree)
|
|
await test_db.flush()
|
|
|
|
schedule = MaintenanceSchedule(
|
|
tree_id=tree.id,
|
|
account_id=account.id,
|
|
created_by=user.id,
|
|
cron_expression="0 9 * * 1",
|
|
timezone="UTC",
|
|
is_active=True,
|
|
)
|
|
test_db.add(schedule)
|
|
await test_db.commit()
|
|
|
|
result = await test_db.execute(
|
|
select(MaintenanceSchedule).where(MaintenanceSchedule.id == schedule.id)
|
|
)
|
|
row = result.scalar_one()
|
|
assert row.account_id == account.id
|
|
```
|
|
|
|
- [ ] **Step 6.2: Generate migration**
|
|
|
|
```bash
|
|
cd backend && alembic revision -m "add_account_id_maintenance"
|
|
```
|
|
|
|
```python
|
|
"""add account_id to maintenance_schedules
|
|
|
|
Revision ID: <GENERATED>
|
|
Revises: <HASH_FROM_TASK_5>
|
|
Create Date: <keep>
|
|
"""
|
|
from typing import Sequence, Union
|
|
from alembic import op
|
|
import sqlalchemy as sa
|
|
|
|
revision: str = '<GENERATED>'
|
|
down_revision: Union[str, None] = '<HASH_FROM_TASK_5>'
|
|
branch_labels: Union[str, Sequence[str], None] = None
|
|
depends_on: Union[str, Sequence[str], None] = None
|
|
|
|
|
|
def upgrade() -> None:
|
|
op.add_column('maintenance_schedules',
|
|
sa.Column('account_id', sa.UUID(), nullable=True))
|
|
op.create_foreign_key(
|
|
'fk_maintenance_schedules_account_id', 'maintenance_schedules', 'accounts',
|
|
['account_id'], ['id'], ondelete='CASCADE',
|
|
)
|
|
|
|
# Primary: tree_id → trees.account_id
|
|
op.execute("""
|
|
UPDATE maintenance_schedules ms
|
|
SET account_id = t.account_id
|
|
FROM trees t
|
|
WHERE ms.tree_id = t.id
|
|
AND t.account_id IS NOT NULL
|
|
AND ms.account_id IS NULL
|
|
""")
|
|
|
|
# Fallback: created_by → users.account_id (for is_default trees with NULL account_id)
|
|
op.execute("""
|
|
UPDATE maintenance_schedules ms
|
|
SET account_id = u.account_id
|
|
FROM users u
|
|
WHERE ms.created_by = u.id
|
|
AND u.account_id IS NOT NULL
|
|
AND ms.account_id IS NULL
|
|
""")
|
|
|
|
result = op.get_bind().execute(
|
|
sa.text("SELECT COUNT(*) FROM maintenance_schedules WHERE account_id IS NULL")
|
|
)
|
|
count = result.scalar()
|
|
if count > 0:
|
|
raise RuntimeError(
|
|
f"ROLLBACK: {count} maintenance_schedules rows have NULL account_id. "
|
|
"Check if created_by is NULL — those rows need manual resolution."
|
|
)
|
|
|
|
op.alter_column('maintenance_schedules', 'account_id', nullable=False)
|
|
op.create_index('ix_maintenance_schedules_account_id', 'maintenance_schedules', ['account_id'])
|
|
|
|
|
|
def downgrade() -> None:
|
|
op.drop_index('ix_maintenance_schedules_account_id', table_name='maintenance_schedules')
|
|
op.drop_constraint('fk_maintenance_schedules_account_id', 'maintenance_schedules', type_='foreignkey')
|
|
op.drop_column('maintenance_schedules', 'account_id')
|
|
```
|
|
|
|
- [ ] **Step 6.3: Run migration, verify, update model, test, commit**
|
|
|
|
```bash
|
|
cd backend && alembic upgrade head
|
|
```
|
|
|
|
In `backend/app/models/maintenance_schedule.py`, add after `created_by`:
|
|
```python
|
|
account_id: Mapped[uuid.UUID] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("accounts.id", ondelete="CASCADE"),
|
|
nullable=False,
|
|
index=True,
|
|
)
|
|
```
|
|
|
|
```bash
|
|
cd backend && python -m pytest tests/test_phase1_migrations.py::test_maintenance_schedule_account_id_matches_tree -v --override-ini="addopts="
|
|
cd backend && python -m pytest --override-ini="addopts="
|
|
git add backend/alembic/versions/*add_account_id_maintenance* \
|
|
backend/app/models/maintenance_schedule.py \
|
|
backend/tests/test_phase1_migrations.py
|
|
git commit -m "feat: Phase 1 Group 6 — add account_id to maintenance_schedules
|
|
|
|
Primary backfill: tree_id → trees.account_id
|
|
Fallback: created_by → users.account_id (for is_default tree rows)
|
|
|
|
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 7: Group 7 — Legacy team_id tables
|
|
|
|
**Tables:** `script_builder_sessions`, `script_templates`, `script_generations`
|
|
|
|
**Backfill paths:**
|
|
- `script_builder_sessions`: `user_id → users.account_id`
|
|
- `script_templates`: `created_by → users.account_id` (`created_by` is nullable — handle with fallback)
|
|
- `script_generations`: `user_id → users.account_id`
|
|
|
|
**Important:** Do NOT drop `team_id` — keep it until all application code is updated.
|
|
|
|
**Files:**
|
|
- Create: `backend/alembic/versions/<hash>_add_account_id_script_tables.py`
|
|
- Modify: `backend/app/models/script_builder_session.py`, `backend/app/models/script_template.py` (ScriptTemplate and ScriptGeneration)
|
|
- Test: `backend/tests/test_phase1_migrations.py`
|
|
|
|
---
|
|
|
|
- [ ] **Step 7.1: Write failing tests**
|
|
|
|
Append to `backend/tests/test_phase1_migrations.py`:
|
|
|
|
```python
|
|
# ── Group 7: Legacy team_id tables ───────────────────────────────────────────
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_script_builder_session_account_id(test_db: AsyncSession):
|
|
"""script_builder_sessions.account_id must match user's account_id."""
|
|
from app.models.script_builder_session import ScriptBuilderSession
|
|
|
|
account, user = await _make_account_and_user(test_db, "sbs1")
|
|
sbs = ScriptBuilderSession(
|
|
user_id=user.id,
|
|
account_id=account.id,
|
|
language="powershell",
|
|
)
|
|
test_db.add(sbs)
|
|
await test_db.commit()
|
|
|
|
result = await test_db.execute(
|
|
select(ScriptBuilderSession).where(ScriptBuilderSession.id == sbs.id)
|
|
)
|
|
row = result.scalar_one()
|
|
assert row.account_id == account.id
|
|
```
|
|
|
|
- [ ] **Step 7.2: Generate migration**
|
|
|
|
```bash
|
|
cd backend && alembic revision -m "add_account_id_script_tables"
|
|
```
|
|
|
|
```python
|
|
"""add account_id to script_builder_sessions, script_templates, script_generations
|
|
|
|
Revision ID: <GENERATED>
|
|
Revises: <HASH_FROM_TASK_6>
|
|
Create Date: <keep>
|
|
"""
|
|
from typing import Sequence, Union
|
|
from alembic import op
|
|
import sqlalchemy as sa
|
|
|
|
revision: str = '<GENERATED>'
|
|
down_revision: Union[str, None] = '<HASH_FROM_TASK_6>'
|
|
branch_labels: Union[str, Sequence[str], None] = None
|
|
depends_on: Union[str, Sequence[str], None] = None
|
|
|
|
|
|
def upgrade() -> None:
|
|
for table in ('script_builder_sessions', 'script_templates', 'script_generations'):
|
|
op.add_column(table, sa.Column('account_id', sa.UUID(), nullable=True))
|
|
op.create_foreign_key(
|
|
f'fk_{table}_account_id', table, 'accounts',
|
|
['account_id'], ['id'], ondelete='CASCADE',
|
|
)
|
|
|
|
# script_builder_sessions: user_id → users.account_id
|
|
op.execute("""
|
|
UPDATE script_builder_sessions sbs
|
|
SET account_id = u.account_id
|
|
FROM users u
|
|
WHERE sbs.user_id = u.id
|
|
AND sbs.account_id IS NULL
|
|
""")
|
|
|
|
# script_templates: created_by → users.account_id
|
|
# created_by is nullable, so left join + only set where not null
|
|
op.execute("""
|
|
UPDATE script_templates st
|
|
SET account_id = u.account_id
|
|
FROM users u
|
|
WHERE st.created_by = u.id
|
|
AND st.account_id IS NULL
|
|
""")
|
|
# Fallback for script_templates with NULL created_by: team_id → team admin user
|
|
op.execute("""
|
|
UPDATE script_templates st
|
|
SET account_id = u.account_id
|
|
FROM users u
|
|
WHERE u.team_id = st.team_id
|
|
AND u.is_team_admin = TRUE
|
|
AND st.account_id IS NULL
|
|
AND EXISTS (SELECT 1 FROM users u2 WHERE u2.team_id = st.team_id)
|
|
""")
|
|
|
|
# script_generations: user_id → users.account_id
|
|
op.execute("""
|
|
UPDATE script_generations sg
|
|
SET account_id = u.account_id
|
|
FROM users u
|
|
WHERE sg.user_id = u.id
|
|
AND sg.account_id IS NULL
|
|
""")
|
|
|
|
# VERIFY
|
|
for table in ('script_builder_sessions', 'script_templates', 'script_generations'):
|
|
result = op.get_bind().execute(
|
|
sa.text(f"SELECT COUNT(*) FROM {table} WHERE account_id IS NULL")
|
|
)
|
|
count = result.scalar()
|
|
if count > 0:
|
|
raise RuntimeError(f"ROLLBACK: {count} NULL account_id rows in {table}.")
|
|
|
|
for table in ('script_builder_sessions', 'script_templates', 'script_generations'):
|
|
op.alter_column(table, 'account_id', nullable=False)
|
|
op.create_index(f'ix_{table}_account_id', table, ['account_id'])
|
|
|
|
|
|
def downgrade() -> None:
|
|
for table in ('script_builder_sessions', 'script_templates', 'script_generations'):
|
|
op.drop_index(f'ix_{table}_account_id', table_name=table)
|
|
op.drop_constraint(f'fk_{table}_account_id', table, type_='foreignkey')
|
|
op.drop_column(table, 'account_id')
|
|
```
|
|
|
|
- [ ] **Step 7.3: Run migration, verify, update models, test, commit**
|
|
|
|
```bash
|
|
cd backend && alembic upgrade head
|
|
```
|
|
|
|
In `backend/app/models/script_builder_session.py`, add after `user_id`:
|
|
```python
|
|
account_id: Mapped[uuid.UUID] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("accounts.id", ondelete="CASCADE"),
|
|
nullable=False,
|
|
index=True,
|
|
)
|
|
```
|
|
|
|
In `backend/app/models/script_template.py`:
|
|
- In `ScriptTemplate` class, add after `team_id`:
|
|
```python
|
|
account_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("accounts.id", ondelete="CASCADE"),
|
|
nullable=False,
|
|
index=True,
|
|
)
|
|
```
|
|
- In `ScriptGeneration` class, add after `user_id`:
|
|
```python
|
|
account_id: Mapped[uuid.UUID] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("accounts.id", ondelete="CASCADE"),
|
|
nullable=False,
|
|
index=True,
|
|
)
|
|
```
|
|
|
|
```bash
|
|
cd backend && python -m pytest tests/test_phase1_migrations.py::test_script_builder_session_account_id -v --override-ini="addopts="
|
|
cd backend && python -m pytest --override-ini="addopts="
|
|
git add backend/alembic/versions/*add_account_id_script_tables* \
|
|
backend/app/models/script_builder_session.py \
|
|
backend/app/models/script_template.py \
|
|
backend/tests/test_phase1_migrations.py
|
|
git commit -m "feat: Phase 1 Group 7 — add account_id to script tables (keep team_id)
|
|
|
|
team_id is kept in all three tables — drop deferred until app code
|
|
is fully migrated off team_id references.
|
|
|
|
Tables: script_builder_sessions, script_templates, script_generations
|
|
Backfill: user_id/created_by → users.account_id
|
|
|
|
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 8: Group 8 — TargetList
|
|
|
|
**Table:** `target_lists`
|
|
|
|
**Backfill path:** `team_id → users WHERE is_team_admin=TRUE → account_id`
|
|
|
|
**Context:** Zero rows in production (confirmed 2026-04-09). The migration is schema-only in practice but must be correct for any future rows. The `team_id` FK to `teams` is NOT NULL — keep it. Do NOT drop it.
|
|
|
|
**Files:**
|
|
- Create: `backend/alembic/versions/<hash>_add_account_id_target_lists.py`
|
|
- Modify: `backend/app/models/target_list.py`
|
|
- Test: `backend/tests/test_phase1_migrations.py`
|
|
|
|
---
|
|
|
|
- [ ] **Step 8.1: Write failing test**
|
|
|
|
Append to `backend/tests/test_phase1_migrations.py`:
|
|
|
|
```python
|
|
# ── Group 8: TargetList ────────────────────────────────────────────────────────
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_target_list_account_id_from_team_admin(test_db: AsyncSession):
|
|
"""target_lists.account_id must be set to the team admin's account_id."""
|
|
from app.models.target_list import TargetList
|
|
from app.models.team import Team
|
|
|
|
account, user = await _make_account_and_user(test_db, "tl1")
|
|
# Make user a team admin
|
|
team = Team(name=f"Team {uuid.uuid4().hex[:6]}")
|
|
test_db.add(team)
|
|
await test_db.flush()
|
|
|
|
user.team_id = team.id
|
|
user.is_team_admin = True
|
|
await test_db.flush()
|
|
|
|
target_list = TargetList(
|
|
team_id=team.id,
|
|
account_id=account.id,
|
|
created_by=user.id,
|
|
name="Server Targets",
|
|
targets=[{"label": "SRV-01"}],
|
|
)
|
|
test_db.add(target_list)
|
|
await test_db.commit()
|
|
|
|
result = await test_db.execute(
|
|
select(TargetList).where(TargetList.id == target_list.id)
|
|
)
|
|
row = result.scalar_one()
|
|
assert row.account_id == account.id
|
|
```
|
|
|
|
- [ ] **Step 8.2: Generate migration**
|
|
|
|
```bash
|
|
cd backend && alembic revision -m "add_account_id_target_lists"
|
|
```
|
|
|
|
```python
|
|
"""add account_id to target_lists (keep team_id)
|
|
|
|
Revision ID: <GENERATED>
|
|
Revises: <HASH_FROM_TASK_7>
|
|
Create Date: <keep>
|
|
"""
|
|
from typing import Sequence, Union
|
|
from alembic import op
|
|
import sqlalchemy as sa
|
|
|
|
revision: str = '<GENERATED>'
|
|
down_revision: Union[str, None] = '<HASH_FROM_TASK_7>'
|
|
branch_labels: Union[str, Sequence[str], None] = None
|
|
depends_on: Union[str, Sequence[str], None] = None
|
|
|
|
|
|
def upgrade() -> None:
|
|
op.add_column('target_lists', sa.Column('account_id', sa.UUID(), nullable=True))
|
|
op.create_foreign_key(
|
|
'fk_target_lists_account_id', 'target_lists', 'accounts',
|
|
['account_id'], ['id'], ondelete='CASCADE',
|
|
)
|
|
|
|
# Backfill: team_id → team admin user → account_id
|
|
# If any row cannot be backfilled (no team admin found) → ROLLBACK
|
|
op.execute("""
|
|
UPDATE target_lists tl
|
|
SET account_id = u.account_id
|
|
FROM users u
|
|
WHERE u.team_id = tl.team_id
|
|
AND u.is_team_admin = TRUE
|
|
AND u.account_id IS NOT NULL
|
|
AND tl.account_id IS NULL
|
|
""")
|
|
|
|
# Secondary fallback: created_by user
|
|
op.execute("""
|
|
UPDATE target_lists tl
|
|
SET account_id = u.account_id
|
|
FROM users u
|
|
WHERE tl.created_by = u.id
|
|
AND u.account_id IS NOT NULL
|
|
AND tl.account_id IS NULL
|
|
""")
|
|
|
|
result = op.get_bind().execute(
|
|
sa.text("SELECT COUNT(*) FROM target_lists WHERE account_id IS NULL")
|
|
)
|
|
count = result.scalar()
|
|
if count > 0:
|
|
raise RuntimeError(
|
|
f"ROLLBACK: {count} target_lists rows have NULL account_id. "
|
|
"No team admin found for these teams. Resolve before re-running."
|
|
)
|
|
|
|
op.alter_column('target_lists', 'account_id', nullable=False)
|
|
op.create_index('ix_target_lists_account_id', 'target_lists', ['account_id'])
|
|
|
|
|
|
def downgrade() -> None:
|
|
op.drop_index('ix_target_lists_account_id', table_name='target_lists')
|
|
op.drop_constraint('fk_target_lists_account_id', 'target_lists', type_='foreignkey')
|
|
op.drop_column('target_lists', 'account_id')
|
|
```
|
|
|
|
- [ ] **Step 8.3: Run migration, verify, update model, test, commit**
|
|
|
|
```bash
|
|
cd backend && alembic upgrade head
|
|
```
|
|
|
|
In `backend/app/models/target_list.py`, add after `team_id`:
|
|
```python
|
|
account_id: Mapped[uuid.UUID] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("accounts.id", ondelete="CASCADE"),
|
|
nullable=False,
|
|
index=True,
|
|
)
|
|
```
|
|
|
|
Also add the Account import to TYPE_CHECKING:
|
|
```python
|
|
if TYPE_CHECKING:
|
|
from app.models.user import User
|
|
from app.models.team import Team
|
|
from app.models.account import Account
|
|
```
|
|
|
|
```bash
|
|
cd backend && python -m pytest tests/test_phase1_migrations.py::test_target_list_account_id_from_team_admin -v --override-ini="addopts="
|
|
cd backend && python -m pytest --override-ini="addopts="
|
|
git add backend/alembic/versions/*add_account_id_target_lists* \
|
|
backend/app/models/target_list.py \
|
|
backend/tests/test_phase1_migrations.py
|
|
git commit -m "feat: Phase 1 Group 8 — add account_id to target_lists (keep team_id)
|
|
|
|
Zero rows in production — this is a schema-only migration in practice.
|
|
team_id kept for app code compatibility. Drop deferred to later cleanup.
|
|
Backfill: team_id → team admin user → account_id; fallback: created_by.
|
|
|
|
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 9: Group 10 — Global content separation (runs before Group 9)
|
|
|
|
**Why this runs before Task 10:** `trees` has `account_id=NULL` for `is_default=TRUE` rows (platform trees). Task 10 sets trees.account_id NOT NULL, which would fail without first handling these rows. This task moves them to `template_trees` (no account_id column), then Task 10 can safely SET NOT NULL on trees.
|
|
|
|
**Action:**
|
|
1. Create `template_trees` table — stores platform-owned troubleshooting trees (no account_id, no RLS)
|
|
2. Create `platform_steps` table — stores platform-owned steps (no account_id, no RLS)
|
|
3. Copy `is_default=TRUE` trees to `template_trees`
|
|
4. Copy `visibility='public'` steps from `step_library` to `platform_steps`
|
|
5. Remove the copied rows from `trees` (set `is_default=FALSE` and assign a NULL-safe account) — or delete if no sessions reference them
|
|
6. Handle global `tree_categories`, `tree_tags`, `step_categories` (NULL `account_id` rows = global platform items) — assign to a "ResolutionFlow Platform" internal account created in this migration
|
|
|
|
**Files:**
|
|
- Create: `backend/alembic/versions/<hash>_create_global_content_tables.py`
|
|
- Create: `backend/app/models/template_tree.py`
|
|
- Create: `backend/app/models/platform_step.py`
|
|
- Modify: `backend/app/models/__init__.py` (register new models)
|
|
- Test: `backend/tests/test_phase1_migrations.py`
|
|
|
|
---
|
|
|
|
- [ ] **Step 9.1: Write failing tests**
|
|
|
|
Append to `backend/tests/test_phase1_migrations.py`:
|
|
|
|
```python
|
|
# ── Group 10 (runs first): Global content tables ──────────────────────────────
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_template_trees_table_exists_and_has_no_account_id(test_db: AsyncSession):
|
|
"""template_trees must exist and must NOT have an account_id column."""
|
|
result = await test_db.execute(text("""
|
|
SELECT column_name
|
|
FROM information_schema.columns
|
|
WHERE table_name = 'template_trees'
|
|
"""))
|
|
columns = {row[0] for row in result.fetchall()}
|
|
assert 'id' in columns, "template_trees.id must exist"
|
|
assert 'account_id' not in columns, "template_trees must not have account_id (global content)"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_platform_steps_table_exists_and_has_no_account_id(test_db: AsyncSession):
|
|
"""platform_steps must exist and must NOT have an account_id column."""
|
|
result = await test_db.execute(text("""
|
|
SELECT column_name
|
|
FROM information_schema.columns
|
|
WHERE table_name = 'platform_steps'
|
|
"""))
|
|
columns = {row[0] for row in result.fetchall()}
|
|
assert 'id' in columns, "platform_steps.id must exist"
|
|
assert 'account_id' not in columns, "platform_steps must not have account_id (global content)"
|
|
```
|
|
|
|
- [ ] **Step 9.2: Generate migration**
|
|
|
|
```bash
|
|
cd backend && alembic revision -m "create_global_content_tables"
|
|
```
|
|
|
|
```python
|
|
"""create template_trees and platform_steps global content tables
|
|
|
|
Revision ID: <GENERATED>
|
|
Revises: <HASH_FROM_TASK_8>
|
|
Create Date: <keep>
|
|
|
|
These tables hold platform-owned content that is readable by all
|
|
authenticated users. No account_id. No RLS. Ever.
|
|
"""
|
|
from typing import Sequence, Union
|
|
from alembic import op
|
|
import sqlalchemy as sa
|
|
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
|
|
|
revision: str = '<GENERATED>'
|
|
down_revision: Union[str, None] = '<HASH_FROM_TASK_8>'
|
|
branch_labels: Union[str, Sequence[str], None] = None
|
|
depends_on: Union[str, Sequence[str], None] = None
|
|
|
|
|
|
def upgrade() -> None:
|
|
# ── Create template_trees ─────────────────────────────────────────────────
|
|
op.create_table(
|
|
'template_trees',
|
|
sa.Column('id', UUID(), primary_key=True),
|
|
sa.Column('name', sa.String(255), nullable=False),
|
|
sa.Column('description', sa.Text(), nullable=True),
|
|
sa.Column('category', sa.String(100), nullable=True),
|
|
sa.Column('tree_type', sa.String(20), nullable=False),
|
|
sa.Column('tree_structure', JSONB(), nullable=False),
|
|
sa.Column('tags', JSONB(), nullable=False, server_default='[]'),
|
|
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true'),
|
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
|
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
|
# source_tree_id: original tree this was promoted from (nullable)
|
|
sa.Column('source_tree_id', UUID(), sa.ForeignKey('trees.id', ondelete='SET NULL'), nullable=True),
|
|
)
|
|
op.create_index('ix_template_trees_tree_type', 'template_trees', ['tree_type'])
|
|
|
|
# ── Create platform_steps ────────────────────────────────────────────────
|
|
op.create_table(
|
|
'platform_steps',
|
|
sa.Column('id', UUID(), primary_key=True),
|
|
sa.Column('title', sa.String(255), nullable=False),
|
|
sa.Column('step_type', sa.String(50), nullable=False),
|
|
sa.Column('content', JSONB(), nullable=False),
|
|
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true'),
|
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
|
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
|
# source_step_id: original step this was promoted from (nullable)
|
|
sa.Column('source_step_id', UUID(), sa.ForeignKey('step_library.id', ondelete='SET NULL'), nullable=True),
|
|
)
|
|
op.create_index('ix_platform_steps_step_type', 'platform_steps', ['step_type'])
|
|
|
|
# ── Migrate is_default=TRUE trees → template_trees ─────────────────────
|
|
op.execute("""
|
|
INSERT INTO template_trees
|
|
(id, name, description, category, tree_type, tree_structure,
|
|
is_active, created_at, updated_at, source_tree_id)
|
|
SELECT
|
|
gen_random_uuid(), name, description, category, tree_type,
|
|
tree_structure, is_active,
|
|
COALESCE(created_at, NOW()), COALESCE(updated_at, NOW()), id
|
|
FROM trees
|
|
WHERE is_default = TRUE
|
|
""")
|
|
|
|
# ── Migrate visibility='public' steps → platform_steps ─────────────────
|
|
op.execute("""
|
|
INSERT INTO platform_steps
|
|
(id, title, step_type, content, is_active, created_at, updated_at, source_step_id)
|
|
SELECT
|
|
gen_random_uuid(), title, step_type, content, is_active,
|
|
COALESCE(created_at, NOW()), COALESCE(updated_at, NOW()), id
|
|
FROM step_library
|
|
WHERE visibility = 'public'
|
|
""")
|
|
|
|
# ── Create a ResolutionFlow platform account for global content ──────────
|
|
# Used to satisfy NOT NULL on trees, tree_categories, tree_tags, etc.
|
|
# This is a sentinel account — it is NOT a real customer account.
|
|
op.execute("""
|
|
INSERT INTO accounts (id, name, display_code, created_at, updated_at)
|
|
VALUES (
|
|
'00000000-0000-0000-0000-000000000001',
|
|
'ResolutionFlow Platform',
|
|
'PLATFORM',
|
|
NOW(),
|
|
NOW()
|
|
)
|
|
ON CONFLICT (id) DO NOTHING
|
|
""")
|
|
|
|
# ── Assign is_default trees to platform account ──────────────────────────
|
|
op.execute("""
|
|
UPDATE trees
|
|
SET account_id = '00000000-0000-0000-0000-000000000001'
|
|
WHERE is_default = TRUE
|
|
AND account_id IS NULL
|
|
""")
|
|
|
|
# ── Assign global tree_categories (team_id=NULL, account_id=NULL) ────────
|
|
op.execute("""
|
|
UPDATE tree_categories
|
|
SET account_id = '00000000-0000-0000-0000-000000000001'
|
|
WHERE account_id IS NULL
|
|
""")
|
|
|
|
# ── Assign global tree_tags (team_id=NULL, account_id=NULL) ─────────────
|
|
op.execute("""
|
|
UPDATE tree_tags
|
|
SET account_id = '00000000-0000-0000-0000-000000000001'
|
|
WHERE account_id IS NULL
|
|
""")
|
|
|
|
# ── Assign global step_categories (account_id=NULL) ──────────────────────
|
|
op.execute("""
|
|
UPDATE step_categories
|
|
SET account_id = '00000000-0000-0000-0000-000000000001'
|
|
WHERE account_id IS NULL
|
|
""")
|
|
|
|
# ── Assign global step_library entries (visibility='public', account_id=NULL) ─
|
|
op.execute("""
|
|
UPDATE step_library
|
|
SET account_id = '00000000-0000-0000-0000-000000000001'
|
|
WHERE account_id IS NULL
|
|
""")
|
|
|
|
# ── Verify all target tables now have zero NULLs ─────────────────────────
|
|
for table in ('trees', 'tree_categories', 'tree_tags', 'step_categories', 'step_library'):
|
|
result = op.get_bind().execute(
|
|
sa.text(f"SELECT COUNT(*) FROM {table} WHERE account_id IS NULL")
|
|
)
|
|
count = result.scalar()
|
|
if count > 0:
|
|
raise RuntimeError(
|
|
f"ROLLBACK: {count} NULL account_id rows remain in {table} "
|
|
"after platform account assignment. Investigate before re-running."
|
|
)
|
|
|
|
|
|
def downgrade() -> None:
|
|
# Reverse platform account assignments (set back to NULL where platform account)
|
|
platform_id = '00000000-0000-0000-0000-000000000001'
|
|
for table in ('trees', 'tree_categories', 'tree_tags', 'step_categories', 'step_library'):
|
|
op.execute(f"UPDATE {table} SET account_id = NULL WHERE account_id = '{platform_id}'")
|
|
|
|
op.execute(f"DELETE FROM accounts WHERE id = '{platform_id}'")
|
|
op.drop_index('ix_platform_steps_step_type', table_name='platform_steps')
|
|
op.drop_index('ix_template_trees_tree_type', table_name='template_trees')
|
|
op.drop_table('platform_steps')
|
|
op.drop_table('template_trees')
|
|
```
|
|
|
|
- [ ] **Step 9.3: Create the SQLAlchemy model files**
|
|
|
|
Create `backend/app/models/template_tree.py`:
|
|
|
|
```python
|
|
"""Template tree model — platform-owned troubleshooting trees, readable by all users.
|
|
|
|
No account_id. No RLS. Readable by any authenticated user.
|
|
Populated by promoting is_default=TRUE trees from the trees table.
|
|
"""
|
|
import uuid
|
|
from datetime import datetime, timezone
|
|
from typing import Optional, Any
|
|
|
|
from sqlalchemy import String, Text, Boolean, DateTime, ForeignKey
|
|
from sqlalchemy.orm import Mapped, mapped_column
|
|
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
|
|
|
from app.core.database import Base
|
|
|
|
|
|
class TemplateTree(Base):
|
|
__tablename__ = "template_trees"
|
|
|
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
|
category: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
|
tree_type: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
|
|
tree_structure: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False)
|
|
tags: Mapped[list] = mapped_column(JSONB, nullable=False, default=list)
|
|
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
|
source_tree_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("trees.id", ondelete="SET NULL"),
|
|
nullable=True,
|
|
)
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
|
)
|
|
updated_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True),
|
|
default=lambda: datetime.now(timezone.utc),
|
|
onupdate=lambda: datetime.now(timezone.utc),
|
|
)
|
|
```
|
|
|
|
Create `backend/app/models/platform_step.py`:
|
|
|
|
```python
|
|
"""Platform step model — platform-owned steps, readable by all users.
|
|
|
|
No account_id. No RLS. Readable by any authenticated user.
|
|
Populated by promoting visibility='public' steps from step_library.
|
|
"""
|
|
import uuid
|
|
from datetime import datetime, timezone
|
|
from typing import Optional, Any
|
|
|
|
from sqlalchemy import String, Boolean, DateTime, ForeignKey
|
|
from sqlalchemy.orm import Mapped, mapped_column
|
|
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
|
|
|
from app.core.database import Base
|
|
|
|
|
|
class PlatformStep(Base):
|
|
__tablename__ = "platform_steps"
|
|
|
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
step_type: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
|
content: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False)
|
|
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
|
source_step_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("step_library.id", ondelete="SET NULL"),
|
|
nullable=True,
|
|
)
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
|
)
|
|
updated_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True),
|
|
default=lambda: datetime.now(timezone.utc),
|
|
onupdate=lambda: datetime.now(timezone.utc),
|
|
)
|
|
```
|
|
|
|
In `backend/app/models/__init__.py`, add:
|
|
```python
|
|
from .template_tree import TemplateTree
|
|
from .platform_step import PlatformStep
|
|
```
|
|
|
|
And add `"TemplateTree"` and `"PlatformStep"` to the `__all__` list.
|
|
|
|
- [ ] **Step 9.4: Run migration, run tests, commit**
|
|
|
|
```bash
|
|
cd backend && alembic upgrade head
|
|
cd backend && python -m pytest tests/test_phase1_migrations.py -k "template_trees or platform_steps" -v --override-ini="addopts="
|
|
cd backend && python -m pytest --override-ini="addopts="
|
|
git add backend/alembic/versions/*create_global_content_tables* \
|
|
backend/app/models/template_tree.py \
|
|
backend/app/models/platform_step.py \
|
|
backend/app/models/__init__.py \
|
|
backend/tests/test_phase1_migrations.py
|
|
git commit -m "feat: Phase 1 Group 10 — create global content tables and platform account
|
|
|
|
Creates template_trees and platform_steps (no account_id, no RLS).
|
|
Migrates is_default=TRUE trees and public steps into them.
|
|
Creates sentinel platform account (00000000-...-0001) for global
|
|
tree_categories, tree_tags, step_categories, step_library, and
|
|
is_default trees — clearing all NULL account_id rows in those tables
|
|
as prerequisite for Group 9 SET NOT NULL.
|
|
|
|
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 10: Group 9 — SET NOT NULL on existing nullable account_id columns
|
|
|
|
**Why this runs last:** Depends on Task 9 having cleared all NULL account_id rows via platform account assignment.
|
|
|
|
**Tables:** `users`, `trees`, `tree_categories`, `tree_tags`, `step_categories`, `step_library`, `tree_embeddings`, `feedback`
|
|
|
|
**Action:** For each table:
|
|
1. Verify zero NULLs (if any remain, backfill or delete)
|
|
2. SET NOT NULL
|
|
3. If index doesn't already exist, CREATE INDEX
|
|
|
|
**Special cases:**
|
|
- `users.account_id`: Any user with NULL account_id must be investigated — they are orphaned. If none, proceed.
|
|
- `tree_embeddings.account_id`: Backfill from `tree_id → trees.account_id` (trees now all have account_id after Task 9).
|
|
- `feedback.account_id`: Backfill from `user_id → users.account_id`.
|
|
|
|
**Files:**
|
|
- Create: `backend/alembic/versions/<hash>_set_not_null_account_id_phase1.py`
|
|
- Modify: `backend/app/models/user.py`, `tree.py`, `category.py`, `tag.py`, `step_category.py`, `step_library.py` (StepLibrary), `tree_embedding.py`, `feedback.py`
|
|
- Test: `backend/tests/test_phase1_migrations.py`
|
|
|
|
---
|
|
|
|
- [ ] **Step 10.1: Write failing tests**
|
|
|
|
Append to `backend/tests/test_phase1_migrations.py`:
|
|
|
|
```python
|
|
# ── Group 9: SET NOT NULL on existing nullable columns ────────────────────────
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_tree_account_id_is_not_null(test_db: AsyncSession):
|
|
"""trees.account_id must be NOT NULL after Phase 1 — enforced at DB level."""
|
|
# Try to insert a tree with no account_id — must fail
|
|
from sqlalchemy.exc import IntegrityError
|
|
with pytest.raises(IntegrityError):
|
|
test_db.add(Tree(
|
|
name="Bad tree",
|
|
# account_id intentionally omitted
|
|
author_id=None,
|
|
visibility="private",
|
|
tree_type="troubleshooting",
|
|
tree_structure={},
|
|
is_active=True,
|
|
status="draft",
|
|
))
|
|
await test_db.flush()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_user_account_id_is_not_null(test_db: AsyncSession):
|
|
"""users.account_id must be NOT NULL after Phase 1."""
|
|
from sqlalchemy.exc import IntegrityError
|
|
with pytest.raises(IntegrityError):
|
|
test_db.add(User(
|
|
email=f"orphan-{uuid.uuid4().hex[:6]}@example.com",
|
|
name="Orphan",
|
|
password_hash=get_password_hash("x"),
|
|
is_active=True,
|
|
# account_id intentionally omitted
|
|
))
|
|
await test_db.flush()
|
|
```
|
|
|
|
- [ ] **Step 10.2: Generate migration**
|
|
|
|
```bash
|
|
cd backend && alembic revision -m "set_not_null_account_id_phase1"
|
|
```
|
|
|
|
```python
|
|
"""set NOT NULL on all previously-nullable account_id columns
|
|
|
|
Revision ID: <GENERATED>
|
|
Revises: <HASH_FROM_TASK_9>
|
|
Create Date: <keep>
|
|
|
|
All tables in this migration had account_id set to nullable previously.
|
|
Task 9 (create_global_content_tables) cleared all NULL rows.
|
|
This migration enforces the NOT NULL constraint.
|
|
"""
|
|
from typing import Sequence, Union
|
|
from alembic import op
|
|
import sqlalchemy as sa
|
|
|
|
revision: str = '<GENERATED>'
|
|
down_revision: Union[str, None] = '<HASH_FROM_TASK_9>'
|
|
branch_labels: Union[str, Sequence[str], None] = None
|
|
depends_on: Union[str, Sequence[str], None] = None
|
|
|
|
|
|
def upgrade() -> None:
|
|
# tree_embeddings: backfill from trees (must happen before SET NOT NULL)
|
|
op.execute("""
|
|
UPDATE tree_embeddings te
|
|
SET account_id = t.account_id
|
|
FROM trees t
|
|
WHERE te.tree_id = t.id
|
|
AND te.account_id IS NULL
|
|
""")
|
|
|
|
# feedback: backfill from users
|
|
op.execute("""
|
|
UPDATE feedback f
|
|
SET account_id = u.account_id
|
|
FROM users u
|
|
WHERE f.user_id = u.id
|
|
AND f.account_id IS NULL
|
|
""")
|
|
|
|
# Verify ALL tables before touching any SET NOT NULL
|
|
tables_with_account_id = [
|
|
'users', 'trees', 'tree_categories', 'tree_tags',
|
|
'step_categories', 'step_library', 'tree_embeddings', 'feedback',
|
|
]
|
|
for table in tables_with_account_id:
|
|
result = op.get_bind().execute(
|
|
sa.text(f"SELECT COUNT(*) FROM {table} WHERE account_id IS NULL")
|
|
)
|
|
count = result.scalar()
|
|
if count > 0:
|
|
raise RuntimeError(
|
|
f"ROLLBACK: {count} NULL account_id rows in {table}. "
|
|
"Run Task 9 (create_global_content_tables) first, or "
|
|
"manually backfill/delete orphaned rows."
|
|
)
|
|
|
|
# SET NOT NULL on all
|
|
for table in tables_with_account_id:
|
|
op.alter_column(table, 'account_id', nullable=False)
|
|
|
|
# Create indexes where they don't already exist
|
|
# (some tables like trees already have ix_trees_account_id from prior work)
|
|
new_indexes = [
|
|
('tree_embeddings', 'ix_tree_embeddings_account_id'),
|
|
('feedback', 'ix_feedback_account_id'),
|
|
]
|
|
for table, index_name in new_indexes:
|
|
# Check if index exists to avoid duplicate error
|
|
result = op.get_bind().execute(sa.text(
|
|
f"SELECT 1 FROM pg_indexes WHERE tablename='{table}' AND indexname='{index_name}'"
|
|
))
|
|
if not result.fetchone():
|
|
op.create_index(index_name, table, ['account_id'])
|
|
|
|
|
|
def downgrade() -> None:
|
|
# Revert to nullable
|
|
for table in ('users', 'trees', 'tree_categories', 'tree_tags',
|
|
'step_categories', 'step_library', 'tree_embeddings', 'feedback'):
|
|
op.alter_column(table, 'account_id', nullable=True)
|
|
for table, index_name in (
|
|
('tree_embeddings', 'ix_tree_embeddings_account_id'),
|
|
('feedback', 'ix_feedback_account_id'),
|
|
):
|
|
try:
|
|
op.drop_index(index_name, table_name=table)
|
|
except Exception:
|
|
pass
|
|
```
|
|
|
|
- [ ] **Step 10.3: Run migration**
|
|
|
|
```bash
|
|
cd backend && alembic upgrade head
|
|
```
|
|
|
|
If this errors with "NULL account_id rows remain in users", investigate:
|
|
```sql
|
|
-- Run from VPS SSH via docker exec
|
|
SELECT id, email, account_id FROM users WHERE account_id IS NULL;
|
|
```
|
|
These are orphaned users. Either assign them to an account or delete them if they are test/seed data.
|
|
|
|
- [ ] **Step 10.4: Update SQLAlchemy models — change `Mapped[Optional[uuid.UUID]]` to `Mapped[uuid.UUID]` and `nullable=True` to `nullable=False`**
|
|
|
|
**`backend/app/models/user.py`** — find `account_id` (around line 46) and change:
|
|
```python
|
|
# BEFORE:
|
|
account_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("accounts.id", ondelete="RESTRICT"),
|
|
nullable=True,
|
|
...
|
|
)
|
|
|
|
# AFTER:
|
|
account_id: Mapped[uuid.UUID] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("accounts.id", ondelete="RESTRICT"),
|
|
nullable=False,
|
|
index=True,
|
|
)
|
|
```
|
|
|
|
**`backend/app/models/tree.py`** — find `account_id` (around line 79) and change:
|
|
```python
|
|
# BEFORE:
|
|
account_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
|
UUID(as_uuid=True), ..., nullable=True, ...
|
|
)
|
|
|
|
# AFTER:
|
|
account_id: Mapped[uuid.UUID] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("accounts.id", ondelete="CASCADE"),
|
|
nullable=False,
|
|
index=True,
|
|
)
|
|
```
|
|
|
|
Apply the same pattern (`Optional` → required, `nullable=True` → `nullable=False`) to:
|
|
- `backend/app/models/category.py` — `TreeCategory.account_id`
|
|
- `backend/app/models/tag.py` — `TreeTag.account_id`
|
|
- `backend/app/models/step_category.py` — `StepCategory.account_id`
|
|
- `backend/app/models/step_library.py` — `StepLibrary.account_id`
|
|
- `backend/app/models/tree_embedding.py` — `TreeEmbedding.account_id`
|
|
- `backend/app/models/feedback.py` — `Feedback.account_id`
|
|
|
|
- [ ] **Step 10.5: Run tests, full suite, commit**
|
|
|
|
```bash
|
|
cd backend && python -m pytest tests/test_phase1_migrations.py -v --override-ini="addopts="
|
|
cd backend && python -m pytest --override-ini="addopts="
|
|
```
|
|
|
|
If any existing tests fail because they create objects without `account_id`, update those test fixtures to provide the required field.
|
|
|
|
```bash
|
|
git add backend/alembic/versions/*set_not_null_account_id* \
|
|
backend/app/models/user.py \
|
|
backend/app/models/tree.py \
|
|
backend/app/models/category.py \
|
|
backend/app/models/tag.py \
|
|
backend/app/models/step_category.py \
|
|
backend/app/models/step_library.py \
|
|
backend/app/models/tree_embedding.py \
|
|
backend/app/models/feedback.py \
|
|
backend/tests/test_phase1_migrations.py
|
|
git commit -m "feat: Phase 1 Group 9 — enforce NOT NULL on all account_id columns
|
|
|
|
All previously-nullable account_id columns are now NOT NULL.
|
|
tree_embeddings and feedback backfilled before constraint applied.
|
|
Global content assigned to platform sentinel account (00000000-...-0001)
|
|
in preceding migration.
|
|
|
|
Tables updated: users, trees, tree_categories, tree_tags,
|
|
step_categories, step_library, tree_embeddings, feedback
|
|
|
|
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 11: Phase 1 gate verification
|
|
|
|
**Run the gate verification query across all tenant tables. All must return zero NULLs.**
|
|
|
|
**Files:** No code changes — verification only.
|
|
|
|
---
|
|
|
|
- [ ] **Step 11.1: Run the gate verification query**
|
|
|
|
From VPS SSH:
|
|
|
|
```bash
|
|
docker exec -it resolutionflow_postgres psql -U postgres -d resolutionflow -c "
|
|
SELECT tablename, null_count
|
|
FROM (
|
|
SELECT 'sessions' AS tablename, COUNT(*) FILTER (WHERE account_id IS NULL) AS null_count FROM sessions
|
|
UNION ALL
|
|
SELECT 'attachments', COUNT(*) FILTER (WHERE account_id IS NULL) FROM attachments
|
|
UNION ALL
|
|
SELECT 'session_supporting_data', COUNT(*) FILTER (WHERE account_id IS NULL) FROM session_supporting_data
|
|
UNION ALL
|
|
SELECT 'session_resolution_outputs',COUNT(*) FILTER (WHERE account_id IS NULL) FROM session_resolution_outputs
|
|
UNION ALL
|
|
SELECT 'session_branches', COUNT(*) FILTER (WHERE account_id IS NULL) FROM session_branches
|
|
UNION ALL
|
|
SELECT 'session_handoffs', COUNT(*) FILTER (WHERE account_id IS NULL) FROM session_handoffs
|
|
UNION ALL
|
|
SELECT 'fork_points', COUNT(*) FILTER (WHERE account_id IS NULL) FROM fork_points
|
|
UNION ALL
|
|
SELECT 'ai_session_steps', COUNT(*) FILTER (WHERE account_id IS NULL) FROM ai_session_steps
|
|
UNION ALL
|
|
SELECT 'ai_suggestions', COUNT(*) FILTER (WHERE account_id IS NULL) FROM ai_suggestions
|
|
UNION ALL
|
|
SELECT 'step_ratings', COUNT(*) FILTER (WHERE account_id IS NULL) FROM step_ratings
|
|
UNION ALL
|
|
SELECT 'step_usage_log', COUNT(*) FILTER (WHERE account_id IS NULL) FROM step_usage_log
|
|
UNION ALL
|
|
SELECT 'user_folders', COUNT(*) FILTER (WHERE account_id IS NULL) FROM user_folders
|
|
UNION ALL
|
|
SELECT 'user_pinned_trees', COUNT(*) FILTER (WHERE account_id IS NULL) FROM user_pinned_trees
|
|
UNION ALL
|
|
SELECT 'psa_post_log', COUNT(*) FILTER (WHERE account_id IS NULL) FROM psa_post_log
|
|
UNION ALL
|
|
SELECT 'psa_member_mappings', COUNT(*) FILTER (WHERE account_id IS NULL) FROM psa_member_mappings
|
|
UNION ALL
|
|
SELECT 'notification_logs', COUNT(*) FILTER (WHERE account_id IS NULL) FROM notification_logs
|
|
UNION ALL
|
|
SELECT 'maintenance_schedules', COUNT(*) FILTER (WHERE account_id IS NULL) FROM maintenance_schedules
|
|
UNION ALL
|
|
SELECT 'script_builder_sessions', COUNT(*) FILTER (WHERE account_id IS NULL) FROM script_builder_sessions
|
|
UNION ALL
|
|
SELECT 'script_templates', COUNT(*) FILTER (WHERE account_id IS NULL) FROM script_templates
|
|
UNION ALL
|
|
SELECT 'script_generations', COUNT(*) FILTER (WHERE account_id IS NULL) FROM script_generations
|
|
UNION ALL
|
|
SELECT 'target_lists', COUNT(*) FILTER (WHERE account_id IS NULL) FROM target_lists
|
|
UNION ALL
|
|
SELECT 'users', COUNT(*) FILTER (WHERE account_id IS NULL) FROM users
|
|
UNION ALL
|
|
SELECT 'trees', COUNT(*) FILTER (WHERE account_id IS NULL) FROM trees
|
|
UNION ALL
|
|
SELECT 'tree_categories', COUNT(*) FILTER (WHERE account_id IS NULL) FROM tree_categories
|
|
UNION ALL
|
|
SELECT 'tree_tags', COUNT(*) FILTER (WHERE account_id IS NULL) FROM tree_tags
|
|
UNION ALL
|
|
SELECT 'step_categories', COUNT(*) FILTER (WHERE account_id IS NULL) FROM step_categories
|
|
UNION ALL
|
|
SELECT 'step_library', COUNT(*) FILTER (WHERE account_id IS NULL) FROM step_library
|
|
UNION ALL
|
|
SELECT 'tree_embeddings', COUNT(*) FILTER (WHERE account_id IS NULL) FROM tree_embeddings
|
|
UNION ALL
|
|
SELECT 'feedback', COUNT(*) FILTER (WHERE account_id IS NULL) FROM feedback
|
|
) t
|
|
ORDER BY null_count DESC, tablename;
|
|
"
|
|
```
|
|
|
|
Expected: all rows show `null_count = 0`.
|
|
|
|
Any non-zero row is a blocker — do not proceed to Phase 2 until resolved.
|
|
|
|
- [ ] **Step 11.2: Verify CI is still green**
|
|
|
|
```bash
|
|
gh run list --limit 3
|
|
```
|
|
|
|
Check that the latest CI run on `feat/tenant-isolation-phase-1` is green. The tenant filter check will now report fewer warnings (tables that gained account_id no longer trigger false positives).
|
|
|
|
- [ ] **Step 11.3: Create PR**
|
|
|
|
```bash
|
|
git push -u origin feat/tenant-isolation-phase-1
|
|
gh pr create \
|
|
--base main \
|
|
--title "feat: tenant isolation Phase 1 — add account_id to all tenant tables" \
|
|
--body "Adds account_id NOT NULL to all tenant tables, creates global content tables, and enforces the platform account sentinel for legacy global content. Phase 2 (RLS + SET LOCAL in get_db) is unblocked once this merges and gate query returns all zeros."
|
|
```
|
|
|
|
---
|
|
|
|
## Phase 1 Gate Checklist
|
|
|
|
Before merging and declaring Phase 1 complete:
|
|
|
|
- [ ] All 10 migrations in `alembic/versions/` chained correctly (`down_revision` points to previous)
|
|
- [ ] All migrations run cleanly: `alembic upgrade head` exits 0
|
|
- [ ] All 28 tenant tables show `null_count = 0` in gate verification query
|
|
- [ ] Full test suite passes: `python -m pytest --override-ini="addopts="`
|
|
- [ ] `python scripts/check_tenant_filters.py` warning count has decreased (tables with account_id no longer flagged)
|
|
- [ ] `session_ratings` not touched (already had `account_id NOT NULL` ✓)
|
|
- [ ] `team_id` columns NOT dropped on script tables, target_lists (deferred cleanup)
|
|
- [ ] CI passes on `feat/tenant-isolation-phase-1` branch
|
|
- [ ] Gate verification query run against **production DB** (VPS SSH) and returns all zeros
|