From af36011f56e3293fb635c4c9c28ba7075ec4a2c2 Mon Sep 17 00:00:00 2001 From: Will Pike <6687499+pike00@users.noreply.github.com> Date: Sun, 3 May 2026 05:36:14 -0500 Subject: [PATCH 1/4] feat: add soft delete to Interaction, Reminder, Gift, Debt, LifeEvent, Note - Add deleted_at field to model classes with index - Add deleted_at to Public response classes - Update API endpoints to use soft delete instead of hard delete - Add restore endpoints (POST /{entity}/{id}/restore) - Create Alembic migration f6a7b8c9d0e1 - Update deps.py to use filtered session with soft-delete criteria --- ...f6a7b8c9d0e1_add_deleted_at_to_entities.py | 97 +++++++++++++++++++ backend/app/api/deps.py | 5 +- backend/app/api/routes/contacts.py | 24 ++--- backend/app/api/routes/debts.py | 38 ++++++-- backend/app/api/routes/gifts.py | 38 ++++++-- backend/app/api/routes/interactions.py | 45 +++++++-- backend/app/api/routes/life_events.py | 42 ++++++-- backend/app/api/routes/notes.py | 38 ++++++-- backend/app/api/routes/reminders.py | 43 +++++--- backend/app/models.py | 60 ++++++++++++ openapi.json | 0 11 files changed, 350 insertions(+), 80 deletions(-) create mode 100644 backend/app/alembic/versions/f6a7b8c9d0e1_add_deleted_at_to_entities.py create mode 100644 openapi.json diff --git a/backend/app/alembic/versions/f6a7b8c9d0e1_add_deleted_at_to_entities.py b/backend/app/alembic/versions/f6a7b8c9d0e1_add_deleted_at_to_entities.py new file mode 100644 index 00000000..de4471df --- /dev/null +++ b/backend/app/alembic/versions/f6a7b8c9d0e1_add_deleted_at_to_entities.py @@ -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 = "f6a7b8c9d0e1" +down_revision = "d7d81f2" +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") diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index 2c4d2d0f..c127315a 100644 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -11,7 +11,7 @@ from app import crud from app.core import oidc, security 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" @@ -25,7 +25,8 @@ def get_db() -> Generator[Session, None, None]: - with Session(engine) as session: + with SessionLocal() as session: + configure_session(session) yield session diff --git a/backend/app/api/routes/contacts.py b/backend/app/api/routes/contacts.py index 1bfecfd2..3dfa5e2f 100644 --- a/backend/app/api/routes/contacts.py +++ b/backend/app/api/routes/contacts.py @@ -97,8 +97,7 @@ def _remove_contact_safe(contact_id: str) -> None: @router.get("/", response_model=ContactsPublic) def list_contacts( session: SessionDep, - current_user: CurrentUser, - skip: int = 0, +skip: int = 0, limit: int = 100, search: str | None = None, tag_id: uuid.UUID | None = None, @@ -196,8 +195,7 @@ def list_contacts( @router.get("/losing-touch", response_model=ContactsPublic) def list_losing_touch( session: SessionDep, - current_user: CurrentUser, - limit: int = 20, +limit: int = 20, ) -> Any: """Return contacts whose cadence has been exceeded. @@ -245,8 +243,7 @@ def list_losing_touch( @router.get("/{contact_id}", response_model=ContactPublic) def get_contact( session: SessionDep, - current_user: CurrentUser, - contact_id: uuid.UUID, +contact_id: uuid.UUID, ) -> Any: """Get a single contact by ID.""" statement = ( @@ -271,8 +268,7 @@ def get_contact( def create_contact( *, session: SessionDep, - current_user: CurrentUser, - contact_in: ContactCreate, +contact_in: ContactCreate, background_tasks: BackgroundTasks, ) -> Any: """Create a new contact.""" @@ -312,8 +308,7 @@ def create_contact( def update_contact( *, session: SessionDep, - current_user: CurrentUser, - contact_id: uuid.UUID, +contact_id: uuid.UUID, contact_in: ContactUpdate, background_tasks: BackgroundTasks, ) -> Any: @@ -382,8 +377,7 @@ def update_contact( @router.delete("/{contact_id}") def delete_contact( session: SessionDep, - current_user: CurrentUser, - contact_id: uuid.UUID, +contact_id: uuid.UUID, background_tasks: BackgroundTasks, ) -> Any: """Soft-delete a contact. @@ -425,8 +419,7 @@ class NoteMentionPublic(BaseModel): @router.get("/{contact_id}/mentions", response_model=list[NoteMentionPublic]) def list_contact_mentions( session: SessionDep, - current_user: CurrentUser, - contact_id: uuid.UUID, +contact_id: uuid.UUID, ) -> Any: """List notes that @-mention this contact, with the source (authoring) contact.""" contact = session.exec( @@ -470,8 +463,7 @@ def list_contact_mentions( @router.post("/{contact_id}/restore", response_model=ContactPublic) def restore_contact( session: SessionDep, - current_user: CurrentUser, - contact_id: uuid.UUID, +contact_id: uuid.UUID, background_tasks: BackgroundTasks, ) -> Any: """Restore a soft-deleted contact (clear ``deleted_at``).""" diff --git a/backend/app/api/routes/debts.py b/backend/app/api/routes/debts.py index a704b4db..48896702 100644 --- a/backend/app/api/routes/debts.py +++ b/backend/app/api/routes/debts.py @@ -21,8 +21,7 @@ def _require_contact_visible(session: Any, user: Any, contact_id: uuid.UUID) -> @router.get("/contact/{contact_id}", response_model=DebtsPublic) def list_debts( session: SessionDep, - current_user: CurrentUser, - contact_id: uuid.UUID, +contact_id: uuid.UUID, ) -> Any: """List debts for a contact.""" _require_contact_visible(session, current_user, contact_id) @@ -40,8 +39,7 @@ def list_debts( def create_debt_route( *, session: SessionDep, - current_user: CurrentUser, - debt_in: DebtCreate, +debt_in: DebtCreate, ) -> Any: """Create a new debt.""" _require_contact_visible(session, current_user, debt_in.contact_id) @@ -54,8 +52,7 @@ def create_debt_route( def update_debt( *, session: SessionDep, - current_user: CurrentUser, - debt_id: uuid.UUID, +debt_id: uuid.UUID, debt_in: DebtUpdate, ) -> Any: """Update a debt.""" @@ -75,15 +72,36 @@ def update_debt( @router.delete("/{debt_id}") def delete_debt( session: SessionDep, - current_user: CurrentUser, - debt_id: uuid.UUID, +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") _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} diff --git a/backend/app/api/routes/gifts.py b/backend/app/api/routes/gifts.py index 3a7cc79e..7e5d81b3 100644 --- a/backend/app/api/routes/gifts.py +++ b/backend/app/api/routes/gifts.py @@ -21,8 +21,7 @@ def _require_contact_visible(session: Any, user: Any, contact_id: uuid.UUID) -> @router.get("/contact/{contact_id}", response_model=GiftsPublic) def list_gifts( session: SessionDep, - current_user: CurrentUser, - contact_id: uuid.UUID, +contact_id: uuid.UUID, ) -> Any: """List gifts for a contact.""" _require_contact_visible(session, current_user, contact_id) @@ -40,8 +39,7 @@ def list_gifts( def create_gift_route( *, session: SessionDep, - current_user: CurrentUser, - gift_in: GiftCreate, +gift_in: GiftCreate, ) -> Any: """Create a new gift.""" _require_contact_visible(session, current_user, gift_in.contact_id) @@ -54,8 +52,7 @@ def create_gift_route( def update_gift( *, session: SessionDep, - current_user: CurrentUser, - gift_id: uuid.UUID, +gift_id: uuid.UUID, gift_in: GiftUpdate, ) -> Any: """Update a gift.""" @@ -75,15 +72,36 @@ def update_gift( @router.delete("/{gift_id}") def delete_gift( session: SessionDep, - current_user: CurrentUser, - gift_id: uuid.UUID, +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") _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} diff --git a/backend/app/api/routes/interactions.py b/backend/app/api/routes/interactions.py index f58feb98..090014fe 100644 --- a/backend/app/api/routes/interactions.py +++ b/backend/app/api/routes/interactions.py @@ -77,8 +77,7 @@ def _resolve_visible_contact_ids( @router.get("/", response_model=InteractionsPublic) def list_interactions( session: SessionDep, - current_user: CurrentUser, - contact_id: uuid.UUID | None = None, +contact_id: uuid.UUID | None = None, skip: int = 0, limit: int = 100, ) -> Any: @@ -119,8 +118,7 @@ def list_interactions( def create_interaction_route( *, session: SessionDep, - current_user: CurrentUser, - interaction_in: InteractionCreate, +interaction_in: InteractionCreate, ) -> Any: """Create a new interaction with one or more attendees.""" visible_ids = _resolve_visible_contact_ids(session, current_user) @@ -138,8 +136,7 @@ def create_interaction_route( def update_interaction( *, session: SessionDep, - current_user: CurrentUser, - interaction_id: uuid.UUID, +interaction_id: uuid.UUID, interaction_in: InteractionUpdate, ) -> Any: """Update an interaction; ``attendee_ids`` replaces the attendee set.""" @@ -204,10 +201,9 @@ def update_interaction( @router.delete("/{interaction_id}") def delete_interaction( session: SessionDep, - current_user: CurrentUser, - interaction_id: uuid.UUID, +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") @@ -223,9 +219,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} diff --git a/backend/app/api/routes/life_events.py b/backend/app/api/routes/life_events.py index 51b14cf1..2b522696 100644 --- a/backend/app/api/routes/life_events.py +++ b/backend/app/api/routes/life_events.py @@ -27,8 +27,7 @@ def _require_contact_visible(session: Any, user: Any, contact_id: uuid.UUID) -> @router.get("/contact/{contact_id}", response_model=LifeEventsPublic) def list_life_events( session: SessionDep, - current_user: CurrentUser, - contact_id: uuid.UUID, +contact_id: uuid.UUID, ) -> Any: """List life events for a contact.""" _require_contact_visible(session, current_user, contact_id) @@ -46,8 +45,7 @@ def list_life_events( def create_life_event_route( *, session: SessionDep, - current_user: CurrentUser, - event_in: LifeEventCreate, +event_in: LifeEventCreate, ) -> Any: """Create a new life event.""" _require_contact_visible(session, current_user, event_in.contact_id) @@ -62,8 +60,7 @@ def create_life_event_route( def update_life_event( *, session: SessionDep, - current_user: CurrentUser, - event_id: uuid.UUID, +event_id: uuid.UUID, event_in: LifeEventUpdate, ) -> Any: """Update a life event.""" @@ -83,15 +80,40 @@ def update_life_event( @router.delete("/{event_id}") def delete_life_event( session: SessionDep, - current_user: CurrentUser, - event_id: uuid.UUID, +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} diff --git a/backend/app/api/routes/notes.py b/backend/app/api/routes/notes.py index 93ec3c4c..c67115c4 100644 --- a/backend/app/api/routes/notes.py +++ b/backend/app/api/routes/notes.py @@ -24,8 +24,7 @@ @router.get("/contact/{contact_id}", response_model=NotesPublic) def list_notes( session: SessionDep, - current_user: CurrentUser, - contact_id: uuid.UUID, +contact_id: uuid.UUID, skip: int = 0, limit: int = 100, ) -> Any: @@ -69,8 +68,7 @@ def list_notes( def create_note_route( *, session: SessionDep, - current_user: CurrentUser, - note_in: NoteCreate, +note_in: NoteCreate, ) -> Any: """Create a new note.""" contact = session.get(Contact, note_in.contact_id) @@ -87,8 +85,7 @@ def create_note_route( def update_note_route( *, session: SessionDep, - current_user: CurrentUser, - note_id: uuid.UUID, +note_id: uuid.UUID, note_in: NoteUpdate, ) -> Any: """Update a note.""" @@ -105,16 +102,37 @@ def update_note_route( @router.delete("/{note_id}") def delete_note( session: SessionDep, - current_user: CurrentUser, - note_id: uuid.UUID, +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} diff --git a/backend/app/api/routes/reminders.py b/backend/app/api/routes/reminders.py index dcb43ebe..b553b000 100644 --- a/backend/app/api/routes/reminders.py +++ b/backend/app/api/routes/reminders.py @@ -31,8 +31,7 @@ def _reminder_accessible(user: Any, reminder: Reminder, session: Any) -> bool: @router.get("/", response_model=RemindersPublic) def list_reminders( session: SessionDep, - current_user: CurrentUser, - skip: int = 0, +skip: int = 0, limit: int = 100, is_active: bool | None = None, ) -> Any: @@ -63,8 +62,7 @@ def list_reminders( def create_reminder_route( *, session: SessionDep, - current_user: CurrentUser, - reminder_in: ReminderCreate, +reminder_in: ReminderCreate, ) -> Any: """Create a new reminder.""" if reminder_in.contact_id is not None and not contact_visible( @@ -81,8 +79,7 @@ def create_reminder_route( def update_reminder( *, session: SessionDep, - current_user: CurrentUser, - reminder_id: uuid.UUID, +reminder_id: uuid.UUID, reminder_in: ReminderUpdate, ) -> Any: """Update a reminder.""" @@ -102,8 +99,7 @@ def update_reminder( def snooze_reminder( *, session: SessionDep, - current_user: CurrentUser, - reminder_id: uuid.UUID, +reminder_id: uuid.UUID, minutes: int = 30, ) -> Any: """Snooze a reminder.""" @@ -123,14 +119,37 @@ def snooze_reminder( @router.delete("/{reminder_id}") def delete_reminder( session: SessionDep, - current_user: CurrentUser, - reminder_id: uuid.UUID, +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} diff --git a/backend/app/models.py b/backend/app/models.py index 3b819f4f..e6e40853 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -1030,6 +1030,15 @@ class Interaction(InteractionBase, table=True): ondelete="CASCADE", description="Owner user; cascades on delete.", ) + deleted_at: datetime | None = Field( + default=None, + index=True, + sa_type=DateTime(timezone=True), + description=( + "Soft-delete marker. When non-null, the row is hidden from the " + "default visibility helpers; restore by clearing this column." + ), + ) created_at: datetime = Field( default_factory=lambda: datetime.now(timezone.utc), nullable=False, @@ -1048,6 +1057,7 @@ class InteractionPublic(InteractionBase): id: uuid.UUID attendees: list[InteractionAttendeeSummary] = [] created_at: datetime + deleted_at: datetime | None = None class InteractionsPublic(SQLModel): @@ -1114,6 +1124,15 @@ class Reminder(ReminderBase, table=True): ondelete="CASCADE", description="Owner user; cascades on delete.", ) + deleted_at: datetime | None = Field( + default=None, + index=True, + sa_type=DateTime(timezone=True), + description=( + "Soft-delete marker. When non-null, the row is hidden from the " + "default visibility helpers; restore by clearing this column." + ), + ) last_sent_at: datetime | None = Field( default=None, description="When the ARQ worker last fired this reminder.", @@ -1135,6 +1154,7 @@ class ReminderPublic(ReminderBase): last_sent_at: datetime | None snoozed_until: datetime | None created_at: datetime + deleted_at: datetime | None = None class RemindersPublic(SQLModel): @@ -1220,6 +1240,15 @@ class Gift(GiftBase, table=True): ondelete="CASCADE", description="Owner user; cascades on delete.", ) + deleted_at: datetime | None = Field( + default=None, + index=True, + sa_type=DateTime(timezone=True), + description=( + "Soft-delete marker. When non-null, the row is hidden from the " + "default visibility helpers; restore by clearing this column." + ), + ) gift_date: date | None = Field( default=None, sa_column=sa.Column("date", sa.Date, nullable=True), @@ -1236,6 +1265,7 @@ class GiftPublic(GiftBase): id: uuid.UUID contact_id: uuid.UUID created_at: datetime + deleted_at: datetime | None = None class GiftsPublic(SQLModel): @@ -1307,6 +1337,15 @@ class Debt(DebtBase, table=True): ondelete="CASCADE", description="Owner user; cascades on delete.", ) + deleted_at: datetime | None = Field( + default=None, + index=True, + sa_type=DateTime(timezone=True), + description=( + "Soft-delete marker. When non-null, the row is hidden from the " + "default visibility helpers; restore by clearing this column." + ), + ) created_at: datetime = Field( default_factory=lambda: datetime.now(timezone.utc), nullable=False, @@ -1318,6 +1357,7 @@ class DebtPublic(DebtBase): id: uuid.UUID contact_id: uuid.UUID created_at: datetime + deleted_at: datetime | None = None class DebtsPublic(SQLModel): @@ -1385,6 +1425,15 @@ class LifeEvent(LifeEventBase, table=True): ondelete="CASCADE", description="Owner user; cascades on delete.", ) + deleted_at: datetime | None = Field( + default=None, + index=True, + sa_type=DateTime(timezone=True), + description=( + "Soft-delete marker. When non-null, the row is hidden from the " + "default visibility helpers; restore by clearing this column." + ), + ) created_at: datetime = Field( default_factory=lambda: datetime.now(timezone.utc), nullable=False, @@ -1396,6 +1445,7 @@ class LifeEventPublic(LifeEventBase): id: uuid.UUID contact_id: uuid.UUID created_at: datetime + deleted_at: datetime | None = None class LifeEventsPublic(SQLModel): @@ -1460,6 +1510,15 @@ class Note(NoteBase, table=True): ondelete="CASCADE", description="Owner user; cascades on delete.", ) + deleted_at: datetime | None = Field( + default=None, + index=True, + sa_type=DateTime(timezone=True), + description=( + "Soft-delete marker. When non-null, the row is hidden from the " + "default visibility helpers; restore by clearing this column." + ), + ) created_at: datetime = Field( default_factory=lambda: datetime.now(timezone.utc), nullable=False, @@ -1478,6 +1537,7 @@ class NotePublic(NoteBase): contact_id: uuid.UUID created_at: datetime updated_at: datetime + deleted_at: datetime | None = None class NotesPublic(SQLModel): diff --git a/openapi.json b/openapi.json new file mode 100644 index 00000000..e69de29b From aa253f63368dfb402ca9aee978705aeb5976d579 Mon Sep 17 00:00:00 2001 From: Will Pike <6687499+pike00@users.noreply.github.com> Date: Sun, 3 May 2026 06:19:17 -0500 Subject: [PATCH 2/4] feat: implement soft delete for all mutable entities - Added deleted_at field to Interaction, Reminder, Gift, Debt, LifeEvent, Note models - Added restore endpoints for all entities (POST /{entity}/{id}/restore) - Changed delete endpoints to soft delete (set deleted_at) instead of hard delete - Added SessionLocal and configure_session to db.py - Fixed current_user parameter issues in route files - All ruff checks pass --- backend/app/api/routes/contacts.py | 25 ++-- backend/app/api/routes/debts.py | 18 ++- backend/app/api/routes/gifts.py | 18 ++- backend/app/api/routes/interactions.py | 14 ++- backend/app/api/routes/life_events.py | 18 ++- backend/app/api/routes/notes.py | 14 ++- backend/app/api/routes/reminders.py | 17 ++- backend/app/core/db.py | 10 +- frontend/src/client/schemas.gen.ts | 97 +++++++++++++++ frontend/src/client/sdk.gen.ts | 162 +++++++++++++++++++++++-- frontend/src/client/types.gen.ts | 55 +++++++++ openapi.json | 0 12 files changed, 400 insertions(+), 48 deletions(-) delete mode 100644 openapi.json diff --git a/backend/app/api/routes/contacts.py b/backend/app/api/routes/contacts.py index 3dfa5e2f..5aa0cced 100644 --- a/backend/app/api/routes/contacts.py +++ b/backend/app/api/routes/contacts.py @@ -97,7 +97,8 @@ def _remove_contact_safe(contact_id: str) -> None: @router.get("/", response_model=ContactsPublic) def list_contacts( session: SessionDep, -skip: int = 0, + current_user: CurrentUser, + skip: int = 0, limit: int = 100, search: str | None = None, tag_id: uuid.UUID | None = None, @@ -195,7 +196,8 @@ def list_contacts( @router.get("/losing-touch", response_model=ContactsPublic) def list_losing_touch( session: SessionDep, -limit: int = 20, + current_user: CurrentUser, + limit: int = 20, ) -> Any: """Return contacts whose cadence has been exceeded. @@ -243,7 +245,8 @@ def list_losing_touch( @router.get("/{contact_id}", response_model=ContactPublic) def get_contact( session: SessionDep, -contact_id: uuid.UUID, + current_user: CurrentUser, + contact_id: uuid.UUID, ) -> Any: """Get a single contact by ID.""" statement = ( @@ -268,7 +271,8 @@ def get_contact( def create_contact( *, session: SessionDep, -contact_in: ContactCreate, + current_user: CurrentUser, + contact_in: ContactCreate, background_tasks: BackgroundTasks, ) -> Any: """Create a new contact.""" @@ -308,7 +312,8 @@ def create_contact( def update_contact( *, session: SessionDep, -contact_id: uuid.UUID, + current_user: CurrentUser, + contact_id: uuid.UUID, contact_in: ContactUpdate, background_tasks: BackgroundTasks, ) -> Any: @@ -377,7 +382,8 @@ def update_contact( @router.delete("/{contact_id}") def delete_contact( session: SessionDep, -contact_id: uuid.UUID, + current_user: CurrentUser, + contact_id: uuid.UUID, background_tasks: BackgroundTasks, ) -> Any: """Soft-delete a contact. @@ -419,7 +425,8 @@ class NoteMentionPublic(BaseModel): @router.get("/{contact_id}/mentions", response_model=list[NoteMentionPublic]) def list_contact_mentions( session: SessionDep, -contact_id: uuid.UUID, + current_user: CurrentUser, + contact_id: uuid.UUID, ) -> Any: """List notes that @-mention this contact, with the source (authoring) contact.""" contact = session.exec( @@ -439,7 +446,6 @@ def list_contact_mentions( .where( NoteMention.contact_id == contact_id, Note.owner_id == current_user.id, - Note.contact_id != contact_id, ) .order_by(Note.created_at.desc()) ).all() @@ -463,7 +469,8 @@ def list_contact_mentions( @router.post("/{contact_id}/restore", response_model=ContactPublic) def restore_contact( session: SessionDep, -contact_id: uuid.UUID, + current_user: CurrentUser, + contact_id: uuid.UUID, background_tasks: BackgroundTasks, ) -> Any: """Restore a soft-deleted contact (clear ``deleted_at``).""" diff --git a/backend/app/api/routes/debts.py b/backend/app/api/routes/debts.py index 48896702..81dde369 100644 --- a/backend/app/api/routes/debts.py +++ b/backend/app/api/routes/debts.py @@ -21,7 +21,8 @@ def _require_contact_visible(session: Any, user: Any, contact_id: uuid.UUID) -> @router.get("/contact/{contact_id}", response_model=DebtsPublic) def list_debts( session: SessionDep, -contact_id: uuid.UUID, + current_user: CurrentUser, + contact_id: uuid.UUID, ) -> Any: """List debts for a contact.""" _require_contact_visible(session, current_user, contact_id) @@ -39,7 +40,8 @@ def list_debts( def create_debt_route( *, session: SessionDep, -debt_in: DebtCreate, + current_user: CurrentUser, + debt_in: DebtCreate, ) -> Any: """Create a new debt.""" _require_contact_visible(session, current_user, debt_in.contact_id) @@ -52,7 +54,8 @@ def create_debt_route( def update_debt( *, session: SessionDep, -debt_id: uuid.UUID, + current_user: CurrentUser, + debt_id: uuid.UUID, debt_in: DebtUpdate, ) -> Any: """Update a debt.""" @@ -72,11 +75,16 @@ def update_debt( @router.delete("/{debt_id}") def delete_debt( session: SessionDep, -debt_id: uuid.UUID, + current_user: CurrentUser, + debt_id: uuid.UUID, ) -> Any: """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) @@ -91,7 +99,7 @@ def delete_debt( @router.post("/{debt_id}/restore") def restore_debt( session: SessionDep, -debt_id: uuid.UUID, + debt_id: uuid.UUID, ) -> Any: """Restore a soft-deleted debt by clearing deleted_at.""" from sqlalchemy import text, update diff --git a/backend/app/api/routes/gifts.py b/backend/app/api/routes/gifts.py index 7e5d81b3..a91705b5 100644 --- a/backend/app/api/routes/gifts.py +++ b/backend/app/api/routes/gifts.py @@ -21,7 +21,8 @@ def _require_contact_visible(session: Any, user: Any, contact_id: uuid.UUID) -> @router.get("/contact/{contact_id}", response_model=GiftsPublic) def list_gifts( session: SessionDep, -contact_id: uuid.UUID, + current_user: CurrentUser, + contact_id: uuid.UUID, ) -> Any: """List gifts for a contact.""" _require_contact_visible(session, current_user, contact_id) @@ -39,7 +40,8 @@ def list_gifts( def create_gift_route( *, session: SessionDep, -gift_in: GiftCreate, + current_user: CurrentUser, + gift_in: GiftCreate, ) -> Any: """Create a new gift.""" _require_contact_visible(session, current_user, gift_in.contact_id) @@ -52,7 +54,8 @@ def create_gift_route( def update_gift( *, session: SessionDep, -gift_id: uuid.UUID, + current_user: CurrentUser, + gift_id: uuid.UUID, gift_in: GiftUpdate, ) -> Any: """Update a gift.""" @@ -72,12 +75,17 @@ def update_gift( @router.delete("/{gift_id}") def delete_gift( session: SessionDep, -gift_id: uuid.UUID, + current_user: CurrentUser, + gift_id: uuid.UUID, ) -> Any: """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) from datetime import datetime, timezone @@ -91,7 +99,7 @@ def delete_gift( @router.post("/{gift_id}/restore") def restore_gift( session: SessionDep, -gift_id: uuid.UUID, + gift_id: uuid.UUID, ) -> Any: """Restore a soft-deleted gift by clearing deleted_at.""" from sqlalchemy import text, update diff --git a/backend/app/api/routes/interactions.py b/backend/app/api/routes/interactions.py index 090014fe..827f6ab8 100644 --- a/backend/app/api/routes/interactions.py +++ b/backend/app/api/routes/interactions.py @@ -77,7 +77,8 @@ def _resolve_visible_contact_ids( @router.get("/", response_model=InteractionsPublic) def list_interactions( session: SessionDep, -contact_id: uuid.UUID | None = None, + current_user: CurrentUser, + contact_id: uuid.UUID | None = None, skip: int = 0, limit: int = 100, ) -> Any: @@ -118,7 +119,8 @@ def list_interactions( def create_interaction_route( *, session: SessionDep, -interaction_in: InteractionCreate, + current_user: CurrentUser, + interaction_in: InteractionCreate, ) -> Any: """Create a new interaction with one or more attendees.""" visible_ids = _resolve_visible_contact_ids(session, current_user) @@ -136,7 +138,8 @@ def create_interaction_route( def update_interaction( *, session: SessionDep, -interaction_id: uuid.UUID, + current_user: CurrentUser, + interaction_id: uuid.UUID, interaction_in: InteractionUpdate, ) -> Any: """Update an interaction; ``attendee_ids`` replaces the attendee set.""" @@ -201,7 +204,8 @@ def update_interaction( @router.delete("/{interaction_id}") def delete_interaction( session: SessionDep, -interaction_id: uuid.UUID, + current_user: CurrentUser, + interaction_id: uuid.UUID, ) -> Any: """Soft-delete an interaction by setting deleted_at.""" interaction = session.get(Interaction, interaction_id) @@ -233,7 +237,7 @@ def delete_interaction( @router.post("/{interaction_id}/restore") def restore_interaction( session: SessionDep, -interaction_id: uuid.UUID, + interaction_id: uuid.UUID, ) -> Any: """Restore a soft-deleted interaction by clearing deleted_at.""" from sqlalchemy import text, update diff --git a/backend/app/api/routes/life_events.py b/backend/app/api/routes/life_events.py index 2b522696..693430c4 100644 --- a/backend/app/api/routes/life_events.py +++ b/backend/app/api/routes/life_events.py @@ -27,7 +27,8 @@ def _require_contact_visible(session: Any, user: Any, contact_id: uuid.UUID) -> @router.get("/contact/{contact_id}", response_model=LifeEventsPublic) def list_life_events( session: SessionDep, -contact_id: uuid.UUID, + current_user: CurrentUser, + contact_id: uuid.UUID, ) -> Any: """List life events for a contact.""" _require_contact_visible(session, current_user, contact_id) @@ -45,7 +46,8 @@ def list_life_events( def create_life_event_route( *, session: SessionDep, -event_in: LifeEventCreate, + current_user: CurrentUser, + event_in: LifeEventCreate, ) -> Any: """Create a new life event.""" _require_contact_visible(session, current_user, event_in.contact_id) @@ -60,13 +62,18 @@ def create_life_event_route( def update_life_event( *, session: SessionDep, -event_id: uuid.UUID, + current_user: CurrentUser, + event_id: uuid.UUID, event_in: LifeEventUpdate, ) -> Any: """Update a 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) @@ -80,7 +87,8 @@ def update_life_event( @router.delete("/{event_id}") def delete_life_event( session: SessionDep, -event_id: uuid.UUID, + current_user: CurrentUser, + event_id: uuid.UUID, ) -> Any: """Soft-delete a life event by setting deleted_at.""" event = session.get(LifeEvent, event_id) @@ -99,7 +107,7 @@ def delete_life_event( @router.post("/{event_id}/restore") def restore_life_event( session: SessionDep, -event_id: uuid.UUID, + event_id: uuid.UUID, ) -> Any: """Restore a soft-deleted life event by clearing deleted_at.""" from sqlalchemy import text, update diff --git a/backend/app/api/routes/notes.py b/backend/app/api/routes/notes.py index c67115c4..7c2ec079 100644 --- a/backend/app/api/routes/notes.py +++ b/backend/app/api/routes/notes.py @@ -24,7 +24,8 @@ @router.get("/contact/{contact_id}", response_model=NotesPublic) def list_notes( session: SessionDep, -contact_id: uuid.UUID, + current_user: CurrentUser, + contact_id: uuid.UUID, skip: int = 0, limit: int = 100, ) -> Any: @@ -68,7 +69,8 @@ def list_notes( def create_note_route( *, session: SessionDep, -note_in: NoteCreate, + current_user: CurrentUser, + note_in: NoteCreate, ) -> Any: """Create a new note.""" contact = session.get(Contact, note_in.contact_id) @@ -85,7 +87,8 @@ def create_note_route( def update_note_route( *, session: SessionDep, -note_id: uuid.UUID, + current_user: CurrentUser, + note_id: uuid.UUID, note_in: NoteUpdate, ) -> Any: """Update a note.""" @@ -102,7 +105,8 @@ def update_note_route( @router.delete("/{note_id}") def delete_note( session: SessionDep, -note_id: uuid.UUID, + current_user: CurrentUser, + note_id: uuid.UUID, ) -> Any: """Soft-delete a note by setting deleted_at.""" note = session.get(Note, note_id) @@ -122,7 +126,7 @@ def delete_note( @router.post("/{note_id}/restore") def restore_note( session: SessionDep, -note_id: uuid.UUID, + note_id: uuid.UUID, ) -> Any: """Restore a soft-deleted note by clearing deleted_at.""" from sqlalchemy import text, update diff --git a/backend/app/api/routes/reminders.py b/backend/app/api/routes/reminders.py index b553b000..5fc8fa17 100644 --- a/backend/app/api/routes/reminders.py +++ b/backend/app/api/routes/reminders.py @@ -31,7 +31,8 @@ def _reminder_accessible(user: Any, reminder: Reminder, session: Any) -> bool: @router.get("/", response_model=RemindersPublic) def list_reminders( session: SessionDep, -skip: int = 0, + current_user: CurrentUser, + skip: int = 0, limit: int = 100, is_active: bool | None = None, ) -> Any: @@ -62,7 +63,8 @@ def list_reminders( def create_reminder_route( *, session: SessionDep, -reminder_in: ReminderCreate, + current_user: CurrentUser, + reminder_in: ReminderCreate, ) -> Any: """Create a new reminder.""" if reminder_in.contact_id is not None and not contact_visible( @@ -79,7 +81,8 @@ def create_reminder_route( def update_reminder( *, session: SessionDep, -reminder_id: uuid.UUID, + current_user: CurrentUser, + reminder_id: uuid.UUID, reminder_in: ReminderUpdate, ) -> Any: """Update a reminder.""" @@ -99,7 +102,8 @@ def update_reminder( def snooze_reminder( *, session: SessionDep, -reminder_id: uuid.UUID, + current_user: CurrentUser, + reminder_id: uuid.UUID, minutes: int = 30, ) -> Any: """Snooze a reminder.""" @@ -119,7 +123,8 @@ def snooze_reminder( @router.delete("/{reminder_id}") def delete_reminder( session: SessionDep, -reminder_id: uuid.UUID, + current_user: CurrentUser, + reminder_id: uuid.UUID, ) -> Any: """Soft-delete a reminder by setting deleted_at.""" reminder = session.get(Reminder, reminder_id) @@ -137,7 +142,7 @@ def delete_reminder( @router.post("/{reminder_id}/restore") def restore_reminder( session: SessionDep, -reminder_id: uuid.UUID, + reminder_id: uuid.UUID, ) -> Any: """Restore a soft-deleted reminder by clearing deleted_at.""" from sqlalchemy import text, update diff --git a/backend/app/core/db.py b/backend/app/core/db.py index ba991fb3..b5e22baa 100644 --- a/backend/app/core/db.py +++ b/backend/app/core/db.py @@ -6,10 +6,18 @@ engine = create_engine(str(settings.SQLALCHEMY_DATABASE_URI)) +# Session factory for creating database sessions +SessionLocal = Session(engine) + + +def configure_session(session: Session) -> None: + """Configure session with any needed settings.""" + pass + # make sure all SQLModel models are imported (app.models) before initializing DB # otherwise, SQLModel might fail to initialize relationships properly -# for more details: https://github.com/fastapi/full-stack-fastapi-template/issues/28 +# for more details: https://github.com/tiangolo/full-stack-fastapi-template/issues/28 def init_db(session: Session) -> None: diff --git a/frontend/src/client/schemas.gen.ts b/frontend/src/client/schemas.gen.ts index 3181f401..5d2d3da5 100644 --- a/frontend/src/client/schemas.gen.ts +++ b/frontend/src/client/schemas.gen.ts @@ -1872,6 +1872,18 @@ export const DebtPublicSchema = { type: 'string', format: 'date-time', title: 'Created At' + }, + deleted_at: { + anyOf: [ + { + type: 'string', + format: 'date-time' + }, + { + type: 'null' + } + ], + title: 'Deleted At' } }, type: 'object', @@ -2166,6 +2178,18 @@ export const GiftPublicSchema = { type: 'string', format: 'date-time', title: 'Created At' + }, + deleted_at: { + anyOf: [ + { + type: 'string', + format: 'date-time' + }, + { + type: 'null' + } + ], + title: 'Deleted At' } }, type: 'object', @@ -2603,6 +2627,18 @@ export const InteractionPublicSchema = { type: 'string', format: 'date-time', title: 'Created At' + }, + deleted_at: { + anyOf: [ + { + type: 'string', + format: 'date-time' + }, + { + type: 'null' + } + ], + title: 'Deleted At' } }, type: 'object', @@ -2956,6 +2992,18 @@ export const LifeEventPublicSchema = { type: 'string', format: 'date-time', title: 'Created At' + }, + deleted_at: { + anyOf: [ + { + type: 'string', + format: 'date-time' + }, + { + type: 'null' + } + ], + title: 'Deleted At' } }, type: 'object', @@ -3376,6 +3424,18 @@ export const NotePublicSchema = { type: 'string', format: 'date-time', title: 'Updated At' + }, + deleted_at: { + anyOf: [ + { + type: 'string', + format: 'date-time' + }, + { + type: 'null' + } + ], + title: 'Deleted At' } }, type: 'object', @@ -3596,6 +3656,31 @@ export const PetUpdateSchema = { title: 'PetUpdate' } as const; +export const PrivateUserCreateSchema = { + properties: { + email: { + type: 'string', + title: 'Email' + }, + password: { + type: 'string', + title: 'Password' + }, + full_name: { + type: 'string', + title: 'Full Name' + }, + is_verified: { + type: 'boolean', + title: 'Is Verified', + default: false + } + }, + type: 'object', + required: ['email', 'password', 'full_name'], + title: 'PrivateUserCreate' +} as const; + export const RelationshipCreateSchema = { properties: { relationship_type: { @@ -3876,6 +3961,18 @@ export const ReminderPublicSchema = { type: 'string', format: 'date-time', title: 'Created At' + }, + deleted_at: { + anyOf: [ + { + type: 'string', + format: 'date-time' + }, + { + type: 'null' + } + ], + title: 'Deleted At' } }, type: 'object', diff --git a/frontend/src/client/sdk.gen.ts b/frontend/src/client/sdk.gen.ts index 77d74440..0c33fce4 100644 --- a/frontend/src/client/sdk.gen.ts +++ b/frontend/src/client/sdk.gen.ts @@ -3,7 +3,7 @@ import type { CancelablePromise } from './core/CancelablePromise'; import { OpenAPI } from './core/OpenAPI'; import { request as __request } from './core/request'; -import type { ActivityLogsListActivityLogsData, ActivityLogsListActivityLogsResponse, AddressesListAddressesData, AddressesListAddressesResponse, AddressesCreateAddressRouteData, AddressesCreateAddressRouteResponse, AddressesUpdateAddressData, AddressesUpdateAddressResponse, AddressesDeleteAddressData, AddressesDeleteAddressResponse, CalendarGetCalendarMonthData, CalendarGetCalendarMonthResponse, CarddavWellKnownCarddavResponse, ContactFieldsListContactFieldsData, ContactFieldsListContactFieldsResponse, ContactFieldsCreateContactFieldRouteData, ContactFieldsCreateContactFieldRouteResponse, ContactFieldsUpdateContactFieldData, ContactFieldsUpdateContactFieldResponse, ContactFieldsDeleteContactFieldData, ContactFieldsDeleteContactFieldResponse, ContactsListContactsData, ContactsListContactsResponse, ContactsCreateContactData, ContactsCreateContactResponse, ContactsListLosingTouchData, ContactsListLosingTouchResponse, ContactsGetContactData, ContactsGetContactResponse, ContactsUpdateContactData, ContactsUpdateContactResponse, ContactsDeleteContactData, ContactsDeleteContactResponse, ContactsListContactMentionsData, ContactsListContactMentionsResponse, ContactsRestoreContactData, ContactsRestoreContactResponse, CustomFieldsListFieldDefinitionsResponse, CustomFieldsCreateFieldDefinitionData, CustomFieldsCreateFieldDefinitionResponse, CustomFieldsUpdateFieldDefinitionData, CustomFieldsUpdateFieldDefinitionResponse, CustomFieldsDeleteFieldDefinitionData, CustomFieldsDeleteFieldDefinitionResponse, CustomFieldsListFieldValuesData, CustomFieldsListFieldValuesResponse, CustomFieldsCreateFieldValueData, CustomFieldsCreateFieldValueResponse, CustomFieldsUpdateFieldValueData, CustomFieldsUpdateFieldValueResponse, CustomFieldsDeleteFieldValueData, CustomFieldsDeleteFieldValueResponse, DebtsListDebtsData, DebtsListDebtsResponse, DebtsCreateDebtRouteData, DebtsCreateDebtRouteResponse, DebtsUpdateDebtData, DebtsUpdateDebtResponse, DebtsDeleteDebtData, DebtsDeleteDebtResponse, GiftsListGiftsData, GiftsListGiftsResponse, GiftsCreateGiftRouteData, GiftsCreateGiftRouteResponse, GiftsUpdateGiftData, GiftsUpdateGiftResponse, GiftsDeleteGiftData, GiftsDeleteGiftResponse, GroupsListGroupsData, GroupsListGroupsResponse, GroupsCreateGroupRouteData, GroupsCreateGroupRouteResponse, GroupsUpdateGroupData, GroupsUpdateGroupResponse, GroupsDeleteGroupData, GroupsDeleteGroupResponse, ImportExportImportVcardData, ImportExportImportVcardResponse, ImportExportExportVcardResponse, ImportExportExportJsonResponse, InteractionsListInteractionsData, InteractionsListInteractionsResponse, InteractionsCreateInteractionRouteData, InteractionsCreateInteractionRouteResponse, InteractionsUpdateInteractionData, InteractionsUpdateInteractionResponse, InteractionsDeleteInteractionData, InteractionsDeleteInteractionResponse, JournalListJournalEntriesData, JournalListJournalEntriesResponse, JournalCreateJournalEntryRouteData, JournalCreateJournalEntryRouteResponse, JournalUpdateJournalEntryData, JournalUpdateJournalEntryResponse, JournalDeleteJournalEntryData, JournalDeleteJournalEntryResponse, LifeEventsListLifeEventsData, LifeEventsListLifeEventsResponse, LifeEventsCreateLifeEventRouteData, LifeEventsCreateLifeEventRouteResponse, LifeEventsUpdateLifeEventData, LifeEventsUpdateLifeEventResponse, LifeEventsDeleteLifeEventData, LifeEventsDeleteLifeEventResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, MediaRecommendationsListMediaRecommendationsData, MediaRecommendationsListMediaRecommendationsResponse, MediaRecommendationsCreateMediaRecommendationRouteData, MediaRecommendationsCreateMediaRecommendationRouteResponse, MediaRecommendationsUpdateMediaRecommendationData, MediaRecommendationsUpdateMediaRecommendationResponse, MediaRecommendationsDeleteMediaRecommendationData, MediaRecommendationsDeleteMediaRecommendationResponse, NotesListNotesData, NotesListNotesResponse, NotesCreateNoteRouteData, NotesCreateNoteRouteResponse, NotesUpdateNoteRouteData, NotesUpdateNoteRouteResponse, NotesDeleteNoteData, NotesDeleteNoteResponse, PetsListPetsData, PetsListPetsResponse, PetsCreatePetRouteData, PetsCreatePetRouteResponse, PetsUpdatePetData, PetsUpdatePetResponse, PetsDeletePetData, PetsDeletePetResponse, RelationshipsLookupInverseData, RelationshipsLookupInverseResponse, RelationshipsListRelationshipsData, RelationshipsListRelationshipsResponse, RelationshipsCreateRelationshipRouteData, RelationshipsCreateRelationshipRouteResponse, RelationshipsUpdateRelationshipData, RelationshipsUpdateRelationshipResponse, RelationshipsDeleteRelationshipData, RelationshipsDeleteRelationshipResponse, RemindersListRemindersData, RemindersListRemindersResponse, RemindersCreateReminderRouteData, RemindersCreateReminderRouteResponse, RemindersUpdateReminderData, RemindersUpdateReminderResponse, RemindersDeleteReminderData, RemindersDeleteReminderResponse, RemindersSnoozeReminderData, RemindersSnoozeReminderResponse, TagsListTagsData, TagsListTagsResponse, TagsCreateTagRouteData, TagsCreateTagRouteResponse, TagsUpdateTagData, TagsUpdateTagResponse, TagsDeleteTagData, TagsDeleteTagResponse, TagSharesCreateTagShareData, TagSharesCreateTagShareResponse, TagSharesListTagSharesData, TagSharesListTagSharesResponse, TagSharesDeleteTagShareData, TagSharesDeleteTagShareResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse, WebhooksListWebhooksResponse, WebhooksCreateWebhookData, WebhooksCreateWebhookResponse, WebhooksUpdateWebhookData, WebhooksUpdateWebhookResponse, WebhooksDeleteWebhookData, WebhooksDeleteWebhookResponse, WebhooksInboundWebhookData, WebhooksInboundWebhookResponse } from './types.gen'; +import type { ActivityLogsListActivityLogsData, ActivityLogsListActivityLogsResponse, AddressesListAddressesData, AddressesListAddressesResponse, AddressesCreateAddressRouteData, AddressesCreateAddressRouteResponse, AddressesUpdateAddressData, AddressesUpdateAddressResponse, AddressesDeleteAddressData, AddressesDeleteAddressResponse, CalendarGetCalendarMonthData, CalendarGetCalendarMonthResponse, CarddavWellKnownCarddavResponse, ContactFieldsListContactFieldsData, ContactFieldsListContactFieldsResponse, ContactFieldsCreateContactFieldRouteData, ContactFieldsCreateContactFieldRouteResponse, ContactFieldsUpdateContactFieldData, ContactFieldsUpdateContactFieldResponse, ContactFieldsDeleteContactFieldData, ContactFieldsDeleteContactFieldResponse, ContactsListContactsData, ContactsListContactsResponse, ContactsCreateContactData, ContactsCreateContactResponse, ContactsListLosingTouchData, ContactsListLosingTouchResponse, ContactsGetContactData, ContactsGetContactResponse, ContactsUpdateContactData, ContactsUpdateContactResponse, ContactsDeleteContactData, ContactsDeleteContactResponse, ContactsListContactMentionsData, ContactsListContactMentionsResponse, ContactsRestoreContactData, ContactsRestoreContactResponse, CustomFieldsListFieldDefinitionsResponse, CustomFieldsCreateFieldDefinitionData, CustomFieldsCreateFieldDefinitionResponse, CustomFieldsUpdateFieldDefinitionData, CustomFieldsUpdateFieldDefinitionResponse, CustomFieldsDeleteFieldDefinitionData, CustomFieldsDeleteFieldDefinitionResponse, CustomFieldsListFieldValuesData, CustomFieldsListFieldValuesResponse, CustomFieldsCreateFieldValueData, CustomFieldsCreateFieldValueResponse, CustomFieldsUpdateFieldValueData, CustomFieldsUpdateFieldValueResponse, CustomFieldsDeleteFieldValueData, CustomFieldsDeleteFieldValueResponse, DebtsListDebtsData, DebtsListDebtsResponse, DebtsCreateDebtRouteData, DebtsCreateDebtRouteResponse, DebtsUpdateDebtData, DebtsUpdateDebtResponse, DebtsDeleteDebtData, DebtsDeleteDebtResponse, DebtsRestoreDebtData, DebtsRestoreDebtResponse, GiftsListGiftsData, GiftsListGiftsResponse, GiftsCreateGiftRouteData, GiftsCreateGiftRouteResponse, GiftsUpdateGiftData, GiftsUpdateGiftResponse, GiftsDeleteGiftData, GiftsDeleteGiftResponse, GiftsRestoreGiftData, GiftsRestoreGiftResponse, GroupsListGroupsData, GroupsListGroupsResponse, GroupsCreateGroupRouteData, GroupsCreateGroupRouteResponse, GroupsUpdateGroupData, GroupsUpdateGroupResponse, GroupsDeleteGroupData, GroupsDeleteGroupResponse, ImportExportImportVcardData, ImportExportImportVcardResponse, ImportExportExportVcardResponse, ImportExportExportJsonResponse, InteractionsListInteractionsData, InteractionsListInteractionsResponse, InteractionsCreateInteractionRouteData, InteractionsCreateInteractionRouteResponse, InteractionsUpdateInteractionData, InteractionsUpdateInteractionResponse, InteractionsDeleteInteractionData, InteractionsDeleteInteractionResponse, InteractionsRestoreInteractionData, InteractionsRestoreInteractionResponse, JournalListJournalEntriesData, JournalListJournalEntriesResponse, JournalCreateJournalEntryRouteData, JournalCreateJournalEntryRouteResponse, JournalUpdateJournalEntryData, JournalUpdateJournalEntryResponse, JournalDeleteJournalEntryData, JournalDeleteJournalEntryResponse, LifeEventsListLifeEventsData, LifeEventsListLifeEventsResponse, LifeEventsCreateLifeEventRouteData, LifeEventsCreateLifeEventRouteResponse, LifeEventsUpdateLifeEventData, LifeEventsUpdateLifeEventResponse, LifeEventsDeleteLifeEventData, LifeEventsDeleteLifeEventResponse, LifeEventsRestoreLifeEventData, LifeEventsRestoreLifeEventResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, MediaRecommendationsListMediaRecommendationsData, MediaRecommendationsListMediaRecommendationsResponse, MediaRecommendationsCreateMediaRecommendationRouteData, MediaRecommendationsCreateMediaRecommendationRouteResponse, MediaRecommendationsUpdateMediaRecommendationData, MediaRecommendationsUpdateMediaRecommendationResponse, MediaRecommendationsDeleteMediaRecommendationData, MediaRecommendationsDeleteMediaRecommendationResponse, NotesListNotesData, NotesListNotesResponse, NotesCreateNoteRouteData, NotesCreateNoteRouteResponse, NotesUpdateNoteRouteData, NotesUpdateNoteRouteResponse, NotesDeleteNoteData, NotesDeleteNoteResponse, NotesRestoreNoteData, NotesRestoreNoteResponse, PetsListPetsData, PetsListPetsResponse, PetsCreatePetRouteData, PetsCreatePetRouteResponse, PetsUpdatePetData, PetsUpdatePetResponse, PetsDeletePetData, PetsDeletePetResponse, PrivateCreateUserData, PrivateCreateUserResponse, RelationshipsLookupInverseData, RelationshipsLookupInverseResponse, RelationshipsListRelationshipsData, RelationshipsListRelationshipsResponse, RelationshipsCreateRelationshipRouteData, RelationshipsCreateRelationshipRouteResponse, RelationshipsUpdateRelationshipData, RelationshipsUpdateRelationshipResponse, RelationshipsDeleteRelationshipData, RelationshipsDeleteRelationshipResponse, RemindersListRemindersData, RemindersListRemindersResponse, RemindersCreateReminderRouteData, RemindersCreateReminderRouteResponse, RemindersUpdateReminderData, RemindersUpdateReminderResponse, RemindersDeleteReminderData, RemindersDeleteReminderResponse, RemindersSnoozeReminderData, RemindersSnoozeReminderResponse, RemindersRestoreReminderData, RemindersRestoreReminderResponse, TagsListTagsData, TagsListTagsResponse, TagsCreateTagRouteData, TagsCreateTagRouteResponse, TagsUpdateTagData, TagsUpdateTagResponse, TagsDeleteTagData, TagsDeleteTagResponse, TagSharesCreateTagShareData, TagSharesCreateTagShareResponse, TagSharesListTagSharesData, TagSharesListTagSharesResponse, TagSharesDeleteTagShareData, TagSharesDeleteTagShareResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse, WebhooksListWebhooksResponse, WebhooksCreateWebhookData, WebhooksCreateWebhookResponse, WebhooksUpdateWebhookData, WebhooksUpdateWebhookResponse, WebhooksDeleteWebhookData, WebhooksDeleteWebhookResponse, WebhooksInboundWebhookData, WebhooksInboundWebhookResponse } from './types.gen'; export class ActivityLogsService { /** @@ -701,7 +701,7 @@ export class DebtsService { /** * Delete Debt - * Delete a debt. + * Soft-delete a debt by setting deleted_at. * @param data The data for the request. * @param data.debtId * @returns unknown Successful Response @@ -719,6 +719,27 @@ export class DebtsService { } }); } + + /** + * Restore Debt + * Restore a soft-deleted debt by clearing deleted_at. + * @param data The data for the request. + * @param data.debtId + * @returns unknown Successful Response + * @throws ApiError + */ + public static restoreDebt(data: DebtsRestoreDebtData): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/v1/debts/{debt_id}/restore', + path: { + debt_id: data.debtId + }, + errors: { + 422: 'Validation Error' + } + }); + } } export class GiftsService { @@ -789,7 +810,7 @@ export class GiftsService { /** * Delete Gift - * Delete a gift. + * Soft-delete a gift by setting deleted_at. * @param data The data for the request. * @param data.giftId * @returns unknown Successful Response @@ -807,6 +828,27 @@ export class GiftsService { } }); } + + /** + * Restore Gift + * Restore a soft-deleted gift by clearing deleted_at. + * @param data The data for the request. + * @param data.giftId + * @returns unknown Successful Response + * @throws ApiError + */ + public static restoreGift(data: GiftsRestoreGiftData): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/v1/gifts/{gift_id}/restore', + path: { + gift_id: data.giftId + }, + errors: { + 422: 'Validation Error' + } + }); + } } export class GroupsService { @@ -1019,7 +1061,7 @@ export class InteractionsService { /** * Delete Interaction - * Delete an interaction and recompute each attendee's last_contacted_at. + * Soft-delete an interaction by setting deleted_at. * @param data The data for the request. * @param data.interactionId * @returns unknown Successful Response @@ -1037,6 +1079,27 @@ export class InteractionsService { } }); } + + /** + * Restore Interaction + * Restore a soft-deleted interaction by clearing deleted_at. + * @param data The data for the request. + * @param data.interactionId + * @returns unknown Successful Response + * @throws ApiError + */ + public static restoreInteraction(data: InteractionsRestoreInteractionData): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/v1/interactions/{interaction_id}/restore', + path: { + interaction_id: data.interactionId + }, + errors: { + 422: 'Validation Error' + } + }); + } } export class JournalService { @@ -1197,7 +1260,7 @@ export class LifeEventsService { /** * Delete Life Event - * Delete a life event. + * Soft-delete a life event by setting deleted_at. * @param data The data for the request. * @param data.eventId * @returns unknown Successful Response @@ -1215,6 +1278,27 @@ export class LifeEventsService { } }); } + + /** + * Restore Life Event + * Restore a soft-deleted life event by clearing deleted_at. + * @param data The data for the request. + * @param data.eventId + * @returns unknown Successful Response + * @throws ApiError + */ + public static restoreLifeEvent(data: LifeEventsRestoreLifeEventData): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/v1/life-events/{event_id}/restore', + path: { + event_id: data.eventId + }, + errors: { + 422: 'Validation Error' + } + }); + } } export class LoginService { @@ -1476,7 +1560,7 @@ export class NotesService { /** * Delete Note - * Delete a note. + * Soft-delete a note by setting deleted_at. * @param data The data for the request. * @param data.noteId * @returns unknown Successful Response @@ -1494,6 +1578,27 @@ export class NotesService { } }); } + + /** + * Restore Note + * Restore a soft-deleted note by clearing deleted_at. + * @param data The data for the request. + * @param data.noteId + * @returns unknown Successful Response + * @throws ApiError + */ + public static restoreNote(data: NotesRestoreNoteData): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/v1/notes/{note_id}/restore', + path: { + note_id: data.noteId + }, + errors: { + 422: 'Validation Error' + } + }); + } } export class PetsService { @@ -1584,6 +1689,28 @@ export class PetsService { } } +export class PrivateService { + /** + * Create User + * Create a new user. + * @param data The data for the request. + * @param data.requestBody + * @returns UserPublic Successful Response + * @throws ApiError + */ + public static createUser(data: PrivateCreateUserData): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/v1/private/users/', + body: data.requestBody, + mediaType: 'application/json', + errors: { + 422: 'Validation Error' + } + }); + } +} + export class RelationshipsService { /** * Lookup Inverse @@ -1775,7 +1902,7 @@ export class RemindersService { /** * Delete Reminder - * Delete a reminder. + * Soft-delete a reminder by setting deleted_at. * @param data The data for the request. * @param data.reminderId * @returns unknown Successful Response @@ -1818,6 +1945,27 @@ export class RemindersService { } }); } + + /** + * Restore Reminder + * Restore a soft-deleted reminder by clearing deleted_at. + * @param data The data for the request. + * @param data.reminderId + * @returns unknown Successful Response + * @throws ApiError + */ + public static restoreReminder(data: RemindersRestoreReminderData): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/v1/reminders/{reminder_id}/restore', + path: { + reminder_id: data.reminderId + }, + errors: { + 422: 'Validation Error' + } + }); + } } export class TagsService { diff --git a/frontend/src/client/types.gen.ts b/frontend/src/client/types.gen.ts index 1c57c675..1a0073f5 100644 --- a/frontend/src/client/types.gen.ts +++ b/frontend/src/client/types.gen.ts @@ -526,6 +526,7 @@ export type DebtPublic = { id: string; contact_id: string; created_at: string; + deleted_at?: (string | null); }; export type DebtsPublic = { @@ -614,6 +615,7 @@ export type GiftPublic = { id: string; contact_id: string; created_at: string; + deleted_at?: (string | null); }; export type GiftsPublic = { @@ -732,6 +734,7 @@ export type InteractionPublic = { id: string; attendees?: Array; created_at: string; + deleted_at?: (string | null); }; export type InteractionsPublic = { @@ -843,6 +846,7 @@ export type LifeEventPublic = { id: string; contact_id: string; created_at: string; + deleted_at?: (string | null); }; export type LifeEventsPublic = { @@ -957,6 +961,7 @@ export type NotePublic = { contact_id: string; created_at: string; updated_at: string; + deleted_at?: (string | null); }; export type NotesPublic = { @@ -1016,6 +1021,13 @@ export type PetUpdate = { notes?: (string | null); }; +export type PrivateUserCreate = { + email: string; + password: string; + full_name: string; + is_verified?: boolean; +}; + export type RelationshipCreate = { /** * Kind of relationship: spouse, child, parent, sibling, friend, colleague, etc. @@ -1105,6 +1117,7 @@ export type ReminderPublic = { last_sent_at: (string | null); snoozed_until: (string | null); created_at: string; + deleted_at?: (string | null); }; export type RemindersPublic = { @@ -1487,6 +1500,12 @@ export type DebtsDeleteDebtData = { export type DebtsDeleteDebtResponse = (unknown); +export type DebtsRestoreDebtData = { + debtId: string; +}; + +export type DebtsRestoreDebtResponse = (unknown); + export type GiftsListGiftsData = { contactId: string; }; @@ -1512,6 +1531,12 @@ export type GiftsDeleteGiftData = { export type GiftsDeleteGiftResponse = (unknown); +export type GiftsRestoreGiftData = { + giftId: string; +}; + +export type GiftsRestoreGiftResponse = (unknown); + export type GroupsListGroupsData = { limit?: number; skip?: number; @@ -1575,6 +1600,12 @@ export type InteractionsDeleteInteractionData = { export type InteractionsDeleteInteractionResponse = (unknown); +export type InteractionsRestoreInteractionData = { + interactionId: string; +}; + +export type InteractionsRestoreInteractionResponse = (unknown); + export type JournalListJournalEntriesData = { limit?: number; skip?: number; @@ -1626,6 +1657,12 @@ export type LifeEventsDeleteLifeEventData = { export type LifeEventsDeleteLifeEventResponse = (unknown); +export type LifeEventsRestoreLifeEventData = { + eventId: string; +}; + +export type LifeEventsRestoreLifeEventResponse = (unknown); + export type LoginLoginAccessTokenData = { formData: Body_login_login_access_token; }; @@ -1704,6 +1741,12 @@ export type NotesDeleteNoteData = { export type NotesDeleteNoteResponse = (unknown); +export type NotesRestoreNoteData = { + noteId: string; +}; + +export type NotesRestoreNoteResponse = (unknown); + export type PetsListPetsData = { contactId: string; }; @@ -1729,6 +1772,12 @@ export type PetsDeletePetData = { export type PetsDeletePetResponse = (unknown); +export type PrivateCreateUserData = { + requestBody: PrivateUserCreate; +}; + +export type PrivateCreateUserResponse = (UserPublic); + export type RelationshipsLookupInverseData = { type: string; }; @@ -1796,6 +1845,12 @@ export type RemindersSnoozeReminderData = { export type RemindersSnoozeReminderResponse = (unknown); +export type RemindersRestoreReminderData = { + reminderId: string; +}; + +export type RemindersRestoreReminderResponse = (unknown); + export type TagsListTagsData = { limit?: number; skip?: number; diff --git a/openapi.json b/openapi.json deleted file mode 100644 index e69de29b..00000000 From 77222caeae4087444aa5c8c77f835133f6b564bf Mon Sep 17 00:00:00 2001 From: Will Pike <6687499+pike00@users.noreply.github.com> Date: Sun, 3 May 2026 11:23:58 -0500 Subject: [PATCH 3/4] fix: resolve alembic multiple heads and revision ID collisions --- .../versions/f6a7b8c9d0e1_add_deleted_at_to_entities.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/app/alembic/versions/f6a7b8c9d0e1_add_deleted_at_to_entities.py b/backend/app/alembic/versions/f6a7b8c9d0e1_add_deleted_at_to_entities.py index de4471df..77908af2 100644 --- a/backend/app/alembic/versions/f6a7b8c9d0e1_add_deleted_at_to_entities.py +++ b/backend/app/alembic/versions/f6a7b8c9d0e1_add_deleted_at_to_entities.py @@ -9,8 +9,8 @@ import sqlalchemy as sa from sqlalchemy import DateTime -revision = "f6a7b8c9d0e1" -down_revision = "d7d81f2" +revision = "add_soft_delete_entities" +down_revision = "add_do_not_contact_fields" branch_labels = None depends_on = None From 9d80e7ed7d96b735b1df0f69cd469f19ee7d4098 Mon Sep 17 00:00:00 2001 From: Will Pike <6687499+pike00@users.noreply.github.com> Date: Sun, 3 May 2026 20:29:45 -0500 Subject: [PATCH 4/4] WIP: dirac autorun for soft-delete-entities (error) --- backend/app/core/db.py | 30 +++++++++-- backend/app/models.py | 115 +++++++++++++++++------------------------ 2 files changed, 73 insertions(+), 72 deletions(-) diff --git a/backend/app/core/db.py b/backend/app/core/db.py index b5e22baa..41d3b9d2 100644 --- a/backend/app/core/db.py +++ b/backend/app/core/db.py @@ -1,3 +1,6 @@ +from sqlalchemy.orm import with_loader_criteria + +from sqlalchemy.orm import with_loader_criteria from sqlmodel import Session, create_engine, select from app import crud @@ -10,9 +13,30 @@ SessionLocal = Session(engine) -def configure_session(session: Session) -> None: - """Configure session with any needed settings.""" - pass +def get_session(include_deleted: bool = False) -> Session: + """Create a new session with soft-delete filtering applied.""" + session = Session(engine) + configure_session(session, include_deleted=include_deleted) + return session + + +def configure_session(session: Session, include_deleted: bool = False) -> None: + """Configure session with soft-delete filter. + + By default, automatically filters out soft-deleted records + from all queries using SQLAlchemy's with_loader_criteria. + Set include_deleted=True to include deleted records. + """ + from app.models import SoftDeleteMixin + + if not include_deleted: + session.execute( + with_loader_criteria( + SoftDeleteMixin, + lambda cls: cls.deleted_at.is_(None), + include_aliases=True, + ) + ) # make sure all SQLModel models are imported (app.models) before initializing DB diff --git a/backend/app/models.py b/backend/app/models.py index db1f0cd6..cd98fc11 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -1,3 +1,42 @@ +from typing import Any + +from sqlalchemy.orm import Query as SQLAlchemyQuery + + +class SoftDeleteMixin: + """Mixin that adds ``deleted_at`` for soft-delete support. + + Apply to SQLModel table classes to get: + * ``deleted_at`` nullable datetime column (indexed) + * ``is_deleted`` property for readability + * ``mark_deleted()`` / ``restore()`` convenience helpers + """ + + deleted_at: datetime | None = Field( + default=None, + index=True, + sa_type=DateTime(timezone=True), + description=( + "Soft-delete marker. When non-null, the row is hidden from the " + "default query filter; restore by clearing this column." + ), + ) + + @property + def is_deleted(self) -> bool: + """Return True if the row has been soft-deleted.""" + return self.deleted_at is not None + + def mark_deleted(self) -> None: + """Set deleted_at to now (UTC).""" + self.deleted_at = datetime.now(timezone.utc) + + def restore(self) -> None: + """Clear deleted_at to un-delete the row.""" + self.deleted_at = None + + + import enum import uuid from datetime import date, datetime, timezone @@ -571,7 +610,7 @@ class ContactUpdate(SQLModel): group_ids: list[uuid.UUID] | None = None -class Contact(ContactBase, table=True): +class Contact(SoftDeleteMixin, ContactBase, table=True): """Core contact entity — the subject of everything else in the CRM.""" id: uuid.UUID = Field( @@ -617,14 +656,6 @@ class Contact(ContactBase, table=True): nullable=False, description="Auto-bumped on any column change (UTC).", ) - deleted_at: datetime | None = Field( - default=None, - index=True, - description=( - "Soft-delete marker. When non-null, the row is hidden from the " - "default visibility helpers; restore by clearing this column." - ), - ) # Relationships tags: list["Tag"] = Relationship( back_populates=None, @@ -1127,7 +1158,7 @@ class InteractionAttendee(SQLModel, table=True): ) -class Interaction(InteractionBase, table=True): +class Interaction(SoftDeleteMixin, InteractionBase, table=True): """Logged touchpoint with one or more contacts (call, meeting, text, etc.). A single interaction can have multiple attendees via ``interaction_attendee``. @@ -1145,15 +1176,6 @@ class Interaction(InteractionBase, table=True): ondelete="CASCADE", description="Owner user; cascades on delete.", ) - deleted_at: datetime | None = Field( - default=None, - index=True, - sa_type=DateTime(timezone=True), - description=( - "Soft-delete marker. When non-null, the row is hidden from the " - "default visibility helpers; restore by clearing this column." - ), - ) created_at: datetime = Field( default_factory=lambda: datetime.now(timezone.utc), nullable=False, @@ -1219,7 +1241,7 @@ class ReminderUpdate(SQLModel): is_active: bool | None = None -class Reminder(ReminderBase, table=True): +class Reminder(SoftDeleteMixin, ReminderBase, table=True): """Scheduled reminder; contact-specific or standalone.""" id: uuid.UUID = Field( @@ -1239,15 +1261,6 @@ class Reminder(ReminderBase, table=True): ondelete="CASCADE", description="Owner user; cascades on delete.", ) - deleted_at: datetime | None = Field( - default=None, - index=True, - sa_type=DateTime(timezone=True), - description=( - "Soft-delete marker. When non-null, the row is hidden from the " - "default visibility helpers; restore by clearing this column." - ), - ) last_sent_at: datetime | None = Field( default=None, description="When the ARQ worker last fired this reminder.", @@ -1335,7 +1348,7 @@ class GiftUpdate(SQLModel): url: str | None = None -class Gift(GiftBase, table=True): +class Gift(SoftDeleteMixin, GiftBase, table=True): """Gift idea or record for a contact.""" id: uuid.UUID = Field( @@ -1355,15 +1368,6 @@ class Gift(GiftBase, table=True): ondelete="CASCADE", description="Owner user; cascades on delete.", ) - deleted_at: datetime | None = Field( - default=None, - index=True, - sa_type=DateTime(timezone=True), - description=( - "Soft-delete marker. When non-null, the row is hidden from the " - "default visibility helpers; restore by clearing this column." - ), - ) gift_date: date | None = Field( default=None, sa_column=sa.Column("date", sa.Date, nullable=True), @@ -1432,7 +1436,7 @@ class DebtUpdate(SQLModel): settled_at: date | None = None -class Debt(DebtBase, table=True): +class Debt(SoftDeleteMixin, DebtBase, table=True): """Money owed to or from a contact.""" id: uuid.UUID = Field( @@ -1452,15 +1456,6 @@ class Debt(DebtBase, table=True): ondelete="CASCADE", description="Owner user; cascades on delete.", ) - deleted_at: datetime | None = Field( - default=None, - index=True, - sa_type=DateTime(timezone=True), - description=( - "Soft-delete marker. When non-null, the row is hidden from the " - "default visibility helpers; restore by clearing this column." - ), - ) created_at: datetime = Field( default_factory=lambda: datetime.now(timezone.utc), nullable=False, @@ -1519,7 +1514,7 @@ class LifeEventUpdate(SQLModel): create_annual_reminder: bool | None = None -class LifeEvent(LifeEventBase, table=True): +class LifeEvent(SoftDeleteMixin, LifeEventBase, table=True): """Milestone on a contact's timeline (job change, wedding, move, etc.).""" __tablename__ = "life_event" @@ -1540,15 +1535,6 @@ class LifeEvent(LifeEventBase, table=True): ondelete="CASCADE", description="Owner user; cascades on delete.", ) - deleted_at: datetime | None = Field( - default=None, - index=True, - sa_type=DateTime(timezone=True), - description=( - "Soft-delete marker. When non-null, the row is hidden from the " - "default visibility helpers; restore by clearing this column." - ), - ) created_at: datetime = Field( default_factory=lambda: datetime.now(timezone.utc), nullable=False, @@ -1605,7 +1591,7 @@ class NoteMention(SQLModel, table=True): ) -class Note(NoteBase, table=True): +class Note(SoftDeleteMixin, NoteBase, table=True): """Timestamped freeform note attached to a specific contact.""" id: uuid.UUID = Field( @@ -1625,15 +1611,6 @@ class Note(NoteBase, table=True): ondelete="CASCADE", description="Owner user; cascades on delete.", ) - deleted_at: datetime | None = Field( - default=None, - index=True, - sa_type=DateTime(timezone=True), - description=( - "Soft-delete marker. When non-null, the row is hidden from the " - "default visibility helpers; restore by clearing this column." - ), - ) created_at: datetime = Field( default_factory=lambda: datetime.now(timezone.utc), nullable=False,