diff --git a/backend/script/reset_dev.py b/backend/script/reset_dev.py index de66aec..a12ab0a 100644 --- a/backend/script/reset_dev.py +++ b/backend/script/reset_dev.py @@ -79,12 +79,7 @@ async def reset_dev(): print("Populating tables...") async with AsyncSessionLocal() as session: with open( - str( - Path(__file__).parent.parent.parent - / "frontend" - / "shared" - / "mock_data.json" - ), + str(Path(__file__).parent.parent.parent / "frontend" / "shared" / "mock_data.json"), "r", ) as f: data = json.load(f) @@ -118,11 +113,9 @@ async def reset_dev(): session.add(account) await session.flush() - student = StudentEntity.from_model( + student = StudentEntity.from_data( StudentData( - contact_preference=ContactPreference( - student_data["contact_preference"] - ), + contact_preference=ContactPreference(student_data["contact_preference"]), phone_number=student_data["phone_number"], last_registered=parse_date(student_data.get("last_registered")), ), @@ -150,17 +143,20 @@ async def reset_dev(): session.add(location) for party_data in data["parties"]: + party_datetime = parse_date(party_data["party_datetime"]) + assert party_datetime is not None, f"party_datetime required for party {party_data['id']}" + party = PartyEntity( - party_datetime=parse_date(party_data["party_datetime"]), + party_datetime=party_datetime, location_id=party_data["location_id"], contact_one_id=party_data["contact_one_id"], contact_two_first_name=party_data["contact_two"]["first_name"], contact_two_last_name=party_data["contact_two"]["last_name"], contact_two_email=party_data["contact_two"]["email"], contact_two_phone_number=party_data["contact_two"]["phone_number"], - contact_two_contact_preference=party_data["contact_two"][ - "contact_preference" - ], + contact_two_contact_preference=ContactPreference( + party_data["contact_two"]["contact_preference"] + ), ) session.add(party) diff --git a/backend/src/core/authentication.py b/backend/src/core/authentication.py index d288747..650da3a 100644 --- a/backend/src/core/authentication.py +++ b/backend/src/core/authentication.py @@ -3,8 +3,8 @@ from fastapi import Depends, Request from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from src.core.exceptions import CredentialsException, ForbiddenException -from src.modules.account.account_model import Account, AccountRole -from src.modules.police.police_model import PoliceAccount +from src.modules.account.account_model import AccountDto, AccountRole +from src.modules.police.police_model import PoliceAccountDto StringRole = Literal["student", "admin", "staff", "police"] @@ -20,7 +20,7 @@ async def __call__(self, request: Request): bearer_scheme = HTTPBearer401() -def mock_authenticate(role: AccountRole) -> Account | None: +def mock_authenticate(role: AccountRole) -> AccountDto | None: """Mock authentication function. Replace with real authentication logic.""" role_to_id = { AccountRole.ADMIN: 1, @@ -32,7 +32,7 @@ def mock_authenticate(role: AccountRole) -> Account | None: AccountRole.ADMIN: "222222222", AccountRole.STAFF: "333333333", } - return Account( + return AccountDto( id=role_to_id[role], email="user@example.com", first_name="Test", @@ -44,7 +44,7 @@ def mock_authenticate(role: AccountRole) -> Account | None: async def authenticate_user( authorization: HTTPAuthorizationCredentials = Depends(bearer_scheme), -) -> Account: +) -> AccountDto: """ Middleware to authenticate user from Bearer token. Expects token to be one of: "student", "admin", "staff" for mock authentication. @@ -74,13 +74,13 @@ def authenticate_by_role(*roles: StringRole): async def _authenticate( authorization: HTTPAuthorizationCredentials = Depends(bearer_scheme), - ) -> Account | PoliceAccount: + ) -> AccountDto | PoliceAccountDto: token = authorization.credentials.lower() # Check if police token and police is allowed if token == "police": if "police" in roles: - return PoliceAccount(email="police@example.com") + return PoliceAccountDto(email="police@example.com") else: raise ForbiddenException(detail="Insufficient privileges") @@ -102,38 +102,38 @@ async def _authenticate( async def authenticate_admin( - account: Account | PoliceAccount = Depends(authenticate_by_role("admin")), -) -> Account: - if not isinstance(account, Account): + account: AccountDto | PoliceAccountDto = Depends(authenticate_by_role("admin")), +) -> AccountDto: + if not isinstance(account, AccountDto): raise ForbiddenException(detail="Insufficient privileges") return account async def authenticate_staff_or_admin( - account: Account | PoliceAccount = Depends(authenticate_by_role("staff", "admin")), -) -> Account: - if not isinstance(account, Account): + account: AccountDto | PoliceAccountDto = Depends(authenticate_by_role("staff", "admin")), +) -> AccountDto: + if not isinstance(account, AccountDto): raise ForbiddenException(detail="Insufficient privileges") return account async def authenticate_student_or_admin( - account: Account | PoliceAccount = Depends(authenticate_by_role("student", "admin")), -) -> Account: - if not isinstance(account, Account): + account: AccountDto | PoliceAccountDto = Depends(authenticate_by_role("student", "admin")), +) -> AccountDto: + if not isinstance(account, AccountDto): raise ForbiddenException(detail="Insufficient privileges") return account async def authenticate_student( - account: Account | PoliceAccount = Depends(authenticate_by_role("student")), -) -> Account: - if not isinstance(account, Account): + account: AccountDto | PoliceAccountDto = Depends(authenticate_by_role("student")), +) -> AccountDto: + if not isinstance(account, AccountDto): raise ForbiddenException(detail="Insufficient privileges") return account async def authenticate_police_or_admin( - account: Account | PoliceAccount = Depends(authenticate_by_role("police", "admin")), -) -> PoliceAccount | Account: + account: AccountDto | PoliceAccountDto = Depends(authenticate_by_role("police", "admin")), +) -> PoliceAccountDto | AccountDto: return account diff --git a/backend/src/modules/account/account_entity.py b/backend/src/modules/account/account_entity.py index cda3ffe..f94fa7a 100644 --- a/backend/src/modules/account/account_entity.py +++ b/backend/src/modules/account/account_entity.py @@ -3,7 +3,7 @@ from sqlalchemy import CheckConstraint, Enum, Integer, String from sqlalchemy.orm import Mapped, MappedAsDataclass, mapped_column from src.core.database import EntityBase -from src.modules.account.account_model import Account, AccountData, AccountRole +from src.modules.account.account_model import AccountData, AccountDto, AccountRole class AccountEntity(MappedAsDataclass, EntityBase): @@ -24,7 +24,7 @@ class AccountEntity(MappedAsDataclass, EntityBase): role: Mapped[AccountRole] = mapped_column(Enum(AccountRole), nullable=False) @classmethod - def from_model(cls, data: "AccountData") -> Self: + def from_data(cls, data: "AccountData") -> Self: return cls( email=data.email, first_name=data.first_name, @@ -33,8 +33,8 @@ def from_model(cls, data: "AccountData") -> Self: role=AccountRole(data.role), ) - def to_model(self) -> "Account": - return Account( + def to_dto(self) -> "AccountDto": + return AccountDto( id=self.id, email=self.email, first_name=self.first_name, diff --git a/backend/src/modules/account/account_model.py b/backend/src/modules/account/account_model.py index 2448320..d344444 100644 --- a/backend/src/modules/account/account_model.py +++ b/backend/src/modules/account/account_model.py @@ -19,7 +19,7 @@ class AccountData(BaseModel): role: AccountRole -class Account(BaseModel): +class AccountDto(BaseModel): """DTO for Account responses.""" id: int diff --git a/backend/src/modules/account/account_router.py b/backend/src/modules/account/account_router.py index f6953de..b4a2f50 100644 --- a/backend/src/modules/account/account_router.py +++ b/backend/src/modules/account/account_router.py @@ -1,8 +1,8 @@ from fastapi import APIRouter, Depends, Query from src.core.authentication import authenticate_admin -from src.modules.account.account_model import Account, AccountData, AccountRole +from src.modules.account.account_model import AccountData, AccountDto, AccountRole from src.modules.account.account_service import AccountService -from src.modules.police.police_model import PoliceAccount, PoliceAccountUpdate +from src.modules.police.police_model import PoliceAccountDto, PoliceAccountUpdate from src.modules.police.police_service import PoliceService account_router = APIRouter(prefix="/api/accounts", tags=["accounts"]) @@ -12,9 +12,9 @@ async def get_police_credentials( police_service: PoliceService = Depends(), _=Depends(authenticate_admin), -) -> PoliceAccount: +) -> PoliceAccountDto: police_entity = await police_service.get_police() - return PoliceAccount(email=police_entity.email) + return PoliceAccountDto(email=police_entity.email) @account_router.put("/police") @@ -22,9 +22,9 @@ async def update_police_credentials( data: PoliceAccountUpdate, police_service: PoliceService = Depends(), _=Depends(authenticate_admin), -) -> PoliceAccount: +) -> PoliceAccountDto: police_entity = await police_service.update_police(data.email, data.password) - return PoliceAccount(email=police_entity.email) + return PoliceAccountDto(email=police_entity.email) @account_router.get("") @@ -34,7 +34,7 @@ async def list_accounts( ), account_service: AccountService = Depends(), _=Depends(authenticate_admin), -) -> list[Account]: +) -> list[AccountDto]: return await account_service.get_accounts_by_roles(role) @@ -43,7 +43,7 @@ async def create_account( data: AccountData, account_service: AccountService = Depends(), _=Depends(authenticate_admin), -) -> Account: +) -> AccountDto: return await account_service.create_account(data) @@ -53,7 +53,7 @@ async def update_account( data: AccountData, account_service: AccountService = Depends(), _=Depends(authenticate_admin), -) -> Account: +) -> AccountDto: return await account_service.update_account(account_id, data) @@ -62,5 +62,5 @@ async def delete_account( account_id: int, account_service: AccountService = Depends(), _=Depends(authenticate_admin), -) -> Account: +) -> AccountDto: return await account_service.delete_account(account_id) diff --git a/backend/src/modules/account/account_service.py b/backend/src/modules/account/account_service.py index eae899a..5440875 100644 --- a/backend/src/modules/account/account_service.py +++ b/backend/src/modules/account/account_service.py @@ -5,7 +5,7 @@ from src.core.database import get_session from src.core.exceptions import ConflictException, NotFoundException from src.modules.account.account_entity import AccountEntity, AccountRole -from src.modules.account.account_model import Account, AccountData +from src.modules.account.account_model import AccountData, AccountDto class AccountNotFoundException(NotFoundException): @@ -45,12 +45,14 @@ async def _get_account_entity_by_email(self, email: str) -> AccountEntity: raise AccountByEmailNotFoundException(email) return account - async def get_accounts(self) -> list[Account]: + async def get_accounts(self) -> list[AccountDto]: result = await self.session.execute(select(AccountEntity)) accounts = result.scalars().all() - return [account.to_model() for account in accounts] + return [account.to_dto() for account in accounts] - async def get_accounts_by_roles(self, roles: list[AccountRole] | None = None) -> list[Account]: + async def get_accounts_by_roles( + self, roles: list[AccountRole] | None = None + ) -> list[AccountDto]: if not roles: return await self.get_accounts() @@ -58,17 +60,17 @@ async def get_accounts_by_roles(self, roles: list[AccountRole] | None = None) -> select(AccountEntity).where(AccountEntity.role.in_(roles)) ) accounts = result.scalars().all() - return [account.to_model() for account in accounts] + return [account.to_dto() for account in accounts] - async def get_account_by_id(self, account_id: int) -> Account: + async def get_account_by_id(self, account_id: int) -> AccountDto: account_entity = await self._get_account_entity_by_id(account_id) - return account_entity.to_model() + return account_entity.to_dto() - async def get_account_by_email(self, email: str) -> Account: + async def get_account_by_email(self, email: str) -> AccountDto: account_entity = await self._get_account_entity_by_email(email) - return account_entity.to_model() + return account_entity.to_dto() - async def create_account(self, data: AccountData) -> Account: + async def create_account(self, data: AccountData) -> AccountDto: try: await self._get_account_entity_by_email(data.email) # If we get here, account exists @@ -91,9 +93,9 @@ async def create_account(self, data: AccountData) -> Account: # handle race condition where another session inserted the same email raise AccountConflictException(data.email) await self.session.refresh(new_account) - return new_account.to_model() + return new_account.to_dto() - async def update_account(self, account_id: int, data: AccountData) -> Account: + async def update_account(self, account_id: int, data: AccountData) -> AccountDto: account_entity = await self._get_account_entity_by_id(account_id) if data.email != account_entity.email: @@ -118,11 +120,11 @@ async def update_account(self, account_id: int, data: AccountData) -> Account: except IntegrityError: raise AccountConflictException(data.email) await self.session.refresh(account_entity) - return account_entity.to_model() + return account_entity.to_dto() - async def delete_account(self, account_id: int) -> Account: + async def delete_account(self, account_id: int) -> AccountDto: account_entity = await self._get_account_entity_by_id(account_id) - account = account_entity.to_model() + account = account_entity.to_dto() await self.session.delete(account_entity) await self.session.commit() return account diff --git a/backend/src/modules/complaint/complaint_entity.py b/backend/src/modules/complaint/complaint_entity.py index c5a1a62..e0d8fe7 100644 --- a/backend/src/modules/complaint/complaint_entity.py +++ b/backend/src/modules/complaint/complaint_entity.py @@ -4,7 +4,7 @@ from sqlalchemy import DateTime, ForeignKey, Integer, String from sqlalchemy.orm import Mapped, MappedAsDataclass, mapped_column, relationship from src.core.database import EntityBase -from src.modules.complaint.complaint_model import Complaint, ComplaintData +from src.modules.complaint.complaint_model import ComplaintData, ComplaintDto if TYPE_CHECKING: from src.modules.location.location_entity import LocationEntity @@ -26,16 +26,16 @@ class ComplaintEntity(MappedAsDataclass, EntityBase): ) @classmethod - def from_model(cls, data: ComplaintData) -> Self: + def from_data(cls, data: ComplaintData) -> Self: return cls( location_id=data.location_id, complaint_datetime=data.complaint_datetime, description=data.description, ) - def to_model(self) -> Complaint: + def to_dto(self) -> ComplaintDto: """Convert entity to model.""" - return Complaint( + return ComplaintDto( id=self.id, location_id=self.location_id, complaint_datetime=self.complaint_datetime, diff --git a/backend/src/modules/complaint/complaint_model.py b/backend/src/modules/complaint/complaint_model.py index 1ce6aae..c240fde 100644 --- a/backend/src/modules/complaint/complaint_model.py +++ b/backend/src/modules/complaint/complaint_model.py @@ -11,7 +11,7 @@ class ComplaintData(BaseModel): description: str = "" -class Complaint(ComplaintData): +class ComplaintDto(ComplaintData): """Output DTO for a complaint.""" id: int diff --git a/backend/src/modules/complaint/complaint_router.py b/backend/src/modules/complaint/complaint_router.py index d6b0115..5ab560c 100644 --- a/backend/src/modules/complaint/complaint_router.py +++ b/backend/src/modules/complaint/complaint_router.py @@ -1,8 +1,8 @@ from fastapi import APIRouter, Depends, status from src.core.authentication import authenticate_admin, authenticate_staff_or_admin -from src.modules.account.account_model import Account +from src.modules.account.account_model import AccountDto -from .complaint_model import Complaint, ComplaintData +from .complaint_model import ComplaintData, ComplaintDto from .complaint_service import ComplaintService complaint_router = APIRouter(prefix="/api/locations", tags=["complaints"]) @@ -10,7 +10,7 @@ @complaint_router.get( "/{location_id}/complaints", - response_model=list[Complaint], + response_model=list[ComplaintDto], status_code=status.HTTP_200_OK, summary="Get all complaints for a location", description="Returns all complaints associated with a given location. Staff or admin only.", @@ -18,15 +18,15 @@ async def get_complaints_by_location( location_id: int, complaint_service: ComplaintService = Depends(), - _: Account = Depends(authenticate_staff_or_admin), -) -> list[Complaint]: + _: AccountDto = Depends(authenticate_staff_or_admin), +) -> list[ComplaintDto]: """Get all complaints for a location.""" return await complaint_service.get_complaints_by_location(location_id) @complaint_router.post( "/{location_id}/complaints", - response_model=Complaint, + response_model=ComplaintDto, status_code=status.HTTP_201_CREATED, summary="Create a complaint for a location", description="Creates a new complaint associated with a location. Admin only.", @@ -35,15 +35,15 @@ async def create_complaint( location_id: int, complaint_data: ComplaintData, complaint_service: ComplaintService = Depends(), - _: Account = Depends(authenticate_admin), -) -> Complaint: + _: AccountDto = Depends(authenticate_admin), +) -> ComplaintDto: """Create a complaint for a location.""" return await complaint_service.create_complaint(location_id, complaint_data) @complaint_router.put( "/{location_id}/complaints/{complaint_id}", - response_model=Complaint, + response_model=ComplaintDto, status_code=status.HTTP_200_OK, summary="Update a complaint", description="Updates an existing complaint. Admin only.", @@ -53,17 +53,15 @@ async def update_complaint( complaint_id: int, complaint_data: ComplaintData, complaint_service: ComplaintService = Depends(), - _: Account = Depends(authenticate_admin), -) -> Complaint: + _: AccountDto = Depends(authenticate_admin), +) -> ComplaintDto: """Update a complaint.""" - return await complaint_service.update_complaint( - complaint_id, location_id, complaint_data - ) + return await complaint_service.update_complaint(complaint_id, location_id, complaint_data) @complaint_router.delete( "/{location_id}/complaints/{complaint_id}", - response_model=Complaint, + response_model=ComplaintDto, status_code=status.HTTP_200_OK, summary="Delete a complaint", description="Deletes a complaint. Admin only.", @@ -72,7 +70,7 @@ async def delete_complaint( location_id: int, complaint_id: int, complaint_service: ComplaintService = Depends(), - _: Account = Depends(authenticate_admin), -) -> Complaint: + _: AccountDto = Depends(authenticate_admin), +) -> ComplaintDto: """Delete a complaint.""" return await complaint_service.delete_complaint(complaint_id) diff --git a/backend/src/modules/complaint/complaint_service.py b/backend/src/modules/complaint/complaint_service.py index 8ea7589..c452061 100644 --- a/backend/src/modules/complaint/complaint_service.py +++ b/backend/src/modules/complaint/complaint_service.py @@ -7,7 +7,7 @@ from src.modules.location.location_service import LocationNotFoundException from .complaint_entity import ComplaintEntity -from .complaint_model import Complaint, ComplaintData +from .complaint_model import ComplaintData, ComplaintDto class ComplaintNotFoundException(NotFoundException): @@ -31,22 +31,20 @@ async def _get_complaint_entity_by_id(self, complaint_id: int) -> ComplaintEntit raise ComplaintNotFoundException(complaint_id) return complaint_entity - async def get_complaints_by_location(self, location_id: int) -> list[Complaint]: + async def get_complaints_by_location(self, location_id: int) -> list[ComplaintDto]: """Get all complaints for a given location.""" result = await self.session.execute( select(ComplaintEntity).where(ComplaintEntity.location_id == location_id) ) complaints = result.scalars().all() - return [complaint.to_model() for complaint in complaints] + return [complaint.to_dto() for complaint in complaints] - async def get_complaint_by_id(self, complaint_id: int) -> Complaint: + async def get_complaint_by_id(self, complaint_id: int) -> ComplaintDto: """Get a single complaint by ID.""" complaint_entity = await self._get_complaint_entity_by_id(complaint_id) - return complaint_entity.to_model() + return complaint_entity.to_dto() - async def create_complaint( - self, location_id: int, data: ComplaintData - ) -> Complaint: + async def create_complaint(self, location_id: int, data: ComplaintData) -> ComplaintDto: """Create a new complaint.""" new_complaint = ComplaintEntity( location_id=location_id, @@ -62,11 +60,11 @@ async def create_complaint( raise LocationNotFoundException(location_id) raise await self.session.refresh(new_complaint) - return new_complaint.to_model() + return new_complaint.to_dto() async def update_complaint( self, complaint_id: int, location_id: int, data: ComplaintData - ) -> Complaint: + ) -> ComplaintDto: """Update an existing complaint.""" complaint_entity = await self._get_complaint_entity_by_id(complaint_id) @@ -83,12 +81,12 @@ async def update_complaint( raise LocationNotFoundException(location_id) raise await self.session.refresh(complaint_entity) - return complaint_entity.to_model() + return complaint_entity.to_dto() - async def delete_complaint(self, complaint_id: int) -> Complaint: + async def delete_complaint(self, complaint_id: int) -> ComplaintDto: """Delete a complaint.""" complaint_entity = await self._get_complaint_entity_by_id(complaint_id) - complaint = complaint_entity.to_model() + complaint = complaint_entity.to_dto() await self.session.delete(complaint_entity) await self.session.commit() return complaint diff --git a/backend/src/modules/location/location_entity.py b/backend/src/modules/location/location_entity.py index 0302456..2958e98 100644 --- a/backend/src/modules/location/location_entity.py +++ b/backend/src/modules/location/location_entity.py @@ -7,7 +7,7 @@ from src.core.database import EntityBase from src.modules.complaint.complaint_entity import ComplaintEntity -from .location_model import Location, LocationData +from .location_model import LocationData, LocationDto class LocationEntity(MappedAsDataclass, EntityBase): @@ -53,7 +53,7 @@ class LocationEntity(MappedAsDataclass, EntityBase): __table_args__ = (Index("idx_lat_lng", "latitude", "longitude"),) - def to_model(self) -> Location: + def to_dto(self) -> LocationDto: # Check if complaints relationship is loaded to avoid lazy loading in tests # This prevents issues when LocationEntity is created without loading relationships insp = inspect(self) @@ -63,7 +63,7 @@ def to_model(self) -> Location: if hold_exp is not None and hold_exp.tzinfo is None: hold_exp = hold_exp.replace(tzinfo=timezone.utc) - return Location( + return LocationDto( id=self.id, google_place_id=self.google_place_id, formatted_address=self.formatted_address, @@ -80,13 +80,13 @@ def to_model(self) -> Location: warning_count=self.warning_count, citation_count=self.citation_count, hold_expiration=hold_exp, - complaints=[complaint.to_model() for complaint in self.complaints] + complaints=[complaint.to_dto() for complaint in self.complaints] if complaints_loaded else [], ) @classmethod - def from_model(cls, data: LocationData) -> Self: + def from_data(cls, data: LocationData) -> Self: return cls( google_place_id=data.google_place_id, formatted_address=data.formatted_address, diff --git a/backend/src/modules/location/location_model.py b/backend/src/modules/location/location_model.py index 8af8b06..915eb46 100644 --- a/backend/src/modules/location/location_model.py +++ b/backend/src/modules/location/location_model.py @@ -2,7 +2,7 @@ from pydantic import AwareDatetime, BaseModel, Field from src.core.models import PaginatedResponse -from src.modules.complaint.complaint_model import Complaint +from src.modules.complaint.complaint_model import ComplaintDto # Maximum allowed value for warning/citation counts to prevent overflow MAX_COUNT = 999999 @@ -67,12 +67,12 @@ def from_address( ) -class Location(LocationData): +class LocationDto(LocationData): id: int - complaints: list[Complaint] = [] + complaints: list[ComplaintDto] = [] -PaginatedLocationResponse = PaginatedResponse[Location] +PaginatedLocationResponse = PaginatedResponse[LocationDto] class LocationCreate(BaseModel): diff --git a/backend/src/modules/location/location_router.py b/backend/src/modules/location/location_router.py index 452e5dd..4a4272d 100644 --- a/backend/src/modules/location/location_router.py +++ b/backend/src/modules/location/location_router.py @@ -4,16 +4,16 @@ authenticate_by_role, authenticate_staff_or_admin, ) -from src.modules.account.account_model import Account +from src.modules.account.account_model import AccountDto from src.modules.location.location_model import ( AddressData, - Location, LocationCreate, LocationData, + LocationDto, PaginatedLocationResponse, ) from src.modules.location.location_service import LocationService -from src.modules.police.police_model import PoliceAccount +from src.modules.police.police_model import PoliceAccountDto from .location_model import AutocompleteInput, AutocompleteResult @@ -30,7 +30,7 @@ async def autocomplete_address( input_data: AutocompleteInput, location_service: LocationService = Depends(), - user: Account | PoliceAccount = Depends( + user: AccountDto | PoliceAccountDto = Depends( authenticate_by_role("police", "student", "admin", "staff") ), ) -> list[AutocompleteResult]: @@ -64,7 +64,7 @@ async def autocomplete_address( async def get_place_details( place_id: str, location_service: LocationService = Depends(), - user: Account | PoliceAccount = Depends( + user: AccountDto | PoliceAccountDto = Depends( authenticate_by_role("police", "student", "admin", "staff") ), ) -> AddressData: @@ -101,7 +101,7 @@ async def get_locations( ) -@location_router.get("/{location_id}", response_model=Location) +@location_router.get("/{location_id}", response_model=LocationDto) async def get_location( location_id: int, location_service: LocationService = Depends(), @@ -110,7 +110,7 @@ async def get_location( return await location_service.get_location_by_id(location_id) -@location_router.post("/", status_code=201, response_model=Location) +@location_router.post("/", status_code=201, response_model=LocationDto) async def create_location( data: LocationCreate, location_service: LocationService = Depends(), @@ -127,7 +127,7 @@ async def create_location( ) -@location_router.put("/{location_id}", response_model=Location) +@location_router.put("/{location_id}", response_model=LocationDto) async def update_location( location_id: int, data: LocationCreate, @@ -147,9 +147,7 @@ async def update_location( ) else: location_data = LocationData( - **location.model_dump( - exclude={"warning_count", "citation_count", "hold_expiration"} - ), + **location.model_dump(exclude={"warning_count", "citation_count", "hold_expiration"}), warning_count=data.warning_count, citation_count=data.citation_count, hold_expiration=data.hold_expiration, @@ -158,7 +156,7 @@ async def update_location( return await location_service.update_location(location_id, location_data) -@location_router.delete("/{location_id}", response_model=Location) +@location_router.delete("/{location_id}", response_model=LocationDto) async def delete_location( location_id: int, location_service: LocationService = Depends(), diff --git a/backend/src/modules/location/location_service.py b/backend/src/modules/location/location_service.py index 8585c37..e060e70 100644 --- a/backend/src/modules/location/location_service.py +++ b/backend/src/modules/location/location_service.py @@ -17,7 +17,7 @@ ) from .location_entity import LocationEntity -from .location_model import MAX_COUNT, AddressData, AutocompleteResult, Location, LocationData +from .location_model import MAX_COUNT, AddressData, AutocompleteResult, LocationData, LocationDto class GoogleMapsAPIException(InternalServerException): @@ -93,34 +93,36 @@ async def _get_location_entity_by_place_id(self, google_place_id: str) -> Locati ) return result.scalar_one_or_none() - def assert_valid_location_hold(self, location: Location) -> None: + def assert_valid_location_hold(self, location: LocationDto) -> None: """Validate that location does not have an active hold.""" - if location.hold_expiration is not None and location.hold_expiration > datetime.now(timezone.utc): + if location.hold_expiration is not None and location.hold_expiration > datetime.now( + timezone.utc + ): raise LocationHoldActiveException(location.id, location.hold_expiration) - async def get_locations(self) -> list[Location]: + async def get_locations(self) -> list[LocationDto]: result = await self.session.execute(select(LocationEntity)) locations = result.scalars().all() - return [location.to_model() for location in locations] + return [location.to_dto() for location in locations] - async def get_location_by_id(self, location_id: int) -> Location: + async def get_location_by_id(self, location_id: int) -> LocationDto: location_entity = await self._get_location_entity_by_id(location_id) - return location_entity.to_model() + return location_entity.to_dto() - async def get_location_by_place_id(self, google_place_id: str) -> Location: + async def get_location_by_place_id(self, google_place_id: str) -> LocationDto: location_entity = await self._get_location_entity_by_place_id(google_place_id) if location_entity is None: raise LocationNotFoundException(google_place_id=google_place_id) - return location_entity.to_model() + return location_entity.to_dto() async def assert_location_exists(self, location_id: int) -> None: await self._get_location_entity_by_id(location_id) - async def create_location(self, data: LocationData) -> Location: + async def create_location(self, data: LocationData) -> LocationDto: if await self._get_location_entity_by_place_id(data.google_place_id): raise LocationConflictException(data.google_place_id) - new_location = LocationEntity.from_model(data) + new_location = LocationEntity.from_data(data) try: self.session.add(new_location) await self.session.commit() @@ -128,17 +130,17 @@ async def create_location(self, data: LocationData) -> Location: # handle race condition where another session inserted the same google_place_id raise LocationConflictException(data.google_place_id) await self.session.refresh(new_location) - return new_location.to_model() + return new_location.to_dto() - async def create_location_from_address(self, address_data: AddressData) -> Location: + async def create_location_from_address(self, address_data: AddressData) -> LocationDto: location_data = LocationData.from_address(address_data) return await self.create_location(location_data) - async def create_location_from_place_id(self, place_id: str) -> Location: + async def create_location_from_place_id(self, place_id: str) -> LocationDto: address_data = await self.get_place_details(place_id) return await self.create_location_from_address(address_data) - async def get_or_create_location(self, place_id: str) -> Location: + async def get_or_create_location(self, place_id: str) -> LocationDto: """Get existing location by place_id, or create it if it doesn't exist.""" # Try to get existing location try: @@ -148,7 +150,7 @@ async def get_or_create_location(self, place_id: str) -> Location: location = await self.create_location_from_place_id(place_id) return location - async def update_location(self, location_id: int, data: LocationData) -> Location: + async def update_location(self, location_id: int, data: LocationData) -> LocationDto: location_entity = await self._get_location_entity_by_id(location_id) if data.google_place_id != location_entity.google_place_id: @@ -167,16 +169,16 @@ async def update_location(self, location_id: int, data: LocationData) -> Locatio except IntegrityError: raise LocationConflictException(data.google_place_id) await self.session.refresh(location_entity) - return location_entity.to_model() + return location_entity.to_dto() - async def delete_location(self, location_id: int) -> Location: + async def delete_location(self, location_id: int) -> LocationDto: location_entity = await self._get_location_entity_by_id(location_id) - location = location_entity.to_model() + location = location_entity.to_dto() await self.session.delete(location_entity) await self.session.commit() return location - async def increment_warnings(self, location_id: int) -> Location: + async def increment_warnings(self, location_id: int) -> LocationDto: """Increment the warning count for a location by 1.""" location_entity = await self._get_location_entity_by_id(location_id) if location_entity.warning_count >= MAX_COUNT: @@ -185,9 +187,9 @@ async def increment_warnings(self, location_id: int) -> Location: self.session.add(location_entity) await self.session.commit() await self.session.refresh(location_entity) - return location_entity.to_model() + return location_entity.to_dto() - async def increment_citations(self, location_id: int) -> Location: + async def increment_citations(self, location_id: int) -> LocationDto: """Increment the citation count for a location by 1.""" location_entity = await self._get_location_entity_by_id(location_id) if location_entity.citation_count >= MAX_COUNT: @@ -196,7 +198,7 @@ async def increment_citations(self, location_id: int) -> Location: self.session.add(location_entity) await self.session.commit() await self.session.refresh(location_entity) - return location_entity.to_model() + return location_entity.to_dto() async def autocomplete_address(self, input_text: str) -> list[AutocompleteResult]: # Autocomplete an address using Google Maps Places API. Biased towards Chapel Hill, NC area diff --git a/backend/src/modules/party/party_entity.py b/backend/src/modules/party/party_entity.py index 00b1d66..a9a5621 100644 --- a/backend/src/modules/party/party_entity.py +++ b/backend/src/modules/party/party_entity.py @@ -8,7 +8,7 @@ from src.modules.student.student_model import ContactPreference from ..student.student_entity import StudentEntity -from .party_model import Contact, Party, PartyData +from .party_model import ContactDto, PartyData, PartyDto if TYPE_CHECKING: from ..location.location_entity import LocationEntity @@ -44,7 +44,7 @@ class PartyEntity(MappedAsDataclass, EntityBase): ) @classmethod - def from_model(cls, data: PartyData) -> Self: + def from_data(cls, data: PartyData) -> Self: return cls( party_datetime=data.party_datetime, location_id=data.location_id, @@ -56,19 +56,19 @@ def from_model(cls, data: PartyData) -> Self: contact_two_contact_preference=data.contact_two.contact_preference, ) - def to_model(self) -> Party: + def to_dto(self) -> PartyDto: """Convert entity to model. Requires relationships to be eagerly loaded.""" # Ensure party_datetime is timezone-aware party_dt = self.party_datetime if party_dt.tzinfo is None: party_dt = party_dt.replace(tzinfo=timezone.utc) - return Party( + return PartyDto( id=self.id, party_datetime=party_dt, - location=self.location.to_model(), + location=self.location.to_dto(), contact_one=self.contact_one.to_dto(), - contact_two=Contact( + contact_two=ContactDto( email=self.contact_two_email, first_name=self.contact_two_first_name, last_name=self.contact_two_last_name, @@ -77,7 +77,7 @@ def to_model(self) -> Party: ), ) - async def load_model(self, session: AsyncSession) -> Party: + async def load_dto(self, session: AsyncSession) -> PartyDto: """ Load party with relationships from database and convert to model. Should be used to get the model only if relationships haven't been loaded yet. @@ -92,9 +92,9 @@ async def load_model(self, session: AsyncSession) -> Party: ) ) party_entity = result.scalar_one() - return party_entity.to_model() + return party_entity.to_dto() - def set_contact_two(self, contact: Contact) -> None: + def set_contact_two(self, contact: ContactDto) -> None: self.contact_two_email = contact.email self.contact_two_first_name = contact.first_name self.contact_two_last_name = contact.last_name diff --git a/backend/src/modules/party/party_model.py b/backend/src/modules/party/party_model.py index 934ff6c..51788fc 100644 --- a/backend/src/modules/party/party_model.py +++ b/backend/src/modules/party/party_model.py @@ -2,18 +2,18 @@ from pydantic import AwareDatetime, BaseModel, EmailStr, Field from src.core.models import PaginatedResponse -from src.modules.location.location_model import Location -from src.modules.student.student_model import ContactPreference, Student +from src.modules.location.location_model import LocationDto +from src.modules.student.student_model import ContactPreference, StudentDto class PartyData(BaseModel): party_datetime: AwareDatetime = Field(..., description="Date and time of the party") location_id: int = Field(..., description="ID of the location where the party is held") contact_one_id: int = Field(..., description="ID of the first contact student") - contact_two: "Contact" = Field(..., description="Contact information for the second contact") + contact_two: "ContactDto" = Field(..., description="Contact information for the second contact") -class Contact(BaseModel): +class ContactDto(BaseModel): """DTO for contact information (contact_two in party registration).""" email: EmailStr = Field(..., description="Email address of the contact") @@ -27,25 +27,25 @@ class Contact(BaseModel): ) -class Party(BaseModel): +class PartyDto(BaseModel): id: int party_datetime: AwareDatetime = Field(..., description="Date and time of the party") - location: Location = Field(..., description="Location where the party is held") - contact_one: Student = Field(..., description="First contact student") - contact_two: Contact = Field(..., description="Contact information for the second contact") + location: LocationDto = Field(..., description="Location where the party is held") + contact_one: StudentDto = Field(..., description="First contact student") + contact_two: ContactDto = Field(..., description="Contact information for the second contact") -class StudentCreatePartyDTO(BaseModel): +class StudentCreatePartyDto(BaseModel): """DTO for students creating a party registration. contact_one will be automatically set from the authenticated student.""" type: Literal["student"] = Field("student", description="Request type discriminator") party_datetime: AwareDatetime = Field(..., description="Date and time of the party") google_place_id: str = Field(..., description="Google Maps place ID of the location") - contact_two: Contact = Field(..., description="Contact information for the second contact") + contact_two: ContactDto = Field(..., description="Contact information for the second contact") -class AdminCreatePartyDTO(BaseModel): +class AdminCreatePartyDto(BaseModel): """DTO for admins creating or updating a party registration. Both contacts must be explicitly specified.""" @@ -55,13 +55,13 @@ class AdminCreatePartyDTO(BaseModel): contact_one_email: EmailStr = Field( ..., description="Email address of the first contact student" ) - contact_two: Contact = Field(..., description="Contact information for the second contact") + contact_two: ContactDto = Field(..., description="Contact information for the second contact") # Discriminated union for party creation/update requests -CreatePartyDTO = Annotated[ - Union[StudentCreatePartyDTO, AdminCreatePartyDTO], Field(discriminator="type") +CreatePartyDto = Annotated[ + Union[StudentCreatePartyDto, AdminCreatePartyDto], Field(discriminator="type") ] -PaginatedPartiesResponse = PaginatedResponse[Party] +PaginatedPartiesResponse = PaginatedResponse[PartyDto] diff --git a/backend/src/modules/party/party_router.py b/backend/src/modules/party/party_router.py index 59e717c..71b8c7e 100644 --- a/backend/src/modules/party/party_router.py +++ b/backend/src/modules/party/party_router.py @@ -9,16 +9,20 @@ authenticate_staff_or_admin, authenticate_user, ) -from src.core.exceptions import BadRequestException, ForbiddenException, UnprocessableEntityException -from src.modules.account.account_model import Account, AccountRole +from src.core.exceptions import ( + BadRequestException, + ForbiddenException, + UnprocessableEntityException, +) +from src.modules.account.account_model import AccountDto, AccountRole from src.modules.location.location_service import LocationService from .party_model import ( - AdminCreatePartyDTO, - CreatePartyDTO, + AdminCreatePartyDto, + CreatePartyDto, PaginatedPartiesResponse, - Party, - StudentCreatePartyDTO, + PartyDto, + StudentCreatePartyDto, ) from .party_service import PartyService @@ -27,10 +31,10 @@ @party_router.post("/", status_code=201) async def create_party( - party_data: CreatePartyDTO, + party_data: CreatePartyDto, party_service: PartyService = Depends(), - user: Account = Depends(authenticate_user), -) -> Party: + user: AccountDto = Depends(authenticate_user), +) -> PartyDto: """ Create a new party registration. @@ -44,13 +48,13 @@ async def create_party( If contact_two's email doesn't exist in the system, a new student account will be created. """ # Validate that the DTO type matches the user's role - if isinstance(party_data, StudentCreatePartyDTO): + if isinstance(party_data, StudentCreatePartyDto): if user.role != AccountRole.STUDENT: raise ForbiddenException( detail="Only students can use the student party creation endpoint" ) return await party_service.create_party_from_student_dto(party_data, user.id) - elif isinstance(party_data, AdminCreatePartyDTO): + elif isinstance(party_data, AdminCreatePartyDto): if user.role != AccountRole.ADMIN: raise ForbiddenException(detail="Only admins can use the admin party creation endpoint") return await party_service.create_party_from_admin_dto(party_data) @@ -114,12 +118,16 @@ async def list_parties( @party_router.get("/nearby") async def get_parties_nearby( place_id: str = Query(..., description="Google Maps place ID"), - start_date: str = Query(..., pattern=r"^\d{4}-\d{2}-\d{2}$", description="Start date (YYYY-MM-DD format)"), - end_date: str = Query(..., pattern=r"^\d{4}-\d{2}-\d{2}$", description="End date (YYYY-MM-DD format)"), + start_date: str = Query( + ..., pattern=r"^\d{4}-\d{2}-\d{2}$", description="Start date (YYYY-MM-DD format)" + ), + end_date: str = Query( + ..., pattern=r"^\d{4}-\d{2}-\d{2}$", description="End date (YYYY-MM-DD format)" + ), party_service: PartyService = Depends(), location_service: LocationService = Depends(), _=Depends(authenticate_police_or_admin), -) -> list[Party]: +) -> list[PartyDto]: """ Returns parties within a radius of a location specified by Google Maps place ID, filtered by date range. @@ -166,8 +174,12 @@ async def get_parties_nearby( @party_router.get("/csv") async def get_parties_csv( - start_date: str = Query(..., pattern=r"^\d{4}-\d{2}-\d{2}$", description="Start date in YYYY-MM-DD format"), - end_date: str = Query(..., pattern=r"^\d{4}-\d{2}-\d{2}$", description="End date in YYYY-MM-DD format"), + start_date: str = Query( + ..., pattern=r"^\d{4}-\d{2}-\d{2}$", description="Start date in YYYY-MM-DD format" + ), + end_date: str = Query( + ..., pattern=r"^\d{4}-\d{2}-\d{2}$", description="End date in YYYY-MM-DD format" + ), party_service: PartyService = Depends(), _=Depends(authenticate_admin), ) -> Response: @@ -209,10 +221,10 @@ async def get_parties_csv( @party_router.put("/{party_id}") async def update_party( party_id: int, - party_data: CreatePartyDTO, + party_data: CreatePartyDto, party_service: PartyService = Depends(), - user: Account = Depends(authenticate_user), -) -> Party: + user: AccountDto = Depends(authenticate_user), +) -> PartyDto: """ Update an existing party registration. @@ -226,13 +238,13 @@ async def update_party( If contact_two's email doesn't exist in the system, a new student account will be created. """ # Validate that the DTO type matches the user's role - if isinstance(party_data, StudentCreatePartyDTO): + if isinstance(party_data, StudentCreatePartyDto): if user.role != AccountRole.STUDENT: raise ForbiddenException( detail="Only students can use the student party update endpoint" ) return await party_service.update_party_from_student_dto(party_id, party_data, user.id) - elif isinstance(party_data, AdminCreatePartyDTO): + elif isinstance(party_data, AdminCreatePartyDto): if user.role != AccountRole.ADMIN: raise ForbiddenException(detail="Only admins can use the admin party update endpoint") return await party_service.update_party_from_admin_dto(party_id, party_data) @@ -245,7 +257,7 @@ async def get_party( party_id: int, party_service: PartyService = Depends(), _=Depends(authenticate_staff_or_admin), -) -> Party: +) -> PartyDto: """ Returns a party registration by ID. @@ -266,7 +278,7 @@ async def delete_party( party_id: int, party_service: PartyService = Depends(), _=Depends(authenticate_admin), -) -> Party: +) -> PartyDto: """ Deletes a party registration by ID. diff --git a/backend/src/modules/party/party_service.py b/backend/src/modules/party/party_service.py index 894d880..285bad1 100644 --- a/backend/src/modules/party/party_service.py +++ b/backend/src/modules/party/party_service.py @@ -12,14 +12,14 @@ from src.core.config import env from src.core.database import get_session from src.core.exceptions import BadRequestException, ConflictException, NotFoundException -from src.modules.location.location_model import Location +from src.modules.location.location_model import LocationDto from src.modules.student.student_service import StudentNotFoundException, StudentService from ..account.account_service import AccountByEmailNotFoundException, AccountService from ..location.location_service import LocationService from ..student.student_entity import StudentEntity from .party_entity import PartyEntity -from .party_model import AdminCreatePartyDTO, Party, PartyData, StudentCreatePartyDTO +from .party_model import AdminCreatePartyDto, PartyData, PartyDto, StudentCreatePartyDto class PartyNotFoundException(NotFoundException): @@ -128,7 +128,7 @@ async def _validate_party_smart_attendance(self, student_id: int) -> None: if student.last_registered < most_recent_august_first: raise PartySmartNotCompletedException(student_id) - async def _validate_and_get_location(self, place_id: str) -> Location: + async def _validate_and_get_location(self, place_id: str) -> LocationDto: """Get or create location and validate it has no active hold.""" location = await self.location_service.get_or_create_location(place_id) self.location_service.assert_valid_location_hold(location) @@ -160,7 +160,7 @@ async def _get_student_by_email(self, email: str) -> StudentEntity: raise StudentNotFoundException(account.id) return student - async def get_parties(self, skip: int = 0, limit: int | None = None) -> List[Party]: + async def get_parties(self, skip: int = 0, limit: int | None = None) -> List[PartyDto]: query = ( select(PartyEntity) .offset(skip) @@ -173,13 +173,13 @@ async def get_parties(self, skip: int = 0, limit: int | None = None) -> List[Par query = query.limit(limit) result = await self.session.execute(query) parties = result.scalars().all() - return [party.to_model() for party in parties] + return [party.to_dto() for party in parties] - async def get_party_by_id(self, party_id: int) -> Party: + async def get_party_by_id(self, party_id: int) -> PartyDto: party_entity = await self._get_party_entity_by_id(party_id) - return party_entity.to_model() + return party_entity.to_dto() - async def get_parties_by_location(self, location_id: int) -> List[Party]: + async def get_parties_by_location(self, location_id: int) -> List[PartyDto]: result = await self.session.execute( select(PartyEntity) .where(PartyEntity.location_id == location_id) @@ -189,9 +189,9 @@ async def get_parties_by_location(self, location_id: int) -> List[Party]: ) ) parties = result.scalars().all() - return [party.to_model() for party in parties] + return [party.to_dto() for party in parties] - async def get_parties_by_contact(self, student_id: int) -> List[Party]: + async def get_parties_by_contact(self, student_id: int) -> List[PartyDto]: result = await self.session.execute( select(PartyEntity) .where(PartyEntity.contact_one_id == student_id) @@ -201,11 +201,11 @@ async def get_parties_by_contact(self, student_id: int) -> List[Party]: ) ) parties = result.scalars().all() - return [party.to_model() for party in parties] + return [party.to_dto() for party in parties] async def get_parties_by_date_range( self, start_date: datetime, end_date: datetime - ) -> List[Party]: + ) -> List[PartyDto]: result = await self.session.execute( select(PartyEntity) .where( @@ -218,22 +218,22 @@ async def get_parties_by_date_range( ) ) parties = result.scalars().all() - return [party.to_model() for party in parties] + return [party.to_dto() for party in parties] - async def create_party(self, data: PartyData) -> Party: + async def create_party(self, data: PartyData) -> PartyDto: # Validate that referenced resources exist await self.location_service.assert_location_exists(data.location_id) await self.student_service.assert_student_exists(data.contact_one_id) - new_party = PartyEntity.from_model(data) + new_party = PartyEntity.from_data(data) try: self.session.add(new_party) await self.session.commit() except IntegrityError as e: raise PartyConflictException(f"Failed to create party: {str(e)}") - return await new_party.load_model(self.session) + return await new_party.load_dto(self.session) - async def update_party(self, party_id: int, data: PartyData) -> Party: + async def update_party(self, party_id: int, data: PartyData) -> PartyDto: party_entity = await self._get_party_entity_by_id(party_id) # Validate that referenced resources exist @@ -253,11 +253,11 @@ async def update_party(self, party_id: int, data: PartyData) -> Party: await self.session.commit() except IntegrityError as e: raise PartyConflictException(f"Failed to update party: {str(e)}") - return await party_entity.load_model(self.session) + return await party_entity.load_dto(self.session) async def create_party_from_student_dto( - self, dto: StudentCreatePartyDTO, student_account_id: int - ) -> Party: + self, dto: StudentCreatePartyDto, student_account_id: int + ) -> PartyDto: """Create a party registration from a student. contact_one is auto-filled.""" # Validate student party prerequisites (date and Party Smart) await self._validate_student_party_prerequisites(student_account_id, dto.party_datetime) @@ -274,12 +274,12 @@ async def create_party_from_student_dto( ) # Create party - new_party = PartyEntity.from_model(party_data) + new_party = PartyEntity.from_data(party_data) self.session.add(new_party) await self.session.commit() - return await new_party.load_model(self.session) + return await new_party.load_dto(self.session) - async def create_party_from_admin_dto(self, dto: AdminCreatePartyDTO) -> Party: + async def create_party_from_admin_dto(self, dto: AdminCreatePartyDto) -> PartyDto: """Create a party registration from an admin. Both contacts must be specified.""" # Get/create location and validate no hold location = await self._validate_and_get_location(dto.google_place_id) @@ -296,14 +296,14 @@ async def create_party_from_admin_dto(self, dto: AdminCreatePartyDTO) -> Party: ) # Create party - new_party = PartyEntity.from_model(party_data) + new_party = PartyEntity.from_data(party_data) self.session.add(new_party) await self.session.commit() - return await new_party.load_model(self.session) + return await new_party.load_dto(self.session) async def update_party_from_student_dto( - self, party_id: int, dto: StudentCreatePartyDTO, student_account_id: int - ) -> Party: + self, party_id: int, dto: StudentCreatePartyDto, student_account_id: int + ) -> PartyDto: """Update a party registration from a student. contact_one is auto-filled.""" # Get existing party party_entity = await self._get_party_entity_by_id(party_id) @@ -329,9 +329,11 @@ async def update_party_from_student_dto( self.session.add(party_entity) await self.session.commit() - return await party_entity.load_model(self.session) + return await party_entity.load_dto(self.session) - async def update_party_from_admin_dto(self, party_id: int, dto: AdminCreatePartyDTO) -> Party: + async def update_party_from_admin_dto( + self, party_id: int, dto: AdminCreatePartyDto + ) -> PartyDto: """Update a party registration from an admin. Both contacts must be specified.""" # Get existing party party_entity = await self._get_party_entity_by_id(party_id) @@ -355,11 +357,11 @@ async def update_party_from_admin_dto(self, party_id: int, dto: AdminCreateParty self.session.add(party_entity) await self.session.commit() - return await party_entity.load_model(self.session) + return await party_entity.load_dto(self.session) - async def delete_party(self, party_id: int) -> Party: + async def delete_party(self, party_id: int) -> PartyDto: party_entity = await self._get_party_entity_by_id(party_id) - party = party_entity.to_model() + party = party_entity.to_dto() await self.session.delete(party_entity) await self.session.commit() return party @@ -375,7 +377,7 @@ async def get_party_count(self) -> int: async def get_parties_by_student_and_date( self, student_id: int, target_date: datetime - ) -> List[Party]: + ) -> List[PartyDto]: start_of_day = target_date.replace(hour=0, minute=0, second=0, microsecond=0) end_of_day = target_date.replace(hour=23, minute=59, second=59, microsecond=999999) @@ -392,9 +394,9 @@ async def get_parties_by_student_and_date( ) ) parties = result.scalars().all() - return [party.to_model() for party in parties] + return [party.to_dto() for party in parties] - async def get_parties_by_radius(self, latitude: float, longitude: float) -> List[Party]: + async def get_parties_by_radius(self, latitude: float, longitude: float) -> List[PartyDto]: current_time = datetime.now(timezone.utc) start_time = current_time - timedelta(hours=6) end_time = current_time + timedelta(hours=12) @@ -412,7 +414,7 @@ async def get_parties_by_radius(self, latitude: float, longitude: float) -> List ) parties = result.scalars().all() - parties_within_radius = [] + parties_within_radius: list[PartyEntity] = [] for party in parties: if party.location is None: continue @@ -427,7 +429,7 @@ async def get_parties_by_radius(self, latitude: float, longitude: float) -> List if distance <= env.PARTY_SEARCH_RADIUS_MILES: parties_within_radius.append(party) - return [party.to_model() for party in parties_within_radius] + return [party.to_dto() for party in parties_within_radius] async def get_parties_by_radius_and_date_range( self, @@ -435,7 +437,7 @@ async def get_parties_by_radius_and_date_range( longitude: float, start_date: datetime, end_date: datetime, - ) -> List[Party]: + ) -> List[PartyDto]: """ Get parties within a radius of a location within a specified date range. @@ -461,7 +463,7 @@ async def get_parties_by_radius_and_date_range( ) parties = result.scalars().all() - parties_within_radius = [] + parties_within_radius: list[PartyEntity] = [] for party in parties: if party.location is None: continue @@ -476,7 +478,7 @@ async def get_parties_by_radius_and_date_range( if distance <= env.PARTY_SEARCH_RADIUS_MILES: parties_within_radius.append(party) - return [party.to_model() for party in parties_within_radius] + return [party.to_dto() for party in parties_within_radius] def _calculate_haversine_distance( self, lat1: float, lon1: float, lat2: float, lon2: float @@ -491,7 +493,7 @@ def _calculate_haversine_distance( r = 3959 return c * r - async def export_parties_to_csv(self, parties: List[Party]) -> str: + async def export_parties_to_csv(self, parties: List[PartyDto]) -> str: """ Export a list of parties to CSV format. diff --git a/backend/src/modules/police/police_entity.py b/backend/src/modules/police/police_entity.py index 50615ca..b815f3d 100644 --- a/backend/src/modules/police/police_entity.py +++ b/backend/src/modules/police/police_entity.py @@ -4,7 +4,7 @@ from sqlalchemy.orm import Mapped, MappedAsDataclass, mapped_column from src.core.bcrypt_utils import hash_password from src.core.database import EntityBase -from src.modules.police.police_model import PoliceAccount, PoliceAccountUpdate +from src.modules.police.police_model import PoliceAccountDto, PoliceAccountUpdate class PoliceEntity(MappedAsDataclass, EntityBase): @@ -21,11 +21,11 @@ class PoliceEntity(MappedAsDataclass, EntityBase): hashed_password: Mapped[str] = mapped_column(String, nullable=False) @classmethod - def from_model(cls, data: PoliceAccountUpdate) -> Self: + def from_data(cls, data: PoliceAccountUpdate) -> Self: """Create a PoliceEntity from a PoliceAccountUpdate model.""" hashed_password = hash_password(data.password) return cls(email=data.email, hashed_password=hashed_password) - def to_model(self) -> PoliceAccount: + def to_dto(self) -> PoliceAccountDto: """Convert the entity to a PoliceAccount model.""" - return PoliceAccount(email=self.email) + return PoliceAccountDto(email=self.email) diff --git a/backend/src/modules/police/police_model.py b/backend/src/modules/police/police_model.py index 01d7f87..b3a8e94 100644 --- a/backend/src/modules/police/police_model.py +++ b/backend/src/modules/police/police_model.py @@ -1,7 +1,7 @@ from pydantic import BaseModel, EmailStr -class PoliceAccount(BaseModel): +class PoliceAccountDto(BaseModel): """DTO for Police Account responses (email only, no password exposed).""" email: EmailStr diff --git a/backend/src/modules/police/police_router.py b/backend/src/modules/police/police_router.py index ab593eb..886fdfb 100644 --- a/backend/src/modules/police/police_router.py +++ b/backend/src/modules/police/police_router.py @@ -1,16 +1,16 @@ from fastapi import APIRouter, Depends, status from src.core.authentication import authenticate_police_or_admin -from src.modules.account.account_model import Account -from src.modules.location.location_model import Location +from src.modules.account.account_model import AccountDto +from src.modules.location.location_model import LocationDto from src.modules.location.location_service import LocationService -from src.modules.police.police_model import PoliceAccount +from src.modules.police.police_model import PoliceAccountDto police_router = APIRouter(prefix="/api/police", tags=["police"]) @police_router.post( "/locations/{location_id}/warnings", - response_model=Location, + response_model=LocationDto, status_code=status.HTTP_200_OK, summary="Increment location warning count", description="Increments the warning count for a location. Requires police or admin authentication.", @@ -18,8 +18,8 @@ async def increment_warnings( location_id: int, location_service: LocationService = Depends(), - _: Account | PoliceAccount = Depends(authenticate_police_or_admin), -) -> Location: + _: AccountDto | PoliceAccountDto = Depends(authenticate_police_or_admin), +) -> LocationDto: """ Increment the warning count for a location by 1. """ @@ -28,7 +28,7 @@ async def increment_warnings( @police_router.post( "/locations/{location_id}/citations", - response_model=Location, + response_model=LocationDto, status_code=status.HTTP_200_OK, summary="Increment location citation count", description="Increments the citation count for a location. Requires police or admin authentication.", @@ -36,8 +36,8 @@ async def increment_warnings( async def increment_citations( location_id: int, location_service: LocationService = Depends(), - _: Account | PoliceAccount = Depends(authenticate_police_or_admin), -) -> Location: + _: AccountDto | PoliceAccountDto = Depends(authenticate_police_or_admin), +) -> LocationDto: """ Increment the citation count for a location by 1. """ diff --git a/backend/src/modules/student/student_entity.py b/backend/src/modules/student/student_entity.py index 78860b9..202000b 100644 --- a/backend/src/modules/student/student_entity.py +++ b/backend/src/modules/student/student_entity.py @@ -6,7 +6,7 @@ from sqlalchemy.orm import Mapped, MappedAsDataclass, mapped_column, relationship, selectinload from src.core.database import EntityBase -from .student_model import ContactPreference, DbStudent, Student, StudentData +from .student_model import ContactPreference, StudentData, StudentDto if TYPE_CHECKING: from src.modules.account.account_entity import AccountEntity @@ -27,7 +27,7 @@ class StudentEntity(MappedAsDataclass, EntityBase): account: Mapped["AccountEntity"] = relationship("AccountEntity", init=False) @classmethod - def from_model(cls, data: "StudentData", account_id: int) -> Self: + def from_data(cls, data: "StudentData", account_id: int) -> Self: return cls( contact_preference=data.contact_preference, last_registered=data.last_registered, @@ -35,27 +35,14 @@ def from_model(cls, data: "StudentData", account_id: int) -> Self: account_id=account_id, ) - def to_model(self) -> "DbStudent": - # Ensure last_registered is timezone-aware if present - last_reg = self.last_registered - if last_reg is not None and last_reg.tzinfo is None: - last_reg = last_reg.replace(tzinfo=timezone.utc) - - return DbStudent( - account_id=self.account_id, - contact_preference=self.contact_preference, - last_registered=last_reg, - phone_number=self.phone_number, - ) - - def to_dto(self) -> "Student": + def to_dto(self) -> "StudentDto": """Convert entity to DTO using the account relationship.""" # Ensure last_registered is timezone-aware if present last_reg = self.last_registered if last_reg is not None and last_reg.tzinfo is None: last_reg = last_reg.replace(tzinfo=timezone.utc) - return Student( + return StudentDto( id=self.account_id, pid=self.account.pid, email=self.account.email, @@ -66,7 +53,7 @@ def to_dto(self) -> "Student": last_registered=last_reg, ) - async def load_dto(self, session: AsyncSession) -> Student: + async def load_dto(self, session: AsyncSession) -> StudentDto: """ Load student with account relationship from database and convert to DTO. Should be used to get the DTO only if the account relationship hasn't been loaded yet. diff --git a/backend/src/modules/student/student_model.py b/backend/src/modules/student/student_model.py index f51b0a3..c963ef1 100644 --- a/backend/src/modules/student/student_model.py +++ b/backend/src/modules/student/student_model.py @@ -5,8 +5,8 @@ class ContactPreference(enum.Enum): - call = "call" - text = "text" + CALL = "call" + TEXT = "text" class StudentData(BaseModel): @@ -35,7 +35,7 @@ def id(self) -> int: return self.account_id -class Student(BaseModel): +class StudentDto(BaseModel): """ Admin-facing Student DTO combining student and account data. @@ -69,9 +69,7 @@ class StudentCreate(BaseModel): class IsRegisteredUpdate(BaseModel): """Request body for updating student registration status (staff/admin).""" - is_registered: bool = Field( - ..., description="True to mark as registered, False to unmark" - ) + is_registered: bool = Field(..., description="True to mark as registered, False to unmark") -PaginatedStudentsResponse = PaginatedResponse[Student] +PaginatedStudentsResponse = PaginatedResponse[StudentDto] diff --git a/backend/src/modules/student/student_router.py b/backend/src/modules/student/student_router.py index 8714dac..47d0966 100644 --- a/backend/src/modules/student/student_router.py +++ b/backend/src/modules/student/student_router.py @@ -4,17 +4,17 @@ authenticate_staff_or_admin, authenticate_student, ) -from src.modules.account.account_model import Account -from src.modules.party.party_model import Party +from src.modules.account.account_model import AccountDto +from src.modules.party.party_model import PartyDto from src.modules.party.party_service import PartyService from .student_model import ( IsRegisteredUpdate, PaginatedStudentsResponse, - Student, StudentCreate, StudentData, StudentDataWithNames, + StudentDto, ) from .student_service import StudentService @@ -24,8 +24,8 @@ @student_router.get("/me") async def get_me( student_service: StudentService = Depends(), - user: "Account" = Depends(authenticate_student), -) -> Student: + user: "AccountDto" = Depends(authenticate_student), +) -> StudentDto: return await student_service.get_student_by_id(user.id) @@ -33,16 +33,16 @@ async def get_me( async def update_me( data: StudentData, student_service: StudentService = Depends(), - user: "Account" = Depends(authenticate_student), -) -> Student: + user: "AccountDto" = Depends(authenticate_student), +) -> StudentDto: return await student_service.update_student(user.id, data) @student_router.get("/me/parties") async def get_my_parties( party_service: PartyService = Depends(), - user: "Account" = Depends(authenticate_student), -) -> list[Party]: + user: "AccountDto" = Depends(authenticate_student), +) -> list[PartyDto]: return await party_service.get_parties_by_contact(user.id) @@ -98,9 +98,7 @@ async def list_students( students = await student_service.get_students(skip=skip, limit=page_size) # Calculate total pages (ceiling division) - total_pages = ( - (total_records + page_size - 1) // page_size if total_records > 0 else 0 - ) + total_pages = (total_records + page_size - 1) // page_size if total_records > 0 else 0 return PaginatedStudentsResponse( items=students, @@ -116,7 +114,7 @@ async def get_student( student_id: int, student_service: StudentService = Depends(), _=Depends(authenticate_staff_or_admin), -) -> Student: +) -> StudentDto: return await student_service.get_student_by_id(student_id) @@ -125,7 +123,7 @@ async def create_student( payload: StudentCreate, student_service: StudentService = Depends(), _=Depends(authenticate_admin), -) -> Student: +) -> StudentDto: return await student_service.create_student(payload.data, payload.account_id) @@ -135,7 +133,7 @@ async def update_student( data: StudentDataWithNames, student_service: StudentService = Depends(), _=Depends(authenticate_admin), -) -> Student: +) -> StudentDto: return await student_service.update_student(student_id, data) @@ -144,7 +142,7 @@ async def delete_student( student_id: int, student_service: StudentService = Depends(), _=Depends(authenticate_admin), -) -> Student: +) -> StudentDto: return await student_service.delete_student(student_id) @@ -154,7 +152,7 @@ async def update_is_registered( data: IsRegisteredUpdate, student_service: StudentService = Depends(), _=Depends(authenticate_staff_or_admin), -) -> Student: +) -> StudentDto: """ Update the registration status (attendance) for a student. Staff can use this to mark students as present/absent. diff --git a/backend/src/modules/student/student_service.py b/backend/src/modules/student/student_service.py index 92d006c..66abf2c 100644 --- a/backend/src/modules/student/student_service.py +++ b/backend/src/modules/student/student_service.py @@ -6,15 +6,11 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from src.core.database import get_session -from src.core.exceptions import ( - BadRequestException, - ConflictException, - NotFoundException, -) +from src.core.exceptions import BadRequestException, ConflictException, NotFoundException from src.modules.account.account_entity import AccountEntity, AccountRole from .student_entity import StudentEntity -from .student_model import Student, StudentData, StudentDataWithNames +from .student_model import StudentData, StudentDataWithNames, StudentDto class StudentNotFoundException(NotFoundException): @@ -64,9 +60,7 @@ async def _get_student_entity_by_account_id(self, account_id: int) -> StudentEnt raise StudentNotFoundException(account_id) return student_entity - async def _get_student_entity_by_phone( - self, phone_number: str - ) -> StudentEntity | None: + async def _get_student_entity_by_phone(self, phone_number: str) -> StudentEntity | None: result = await self.session.execute( select(StudentEntity).where(StudentEntity.phone_number == phone_number) ) @@ -96,14 +90,8 @@ async def _validate_account_for_student(self, account_id: int) -> AccountEntity: return account - async def get_students( - self, skip: int = 0, limit: int | None = None - ) -> list[Student]: - query = ( - select(StudentEntity) - .options(selectinload(StudentEntity.account)) - .offset(skip) - ) + async def get_students(self, skip: int = 0, limit: int | None = None) -> list[StudentDto]: + query = select(StudentEntity).options(selectinload(StudentEntity.account)).offset(skip) if limit is not None: query = query.limit(limit) result = await self.session.execute(query) @@ -115,7 +103,7 @@ async def get_student_count(self) -> int: count_result = await self.session.execute(count_query) return count_result.scalar_one() - async def get_student_by_id(self, account_id: int) -> Student: + async def get_student_by_id(self, account_id: int) -> StudentDto: student_entity = await self._get_student_entity_by_account_id(account_id) return student_entity.to_dto() @@ -123,9 +111,7 @@ async def assert_student_exists(self, account_id: int) -> None: """Assert that a student with the given account ID exists.""" await self._get_student_entity_by_account_id(account_id) - async def create_student( - self, data: StudentDataWithNames, account_id: int - ) -> Student: + async def create_student(self, data: StudentDataWithNames, account_id: int) -> StudentDto: account = await self._validate_account_for_student(account_id) if await self._get_student_entity_by_phone(data.phone_number): @@ -140,7 +126,7 @@ async def create_student( last_registered=data.last_registered, phone_number=data.phone_number, ) - new_student = StudentEntity.from_model(student_data, account_id) + new_student = StudentEntity.from_data(student_data, account_id) try: self.session.add(new_student) await self.session.commit() @@ -153,7 +139,7 @@ async def create_student( async def update_student( self, account_id: int, data: StudentData | StudentDataWithNames - ) -> Student: + ) -> StudentDto: student_entity = await self._get_student_entity_by_account_id(account_id) account = student_entity.account @@ -187,14 +173,14 @@ async def update_student( await self.session.refresh(student_entity, ["account"]) return student_entity.to_dto() - async def delete_student(self, account_id: int) -> Student: + async def delete_student(self, account_id: int) -> StudentDto: student_entity = await self._get_student_entity_by_account_id(account_id) student_dto = student_entity.to_dto() await self.session.delete(student_entity) await self.session.commit() return student_dto - async def update_is_registered(self, account_id: int, is_registered: bool) -> Student: + async def update_is_registered(self, account_id: int, is_registered: bool) -> StudentDto: """ Update the registration status of a student. If is_registered is True, sets last_registered to current datetime. diff --git a/backend/test/modules/account/account_router_test.py b/backend/test/modules/account/account_router_test.py index 0deeaad..5d43f0f 100644 --- a/backend/test/modules/account/account_router_test.py +++ b/backend/test/modules/account/account_router_test.py @@ -2,10 +2,10 @@ import pytest_asyncio from httpx import AsyncClient from src.modules.account.account_entity import AccountEntity, AccountRole -from src.modules.account.account_model import Account +from src.modules.account.account_model import AccountDto from src.modules.account.account_service import AccountConflictException, AccountNotFoundException from src.modules.police.police_entity import PoliceEntity -from src.modules.police.police_model import PoliceAccount +from src.modules.police.police_model import PoliceAccountDto from src.modules.police.police_service import PoliceNotFoundException from test.modules.account.account_utils import AccountTestUtils from test.modules.police.police_utils import PoliceTestUtils @@ -58,7 +58,7 @@ async def test_get_all_accounts( accounts_two_per_role: list[AccountEntity], ): response = await self.admin_client.get("/api/accounts") - data = assert_res_success(response, list[Account]) + data = assert_res_success(response, list[AccountDto]) data_by_id = {account.id: account for account in data} @@ -83,7 +83,7 @@ async def test_get_accounts_by_role( query_string = "&".join(f"role={role.value}" for role in roles) response = await self.admin_client.get(f"/api/accounts?{query_string}") - data = assert_res_success(response, list[Account]) + data = assert_res_success(response, list[AccountDto]) filtered_fixture = [a for a in accounts_two_per_role if a.role in roles] assert len(data) == len(filtered_fixture) @@ -100,7 +100,7 @@ async def test_create_account(self, role: AccountRole): response = await self.admin_client.post( "/api/accounts", json=new_account.model_dump(mode="json") ) - data = assert_res_success(response, Account) + data = assert_res_success(response, AccountDto) self.account_utils.assert_matches(new_account, data) @pytest.mark.asyncio @@ -137,7 +137,7 @@ async def test_update_account(self, accounts_two_per_role: list[AccountEntity]): f"/api/accounts/{account_to_update.id}", json=updated_data.model_dump(mode="json"), ) - data = assert_res_success(response, Account) + data = assert_res_success(response, AccountDto) self.account_utils.assert_matches(updated_data, data) assert data.id == account_to_update.id @@ -145,13 +145,13 @@ async def test_update_account(self, accounts_two_per_role: list[AccountEntity]): async def test_update_account_change_email(self, accounts_two_per_role: list[AccountEntity]): """Test changing an account's email successfully.""" account_to_update = accounts_two_per_role[0] - updated_data = account_to_update.to_model() + updated_data = account_to_update.to_dto() updated_data.email = "newemail@example.com" response = await self.admin_client.put( f"/api/accounts/{account_to_update.id}", json=updated_data.model_dump(mode="json", exclude={"id"}), ) - response_data = assert_res_success(response, Account) + response_data = assert_res_success(response, AccountDto) self.account_utils.assert_matches(updated_data, response_data) @pytest.mark.asyncio @@ -197,7 +197,7 @@ async def test_delete_account(self, accounts_two_per_role: list[AccountEntity]): account_to_delete = accounts_two_per_role[0] response = await self.admin_client.delete(f"/api/accounts/{account_to_delete.id}") - data = assert_res_success(response, Account) + data = assert_res_success(response, AccountDto) self.account_utils.assert_matches(account_to_delete, data) assert data.id == account_to_delete.id @@ -216,7 +216,7 @@ async def test_get_police_credentials( ): """Test getting police credentials""" response = await self.admin_client.get("/api/accounts/police") - data = assert_res_success(response, PoliceAccount) + data = assert_res_success(response, PoliceAccountDto) police_utils.assert_matches(sample_police, data) @pytest.mark.asyncio @@ -234,7 +234,7 @@ async def test_update_police_credentials( response = await self.admin_client.put( "/api/accounts/police", json=updated_data.model_dump(mode="json") ) - data = assert_res_success(response, PoliceAccount) + data = assert_res_success(response, PoliceAccountDto) police_utils.assert_matches(updated_data, data) @pytest.mark.asyncio @@ -258,7 +258,7 @@ async def test_update_police_password_is_hashed( "/api/accounts/police", json=updated_data.model_dump(mode="json") ) - assert_res_success(response, PoliceAccount) + assert_res_success(response, PoliceAccountDto) updated_police = await police_utils.get_police() police_utils.assert_matches(updated_data, updated_police) diff --git a/backend/test/modules/account/account_utils.py b/backend/test/modules/account/account_utils.py index 8e59edf..f213a3b 100644 --- a/backend/test/modules/account/account_utils.py +++ b/backend/test/modules/account/account_utils.py @@ -2,7 +2,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from src.modules.account.account_entity import AccountEntity, AccountRole -from src.modules.account.account_model import Account, AccountData +from src.modules.account.account_model import AccountData, AccountDto from test.utils.resource_test_utils import ResourceTestUtils @@ -18,7 +18,7 @@ class AccountTestUtils( ResourceTestUtils[ AccountEntity, AccountData, - Account, + AccountDto, ] ): def __init__(self, session: AsyncSession): diff --git a/backend/test/modules/complaint/complaint_router_test.py b/backend/test/modules/complaint/complaint_router_test.py index a33782a..74a1741 100644 --- a/backend/test/modules/complaint/complaint_router_test.py +++ b/backend/test/modules/complaint/complaint_router_test.py @@ -2,7 +2,7 @@ import pytest from httpx import AsyncClient -from src.modules.complaint.complaint_model import Complaint +from src.modules.complaint.complaint_model import ComplaintDto from src.modules.complaint.complaint_service import ComplaintNotFoundException from test.modules.complaint.complaint_utils import ComplaintTestUtils from test.modules.location.location_utils import LocationTestUtils @@ -56,7 +56,7 @@ async def test_get_complaints_by_location_success(self) -> None: complaints = await self.complaint_utils.create_many(i=2, location_id=location.id) response = await self.admin_client.get(f"/api/locations/{location.id}/complaints") - data = assert_res_success(response, list[Complaint]) + data = assert_res_success(response, list[ComplaintDto]) assert len(data) == 2 data_by_id = {complaint.id: complaint for complaint in data} @@ -70,7 +70,7 @@ async def test_get_complaints_by_location_empty(self) -> None: location = await self.location_utils.create_one() response = await self.admin_client.get(f"/api/locations/{location.id}/complaints") - data = assert_res_success(response, list[Complaint]) + data = assert_res_success(response, list[ComplaintDto]) assert data == [] @@ -84,7 +84,7 @@ async def test_create_complaint_success(self) -> None: f"/api/locations/{location.id}/complaints", json=complaint_data.model_dump(mode="json"), ) - data = assert_res_success(response, Complaint, status=201) + data = assert_res_success(response, ComplaintDto, status=201) self.complaint_utils.assert_matches(complaint_data, data) @@ -100,7 +100,7 @@ async def test_create_complaint_with_empty_description(self) -> None: f"/api/locations/{location.id}/complaints", json=complaint_data.model_dump(mode="json"), ) - data = assert_res_success(response, Complaint, status=201) + data = assert_res_success(response, ComplaintDto, status=201) assert data.description == "" @@ -133,7 +133,7 @@ async def test_update_complaint_success(self) -> None: f"/api/locations/{complaint.location_id}/complaints/{complaint.id}", json=update_data.model_dump(mode="json"), ) - data = assert_res_success(response, Complaint) + data = assert_res_success(response, ComplaintDto) assert data.id == complaint.id self.complaint_utils.assert_matches(update_data, data) @@ -175,7 +175,7 @@ async def test_delete_complaint_success(self) -> None: response = await self.admin_client.delete( f"/api/locations/{complaint.location_id}/complaints/{complaint.id}" ) - data = assert_res_success(response, Complaint) + data = assert_res_success(response, ComplaintDto) self.complaint_utils.assert_matches(complaint, data) diff --git a/backend/test/modules/complaint/complaint_utils.py b/backend/test/modules/complaint/complaint_utils.py index 321ff42..db33993 100644 --- a/backend/test/modules/complaint/complaint_utils.py +++ b/backend/test/modules/complaint/complaint_utils.py @@ -3,7 +3,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from src.modules.complaint.complaint_entity import ComplaintEntity -from src.modules.complaint.complaint_model import Complaint, ComplaintData +from src.modules.complaint.complaint_model import ComplaintData, ComplaintDto from test.modules.location.location_utils import LocationTestUtils from test.utils.resource_test_utils import ResourceTestUtils @@ -18,7 +18,7 @@ class ComplaintTestUtils( ResourceTestUtils[ ComplaintEntity, ComplaintData, - Complaint, + ComplaintDto, ] ): def __init__(self, session: AsyncSession, location_utils: LocationTestUtils): diff --git a/backend/test/modules/location/location_router_test.py b/backend/test/modules/location/location_router_test.py index 28dd1b7..911c9f0 100644 --- a/backend/test/modules/location/location_router_test.py +++ b/backend/test/modules/location/location_router_test.py @@ -4,7 +4,7 @@ from httpx import AsyncClient from src.core.exceptions import InternalServerException from src.modules.location.location_entity import LocationEntity -from src.modules.location.location_model import AutocompleteResult, Location +from src.modules.location.location_model import AutocompleteResult, LocationDto from src.modules.location.location_service import ( LocationConflictException, LocationNotFoundException, @@ -58,7 +58,7 @@ async def test_list_locations_empty(self): response = await self.staff_client.get("/api/locations/") paginated = assert_res_paginated( - response, Location, total_records=0, page_size=0, total_pages=1 + response, LocationDto, total_records=0, page_size=0, total_pages=1 ) assert paginated.items == [] @@ -68,7 +68,7 @@ async def test_list_locations_with_data(self, sample_locations: list[LocationEnt response = await self.staff_client.get("/api/locations/") paginated = assert_res_paginated( - response, Location, total_records=3, page_size=3, total_pages=1 + response, LocationDto, total_records=3, page_size=3, total_pages=1 ) assert len(paginated.items) == 3 @@ -105,7 +105,7 @@ async def test_get_location_by_id_success(self): location = await self.location_utils.create_one() response = await self.staff_client.get(f"/api/locations/{location.id}") - data = assert_res_success(response, Location) + data = assert_res_success(response, LocationDto) self.location_utils.assert_matches(location, data) @@ -128,7 +128,7 @@ async def test_create_location_success(self): ) response = await self.admin_client.post("/api/locations/", json=request_data) - data = assert_res_success(response, Location, status=201) + data = assert_res_success(response, LocationDto, status=201) self.location_utils.assert_matches(location_data, data) @@ -145,7 +145,7 @@ async def test_create_location_with_warnings_and_citations(self): ) response = await self.admin_client.post("/api/locations/", json=request_data) - data = assert_res_success(response, Location, status=201) + data = assert_res_success(response, LocationDto, status=201) self.location_utils.assert_matches(location_data, data) @@ -183,7 +183,7 @@ async def test_update_location_success(self): ) response = await self.admin_client.put(f"/api/locations/{location.id}", json=request_data) - data = assert_res_success(response, Location) + data = assert_res_success(response, LocationDto) assert data.id == location.id # When place_id unchanged, address data stays the same, only counts/expiration update @@ -231,7 +231,7 @@ async def test_delete_location_success(self): location = await self.location_utils.create_one() response = await self.admin_client.delete(f"/api/locations/{location.id}") - data = assert_res_success(response, Location) + data = assert_res_success(response, LocationDto) self.location_utils.assert_matches(location, data) diff --git a/backend/test/modules/location/location_utils.py b/backend/test/modules/location/location_utils.py index 87e3fa2..f50ef48 100644 --- a/backend/test/modules/location/location_utils.py +++ b/backend/test/modules/location/location_utils.py @@ -9,8 +9,8 @@ from src.modules.location.location_model import ( AddressData, AutocompleteResult, - Location, LocationData, + LocationDto, ) from test.utils.resource_test_utils import ResourceTestUtils @@ -37,7 +37,7 @@ class LocationTestUtils( ResourceTestUtils[ LocationEntity, LocationData, - Location | AddressData | AutocompleteResult, + LocationDto | AddressData | AutocompleteResult, ] ): def __init__(self, session: AsyncSession): @@ -129,13 +129,13 @@ def assert_matches( self, resource1: LocationEntity | LocationData - | Location + | LocationDto | AddressData | AutocompleteResult | None, resource2: LocationEntity | LocationData - | Location + | LocationDto | AddressData | AutocompleteResult | None, diff --git a/backend/test/modules/party/party_router_test.py b/backend/test/modules/party/party_router_test.py index 95498da..abc58b6 100644 --- a/backend/test/modules/party/party_router_test.py +++ b/backend/test/modules/party/party_router_test.py @@ -5,7 +5,7 @@ from httpx import AsyncClient from src.modules.account.account_entity import AccountRole from src.modules.location.location_service import LocationHoldActiveException -from src.modules.party.party_model import Party +from src.modules.party.party_model import PartyDto from src.modules.party.party_service import PartyNotFoundException from src.modules.student.student_entity import StudentEntity from test.modules.account.account_utils import AccountTestUtils @@ -52,7 +52,7 @@ async def test_list_parties_empty(self): """Test listing parties when database is empty.""" response = await self.admin_client.get("/api/parties/") paginated = assert_res_paginated( - response, Party, total_records=0, page_size=0, total_pages=1 + response, PartyDto, total_records=0, page_size=0, total_pages=1 ) assert paginated.items == [] @@ -63,7 +63,7 @@ async def test_list_parties_with_data(self): response = await self.admin_client.get("/api/parties/") paginated = assert_res_paginated( - response, Party, total_records=3, page_size=3, total_pages=1 + response, PartyDto, total_records=3, page_size=3, total_pages=1 ) assert len(paginated.items) == 3 @@ -90,7 +90,7 @@ async def test_get_party_success(self): party = await self.party_utils.create_one() response = await self.admin_client.get(f"/api/parties/{party.id}") - data = assert_res_success(response, Party) + data = assert_res_success(response, PartyDto) self.party_utils.assert_matches(party, data) @@ -118,7 +118,7 @@ async def test_delete_party_success(self): party = await self.party_utils.create_one() response = await self.admin_client.delete(f"/api/parties/{party.id}") - data = assert_res_success(response, Party) + data = assert_res_success(response, PartyDto) self.party_utils.assert_matches(party, data) @@ -165,7 +165,7 @@ async def test_create_party_as_admin_success(self): response = await self.admin_client.post( "/api/parties/", json=payload.model_dump(mode="json") ) - data = assert_res_success(response, Party, status=201) + data = assert_res_success(response, PartyDto, status=201) assert data.contact_one.email == payload.contact_one_email assert data.contact_two.email == payload.contact_two.email @@ -252,7 +252,7 @@ async def test_create_party_as_student_success(self, current_student: StudentEnt response = await self.student_client.post( "/api/parties/", json=payload.model_dump(mode="json") ) - data = assert_res_success(response, Party, status=201) + data = assert_res_success(response, PartyDto, status=201) assert data.contact_one.id == current_student.account_id assert data.contact_two.email == payload.contact_two.email @@ -293,7 +293,7 @@ async def test_get_parties_nearby_empty(self): "end_date": (now + timedelta(days=7)).strftime("%Y-%m-%d"), } response = await self.admin_client.get("/api/parties/nearby", params=params) - data = assert_res_success(response, list[Party]) + data = assert_res_success(response, list[PartyDto]) assert data == [] @pytest.mark.asyncio @@ -339,7 +339,7 @@ async def test_get_parties_nearby_within_radius(self): "end_date": (now + timedelta(days=1)).strftime("%Y-%m-%d"), } response = await self.admin_client.get("/api/parties/nearby", params=params) - data = assert_res_success(response, list[Party]) + data = assert_res_success(response, list[PartyDto]) party_ids = [p.id for p in data] assert party_within.id in party_ids @@ -384,7 +384,7 @@ async def test_get_parties_nearby_with_date_range(self): } response = await self.admin_client.get("/api/parties/nearby", params=params) - data = assert_res_success(response, list[Party]) + data = assert_res_success(response, list[PartyDto]) assert len(data) == 1 assert data[0].id == party_valid.id @@ -396,12 +396,36 @@ async def test_get_parties_nearby_with_date_range(self): {"start_date": "2024-01-01", "end_date": "2024-01-02"}, # missing place_id {"place_id": "ChIJtest123", "end_date": "2024-01-02"}, # missing start_date {"place_id": "ChIJtest123", "start_date": "2024-01-01"}, # missing end_date - {"place_id": "ChIJtest123", "start_date": "01-01-2024", "end_date": "2024-01-02"}, # MM-DD-YYYY - {"place_id": "ChIJtest123", "start_date": "2024-01-01", "end_date": "01/02/2024"}, # MM/DD/YYYY - {"place_id": "ChIJtest123", "start_date": "2024/01/01", "end_date": "2024-01-02"}, # slashes - {"place_id": "ChIJtest123", "start_date": "2024-1-1", "end_date": "2024-01-02"}, # no leading zeros - {"place_id": "ChIJtest123", "start_date": "2024-01-01", "end_date": "24-01-02"}, # 2-digit year - {"place_id": "ChIJtest123", "start_date": "not-a-date", "end_date": "2024-01-02"}, # invalid string + { + "place_id": "ChIJtest123", + "start_date": "01-01-2024", + "end_date": "2024-01-02", + }, # MM-DD-YYYY + { + "place_id": "ChIJtest123", + "start_date": "2024-01-01", + "end_date": "01/02/2024", + }, # MM/DD/YYYY + { + "place_id": "ChIJtest123", + "start_date": "2024/01/01", + "end_date": "2024-01-02", + }, # slashes + { + "place_id": "ChIJtest123", + "start_date": "2024-1-1", + "end_date": "2024-01-02", + }, # no leading zeros + { + "place_id": "ChIJtest123", + "start_date": "2024-01-01", + "end_date": "24-01-02", + }, # 2-digit year + { + "place_id": "ChIJtest123", + "start_date": "not-a-date", + "end_date": "2024-01-02", + }, # invalid string ], ) async def test_get_parties_nearby_validation_errors(self, params: dict[str, str]): diff --git a/backend/test/modules/party/party_utils.py b/backend/test/modules/party/party_utils.py index 7ad2686..be7a70c 100644 --- a/backend/test/modules/party/party_utils.py +++ b/backend/test/modules/party/party_utils.py @@ -4,11 +4,11 @@ from sqlalchemy.ext.asyncio import AsyncSession from src.modules.party.party_entity import PartyEntity from src.modules.party.party_model import ( - AdminCreatePartyDTO, - Contact, - Party, + AdminCreatePartyDto, + ContactDto, PartyData, - StudentCreatePartyDTO, + PartyDto, + StudentCreatePartyDto, ) from src.modules.student.student_model import ContactPreference from test.modules.location.location_utils import LocationTestUtils @@ -28,7 +28,7 @@ class PartyOverrides(TypedDict, total=False): contact_one_id: int google_place_id: str contact_one_email: str - contact_two: Contact + contact_two: ContactDto contact_two_email: str contact_two_first_name: str contact_two_last_name: str @@ -40,7 +40,7 @@ class PartyTestUtils( ResourceTestUtils[ PartyEntity, PartyData, - Party, + PartyDto, ] ): def __init__( @@ -83,10 +83,10 @@ async def next_dict(self, **overrides: Unpack[PartyOverrides]) -> dict: return await super().next_dict(**overrides) - def next_contact(self, **overrides: Unpack[PartyOverrides]) -> Contact: + def next_contact(self, **overrides: Unpack[PartyOverrides]) -> ContactDto: """Generate test contact data.""" defaults = self.generate_defaults(self.count) - return Contact( + return ContactDto( email=overrides.get("contact_two_email", defaults["contact_two_email"]), first_name=overrides.get("contact_two_first_name", defaults["contact_two_first_name"]), last_name=overrides.get("contact_two_last_name", defaults["contact_two_last_name"]), @@ -111,7 +111,7 @@ async def next_data(self, **overrides: Unpack[PartyOverrides]) -> PartyData: async def next_admin_create_dto( self, **overrides: Unpack[PartyOverrides] - ) -> AdminCreatePartyDTO: + ) -> AdminCreatePartyDto: """Generate an AdminCreatePartyDTO for testing.""" if "google_place_id" not in overrides: location = await self.location_utils.create_one() @@ -122,7 +122,7 @@ async def next_admin_create_dto( student_dto = await student.load_dto(self.student_utils.session) overrides["contact_one_email"] = student_dto.email - return AdminCreatePartyDTO( + return AdminCreatePartyDto( type="admin", party_datetime=overrides.get("party_datetime", get_valid_party_datetime()), google_place_id=overrides["google_place_id"], @@ -132,13 +132,13 @@ async def next_admin_create_dto( async def next_student_create_dto( self, **overrides: Unpack[PartyOverrides] - ) -> StudentCreatePartyDTO: + ) -> StudentCreatePartyDto: """Generate a StudentCreatePartyDTO for testing.""" if "google_place_id" not in overrides: location = await self.location_utils.create_one() overrides["google_place_id"] = location.google_place_id - return StudentCreatePartyDTO( + return StudentCreatePartyDto( type="student", party_datetime=overrides.get("party_datetime", get_valid_party_datetime()), google_place_id=overrides["google_place_id"], @@ -148,8 +148,8 @@ async def next_student_create_dto( @override def assert_matches( self, - resource1: PartyEntity | PartyData | Party | None, - resource2: PartyEntity | PartyData | Party | None, + resource1: PartyEntity | PartyData | PartyDto | None, + resource2: PartyEntity | PartyData | PartyDto | None, ) -> None: """Assert that two party resources match, with special handling for nested objects.""" assert resource1 is not None, "First party is None" @@ -168,7 +168,7 @@ def assert_matches( # Compare location location_id_1, location_id_2 = [ - party.location.id if isinstance(party, Party) else party.location_id + party.location.id if isinstance(party, PartyDto) else party.location_id for party in (resource1, resource2) ] @@ -176,12 +176,12 @@ def assert_matches( f"Location ID mismatch: {location_id_1} != {location_id_2}" ) - if isinstance(resource1, Party) and isinstance(resource2, Party): + if isinstance(resource1, PartyDto) and isinstance(resource2, PartyDto): self.location_utils.assert_matches(resource1.location, resource2.location) # Compare contact_one contact_one_id_1, contact_one_id_2 = [ - party.contact_one.id if isinstance(party, Party) else party.contact_one_id + party.contact_one.id if isinstance(party, PartyDto) else party.contact_one_id for party in (resource1, resource2) ] @@ -189,7 +189,7 @@ def assert_matches( f"Contact one ID mismatch: {contact_one_id_1} != {contact_one_id_2}" ) - if isinstance(resource1, Party) and isinstance(resource2, Party): + if isinstance(resource1, PartyDto) and isinstance(resource2, PartyDto): self.student_utils.assert_matches(resource1.contact_one, resource2.contact_one) # Compare contact_two fields @@ -197,7 +197,7 @@ def assert_matches( ( party.contact_two if not isinstance(party, PartyEntity) - else Contact( + else ContactDto( email=party.contact_two_email, first_name=party.contact_two_first_name, last_name=party.contact_two_last_name, diff --git a/backend/test/modules/police/police_router_test.py b/backend/test/modules/police/police_router_test.py index 5e27a85..a7ba67f 100644 --- a/backend/test/modules/police/police_router_test.py +++ b/backend/test/modules/police/police_router_test.py @@ -2,7 +2,7 @@ import pytest from httpx import AsyncClient -from src.modules.location.location_model import MAX_COUNT, Location +from src.modules.location.location_model import MAX_COUNT, LocationDto from src.modules.location.location_service import ( CountLimitExceededException, LocationNotFoundException, @@ -44,7 +44,7 @@ async def test_increment_warnings_success(self): location = await self.location_utils.create_one(warning_count=0) response = await self.police_client.post(f"/api/police/locations/{location.id}/warnings") - data = assert_res_success(response, Location) + data = assert_res_success(response, LocationDto) assert data.id == location.id assert data.warning_count == 1 @@ -73,12 +73,12 @@ async def test_increment_warnings_multiple_times(self): # First increment response = await self.police_client.post(f"/api/police/locations/{location.id}/warnings") - data = assert_res_success(response, Location) + data = assert_res_success(response, LocationDto) assert data.warning_count == 1 # Second increment response = await self.police_client.post(f"/api/police/locations/{location.id}/warnings") - data = assert_res_success(response, Location) + data = assert_res_success(response, LocationDto) assert data.warning_count == 2 @pytest.mark.asyncio @@ -87,7 +87,7 @@ async def test_increment_citations_success(self): location = await self.location_utils.create_one(citation_count=0) response = await self.police_client.post(f"/api/police/locations/{location.id}/citations") - data = assert_res_success(response, Location) + data = assert_res_success(response, LocationDto) assert data.id == location.id assert data.citation_count == 1 @@ -116,10 +116,10 @@ async def test_increment_citations_multiple_times(self): # First increment response = await self.police_client.post(f"/api/police/locations/{location.id}/citations") - data = assert_res_success(response, Location) + data = assert_res_success(response, LocationDto) assert data.citation_count == 1 # Second increment response = await self.police_client.post(f"/api/police/locations/{location.id}/citations") - data = assert_res_success(response, Location) + data = assert_res_success(response, LocationDto) assert data.citation_count == 2 diff --git a/backend/test/modules/police/police_utils.py b/backend/test/modules/police/police_utils.py index d3037d4..e9100e8 100644 --- a/backend/test/modules/police/police_utils.py +++ b/backend/test/modules/police/police_utils.py @@ -3,7 +3,7 @@ import bcrypt from sqlalchemy.ext.asyncio import AsyncSession from src.modules.police.police_entity import PoliceEntity -from src.modules.police.police_model import PoliceAccount, PoliceAccountUpdate +from src.modules.police.police_model import PoliceAccountDto, PoliceAccountUpdate from test.utils.resource_test_utils import ResourceTestUtils @@ -21,7 +21,7 @@ class PoliceTestUtils( ResourceTestUtils[ PoliceEntity, PoliceAccountUpdate, - PoliceAccount, + PoliceAccountDto, ] ): TEST_PASSWORD = "testpassword123" @@ -67,8 +67,8 @@ async def get_police(self) -> PoliceEntity: @override def assert_matches( self, - resource1: PoliceEntity | PoliceAccountUpdate | PoliceAccount | None, - resource2: PoliceEntity | PoliceAccountUpdate | PoliceAccount | None, + resource1: PoliceEntity | PoliceAccountUpdate | PoliceAccountDto | None, + resource2: PoliceEntity | PoliceAccountUpdate | PoliceAccountDto | None, ) -> None: """Assert that two police resources match.""" assert resource1 is not None, "First resource is None" diff --git a/backend/test/modules/student/student_router_test.py b/backend/test/modules/student/student_router_test.py index db9d5e0..64b4eb3 100644 --- a/backend/test/modules/student/student_router_test.py +++ b/backend/test/modules/student/student_router_test.py @@ -4,9 +4,9 @@ import pytest_asyncio from httpx import AsyncClient from src.modules.account.account_entity import AccountRole -from src.modules.party.party_model import Party +from src.modules.party.party_model import PartyDto from src.modules.student.student_entity import StudentEntity -from src.modules.student.student_model import ContactPreference, Student, StudentData +from src.modules.student.student_model import ContactPreference, StudentData, StudentDto from src.modules.student.student_service import ( AccountNotFoundException, InvalidAccountRoleException, @@ -55,7 +55,7 @@ async def test_list_students_empty(self): response = await self.admin_client.get("/api/students/") paginated = assert_res_paginated( response, - Student, + StudentDto, total_records=0, page_number=1, page_size=0, @@ -70,7 +70,7 @@ async def test_list_students_with_data(self): response = await self.admin_client.get("/api/students/") paginated = assert_res_paginated( response, - Student, + StudentDto, total_records=3, page_number=1, page_size=3, @@ -119,7 +119,7 @@ async def test_list_students_pagination( paginated = assert_res_paginated( response, - Student, + StudentDto, total_records=total_students, page_number=expected_page_number, page_size=expected_page_size, @@ -164,7 +164,7 @@ async def test_create_student_success(self): response = await self.admin_client.post( "/api/students/", json=payload.model_dump(mode="json") ) - data = assert_res_success(response, Student, status=201) + data = assert_res_success(response, StudentDto, status=201) self.student_utils.assert_matches(payload.data, data) assert data.id == payload.account_id @@ -178,7 +178,7 @@ async def test_create_student_with_datetime(self): response = await self.admin_client.post( "/api/students/", json=payload.model_dump(mode="json") ) - data = assert_res_success(response, Student, status=201) + data = assert_res_success(response, StudentDto, status=201) self.student_utils.assert_matches(payload.data, data) assert data.last_registered is not None @@ -232,7 +232,7 @@ async def test_get_student_success(self): student = await self.student_utils.create_one() response = await self.admin_client.get(f"/api/students/{student.account_id}") - data = assert_res_success(response, Student) + data = assert_res_success(response, StudentDto) self.student_utils.assert_matches(student, data) @@ -252,7 +252,7 @@ async def test_update_student_success(self): f"/api/students/{student.account_id}", json=updated_data.model_dump(mode="json"), ) - data = assert_res_success(response, Student) + data = assert_res_success(response, StudentDto) self.student_utils.assert_matches(updated_data, data) assert data.id == student.account_id @@ -288,7 +288,7 @@ async def test_delete_student_success(self): student = await self.student_utils.create_one() response = await self.admin_client.delete(f"/api/students/{student.account_id}") - data = assert_res_success(response, Student) + data = assert_res_success(response, StudentDto) self.student_utils.assert_matches(student, data) @@ -331,7 +331,7 @@ async def test_update_is_registered_mark_as_registered_as_staff(self): f"/api/students/{student.account_id}/is-registered", json=payload, ) - data = assert_res_success(response, Student) + data = assert_res_success(response, StudentDto) assert data.id == student.account_id assert data.last_registered is not None @@ -346,7 +346,7 @@ async def test_update_is_registered_mark_as_not_registered_as_admin(self): f"/api/students/{student.account_id}/is-registered", json=payload, ) - data = assert_res_success(response, Student) + data = assert_res_success(response, StudentDto) assert data.id == student.account_id assert data.last_registered is None @@ -368,7 +368,7 @@ async def test_update_is_registered_toggle(self): f"/api/students/{student.account_id}/is-registered", json={"is_registered": True}, ) - data = assert_res_success(response, Student) + data = assert_res_success(response, StudentDto) assert data.last_registered is not None first_registered_time = data.last_registered @@ -377,7 +377,7 @@ async def test_update_is_registered_toggle(self): f"/api/students/{student.account_id}/is-registered", json={"is_registered": False}, ) - data = assert_res_success(response, Student) + data = assert_res_success(response, StudentDto) assert data.last_registered is None # Mark as registered again @@ -385,7 +385,7 @@ async def test_update_is_registered_toggle(self): f"/api/students/{student.account_id}/is-registered", json={"is_registered": True}, ) - data = assert_res_success(response, Student) + data = assert_res_success(response, StudentDto) assert data.last_registered is not None assert data.last_registered != first_registered_time @@ -435,7 +435,7 @@ def _setup( async def test_get_me_success(self, current_student: StudentEntity): """Test getting current student's own information.""" response = await self.student_client.get("/api/students/me") - data = assert_res_success(response, Student) + data = assert_res_success(response, StudentDto) self.student_utils.assert_matches(current_student, data) @@ -460,7 +460,7 @@ async def test_update_me_success(self, current_student: StudentEntity): "/api/students/me", json=updated_data.model_dump(mode="json"), ) - data = assert_res_success(response, Student) + data = assert_res_success(response, StudentDto) self.student_utils.assert_matches(updated_data, data) # Names should not change via /me endpoint (only StudentData, not StudentDataWithNames) @@ -471,7 +471,7 @@ async def test_update_me_phone_conflict(self, current_student: StudentEntity): """Test updating me with phone number that already exists.""" other_student = await self.student_utils.create_one() updated_data = StudentData( - contact_preference=ContactPreference.text, + contact_preference=ContactPreference.TEXT, phone_number=other_student.phone_number, ) @@ -509,7 +509,7 @@ async def test_get_me_parties_with_data(self, current_student: StudentEntity): response = await self.student_client.get("/api/students/me/parties") - parties = assert_res_success(response, list[Party]) + parties = assert_res_success(response, list[PartyDto]) assert len(parties) == 1 assert party2.id not in [p.id for p in parties] self.party_utils.assert_matches(parties[0], party1) diff --git a/backend/test/modules/student/student_service_test.py b/backend/test/modules/student/student_service_test.py index 8ba1039..555559d 100644 --- a/backend/test/modules/student/student_service_test.py +++ b/backend/test/modules/student/student_service_test.py @@ -3,7 +3,7 @@ import pytest from sqlalchemy.ext.asyncio import AsyncSession from src.modules.account.account_entity import AccountRole -from src.modules.student.student_model import ContactPreference, Student +from src.modules.student.student_model import ContactPreference, StudentDto from src.modules.student.student_service import ( AccountNotFoundException, InvalidAccountRoleException, @@ -44,7 +44,7 @@ async def test_create_student(self) -> None: student = await self.student_service.create_student(data, account_id=account.id) - assert isinstance(student, Student) + assert isinstance(student, StudentDto) assert student.id == account.id self.student_utils.assert_matches(student, data) @@ -90,7 +90,7 @@ async def test_update_student(self): update_data = await self.student_utils.next_data_with_names( first_name="Jane", last_name="Doe", - contact_preference=ContactPreference.call, + contact_preference=ContactPreference.CALL, ) updated = await self.student_service.update_student(student_entity.account_id, update_data) diff --git a/backend/test/modules/student/student_utils.py b/backend/test/modules/student/student_utils.py index 3d63af1..8181d94 100644 --- a/backend/test/modules/student/student_utils.py +++ b/backend/test/modules/student/student_utils.py @@ -7,10 +7,10 @@ from src.modules.student.student_model import ( ContactPreference, DbStudent, - Student, StudentCreate, StudentData, StudentDataWithNames, + StudentDto, ) from test.modules.account.account_utils import AccountTestUtils from test.utils.resource_test_utils import ResourceTestUtils @@ -31,7 +31,7 @@ class StudentTestUtils( ResourceTestUtils[ StudentEntity, StudentData, - Student | StudentDataWithNames | DbStudent, + StudentDto | StudentDataWithNames | DbStudent, ] ): def __init__(self, session: AsyncSession, account_utils: AccountTestUtils): @@ -67,7 +67,9 @@ async def next_dict(self, **overrides: Unpack[StudentOverrides]) -> dict: self.count += 1 return data - async def next_data_with_names(self, **overrides: Unpack[StudentOverrides]) -> StudentDataWithNames: + async def next_data_with_names( + self, **overrides: Unpack[StudentOverrides] + ) -> StudentDataWithNames: student_data = await self.next_dict(**overrides) return StudentDataWithNames( **self.get_or_default(overrides, {"first_name", "last_name"}), @@ -87,7 +89,7 @@ async def next_student_create(self, **overrides: Unpack[StudentOverrides]) -> St @override async def next_entity(self, **overrides: Unpack[StudentOverrides]) -> StudentEntity: student_create = await self.next_student_create(**overrides) - return StudentEntity.from_model(student_create.data, student_create.account_id) + return StudentEntity.from_data(student_create.data, student_create.account_id) # ================================ Typing Overrides ================================ @@ -102,7 +104,9 @@ async def next_data(self, **overrides: Unpack[StudentOverrides]) -> StudentData: return await super().next_data(**overrides) @override - async def create_many(self, *, i: int, **overrides: Unpack[StudentOverrides]) -> list[StudentEntity]: + async def create_many( + self, *, i: int, **overrides: Unpack[StudentOverrides] + ) -> list[StudentEntity]: return await super().create_many(i=i, **overrides) @override diff --git a/backend/test/utils/resource_test_utils.py b/backend/test/utils/resource_test_utils.py index dad328a..5fe8fa9 100644 --- a/backend/test/utils/resource_test_utils.py +++ b/backend/test/utils/resource_test_utils.py @@ -35,8 +35,8 @@ class ResourceTestUtils[ Type Parameters: - ResourceEntity: The SQLAlchemy entity class that implements EntityBase. - - Note: the default implementation expects `from_model` - and `to_model` methods to be present. However, these methods are not required in the type definition to allow for flexibility, + - Note: the default implementation expects `from_data` + and `to_dto` methods to be present. However, these methods are not required in the type definition to allow for flexibility, expecting a subclass to override `next_entity` and/or `entity_to_dict` as needed if these methods are not present. - ResourceData: The Pydantic model representing the resource's data object used to create entities. - OtherModels: Additional Pydantic models that may be used in assertions @@ -167,13 +167,13 @@ async def next_entity(self, **overrides: Any) -> ResourceEntity: **overrides: Fields to override in the generated entity. """ data = await self.next_data(**overrides) - if not hasattr(self._ResourceEntity, "from_model") or not callable( - getattr(self._ResourceEntity, "from_model") + if not hasattr(self._ResourceEntity, "from_data") or not callable( + getattr(self._ResourceEntity, "from_data") ): raise AttributeError( - f"{self._ResourceEntity.__name__} must implement a 'from_model' classmethod" + f"{self._ResourceEntity.__name__} must implement a 'from_data' classmethod" ) - return self._ResourceEntity.from_model(data) # type: ignore + return self._ResourceEntity.from_data(data) # type: ignore async def create_many(self, *, i: int, **overrides: Any) -> list[ResourceEntity]: """Create multiple resource entities in the database, applying any overrides to every entity. @@ -206,13 +206,13 @@ async def get_all(self) -> list[ResourceEntity]: def entity_to_dict(self, entity: ResourceEntity) -> dict: """Convert a resource entity to a dict via its model representation.""" - if not hasattr(self._ResourceEntity, "to_model") or not callable( - getattr(self._ResourceEntity, "to_model") + if not hasattr(self._ResourceEntity, "to_dto") or not callable( + getattr(self._ResourceEntity, "to_dto") ): raise AttributeError( - f"{self._ResourceEntity.__name__} must implement a 'to_model' method" + f"{self._ResourceEntity.__name__} must implement a 'to_dto' method" ) - return entity.to_model().model_dump() # type: ignore + return entity.to_dto().model_dump() # type: ignore def assert_matches( self, diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 9bfea39..9b0dbc8 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -113,7 +113,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -706,7 +705,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -1866,7 +1864,6 @@ "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", "license": "MIT", - "peer": true, "engines": { "node": "^14.21.3 || >=16" }, @@ -3297,7 +3294,6 @@ "integrity": "sha512-pb1Uqj5WJP7wrcbLU7Ru4QtA0+3kAXrkutGiD26wUKzSMgNNaPARTUDQmElUXp64kh3cWdou3Q0C7qwwxqSFmg==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -3308,7 +3304,6 @@ "integrity": "sha512-1LOH8xovvsKsCBq1wnT4ntDUdCJKmnEakhsuoUSy6ExlHCkGP2hqnatagYTgFk6oeL0VU31u7SNjunPN+GchtA==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -3319,7 +3314,6 @@ "integrity": "sha512-brtBs0MnE9SMx7px208g39lRmC5uHZs96caOJfTjFcYSLHNamvaSMfJNagChVNkup2SdtOxKX1FDBkRSJe1ZAg==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -3376,7 +3370,6 @@ "integrity": "sha512-TGf22kon8KW+DeKaUmOibKWktRY8b2NSAZNdtWh798COm1NWx8+xJ6iFBtk3IvLdv6+LGLJLRlyhrhEDZWargQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.45.0", "@typescript-eslint/types": "8.45.0", @@ -3942,7 +3935,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4409,7 +4401,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.25", "caniuse-lite": "^1.0.30001754", @@ -5381,7 +5372,6 @@ "integrity": "sha512-hB4FIzXovouYzwzECDcUkJ4OcfOEkXTv2zRY6B9bkwjx/cprAq0uvm1nl7zvQ0/TsUk0zQiN4uPfJpB9m+rPMQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -5556,7 +5546,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -8717,7 +8706,6 @@ "resolved": "https://registry.npmjs.org/preact/-/preact-10.27.2.tgz", "integrity": "sha512-5SYSgFKSyhCbk6SrXyMpqjb5+MQBgfvEKE/OC+PujcY34sOpqtr+0AZQtPYx5IA6VxynQ7rUPCtKzyovpj9Bpg==", "license": "MIT", - "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/preact" @@ -8909,7 +8897,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -8940,7 +8927,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.26.0" }, @@ -8953,7 +8939,6 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.65.0.tgz", "integrity": "sha512-xtOzDz063WcXvGWaHgLNrNzlsdFgtUWcb32E6WFaGTd7kPZG3EeDusjdZfUsPwKCKVXy1ZlntifaHZ4l8pAsmw==", "license": "MIT", - "peer": true, "engines": { "node": ">=18.0.0" }, @@ -10121,7 +10106,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -10376,7 +10360,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -10908,7 +10891,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz", "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/frontend/src/app/police/_components/EmbeddedMap.tsx b/frontend/src/app/police/_components/EmbeddedMap.tsx index 708b2da..32c4ea1 100644 --- a/frontend/src/app/police/_components/EmbeddedMap.tsx +++ b/frontend/src/app/police/_components/EmbeddedMap.tsx @@ -1,6 +1,6 @@ "use client"; -import { Party } from "@/lib/api/party/party.types"; +import { PartyDto } from "@/lib/api/party/party.types"; import { AdvancedMarker, APIProvider, @@ -11,11 +11,7 @@ import { } from "@vis.gl/react-google-maps"; import { format } from "date-fns"; import { useCallback, useEffect, useState } from "react"; -interface PoiMarkersProps { - pois: (Poi & { party?: Party })[]; - activePoiKey?: string; - onSelect?: (party: Party | null) => void; -} + type Poi = { key: string; activePoiKey?: string; @@ -23,10 +19,10 @@ type Poi = { }; interface EmbeddedMapProps { - parties: Party[]; - activeParty?: Party; + parties: PartyDto[]; + activeParty?: PartyDto; center?: { lat: number; lng: number }; - onSelect?: (party: Party | null) => void; + onSelect?: (party: PartyDto | null) => void; } const EmbeddedMap = ({ @@ -83,6 +79,12 @@ const EmbeddedMap = ({ ); }; +type PoiMarkersProps = { + pois: (Poi & { party?: PartyDto })[]; + activePoiKey?: string; + onSelect?: (party: PartyDto | null) => void; +}; + const PoiMarkers = ({ pois, activePoiKey, onSelect }: PoiMarkersProps) => { const map = useMap(); const [selectedPoi, setSelectedPoi] = useState<(typeof pois)[0] | null>(null); @@ -97,12 +99,12 @@ const PoiMarkers = ({ pois, activePoiKey, onSelect }: PoiMarkersProps) => { return phone; }; - const getShortAddress = (location: Party["location"]): string => { + const getShortAddress = (location: PartyDto["location"]): string => { const parts = []; - if (location.streetNumber) parts.push(location.streetNumber); - if (location.streetName) parts.push(location.streetName); + if (location.street_number) parts.push(location.street_number); + if (location.street_name) parts.push(location.street_name); if (location.unit) parts.push(`Unit ${location.unit}`); - return parts.join(" ") || location.formattedAddress; + return parts.join(" ") || location.formatted_address; }; useEffect(() => { @@ -160,23 +162,25 @@ const PoiMarkers = ({ pois, activePoiKey, onSelect }: PoiMarkersProps) => { >
- {format(selectedPoi.party.datetime, "MMM d, yyyy")} at{" "} - {format(selectedPoi.party.datetime, "h:mm a")} + {format(selectedPoi.party.party_datetime, "MMM d, yyyy")} at{" "} + {format(selectedPoi.party.party_datetime, "h:mm a")}
- {selectedPoi.party.contactOne.firstName}{" "} - {selectedPoi.party.contactOne.lastName} + {selectedPoi.party.contact_one.first_name}{" "} + {selectedPoi.party.contact_one.last_name}
- {formatPhoneNumber(selectedPoi.party.contactOne.phoneNumber)} + {formatPhoneNumber( + selectedPoi.party.contact_one.phone_number + )} - {selectedPoi.party.contactOne.contactPreference + {selectedPoi.party.contact_one.contact_preference .charAt(0) .toUpperCase() + - selectedPoi.party.contactOne.contactPreference + selectedPoi.party.contact_one.contact_preference .slice(1) .toLowerCase()} diff --git a/frontend/src/app/police/_components/PartyList.tsx b/frontend/src/app/police/_components/PartyList.tsx index 5d2b1d7..9744e4f 100644 --- a/frontend/src/app/police/_components/PartyList.tsx +++ b/frontend/src/app/police/_components/PartyList.tsx @@ -1,12 +1,12 @@ "use client"; -import { Party } from "@/lib/api/party/party.types"; +import { PartyDto } from "@/lib/api/party/party.types"; import { format } from "date-fns"; interface PartyListProps { - parties?: Party[]; - onSelect?: (party: Party) => void; - activeParty?: Party; + parties?: PartyDto[]; + onSelect?: (party: PartyDto) => void; + activeParty?: PartyDto; } const formatPhoneNumber = (phone: string): string => { @@ -48,10 +48,11 @@ const PartyList = ({ parties = [], onSelect, activeParty }: PartyListProps) => { {/* Address and Date/Time */}
- {party.location.formattedAddress} + {party.location.formatted_address}
- {format(party.datetime, "PPP")} at {format(party.datetime, "p")} + {format(party.party_datetime, "PPP")} at{" "} + {format(party.party_datetime, "p")}
@@ -64,15 +65,17 @@ const PartyList = ({ parties = [], onSelect, activeParty }: PartyListProps) => {
- {party.contactOne.firstName} {party.contactOne.lastName} + {party.contact_one.first_name} {party.contact_one.last_name}
-
{formatPhoneNumber(party.contactOne.phoneNumber)}
+
{formatPhoneNumber(party.contact_one.phone_number)}
Prefers:{" "} - {party.contactOne.contactPreference + {party.contact_one.contact_preference .charAt(0) .toUpperCase() + - party.contactOne.contactPreference.slice(1).toLowerCase()} + party.contact_one.contact_preference + .slice(1) + .toLowerCase()}
@@ -84,15 +87,17 @@ const PartyList = ({ parties = [], onSelect, activeParty }: PartyListProps) => {
- {party.contactTwo.firstName} {party.contactTwo.lastName} + {party.contact_two.first_name} {party.contact_two.last_name}
-
{formatPhoneNumber(party.contactTwo.phoneNumber)}
+
{formatPhoneNumber(party.contact_two.phone_number)}
Prefers:{" "} - {party.contactTwo.contactPreference + {party.contact_two.contact_preference .charAt(0) .toUpperCase() + - party.contactTwo.contactPreference.slice(1).toLowerCase()} + party.contact_two.contact_preference + .slice(1) + .toLowerCase()}
diff --git a/frontend/src/app/police/page.tsx b/frontend/src/app/police/page.tsx index c7626aa..4451c7a 100644 --- a/frontend/src/app/police/page.tsx +++ b/frontend/src/app/police/page.tsx @@ -1,22 +1,20 @@ "use client"; -import DateRangeFilter from "@/app/police/_components/DateRangeFilter"; +import DateRangeFilter from "@/components/DateRangeFilter"; import EmbeddedMap from "@/app/police/_components/EmbeddedMap"; import PartyList from "@/app/police/_components/PartyList"; import AddressSearch from "@/components/AddressSearch"; -import { - AutocompleteResult, - LocationService, -} from "@/lib/api/location/location.service"; -import { Party } from "@/lib/api/party/party.types"; +import { LocationService } from "@/lib/api/location/location.service"; +import { AutocompleteResult } from "@/lib/api/location/location.types"; +import { PartyService } from "@/lib/api/party/party.service"; +import { PartyDto } from "@/lib/api/party/party.types"; import getMockClient from "@/lib/network/mockClient"; -import { policeService } from "@/lib/network/policeService"; import { useQuery } from "@tanstack/react-query"; import { startOfDay } from "date-fns"; import { useEffect, useMemo, useState } from "react"; -// Create police-authenticated location service (module-level to prevent recreation) const policeLocationService = new LocationService(getMockClient("police")); +const partyService = new PartyService(getMockClient("police")); export default function PolicePage() { const today = startOfDay(new Date()); @@ -25,14 +23,14 @@ export default function PolicePage() { const [searchAddress, setSearchAddress] = useState( null ); - const [activeParty, setActiveParty] = useState(); + const [activeParty, setActiveParty] = useState(); // Fetch place details when address is selected const { data: placeDetails } = useQuery({ - queryKey: ["place-details", searchAddress?.place_id], + queryKey: ["place-details", searchAddress?.google_place_id], queryFn: () => - policeLocationService.getPlaceDetails(searchAddress!.place_id), - enabled: !!searchAddress?.place_id, + policeLocationService.getPlaceDetails(searchAddress!.google_place_id), + enabled: !!searchAddress?.google_place_id, }); // Fetch parties using Tanstack Query @@ -41,21 +39,29 @@ export default function PolicePage() { isLoading, error, } = useQuery({ - queryKey: ["parties", startDate, endDate], - queryFn: () => policeService.getParties(startDate, endDate), + queryKey: ["parties"], + queryFn: async () => { + const page = await partyService.listParties(); + return page.items; + }, // TODO: add date filtering enabled: !!startDate && !!endDate, }); // Fetch nearby parties if address search is active const { data: nearbyParties, isLoading: isLoadingNearby } = useQuery({ - queryKey: ["parties-nearby", searchAddress?.place_id, startDate, endDate], + queryKey: [ + "parties-nearby", + searchAddress?.google_place_id, + startDate, + endDate, + ], queryFn: () => - policeService.getPartiesNearby( - searchAddress!.place_id, + partyService.getPartiesNearby( + searchAddress!.google_place_id, startDate!, endDate! ), - enabled: !!searchAddress?.place_id && !!startDate && !!endDate, + enabled: !!searchAddress?.google_place_id && !!startDate && !!endDate, }); // Use nearby parties if address search is active, otherwise use all parties @@ -66,7 +72,7 @@ export default function PolicePage() { }, [allParties, nearbyParties, searchAddress]); // Handle party selection from the list - function handleActiveParty(party: Party | null): void { + function handleActiveParty(party: PartyDto | null): void { setActiveParty(party ?? undefined); console.log("Active party set to:", party); } @@ -101,7 +107,7 @@ export default function PolicePage() { /> - {/* Party Search Section */} + {/* Dto Search Section */}

Proximity Search diff --git a/frontend/src/app/staff/_components/account/AccountTable.tsx b/frontend/src/app/staff/_components/account/AccountTable.tsx index b29ff41..77b7da3 100644 --- a/frontend/src/app/staff/_components/account/AccountTable.tsx +++ b/frontend/src/app/staff/_components/account/AccountTable.tsx @@ -7,14 +7,12 @@ import { useState } from "react"; import * as z from "zod"; import { useSidebar } from "../shared/sidebar/SidebarContext"; import { TableTemplate } from "../shared/table/TableTemplate"; -import AccountTableCreateEditForm, { - AccountCreateEditValues as AccountCreateEditSchema, -} from "./AccountTableCreateEdit"; +import AccountTableForm, { accountTableFormSchema } from "./AccountTableForm"; -import type { Account, AccountRole } from "@/lib/api/account/account.service"; +import type { AccountDto, AccountRole } from "@/lib/api/account/account.types"; import { isAxiosError } from "axios"; -type AccountCreateEditValues = z.infer; +type AccountTableFormValues = z.infer; const accountService = new AccountService(); @@ -22,7 +20,7 @@ export const AccountTable = () => { const queryClient = useQueryClient(); const { openSidebar, closeSidebar } = useSidebar(); const [sidebarMode, setSidebarMode] = useState<"create" | "edit">("create"); - const [editingAccount, setEditingAccount] = useState(null); + const [editingAccount, setEditingAccount] = useState(null); const [submissionError, setSubmissionError] = useState(null); const accountsQuery = useQuery({ @@ -36,16 +34,16 @@ export const AccountTable = () => { .slice() .sort( (a, b) => - a.lastName.localeCompare(b.lastName) || - a.firstName.localeCompare(b.firstName) + a.last_name.localeCompare(b.last_name) || + a.first_name.localeCompare(b.first_name) ); const createMutation = useMutation({ - mutationFn: (data: AccountCreateEditValues) => + mutationFn: (data: AccountTableFormValues) => accountService.createAccount({ email: data.email, - firstName: data.firstName, - lastName: data.lastName, + first_name: data.first_name, + last_name: data.last_name, pid: data.pid, role: data.role as AccountRole, }), @@ -65,11 +63,11 @@ export const AccountTable = () => { }); const updateMutation = useMutation({ - mutationFn: ({ id, data }: { id: number; data: AccountCreateEditValues }) => + mutationFn: ({ id, data }: { id: number; data: AccountTableFormValues }) => accountService.updateAccount(id, { email: data.email, - firstName: data.firstName, - lastName: data.lastName, + first_name: data.first_name, + last_name: data.last_name, pid: data.pid, role: data.role as AccountRole, }), @@ -91,9 +89,9 @@ export const AccountTable = () => { onMutate: async (id: number) => { await queryClient.cancelQueries({ queryKey: ["accounts"] }); - const previous = queryClient.getQueryData(["accounts"]); + const previous = queryClient.getQueryData(["accounts"]); - queryClient.setQueryData(["accounts"], (old) => + queryClient.setQueryData(["accounts"], (old) => old?.filter((a) => a.id !== id) ); @@ -110,7 +108,7 @@ export const AccountTable = () => { }, }); - const handleEdit = (account: Account) => { + const handleEdit = (account: AccountDto) => { setEditingAccount(account); setSidebarMode("edit"); setSubmissionError(null); @@ -118,14 +116,14 @@ export const AccountTable = () => { `edit-account-${account.id}`, "Edit Account", "Update account information", - { ); }; - const handleDelete = (account: Account) => { + const handleDelete = (account: AccountDto) => { deleteMutation.mutate(account.id); }; @@ -145,7 +143,7 @@ export const AccountTable = () => { "create-account", "New Account", "Add a new account to the system", - { ); }; - const handleFormSubmit = async (data: AccountCreateEditValues) => { + const handleFormSubmit = async (data: AccountTableFormValues) => { if (sidebarMode === "edit" && editingAccount) { updateMutation.mutate({ id: editingAccount.id, data }); } else if (sidebarMode === "create") { @@ -161,19 +159,19 @@ export const AccountTable = () => { } }; - const columns: ColumnDef[] = [ + const columns: ColumnDef[] = [ { accessorKey: "email", header: "Email", enableColumnFilter: true, }, { - accessorKey: "firstName", + accessorKey: "first_name", header: "First Name", enableColumnFilter: true, }, { - accessorKey: "lastName", + accessorKey: "last_name", header: "Last Name", enableColumnFilter: true, }, @@ -195,7 +193,7 @@ export const AccountTable = () => { onCreateNew={handleCreate} isLoading={accountsQuery.isLoading} error={accountsQuery.error as Error | null} - getDeleteDescription={(account: Account) => + getDeleteDescription={(account: AccountDto) => `Are you sure you want to delete account ${account.email}? This action cannot be undone.` } isDeleting={deleteMutation.isPending} diff --git a/frontend/src/app/staff/_components/account/AccountTableCreateEdit.tsx b/frontend/src/app/staff/_components/account/AccountTableForm.tsx similarity index 76% rename from frontend/src/app/staff/_components/account/AccountTableCreateEdit.tsx rename to frontend/src/app/staff/_components/account/AccountTableForm.tsx index 79edcaf..17670bf 100644 --- a/frontend/src/app/staff/_components/account/AccountTableCreateEdit.tsx +++ b/frontend/src/app/staff/_components/account/AccountTableForm.tsx @@ -19,33 +19,33 @@ import { import { useState } from "react"; import * as z from "zod"; -export const AccountCreateEditValues = z.object({ +export const accountTableFormSchema = z.object({ pid: z.string().length(9, "Please input a valid PID"), email: z.email({ pattern: z.regexes.html5Email }).min(1, "Email is required"), - firstName: z.string().min(1, "First name is required"), - lastName: z.string().min(1, "Second name is required"), + first_name: z.string().min(1, "First name is required"), + last_name: z.string().min(1, "Second name is required"), role: z.string().min(1, "Role is required"), }); -type AccountCreateEditValues = z.infer; +type AccountTableFormValues = z.infer; -interface AccountRegistrationFormProps { - onSubmit: (data: AccountCreateEditValues) => void | Promise; - editData?: AccountCreateEditValues; +interface AccountTableFormProps { + onSubmit: (data: AccountTableFormValues) => void | Promise; + editData?: AccountTableFormValues; submissionError?: string | null; title?: string; } -export default function AccountTableCreateEditForm({ +export default function AccountTableForm({ onSubmit, editData, submissionError, title, -}: AccountRegistrationFormProps) { - const [formData, setFormData] = useState>({ +}: AccountTableFormProps) { + const [formData, setFormData] = useState>({ email: editData?.email ?? "", - firstName: editData?.firstName ?? "", - lastName: editData?.lastName ?? "", + first_name: editData?.first_name ?? "", + last_name: editData?.last_name ?? "", role: editData?.role ?? "", pid: editData?.pid ?? "", }); @@ -56,7 +56,7 @@ export default function AccountTableCreateEditForm({ e.preventDefault(); setErrors({}); - const result = AccountCreateEditValues.safeParse(formData); + const result = accountTableFormSchema.safeParse(formData); if (!result.success) { const fieldErrors: Record = {}; @@ -77,9 +77,9 @@ export default function AccountTableCreateEditForm({ } }; - const updateField = ( + const updateField = ( field: K, - value: AccountCreateEditValues[K] + value: AccountTableFormValues[K] ) => { setFormData((prev) => ({ ...prev, [field]: value })); if (errors[field]) { @@ -129,28 +129,28 @@ export default function AccountTableCreateEditForm({ {errors.pid && {errors.pid}} - + First name updateField("firstName", e.target.value)} - aria-invalid={!!errors.firstName} + value={formData.first_name} + onChange={(e) => updateField("first_name", e.target.value)} + aria-invalid={!!errors.first_name} /> - {errors.firstName && {errors.firstName}} + {errors.first_name && {errors.first_name}} - + Last name updateField("lastName", e.target.value)} - aria-invalid={!!errors.lastName} + value={formData.last_name} + onChange={(e) => updateField("last_name", e.target.value)} + aria-invalid={!!errors.last_name} /> - {errors.lastName && {errors.lastName}} + {errors.last_name && {errors.last_name}} diff --git a/frontend/src/app/staff/_components/location/LocationTable.tsx b/frontend/src/app/staff/_components/location/LocationTable.tsx index baecf46..b694884 100644 --- a/frontend/src/app/staff/_components/location/LocationTable.tsx +++ b/frontend/src/app/staff/_components/location/LocationTable.tsx @@ -1,17 +1,14 @@ "use client"; -import { - LocationCreatePayload, - LocationService, - PaginatedLocationResponse, -} from "@/lib/api/location/location.service"; -import { Location } from "@/lib/api/location/location.types"; +import { LocationService } from "@/lib/api/location/location.service"; +import { LocationCreate, LocationDto } from "@/lib/api/location/location.types"; +import { PaginatedResponse } from "@/lib/shared"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { ColumnDef } from "@tanstack/react-table"; import { useState } from "react"; import { useSidebar } from "../shared/sidebar/SidebarContext"; -import LocationTableCreateEditForm from "./LocationTableCreateEdit"; import { TableTemplate } from "../shared/table/TableTemplate"; +import LocationTableForm from "./LocationTableForm"; const locationService = new LocationService(); @@ -19,10 +16,12 @@ export const LocationTable = () => { const queryClient = useQueryClient(); const { openSidebar, closeSidebar } = useSidebar(); const [sidebarMode, setSidebarMode] = useState<"create" | "edit">("create"); - const [editingLocation, setEditingLocation] = useState(null); + const [editingLocation, setEditingLocation] = useState( + null + ); const [submissionError, setSubmissionError] = useState(null); - const locationsQuery = useQuery({ + const locationsQuery = useQuery>({ queryKey: ["locations"], queryFn: () => locationService.getLocations(), retry: 1, @@ -31,11 +30,11 @@ export const LocationTable = () => { const locations = (locationsQuery.data?.items ?? []) .slice() .sort((a, b) => - (a.formattedAddress || "").localeCompare(b.formattedAddress || "") + (a.formatted_address || "").localeCompare(b.formatted_address || "") ); const createMutation = useMutation({ - mutationFn: (payload: LocationCreatePayload) => + mutationFn: (payload: LocationCreate) => locationService.createLocation(payload), onError: (error: Error) => { console.error("Failed to create location:", error); @@ -50,13 +49,8 @@ export const LocationTable = () => { }); const updateMutation = useMutation({ - mutationFn: ({ - id, - payload, - }: { - id: number; - payload: LocationCreatePayload; - }) => locationService.updateLocation(id, payload), + mutationFn: ({ id, payload }: { id: number; payload: LocationCreate }) => + locationService.updateLocation(id, payload), onError: (error: Error) => { console.error("Failed to update location:", error); setSubmissionError(`Failed to update location: ${error.message}`); @@ -75,11 +69,11 @@ export const LocationTable = () => { onMutate: async (id: number) => { await queryClient.cancelQueries({ queryKey: ["locations"] }); - const previous = queryClient.getQueryData([ - "locations", - ]); + const previous = queryClient.getQueryData>( + ["locations"] + ); - queryClient.setQueryData( + queryClient.setQueryData | undefined>( ["locations"], (old) => old @@ -103,7 +97,7 @@ export const LocationTable = () => { }, }); - const handleEdit = (location: Location) => { + const handleEdit = (location: LocationDto) => { setEditingLocation(location); setSidebarMode("edit"); setSubmissionError(null); @@ -111,22 +105,22 @@ export const LocationTable = () => { `edit-location-${location.id}`, "Edit Location", "Update location information", - ); }; - const handleDelete = (location: Location) => { + const handleDelete = (location: LocationDto) => { deleteMutation.mutate(location.id); }; @@ -138,7 +132,7 @@ export const LocationTable = () => { "create-location", "New Location", "Add a new location to the system", - { address: string; placeId: string; holdExpiration: Date | null; - warningCount: number; - citationCount: number; + warning_count: number; + citation_count: number; }) => { - let hold_expiration_str: string | null = null; - if (data.holdExpiration) { - // Format as local datetime without timezone (YYYY-MM-DDTHH:mm:ss) - const date = data.holdExpiration; - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, "0"); - const day = String(date.getDate()).padStart(2, "0"); - const hour = String(date.getHours()).padStart(2, "0"); - const minute = String(date.getMinutes()).padStart(2, "0"); - const second = String(date.getSeconds()).padStart(2, "0"); - hold_expiration_str = `${year}-${month}-${day}T${hour}:${minute}:${second}`; - } - - const payload: LocationCreatePayload = { + const payload: LocationCreate = { google_place_id: data.placeId, - warning_count: data.warningCount, - citation_count: data.citationCount, - hold_expiration: hold_expiration_str, + warning_count: data.warning_count, + citation_count: data.citation_count, + hold_expiration: data.holdExpiration, }; if (sidebarMode === "edit" && editingLocation) { @@ -179,25 +160,25 @@ export const LocationTable = () => { createMutation.mutate(payload); } }; - const columns: ColumnDef[] = [ + const columns: ColumnDef[] = [ { - accessorKey: "formattedAddress", + accessorKey: "formatted_address", header: "Address", }, { - accessorKey: "warningCount", + accessorKey: "warning_count", header: "Warning Count", }, { - accessorKey: "citationCount", + accessorKey: "citation_count", header: "Citation Count", }, { - accessorKey: "holdExpirationDate", + accessorKey: "hold_expiration", header: "Active Hold", enableColumnFilter: true, cell: ({ row }) => { - const holdDate = row.getValue("holdExpirationDate") as Date | null; + const holdDate = row.getValue("hold_expiration") as Date | null; if (holdDate) { const formattedDate = new Date(holdDate).toLocaleDateString(); return `until ${formattedDate}`; @@ -228,8 +209,8 @@ export const LocationTable = () => { onCreateNew={handleCreate} isLoading={locationsQuery.isLoading} error={locationsQuery.error as Error | null} - getDeleteDescription={(location: Location) => - `Are you sure you want to delete location ${location.formattedAddress}? This action cannot be undone.` + getDeleteDescription={(location: LocationDto) => + `Are you sure you want to delete location ${location.formatted_address}? This action cannot be undone.` } isDeleting={deleteMutation.isPending} /> diff --git a/frontend/src/app/staff/_components/location/LocationTableCreateEdit.tsx b/frontend/src/app/staff/_components/location/LocationTableForm.tsx similarity index 79% rename from frontend/src/app/staff/_components/location/LocationTableCreateEdit.tsx rename to frontend/src/app/staff/_components/location/LocationTableForm.tsx index 5e7ea36..860728e 100644 --- a/frontend/src/app/staff/_components/location/LocationTableCreateEdit.tsx +++ b/frontend/src/app/staff/_components/location/LocationTableForm.tsx @@ -16,48 +16,46 @@ import { PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; -import { - AutocompleteResult, - LocationService, -} from "@/lib/api/location/location.service"; +import { LocationService } from "@/lib/api/location/location.service"; +import { AutocompleteResult } from "@/lib/api/location/location.types"; import { addBusinessDays, format, isAfter, startOfDay } from "date-fns"; import { CalendarIcon } from "lucide-react"; import { useState } from "react"; import * as z from "zod"; -export const LocationCreateEditSchema = z.object({ +export const locationTableFormSchema = z.object({ address: z.string().min(1, "Address is required"), placeId: z .string() .min(1, "Please select an address from the search results"), holdExpiration: z.date().nullable(), - warningCount: z.number(), - citationCount: z.number(), + warning_count: z.number(), + citation_count: z.number(), }); -type LocationCreateEditValues = z.infer; +type LocationTableFormValues = z.infer; -interface StudentRegistrationFormProps { - onSubmit: (data: LocationCreateEditValues) => void | Promise; - editData?: LocationCreateEditValues; +interface LocationTableFormProps { + onSubmit: (data: LocationTableFormValues) => void | Promise; + editData?: LocationTableFormValues; submissionError?: string | null; title?: string; } -export default function LocationTableCreateEditForm({ +export default function LocationTableForm({ onSubmit, editData, submissionError, title, -}: StudentRegistrationFormProps) { +}: LocationTableFormProps) { const locationService = new LocationService(); - const [formData, setFormData] = useState>({ + const [formData, setFormData] = useState>({ address: editData?.address ?? "", placeId: editData?.placeId ?? undefined, holdExpiration: editData?.holdExpiration ?? null, - warningCount: editData?.warningCount ?? 0, - citationCount: editData?.citationCount ?? 0, + warning_count: editData?.warning_count ?? 0, + citation_count: editData?.citation_count ?? 0, }); const [errors, setErrors] = useState>({}); const [isSubmitting, setIsSubmitting] = useState(false); @@ -66,7 +64,7 @@ export default function LocationTableCreateEditForm({ e.preventDefault(); setErrors({}); - const result = LocationCreateEditSchema.safeParse(formData); + const result = locationTableFormSchema.safeParse(formData); if (!result.success) { const fieldErrors: Record = {}; @@ -91,7 +89,7 @@ export default function LocationTableCreateEditForm({ setFormData((prev) => ({ ...prev, address: address?.formatted_address || "", - placeId: address?.place_id || undefined, + placeId: address?.google_place_id || undefined, })); if (errors.address) { setErrors((prev) => { @@ -102,9 +100,9 @@ export default function LocationTableCreateEditForm({ } }; - const updateField = ( + const updateField = ( field: K, - value: LocationCreateEditValues[K] + value: LocationTableFormValues[K] ) => { setFormData((prev) => ({ ...prev, [field]: value })); if (errors[field]) { @@ -182,37 +180,37 @@ export default function LocationTableCreateEditForm({ )} - + Warning count - updateField("warningCount", Number(e.target.value)) + updateField("warning_count", Number(e.target.value)) } id="warning-count" type="number" min={0} step={1} /> - {errors.warningCount && ( - {errors.warningCount} + {errors.warning_count && ( + {errors.warning_count} )} - + Citation count - updateField("citationCount", Number(e.target.value)) + updateField("citation_count", Number(e.target.value)) } id="warning-count" type="number" min={0} step={1} /> - {errors.citationCount && ( - {errors.citationCount} + {errors.citation_count && ( + {errors.citation_count} )} diff --git a/frontend/src/app/staff/_components/party/PartyTable.tsx b/frontend/src/app/staff/_components/party/PartyTable.tsx index 8d2e7c8..6f52ca7 100644 --- a/frontend/src/app/staff/_components/party/PartyTable.tsx +++ b/frontend/src/app/staff/_components/party/PartyTable.tsx @@ -1,11 +1,8 @@ "use client"; -import { - AdminPartyPayload, - PaginatedPartiesResponse, - PartyService, -} from "@/lib/api/party/party.service"; -import { Party } from "@/lib/api/party/party.types"; +import { PartyService } from "@/lib/api/party/party.service"; +import { AdminCreatePartyDto, PartyDto } from "@/lib/api/party/party.types"; +import { PaginatedResponse } from "@/lib/shared"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { ColumnDef } from "@tanstack/react-table"; import { format, isWithinInterval, startOfDay } from "date-fns"; @@ -13,11 +10,11 @@ import { useState } from "react"; import { DateRange } from "react-day-picker"; import { GenericInfoChip } from "../shared/sidebar/GenericInfoChip"; import { useSidebar } from "../shared/sidebar/SidebarContext"; +import { TableTemplate } from "../shared/table/TableTemplate"; import ContactInfoChipDetails from "./details/ContactInfoChipDetails"; import LocationInfoChipDetails from "./details/LocationInfoChipDetails"; import StudentInfoChipDetails from "./details/StudentInfoChipDetails"; -import PartyTableCreateEditForm from "./PartyTableCreateEdit"; -import { TableTemplate } from "../shared/table/TableTemplate"; +import PartyTableForm from "./PartyTableForm"; const partyService = new PartyService(); @@ -25,7 +22,7 @@ export const PartyTable = () => { const queryClient = useQueryClient(); const { openSidebar, closeSidebar } = useSidebar(); const [sidebarMode, setSidebarMode] = useState<"create" | "edit">("create"); - const [editingParty, setEditingParty] = useState(null); + const [editingParty, setEditingParty] = useState(null); const [submissionError, setSubmissionError] = useState(null); const partiesQuery = useQuery({ @@ -37,7 +34,7 @@ export const PartyTable = () => { const parties = partiesQuery.data?.items ?? []; const createMutation = useMutation({ - mutationFn: (payload: AdminPartyPayload) => + mutationFn: (payload: AdminCreatePartyDto) => partyService.createParty(payload), onError: (error: Error) => { console.error("Failed to create party:", error); @@ -63,8 +60,13 @@ export const PartyTable = () => { }); const updateMutation = useMutation({ - mutationFn: ({ id, payload }: { id: number; payload: AdminPartyPayload }) => - partyService.updateParty(id, payload), + mutationFn: ({ + id, + payload, + }: { + id: number; + payload: AdminCreatePartyDto; + }) => partyService.updateParty(id, payload), onError: (error: Error) => { console.error("Failed to update party:", error); @@ -92,11 +94,11 @@ export const PartyTable = () => { onMutate: async (id: number) => { await queryClient.cancelQueries({ queryKey: ["parties"] }); - const previous = queryClient.getQueryData([ + const previous = queryClient.getQueryData>([ "parties", ]); - queryClient.setQueryData( + queryClient.setQueryData>( ["parties"], (prev) => prev && { @@ -118,7 +120,7 @@ export const PartyTable = () => { }, }); - const handleEdit = (party: Party) => { + const handleEdit = (party: PartyDto) => { setEditingParty(party); setSidebarMode("edit"); setSubmissionError(null); @@ -126,7 +128,7 @@ export const PartyTable = () => { `edit-party-${party.id}`, "Edit Party", "Update party information", - { ); }; - const handleDelete = (party: Party) => { + const handleDelete = (party: PartyDto) => { deleteMutation.mutate(party.id); }; @@ -147,7 +149,7 @@ export const PartyTable = () => { "create-party", "New Party", "Add a new party to the system", - { contactTwoPhoneNumber: string; contactTwoPreference: "call" | "text" | string; }) => { - // Check if we're editing and if date/time have changed - let party_datetime_str: string; - - if (sidebarMode === "edit" && editingParty) { - // Get original datetime components from the Date object - const originalDate = new Date(editingParty.datetime); - const originalDateStr = `${originalDate.getFullYear()}-${String( - originalDate.getMonth() + 1 - ).padStart(2, "0")}-${String(originalDate.getDate()).padStart(2, "0")}`; - const originalTimeStr = `${String(originalDate.getHours()).padStart( - 2, - "0" - )}:${String(originalDate.getMinutes()).padStart(2, "0")}`; - - // Get new date components - const newDateStr = `${data.partyDate.getFullYear()}-${String( - data.partyDate.getMonth() + 1 - ).padStart(2, "0")}-${String(data.partyDate.getDate()).padStart(2, "0")}`; - - // If date and time haven't changed, use the original datetime string from backend - if ( - originalDateStr === newDateStr && - originalTimeStr === data.partyTime - ) { - // Use the raw datetime string directly to avoid any timezone conversion - party_datetime_str = editingParty.datetime.toISOString().slice(0, 19); - } else { - // Date or time changed, reconstruct - const [hours, minutes] = data.partyTime.split(":").map(Number); - const datetime = new Date(data.partyDate); - datetime.setHours(hours ?? 0, minutes ?? 0, 0, 0); - - const year = datetime.getFullYear(); - const month = String(datetime.getMonth() + 1).padStart(2, "0"); - const day = String(datetime.getDate()).padStart(2, "0"); - const hour = String(datetime.getHours()).padStart(2, "0"); - const minute = String(datetime.getMinutes()).padStart(2, "0"); - const second = String(datetime.getSeconds()).padStart(2, "0"); - party_datetime_str = `${year}-${month}-${day}T${hour}:${minute}:${second}`; - } - } else { - // Creating new party - const [hours, minutes] = data.partyTime.split(":").map(Number); - const datetime = new Date(data.partyDate); - datetime.setHours(hours ?? 0, minutes ?? 0, 0, 0); - - const year = datetime.getFullYear(); - const month = String(datetime.getMonth() + 1).padStart(2, "0"); - const day = String(datetime.getDate()).padStart(2, "0"); - const hour = String(datetime.getHours()).padStart(2, "0"); - const minute = String(datetime.getMinutes()).padStart(2, "0"); - const second = String(datetime.getSeconds()).padStart(2, "0"); - party_datetime_str = `${year}-${month}-${day}T${hour}:${minute}:${second}`; - } + // Construct party datetime + const [hours, minutes] = data.partyTime.split(":").map(Number); + const party_datetime = new Date(data.partyDate); + party_datetime.setHours(hours ?? 0, minutes ?? 0, 0, 0); - const payload: AdminPartyPayload = { + const payload: AdminCreatePartyDto = { type: "admin", - placeId: data.placeId, - partyDatetime: new Date(party_datetime_str), - contactOneEmail: data.contactOneEmail, - contactTwo: { + google_place_id: data.placeId, + party_datetime, + contact_one_email: data.contactOneEmail, + contact_two: { email: data.contactTwoEmail, - firstName: data.contactTwoFirstName, - lastName: data.contactTwoLastName, - phoneNumber: data.contactTwoPhoneNumber, - contactPreference: + first_name: data.contactTwoFirstName, + last_name: data.contactTwoLastName, + phone_number: data.contactTwoPhoneNumber, + contact_preference: (data.contactTwoPreference as "call" | "text") ?? "call", }, }; @@ -243,10 +195,10 @@ export const PartyTable = () => { createMutation.mutate(payload); } }; - const columns: ColumnDef[] = [ + const columns: ColumnDef[] = [ { id: "location", - accessorFn: (row) => row.location.formattedAddress, + accessorFn: (row) => row.location.formatted_address, header: "Address", enableColumnFilter: true, meta: { @@ -262,7 +214,7 @@ export const PartyTable = () => { chipKey={`party-${row.original.id}-location`} title="Location Information" description="Detailed information about the selected location" - shortName={location.formattedAddress} + shortName={location.formatted_address} sidebarContent={} /> ); @@ -270,8 +222,8 @@ export const PartyTable = () => { filterFn: (row, _columnId, filterValue) => { const location = row.original.location; - const addressString = `${location.streetNumber || ""} ${ - location.streetName || "" + const addressString = `${location.street_number || ""} ${ + location.street_name || "" }` .toLowerCase() .trim(); @@ -279,16 +231,16 @@ export const PartyTable = () => { }, }, { - id: "datetime", - accessorFn: (row) => format(row.datetime, "MM-dd-yyyy"), + id: "party_datetime", + accessorFn: (row) => format(row.party_datetime, "MM-dd-yyyy"), header: "Date", enableColumnFilter: true, meta: { filterType: "dateRange", }, cell: ({ row }) => { - const datetime = row.original.datetime; - const date = new Date(datetime); + const party_datetime = row.original.party_datetime; + const date = new Date(party_datetime); return date.toLocaleDateString(); }, @@ -296,8 +248,8 @@ export const PartyTable = () => { if (!filterValue) return true; const dateRange = filterValue as DateRange; - const datetime = row.original.datetime; - const date = startOfDay(new Date(datetime)); + const party_datetime = row.original.party_datetime; + const date = startOfDay(new Date(party_datetime)); // If only 'from' date is selected if (dateRange.from && !dateRange.to) { @@ -317,15 +269,15 @@ export const PartyTable = () => { }, { id: "time", - accessorFn: (row) => format(row.datetime, "HH:mm"), + accessorFn: (row) => format(row.party_datetime, "HH:mm"), header: "Time", enableColumnFilter: true, meta: { filterType: "time", }, cell: ({ row }) => { - const datetime = row.original.datetime; - const date = new Date(datetime); + const party_datetime = row.original.party_datetime; + const date = new Date(party_datetime); return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", @@ -335,8 +287,8 @@ export const PartyTable = () => { filterFn: (row, _columnId, filterValue) => { if (!filterValue) return true; - const datetime = row.original.datetime; - const date = new Date(datetime); + const party_datetime = row.original.party_datetime; + const date = new Date(party_datetime); // Get hours and minutes from the time input (e.g., "14:30") const [filterHours, filterMinutes] = String(filterValue) @@ -350,20 +302,20 @@ export const PartyTable = () => { }, }, { - id: "contactOne", + id: "contact_one", accessorFn: (row) => - `${row.contactOne.firstName} ${row.contactOne.lastName}`, + `${row.contact_one.first_name} ${row.contact_one.last_name}`, header: "Contact One", enableColumnFilter: true, meta: { filterType: "text", }, cell: ({ row }) => { - const contact = row.original.contactOne; + const contact = row.original.contact_one; return contact ? ( } @@ -374,29 +326,29 @@ export const PartyTable = () => { }, filterFn: (row, _columnId, filterValue) => { - const contact = row.original.contactOne; + const contact = row.original.contact_one; const fullName = - `${contact.firstName} ${contact.lastName}`.toLowerCase(); + `${contact.first_name} ${contact.last_name}`.toLowerCase(); return fullName.includes(String(filterValue).toLowerCase()); }, }, { - id: "contactTwo", + id: "contact_two", accessorFn: (row) => - `${row.contactTwo.firstName} ${row.contactTwo.lastName}`, + `${row.contact_two.first_name} ${row.contact_two.last_name}`, header: "Contact Two", enableColumnFilter: true, meta: { filterType: "text", }, cell: ({ row }) => { - const contact = row.original.contactTwo; + const contact = row.original.contact_two; const partyId = row.original.id; if (!contact) return "—"; return ( } @@ -405,9 +357,9 @@ export const PartyTable = () => { }, filterFn: (row, _columnId, filterValue) => { - const contact = row.original.contactTwo; + const contact = row.original.contact_two; const fullName = - `${contact.firstName} ${contact.lastName}`.toLowerCase(); + `${contact.first_name} ${contact.last_name}`.toLowerCase(); return fullName.includes(String(filterValue).toLowerCase()); }, }, @@ -419,15 +371,15 @@ export const PartyTable = () => { data={parties} columns={columns} resourceName="Party" - initialSort={[{ id: "datetime", desc: true }]} + initialSort={[{ id: "party_datetime", desc: true }]} onEdit={handleEdit} onDelete={handleDelete} onCreateNew={handleCreate} isLoading={partiesQuery.isLoading} error={partiesQuery.error as Error | null} - getDeleteDescription={(party: Party) => + getDeleteDescription={(party: PartyDto) => `Are you sure you want to delete this party on ${new Date( - party.datetime + party.party_datetime ).toLocaleString()}? This action cannot be undone.` } isDeleting={deleteMutation.isPending} diff --git a/frontend/src/app/staff/_components/party/PartyTableCreateEdit.tsx b/frontend/src/app/staff/_components/party/PartyTableForm.tsx similarity index 88% rename from frontend/src/app/staff/_components/party/PartyTableCreateEdit.tsx rename to frontend/src/app/staff/_components/party/PartyTableForm.tsx index 42ad29f..5245b7c 100644 --- a/frontend/src/app/staff/_components/party/PartyTableCreateEdit.tsx +++ b/frontend/src/app/staff/_components/party/PartyTableForm.tsx @@ -23,17 +23,15 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { - AutocompleteResult, - LocationService, -} from "@/lib/api/location/location.service"; -import { Party } from "@/lib/api/party/party.types"; +import { LocationService } from "@/lib/api/location/location.service"; +import { AutocompleteResult } from "@/lib/api/location/location.types"; +import { PartyDto } from "@/lib/api/party/party.types"; import { addBusinessDays, format, isAfter, startOfDay } from "date-fns"; import { CalendarIcon } from "lucide-react"; import { useState } from "react"; import * as z from "zod"; -export const PartyTableCreateEditSchema = z.object({ +export const partyTableFormSchema = z.object({ address: z.string().min(1, "Address is required"), placeId: z .string() @@ -60,34 +58,34 @@ export const PartyTableCreateEditSchema = z.object({ contactTwoPreference: z.string(), }); -type PartyCreateEditValues = z.infer; +type PartyTableFormValues = z.infer; -interface PartyRegistrationFormProps { - onSubmit: (data: PartyCreateEditValues) => void | Promise; - editData?: Party; +interface PartyTableFormProps { + onSubmit: (data: PartyTableFormValues) => void | Promise; + editData?: PartyDto; submissionError?: string | null; title?: string; } -export default function PartyTableCreateEditForm({ +export default function PartyTableForm({ onSubmit, editData, submissionError, title, -}: PartyRegistrationFormProps) { +}: PartyTableFormProps) { const locationService = new LocationService(); - const [formData, setFormData] = useState>({ - address: editData?.location.formattedAddress ?? "", - placeId: editData?.location.googlePlaceId ?? undefined, - partyDate: editData?.datetime ?? undefined, - partyTime: editData?.datetime.toISOString().slice(11, 16) ?? "", - contactOneEmail: editData?.contactOne.email ?? "", - contactTwoEmail: editData?.contactTwo.email ?? "", - contactTwoFirstName: editData?.contactTwo.firstName ?? "", - contactTwoLastName: editData?.contactTwo.lastName ?? "", - contactTwoPhoneNumber: editData?.contactTwo.phoneNumber ?? "", - contactTwoPreference: editData?.contactTwo.contactPreference ?? "", + const [formData, setFormData] = useState>({ + address: editData?.location.formatted_address ?? "", + placeId: editData?.location.google_place_id ?? undefined, + partyDate: editData?.party_datetime ?? undefined, + partyTime: editData?.party_datetime.toISOString().slice(11, 16) ?? "", + contactOneEmail: editData?.contact_one.email ?? "", + contactTwoEmail: editData?.contact_two.email ?? "", + contactTwoFirstName: editData?.contact_two.first_name ?? "", + contactTwoLastName: editData?.contact_two.last_name ?? "", + contactTwoPhoneNumber: editData?.contact_two.phone_number ?? "", + contactTwoPreference: editData?.contact_two.contact_preference ?? "", }); const [errors, setErrors] = useState>({}); const [isSubmitting, setIsSubmitting] = useState(false); @@ -96,7 +94,7 @@ export default function PartyTableCreateEditForm({ e.preventDefault(); setErrors({}); - const result = PartyTableCreateEditSchema.safeParse(formData); + const result = partyTableFormSchema.safeParse(formData); if (!result.success) { const fieldErrors: Record = {}; @@ -121,7 +119,7 @@ export default function PartyTableCreateEditForm({ setFormData((prev) => ({ ...prev, address: address?.formatted_address || "", - placeId: address?.place_id || undefined, + placeId: address?.google_place_id || undefined, })); if (errors.address) { setErrors((prev) => { @@ -132,9 +130,9 @@ export default function PartyTableCreateEditForm({ } }; - const updateField = ( + const updateField = ( field: K, - value: PartyCreateEditValues[K] + value: PartyTableFormValues[K] ) => { setFormData((prev) => ({ ...prev, [field]: value })); if (errors[field]) { diff --git a/frontend/src/app/staff/_components/party/details/ContactInfoChipDetails.tsx b/frontend/src/app/staff/_components/party/details/ContactInfoChipDetails.tsx index 9c06d09..4cb347d 100644 --- a/frontend/src/app/staff/_components/party/details/ContactInfoChipDetails.tsx +++ b/frontend/src/app/staff/_components/party/details/ContactInfoChipDetails.tsx @@ -1,15 +1,15 @@ "use client"; -import { Contact } from "@/lib/api/student/student.types"; +import { ContactDto } from "@/lib/api/party/party.types"; import { GenericChipDetails } from "../../shared/sidebar/GenericChipDetails"; interface ContactInfoChipDetailsProps { - data: Contact; + data: ContactDto; } export function ContactInfoChipDetails({ data }: ContactInfoChipDetailsProps) { return ( - + data={data} title={"Info about the Contact"} description={"View information on the Contact you just clicked on"} @@ -17,11 +17,11 @@ export function ContactInfoChipDetails({ data }: ContactInfoChipDetailsProps) {
-

{d.firstName}

+

{d.first_name}

-

{d.lastName}

+

{d.last_name}

@@ -29,13 +29,13 @@ export function ContactInfoChipDetails({ data }: ContactInfoChipDetailsProps) {
-

{d.phoneNumber}

+

{d.phone_number}

-

{d.contactPreference}

+

{d.contact_preference}

)} diff --git a/frontend/src/app/staff/_components/party/details/LocationInfoChipDetails.tsx b/frontend/src/app/staff/_components/party/details/LocationInfoChipDetails.tsx index 8628ad7..9d4bcc6 100644 --- a/frontend/src/app/staff/_components/party/details/LocationInfoChipDetails.tsx +++ b/frontend/src/app/staff/_components/party/details/LocationInfoChipDetails.tsx @@ -1,15 +1,16 @@ "use client"; -import { Location } from "@/lib/api/location/location.types"; +import { hasActiveHold } from "@/lib/api/location/location.service"; +import { LocationDto } from "@/lib/api/location/location.types"; import { GenericChipDetails } from "../../shared/sidebar/GenericChipDetails"; interface LocationInfoChipDetailsProps { - data: Location; + data: LocationDto; } function LocationInfoChipDetails({ data }: LocationInfoChipDetailsProps) { return ( - + data={data} title={"Info about the Location"} description={"View information on the Location you just clicked on"} @@ -17,21 +18,21 @@ function LocationInfoChipDetails({ data }: LocationInfoChipDetailsProps) {
-

{d.formattedAddress}

+

{d.formatted_address}

-

{d.warningCount}

+

{d.warning_count}

-

{d.citationCount}

+

{d.citation_count}

- {d.hasActiveHold - ? "Active: Expires " + d.holdExpirationDate?.toDateString() + {hasActiveHold(d.hold_expiration) + ? "Active: Expires " + d.hold_expiration?.toDateString() : "No"}

diff --git a/frontend/src/app/staff/_components/party/details/PartyInfoChipDetails.tsx b/frontend/src/app/staff/_components/party/details/PartyInfoChipDetails.tsx index 20513a0..7793db9 100644 --- a/frontend/src/app/staff/_components/party/details/PartyInfoChipDetails.tsx +++ b/frontend/src/app/staff/_components/party/details/PartyInfoChipDetails.tsx @@ -1,15 +1,15 @@ "use client"; -import { Party } from "@/lib/api/party/party.types"; +import { PartyDto } from "@/lib/api/party/party.types"; import { GenericChipDetails } from "../../shared/sidebar/GenericChipDetails"; interface PartyInfoChipDetailsProps { - data: Party; + data: PartyDto; } export function PartyInfoChipDetails({ data }: PartyInfoChipDetailsProps) { return ( - + data={data} title={"Info about the Party"} description={"View information on the Party you just clicked on"} @@ -17,19 +17,21 @@ export function PartyInfoChipDetails({ data }: PartyInfoChipDetailsProps) {
-

{d.location.formattedAddress}

+

{d.location.formatted_address}

-

{d.datetime.toDateString()}

+

+ {d.party_datetime.toDateString()} +

-

{d.contactOne.firstName}

+

{d.contact_one.first_name}

-

{d.contactOne.lastName}

+

{d.contact_one.last_name}

)} diff --git a/frontend/src/app/staff/_components/party/details/StudentInfoChipDetails.tsx b/frontend/src/app/staff/_components/party/details/StudentInfoChipDetails.tsx index f87e6ce..d661fd2 100644 --- a/frontend/src/app/staff/_components/party/details/StudentInfoChipDetails.tsx +++ b/frontend/src/app/staff/_components/party/details/StudentInfoChipDetails.tsx @@ -1,15 +1,15 @@ "use client"; -import { Student } from "@/lib/api/student/student.types"; +import { StudentDto } from "@/lib/api/student/student.types"; import { GenericChipDetails } from "../../shared/sidebar/GenericChipDetails"; interface StudentInfoChipDetailsProps { - data: Student; + data: StudentDto; } export function StudentInfoChipDetails({ data }: StudentInfoChipDetailsProps) { return ( - + data={data} title={"Info about the Student"} description={"View information on the Student you just clicked on"} @@ -17,21 +17,21 @@ export function StudentInfoChipDetails({ data }: StudentInfoChipDetailsProps) {
-

{d.firstName}

+

{d.first_name}

-

{d.lastName}

+

{d.last_name}

-

{d.phoneNumber}

+

{d.phone_number}

-

{d.contactPreference}

+

{d.contact_preference}

@@ -46,7 +46,7 @@ export function StudentInfoChipDetails({ data }: StudentInfoChipDetailsProps) { Completed Party Smart

- {d.lastRegistered != null ? "Yes" : "Not Registered"} + {d.last_registered != null ? "Yes" : "Not Registered"}

diff --git a/frontend/src/app/staff/_components/shared/TableList.tsx b/frontend/src/app/staff/_components/shared/TableList.tsx index 37108b2..8d2bf42 100644 --- a/frontend/src/app/staff/_components/shared/TableList.tsx +++ b/frontend/src/app/staff/_components/shared/TableList.tsx @@ -1,17 +1,17 @@ "use client"; -import { Location } from "@/lib/api/location/location.types"; -import { Party } from "@/lib/api/party/party.types"; -import { Student } from "@/lib/api/student/student.types"; +import { LocationDto } from "@/lib/api/location/location.types"; +import { PartyDto } from "@/lib/api/party/party.types"; +import { StudentDto } from "@/lib/api/student/student.types"; import { useState } from "react"; interface TableListProps { - parties: Party[]; - students: Student[]; - locations: Location[]; - setFilteredParties: (val: Party[]) => void; - setFilteredStudents: (val: Student[]) => void; - setFilteredLocations: (val: Location[]) => void; + parties: PartyDto[]; + students: StudentDto[]; + locations: LocationDto[]; + setFilteredParties: (val: PartyDto[]) => void; + setFilteredStudents: (val: StudentDto[]) => void; + setFilteredLocations: (val: LocationDto[]) => void; } export default function TableList({ @@ -30,19 +30,19 @@ export default function TableList({ setFilteredParties( parties.filter((p) => - p.location.formattedAddress.toLowerCase().includes(value) + p.location.formatted_address.toLowerCase().includes(value) ) ); setFilteredStudents( students.filter( (s) => - s.firstName.toLowerCase().includes(value) || - s.lastName.toLowerCase().includes(value) || + s.first_name.toLowerCase().includes(value) || + s.last_name.toLowerCase().includes(value) || s.email.toLowerCase().includes(value) ) ); setFilteredLocations( - locations.filter((l) => l.formattedAddress.toLowerCase().includes(value)) + locations.filter((l) => l.formatted_address.toLowerCase().includes(value)) ); }; diff --git a/frontend/src/app/staff/_components/student/StudentTable.tsx b/frontend/src/app/staff/_components/student/StudentTable.tsx index aa799a6..94dbaf3 100644 --- a/frontend/src/app/staff/_components/student/StudentTable.tsx +++ b/frontend/src/app/staff/_components/student/StudentTable.tsx @@ -4,15 +4,13 @@ import { useSidebar } from "@/app/staff/_components/shared/sidebar/SidebarContex import { Checkbox } from "@/components/ui/checkbox"; import { AccountService } from "@/lib/api/account/account.service"; import { AdminStudentService } from "@/lib/api/student/admin-student.service"; -import { - PaginatedStudentsResponse, - Student, -} from "@/lib/api/student/student.types"; +import { StudentDto } from "@/lib/api/student/student.types"; +import { PaginatedResponse } from "@/lib/shared"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { ColumnDef } from "@tanstack/react-table"; import { useMemo, useState } from "react"; import { TableTemplate } from "../shared/table/TableTemplate"; -import StudentTableCreateEditForm from "./StudentTableCreateEdit"; +import StudentTableForm from "./StudentTableForm"; const studentService = new AdminStudentService(); const accountService = new AccountService(); @@ -20,7 +18,7 @@ const accountService = new AccountService(); export const StudentTable = () => { const queryClient = useQueryClient(); const { openSidebar, closeSidebar } = useSidebar(); - const [editingStudent, setEditingStudent] = useState(null); + const [editingStudent, setEditingStudent] = useState(null); const [submissionError, setSubmissionError] = useState(null); // Fetch students @@ -36,8 +34,8 @@ export const StudentTable = () => { .slice() .sort( (a, b) => - a.lastName.localeCompare(b.lastName) || - a.firstName.localeCompare(b.firstName) + a.last_name.localeCompare(b.last_name) || + a.first_name.localeCompare(b.first_name) ), [studentsQuery.data?.items] ); @@ -49,18 +47,18 @@ export const StudentTable = () => { data, }: { id: number; - data: Omit; + data: Omit; }) => studentService.updateStudent(id, data), // Optimistically update the student in the cache so things like the // "Is Registered" checkbox feel instant. onMutate: async ({ id, data }) => { await queryClient.cancelQueries({ queryKey: ["students"] }); - const previous = queryClient.getQueryData([ + const previous = queryClient.getQueryData>([ "students", ]); - queryClient.setQueryData( + queryClient.setQueryData>( ["students"], (old) => old && { @@ -90,7 +88,7 @@ export const StudentTable = () => { // Create student mutation const createStudentMutation = useMutation({ - mutationFn: async ({ data }: { data: Omit }) => { + mutationFn: async ({ data }: { data: Omit }) => { const account = await accountService.createAccount({ role: "student", ...data, @@ -98,15 +96,7 @@ export const StudentTable = () => { studentService.createStudent({ account_id: account.id, - data: { - first_name: data.firstName, - last_name: data.lastName, - phone_number: data.phoneNumber, - contact_preference: data.contactPreference, - last_registered: data.lastRegistered - ? data.lastRegistered.toISOString() - : null, - }, + data, }); }, onError: (error: Error) => { @@ -133,10 +123,10 @@ export const StudentTable = () => { queryClient.setQueryData(["students"], (old: unknown) => { if (!old || typeof old !== "object") return old; - const oldWithItems = old as { items?: Student[] }; + const oldWithItems = old as { items?: StudentDto[] }; if (Array.isArray(oldWithItems.items)) { const paginated = old as { - items: Student[]; + items: StudentDto[]; [key: string]: unknown; }; return { @@ -146,7 +136,7 @@ export const StudentTable = () => { } if (Array.isArray(old)) { - return (old as Student[]).filter((s) => s.id !== id); + return (old as StudentDto[]).filter((s) => s.id !== id); } return old; @@ -165,7 +155,7 @@ export const StudentTable = () => { }, }); - const handleEdit = (student: Student) => { + const handleEdit = (student: StudentDto) => { setEditingStudent(student); setSubmissionError(null); @@ -173,7 +163,7 @@ export const StudentTable = () => { `edit-student-${student.id}`, "Edit Student", "Update student information", - { if (!editingStudent) return; @@ -188,7 +178,7 @@ export const StudentTable = () => { ); }; - const handleDelete = (student: Student) => { + const handleDelete = (student: StudentDto) => { deleteMutation.mutate(student.id); }; @@ -200,7 +190,7 @@ export const StudentTable = () => { "create-student", "New Student", "Add a new student to the system", - { await createStudentMutation.mutateAsync({ data }); @@ -210,19 +200,19 @@ export const StudentTable = () => { ); }; - const columns: ColumnDef[] = [ + const columns: ColumnDef[] = [ { accessorKey: "pid", header: "PID", enableColumnFilter: true, }, { - accessorKey: "firstName", + accessorKey: "first_name", header: "First Name", enableColumnFilter: true, }, { - accessorKey: "lastName", + accessorKey: "last_name", header: "Last Name", enableColumnFilter: true, }, @@ -232,11 +222,11 @@ export const StudentTable = () => { enableColumnFilter: true, }, { - accessorKey: "phoneNumber", + accessorKey: "phone_number", header: "Phone Number", enableColumnFilter: true, cell: ({ row }) => { - const number = row.getValue("phoneNumber") as string; + const number = row.getValue("phone_number") as string; return number ? `(${number.slice(0, 3)}) ${number.slice(3, 6)}-${number.slice( 6, @@ -246,7 +236,7 @@ export const StudentTable = () => { }, }, { - accessorKey: "contactPreference", + accessorKey: "contact_preference", header: "Contact Preference", enableColumnFilter: true, meta: { @@ -255,17 +245,17 @@ export const StudentTable = () => { }, cell: ({ row }) => { const preference = - row.getValue("contactPreference"); + row.getValue("contact_preference"); return preference === "call" ? "Call" : "Text"; }, }, { - accessorKey: "lastRegistered", + accessorKey: "last_registered", header: "Is Registered", enableColumnFilter: false, cell: ({ row }) => { const student = row.original; - const isRegistered = !!student.lastRegistered; + const isRegistered = !!student.last_registered; return ( { id: student.id, data: { ...student, - lastRegistered: checked ? new Date() : null, + last_registered: checked ? new Date() : null, }, }); }} @@ -297,8 +287,8 @@ export const StudentTable = () => { onCreateNew={handleCreate} isLoading={studentsQuery.isLoading} error={studentsQuery.error} - getDeleteDescription={(student: Student) => - `Are you sure you want to delete ${student.firstName} ${student.lastName}? This action cannot be undone.` + getDeleteDescription={(student: StudentDto) => + `Are you sure you want to delete ${student.first_name} ${student.last_name}? This action cannot be undone.` } isDeleting={deleteMutation.isPending} /> diff --git a/frontend/src/app/staff/_components/student/StudentTableCreateEdit.tsx b/frontend/src/app/staff/_components/student/StudentTableForm.tsx similarity index 67% rename from frontend/src/app/staff/_components/student/StudentTableCreateEdit.tsx rename to frontend/src/app/staff/_components/student/StudentTableForm.tsx index 8a89b41..aeeed27 100644 --- a/frontend/src/app/staff/_components/student/StudentTableCreateEdit.tsx +++ b/frontend/src/app/staff/_components/student/StudentTableForm.tsx @@ -27,39 +27,39 @@ import { CalendarIcon } from "lucide-react"; import { useState } from "react"; import * as z from "zod"; -export const StudentCreateEditValues = z.object({ - firstName: z.string().min(1, "First name is required"), - lastName: z.string().min(1, "Second name is required"), +export const studentTableFormSchema = z.object({ + first_name: z.string().min(1, "First name is required"), + last_name: z.string().min(1, "Second name is required"), email: z.email("Please enter a valid email").min(1, "Email is required"), - phoneNumber: z.string().min(1, "Phone number is required"), - contactPreference: z.enum(["call", "text"]), - lastRegistered: z.date().nullable(), + phone_number: z.string().min(1, "Phone number is required"), + contact_preference: z.enum(["call", "text"]), + last_registered: z.date().nullable(), pid: z.string().length(9, "Please input a valid PID"), }); -type StudentCreateEditValues = z.infer; +type StudentTableFormValues = z.infer; -interface StudentRegistrationFormProps { - onSubmit: (data: StudentCreateEditValues) => void | Promise; - editData?: StudentCreateEditValues; +interface StudentTableFormProps { + onSubmit: (data: StudentTableFormValues) => void | Promise; + editData?: StudentTableFormValues; submissionError?: string | null; title?: string; } -export default function StudentTableCreateEditForm({ +export default function StudentTableForm({ onSubmit, editData, submissionError, title, -}: StudentRegistrationFormProps) { - const [formData, setFormData] = useState>({ +}: StudentTableFormProps) { + const [formData, setFormData] = useState>({ pid: editData?.pid ?? "", - firstName: editData?.firstName ?? "", - lastName: editData?.lastName ?? "", + first_name: editData?.first_name ?? "", + last_name: editData?.last_name ?? "", email: editData?.email ?? "", - phoneNumber: editData?.phoneNumber ?? "", - contactPreference: editData?.contactPreference ?? undefined, - lastRegistered: editData?.lastRegistered ?? null, + phone_number: editData?.phone_number ?? "", + contact_preference: editData?.contact_preference ?? undefined, + last_registered: editData?.last_registered ?? null, }); const [errors, setErrors] = useState>({}); const [isSubmitting, setIsSubmitting] = useState(false); @@ -68,7 +68,7 @@ export default function StudentTableCreateEditForm({ e.preventDefault(); setErrors({}); - const result = StudentCreateEditValues.safeParse(formData); + const result = studentTableFormSchema.safeParse(formData); if (!result.success) { const fieldErrors: Record = {}; @@ -89,9 +89,9 @@ export default function StudentTableCreateEditForm({ } }; - const updateField = ( + const updateField = ( field: K, - value: StudentCreateEditValues[K] + value: StudentTableFormValues[K] ) => { setFormData((prev) => ({ ...prev, [field]: value })); if (errors[field]) { @@ -127,28 +127,28 @@ export default function StudentTableCreateEditForm({ /> {errors.pid && {errors.pid}} - + First name updateField("firstName", e.target.value)} - aria-invalid={!!errors.firstName} + value={formData.first_name} + onChange={(e) => updateField("first_name", e.target.value)} + aria-invalid={!!errors.first_name} /> - {errors.firstName && {errors.firstName}} + {errors.first_name && {errors.first_name}} - + Last name updateField("lastName", e.target.value)} - aria-invalid={!!errors.lastName} + value={formData.last_name} + onChange={(e) => updateField("last_name", e.target.value)} + aria-invalid={!!errors.last_name} /> - {errors.lastName && {errors.lastName}} + {errors.last_name && {errors.last_name}} @@ -164,21 +164,21 @@ export default function StudentTableCreateEditForm({ {errors.email && {errors.email}} - + Phone Number updateField("phoneNumber", e.target.value)} - aria-invalid={!!errors.phoneNumber} + value={formData.phone_number} + onChange={(e) => updateField("phone_number", e.target.value)} + aria-invalid={!!errors.phone_number} /> - {errors.phoneNumber && ( - {errors.phoneNumber} + {errors.phone_number && ( + {errors.phone_number} )} - + Last registered @@ -186,11 +186,11 @@ export default function StudentTableCreateEditForm({ id="party-date" variant="outline" className={`w-full justify-start text-left font-normal ${ - !formData.lastRegistered && "text-muted-foreground" + !formData.last_registered && "text-muted-foreground" }`} > - {formData.lastRegistered ? ( - format(formData.lastRegistered, "PPP") + {formData.last_registered ? ( + format(formData.last_registered, "PPP") ) : ( Pick a date )} @@ -200,9 +200,9 @@ export default function StudentTableCreateEditForm({ - updateField("lastRegistered", date ?? null) + updateField("last_registered", date ?? null) } disabled={(date) => isAfter( @@ -213,19 +213,19 @@ export default function StudentTableCreateEditForm({ /> - {errors.lastRegistered && ( - {errors.lastRegistered} + {errors.last_registered && ( + {errors.last_registered} )} - + Contact Preference - {errors.contactPreference && ( - {errors.contactPreference} + {errors.contact_preference && ( + {errors.contact_preference} )} diff --git a/frontend/src/app/student/_components/PartyRegistrationForm.tsx b/frontend/src/app/student/_components/PartyRegistrationForm.tsx index 4948f01..0fa7e86 100644 --- a/frontend/src/app/student/_components/PartyRegistrationForm.tsx +++ b/frontend/src/app/student/_components/PartyRegistrationForm.tsx @@ -29,10 +29,8 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { - AutocompleteResult, - LocationService, -} from "@/lib/api/location/location.service"; +import { LocationService } from "@/lib/api/location/location.service"; +import { AutocompleteResult } from "@/lib/api/location/location.types"; const partyFormSchema = z.object({ address: z.string().min(1, "Address is required"), @@ -138,7 +136,7 @@ export default function PartyRegistrationForm({ /** ⭐ AddressSearch now sets BOTH address + placeId */ const handleAddressSelect = (address: AutocompleteResult | null) => { updateField("address", address?.formatted_address || ""); - setPlaceId(address?.place_id || ""); // ⭐ new required field + setPlaceId(address?.google_place_id || ""); // ⭐ new required field }; return ( @@ -255,7 +253,7 @@ export default function PartyRegistrationForm({ )}
- + Phone Number How should we contact the second contact? - {errors.contactPreference && ( - {errors.contactPreference} + {errors.contact_preference && ( + {errors.contact_preference} )} diff --git a/frontend/src/app/student/_components/RegistrationTracker.tsx b/frontend/src/app/student/_components/RegistrationTracker.tsx index 58c2c5e..6d93e89 100644 --- a/frontend/src/app/student/_components/RegistrationTracker.tsx +++ b/frontend/src/app/student/_components/RegistrationTracker.tsx @@ -1,12 +1,12 @@ "use client"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { Party } from "@/lib/api/party/party.types"; +import { PartyDto } from "@/lib/api/party/party.types"; import { format } from "date-fns"; import { useMemo, useState } from "react"; interface RegistrationTrackerProps { - data: Party[] | undefined; + data: PartyDto[] | undefined; isPending?: boolean; error?: Error | null; } @@ -31,11 +31,11 @@ export default function RegistrationTracker({ const { activeParties, pastParties } = useMemo(() => { const now = new Date(); - const active: Party[] = []; - const past: Party[] = []; + const active: PartyDto[] = []; + const past: PartyDto[] = []; parties.forEach((party) => { - const partyDate = new Date(party.datetime); + const partyDate = new Date(party.party_datetime); const twelveHoursAfterParty = new Date( partyDate.getTime() + 12 * 60 * 60 * 1000 ); @@ -48,23 +48,30 @@ export default function RegistrationTracker({ }); active.sort( - (a, b) => new Date(b.datetime).getTime() - new Date(a.datetime).getTime() + (a, b) => + new Date(b.party_datetime).getTime() - + new Date(a.party_datetime).getTime() ); past.sort( - (a, b) => new Date(b.datetime).getTime() - new Date(a.datetime).getTime() + (a, b) => + new Date(b.party_datetime).getTime() - + new Date(a.party_datetime).getTime() ); return { activeParties: active, pastParties: past }; }, [parties]); - const PartyCard = ({ party }: { party: Party }) => ( + const PartyCard = ({ party }: { party: PartyDto }) => (
{/* Address and Date/Time */}
-
{party.location.formattedAddress}
+
+ {party.location.formatted_address} +
- {format(party.datetime, "PPP")} at {format(party.datetime, "p")} + {format(party.party_datetime, "PPP")} at{" "} + {format(party.party_datetime, "p")}
@@ -75,14 +82,16 @@ export default function RegistrationTracker({
Contact 1:
- {party.contactOne.firstName} {party.contactOne.lastName} + {party.contact_one.first_name} {party.contact_one.last_name}
-
{formatPhoneNumber(party.contactOne.phoneNumber)}
+
{formatPhoneNumber(party.contact_one.phone_number)}
Prefers:{" "} - {party.contactOne.contactPreference - ? party.contactOne.contactPreference.charAt(0).toUpperCase() + - party.contactOne.contactPreference.slice(1).toLowerCase() + {party.contact_one.contact_preference + ? party.contact_one.contact_preference + .charAt(0) + .toUpperCase() + + party.contact_one.contact_preference.slice(1).toLowerCase() : "N/A"}
@@ -93,14 +102,16 @@ export default function RegistrationTracker({
Contact 2:
- {party.contactTwo.firstName} {party.contactTwo.lastName} + {party.contact_two.first_name} {party.contact_two.last_name}
-
{formatPhoneNumber(party.contactTwo.phoneNumber)}
+
{formatPhoneNumber(party.contact_two.phone_number)}
Prefers:{" "} - {party.contactTwo.contactPreference - ? party.contactTwo.contactPreference.charAt(0).toUpperCase() + - party.contactTwo.contactPreference.slice(1).toLowerCase() + {party.contact_two.contact_preference + ? party.contact_two.contact_preference + .charAt(0) + .toUpperCase() + + party.contact_two.contact_preference.slice(1).toLowerCase() : "N/A"}
diff --git a/frontend/src/app/student/_components/StatusComponent.tsx b/frontend/src/app/student/_components/StatusComponent.tsx index 2466a94..3d243f1 100644 --- a/frontend/src/app/student/_components/StatusComponent.tsx +++ b/frontend/src/app/student/_components/StatusComponent.tsx @@ -5,13 +5,13 @@ import { isAfter, isBefore, startOfDay } from "date-fns"; import Image from "next/image"; type CompletionCardProps = { - lastRegistered: Date | null | undefined; + last_registered: Date | null | undefined; isPending?: boolean; error?: Error | null; }; export default function StatusComponent({ - lastRegistered = null, + last_registered = null, isPending = false, error = null, }: CompletionCardProps) { @@ -49,8 +49,8 @@ export default function StatusComponent({ : augustFirst; const isCompleted = - lastRegistered !== null && - isAfter(startOfDay(lastRegistered), startOfDay(mostRecentAugust1)); + last_registered !== null && + isAfter(startOfDay(last_registered), startOfDay(mostRecentAugust1)); return ( @@ -61,7 +61,7 @@ export default function StatusComponent({ check icon Completed on{" "} - {lastRegistered?.toLocaleDateString()} + {last_registered?.toLocaleDateString()}
diff --git a/frontend/src/app/student/_components/StudentInfo.tsx b/frontend/src/app/student/_components/StudentInfo.tsx index c09a7b5..b48f510 100644 --- a/frontend/src/app/student/_components/StudentInfo.tsx +++ b/frontend/src/app/student/_components/StudentInfo.tsx @@ -17,22 +17,22 @@ import { SelectValue, } from "@/components/ui/select"; import { useUpdateStudent } from "@/lib/api/student/student.queries"; -import { StudentDataRequest } from "@/lib/api/student/student.service"; +import { StudentDto } from "@/lib/api/student/student.types"; import { Pencil } from "lucide-react"; import { useState } from "react"; import * as z from "zod"; const studentInfoSchema = z.object({ - firstName: z.string().min(1, "First name is required"), - lastName: z.string().min(1, "Last name is required"), - phoneNumber: z + first_name: z.string().min(1, "First name is required"), + last_name: z.string().min(1, "Last name is required"), + phone_number: z .string() .min(1, "Phone number is required") .refine( (val) => val.replace(/\D/g, "").length >= 10, "Phone number must be at least 10 digits" ), // Ensures phone number is at least 10 digits regardless of format (ex: (123) 456-7890 or 1234567890) - contactPreference: z.enum(["call", "text"], { + contact_preference: z.enum(["call", "text"], { message: "Please select a contact preference", }), }); @@ -41,18 +41,13 @@ const studentInfoSchema = z.object({ type StudentInfoValues = z.infer; interface StudentInfoProps { - initialData?: Partial; + initialData: StudentDto; } export default function StudentInfo({ initialData }: StudentInfoProps) { const updateStudentMutation = useUpdateStudent(); const [isEditing, setIsEditing] = useState(false); - const [formData, setFormData] = useState>({ - firstName: initialData?.firstName || "", - lastName: initialData?.lastName || "", - phoneNumber: initialData?.phoneNumber || "", - contactPreference: initialData?.contactPreference, - }); + const [formData, setFormData] = useState(initialData); const [errors, setErrors] = useState>({}); const [isSubmitting, setIsSubmitting] = useState(false); @@ -77,15 +72,11 @@ export default function StudentInfo({ initialData }: StudentInfoProps) { // Safely handle submission setIsSubmitting(true); try { - // Map form data to API format (camelCase to snake_case) - const apiData: StudentDataRequest = { - first_name: result.data.firstName, - last_name: result.data.lastName, - phone_number: result.data.phoneNumber, - contact_preference: result.data.contactPreference, - }; - - await updateStudentMutation.mutateAsync(apiData); + await updateStudentMutation.mutateAsync({ + phone_number: result.data.phone_number, + contact_preference: result.data.contact_preference, + last_registered: initialData.last_registered, + }); // Update formData with the submitted values to reflect in display setFormData(result.data); @@ -137,11 +128,13 @@ export default function StudentInfo({ initialData }: StudentInfoProps) { }; const displayData = { - firstName: formData.firstName ?? initialData?.firstName ?? "", - lastName: formData.lastName ?? initialData?.lastName ?? "", - phoneNumber: formData.phoneNumber ?? initialData?.phoneNumber ?? "", - contactPreference: - formData.contactPreference ?? initialData?.contactPreference ?? undefined, + first_name: formData.first_name ?? initialData?.first_name ?? "", + last_name: formData.last_name ?? initialData?.last_name ?? "", + phone_number: formData.phone_number ?? initialData?.phone_number ?? "", + contact_preference: + formData.contact_preference ?? + initialData?.contact_preference ?? + undefined, }; if (!isEditing) { @@ -166,7 +159,7 @@ export default function StudentInfo({ initialData }: StudentInfoProps) { First Name
- {displayData.firstName || "Not set"} + {displayData.first_name}

@@ -175,7 +168,7 @@ export default function StudentInfo({ initialData }: StudentInfoProps) { Last Name
- {displayData.lastName || "Not set"} + {displayData.last_name}
@@ -184,7 +177,7 @@ export default function StudentInfo({ initialData }: StudentInfoProps) { Phone Number
- {displayData.phoneNumber || "Not set"} + {displayData.phone_number}
@@ -193,9 +186,9 @@ export default function StudentInfo({ initialData }: StudentInfoProps) { Contact Method
- {displayData.contactPreference - ? displayData.contactPreference.charAt(0).toUpperCase() + - displayData.contactPreference.slice(1) + {displayData.contact_preference + ? displayData.contact_preference.charAt(0).toUpperCase() + + displayData.contact_preference.slice(1) : "Not set"}
@@ -215,7 +208,7 @@ export default function StudentInfo({ initialData }: StudentInfoProps) {
- + updateField("firstName", e.target.value)} - aria-invalid={!!errors.firstName} + value={formData.first_name} + onChange={(e) => updateField("first_name", e.target.value)} + aria-invalid={!!errors.first_name} className="border-gray-300" /> - {errors.firstName && {errors.firstName}} + {errors.first_name && ( + {errors.first_name} + )} - + updateField("lastName", e.target.value)} - aria-invalid={!!errors.lastName} + value={formData.last_name} + onChange={(e) => updateField("last_name", e.target.value)} + aria-invalid={!!errors.last_name} className="border-gray-300" /> - {errors.lastName && {errors.lastName}} + {errors.last_name && {errors.last_name}} - + updateField("phoneNumber", e.target.value)} - aria-invalid={!!errors.phoneNumber} + value={formData.phone_number} + onChange={(e) => updateField("phone_number", e.target.value)} + aria-invalid={!!errors.phone_number} className="border-gray-300" /> - {errors.phoneNumber && ( - {errors.phoneNumber} + {errors.phone_number && ( + {errors.phone_number} )} - + - {errors.contactPreference && ( - {errors.contactPreference} + {errors.contact_preference && ( + {errors.contact_preference} )}
diff --git a/frontend/src/app/student/new-party/page.tsx b/frontend/src/app/student/new-party/page.tsx index f31aa07..6a0e744 100644 --- a/frontend/src/app/student/new-party/page.tsx +++ b/frontend/src/app/student/new-party/page.tsx @@ -3,8 +3,9 @@ import Header from "@/app/student/_components/Header"; import PartyRegistrationForm, { PartyFormValues, } from "@/app/student/_components/PartyRegistrationForm"; -import { useCreateParty } from "@/lib/api/party/party.queries"; import { LocationService } from "@/lib/api/location/location.service"; +import { useCreateParty } from "@/lib/api/party/party.queries"; +import { StudentCreatePartyDto } from "@/lib/api/party/party.types"; import getMockClient from "@/lib/network/mockClient"; import Link from "next/link"; import { useRouter } from "next/navigation"; @@ -13,13 +14,36 @@ export default function RegistrationForm() { const createPartyMutation = useCreateParty(); const router = useRouter(); + const formToData = ( + values: PartyFormValues, + placeId: string + ): StudentCreatePartyDto => { + const [hours, minutes] = values.partyTime.split(":"); + const partyDateTime = new Date(values.partyDate); + partyDateTime.setHours(parseInt(hours, 10), parseInt(minutes, 10), 0, 0); + + return { + type: "student", + party_datetime: partyDateTime, + google_place_id: placeId, + contact_two: { + email: values.contactTwoEmail, + first_name: values.secondContactFirstName, + last_name: values.secondContactLastName, + phone_number: values.phoneNumber, + contact_preference: values.contactPreference, + }, + }; + }; + const handleSubmit = async (values: PartyFormValues, placeId: string) => { try { - await createPartyMutation.mutateAsync({ values, placeId }); + const partyData = formToData(values, placeId); + await createPartyMutation.mutateAsync(partyData); alert("Party created successfully!"); router.push("/student"); } catch (err) { - console.error(err); + console.log(err); alert("Failed to create party"); } }; diff --git a/frontend/src/app/student/page.tsx b/frontend/src/app/student/page.tsx index 1add8a0..9d156d6 100644 --- a/frontend/src/app/student/page.tsx +++ b/frontend/src/app/student/page.tsx @@ -22,7 +22,7 @@ export default function StudentDashboard() {
Events
- + @@ -32,7 +32,7 @@ export default function StudentDashboard() {
Party Smart Course
diff --git a/frontend/src/app/student/profile/page.tsx b/frontend/src/app/student/profile/page.tsx index 2969fd4..85578f1 100644 --- a/frontend/src/app/student/profile/page.tsx +++ b/frontend/src/app/student/profile/page.tsx @@ -37,12 +37,7 @@ export default function StudentProfilePage() {
{student && ( )}
diff --git a/frontend/src/components/AddressSearch.tsx b/frontend/src/components/AddressSearch.tsx index 837ce24..e5af99f 100644 --- a/frontend/src/components/AddressSearch.tsx +++ b/frontend/src/components/AddressSearch.tsx @@ -14,10 +14,8 @@ import { PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; -import { - AutocompleteResult, - LocationService, -} from "@/lib/api/location/location.service"; +import { LocationService } from "@/lib/api/location/location.service"; +import { AutocompleteResult } from "@/lib/api/location/location.types"; import { cn } from "@/lib/utils"; import { CheckIcon, Loader2Icon, MapPinIcon, XIcon } from "lucide-react"; import { useEffect, useRef, useState } from "react"; @@ -122,7 +120,9 @@ export default function AddressSearch({ * Handle address selection from dropdown */ const handleSelect = (currentValue: string) => { - const suggestion = suggestions.find((s) => s.place_id === currentValue); + const suggestion = suggestions.find( + (s) => s.google_place_id === currentValue + ); if (suggestion) { setSelectedAddress(suggestion); @@ -190,7 +190,7 @@ export default function AddressSearch({ case "Enter": e.preventDefault(); if (highlightedIndex >= 0 && highlightedIndex < suggestions.length) { - handleSelect(suggestions[highlightedIndex].place_id); + handleSelect(suggestions[highlightedIndex].google_place_id); } break; case "Escape": @@ -285,8 +285,8 @@ export default function AddressSearch({ {suggestions.map((suggestion, index) => ( { + async listAccounts(roles?: AccountRole[]): Promise { try { - const response = await this.client.post( - "/accounts", - toBackendAccountPayload(payload) - ); - return toFrontendAccount(response.data); + const params = roles ? { role: roles } : {}; + const response = await this.client.get("/accounts/", { + params, + }); + return response.data; } catch (error) { - console.error("Failed to create account:", error); - throw new Error("Failed to create account"); + console.error("Failed to fetch accounts:", error); + throw new Error("Failed to fetch accounts"); } } /** - * Fetches a list of accounts + * Create account (POST /api/accounts) */ - async listAccounts(roles?: AccountRole[]): Promise { + async createAccount(data: AccountData): Promise { try { - const params = roles ? { role: roles } : {}; - const response = await this.client.get("/accounts", { - params, - }); - return response.data.map(toFrontendAccount); + const response = await this.client.post("/accounts/", data); + return response.data; } catch (error) { - console.error("Failed to fetch accounts:", error); - throw new Error("Failed to fetch accounts"); + console.error("Failed to create account:", error); + throw new Error("Failed to create account"); } } /** - * Updates an existing account + * Update account (PUT /api/accounts/{account_id}) */ - async updateAccount( - id: number, - data: AccountCreatePayload - ): Promise { + async updateAccount(accountId: number, data: AccountData): Promise { try { - const response = await this.client.put( - `/accounts/${id}`, - toBackendAccountPayload(data) - ); - return toFrontendAccount(response.data); + const response = await this.client.put(`/accounts/${accountId}`, data); + return response.data; } catch (error) { - console.error(`Failed to update account ${id}:`, error); + console.error(`Failed to update account ${accountId}:`, error); throw new Error("Failed to update account"); } } /** - * Deletes an account + * Delete account (DELETE /api/accounts/{account_id}) */ - async deleteAccount(id: number): Promise { + async deleteAccount(accountId: number): Promise { try { - const response = await this.client.delete( - `/accounts/${id}` - ); - return toFrontendAccount(response.data); + const response = await this.client.delete(`/accounts/${accountId}`); + return response.data; } catch (error) { - console.error(`Failed to delete account ${id}:`, error); + console.error(`Failed to delete account ${accountId}:`, error); throw new Error("Failed to delete account"); } } diff --git a/frontend/src/lib/api/account/account.types.ts b/frontend/src/lib/api/account/account.types.ts index 5b05f89..3a8543b 100644 --- a/frontend/src/lib/api/account/account.types.ts +++ b/frontend/src/lib/api/account/account.types.ts @@ -1,14 +1,29 @@ -type Account = { - id: number; - pid: string; +/** + * Account role types matching backend AccountRole enum + */ +export type AccountRole = "student" | "staff" | "admin"; + +/** + * DTO for creating/updating an Account + */ +type AccountData = { email: string; - firstName: string; - lastName: string; - role: "staff" | "admin" | "student"; + first_name: string; + last_name: string; + pid: string; + role: AccountRole; }; -type PoliceAccount = { +/** + * DTO for Account responses + */ +type AccountDto = { + id: number; email: string; + first_name: string; + last_name: string; + pid: string; + role: AccountRole; }; -export type { Account, PoliceAccount }; +export type { AccountData, AccountDto }; diff --git a/frontend/src/lib/api/location/location.service.ts b/frontend/src/lib/api/location/location.service.ts index c91f3e1..41e48f9 100644 --- a/frontend/src/lib/api/location/location.service.ts +++ b/frontend/src/lib/api/location/location.service.ts @@ -1,85 +1,38 @@ -import { BackendLocation, Location } from "@/lib/api/location/location.types"; import getMockClient from "@/lib/network/mockClient"; +import { PaginatedResponse } from "@/lib/shared"; import { AxiosInstance } from "axios"; - -export interface AutocompleteResult { - formatted_address: string; - place_id: string; -} - -/** - * Place details with coordinates - */ -export interface PlaceDetails { - googlePlaceId: string; - formattedAddress: string; - latitude: number; - longitude: number; - streetNumber: string | null; - streetName: string | null; - unit: string | null; - city: string | null; - county: string | null; - state: string | null; - country: string | null; - zipCode: string | null; -} - -export interface PaginatedLocationResponse { - items: Location[]; - total_records: number; - page_number: number; - page_size: number; - total_pages: number; -} - -export interface LocationCreatePayload { - google_place_id: string; - warning_count: number; - citation_count: number; - hold_expiration: string | null; // ISO date string or null -} - -const defaultClient = getMockClient("admin"); - -export function toFrontendLocation(raw: BackendLocation): Location { - return { - id: raw.id, - citationCount: raw.citation_count, - warningCount: raw.warning_count, - holdExpirationDate: raw.hold_expiration - ? new Date(raw.hold_expiration) - : null, - hasActiveHold: raw.has_active_hold, - googlePlaceId: raw.google_place_id, - formattedAddress: raw.formatted_address, - latitude: raw.latitude, - longitude: raw.longitude, - streetNumber: raw.street_number, - streetName: raw.street_name, - unit: raw.unit, - city: raw.city, - county: raw.county, - state: raw.state, - country: raw.country, - zipCode: raw.zip_code, - }; -} +import { + AddressData, + AutocompleteInput, + AutocompleteResult, + convertLocation, + LocationCreate, + LocationDto, + LocationDtoBackend, +} from "./location.types"; + +export const hasActiveHold = (holdExpiration: Date | null): boolean => { + if (!holdExpiration) return false; + const now = new Date(); + return holdExpiration > now; +}; export class LocationService { - constructor(private client: AxiosInstance = defaultClient) {} + constructor(private client: AxiosInstance = getMockClient("admin")) {} + /** + * Autocomplete address search (POST /api/locations/autocomplete) + */ async autocompleteAddress(inputText: string): Promise { if (!inputText || inputText.trim().length < 3) { return []; } try { + const input: AutocompleteInput = { address: inputText.trim() }; const response = await this.client.post( "/locations/autocomplete", - { - address: inputText.trim(), - } + input ); return response.data; } catch (error) { @@ -89,84 +42,85 @@ export class LocationService { } /** - * Fetches place details including coordinates for a given place ID + * Get place details (GET /api/locations/place-details/{place_id}) */ - async getPlaceDetails(placeId: string): Promise { + async getPlaceDetails(placeId: string): Promise { try { - const response = await this.client.get<{ - google_place_id: string; - formatted_address: string; - latitude: number; - longitude: number; - street_number: string | null; - street_name: string | null; - unit: string | null; - city: string | null; - county: string | null; - state: string | null; - country: string | null; - zip_code: string | null; - }>(`/locations/place-details/${placeId}`); - - // Map snake_case to camelCase - return { - googlePlaceId: response.data.google_place_id, - formattedAddress: response.data.formatted_address, - latitude: response.data.latitude, - longitude: response.data.longitude, - streetNumber: response.data.street_number, - streetName: response.data.street_name, - unit: response.data.unit, - city: response.data.city, - county: response.data.county, - state: response.data.state, - country: response.data.country, - zipCode: response.data.zip_code, - }; + const response = await this.client.get( + `/locations/place-details/${placeId}` + ); + return response.data; } catch (error) { console.error("Failed to fetch place details:", error); throw new Error("Failed to fetch place details"); } } - async getLocations(): Promise { - const response = await this.client.get<{ - items: BackendLocation[]; - total_records: number; - page_number: number; - page_size: number; - total_pages: number; - }>("/locations"); - - return { - ...response.data, - items: response.data.items.map(toFrontendLocation), - }; + /** + * Get locations (GET /api/locations) + */ + async getLocations(): Promise> { + try { + const response = await this.client.get< + PaginatedResponse + >("/locations"); + return { + ...response.data, + items: response.data.items.map(convertLocation), + }; + } catch (error) { + console.error("Failed to fetch locations:", error); + throw new Error("Failed to fetch locations"); + } } - async createLocation(payload: LocationCreatePayload): Promise { - const response = await this.client.post( - "/locations", - payload - ); - return toFrontendLocation(response.data); + /** + * Create location (POST /api/locations) + */ + async createLocation(payload: LocationCreate): Promise { + try { + const response = await this.client.post( + "/locations", + payload + ); + return convertLocation(response.data); + } catch (error) { + console.error("Failed to create location:", error); + throw new Error("Failed to create location"); + } } + /** + * Update location (PUT /api/locations/{location_id}) + */ async updateLocation( id: number, - payload: LocationCreatePayload - ): Promise { - const response = await this.client.put( - `/locations/${id}`, - payload - ); - return toFrontendLocation(response.data); + payload: LocationCreate + ): Promise { + try { + const response = await this.client.put( + `/locations/${id}`, + payload + ); + return convertLocation(response.data); + } catch (error) { + console.error(`Failed to update location ${id}:`, error); + throw new Error("Failed to update location"); + } } - async deleteLocation(id: number): Promise { - const response = await this.client.delete( - `/locations/${id}` - ); - return toFrontendLocation(response.data); + /** + * Delete location (DELETE /api/locations/{location_id}) + */ + async deleteLocation(id: number): Promise { + try { + const response = await this.client.delete( + `/locations/${id}` + ); + return convertLocation(response.data); + } catch (error) { + console.error(`Failed to delete location ${id}:`, error); + throw new Error("Failed to delete location"); + } } } diff --git a/frontend/src/lib/api/location/location.types.ts b/frontend/src/lib/api/location/location.types.ts index 8670a5a..9df1ac3 100644 --- a/frontend/src/lib/api/location/location.types.ts +++ b/frontend/src/lib/api/location/location.types.ts @@ -1,35 +1,22 @@ -type Location = { - id: number; - citationCount: number; - warningCount: number; - holdExpirationDate: Date | null; - hasActiveHold: boolean; - - // Google Maps Data - googlePlaceId: string; - formattedAddress: string; - - // Geography - latitude: number; - longitude: number; +/** + * Input for address autocomplete + */ +type AutocompleteInput = { + address: string; +}; - // Address Components - streetNumber: string | null; - streetName: string | null; - unit: string | null; - city: string | null; - county: string | null; - state: string | null; - country: string | null; - zipCode: string | null; +/** + * Result from Google Maps autocomplete + */ +type AutocompleteResult = { + formatted_address: string; + google_place_id: string; }; -export interface BackendLocation { - id: number; - citation_count: number; - warning_count: number; - hold_expiration: string | null; - has_active_hold: boolean; +/** + * Location data without OCSL-specific fields + */ +type AddressData = { google_place_id: string; formatted_address: string; latitude: number; @@ -42,6 +29,96 @@ export interface BackendLocation { state: string | null; country: string | null; zip_code: string | null; +}; + +/** + * Location data with OCSL-specific fields + */ +type LocationData = AddressData & { + warning_count: number; + citation_count: number; + hold_expiration: Date | null; +}; + +/** + * Complaint DTO + */ +type ComplaintDto = { + id: number; + location_id: number; + complaint_datetime: Date; + description: string; +}; + +/** + * Complaint DTO (backend response format with string dates) + */ +type ComplaintDtoBackend = Omit & { + complaint_datetime: string; +}; + +/** + * Convert complaint from backend format (string dates) to frontend format (Date objects) + */ +function convertComplaint(backend: ComplaintDtoBackend): ComplaintDto { + return { + ...backend, + complaint_datetime: new Date(backend.complaint_datetime), + }; +} + +/** + * Location DTO + */ +type LocationDto = LocationData & { + id: number; + complaints: ComplaintDto[]; +}; + +/** + * Location DTO (backend response format with string dates) + */ +type LocationDtoBackend = Omit< + LocationDto, + "hold_expiration" | "complaints" +> & { + hold_expiration: string | null; + complaints: ComplaintDtoBackend[]; +}; + +/** + * Convert location from backend format (string dates) to frontend format (Date objects) + */ +function convertLocation(backend: LocationDtoBackend): LocationDto { + return { + ...backend, + hold_expiration: backend.hold_expiration + ? new Date(backend.hold_expiration) + : null, + complaints: backend.complaints.map(convertComplaint), + }; } -export type { Location }; +/** + * Input for creating/updating a location + */ +type LocationCreate = { + google_place_id: string; + warning_count?: number; + citation_count?: number; + hold_expiration?: Date | null; +}; + +export type { + AddressData, + AutocompleteInput, + AutocompleteResult, + ComplaintDto, + ComplaintDtoBackend, + LocationCreate, + LocationData, + LocationDto, + LocationDtoBackend, +}; + +export { convertComplaint, convertLocation }; diff --git a/frontend/src/lib/api/party/party.queries.ts b/frontend/src/lib/api/party/party.queries.ts index 762733d..b34a216 100644 --- a/frontend/src/lib/api/party/party.queries.ts +++ b/frontend/src/lib/api/party/party.queries.ts @@ -1,23 +1,19 @@ -import { PartyFormValues } from "@/app/student/_components/PartyRegistrationForm"; import { PartyService } from "@/lib/api/party/party.service"; -import { Party } from "@/lib/api/party/party.types"; +import { CreatePartyDto, PartyDto } from "@/lib/api/party/party.types"; +import getMockClient from "@/lib/network/mockClient"; +import { StringRole } from "@/lib/shared"; import { useMutation, useQueryClient } from "@tanstack/react-query"; -const partyService = new PartyService(); /** * Hook to create a new party registration - */ -export function useCreateParty() { +*/ +export function useCreateParty(role: StringRole = "student") { + const partyService = new PartyService(getMockClient(role)); const queryClient = useQueryClient(); - return useMutation< - Party, - Error, - { values: PartyFormValues; placeId: string } - >({ - mutationFn: ({ values, placeId }) => - partyService.createStudentParty(values, placeId), + return useMutation({ + mutationFn: (data) => partyService.createParty(data), onSuccess: () => { // Invalidate parties list to refetch after creation queryClient.invalidateQueries({ queryKey: ["student", "me", "parties"] }); diff --git a/frontend/src/lib/api/party/party.service.ts b/frontend/src/lib/api/party/party.service.ts index 53d1dbb..58a4d13 100644 --- a/frontend/src/lib/api/party/party.service.ts +++ b/frontend/src/lib/api/party/party.service.ts @@ -1,216 +1,160 @@ -import { PartyFormValues } from "@/app/student/_components/PartyRegistrationForm"; -import { - BackendParty, - Party, - StudentCreatePartyDTO, -} from "@/lib/api/party/party.types"; import getMockClient from "@/lib/network/mockClient"; +import { PaginatedResponse } from "@/lib/shared"; import { AxiosInstance } from "axios"; -import { format } from "date-fns"; -import { toFrontendLocation } from "../location/location.service"; -import { toFrontendStudent } from "../student/admin-student.service"; - -/** - * Transform API party data to frontend format - */ -export function toFrontendParty(backendParty: BackendParty): Party { - return { - id: backendParty.id, - datetime: new Date(backendParty.party_datetime), - location: toFrontendLocation(backendParty.location), - contactOne: toFrontendStudent(backendParty.contact_one), - contactTwo: { - email: backendParty.contact_two.email, - firstName: backendParty.contact_two.first_name, - lastName: backendParty.contact_two.last_name, - phoneNumber: backendParty.contact_two.phone_number, - contactPreference: backendParty.contact_two.contact_preference, - }, - }; -} - -function mapFormToStudentDTO( - values: PartyFormValues, - placeId: string -): StudentCreatePartyDTO { - const date = values.partyDate; // Date object - const [hours, minutes] = values.partyTime.split(":"); - - // Set the time - date.setHours(Number(hours), Number(minutes), 0, 0); - - // produce offset-aware ISO string - const party_datetime = date.toISOString(); - - return { - type: "student", - party_datetime, - place_id: placeId, - contact_two: { - email: values.contactTwoEmail, - first_name: values.secondContactFirstName, - last_name: values.secondContactLastName, - phone_number: values.phoneNumber, - contact_preference: values.contactPreference, - }, - }; -} - -export interface PaginatedPartiesResponse { - items: Party[]; - total_records: number; - page_size: number; - page_number: number; - total_pages: number; -} - -/** - * Payload for creating or updating a party from the admin UI. - * This mirrors the AdminCreatePartyDTO shape on the backend. - */ -export interface BackendAdminPartyPayload { - type: "admin"; - party_datetime: string; // ISO string - place_id: string; - contact_one_email: string; - contact_two: { - email: string; - first_name: string; - last_name: string; - phone_number: string; - contact_preference: "call" | "text"; - }; -} - -export interface AdminPartyPayload { - type: "admin"; - partyDatetime: Date; - placeId: string; - contactOneEmail: string; - contactTwo: { - email: string; - firstName: string; - lastName: string; - phoneNumber: string; - contactPreference: "call" | "text"; - }; -} - -function toBackendPayload( - payload: AdminPartyPayload -): BackendAdminPartyPayload { - return { - type: "admin", - party_datetime: payload.partyDatetime.toISOString(), - place_id: payload.placeId, - contact_one_email: payload.contactOneEmail, - contact_two: { - email: payload.contactTwo.email, - first_name: payload.contactTwo.firstName, - last_name: payload.contactTwo.lastName, - phone_number: payload.contactTwo.phoneNumber, - contact_preference: payload.contactTwo.contactPreference, - }, - }; -} - -const defaultClient = getMockClient("admin"); +import { + AdminCreatePartyDto, + convertParty, + PartyDto, + PartyDtoBackend, + StudentCreatePartyDto, +} from "./party.types"; export class PartyService { - private client: AxiosInstance; - - constructor(client: AxiosInstance = defaultClient) { - this.client = client; - } - - async createStudentParty( - values: PartyFormValues, - placeId: string - ): Promise { - const myClient = getMockClient("student"); - const dto = mapFormToStudentDTO(values, placeId); - - const response = await myClient.post("/parties", dto); - - return toFrontendParty(response.data); + constructor(private client: AxiosInstance = getMockClient("admin")) {} + + /** + * Create party (POST /api/parties) + */ + async createParty( + data: StudentCreatePartyDto | AdminCreatePartyDto + ): Promise { + try { + const response = await this.client.post( + "/parties", + data + ); + return convertParty(response.data); + } catch (error) { + console.error("Failed to create party:", error); + throw new Error("Failed to create party"); + } } + /** + * List parties (GET /api/parties) + */ async listParties( pageNumber?: number, pageSize?: number - ): Promise { - const params: Record = {}; - if (pageNumber !== undefined) params.page_number = pageNumber; - if (pageSize !== undefined) params.page_size = pageSize; - - const response = await this.client.get<{ - items: BackendParty[]; - total_records: number; - page_size: number; - page_number: number; - total_pages: number; - }>("/parties", { params }); - - return { - ...response.data, - items: response.data.items.map(toFrontendParty), - }; - } - - async getParty(id: number): Promise { - const response = await this.client.get(`/parties/${id}`); - return toFrontendParty(response.data); - } - - async createParty(payload: AdminPartyPayload): Promise { - const backendPayload = toBackendPayload(payload); - const response = await this.client.post( - "/parties", - backendPayload - ); - return toFrontendParty(response.data); - } - - async updateParty(id: number, payload: AdminPartyPayload): Promise { - const backendPayload = toBackendPayload(payload); - const response = await this.client.put( - `/parties/${id}`, - backendPayload - ); - return toFrontendParty(response.data); - } - - async deleteParty(id: number): Promise { - const response = await this.client.delete(`/parties/${id}`); - return toFrontendParty(response.data); + ): Promise> { + try { + const params: Record = {}; + if (pageNumber !== undefined) params.page_number = pageNumber; + if (pageSize !== undefined) params.page_size = pageSize; + + const response = await this.client.get< + PaginatedResponse + >("/parties", { params }); + return { + ...response.data, + items: response.data.items.map(convertParty), + }; + } catch (error) { + console.error("Failed to list parties:", error); + throw new Error("Failed to list parties"); + } } - async downloadPartiesCsv(startDate: Date, endDate: Date): Promise { + /** + * Get nearby parties (GET /api/parties/nearby) + */ + async getPartiesNearby( + placeId: string, + startDate: Date, + endDate: Date + ): Promise { try { - const formattedStartDate = format(startDate, "yyyy-MM-dd"); - const formattedEndDate = format(endDate, "yyyy-MM-dd"); - - const response = await this.client.get( - `/parties/csv?start_date=${formattedStartDate}&end_date=${formattedEndDate}`, + const response = await this.client.get( + "/parties/nearby", { - responseType: "blob", + params: { + place_id: placeId, + start_date: startDate.toISOString(), + end_date: endDate.toISOString(), + }, } ); + return response.data.map(convertParty); + } catch (error) { + console.error("Failed to get nearby parties:", error); + throw new Error("Failed to get nearby parties"); + } + } - const blob = new Blob([response.data], { type: "text/csv" }); + /** + * Download parties CSV (GET /api/parties/csv) + */ + async downloadPartiesCsv(startDate: Date, endDate: Date): Promise { + try { + const response = await this.client.get("/parties/csv", { + params: { + start_date: startDate.toISOString(), + end_date: endDate.toISOString(), + }, + responseType: "blob", + }); + const blob = new Blob([response.data], { type: "text/csv" }); const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; - link.download = `parties_${formattedStartDate}_to_${formattedEndDate}.csv`; + link.download = `parties_${startDate}_to_${endDate}.csv`; document.body.appendChild(link); link.click(); - document.body.removeChild(link); URL.revokeObjectURL(url); } catch (error) { console.error("Failed to download parties CSV:", error); - throw new Error("Failed to download CSV. Please try again."); + throw new Error("Failed to download CSV"); + } + } + + /** + * Update party (PUT /api/parties/{party_id}) + */ + async updateParty( + partyId: number, + data: StudentCreatePartyDto | AdminCreatePartyDto + ): Promise { + try { + const response = await this.client.put( + `/parties/${partyId}`, + data + ); + return convertParty(response.data); + } catch (error) { + console.error(`Failed to update party ${partyId}:`, error); + throw new Error("Failed to update party"); + } + } + + /** + * Get party by ID (GET /api/parties/{party_id}) + */ + async getParty(partyId: number): Promise { + try { + const response = await this.client.get( + `/parties/${partyId}` + ); + return convertParty(response.data); + } catch (error) { + console.error(`Failed to get party ${partyId}:`, error); + throw new Error("Failed to get party"); + } + } + + /** + * Delete party (DELETE /api/parties/{party_id}) + */ + async deleteParty(partyId: number): Promise { + try { + const response = await this.client.delete( + `/parties/${partyId}` + ); + return convertParty(response.data); + } catch (error) { + console.error(`Failed to delete party ${partyId}:`, error); + throw new Error("Failed to delete party"); } } } diff --git a/frontend/src/lib/api/party/party.types.ts b/frontend/src/lib/api/party/party.types.ts index 9423d83..9004efe 100644 --- a/frontend/src/lib/api/party/party.types.ts +++ b/frontend/src/lib/api/party/party.types.ts @@ -1,46 +1,93 @@ -import type { BackendLocation, Location } from "../location/location.types"; -import type { - BackendContact, - Contact, - Student, +import { + convertLocation, + LocationDto, + LocationDtoBackend, +} from "../location/location.types"; +import { + ContactPreference, + convertStudent, + StudentDto, + StudentDtoBackend, } from "../student/student.types"; /** - * Party data for frontend use (with camelCase and Date objects) + * Contact information for second contact (no dates to convert) */ -type Party = { - id: number; - datetime: Date; - location: Location; - contactOne: Student; - contactTwo: Contact; +type ContactDto = { + email: string; + first_name: string; + last_name: string; + phone_number: string; + contact_preference: ContactPreference; }; /** - * DTO for creating a party as a student + * Party DTO */ -type StudentCreatePartyDTO = { - type: "student"; - party_datetime: string; // ISO format - place_id: string; - contact_two: BackendContact; +type PartyDto = { + id: number; + party_datetime: Date; + location: LocationDto; + contact_one: StudentDto; + contact_two: ContactDto; }; -type BackendParty = { - id: number; +/** + * Party DTO (backend response format with string dates) + */ +type PartyDtoBackend = Omit< + PartyDto, + "party_datetime" | "location" | "contact_one" +> & { party_datetime: string; - location: BackendLocation; - contact_one: { - id: number; - email: string; - first_name: string; - last_name: string; - phone_number: string; - contact_preference: "call" | "text"; - last_registered: string | null; - pid: string; + location: LocationDtoBackend; + contact_one: StudentDtoBackend; +}; + +/** + * Convert party from backend format (string dates) to frontend format (Date objects) + */ +export function convertParty(backend: PartyDtoBackend): PartyDto { + return { + id: backend.id, + party_datetime: new Date(backend.party_datetime), + location: convertLocation(backend.location), + contact_one: convertStudent(backend.contact_one), + contact_two: backend.contact_two, }; - contact_two: BackendContact; +} + +/** + * Input for students creating a party registration + */ +type StudentCreatePartyDto = { + type: "student"; + party_datetime: Date; + google_place_id: string; + contact_two: ContactDto; }; -export type { BackendParty, Party, StudentCreatePartyDTO }; +/** + * Input for admins creating or updating a party registration + */ +type AdminCreatePartyDto = { + type: "admin"; + party_datetime: Date; + google_place_id: string; + contact_one_email: string; + contact_two: ContactDto; +}; + +/** + * Discriminated union for party creation/update + */ +type CreatePartyDto = StudentCreatePartyDto | AdminCreatePartyDto; + +export type { + AdminCreatePartyDto, + ContactDto, + CreatePartyDto, + PartyDto, + PartyDtoBackend, + StudentCreatePartyDto, +}; diff --git a/frontend/src/lib/api/police/police.service.ts b/frontend/src/lib/api/police/police.service.ts new file mode 100644 index 0000000..54eeed4 --- /dev/null +++ b/frontend/src/lib/api/police/police.service.ts @@ -0,0 +1,52 @@ +import getMockClient from "@/lib/network/mockClient"; +import { AxiosInstance } from "axios"; +import { + convertLocation, + LocationDto, + LocationDtoBackend, +} from "../location/location.types"; + +/** + * Service class for police-related operations + */ +export class PoliceService { + constructor(private client: AxiosInstance = getMockClient("police")) {} + + /** + * Increment location warning count (POST /api/police/locations/{location_id}/warnings) + */ + async incrementWarnings(locationId: number): Promise { + try { + const response = await this.client.post( + `/police/locations/${locationId}/warnings` + ); + return convertLocation(response.data); + } catch (error) { + console.error( + `Failed to increment warnings for location ${locationId}:`, + error + ); + throw new Error("Failed to increment warnings"); + } + } + + /** + * Increment location citation count (POST /api/police/locations/{location_id}/citations) + */ + async incrementCitations(locationId: number): Promise { + try { + const response = await this.client.post( + `/police/locations/${locationId}/citations` + ); + return convertLocation(response.data); + } catch (error) { + console.error( + `Failed to increment citations for location ${locationId}:`, + error + ); + throw new Error("Failed to increment citations"); + } + } +} + +export default PoliceService; diff --git a/frontend/src/lib/api/police/police.types.ts b/frontend/src/lib/api/police/police.types.ts new file mode 100644 index 0000000..0a3a4f9 --- /dev/null +++ b/frontend/src/lib/api/police/police.types.ts @@ -0,0 +1,16 @@ +/** + * DTO for Police Account responses + */ +type PoliceAccountDto = { + email: string; +}; + +/** + * DTO for updating Police credentials + */ +type PoliceAccountUpdate = { + email: string; + password: string; +}; + +export type { PoliceAccountDto, PoliceAccountUpdate }; diff --git a/frontend/src/lib/api/student/admin-student.service.ts b/frontend/src/lib/api/student/admin-student.service.ts index aac2d55..a92828b 100644 --- a/frontend/src/lib/api/student/admin-student.service.ts +++ b/frontend/src/lib/api/student/admin-student.service.ts @@ -1,126 +1,40 @@ -import { Student } from "@/lib/api/student/student.types"; import getMockClient from "@/lib/network/mockClient"; +import { PaginatedResponse } from "@/lib/shared"; import { AxiosInstance } from "axios"; +import { + convertStudent, + StudentCreate, + StudentDataWithNames, + StudentDto, + StudentDtoBackend, +} from "./student.types"; /** - * Paginated response from the API - */ -export interface PaginatedStudentsResponse { - items: Student[]; - total_records: number; - page_size: number; - page_number: number; - total_pages: number; -} - -/** - * Student data for creating a new student - */ -export interface StudentCreatePayload { - account_id: number; - data: { - first_name: string; - last_name: string; - phone_number: string; - contact_preference: "call" | "text"; - last_registered: string | null; // ISO date string - }; -} - -/** - * Student data for updating an existing student - */ -export interface StudentUpdatePayload { - first_name: string; - last_name: string; - phone_number: string; - contact_preference: "call" | "text"; - last_registered: string | null; // ISO date string -} - -/** - * Transform frontend Student to backend format - */ -function toBackendFormat(data: { - firstName: string; - lastName: string; - phoneNumber: string; - contactPreference: "call" | "text"; - lastRegistered: Date | null; -}): StudentUpdatePayload { - return { - first_name: data.firstName, - last_name: data.lastName, - phone_number: data.phoneNumber, - contact_preference: data.contactPreference, - last_registered: data.lastRegistered?.toISOString() || null, - }; -} - -/** - * Backend student response format - */ -export interface BackendStudent { - id: number; - pid: string; - email: string; - first_name: string; - last_name: string; - phone_number: string; - contact_preference: "call" | "text"; - last_registered: string | null; -} - -/** - * Transform backend Student to frontend format - */ -export function toFrontendStudent(data: BackendStudent): Student { - return { - id: data.id, - pid: data.pid, - email: data.email, - firstName: data.first_name, - lastName: data.last_name, - phoneNumber: data.phone_number, - contactPreference: data.contact_preference, - lastRegistered: data.last_registered - ? new Date(data.last_registered) - : null, - }; -} - -/** - * Service class for student-related operations + * Service class for student-related operations (admin) */ export class AdminStudentService { constructor(private client: AxiosInstance = getMockClient("admin")) {} /** - * Fetches a paginated list of students + * Fetches a paginated list of students (GET /api/students) */ async listStudents( pageNumber?: number, pageSize?: number - ): Promise { + ): Promise> { try { const params: Record = {}; if (pageNumber !== undefined) params.page_number = pageNumber; if (pageSize !== undefined) params.page_size = pageSize; - const response = await this.client.get<{ - items: BackendStudent[]; - total_records: number; - page_size: number; - page_number: number; - total_pages: number; - }>("/students", { params }); + const response = await this.client.get>( + "/students", + { params } + ); return { - items: response.data.items.map(toFrontendStudent), - total_records: response.data.total_records, - page_size: response.data.page_size, - page_number: response.data.page_number, - total_pages: response.data.total_pages, + ...response.data, + items: response.data.items.map(convertStudent), }; } catch (error: unknown) { console.error("Failed to fetch students:", error); @@ -136,12 +50,12 @@ export class AdminStudentService { } /** - * Fetches a single student by ID + * Fetches a single student by ID (GET /api/students/{student_id}) */ - async getStudent(id: number): Promise { + async getStudent(id: number): Promise { try { - const response = await this.client.get(`/students/${id}`); - return toFrontendStudent(response.data); + const response = await this.client.get(`/students/${id}`); + return convertStudent(response.data); } catch (error) { console.error(`Failed to fetch student ${id}:`, error); throw new Error("Failed to fetch student"); @@ -149,15 +63,15 @@ export class AdminStudentService { } /** - * Creates a new student + * Creates a new student (POST /api/students) */ - async createStudent(payload: StudentCreatePayload): Promise { + async createStudent(payload: StudentCreate): Promise { try { - const response = await this.client.post( + const response = await this.client.post( "/students", payload ); - return toFrontendStudent(response.data); + return convertStudent(response.data); } catch (error) { console.error("Failed to create student:", error); throw new Error("Failed to create student"); @@ -165,19 +79,18 @@ export class AdminStudentService { } /** - * Updates an existing student + * Updates an existing student (PUT /api/students/{student_id}) */ async updateStudent( id: number, - data: Omit - ): Promise { + data: StudentDataWithNames + ): Promise { try { - const payload = toBackendFormat(data); - const response = await this.client.put( + const response = await this.client.put( `/students/${id}`, - payload + data ); - return toFrontendStudent(response.data); + return convertStudent(response.data); } catch (error) { console.error(`Failed to update student ${id}:`, error); throw new Error("Failed to update student"); @@ -185,14 +98,14 @@ export class AdminStudentService { } /** - * Deletes a student + * Deletes a student (DELETE /api/students/{student_id}) */ - async deleteStudent(id: number): Promise { + async deleteStudent(id: number): Promise { try { - const response = await this.client.delete( + const response = await this.client.delete( `/students/${id}` ); - return toFrontendStudent(response.data); + return convertStudent(response.data); } catch (error) { console.error(`Failed to delete student ${id}:`, error); throw new Error("Failed to delete student"); diff --git a/frontend/src/lib/api/student/student.queries.ts b/frontend/src/lib/api/student/student.queries.ts index 2cac832..8570e1e 100644 --- a/frontend/src/lib/api/student/student.queries.ts +++ b/frontend/src/lib/api/student/student.queries.ts @@ -1,8 +1,6 @@ -import { Party } from "@/lib/api/party/party.types"; -import StudentService, { - StudentDataRequest, -} from "@/lib/api/student/student.service"; -import { Student } from "@/lib/api/student/student.types"; +import { PartyDto } from "@/lib/api/party/party.types"; +import StudentService from "@/lib/api/student/student.service"; +import { StudentData, StudentDto } from "@/lib/api/student/student.types"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; const studentService = new StudentService(); @@ -11,7 +9,7 @@ const studentService = new StudentService(); * Hook to fetch the current authenticated student's information */ export function useCurrentStudent() { - return useQuery({ + return useQuery({ queryKey: ["student", "me"], queryFn: () => studentService.getCurrentStudent(), }); @@ -23,8 +21,8 @@ export function useCurrentStudent() { export function useUpdateStudent() { const queryClient = useQueryClient(); - return useMutation({ - mutationFn: (data: StudentDataRequest) => studentService.updateMe(data), + return useMutation({ + mutationFn: (data: StudentData) => studentService.updateMe(data), onSuccess: (updatedStudent) => { // Invalidate and refetch student data queryClient.setQueryData(["student", "me"], updatedStudent); @@ -37,7 +35,7 @@ export function useUpdateStudent() { * Hook to fetch all parties for the current authenticated student */ export function useMyParties() { - return useQuery({ + return useQuery({ queryKey: ["student", "me", "parties"], queryFn: () => studentService.getMyParties(), }); diff --git a/frontend/src/lib/api/student/student.service.ts b/frontend/src/lib/api/student/student.service.ts index 3cc54dd..03ea90c 100644 --- a/frontend/src/lib/api/student/student.service.ts +++ b/frontend/src/lib/api/student/student.service.ts @@ -1,19 +1,7 @@ -import { BackendParty, Party } from "@/lib/api/party/party.types"; -import { Student } from "@/lib/api/student/student.types"; import getMockClient from "@/lib/network/mockClient"; import { AxiosInstance } from "axios"; -import { toFrontendParty } from "../party/party.service"; -import { BackendStudent, toFrontendStudent } from "./admin-student.service"; - -/** - * Student data for API requests (matches backend StudentData model) - */ -export interface StudentDataRequest { - first_name: string; - last_name: string; - phone_number: string; - contact_preference: "call" | "text"; -} +import { convertParty, PartyDto, PartyDtoBackend } from "../party/party.types"; +import { convertStudent, StudentData, StudentDto, StudentDtoBackend } from "./student.types"; /** * Service class for student-related operations @@ -22,39 +10,42 @@ export class StudentService { constructor(private client: AxiosInstance = getMockClient("student")) {} /** - * Get the current authenticated student's information + * Get current authenticated student (GET /api/students/me) */ - async getCurrentStudent(): Promise { - const response = await this.client.get("/students/me"); - return toFrontendStudent(response.data); + async getCurrentStudent(): Promise { + try { + const response = await this.client.get("/students/me"); + return convertStudent(response.data); + } catch (error) { + console.error("Failed to get current student:", error); + throw new Error("Failed to get current student"); + } } /** - * Update the current authenticated student's information + * Update current authenticated student (PUT /api/students/me) */ - async updateMe(data: StudentDataRequest): Promise { - const response = await this.client.put("/students/me", data); - - // Convert date string to Date object if present - if (response.data.lastRegistered) { - response.data.lastRegistered = new Date(response.data.lastRegistered); + async updateMe(data: StudentData): Promise { + try { + const response = await this.client.put("/students/me", data); + return convertStudent(response.data); + } catch (error) { + console.error("Failed to update student:", error); + throw new Error("Failed to update student"); } - - return response.data; } /** - * Get all parties for the current authenticated student + * Get all parties for current authenticated student (GET /api/students/me/parties) */ - async getMyParties(): Promise { - const response = await this.client.get( - "/students/me/parties" - ); - - // Transform API format to frontend format - const parties = response.data.map(toFrontendParty); - - return parties; + async getMyParties(): Promise { + try { + const response = await this.client.get("/students/me/parties"); + return response.data.map(convertParty); + } catch (error) { + console.error("Failed to get student parties:", error); + throw new Error("Failed to get student parties"); + } } } diff --git a/frontend/src/lib/api/student/student.types.ts b/frontend/src/lib/api/student/student.types.ts index 9b3fb7a..c4107e5 100644 --- a/frontend/src/lib/api/student/student.types.ts +++ b/frontend/src/lib/api/student/student.types.ts @@ -1,47 +1,81 @@ -type Student = { - id: number; - pid: string; - email: string; - firstName: string; - lastName: string; - phoneNumber: string; - contactPreference: "call" | "text"; - lastRegistered: Date | null; +/** + * Contact preference enum matching backend + */ +type ContactPreference = "call" | "text"; + +/** + * Student data without names + */ +type StudentData = { + contact_preference: ContactPreference; + last_registered: Date | null; + phone_number: string; }; /** - * Contact information (API format with snake_case) - * This is what the backend returns and expects + * Student data including names */ -type BackendContact = { +type StudentDataWithNames = StudentData & { + first_name: string; + last_name: string; +}; + +/** + * Student DTO + */ +type StudentDto = { + id: number; + pid: string; email: string; first_name: string; last_name: string; phone_number: string; - contact_preference: "call" | "text"; + contact_preference: ContactPreference; + last_registered: Date | null; }; /** - * Contact information (Frontend format with camelCase) - * This is what the frontend components use + * Student DTO (backend response format with string dates) */ -type Contact = { - email: string; - firstName: string; - lastName: string; - phoneNumber: string; - contactPreference: "call" | "text"; +type StudentDtoBackend = Omit & { + last_registered: string | null; }; /** - * Paginated response from the student list API + * Convert student from backend format (string dates) to frontend format (Date objects) + */ +function convertStudent(backend: StudentDtoBackend): StudentDto { + return { + ...backend, + last_registered: backend.last_registered + ? new Date(backend.last_registered) + : null, + }; +} + +/** + * Request body for creating a student (admin) */ -type PaginatedStudentsResponse = { - items: Student[]; - total_records: number; - page_size: number; - page_number: number; - total_pages: number; +type StudentCreate = { + account_id: number; + data: StudentDataWithNames; +}; + +/** + * Request body for updating student registration status + */ +type IsRegisteredUpdate = { + is_registered: boolean; +}; + +export type { + ContactPreference, + IsRegisteredUpdate, + StudentCreate, + StudentData, + StudentDataWithNames, + StudentDto, + StudentDtoBackend, }; -export type { BackendContact, Contact, PaginatedStudentsResponse, Student }; +export { convertStudent }; diff --git a/frontend/src/lib/mockData.ts b/frontend/src/lib/mockData.ts index 38231ab..92011f3 100644 --- a/frontend/src/lib/mockData.ts +++ b/frontend/src/lib/mockData.ts @@ -1,8 +1,9 @@ import mockData from "@/../shared/mock_data.json"; -import type { Account, PoliceAccount } from "@/lib/api/account/account.types"; -import type { Location } from "@/lib/api/location/location.types"; -import type { Party } from "@/lib/api/party/party.types"; -import type { Contact, Student } from "@/lib/api/student/student.types"; +import type { AccountDto } from "@/lib/api/account/account.types"; +import { LocationDto } from "./api/location/location.types"; +import { ContactDto, PartyDto } from "./api/party/party.types"; +import { PoliceAccountDto } from "./api/police/police.types"; +import { StudentDto } from "./api/student/student.types"; /** * Parses relative date strings like "NOW+7d", "NOW-30d", "NOW+4h", or "NOW-2h" into Date objects @@ -37,64 +38,64 @@ function parseRelativeDate(dateStr: string | null): Date | null { } // Parse Police Account -export const POLICE_ACCOUNT: PoliceAccount = { +export const POLICE_ACCOUNT: PoliceAccountDto = { email: mockData.police.email, }; // Parse Accounts -export const ACCOUNTS: Account[] = mockData.accounts.map((acc) => ({ +export const ACCOUNTS: AccountDto[] = mockData.accounts.map((acc) => ({ id: acc.id, email: acc.email, pid: acc.pid, - firstName: acc.first_name, - lastName: acc.last_name, + first_name: acc.first_name, + last_name: acc.last_name, role: acc.role as "staff" | "admin" | "student", })); // Parse Students -export const STUDENTS: Student[] = mockData.students.map((student) => ({ +export const STUDENTS: StudentDto[] = mockData.students.map((student) => ({ id: student.id, pid: student.pid, email: student.email, - firstName: student.first_name, - lastName: student.last_name, - phoneNumber: student.phone_number, - contactPreference: student.contact_preference as "call" | "text", - lastRegistered: student.last_registered + first_name: student.first_name, + last_name: student.last_name, + phone_number: student.phone_number, + contact_preference: student.contact_preference as "call" | "text", + last_registered: student.last_registered ? parseRelativeDate(student.last_registered) : null, })); // Parse Locations -export const LOCATIONS: Location[] = mockData.locations.map((loc) => ({ +export const LOCATIONS: LocationDto[] = mockData.locations.map((loc) => ({ id: loc.id, - citationCount: loc.citation_count, - warningCount: loc.warning_count, - holdExpirationDate: parseRelativeDate(loc.hold_expiration), - hasActiveHold: !!loc.hold_expiration, - googlePlaceId: loc.google_place_id, - formattedAddress: loc.formatted_address, + citation_count: loc.citation_count, + warning_count: loc.warning_count, + hold_expiration: parseRelativeDate(loc.hold_expiration), + google_place_id: loc.google_place_id, + formatted_address: loc.formatted_address, latitude: loc.latitude, longitude: loc.longitude, - streetNumber: loc.street_number, - streetName: loc.street_name, + street_number: loc.street_number, + street_name: loc.street_name, unit: loc.unit, city: loc.city, county: loc.county, state: loc.state, country: loc.country, - zipCode: loc.zip_code, + zip_code: loc.zip_code, + complaints: [], })); // Helper to find student by ID -function findStudentById(id: number): Student { +function findStudentById(id: number): StudentDto { const student = STUDENTS.find((s) => s.id === id); if (!student) throw new Error(`Student with id ${id} not found`); return student; } // Helper to find location by ID -function findLocationById(id: number): Location { +function findLocationById(id: number): LocationDto { const location = LOCATIONS.find((l) => l.id === id); if (!location) throw new Error(`Location with id ${id} not found`); return location; @@ -103,21 +104,21 @@ function findLocationById(id: number): Location { // Parse contact two objects function parseContactTwo( contactData: (typeof mockData)["parties"][0]["contact_two"] -): Contact { +): ContactDto { return { email: contactData.email, - firstName: contactData.first_name, - lastName: contactData.last_name, - phoneNumber: contactData.phone_number, - contactPreference: contactData.contact_preference as "call" | "text", + first_name: contactData.first_name, + last_name: contactData.last_name, + phone_number: contactData.phone_number, + contact_preference: contactData.contact_preference as "call" | "text", }; } // Parse Parties -export const PARTIES: Party[] = mockData.parties.map((party) => ({ +export const PARTIES: PartyDto[] = mockData.parties.map((party) => ({ id: party.id, - datetime: parseRelativeDate(party.party_datetime) ?? new Date(), + party_datetime: parseRelativeDate(party.party_datetime) ?? new Date(), location: findLocationById(party.location_id), - contactOne: findStudentById(party.contact_one_id), - contactTwo: parseContactTwo(party.contact_two), + contact_one: findStudentById(party.contact_one_id), + contact_two: parseContactTwo(party.contact_two), })); diff --git a/frontend/src/lib/network/mockClient.ts b/frontend/src/lib/network/mockClient.ts index 2b444c2..c6a7829 100644 --- a/frontend/src/lib/network/mockClient.ts +++ b/frontend/src/lib/network/mockClient.ts @@ -1,10 +1,11 @@ import axios from "axios"; +import { StringRole } from "../shared"; /** * Returns a mock API client with preset Authorization header based on user role. * Interfaces with the the mock authentication system in the backend for testing purposes. */ -const getMockClient = (role: "student" | "admin" | "police" | "unauthenticated") => { +const getMockClient = (role: StringRole) => { const mockClient = axios.create({ withCredentials: true, baseURL: `${process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8000"}/api`, diff --git a/frontend/src/lib/network/policeService.ts b/frontend/src/lib/network/policeService.ts deleted file mode 100644 index 07aa148..0000000 --- a/frontend/src/lib/network/policeService.ts +++ /dev/null @@ -1,235 +0,0 @@ -import { Location } from "@/lib/api/location/location.types"; -import { Party } from "@/lib/api/party/party.types"; -import { Student } from "@/lib/api/student/student.types"; -import getMockClient from "./mockClient"; - -const policeClient = getMockClient("police"); - -// Backend response types (snake_case) -interface BackendContact { - email: string; - first_name: string; - last_name: string; - phone_number: string; - contact_preference: "call" | "text"; -} - -interface BackendStudent extends BackendContact { - id: number; - pid: string; - last_registered: string | null; -} - -interface BackendLocation { - id: number; - citation_count: number; - warning_count: number; - hold_expiration_date: string | null; - has_active_hold: boolean; - google_place_id: string; - formatted_address: string; - latitude: number; - longitude: number; - street_number: string | null; - street_name: string | null; - unit: string | null; - city: string | null; - county: string | null; - state: string | null; - country: string | null; - zip_code: string | null; -} - -interface BackendParty { - id: number; - party_datetime: string; - location_id: number; - contact_one_id: number; - contact_two_id: number; -} - -interface BackendPartyExpanded { - id: number; - party_datetime: string; - location: BackendLocation; - contact_one: BackendStudent; - contact_two: BackendStudent; -} - -interface PaginatedResponse { - items: T[]; - total_records: number; - page_size: number; - page_number: number; - total_pages: number; -} - -// Mapper functions -const mapBackendStudent = (backendStudent: BackendStudent): Student => ({ - id: backendStudent.id, - pid: backendStudent.pid, - email: backendStudent.email, - firstName: backendStudent.first_name, - lastName: backendStudent.last_name, - phoneNumber: backendStudent.phone_number, - contactPreference: backendStudent.contact_preference, - lastRegistered: backendStudent.last_registered - ? new Date(backendStudent.last_registered) - : null, -}); - -const mapBackendLocation = (backendLocation: BackendLocation): Location => ({ - id: backendLocation.id, - citationCount: backendLocation.citation_count, - warningCount: backendLocation.warning_count, - holdExpirationDate: backendLocation.hold_expiration_date - ? new Date(backendLocation.hold_expiration_date) - : null, - hasActiveHold: backendLocation.has_active_hold, - googlePlaceId: backendLocation.google_place_id, - formattedAddress: backendLocation.formatted_address, - latitude: backendLocation.latitude, - longitude: backendLocation.longitude, - streetNumber: backendLocation.street_number, - streetName: backendLocation.street_name, - unit: backendLocation.unit, - city: backendLocation.city, - county: backendLocation.county, - state: backendLocation.state, - country: backendLocation.country, - zipCode: backendLocation.zip_code, -}); - -const mapBackendParty = ( - backendParty: BackendParty | BackendPartyExpanded -): Party => { - // Check if the party has expanded nested objects or just IDs - const hasExpandedData = - typeof (backendParty as BackendPartyExpanded).location === "object"; - - if (hasExpandedData) { - const expanded = backendParty as BackendPartyExpanded; - return { - id: expanded.id, - datetime: new Date(expanded.party_datetime), - location: mapBackendLocation(expanded.location), - contactOne: mapBackendStudent(expanded.contact_one), - contactTwo: mapBackendStudent(expanded.contact_two), - }; - } else { - // Backend only returned IDs - we need to create placeholder objects - // This shouldn't happen in production, but handle gracefully - const simple = backendParty as BackendParty; - console.warn( - "Backend returned Party with IDs only, not expanded objects:", - simple - ); - - // Create minimal placeholder objects - return { - id: simple.id, - datetime: new Date(simple.party_datetime), - location: { - id: simple.location_id, - citationCount: 0, - warningCount: 0, - holdExpirationDate: null, - hasActiveHold: false, - googlePlaceId: "", - formattedAddress: `Location ID: ${simple.location_id}`, - latitude: 0, - longitude: 0, - streetNumber: null, - streetName: null, - unit: null, - city: null, - county: null, - state: null, - country: null, - zipCode: null, - }, - contactOne: { - id: simple.contact_one_id, - pid: "", - email: "", - firstName: "Contact", - lastName: `${simple.contact_one_id}`, - phoneNumber: "", - contactPreference: "text", - lastRegistered: null, - }, - contactTwo: { - email: "", - firstName: "Contact", - lastName: `${simple.contact_two_id}`, - phoneNumber: "", - contactPreference: "text", - }, - }; - } -}; - -// Service functions -export const policeService = { - /** - * Get all parties with optional date range filtering - */ - async getParties(startDate?: Date, endDate?: Date): Promise { - const params: Record = {}; - - if (startDate) { - params.start_date = startDate.toISOString(); - } - - if (endDate) { - params.end_date = endDate.toISOString(); - } - - const response = await policeClient.get< - PaginatedResponse - >("/parties", { - params, - }); - - // Extract items from paginated response - const parties = response.data.items || []; - - // Filter out any null/undefined items and map - return parties.filter((party) => party != null).map(mapBackendParty); - }, - /** - * Get parties near a specific location within 0.5 mile radius - */ - async getPartiesNearby( - placeId: string, - startDate: Date, - endDate: Date - ): Promise { - // Backend expects YYYY-MM-DD format - const formatDate = (date: Date) => date.toISOString().split("T")[0]; - - const response = await policeClient.get("/parties/nearby", { - params: { - place_id: placeId, - start_date: formatDate(startDate), - end_date: formatDate(endDate), - }, - }); - - return response.data.map(mapBackendParty); - }, - - /** - * Issue a warning to a location - */ - async issueWarning(locationId: number): Promise { - await policeClient.post(`/locations/${locationId}/warnings`); - }, - - /** - * Issue a citation to a location - */ - async issueCitation(locationId: number): Promise { - await policeClient.post(`/locations/${locationId}/citations`); - }, -}; diff --git a/frontend/src/lib/shared.ts b/frontend/src/lib/shared.ts new file mode 100644 index 0000000..f6812af --- /dev/null +++ b/frontend/src/lib/shared.ts @@ -0,0 +1,11 @@ +type PaginatedResponse = { + items: T[]; + total_records: number; + page_number: number; + page_size: number; + total_pages: number; +}; + +type StringRole = "staff" | "admin" | "student" | "police" | "unauthenticated"; + +export type { PaginatedResponse, StringRole };