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) {
First Name
-
{d.firstName}
+
{d.first_name}
Last Name
-
{d.lastName}
+
{d.last_name}
Email
@@ -29,13 +29,13 @@ export function ContactInfoChipDetails({ data }: ContactInfoChipDetailsProps) {
Phone Number
-
{d.phoneNumber}
+
{d.phone_number}
Contact Preference
-
{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) {
Address
-
{d.formattedAddress}
+
{d.formatted_address}
Warning Count
-
{d.warningCount}
+
{d.warning_count}
Citation Count
-
{d.citationCount}
+
{d.citation_count}
Active Hold
- {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) {
Address
-
{d.location.formattedAddress}
+
{d.location.formatted_address}
Date
-
{d.datetime.toDateString()}
+
+ {d.party_datetime.toDateString()}
+
First name
-
{d.contactOne.firstName}
+
{d.contact_one.first_name}
Last 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) {
First Name
-
{d.firstName}
+
{d.first_name}
Last Name
-
{d.lastName}
+
{d.last_name}
Phone Number
-
{d.phoneNumber}
+
{d.phone_number}
Contact Preference
-
{d.contactPreference}
+
{d.contact_preference}
PID
@@ -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
- updateField("contactPreference", value as "call" | "text")
+ updateField("contact_preference", value as "call" | "text")
}
>
@@ -236,8 +236,8 @@ export default function StudentTableCreateEditForm({
Text
- {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({
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}
)}
-
+
- updateField("contactPreference", value)
+ updateField("contact_preference", value)
}
>
Text
- {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
-
+
Registration Form
@@ -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 };