Secure backlink lead status updates

This commit is contained in:
ي
2026-06-03 18:16:10 +05:30
parent 923fa671fe
commit 40516e5c79
5 changed files with 119 additions and 7 deletions

View File

@@ -192,18 +192,48 @@ async def bulk_update_lead_status(
payload: BulkStatusUpdateRequest, payload: BulkStatusUpdateRequest,
current_user: Dict[str, Any] = Depends(get_current_user), current_user: Dict[str, Any] = Depends(get_current_user),
): ):
"""Bulk update lead statuses.""" """Bulk update lead statuses for leads owned by the current user."""
user_id = _resolve_user_id(current_user) user_id = _resolve_user_id(current_user)
storage = BacklinkOutreachStorageService() storage = BacklinkOutreachStorageService()
access_issues = storage.get_lead_access_issues(
payload.lead_ids, user_id, campaign_id=payload.campaign_id
)
if access_issues["unauthorized"]:
raise HTTPException(
status_code=403,
detail={
"message": "One or more leads do not belong to the current user",
"lead_ids": access_issues["unauthorized"],
},
)
if access_issues["missing"]:
raise HTTPException(
status_code=404,
detail={
"message": "One or more leads were not found",
"lead_ids": access_issues["missing"],
},
)
updated = 0 updated = 0
failed: list[str] = [] failed: list[str] = []
for lid in payload.lead_ids: for lid in payload.lead_ids:
try: try:
lead = storage.update_lead_status(lid, user_id, payload.status, payload.notes) lead = storage.update_lead_status(
lid,
user_id,
payload.status,
payload.notes,
campaign_id=payload.campaign_id,
)
if lead: if lead:
updated += 1 updated += 1
else: else:
failed.append(lid) failed.append(lid)
except PermissionError:
raise HTTPException(
status_code=403, detail="Lead does not belong to the current user"
)
except Exception: except Exception:
failed.append(lid) failed.append(lid)
return BulkStatusUpdateResponse(updated=updated, failed=failed) return BulkStatusUpdateResponse(updated=updated, failed=failed)
@@ -218,7 +248,18 @@ async def update_lead_status(
"""Update lead status (discovered -> contacted -> replied -> placed).""" """Update lead status (discovered -> contacted -> replied -> placed)."""
user_id = _resolve_user_id(current_user) user_id = _resolve_user_id(current_user)
storage = BacklinkOutreachStorageService() storage = BacklinkOutreachStorageService()
lead = storage.update_lead_status(lead_id, user_id, payload.status, payload.notes) try:
lead = storage.update_lead_status(
lead_id,
user_id,
payload.status,
payload.notes,
campaign_id=payload.campaign_id,
)
except PermissionError:
raise HTTPException(
status_code=403, detail="Lead does not belong to the current user"
)
if not lead: if not lead:
raise HTTPException(status_code=404, detail="Lead not found") raise HTTPException(status_code=404, detail="Lead not found")
return lead return lead

View File

@@ -95,6 +95,7 @@ class LeadListResponse(BaseModel):
class LeadStatusUpdateRequest(BaseModel): class LeadStatusUpdateRequest(BaseModel):
status: str = Field(..., min_length=1) status: str = Field(..., min_length=1)
notes: Optional[str] = None notes: Optional[str] = None
campaign_id: Optional[str] = Field(default=None, min_length=1)
class CampaignDetailResponse(BaseModel): class CampaignDetailResponse(BaseModel):
@@ -298,6 +299,7 @@ class BulkStatusUpdateRequest(BaseModel):
lead_ids: List[str] = Field(..., min_length=1) lead_ids: List[str] = Field(..., min_length=1)
status: str = Field(..., min_length=1) status: str = Field(..., min_length=1)
notes: Optional[str] = None notes: Optional[str] = None
campaign_id: Optional[str] = Field(default=None, min_length=1)
class BulkStatusUpdateResponse(BaseModel): class BulkStatusUpdateResponse(BaseModel):

View File

@@ -204,16 +204,38 @@ class BacklinkOutreachStorageService:
db.close() db.close()
def update_lead_status( def update_lead_status(
self, lead_id: str, user_id: str, status: str, notes: Optional[str] = None self,
lead_id: str,
user_id: str,
status: str,
notes: Optional[str] = None,
campaign_id: Optional[str] = None,
) -> Optional[dict]: ) -> Optional[dict]:
self._ensure_tables(user_id) self._ensure_tables(user_id)
db = get_session_for_user(user_id) db = get_session_for_user(user_id)
if not db: if not db:
return None return None
try: try:
lead = db.query(BacklinkLead).filter(BacklinkLead.id == lead_id).first() query = (
db.query(BacklinkLead)
.join(BacklinkCampaign, BacklinkLead.campaign_id == BacklinkCampaign.id)
.filter(
BacklinkLead.id == lead_id,
BacklinkCampaign.user_id == user_id,
)
)
if campaign_id:
query = query.filter(BacklinkCampaign.id == campaign_id)
lead = query.first()
if not lead: if not lead:
access = self._get_lead_access_rows(db, [lead_id]).get(lead_id)
if not access:
return None return None
if access["user_id"] != user_id:
raise PermissionError("Lead does not belong to the current user")
return None
lead.status = status lead.status = status
if notes is not None: if notes is not None:
lead.notes = notes lead.notes = notes
@@ -222,6 +244,44 @@ class BacklinkOutreachStorageService:
finally: finally:
db.close() db.close()
def get_lead_access_issues(
self, lead_ids: List[str], user_id: str, campaign_id: Optional[str] = None
) -> dict:
self._ensure_tables(user_id)
db = get_session_for_user(user_id)
if not db:
return {"missing": list(dict.fromkeys(lead_ids)), "unauthorized": []}
try:
unique_lead_ids = list(dict.fromkeys(lead_ids))
access_rows = self._get_lead_access_rows(db, unique_lead_ids)
missing: List[str] = []
unauthorized: List[str] = []
for lid in unique_lead_ids:
access = access_rows.get(lid)
if not access:
missing.append(lid)
elif access["user_id"] != user_id:
unauthorized.append(lid)
elif campaign_id and access["campaign_id"] != campaign_id:
missing.append(lid)
return {"missing": missing, "unauthorized": unauthorized}
finally:
db.close()
def _get_lead_access_rows(self, db, lead_ids: List[str]) -> dict:
if not lead_ids:
return {}
rows = (
db.query(BacklinkLead.id, BacklinkLead.campaign_id, BacklinkCampaign.user_id)
.outerjoin(BacklinkCampaign, BacklinkLead.campaign_id == BacklinkCampaign.id)
.filter(BacklinkLead.id.in_(lead_ids))
.all()
)
return {
row.id: {"campaign_id": row.campaign_id, "user_id": row.user_id}
for row in rows
}
@staticmethod @staticmethod
def _lead_to_dict(lead) -> dict: def _lead_to_dict(lead) -> dict:
return { return {

View File

@@ -163,6 +163,7 @@ export interface LeadCreateRequest {
export interface LeadStatusUpdateRequest { export interface LeadStatusUpdateRequest {
status: string; status: string;
notes?: string; notes?: string;
campaign_id?: string;
} }
export interface CampaignDetailResponse { export interface CampaignDetailResponse {
@@ -307,6 +308,7 @@ export interface BulkStatusUpdateRequest {
lead_ids: string[]; lead_ids: string[];
status: string; status: string;
notes?: string; notes?: string;
campaign_id?: string;
} }
export interface BulkStatusUpdateResponse { export interface BulkStatusUpdateResponse {

View File

@@ -314,7 +314,10 @@ const BacklinkOutreachDashboard: React.FC = () => {
const handleSingleStatusUpdate = async (leadId: string, status: string) => { const handleSingleStatusUpdate = async (leadId: string, status: string) => {
setIsStatusUpdating(true); setIsStatusUpdating(true);
try { try {
await updateLeadStatus(leadId, { status }); await updateLeadStatus(leadId, {
status,
campaign_id: selectedCampaign!.campaign_id,
});
showToastNotification(`Status updated to "${status}"`, 'success'); showToastNotification(`Status updated to "${status}"`, 'success');
await selectCampaign(selectedCampaign!.campaign_id); await selectCampaign(selectedCampaign!.campaign_id);
} catch (e) { } catch (e) {
@@ -328,7 +331,11 @@ const BacklinkOutreachDashboard: React.FC = () => {
if (selectedLeadIds.size === 0) return; if (selectedLeadIds.size === 0) return;
setIsStatusUpdating(true); setIsStatusUpdating(true);
try { try {
const result = await bulkUpdateLeadStatus({ lead_ids: Array.from(selectedLeadIds), status: bulkStatus }); const result = await bulkUpdateLeadStatus({
lead_ids: Array.from(selectedLeadIds),
status: bulkStatus,
campaign_id: selectedCampaign!.campaign_id,
});
if (result.failed.length > 0) { if (result.failed.length > 0) {
showToastNotification(`Updated ${result.updated} leads; ${result.failed.length} failed`, 'warning'); showToastNotification(`Updated ${result.updated} leads; ${result.failed.length} failed`, 'warning');
} else { } else {