Improve CarPass product UX and service flows

This commit is contained in:
VPN SaaS Dev
2026-05-14 19:33:25 +09:00
parent b85db333d8
commit caa5f6d3db
36 changed files with 1836 additions and 366 deletions

View File

@@ -1,5 +1,7 @@
from datetime import UTC, datetime
from fastapi import APIRouter, Depends, Header, HTTPException, status
from sqlalchemy import select
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import (
@@ -14,6 +16,8 @@ from app.models.car import (
Car,
CarServiceLink,
ServiceCenter,
ServiceCenterReview,
ServiceCenterReviewComment,
ServiceCenterVerification,
ServiceEmployee,
ServiceInboxMessage,
@@ -23,8 +27,14 @@ from app.models.user import User
from app.schemas.service_center import (
CarServiceLinkCreate,
CarServiceLinkRead,
ServiceCenterAccessRequest,
ServiceCenterCreate,
ServiceCenterPublicRead,
ServiceCenterRead,
ServiceCenterReviewCommentCreate,
ServiceCenterReviewCommentRead,
ServiceCenterReviewCreate,
ServiceCenterReviewRead,
ServiceCenterVerificationCreate,
ServiceCenterVerificationRead,
ServiceEmployeeInvite,
@@ -40,6 +50,8 @@ from app.services.vehicle_identity import mask_license_plate, mask_vin
router = APIRouter(prefix="/service-centers", tags=["service-centers"])
APPROVED_SERVICE_STATUSES = {"approved", "verified"}
@router.post("", response_model=ServiceCenterRead, status_code=status.HTTP_201_CREATED)
async def create_service_center(
payload: ServiceCenterCreate,
@@ -57,6 +69,13 @@ async def create_service_center(
contact_phone=payload.contact_phone or payload.phone,
telegram_chat_id=payload.telegram_chat_id,
business_registration_number=payload.business_registration_number,
description=payload.description,
specializations=payload.specializations,
working_hours=payload.working_hours,
facade_photo_url=payload.facade_photo_url,
document_photo_urls=payload.document_photo_urls,
additional_photo_urls=payload.additional_photo_urls,
contact_person=payload.contact_person,
owner_user_id=current_user.id,
verification_status="pending",
)
@@ -89,6 +108,51 @@ async def my_service_centers(
return list(result.scalars())
@router.get("/public", response_model=list[ServiceCenterPublicRead])
async def public_service_centers(
city: str | None = None,
specialization: str | None = None,
limit: int = 50,
offset: int = 0,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> list[ServiceCenter]:
limit = min(max(limit, 1), 200)
stmt = select(ServiceCenter).where(ServiceCenter.verification_status.in_(APPROVED_SERVICE_STATUSES))
if city:
stmt = stmt.where(ServiceCenter.city.ilike(f"%{city}%"))
result = await session.execute(
stmt.order_by(ServiceCenter.rating_avg.desc().nullslast(), ServiceCenter.display_name.asc())
.limit(limit)
.offset(max(offset, 0))
)
centers = list(result.scalars())
if specialization:
needle = specialization.lower()
centers = [
center
for center in centers
if any(needle in item.lower() for item in (center.specializations or []))
]
return centers
@router.get("/{service_center_id}", response_model=ServiceCenterPublicRead)
async def get_public_service_center(
service_center_id: int,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> ServiceCenter:
center = await session.get(ServiceCenter, service_center_id)
if center is None:
raise HTTPException(status_code=404, detail="Service center not found")
if center.verification_status not in APPROVED_SERVICE_STATUSES:
is_employee = await service_employee_or_none(session, service_center_id, current_user)
if not is_employee and center.owner_user_id != current_user.id:
raise HTTPException(status_code=404, detail="Service center not found")
return center
@router.get("", response_model=list[ServiceCenterRead])
async def list_service_centers(
session: AsyncSession = Depends(get_session),
@@ -173,6 +237,45 @@ async def service_center_visits(
return list(result.scalars())
async def service_employee_or_none(
session: AsyncSession, service_center_id: int, user: User
) -> ServiceEmployee | None:
result = await session.execute(
select(ServiceEmployee).where(
ServiceEmployee.service_center_id == service_center_id,
ServiceEmployee.user_id == user.id,
ServiceEmployee.status == "active",
)
)
return result.scalar_one_or_none()
async def ensure_service_center_approved(session: AsyncSession, service_center_id: int) -> ServiceCenter:
center = await session.get(ServiceCenter, service_center_id)
if center is None:
raise HTTPException(status_code=404, detail="Service center not found")
if center.verification_status not in APPROVED_SERVICE_STATUSES:
raise HTTPException(status_code=403, detail="Service center is awaiting approval")
return center
async def ensure_center_vehicle_access(
session: AsyncSession, service_center_id: int, vehicle: Car, user: User
) -> None:
if vehicle.owner_id == user.id:
return
result = await session.execute(
select(CarServiceLink).where(
CarServiceLink.car_id == vehicle.id,
CarServiceLink.service_center_id == service_center_id,
CarServiceLink.status == "approved",
CarServiceLink.is_active.is_(True),
)
)
if result.scalar_one_or_none() is None:
raise HTTPException(status_code=403, detail="Vehicle access is not confirmed by owner")
@router.post("/{service_center_id}/visits", response_model=ServiceVisitRead, status_code=status.HTTP_201_CREATED)
async def create_visit(
service_center_id: int,
@@ -184,9 +287,8 @@ async def create_visit(
vehicle = await session.get(Car, payload.vehicle_id)
if vehicle is None:
raise HTTPException(status_code=404, detail="Vehicle not found")
center = await session.get(ServiceCenter, service_center_id)
if center and center.verification_status not in {"verified", "pending"}:
raise HTTPException(status_code=403, detail="Service center is not allowed to create visits")
await ensure_service_center_approved(session, service_center_id)
await ensure_center_vehicle_access(session, service_center_id, vehicle, current_user)
visit = ServiceVisit(
service_center_id=service_center_id,
vehicle_id=payload.vehicle_id,
@@ -213,6 +315,7 @@ async def request_vehicle_access(
current_user: User = Depends(get_current_telegram_user),
) -> VehicleSearchResult:
await ensure_service_employee(session, service_center_id, current_user, {"owner", "manager", "receptionist"})
await ensure_service_center_approved(session, service_center_id)
stmt = select(Car)
if payload.vin:
stmt = stmt.where(Car.vin_normalized == payload.vin)
@@ -231,6 +334,17 @@ async def request_vehicle_access(
target_id=vehicle.id if vehicle else None,
metadata={"service_center_id": service_center_id, "found": bool(vehicle)},
)
link = None
if vehicle is not None:
link = await upsert_service_link(
session,
car_id=vehicle.id,
service_center_id=service_center_id,
requested_by_user_id=current_user.id,
access_level="basic",
external_vehicle_ref=None,
status_value="pending",
)
await session.commit()
if vehicle is None:
return VehicleSearchResult(access_status="not_found")
@@ -241,10 +355,242 @@ async def request_vehicle_access(
year=vehicle.year,
masked_license_plate=mask_license_plate(vehicle.license_plate_display or vehicle.plate_number),
masked_vin=mask_vin(vehicle.vin_normalized or vehicle.vin),
access_status="request_logged",
access_status="pending_owner_confirmation" if link else "request_logged",
)
@router.post("/{service_center_id}/vehicle-links/request", response_model=CarServiceLinkRead)
async def request_vehicle_link(
service_center_id: int,
payload: ServiceCenterAccessRequest,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> CarServiceLink:
await ensure_service_employee(session, service_center_id, current_user, {"owner", "manager", "receptionist"})
await ensure_service_center_approved(session, service_center_id)
vehicle = await session.get(Car, payload.car_id)
if vehicle is None:
raise HTTPException(status_code=404, detail="Vehicle not found")
link = await upsert_service_link(
session,
car_id=payload.car_id,
service_center_id=service_center_id,
requested_by_user_id=current_user.id,
access_level=payload.access_level,
external_vehicle_ref=payload.external_vehicle_ref,
status_value="pending",
)
await log_audit(
session,
actor=current_user,
action="car_service_link.request",
target_type="car_service_link",
target_id=link.id,
metadata={"car_id": payload.car_id, "service_center_id": service_center_id},
)
await session.commit()
await session.refresh(link)
return link
@router.post("/links/{link_id}/approve", response_model=CarServiceLinkRead)
async def approve_vehicle_link(
link_id: int,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> CarServiceLink:
link = await session.get(CarServiceLink, link_id)
if link is None:
raise HTTPException(status_code=404, detail="Vehicle link not found")
vehicle = await session.get(Car, link.car_id)
if vehicle is None:
raise HTTPException(status_code=404, detail="Vehicle not found")
if vehicle.owner_id != current_user.id:
raise HTTPException(status_code=403, detail="Forbidden")
await ensure_service_center_approved(session, link.service_center_id)
link.status = "approved"
link.is_active = True
link.approved_by_user_id = current_user.id
link.approved_at = datetime.now(UTC)
link.revoked_at = None
await log_audit(session, actor=current_user, action="car_service_link.approve", target_type="car_service_link", target_id=link.id)
await session.commit()
await session.refresh(link)
return link
@router.post("/links/{link_id}/revoke", response_model=CarServiceLinkRead)
async def revoke_vehicle_link(
link_id: int,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> CarServiceLink:
link = await session.get(CarServiceLink, link_id)
if link is None:
raise HTTPException(status_code=404, detail="Vehicle link not found")
vehicle = await session.get(Car, link.car_id)
if vehicle is None:
raise HTTPException(status_code=404, detail="Vehicle not found")
if vehicle.owner_id != current_user.id:
raise HTTPException(status_code=403, detail="Forbidden")
link.status = "revoked"
link.is_active = False
link.revoked_at = datetime.now(UTC)
await log_audit(session, actor=current_user, action="car_service_link.revoke", target_type="car_service_link", target_id=link.id)
await session.commit()
await session.refresh(link)
return link
async def upsert_service_link(
session: AsyncSession,
*,
car_id: int,
service_center_id: int,
requested_by_user_id: int | None,
access_level: str,
external_vehicle_ref: str | None,
status_value: str,
) -> CarServiceLink:
result = await session.execute(
select(CarServiceLink).where(
CarServiceLink.car_id == car_id,
CarServiceLink.service_center_id == service_center_id,
)
)
link = result.scalar_one_or_none()
if link is None:
link = CarServiceLink(
car_id=car_id,
service_center_id=service_center_id,
requested_by_user_id=requested_by_user_id,
access_level=access_level,
external_vehicle_ref=external_vehicle_ref,
status=status_value,
is_active=status_value == "approved",
)
session.add(link)
await session.flush()
else:
link.requested_by_user_id = requested_by_user_id
link.access_level = access_level
link.external_vehicle_ref = external_vehicle_ref or link.external_vehicle_ref
link.status = status_value
link.is_active = status_value == "approved"
return link
@router.get("/{service_center_id}/reviews", response_model=list[ServiceCenterReviewRead])
async def service_center_reviews(
service_center_id: int,
sort: str = "new",
limit: int = 50,
offset: int = 0,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> list[ServiceCenterReview]:
await get_public_service_center(service_center_id, session, current_user)
limit = min(max(limit, 1), 200)
stmt = select(ServiceCenterReview).where(
ServiceCenterReview.service_center_id == service_center_id,
ServiceCenterReview.status == "published",
)
if sort == "high":
stmt = stmt.order_by(ServiceCenterReview.rating.desc(), ServiceCenterReview.created_at.desc())
elif sort == "low":
stmt = stmt.order_by(ServiceCenterReview.rating.asc(), ServiceCenterReview.created_at.desc())
else:
stmt = stmt.order_by(ServiceCenterReview.created_at.desc())
result = await session.execute(stmt.limit(limit).offset(max(offset, 0)))
return list(result.scalars())
@router.post("/{service_center_id}/reviews", response_model=ServiceCenterReviewRead, status_code=status.HTTP_201_CREATED)
async def create_service_center_review(
service_center_id: int,
payload: ServiceCenterReviewCreate,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> ServiceCenterReview:
await ensure_service_center_approved(session, service_center_id)
result = await session.execute(
select(ServiceCenterReview).where(
ServiceCenterReview.service_center_id == service_center_id,
ServiceCenterReview.user_id == current_user.id,
)
)
review = result.scalar_one_or_none()
if review is None:
review = ServiceCenterReview(
service_center_id=service_center_id,
user_id=current_user.id,
**payload.model_dump(),
)
session.add(review)
else:
review.rating = payload.rating
review.text = payload.text
review.photo_urls = payload.photo_urls
review.status = "published"
await log_audit(session, actor=current_user, action="service_review.upsert", target_type="service_center", target_id=service_center_id)
await session.flush()
await refresh_service_rating(session, service_center_id)
await session.commit()
await session.refresh(review)
return review
@router.post("/reviews/{review_id}/comments", response_model=ServiceCenterReviewCommentRead, status_code=status.HTTP_201_CREATED)
async def create_review_comment(
review_id: int,
payload: ServiceCenterReviewCommentCreate,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> ServiceCenterReviewComment:
review = await session.get(ServiceCenterReview, review_id)
if review is None or review.status != "published":
raise HTTPException(status_code=404, detail="Review not found")
comment = ServiceCenterReviewComment(review_id=review_id, user_id=current_user.id, text=payload.text)
session.add(comment)
await log_audit(session, actor=current_user, action="service_review.comment", target_type="service_review", target_id=review_id)
await session.commit()
await session.refresh(comment)
return comment
@router.post("/reviews/{review_id}/respond", response_model=ServiceCenterReviewRead)
async def respond_to_review(
review_id: int,
payload: ServiceCenterReviewCommentCreate,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> ServiceCenterReview:
review = await session.get(ServiceCenterReview, review_id)
if review is None:
raise HTTPException(status_code=404, detail="Review not found")
await ensure_service_employee(session, review.service_center_id, current_user, {"owner", "manager"})
review.service_response = payload.text
review.service_responded_at = datetime.now(UTC)
await log_audit(session, actor=current_user, action="service_review.respond", target_type="service_review", target_id=review_id)
await session.commit()
await session.refresh(review)
return review
async def refresh_service_rating(session: AsyncSession, service_center_id: int) -> None:
result = await session.execute(
select(func.avg(ServiceCenterReview.rating), func.count(ServiceCenterReview.id)).where(
ServiceCenterReview.service_center_id == service_center_id,
ServiceCenterReview.status == "published",
)
)
avg_rating, count = result.one()
center = await session.get(ServiceCenter, service_center_id)
if center is not None:
center.rating_avg = round(avg_rating, 2) if avg_rating is not None else None
center.reviews_count = int(count or 0)
@router.post("/links", response_model=CarServiceLinkRead, status_code=status.HTTP_201_CREATED)
async def link_car_to_service(
payload: CarServiceLinkCreate,
@@ -256,7 +602,7 @@ async def link_car_to_service(
raise HTTPException(status_code=404, detail="Car not found")
if await session.get(ServiceCenter, payload.service_center_id) is None:
raise HTTPException(status_code=404, detail="Service center not found")
link = CarServiceLink(**payload.model_dump())
link = CarServiceLink(**payload.model_dump(), status="approved", approved_at=datetime.now(UTC))
session.add(link)
await session.commit()
await session.refresh(link)