Skip to content
Closed
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 85 additions & 39 deletions backend/app/services/org_sync_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -654,88 +654,134 @@ async def fetch_children(parent_id: str):
logger.info(f"Feishu fetched {len(all_depts)} departments total.")
return all_depts

async def fetch_users(self, department_external_id: str) -> list[ExternalUser]:
"""Fetch users in a department.

Uses user_id_type=user_id which requires the contact:user.employee_id:readonly
permission. If the Feishu API returns an error due to missing permission, raises
a clear error instructing the user to add the required scope.
"""
async def sync_org_structure(self, db: AsyncSession) -> dict[str, Any]:
"""Override to use global user list API so we can get users regardless of department hierarchy."""
errors = []
dept_count = 0
member_count = 0
user_count = 0
profile_count = 0
sync_start = datetime.now()

provider = await self._ensure_provider(db)

try:
# Fetch and sync departments
departments = await self.fetch_departments()
for dept in departments:
try:
async with db.begin_nested():
await self._upsert_department(db, provider, dept)
dept_count += 1
except Exception as e:
errors.append(f"Department {dept.external_id}: {str(e)}")
logger.error(f"[OrgSync] Failed to sync department {dept.external_id}: {e}")

# Fetch ALL users using global user list API (works even without department access)
all_users = await self._fetch_all_users()
logger.info(f"Feishu fetched {len(all_users)} total users globally.")

for user in all_users:
try:
async with db.begin_nested():
# Use first department from user's department_ids, fallback to "0"
dept_ext_id = user.department_ids[0] if user.department_ids else "0"
stats = await self._upsert_member(db, provider, user, dept_ext_id)
if stats.get("user_created"):
user_count += 1
if stats.get("profile_synced"):
profile_count += 1
member_count += 1
except Exception as e:
logger.error(f"[OrgSync] Failed to sync member {user.external_id} ({user.name}): {e}")
errors.append(f"Member {user.external_id}: {str(e)}")

# Update provider metadata
if self.provider:
config = (self.provider.config or {}).copy()
config["last_synced_at"] = datetime.now().isoformat()
self.provider.config = config
await db.flush()
await self._reconcile(db, provider.id, sync_start)
await db.flush()
await self._update_member_counts(db, provider.id)
await db.flush()

except Exception as e:
import traceback
logger.error(f"[OrgSync] Critical error during sync: {e}\n{traceback.format_exc()}")
errors.append(f"Critical: {str(e)}")

return {
"departments": dept_count,
"members": member_count,
"users_created": user_count,
"profiles_synced": profile_count,
"errors": errors,
"provider": self.provider_type,
"synced_at": datetime.now().isoformat()
}

async def _fetch_all_users(self) -> list[ExternalUser]:
"""Fetch all users from Feishu using global users API."""
token = await self.get_access_token()
users: list[ExternalUser] = []
page_token = ""

async with httpx.AsyncClient() as client:
while True:
params = {
"department_id": department_external_id,
"department_id_type": "open_department_id",
"user_id_type": "user_id", # Requires contact:user.employee_id:readonly
"page_size": "50",
"user_id_type": "user_id",
"sort_type": "NameOrder",
}
if page_token:
params["page_token"] = page_token

resp = await client.get(
self.FEISHU_USERS_URL,
"https://open.feishu.cn/open-apis/contact/v3/users",
params=params,
headers={"Authorization": f"Bearer {token}"},
)
data = resp.json()

if data.get("code") != 0:
error_code = data.get("code")
error_msg = data.get("msg", "")
logger.error(
f"Feishu fetch users error for dept {department_external_id}: "
f"code={error_code}, msg={error_msg}"
)
# Raise a user-friendly error for permission issues
raise RuntimeError(
f"Feishu API error (code {error_code}): {error_msg}. "
f"Please ensure the Feishu app has the 'contact:user.employee_id:readonly' "
f"permission enabled. Go to Feishu Open Platform -> App -> Permissions -> "
f"search 'employee_id' -> enable and publish a new version."
)
logger.error(f"Feishu fetch all users error: {data}")
break

res_data = data.get("data", {})
items = res_data.get("items", []) or []
for item in items:
# Collect all departments the user belongs to
raw_dept_ids = item.get("department_ids", [])
department_ids = [str(did) for did in raw_dept_ids] if raw_dept_ids else [department_external_id]

external_id = item.get("user_id", "") or item.get("open_id", "")

# For Feishu, a user is considered inactive if they are explicitly frozen or resigned.
# Merely not being activated (is_activated=False) shouldn't hide them from the org chart.
feishu_status = item.get("status", {})
is_frozen = feishu_status.get("is_frozen", False)
is_resigned = feishu_status.get("is_resigned", False)
member_status = "inactive" if (is_frozen or is_resigned) else "active"
department_ids = [str(did) for did in raw_dept_ids] if raw_dept_ids else ["0"]

user = ExternalUser(
external_id=external_id,
external_id=item.get("user_id", "") or item.get("open_id", ""),
open_id=item.get("open_id", ""),
unionid=item.get("union_id", ""),
name=item.get("name", ""),
email=item.get("email", ""),
avatar_url=item.get("avatar_url", ""),
title=item.get("title", ""),
department_external_id=department_external_id,
department_external_id=department_ids[0] if department_ids else "0",
department_ids=department_ids,
mobile=item.get("mobile", ""),
status=member_status,
status="active" if item.get("status", {}).get("is_activated") else "inactive",
raw_data=item,
)
users.append(user)

page_token = res_data.get("page_token", "")
if not page_token:
has_more = res_data.get("has_more", False)
if not has_more or not page_token:
break

return users

async def fetch_users(self, department_external_id: str) -> list[ExternalUser]:
# Dummy implementation - not used since we override sync_org_structure
return []


class DingTalkOrgSyncAdapter(BaseOrgSyncAdapter):
"""DingTalk organization sync adapter."""
Expand Down