From 4fe9f36b50bdf79c43031a7fbddcdabbea860dd1 Mon Sep 17 00:00:00 2001 From: rishab11250 Date: Wed, 8 Jul 2026 10:58:33 +0530 Subject: [PATCH] feat: add soft delete support for users, projects, and organizations - Add deleted_at and deleted_by_id columns to User, Project, Organization models - Create missing project and organization Pydantic schemas - Convert all hard deletes to soft deletes in services - Filter out soft-deleted records from all queries - Add restore and hard-delete endpoints to routers - Create Alembic migration for new columns - Permission model: owner soft-deletes, owner/superuser restores, superuser hard-deletes Closes #198 --- .../4b4c96034900_add_soft_delete_columns.py | 132 ++++++++++++++++++ backend/app/models/organization.py | 28 ++++ backend/app/models/project.py | 28 ++++ backend/app/models/user.py | 26 +++- backend/app/routers/organizations.py | 74 +++++++++- backend/app/routers/projects.py | 69 ++++++++- backend/app/routers/users.py | 69 ++++++++- backend/app/schemas/organization.py | 104 ++++++++++++++ backend/app/schemas/project.py | 112 +++++++++++++++ backend/app/schemas/user.py | 3 + backend/app/services/organization_service.py | 61 +++++++- backend/app/services/project_service.py | 56 +++++++- backend/app/services/user_service.py | 61 +++++++- 13 files changed, 802 insertions(+), 21 deletions(-) create mode 100644 backend/alembic/versions/4b4c96034900_add_soft_delete_columns.py create mode 100644 backend/app/schemas/organization.py create mode 100644 backend/app/schemas/project.py diff --git a/backend/alembic/versions/4b4c96034900_add_soft_delete_columns.py b/backend/alembic/versions/4b4c96034900_add_soft_delete_columns.py new file mode 100644 index 00000000..8e1cb3d5 --- /dev/null +++ b/backend/alembic/versions/4b4c96034900_add_soft_delete_columns.py @@ -0,0 +1,132 @@ +"""Add soft delete columns + +Revision ID: 4b4c96034900 +Revises: c14aa06f723a +Create Date: 2026-07-08 12:00:00.000000 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = "4b4c96034900" +down_revision: Union[str, Sequence[str], None] = "c14aa06f723a" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + + # --- users --- + op.add_column( + "users", + sa.Column( + "deleted_at", + sa.DateTime(timezone=True), + nullable=True, + ), + ) + op.add_column( + "users", + sa.Column( + "deleted_by_id", + postgresql.UUID(as_uuid=True), + nullable=True, + ), + ) + op.create_foreign_key( + "fk_users_deleted_by_id_users", + "users", + "users", + ["deleted_by_id"], + ["id"], + ondelete="SET NULL", + ) + + # --- projects --- + op.add_column( + "projects", + sa.Column( + "deleted_at", + sa.DateTime(timezone=True), + nullable=True, + ), + ) + op.add_column( + "projects", + sa.Column( + "deleted_by_id", + postgresql.UUID(as_uuid=True), + nullable=True, + ), + ) + op.create_foreign_key( + "fk_projects_deleted_by_id_users", + "projects", + "users", + ["deleted_by_id"], + ["id"], + ondelete="SET NULL", + ) + + # --- organizations --- + op.add_column( + "organizations", + sa.Column( + "deleted_at", + sa.DateTime(timezone=True), + nullable=True, + ), + ) + op.add_column( + "organizations", + sa.Column( + "deleted_by_id", + postgresql.UUID(as_uuid=True), + nullable=True, + ), + ) + op.create_foreign_key( + "fk_organizations_deleted_by_id_users", + "organizations", + "users", + ["deleted_by_id"], + ["id"], + ondelete="SET NULL", + ) + + +def downgrade() -> None: + """Downgrade schema.""" + + # --- organizations --- + op.drop_constraint( + "fk_organizations_deleted_by_id_users", + "organizations", + type_="foreignkey", + ) + op.drop_column("organizations", "deleted_by_id") + op.drop_column("organizations", "deleted_at") + + # --- projects --- + op.drop_constraint( + "fk_projects_deleted_by_id_users", + "projects", + type_="foreignkey", + ) + op.drop_column("projects", "deleted_by_id") + op.drop_column("projects", "deleted_at") + + # --- users --- + op.drop_constraint( + "fk_users_deleted_by_id_users", + "users", + type_="foreignkey", + ) + op.drop_column("users", "deleted_by_id") + op.drop_column("users", "deleted_at") diff --git a/backend/app/models/organization.py b/backend/app/models/organization.py index 2aa6e737..aef428cd 100644 --- a/backend/app/models/organization.py +++ b/backend/app/models/organization.py @@ -19,6 +19,12 @@ from app.database.base import Base +# Forward reference for type annotations +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from app.models.user import User + class OrganizationType(str, Enum): COMPANY = "company" @@ -180,6 +186,28 @@ class Organization(Base): backref="organizations", ) + # ========================================================== + # Soft Delete + # ========================================================== + + deleted_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), + nullable=True, + default=None, + ) + + deleted_by_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + ForeignKey("users.id", ondelete="SET NULL"), + nullable=True, + default=None, + ) + + deleted_by: Mapped[User | None] = relationship( + "User", + foreign_keys=[deleted_by_id], + ) + # ========================================================== # Audit # ========================================================== diff --git a/backend/app/models/project.py b/backend/app/models/project.py index 2f2415fb..ae05a164 100644 --- a/backend/app/models/project.py +++ b/backend/app/models/project.py @@ -19,6 +19,12 @@ from app.database.base import Base +# Forward reference for type annotations +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from app.models.user import User + class ProjectStage(str, Enum): IDEA = "idea" @@ -184,6 +190,28 @@ class Project(Base): default=False, ) + # ---------------------------------------------------------- + # Soft Delete + # ---------------------------------------------------------- + + deleted_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), + nullable=True, + default=None, + ) + + deleted_by_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + ForeignKey("users.id", ondelete="SET NULL"), + nullable=True, + default=None, + ) + + deleted_by: Mapped[User | None] = relationship( + "User", + foreign_keys=[deleted_by_id], + ) + # ---------------------------------------------------------- # Audit # ---------------------------------------------------------- diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 1835199a..ac6d0cf3 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -6,12 +6,13 @@ from sqlalchemy import ( Boolean, DateTime, + ForeignKey, String, Text, func, ) from sqlalchemy.dialects.postgresql import UUID -from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.orm import Mapped, mapped_column, relationship from app.database.base import Base @@ -195,6 +196,29 @@ class User(Base): unique=True, ) + # ------------------------------------------------------------------ + # Soft Delete + # ------------------------------------------------------------------ + + deleted_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), + nullable=True, + default=None, + ) + + deleted_by_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + ForeignKey("users.id", ondelete="SET NULL"), + nullable=True, + default=None, + ) + + deleted_by: Mapped[User | None] = relationship( + "User", + foreign_keys=[deleted_by_id], + remote_side="User.id", + ) + # ------------------------------------------------------------------ # Audit # ------------------------------------------------------------------ diff --git a/backend/app/routers/organizations.py b/backend/app/routers/organizations.py index 788ea212..d87e4ae9 100644 --- a/backend/app/routers/organizations.py +++ b/backend/app/routers/organizations.py @@ -303,6 +303,7 @@ def disable_hiring( def delete_organization( organization_id: uuid.UUID, db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), ): organization = OrganizationService.get_organization( @@ -316,7 +317,78 @@ def delete_organization( detail="Organization not found", ) - OrganizationService.delete_organization( + OrganizationService.soft_delete_organization( + db, + organization, + deleted_by_id=current_user.id, + ) + + +@router.patch( + "/{organization_id}/restore-soft-delete", + response_model=OrganizationResponse, +) +def restore_organization_soft_delete( + organization_id: uuid.UUID, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + + organization = OrganizationService.get_organization_including_deleted( + db, organization_id + ) + + if organization is None: + raise HTTPException( + status_code=404, + detail="Organization not found", + ) + + if organization.deleted_at is None: + raise HTTPException( + status_code=400, + detail="Organization is not deleted", + ) + + if organization.owner_id != current_user.id and not current_user.is_superuser: + raise HTTPException( + status_code=403, + detail="Permission denied", + ) + + return OrganizationService.restore_soft_deleted_organization( + db, + organization, + ) + + +@router.delete( + "/{organization_id}/hard", + status_code=status.HTTP_204_NO_CONTENT, +) +def hard_delete_organization( + organization_id: uuid.UUID, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + + if not current_user.is_superuser: + raise HTTPException( + status_code=403, + detail="Only admins can permanently delete organizations", + ) + + organization = OrganizationService.get_organization_including_deleted( + db, organization_id + ) + + if organization is None: + raise HTTPException( + status_code=404, + detail="Organization not found", + ) + + OrganizationService.hard_delete_organization( db, organization, ) diff --git a/backend/app/routers/projects.py b/backend/app/routers/projects.py index ff6a4f87..24746094 100644 --- a/backend/app/routers/projects.py +++ b/backend/app/routers/projects.py @@ -340,7 +340,74 @@ def delete_project( detail="Permission denied", ) - ProjectService.delete_project( + ProjectService.soft_delete_project( + db, + project, + deleted_by_id=current_user.id, + ) + + +@router.patch( + "/{project_id}/restore-soft-delete", + response_model=ProjectResponse, +) +def restore_project_soft_delete( + project_id: uuid.UUID, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + + project = ProjectService.get_project_including_deleted(db, project_id) + + if project is None: + raise HTTPException( + status_code=404, + detail="Project not found", + ) + + if project.deleted_at is None: + raise HTTPException( + status_code=400, + detail="Project is not deleted", + ) + + if project.owner_id != current_user.id and not current_user.is_superuser: + raise HTTPException( + status_code=403, + detail="Permission denied", + ) + + return ProjectService.restore_soft_deleted_project( + db, + project, + ) + + +@router.delete( + "/{project_id}/hard", + status_code=status.HTTP_204_NO_CONTENT, +) +def hard_delete_project( + project_id: uuid.UUID, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + + if not current_user.is_superuser: + raise HTTPException( + status_code=403, + detail="Only admins can permanently delete projects", + ) + + project = ProjectService.get_project_including_deleted(db, project_id) + + if project is None: + raise HTTPException( + status_code=404, + detail="Project not found", + ) + + ProjectService.hard_delete_project( db, project, ) diff --git a/backend/app/routers/users.py b/backend/app/routers/users.py index bf074c01..09db2fac 100644 --- a/backend/app/routers/users.py +++ b/backend/app/routers/users.py @@ -132,9 +132,76 @@ def delete_me( db: Session = Depends(get_db), ): - UserService.delete_user( + UserService.soft_delete_user( db, current_user, + deleted_by_id=current_user.id, + ) + + +@router.patch( + "/{user_id}/restore", + response_model=UserResponse, +) +def restore_user( + user_id: uuid.UUID, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + + if not current_user.is_superuser: + raise HTTPException( + status_code=403, + detail="Only admins can restore users", + ) + + user = UserService.get_user_including_deleted(db, user_id) + + if user is None: + raise HTTPException( + status_code=404, + detail="User not found", + ) + + if user.deleted_at is None: + raise HTTPException( + status_code=400, + detail="User is not deleted", + ) + + return UserService.restore_user( + db, + user, + ) + + +@router.delete( + "/{user_id}/hard", + status_code=status.HTTP_204_NO_CONTENT, +) +def hard_delete_user( + user_id: uuid.UUID, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + + if not current_user.is_superuser: + raise HTTPException( + status_code=403, + detail="Only admins can permanently delete users", + ) + + user = UserService.get_user_including_deleted(db, user_id) + + if user is None: + raise HTTPException( + status_code=404, + detail="User not found", + ) + + UserService.hard_delete_user( + db, + user, ) diff --git a/backend/app/schemas/organization.py b/backend/app/schemas/organization.py new file mode 100644 index 00000000..15c93055 --- /dev/null +++ b/backend/app/schemas/organization.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +import uuid +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, ConfigDict, Field, HttpUrl + +from app.models.organization import OrganizationType + + +# ========================================================== +# Base Organization Schema +# ========================================================== + + +class OrganizationBase(BaseModel): + name: str = Field( + ..., + min_length=1, + max_length=200, + ) + + slug: str = Field( + ..., + min_length=1, + max_length=200, + ) + + description: Optional[str] = None + + organization_type: OrganizationType = OrganizationType.STARTUP + + website: Optional[HttpUrl] = None + email: Optional[str] = None + phone: Optional[str] = None + + logo_url: Optional[HttpUrl] = None + banner_url: Optional[HttpUrl] = None + + location: Optional[str] = Field(default=None, max_length=200) + + github_url: Optional[HttpUrl] = None + linkedin_url: Optional[HttpUrl] = None + twitter_url: Optional[HttpUrl] = None + + hiring: bool = False + + +# ========================================================== +# Create Organization +# ========================================================== + + +class OrganizationCreate(OrganizationBase): + pass + + +# ========================================================== +# Update Organization +# ========================================================== + + +class OrganizationUpdate(BaseModel): + name: Optional[str] = None + slug: Optional[str] = None + description: Optional[str] = None + organization_type: Optional[OrganizationType] = None + website: Optional[HttpUrl] = None + email: Optional[str] = None + phone: Optional[str] = None + logo_url: Optional[HttpUrl] = None + banner_url: Optional[HttpUrl] = None + location: Optional[str] = None + github_url: Optional[HttpUrl] = None + linkedin_url: Optional[HttpUrl] = None + twitter_url: Optional[HttpUrl] = None + hiring: Optional[bool] = None + + +# ========================================================== +# Organization Response +# ========================================================== + + +class OrganizationResponse(OrganizationBase): + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + + owner_id: uuid.UUID + + members_count: int + projects_count: int + followers_count: int + + verified: bool + active: bool + + created_at: datetime + updated_at: datetime + + deleted_at: Optional[datetime] = None + deleted_by_id: Optional[uuid.UUID] = None diff --git a/backend/app/schemas/project.py b/backend/app/schemas/project.py new file mode 100644 index 00000000..0e95373c --- /dev/null +++ b/backend/app/schemas/project.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +import uuid +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, ConfigDict, Field, HttpUrl + +from app.models.project import ProjectStage, ProjectVisibility + + +# ========================================================== +# Base Project Schema +# ========================================================== + + +class ProjectBase(BaseModel): + title: str = Field( + ..., + min_length=1, + max_length=200, + ) + + slug: str = Field( + ..., + min_length=1, + max_length=200, + ) + + tagline: Optional[str] = Field( + default=None, + max_length=255, + ) + + description: str = Field( + ..., + min_length=1, + ) + + stage: ProjectStage = ProjectStage.IDEA + visibility: ProjectVisibility = ProjectVisibility.PUBLIC + + tech_stack: Optional[str] = None + + repository_url: Optional[HttpUrl] = None + website_url: Optional[HttpUrl] = None + demo_url: Optional[HttpUrl] = None + + team_size: int = 1 + max_team_size: int = 5 + hiring: bool = True + + logo_url: Optional[HttpUrl] = None + banner_url: Optional[HttpUrl] = None + + +# ========================================================== +# Create Project +# ========================================================== + + +class ProjectCreate(ProjectBase): + pass + + +# ========================================================== +# Update Project +# ========================================================== + + +class ProjectUpdate(BaseModel): + title: Optional[str] = None + slug: Optional[str] = None + tagline: Optional[str] = None + description: Optional[str] = None + stage: Optional[ProjectStage] = None + visibility: Optional[ProjectVisibility] = None + tech_stack: Optional[str] = None + repository_url: Optional[HttpUrl] = None + website_url: Optional[HttpUrl] = None + demo_url: Optional[HttpUrl] = None + team_size: Optional[int] = None + max_team_size: Optional[int] = None + hiring: Optional[bool] = None + logo_url: Optional[HttpUrl] = None + banner_url: Optional[HttpUrl] = None + + +# ========================================================== +# Project Response +# ========================================================== + + +class ProjectResponse(ProjectBase): + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + + owner_id: uuid.UUID + + stars: int + views: int + applications_count: int + + is_featured: bool + is_archived: bool + + created_at: datetime + updated_at: datetime + + deleted_at: Optional[datetime] = None + deleted_by_id: Optional[uuid.UUID] = None diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index bed6b94f..a3992968 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -108,6 +108,9 @@ class UserResponse(UserBase): created_at: datetime updated_at: datetime + deleted_at: Optional[datetime] = None + deleted_by_id: Optional[uuid.UUID] = None + # ========================================================== # Private User Response diff --git a/backend/app/services/organization_service.py b/backend/app/services/organization_service.py index adde303d..6e6b05d1 100644 --- a/backend/app/services/organization_service.py +++ b/backend/app/services/organization_service.py @@ -2,7 +2,7 @@ import uuid -from sqlalchemy import select +from sqlalchemy import func, select from sqlalchemy.orm import Session from app.models.organization import Organization @@ -54,6 +54,18 @@ def get_organization( organization_id: uuid.UUID, ) -> Organization | None: + stmt = select(Organization).where( + Organization.id == organization_id, + Organization.deleted_at.is_(None), + ) + return db.scalar(stmt) + + @staticmethod + def get_organization_including_deleted( + db: Session, + organization_id: uuid.UUID, + ) -> Organization | None: + """Retrieve an organization regardless of soft-delete status (admin use).""" return db.get(Organization, organization_id) @staticmethod @@ -62,7 +74,10 @@ def get_by_slug( slug: str, ) -> Organization | None: - stmt = select(Organization).where(Organization.slug == slug) + stmt = select(Organization).where( + Organization.slug == slug, + Organization.deleted_at.is_(None), + ) return db.scalar(stmt) @@ -73,7 +88,12 @@ def list_organizations( limit: int = 20, ) -> list[Organization]: - stmt = select(Organization).offset(skip).limit(limit) + stmt = ( + select(Organization) + .where(Organization.deleted_at.is_(None)) + .offset(skip) + .limit(limit) + ) return list(db.scalars(stmt)) @@ -83,7 +103,10 @@ def list_owner_organizations( owner_id: uuid.UUID, ) -> list[Organization]: - stmt = select(Organization).where(Organization.owner_id == owner_id) + stmt = select(Organization).where( + Organization.owner_id == owner_id, + Organization.deleted_at.is_(None), + ) return list(db.scalars(stmt)) @@ -93,7 +116,10 @@ def search_organizations( keyword: str, ) -> list[Organization]: - stmt = select(Organization).where(Organization.name.ilike(f"%{keyword}%")) + stmt = select(Organization).where( + Organization.name.ilike(f"%{keyword}%"), + Organization.deleted_at.is_(None), + ) return list(db.scalars(stmt)) @@ -180,10 +206,33 @@ def activate_organization( return db_organization @staticmethod - def delete_organization( + def soft_delete_organization( db: Session, db_organization: Organization, + deleted_by_id: uuid.UUID, ) -> None: + """Mark an organization as deleted without removing the row.""" + db_organization.deleted_at = func.now() + db_organization.deleted_by_id = deleted_by_id + db.commit() + @staticmethod + def restore_soft_deleted_organization( + db: Session, + db_organization: Organization, + ) -> Organization: + """Restore a soft-deleted organization.""" + db_organization.deleted_at = None + db_organization.deleted_by_id = None + db.commit() + db.refresh(db_organization) + return db_organization + + @staticmethod + def hard_delete_organization( + db: Session, + db_organization: Organization, + ) -> None: + """Permanently remove an organization from the database (admin only).""" db.delete(db_organization) db.commit() diff --git a/backend/app/services/project_service.py b/backend/app/services/project_service.py index fc13c4e2..76a66b28 100644 --- a/backend/app/services/project_service.py +++ b/backend/app/services/project_service.py @@ -2,7 +2,7 @@ import uuid -from sqlalchemy import select +from sqlalchemy import func, select from sqlalchemy.orm import Session from app.models.project import Project @@ -50,6 +50,18 @@ def get_project( project_id: uuid.UUID, ) -> Project | None: + stmt = select(Project).where( + Project.id == project_id, + Project.deleted_at.is_(None), + ) + return db.scalar(stmt) + + @staticmethod + def get_project_including_deleted( + db: Session, + project_id: uuid.UUID, + ) -> Project | None: + """Retrieve a project regardless of soft-delete status (admin use).""" return db.get(Project, project_id) @staticmethod @@ -58,7 +70,10 @@ def get_by_slug( slug: str, ) -> Project | None: - stmt = select(Project).where(Project.slug == slug) + stmt = select(Project).where( + Project.slug == slug, + Project.deleted_at.is_(None), + ) return db.scalar(stmt) @staticmethod @@ -68,7 +83,12 @@ def list_projects( limit: int = 20, ) -> list[Project]: - stmt = select(Project).offset(skip).limit(limit) + stmt = ( + select(Project) + .where(Project.deleted_at.is_(None)) + .offset(skip) + .limit(limit) + ) return list(db.scalars(stmt)) @@ -78,7 +98,10 @@ def list_owner_projects( owner_id: uuid.UUID, ) -> list[Project]: - stmt = select(Project).where(Project.owner_id == owner_id) + stmt = select(Project).where( + Project.owner_id == owner_id, + Project.deleted_at.is_(None), + ) return list(db.scalars(stmt)) @@ -168,10 +191,33 @@ def decrement_stars( db.commit() @staticmethod - def delete_project( + def soft_delete_project( db: Session, db_project: Project, + deleted_by_id: uuid.UUID, ) -> None: + """Mark a project as deleted without removing the row.""" + db_project.deleted_at = func.now() + db_project.deleted_by_id = deleted_by_id + db.commit() + @staticmethod + def restore_soft_deleted_project( + db: Session, + db_project: Project, + ) -> Project: + """Restore a soft-deleted project.""" + db_project.deleted_at = None + db_project.deleted_by_id = None + db.commit() + db.refresh(db_project) + return db_project + + @staticmethod + def hard_delete_project( + db: Session, + db_project: Project, + ) -> None: + """Permanently remove a project from the database (admin only).""" db.delete(db_project) db.commit() diff --git a/backend/app/services/user_service.py b/backend/app/services/user_service.py index 281ac32d..0b704829 100644 --- a/backend/app/services/user_service.py +++ b/backend/app/services/user_service.py @@ -2,7 +2,7 @@ import uuid -from sqlalchemy import select +from sqlalchemy import func, select from sqlalchemy.orm import Session from app.models.user import User @@ -15,17 +15,38 @@ class UserService: """ @staticmethod - def get_user(db: Session, user_id: uuid.UUID) -> User | None: + def get_user( + db: Session, + user_id: uuid.UUID, + ) -> User | None: + stmt = select(User).where( + User.id == user_id, + User.deleted_at.is_(None), + ) + return db.scalar(stmt) + + @staticmethod + def get_user_including_deleted( + db: Session, + user_id: uuid.UUID, + ) -> User | None: + """Retrieve a user regardless of soft-delete status (admin use).""" return db.get(User, user_id) @staticmethod def get_by_email(db: Session, email: str) -> User | None: - stmt = select(User).where(User.email == email) + stmt = select(User).where( + User.email == email, + User.deleted_at.is_(None), + ) return db.scalar(stmt) @staticmethod def get_by_username(db: Session, username: str) -> User | None: - stmt = select(User).where(User.username == username) + stmt = select(User).where( + User.username == username, + User.deleted_at.is_(None), + ) return db.scalar(stmt) @staticmethod @@ -34,7 +55,12 @@ def list_users( skip: int = 0, limit: int = 20, ) -> list[User]: - stmt = select(User).offset(skip).limit(limit) + stmt = ( + select(User) + .where(User.deleted_at.is_(None)) + .offset(skip) + .limit(limit) + ) return list(db.scalars(stmt)) @staticmethod @@ -76,11 +102,34 @@ def update_user( return db_user @staticmethod - def delete_user( + def soft_delete_user( db: Session, db_user: User, + deleted_by_id: uuid.UUID, ) -> None: + """Mark a user as deleted without removing the row.""" + db_user.deleted_at = func.now() + db_user.deleted_by_id = deleted_by_id + db.commit() + + @staticmethod + def restore_user( + db: Session, + db_user: User, + ) -> User: + """Restore a soft-deleted user.""" + db_user.deleted_at = None + db_user.deleted_by_id = None + db.commit() + db.refresh(db_user) + return db_user + @staticmethod + def hard_delete_user( + db: Session, + db_user: User, + ) -> None: + """Permanently remove a user from the database (admin only).""" db.delete(db_user) db.commit()