Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
"""Add deleted_at to Interaction, Reminder, Gift, Debt, LifeEvent, Note.

Revision ID: f6a7b8c9d0e1
Revises: c3d4e5f6a7b8
Create Date: 2026-05-03 00:00:00.000000

"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy import DateTime

revision = "add_soft_delete_entities"
down_revision = "add_do_not_contact_fields"
branch_labels = None
depends_on = None


def upgrade() -> None:
# Add deleted_at to interaction
op.add_column(
"interaction",
sa.Column("deleted_at", DateTime(timezone=True), nullable=True),
)
op.create_index(
"ix_interaction_deleted_at", "interaction", ["deleted_at"]
)

# Add deleted_at to reminder
op.add_column(
"reminder",
sa.Column("deleted_at", DateTime(timezone=True), nullable=True),
)
op.create_index(
"ix_reminder_deleted_at", "reminder", ["deleted_at"]
)

# Add deleted_at to gift
op.add_column(
"gift",
sa.Column("deleted_at", DateTime(timezone=True), nullable=True),
)
op.create_index(
"ix_gift_deleted_at", "gift", ["deleted_at"]
)

# Add deleted_at to debt
op.add_column(
"debt",
sa.Column("deleted_at", DateTime(timezone=True), nullable=True),
)
op.create_index(
"ix_debt_deleted_at", "debt", ["deleted_at"]
)

# Add deleted_at to life_event
op.add_column(
"life_event",
sa.Column("deleted_at", DateTime(timezone=True), nullable=True),
)
op.create_index(
"ix_life_event_deleted_at", "life_event", ["deleted_at"]
)

# Add deleted_at to note
op.add_column(
"note",
sa.Column("deleted_at", DateTime(timezone=True), nullable=True),
)
op.create_index(
"ix_note_deleted_at", "note", ["deleted_at"]
)


def downgrade() -> None:
# Remove deleted_at from note
op.drop_index("ix_note_deleted_at", "note")
op.drop_column("note", "deleted_at")

# Remove deleted_at from life_event
op.drop_index("ix_life_event_deleted_at", "life_event")
op.drop_column("life_event", "deleted_at")

# Remove deleted_at from debt
op.drop_index("ix_debt_deleted_at", "debt")
op.drop_column("debt", "deleted_at")

# Remove deleted_at from gift
op.drop_index("ix_gift_deleted_at", "gift")
op.drop_column("gift", "deleted_at")

# Remove deleted_at from reminder
op.drop_index("ix_reminder_deleted_at", "reminder")
op.drop_column("reminder", "deleted_at")

# Remove deleted_at from interaction
op.drop_index("ix_interaction_deleted_at", "interaction")
op.drop_column("interaction", "deleted_at")
5 changes: 3 additions & 2 deletions backend/app/api/deps.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from app.core import oidc, security
from app.core.api_keys import hash_key, looks_like_api_key
from app.core.config import settings
from app.core.db import engine
from app.core.db import SessionLocal, configure_session
from app.models import TokenPayload, User

CF_ACCESS_HEADER = "Cf-Access-Jwt-Assertion"
Expand All @@ -27,7 +27,8 @@


def get_db() -> Generator[Session, None, None]:
with Session(engine) as session:
with SessionLocal() as session:
configure_session(session)
yield session


Expand Down
30 changes: 28 additions & 2 deletions backend/app/api/routes/debts.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,12 +78,38 @@ def delete_debt(
current_user: CurrentUser,
debt_id: uuid.UUID,
) -> Any:
"""Delete a debt."""
"""Soft-delete a debt by setting deleted_at."""
debt = session.get(Debt, debt_id)
if debt is None:
raise HTTPException(status_code=404, detail="Debt not found")
if debt.owner_id != current_user.id:
raise HTTPException(status_code=403, detail="Not enough permissions")

raise HTTPException(status_code=404, detail="Debt not found")
_require_contact_visible(session, current_user, debt.contact_id)

session.delete(debt)
from datetime import datetime, timezone

debt.deleted_at = datetime.now(timezone.utc)
session.add(debt)
session.commit()
return {"ok": True}


@router.post("/{debt_id}/restore")
def restore_debt(
session: SessionDep,
debt_id: uuid.UUID,
) -> Any:
"""Restore a soft-deleted debt by clearing deleted_at."""
from sqlalchemy import text, update

result = session.exec(
text("SELECT id FROM debt WHERE id = :id AND deleted_at IS NOT NULL"),
params={"id": str(debt_id)},
).first()
if result is None:
raise HTTPException(status_code=404, detail="Debt not found or not deleted")
session.exec(update(Debt).where(Debt.id == debt_id).values(deleted_at=None))
session.commit()
return {"ok": True}
30 changes: 28 additions & 2 deletions backend/app/api/routes/gifts.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,13 +106,39 @@ def delete_gift(
current_user: CurrentUser,
gift_id: uuid.UUID,
) -> Any:
"""Delete a gift."""
"""Soft-delete a gift by setting deleted_at."""
gift = session.get(Gift, gift_id)
if gift is None:
raise HTTPException(status_code=404, detail="Gift not found")

if gift.owner_id != current_user.id:
raise HTTPException(status_code=403, detail="Not enough permissions")

_require_contact_visible(session, current_user, gift.contact_id)

session.delete(gift)
from datetime import datetime, timezone

gift.deleted_at = datetime.now(timezone.utc)
session.add(gift)
session.commit()
return {"ok": True}


@router.post("/{gift_id}/restore")
def restore_gift(
session: SessionDep,
gift_id: uuid.UUID,
) -> Any:
"""Restore a soft-deleted gift by clearing deleted_at."""
from sqlalchemy import text, update

result = session.exec(
text("SELECT id FROM gift WHERE id = :id AND deleted_at IS NOT NULL"),
params={"id": str(gift_id)},
).first()
if result is None:
raise HTTPException(status_code=404, detail="Gift not found or not deleted")
session.exec(update(Gift).where(Gift.id == gift_id).values(deleted_at=None))
session.commit()
return {"ok": True}

Expand Down
33 changes: 31 additions & 2 deletions backend/app/api/routes/interactions.py
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,7 @@ def delete_interaction(
current_user: CurrentUser,
interaction_id: uuid.UUID,
) -> Any:
"""Delete an interaction and recompute each attendee's last_contacted_at."""
"""Soft-delete an interaction by setting deleted_at."""
interaction = session.get(Interaction, interaction_id)
if interaction is None:
raise HTTPException(status_code=404, detail="Interaction not found")
Expand All @@ -223,9 +223,38 @@ def delete_interaction(
if not (attendee_ids & visible_ids):
raise HTTPException(status_code=404, detail="Interaction not found")

session.delete(interaction)
from datetime import datetime, timezone

interaction.deleted_at = datetime.now(timezone.utc)
session.add(interaction)
session.flush()
for aid in attendee_ids:
recompute_last_contacted_at(session=session, contact_id=aid)
session.commit()
return {"ok": True}


@router.post("/{interaction_id}/restore")
def restore_interaction(
session: SessionDep,
interaction_id: uuid.UUID,
) -> Any:
"""Restore a soft-deleted interaction by clearing deleted_at."""
from sqlalchemy import text, update

# Bypass the soft-delete filter
result = session.exec(
text("SELECT id FROM interaction WHERE id = :id AND deleted_at IS NOT NULL"),
params={"id": str(interaction_id)},
).first()
if result is None:
raise HTTPException(
status_code=404, detail="Interaction not found or not deleted"
)
session.exec(
update(Interaction)
.where(Interaction.id == interaction_id)
.values(deleted_at=None)
)
session.commit()
return {"ok": True}
34 changes: 32 additions & 2 deletions backend/app/api/routes/life_events.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,10 @@ def update_life_event(
event = session.get(LifeEvent, event_id)
if event is None:
raise HTTPException(status_code=404, detail="Life event not found")

if event.owner_id != current_user.id:
raise HTTPException(status_code=403, detail="Not enough permissions")

_require_contact_visible(session, current_user, event.contact_id)

update_data = event_in.model_dump(exclude_unset=True)
Expand All @@ -86,12 +90,38 @@ def delete_life_event(
current_user: CurrentUser,
event_id: uuid.UUID,
) -> Any:
"""Delete a life event."""
"""Soft-delete a life event by setting deleted_at."""
event = session.get(LifeEvent, event_id)
if event is None:
raise HTTPException(status_code=404, detail="Life event not found")
_require_contact_visible(session, current_user, event.contact_id)

session.delete(event)
from datetime import datetime, timezone

event.deleted_at = datetime.now(timezone.utc)
session.add(event)
session.commit()
return {"ok": True}


@router.post("/{event_id}/restore")
def restore_life_event(
session: SessionDep,
event_id: uuid.UUID,
) -> Any:
"""Restore a soft-deleted life event by clearing deleted_at."""
from sqlalchemy import text, update

result = session.exec(
text("SELECT id FROM life_event WHERE id = :id AND deleted_at IS NOT NULL"),
params={"id": str(event_id)},
).first()
if result is None:
raise HTTPException(
status_code=404, detail="Life event not found or not deleted"
)
session.exec(
update(LifeEvent).where(LifeEvent.id == event_id).values(deleted_at=None)
)
session.commit()
return {"ok": True}
26 changes: 24 additions & 2 deletions backend/app/api/routes/notes.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,13 +108,35 @@ def delete_note(
current_user: CurrentUser,
note_id: uuid.UUID,
) -> Any:
"""Delete a note."""
"""Soft-delete a note by setting deleted_at."""
note = session.get(Note, note_id)
if not note:
raise HTTPException(status_code=404, detail="Note not found")
if note.owner_id != current_user.id:
raise HTTPException(status_code=403, detail="Not enough permissions")

session.delete(note)
from datetime import datetime, timezone

note.deleted_at = datetime.now(timezone.utc)
session.add(note)
session.commit()
return {"ok": True}


@router.post("/{note_id}/restore")
def restore_note(
session: SessionDep,
note_id: uuid.UUID,
) -> Any:
"""Restore a soft-deleted note by clearing deleted_at."""
from sqlalchemy import text, update

result = session.exec(
text("SELECT id FROM note WHERE id = :id AND deleted_at IS NOT NULL"),
params={"id": str(note_id)},
).first()
if result is None:
raise HTTPException(status_code=404, detail="Note not found or not deleted")
session.exec(update(Note).where(Note.id == note_id).values(deleted_at=None))
session.commit()
return {"ok": True}
28 changes: 26 additions & 2 deletions backend/app/api/routes/reminders.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,11 +126,35 @@ def delete_reminder(
current_user: CurrentUser,
reminder_id: uuid.UUID,
) -> Any:
"""Delete a reminder."""
"""Soft-delete a reminder by setting deleted_at."""
reminder = session.get(Reminder, reminder_id)
if reminder is None or not _reminder_accessible(current_user, reminder, session):
raise HTTPException(status_code=404, detail="Reminder not found")

session.delete(reminder)
from datetime import datetime, timezone

reminder.deleted_at = datetime.now(timezone.utc)
session.add(reminder)
session.commit()
return {"ok": True}


@router.post("/{reminder_id}/restore")
def restore_reminder(
session: SessionDep,
reminder_id: uuid.UUID,
) -> Any:
"""Restore a soft-deleted reminder by clearing deleted_at."""
from sqlalchemy import text, update

result = session.exec(
text("SELECT id FROM reminder WHERE id = :id AND deleted_at IS NOT NULL"),
params={"id": str(reminder_id)},
).first()
if result is None:
raise HTTPException(status_code=404, detail="Reminder not found or not deleted")
session.exec(
update(Reminder).where(Reminder.id == reminder_id).values(deleted_at=None)
)
session.commit()
return {"ok": True}
Loading