Initial deployment setup
Deploy staging / deploy (push) Failing after 6s

This commit is contained in:
root
2026-08-31 16:45:24 +09:00
commit 33453ecc55
3475 changed files with 850363 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""secretary-api application package."""
+1
View File
@@ -0,0 +1 @@
"""API routing package."""
+13
View File
@@ -0,0 +1,13 @@
from fastapi import APIRouter
from app.api.routes.access import router as access_router
from app.api.routes.health import router as health_router
from app.api.routes.internal import router as internal_router
from app.api.routes.tickets import router as workspace_ticket_router, ticket_router
api_router = APIRouter()
api_router.include_router(health_router, prefix="/api")
api_router.include_router(access_router, prefix="/api")
api_router.include_router(internal_router, prefix="/api")
api_router.include_router(workspace_ticket_router, prefix="/api")
api_router.include_router(ticket_router, prefix="/api")
@@ -0,0 +1 @@
"""API route modules."""
+595
View File
@@ -0,0 +1,595 @@
from fastapi import APIRouter, Depends, HTTPException
from urllib.parse import quote
from pydantic import BaseModel, Field
from sqlalchemy import func, or_
from sqlalchemy.orm import Session
from app.core.auth import SsoPrincipal, get_principal
from app.db.models import (
SupportRole,
SupportRoleAssignment,
SupportUser,
UserWorkspaceAccess,
Workspace,
WorkspaceChannelMapping,
)
from app.db.session import get_db
from app.core.config import ABCProjectTarget, settings
from app.services.access_service import AccessService
from app.services.workspace_mapping_service import workspace_mapping_service
router = APIRouter(prefix="/access", tags=["access"])
service = AccessService()
def abc_project_target(
db: Session,
workspace_id: int,
) -> ABCProjectTarget | None:
mapping = (
db.query(WorkspaceChannelMapping)
.filter(
WorkspaceChannelMapping.workspace_id == workspace_id,
WorkspaceChannelMapping.is_active.is_(True),
WorkspaceChannelMapping.abc_project_id.is_not(None),
)
.order_by(WorkspaceChannelMapping.id.asc())
.first()
)
if mapping is None or mapping.abc_project_id is None:
return None
try:
project_id = int(mapping.abc_project_id)
channel_id = int(mapping.abc_channel_id)
except (TypeError, ValueError):
return None
if project_id <= 0 or channel_id <= 0:
return None
return ABCProjectTarget(project_id=project_id, channel_id=channel_id)
def candidate_tenant_ids(value: str | None = None) -> list[str]:
raw_value = settings.admin_candidate_tenant_id if value is None else value
return [
tenant_id.strip()
for tenant_id in raw_value.split(",")
if tenant_id.strip()
]
def candidate_scope_filter(tenant_ids: str | list[str]):
configured_tenant_ids = (
candidate_tenant_ids(tenant_ids)
if isinstance(tenant_ids, str)
else tenant_ids
)
return or_(
*[
condition
for tenant_id in configured_tenant_ids
for condition in (
SupportUser.tenant_id == tenant_id,
func.json_contains(
func.coalesce(SupportUser.tenant_ids, "[]"),
func.json_quote(tenant_id),
) == 1,
)
],
)
def normalize_phone_number(value: str | None) -> str:
if not value:
return ""
digits = "".join(character for character in value if character.isdigit())
if digits.startswith("82") and len(digits) >= 11:
return "0" + digits[2:]
return digits
def is_initial_super_admin(principal: SsoPrincipal) -> bool:
configured_phone = normalize_phone_number(
settings.initial_super_admin_phone_number,
)
return bool(configured_phone) and normalize_phone_number(
principal.phone_number,
) == configured_phone
def is_candidate_tenant_user(principal: SsoPrincipal) -> bool:
configured_tenant_ids = candidate_tenant_ids()
return bool(configured_tenant_ids) and any(
principal.tenant_id == candidate_tenant_id
or candidate_tenant_id in principal.tenant_ids
for candidate_tenant_id in configured_tenant_ids
)
def has_global_admin(db: Session) -> bool:
return (
db.query(SupportRoleAssignment)
.join(SupportUser)
.filter(
SupportUser.status == "ACTIVE",
SupportRoleAssignment.role_code.in_(service.GLOBAL_ADMIN_ROLES),
)
.first()
is not None
)
def can_manage_admins(db: Session, principal: SsoPrincipal) -> bool:
service.ensure_support_user(db, principal)
db.commit()
if service.is_system_admin(db, principal):
return True
return not has_global_admin(db) and is_initial_super_admin(principal) and is_candidate_tenant_user(principal)
class WorkspaceAccessRequest(BaseModel):
user_id: str = Field(min_length=1, max_length=100)
tenant_id: str = Field(min_length=1, max_length=100)
workspace_role: str = Field(default="END_USER", max_length=30)
can_read: bool = True
can_write: bool = False
can_manage: bool = False
can_approve: bool = False
page_scope: str | None = Field(default=None, max_length=50)
class AdminAssignmentRequest(BaseModel):
user_id: str = Field(min_length=1, max_length=100)
tenant_id: str = Field(min_length=1, max_length=100)
email: str | None = Field(default=None, max_length=320)
name: str | None = Field(default=None, max_length=100)
department: str | None = Field(default=None, max_length=100)
phone_number: str | None = Field(default=None, max_length=50)
tenant_ids: list[str] | None = None
role_code: str = Field(min_length=1, max_length=40)
workspace_code: str | None = Field(default=None, max_length=50)
@router.get("/me")
def get_my_access(
principal: SsoPrincipal = Depends(get_principal),
db: Session = Depends(get_db),
) -> dict[str, object]:
workspace_mapping_service.sync_project_workspaces(db)
workspace_mapping_service.sync_missing_mappings(db)
entries = service.list_accessible_workspaces(db, principal)
is_system_admin = service.is_system_admin(db, principal)
support_entries = [
(workspace, access)
for workspace, access in entries
if access.can_read
or access.workspace_role in {"SYSTEM_ADMIN", "SUPER_ADMIN", "PROJECT_MANAGER"}
]
if not support_entries and not is_system_admin:
entries = service.ensure_default_support_access(db, principal)
manager_entries = [
(workspace, access)
for workspace, access in entries
if access.workspace_role in {"SYSTEM_ADMIN", "SUPER_ADMIN", "PROJECT_MANAGER"}
or access.can_manage
]
support_entries = [
(workspace, access)
for workspace, access in entries
if access.can_read or access.workspace_role in {"SYSTEM_ADMIN", "SUPER_ADMIN", "PROJECT_MANAGER"}
]
default_admin_path = None
admin_workspace_code = None
for workspace, access in manager_entries:
target = abc_project_target(db, workspace.id)
if admin_workspace_code is None:
admin_workspace_code = workspace.workspace_code
if target is None:
continue
admin_workspace_code = workspace.workspace_code
default_admin_path = (
f"/main/project/{target.project_id}/feedback"
f"?channelId={target.channel_id}"
)
break
default_support_path = None
default_support_create_path = None
if support_entries:
workspace_code = support_entries[0][0].workspace_code
encoded_workspace_code = quote(workspace_code, safe='')
default_support_path = f"/support/{encoded_workspace_code}/list"
default_support_create_path = f"/support/{encoded_workspace_code}/new"
return {
"user_id": principal.user_id,
"tenant_id": principal.tenant_id,
"email": principal.email,
"name": principal.name,
"department": principal.department,
"phone_number": principal.phone_number,
"is_system_admin": is_system_admin,
"is_admin": bool(manager_entries),
"roles": sorted({access.workspace_role for _, access in entries}),
"workspaces": [
{
"workspace_code": workspace.workspace_code,
"workspace_name": workspace.workspace_name,
"workspace_role": access.workspace_role,
"can_read": access.can_read,
"can_write": access.can_write,
"can_manage": access.can_manage,
"can_approve": access.can_approve,
"page_scope": access.page_scope,
"abc_project_id": (
abc_project_target(db, workspace.id).project_id
if abc_project_target(db, workspace.id)
else None
),
"abc_channel_id": (
abc_project_target(db, workspace.id).channel_id
if abc_project_target(db, workspace.id)
else None
),
}
for workspace, access in entries
],
"default_admin_path": default_admin_path,
"admin_workspace_code": admin_workspace_code,
"default_support_path": default_support_path,
"default_support_create_path": default_support_create_path,
}
@router.post("/workspaces/{workspace_code}/users")
def upsert_workspace_access(
workspace_code: str,
payload: WorkspaceAccessRequest,
principal: SsoPrincipal = Depends(get_principal),
db: Session = Depends(get_db),
) -> dict[str, object]:
if not service.is_system_admin(db, principal):
raise HTTPException(status_code=403, detail="시스템 관리자 권한이 필요합니다.")
workspace = db.query(Workspace).filter_by(
workspace_code=workspace_code,
is_active=True,
).one_or_none()
if workspace is None:
raise HTTPException(status_code=404, detail="워크스페이스를 찾을 수 없습니다.")
if payload.workspace_role not in service.SUPPORTED_WORKSPACE_ROLES:
raise HTTPException(status_code=400, detail="지원하지 않는 workspace 역할입니다.")
support_user = db.query(SupportUser).filter_by(
sso_subject=payload.user_id,
tenant_id=payload.tenant_id,
).one_or_none()
if support_user is None:
support_user = SupportUser(
sso_subject=payload.user_id,
tenant_id=payload.tenant_id,
)
db.add(support_user)
db.flush()
assignment = db.query(SupportRoleAssignment).filter_by(
support_user_id=support_user.id,
workspace_id=workspace.id,
).filter(SupportRoleAssignment.role_code.in_(service.SUPPORTED_WORKSPACE_ROLES)).first()
if assignment is None:
assignment = SupportRoleAssignment(
support_user_id=support_user.id,
workspace_id=workspace.id,
role_code=payload.workspace_role,
)
db.add(assignment)
else:
assignment.role_code = payload.workspace_role
access = db.query(UserWorkspaceAccess).filter_by(
user_id=payload.user_id,
tenant_id=payload.tenant_id,
workspace_id=workspace.id,
).one_or_none()
if access is None:
access = UserWorkspaceAccess(
user_id=payload.user_id,
tenant_id=payload.tenant_id,
workspace_id=workspace.id,
)
db.add(access)
service._apply_role_permissions(access, payload.workspace_role)
access.page_scope = payload.page_scope
db.commit()
db.refresh(access)
return {
"id": access.id,
"user_id": access.user_id,
"tenant_id": access.tenant_id,
"workspace_code": workspace.workspace_code,
"workspace_role": access.workspace_role,
}
@router.get("/candidates")
def list_admin_candidates(
principal: SsoPrincipal = Depends(get_principal),
db: Session = Depends(get_db),
) -> dict[str, object]:
if not can_manage_admins(db, principal):
raise HTTPException(status_code=403, detail="시스템 관리자 권한이 필요합니다.")
configured_tenant_ids = candidate_tenant_ids()
users_query = db.query(SupportUser).filter(SupportUser.status == "ACTIVE")
if configured_tenant_ids:
users_query = users_query.filter(candidate_scope_filter(configured_tenant_ids))
else:
# System administrators are already authorized to manage the current
# tenant. Do not make the assignment dialog unusable just because the
# optional initial-admin candidate scope is not configured. The POST
# endpoint applies the same current-tenant boundary when this setting
# is empty.
if not service.is_system_admin(db, principal):
return {"items": []}
users_query = users_query.filter(SupportUser.tenant_id == principal.tenant_id)
users = users_query.order_by(SupportUser.id.asc()).all()
return {
"items": [
{
"email": user.email or user.sso_subject,
"isRegistered": True,
"user": {
"id": user.id,
"email": user.email,
"name": user.name,
"department": user.department,
"phone_number": user.phone_number,
"oauthSubject": user.sso_subject,
"oauthTenantId": user.tenant_id,
"tenantIds": user.tenant_ids or [],
},
}
for user in users
],
}
@router.get("/admins")
def list_admin_assignments(
principal: SsoPrincipal = Depends(get_principal),
db: Session = Depends(get_db),
) -> dict[str, object]:
if not can_manage_admins(db, principal):
raise HTTPException(status_code=403, detail="시스템 관리자 권한이 필요합니다.")
project_workspace_codes = workspace_mapping_service.sync_project_workspaces(db)
roles = db.query(SupportRole).order_by(SupportRole.code.asc()).all()
workspace_query = db.query(Workspace).filter(
Workspace.is_active.is_(True),
Workspace.workspace_type == "SOFTWARE_APP",
)
# Limit the selector to projects currently returned by ABC. This avoids
# exposing legacy software_app seed rows that are not ABC projects.
if project_workspace_codes is not None:
workspace_query = workspace_query.filter(
Workspace.workspace_code.in_(project_workspace_codes),
)
workspaces = workspace_query.order_by(Workspace.id.asc()).all()
configured_tenant_ids = candidate_tenant_ids()
assignment_scope = (
candidate_scope_filter(configured_tenant_ids)
if configured_tenant_ids
else SupportUser.tenant_id == principal.tenant_id
)
assignments = (
db.query(SupportRoleAssignment)
.join(SupportUser)
.filter(
SupportUser.status == "ACTIVE",
assignment_scope,
SupportRoleAssignment.role_code.in_(
service.GLOBAL_ADMIN_ROLES | {"PROJECT_MANAGER", "FEEDBACK_PROVIDER"},
),
)
.order_by(SupportRoleAssignment.created_at.asc())
.all()
)
users: dict[int, dict[str, object]] = {}
for assignment in assignments:
user = assignment.user
item = users.setdefault(
user.id,
{
"id": user.id,
"user_id": user.sso_subject,
"tenant_id": user.tenant_id,
"email": user.email,
"name": user.name,
"department": user.department,
"phone_number": user.phone_number,
"assignments": [],
},
)
item["assignments"].append(
{
"id": assignment.id,
"role_code": assignment.role_code,
"role_name": assignment.role.name if assignment.role else assignment.role_code,
"workspace_id": assignment.workspace_id,
"workspace_code": assignment.workspace.workspace_code if assignment.workspace else None,
"workspace_name": assignment.workspace.workspace_name if assignment.workspace else None,
},
)
return {
"roles": [
{
"code": role.code,
"name": role.name,
"description": role.description,
}
for role in roles
],
"workspaces": [
{
"id": workspace.id,
"code": workspace.workspace_code,
"name": workspace.workspace_name,
}
for workspace in workspaces
],
"users": list(users.values()),
}
@router.post("/admins")
def upsert_admin_assignment(
payload: AdminAssignmentRequest,
principal: SsoPrincipal = Depends(get_principal),
db: Session = Depends(get_db),
) -> dict[str, object]:
if not can_manage_admins(db, principal):
raise HTTPException(status_code=403, detail="시스템 관리자 권한이 필요합니다.")
configured_tenant_ids = candidate_tenant_ids()
if configured_tenant_ids:
target_user = db.query(SupportUser).filter_by(
sso_subject=payload.user_id,
tenant_id=payload.tenant_id,
status="ACTIVE",
).one_or_none()
if target_user is None or not (
target_user.tenant_id in configured_tenant_ids
or any(tenant_id in (target_user.tenant_ids or []) for tenant_id in configured_tenant_ids)
):
raise HTTPException(status_code=403, detail="지정된 후보 테넌트의 사용자만 관리할 수 있습니다.")
elif payload.tenant_id != principal.tenant_id:
raise HTTPException(status_code=403, detail="현재 tenant의 사용자만 관리할 수 있습니다.")
role_code = payload.role_code.upper()
if role_code not in service.ROLE_PERMISSIONS:
raise HTTPException(status_code=400, detail="지원하지 않는 역할입니다.")
workspace = None
if role_code in service.GLOBAL_ADMIN_ROLES:
if payload.workspace_code:
raise HTTPException(status_code=400, detail="전체 관리자 역할에는 workspace를 지정할 수 없습니다.")
else:
if not payload.workspace_code:
raise HTTPException(status_code=400, detail="workspace 역할에는 workspace가 필요합니다.")
workspace = db.query(Workspace).filter_by(
workspace_code=payload.workspace_code,
is_active=True,
).one_or_none()
if workspace is None:
raise HTTPException(status_code=404, detail="워크스페이스를 찾을 수 없습니다.")
user = service.ensure_support_user_identity(
db,
user_id=payload.user_id,
tenant_id=payload.tenant_id,
email=payload.email,
name=payload.name,
department=payload.department,
phone_number=payload.phone_number,
tenant_ids=payload.tenant_ids,
)
if workspace is not None:
existing_workspace_assignments = db.query(SupportRoleAssignment).filter(
SupportRoleAssignment.support_user_id == user.id,
SupportRoleAssignment.workspace_id == workspace.id,
SupportRoleAssignment.role_code.in_(service.SUPPORTED_WORKSPACE_ROLES),
).all()
for existing in existing_workspace_assignments:
if existing.role_code != role_code:
db.delete(existing)
assignment = db.query(SupportRoleAssignment).filter_by(
support_user_id=user.id,
role_code=role_code,
workspace_id=workspace.id if workspace else None,
).one_or_none()
if assignment is None:
assignment = SupportRoleAssignment(
support_user_id=user.id,
role_code=role_code,
workspace_id=workspace.id if workspace else None,
)
db.add(assignment)
if workspace is not None:
access = db.query(UserWorkspaceAccess).filter_by(
user_id=user.sso_subject,
tenant_id=user.tenant_id,
workspace_id=workspace.id,
).one_or_none()
if access is None:
access = UserWorkspaceAccess(
user_id=user.sso_subject,
tenant_id=user.tenant_id,
workspace_id=workspace.id,
)
db.add(access)
service._apply_role_permissions(access, role_code)
db.commit()
db.refresh(assignment)
return {
"id": assignment.id,
"user_id": user.sso_subject,
"tenant_id": user.tenant_id,
"role_code": role_code,
"workspace_code": workspace.workspace_code if workspace else None,
}
@router.delete("/admins/{assignment_id}")
def delete_admin_assignment(
assignment_id: int,
principal: SsoPrincipal = Depends(get_principal),
db: Session = Depends(get_db),
) -> dict[str, bool]:
if not service.is_system_admin(db, principal):
raise HTTPException(status_code=403, detail="시스템 관리자 권한이 필요합니다.")
assignment = db.query(SupportRoleAssignment).filter_by(id=assignment_id).one_or_none()
if assignment is None:
raise HTTPException(status_code=404, detail="권한 할당을 찾을 수 없습니다.")
if assignment.role_code in service.GLOBAL_ADMIN_ROLES:
remaining = db.query(SupportRoleAssignment).filter(
SupportRoleAssignment.role_code.in_(service.GLOBAL_ADMIN_ROLES),
SupportRoleAssignment.id != assignment.id,
).count()
if remaining == 0:
raise HTTPException(status_code=400, detail="전체 관리자는 최소 1명 이상 유지해야 합니다.")
workspace_id = assignment.workspace_id
support_user_id = assignment.support_user_id
db.delete(assignment)
if workspace_id is not None:
remaining_role = db.query(SupportRoleAssignment).filter(
SupportRoleAssignment.support_user_id == support_user_id,
SupportRoleAssignment.workspace_id == workspace_id,
SupportRoleAssignment.role_code.in_(service.SUPPORTED_WORKSPACE_ROLES),
).order_by(SupportRoleAssignment.created_at.desc()).first()
access = db.query(UserWorkspaceAccess).filter_by(
workspace_id=workspace_id,
user_id=assignment.user.sso_subject,
tenant_id=assignment.user.tenant_id,
).one_or_none()
if access is not None:
if remaining_role is None:
db.delete(access)
else:
service._apply_role_permissions(access, remaining_role.role_code)
db.commit()
return {"deleted": True}
@@ -0,0 +1,8 @@
from fastapi import APIRouter
router = APIRouter(tags=["health"])
@router.get("/health")
def health_check() -> dict[str, str]:
return {"status": "ok"}
@@ -0,0 +1,74 @@
from fastapi import APIRouter, Header, HTTPException, Query
from sqlalchemy.orm import Session
from app.core.config import settings
from app.db.models import (
SupportRoleAssignment,
SupportUser,
Workspace,
WorkspaceChannelMapping,
)
from app.db.session import get_db
from fastapi import Depends
router = APIRouter(prefix="/internal", tags=["internal"])
@router.get("/notification-recipients")
def list_notification_recipients(
project_id: int = Query(gt=0),
channel_id: int = Query(gt=0),
api_key: str | None = Header(default=None, alias="x-api-key"),
db: Session = Depends(get_db),
) -> dict[str, object]:
"""Return project-scoped notification recipients to ABC API only.
Secretary is the source of truth for workspace/project administrators.
This endpoint is intentionally protected by the service API key and is
not exposed through the public user-facing API contract.
"""
if not settings.master_api_key or api_key != settings.master_api_key:
raise HTTPException(status_code=401, detail="internal service authentication failed")
mapping = (
db.query(WorkspaceChannelMapping)
.join(Workspace, Workspace.id == WorkspaceChannelMapping.workspace_id)
.filter(
WorkspaceChannelMapping.abc_project_id == project_id,
WorkspaceChannelMapping.abc_channel_id == str(channel_id),
WorkspaceChannelMapping.is_active.is_(True),
Workspace.is_active.is_(True),
Workspace.workspace_type == "SOFTWARE_APP",
)
.order_by(WorkspaceChannelMapping.id.desc())
.first()
)
if mapping is None:
return {"items": []}
assignments = (
db.query(SupportRoleAssignment)
.join(SupportUser)
.filter(
SupportRoleAssignment.workspace_id == mapping.workspace_id,
SupportRoleAssignment.role_code == "PROJECT_MANAGER",
SupportRoleAssignment.support_user_id == SupportUser.id,
SupportUser.status == "ACTIVE",
SupportUser.email.is_not(None),
)
.order_by(SupportRoleAssignment.id.asc())
.all()
)
items = []
seen: set[str] = set()
for assignment in assignments:
email = (assignment.user.email or "").strip()
normalized = email.lower()
if not normalized or normalized in seen:
continue
seen.add(normalized)
items.append({"email": email, "role": "PROJECT_ADMIN"})
return {"items": items}
@@ -0,0 +1,803 @@
import json
from typing import Annotated, cast
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile
from fastapi.responses import FileResponse, Response
from sqlalchemy.orm import Session
from urllib.parse import quote
from app.core.auth import SsoPrincipal, get_principal
from app.db.session import get_db
from app.services.access_service import AccessService
from app.schemas.ticket import (
SupportTicketCommentRecord,
SupportTicketRecord,
TicketCommentCreateRequest,
TicketIssueLinkStatusSyncRequest,
TicketAssigneeCandidate,
TicketAssigneeUpdateRequest,
InternalMemoCreateRequest,
InternalMemoUpdateRequest,
TicketCommentUpdateRequest,
TicketCreateRequest,
TicketCreateResponse,
SupportFeedbackMetrics,
SupportFeedbackStatusRecord,
FeedbackStatusUpdateRequest,
TicketUpdateRequest,
TicketMutationResponse,
TicketDeleteResponse,
WorkspaceFormTemplateResponse,
WorkspaceSummary,
)
from app.services.ticket_service import TicketService
from app.services.workspace_mapping_service import workspace_mapping_service
router = APIRouter(prefix="/workspaces", tags=["tickets"])
service = TicketService()
access_service = AccessService()
def can_manage_ticket_comments(
db: Session,
principal: SsoPrincipal,
ticket_id: int,
workspace_code: str | None,
abc_feedback_id: int | None,
) -> bool:
ticket = service._resolve_ticket_for_external_id(
db,
ticket_id,
workspace_code,
abc_feedback_id=abc_feedback_id,
)
if ticket is None:
return False
access = access_service.assert_workspace_access(
db,
principal,
ticket.workspace.workspace_code,
)
return access.can_manage or access.workspace_role in {
"SYSTEM_ADMIN",
"SUPER_ADMIN",
"PROJECT_MANAGER",
}
def require_ticket_access(
ticket_id: int,
workspaceCode: str | None = None,
abcFeedbackId: int | None = None,
principal: SsoPrincipal = Depends(get_principal),
db: Session = Depends(get_db),
) -> SsoPrincipal:
ticket = service._resolve_ticket_for_external_id(
db,
ticket_id,
workspaceCode,
abc_feedback_id=abcFeedbackId,
)
if ticket is None:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="지원 요청을 찾을 수 없습니다.")
access = access_service.assert_workspace_access(
db,
principal,
ticket.workspace.workspace_code,
)
if (
access.can_manage
or access.workspace_role in {"SYSTEM_ADMIN", "SUPER_ADMIN", "PROJECT_MANAGER"}
):
return principal
if (
ticket.requester_id != principal.user_id
or ticket.requester_tenant_id != principal.tenant_id
):
from fastapi import HTTPException
raise HTTPException(status_code=403, detail="이 지원 요청을 조회할 권한이 없습니다.")
return principal
def require_ticket_write_access(
ticket_id: int,
workspaceCode: str | None = None,
abcFeedbackId: int | None = None,
principal: SsoPrincipal = Depends(get_principal),
db: Session = Depends(get_db),
) -> SsoPrincipal:
ticket = service._resolve_ticket_for_external_id(
db,
ticket_id,
workspaceCode,
abc_feedback_id=abcFeedbackId,
)
if ticket is None:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="지원 요청을 찾을 수 없습니다.")
access = access_service.assert_workspace_access(
db,
principal,
ticket.workspace.workspace_code,
write=True,
)
if (
access.can_manage
or access.workspace_role in {"SYSTEM_ADMIN", "SUPER_ADMIN", "PROJECT_MANAGER"}
):
return principal
if (
ticket.requester_id != principal.user_id
or ticket.requester_tenant_id != principal.tenant_id
):
from fastapi import HTTPException
raise HTTPException(status_code=403, detail="이 지원 요청을 변경할 권한이 없습니다.")
return principal
def require_ticket_manager_access(
ticket_id: int,
workspaceCode: str | None = None,
abcFeedbackId: int | None = None,
principal: SsoPrincipal = Depends(get_principal),
db: Session = Depends(get_db),
) -> SsoPrincipal:
ticket = service._resolve_ticket_for_external_id(
db,
ticket_id,
workspaceCode,
abc_feedback_id=abcFeedbackId,
)
if ticket is None:
raise HTTPException(status_code=404, detail="지원 요청을 찾을 수 없습니다.")
access = access_service.assert_workspace_access(
db,
principal,
ticket.workspace.workspace_code,
)
if not (
access.can_manage
or access.workspace_role in {"SYSTEM_ADMIN", "SUPER_ADMIN", "PROJECT_MANAGER"}
):
raise HTTPException(status_code=403, detail="프로젝트 관리자 권한이 필요합니다.")
return principal
@router.get("", response_model=list[WorkspaceSummary])
def list_workspaces(
principal: SsoPrincipal = Depends(get_principal),
db: Session = Depends(get_db),
) -> list[WorkspaceSummary]:
workspace_mapping_service.sync_project_workspaces(db)
return [
WorkspaceSummary(
workspace_code=workspace.workspace_code,
workspace_name=workspace.workspace_name,
workspace_type=workspace.workspace_type,
)
for workspace, access in access_service.list_accessible_workspaces(db, principal)
if access.can_read or access.workspace_role in {"SYSTEM_ADMIN", "SUPER_ADMIN", "PROJECT_MANAGER"}
]
@router.get("/{workspace_code}/form-template", response_model=WorkspaceFormTemplateResponse)
def get_workspace_form_template(
workspace_code: str,
principal: SsoPrincipal = Depends(get_principal),
db: Session = Depends(get_db),
) -> WorkspaceFormTemplateResponse:
access_service.assert_workspace_access(db, principal, workspace_code)
return service.get_workspace_form_template(db, workspace_code)
@router.post(
"/{workspace_code}/tickets",
response_model=TicketCreateResponse,
response_description="생성된 지원 요청의 식별자와 동기화 상태",
summary="지원 요청 등록",
description=(
"application/json 또는 multipart/form-data로 지원 요청을 등록합니다. "
"multipart 요청은 title, description, category_code, ticket_type, is_secret, "
"requires_approval, extra_fields(JSON 문자열), attachments(반복 파일 필드)를 지원합니다. "
"작성자 식별자와 SSO 프로필은 인증 사용자 정보로 서버가 설정합니다."
),
openapi_extra={
"requestBody": {
"required": True,
"content": {
"application/json": {
"schema": {"$ref": "#/components/schemas/TicketCreateRequest"},
},
"multipart/form-data": {
"schema": {
"type": "object",
"required": ["title", "description"],
"properties": {
"title": {"type": "string", "minLength": 1, "maxLength": 255},
"description": {"type": "string", "minLength": 1},
"category_code": {"type": "string"},
"ticket_type": {"type": "string", "default": "GENERAL"},
"is_secret": {"type": "boolean", "default": False},
"requires_approval": {"type": "boolean", "default": False},
"extra_fields": {
"type": "string",
"description": "동적 필드 JSON 객체를 문자열로 전달",
},
"attachments": {
"type": "array",
"items": {"type": "string", "format": "binary"},
"description": "반복 가능한 첨부파일 필드",
},
},
},
},
},
}
},
)
async def create_ticket(
workspace_code: str,
request: Request,
principal: SsoPrincipal = Depends(get_principal),
db: Session = Depends(get_db),
) -> TicketCreateResponse:
access_service.assert_workspace_access(db, principal, workspace_code, write=True)
content_type = request.headers.get("content-type", "")
if content_type.startswith("multipart/form-data"):
form = await request.form()
extra_fields_value = form.get("extra_fields")
extra_fields = (
json.loads(extra_fields_value)
if isinstance(extra_fields_value, str) and extra_fields_value
else {}
)
attachments = [
cast(UploadFile, value)
for key, value in form.multi_items()
if key == "attachments"
and isinstance(value, (UploadFile, StarletteUploadFile))
]
request_payload = TicketCreateRequest(
workspace_code=workspace_code,
requester_id=str(form.get("requester_id") or ""),
requester_tenant_id=str(form.get("requester_tenant_id") or ""),
requester_contact=str(form.get("requester_contact") or ""),
title=str(form.get("title") or ""),
description=str(form.get("description") or ""),
category_code=str(form.get("category_code") or ""),
ticket_type=str(form.get("ticket_type") or "GENERAL"),
is_secret=str(form.get("is_secret") or "false").lower() == "true",
requires_approval=str(form.get("requires_approval") or "false").lower() == "true",
extra_fields=extra_fields,
)
request_payload = request_payload.model_copy(update={
"requester_id": principal.user_id,
"requester_tenant_id": principal.tenant_id,
"requester_email": principal.email,
"requester_name": principal.name,
"requester_department": principal.department,
"requester_phone_number": principal.phone_number,
})
return service.create_ticket(db, request_payload, attachments)
payload = TicketCreateRequest.model_validate(await request.json())
request_payload = payload.model_copy(update={
"workspace_code": workspace_code,
"requester_id": principal.user_id,
"requester_tenant_id": principal.tenant_id,
"requester_email": principal.email,
"requester_name": principal.name,
"requester_department": principal.department,
"requester_phone_number": principal.phone_number,
})
return service.create_ticket(db, request_payload)
@router.get("/{workspace_code}/tickets", response_model=list[SupportTicketRecord])
def list_workspace_tickets(
workspace_code: str,
requesterId: str | None = None,
requesterTenantId: str | None = None,
principal: SsoPrincipal = Depends(get_principal),
db: Session = Depends(get_db),
) -> list[SupportTicketRecord]:
access = access_service.assert_workspace_access(db, principal, workspace_code)
if access.can_manage or access.workspace_role in {"SYSTEM_ADMIN", "SUPER_ADMIN", "PROJECT_MANAGER"}:
requesterId = None
requesterTenantId = None
else:
requesterId = principal.user_id
requesterTenantId = principal.tenant_id
return service.list_workspace_tickets(db, workspace_code, requesterId, requesterTenantId)
@router.get("/../tickets", include_in_schema=False)
def _noop_redirect() -> None:
return None
ticket_router = APIRouter(
prefix="/tickets",
tags=["support-tickets"],
dependencies=[Depends(get_principal)],
)
@ticket_router.get("/statuses", response_model=list[SupportFeedbackStatusRecord])
def get_feedback_statuses(
projectId: int,
channelId: int | None = None,
principal: SsoPrincipal = Depends(get_principal),
db: Session = Depends(get_db),
) -> list[SupportFeedbackStatusRecord]:
return [
SupportFeedbackStatusRecord.model_validate(item)
for item in service.list_feedback_statuses(db, principal, projectId, channelId)
]
@ticket_router.get("/metrics", response_model=SupportFeedbackMetrics)
def get_feedback_metrics(
projectId: int,
channelId: int | None = None,
principal: SsoPrincipal = Depends(get_principal),
db: Session = Depends(get_db),
) -> SupportFeedbackMetrics:
return SupportFeedbackMetrics.model_validate(
service.get_feedback_metrics(db, principal, projectId, channelId)
)
@ticket_router.get("", response_model=list[SupportTicketRecord])
def list_tickets(
principal: SsoPrincipal = Depends(get_principal),
db: Session = Depends(get_db),
) -> list[SupportTicketRecord]:
if not access_service.is_system_admin(db, principal):
from fastapi import HTTPException
raise HTTPException(status_code=403, detail="시스템 관리자 권한이 필요합니다.")
return service.list_tickets(db)
@ticket_router.patch("/{ticket_id}/feedback-status", response_model=TicketMutationResponse, dependencies=[Depends(require_ticket_manager_access)])
def update_feedback_status(
ticket_id: int,
payload: FeedbackStatusUpdateRequest,
workspaceCode: str | None = None,
abcFeedbackId: int | None = None,
db: Session = Depends(get_db),
) -> TicketMutationResponse:
return service.update_feedback_status(
db,
ticket_id,
payload.feedback_status,
workspaceCode,
abcFeedbackId,
)
@ticket_router.get("/{ticket_id}", response_model=SupportTicketRecord, dependencies=[Depends(require_ticket_access)])
def get_ticket(
ticket_id: int,
workspaceCode: str | None = None,
db: Session = Depends(get_db),
) -> SupportTicketRecord:
return service.get_ticket(db, ticket_id, workspaceCode)
@ticket_router.get("/{ticket_id}/attachments/{attachment_id}", dependencies=[Depends(require_ticket_access)])
def download_ticket_attachment(
ticket_id: int,
attachment_id: int,
workspaceCode: str | None = None,
db: Session = Depends(get_db),
) -> Response:
attachment = service.get_ticket_attachment(db, ticket_id, attachment_id, workspaceCode)
if attachment.storage_provider.upper() == "R2":
return Response(
content=service.read_attachment(attachment),
media_type=attachment.mime_type or "application/octet-stream",
headers={
"Content-Disposition": (
"attachment; filename*=UTF-8''"
+ quote(attachment.original_file_name)
)
},
)
file_path = service.resolve_attachment_path(attachment)
return FileResponse(
path=file_path,
media_type=attachment.mime_type or "application/octet-stream",
filename=attachment.original_file_name,
)
@ticket_router.put("/{ticket_id}", response_model=SupportTicketRecord, dependencies=[Depends(require_ticket_write_access)])
def update_ticket(
ticket_id: int,
payload: TicketUpdateRequest,
workspaceCode: str | None = None,
db: Session = Depends(get_db),
) -> SupportTicketRecord:
return service.update_ticket(db, ticket_id, payload, workspaceCode)
@ticket_router.delete("/{ticket_id}", dependencies=[Depends(require_ticket_write_access)])
def delete_ticket(
ticket_id: int,
workspaceCode: str | None = None,
db: Session = Depends(get_db),
) -> dict[str, int | bool]:
return service.delete_ticket(db, ticket_id, workspaceCode)
@ticket_router.get(
"/{ticket_id}/assignees",
response_model=list[TicketAssigneeCandidate],
dependencies=[Depends(require_ticket_manager_access)],
)
def list_ticket_assignees(
ticket_id: int,
workspaceCode: str | None = None,
abcFeedbackId: int | None = None,
db: Session = Depends(get_db),
) -> list[TicketAssigneeCandidate]:
return service.list_assignee_candidates(
db,
ticket_id,
workspaceCode,
abcFeedbackId,
)
@ticket_router.patch(
"/{ticket_id}/assignee",
response_model=SupportTicketRecord,
dependencies=[Depends(require_ticket_manager_access)],
)
def update_ticket_assignee(
ticket_id: int,
payload: TicketAssigneeUpdateRequest,
workspaceCode: str | None = None,
abcFeedbackId: int | None = None,
db: Session = Depends(get_db),
) -> SupportTicketRecord:
return service.update_assignee(
db,
ticket_id,
payload,
workspaceCode,
abcFeedbackId,
)
@ticket_router.get(
"/{ticket_id}/internal-memos",
response_model=list[SupportTicketCommentRecord],
response_description="관리자 전용 내부 메모 목록",
summary="내부 메모 목록 조회",
description=(
"관리자 권한이 있는 사용자만 조회할 수 있습니다. "
"일반 댓글은 반환하지 않으며, 내부 메모는 사용자용 댓글 응답에 포함되지 않습니다."
),
tags=["internal-memos"],
dependencies=[Depends(require_ticket_manager_access)],
)
def list_internal_memos(
ticket_id: int,
workspaceCode: str | None = None,
abcFeedbackId: int | None = None,
principal: SsoPrincipal = Depends(get_principal),
db: Session = Depends(get_db),
) -> list[SupportTicketCommentRecord]:
return service.list_internal_memos(
db,
ticket_id,
workspaceCode,
abcFeedbackId,
principal.user_id,
principal.tenant_id,
True,
)
@ticket_router.post(
"/{ticket_id}/internal-memos",
response_model=SupportTicketCommentRecord,
response_description="생성된 내부 메모",
summary="내부 메모 등록",
description="관리자 전용 메모를 등록합니다. 내부 메모는 고객용 댓글 목록에 노출되지 않습니다.",
tags=["internal-memos"],
dependencies=[Depends(require_ticket_manager_access)],
)
def create_internal_memo(
ticket_id: int,
payload: InternalMemoCreateRequest,
workspaceCode: str | None = None,
abcFeedbackId: int | None = None,
principal: SsoPrincipal = Depends(get_principal),
db: Session = Depends(get_db),
) -> SupportTicketCommentRecord:
secured_payload = TicketCommentCreateRequest(
requester_id=principal.user_id,
requester_tenant_id=principal.tenant_id,
author_name=principal.name or principal.email or principal.user_id,
content=payload.content,
is_internal=True,
)
return service.create_internal_memo(
db,
ticket_id,
secured_payload,
workspaceCode,
abcFeedbackId,
)
@ticket_router.get(
"/{ticket_id}/comments",
response_model=list[SupportTicketCommentRecord],
response_description="공개 댓글 목록",
summary="공개 댓글 목록 조회",
description=(
"지원 요청의 공개 댓글만 반환합니다. 내부 메모는 관리자라도 이 경로에서는 반환하지 않고 "
"별도의 내부 메모 API로만 조회합니다."
),
tags=["comments"],
)
def list_ticket_comments(
ticket_id: int,
workspaceCode: str | None = None,
abcFeedbackId: int | None = None,
principal: SsoPrincipal = Depends(require_ticket_access),
db: Session = Depends(get_db),
) -> list[SupportTicketCommentRecord]:
can_manage_comments = can_manage_ticket_comments(
db,
principal,
ticket_id,
workspaceCode,
abcFeedbackId,
)
return service.list_ticket_comments(
db,
ticket_id,
workspaceCode,
abcFeedbackId,
principal.user_id,
principal.tenant_id,
can_manage_comments,
)
@ticket_router.post(
"/{ticket_id}/comments",
response_model=SupportTicketCommentRecord,
response_description="생성된 공개 댓글",
summary="공개 댓글 등록",
description=(
"공개 댓글을 등록합니다. 작성자와 테넌트는 요청 본문을 신뢰하지 않고 "
"SSO 인증 사용자 정보로 결정합니다. 관리자 전용 메모는 내부 메모 API를 사용합니다."
),
tags=["comments"],
dependencies=[Depends(require_ticket_write_access)],
)
def create_ticket_comment(
ticket_id: int,
payload: TicketCommentCreateRequest,
workspaceCode: str | None = None,
abcFeedbackId: int | None = None,
principal: SsoPrincipal = Depends(get_principal),
db: Session = Depends(get_db),
) -> SupportTicketCommentRecord:
secured_payload = payload.model_copy(update={
"requester_id": principal.user_id,
"requester_tenant_id": principal.tenant_id,
"author_name": principal.name or principal.email or principal.user_id,
"is_internal": False,
})
return service.create_ticket_comment(
db,
ticket_id,
secured_payload,
workspaceCode,
abcFeedbackId,
)
@ticket_router.post(
"/{ticket_id}/comments/attachments",
response_model=SupportTicketCommentRecord,
response_description="첨부파일이 연결된 생성 댓글",
summary="첨부파일과 함께 공개 댓글 등록",
description=(
"multipart/form-data로 공개 댓글과 첨부파일을 등록합니다. "
"content는 댓글 본문이고 attachments는 반복 가능한 파일 필드입니다. "
"내부 메모 여부와 작성자 정보는 인증 권한으로 서버가 결정합니다."
),
tags=["comments", "attachments"],
dependencies=[Depends(require_ticket_write_access)],
)
async def create_ticket_comment_with_attachments(
ticket_id: int,
content: Annotated[str, Form(description="공개 댓글 본문")] = "",
attachments: Annotated[
list[UploadFile] | None,
File(description="댓글에 연결할 파일. 같은 필드명을 반복해 여러 파일을 첨부할 수 있습니다."),
] = None,
workspaceCode: str | None = None,
abcFeedbackId: int | None = None,
principal: SsoPrincipal = Depends(get_principal),
db: Session = Depends(get_db),
) -> SupportTicketCommentRecord:
payload = TicketCommentCreateRequest(
requester_id=principal.user_id,
requester_tenant_id=principal.tenant_id,
author_name=principal.name or principal.email or principal.user_id,
content=content,
is_internal=False,
)
return service.create_ticket_comment(
db,
ticket_id,
payload,
workspaceCode,
abcFeedbackId,
attachments or [],
)
@ticket_router.put(
"/{ticket_id}/internal-memos/{memo_id}",
response_model=SupportTicketCommentRecord,
response_description="수정된 내부 메모",
summary="내부 메모 수정",
description="관리자 전용 내부 메모를 수정합니다.",
tags=["internal-memos"],
dependencies=[Depends(require_ticket_manager_access)],
)
def update_internal_memo(
ticket_id: int,
memo_id: int,
payload: InternalMemoUpdateRequest,
workspaceCode: str | None = None,
abcFeedbackId: int | None = None,
principal: SsoPrincipal = Depends(get_principal),
db: Session = Depends(get_db),
) -> SupportTicketCommentRecord:
secured_payload = TicketCommentUpdateRequest(
requester_id=principal.user_id,
requester_tenant_id=principal.tenant_id,
content=payload.content,
)
return service.update_internal_memo(
db,
ticket_id,
memo_id,
secured_payload,
workspaceCode,
abcFeedbackId,
)
@ticket_router.put(
"/{ticket_id}/comments/{comment_id}",
response_model=SupportTicketCommentRecord,
response_description="수정된 공개 댓글",
summary="공개 댓글 수정",
description="댓글 작성자 본인의 공개 댓글을 수정합니다. 내부 메모는 별도 내부 메모 API로 수정합니다.",
tags=["comments"],
dependencies=[Depends(require_ticket_write_access)],
)
def update_ticket_comment(
ticket_id: int,
comment_id: int,
payload: TicketCommentUpdateRequest,
workspaceCode: str | None = None,
abcFeedbackId: int | None = None,
principal: SsoPrincipal = Depends(get_principal),
db: Session = Depends(get_db),
) -> SupportTicketCommentRecord:
secured_payload = payload.model_copy(update={
"requester_id": principal.user_id,
"requester_tenant_id": principal.tenant_id,
})
return service.update_ticket_comment(
db,
ticket_id,
comment_id,
secured_payload,
workspaceCode,
abcFeedbackId,
)
@ticket_router.delete(
"/{ticket_id}/internal-memos/{memo_id}",
response_model=TicketDeleteResponse,
response_description="삭제 결과",
summary="내부 메모 삭제",
description="관리자 전용 내부 메모를 삭제합니다.",
tags=["internal-memos"],
dependencies=[Depends(require_ticket_manager_access)],
)
def delete_internal_memo(
ticket_id: int,
memo_id: int,
workspaceCode: str | None = None,
abcFeedbackId: int | None = None,
principal: SsoPrincipal = Depends(get_principal),
db: Session = Depends(get_db),
) -> dict[str, int | bool]:
return service.delete_internal_memo(
db,
ticket_id,
memo_id,
principal.user_id,
principal.tenant_id,
workspaceCode,
abcFeedbackId,
True,
)
@ticket_router.delete(
"/{ticket_id}/comments/{comment_id}",
response_model=TicketDeleteResponse,
response_description="삭제 결과",
summary="공개 댓글 삭제",
description="댓글 작성자 본인 또는 관리자 권한으로 공개 댓글을 삭제합니다.",
tags=["comments"],
dependencies=[Depends(require_ticket_write_access)],
)
def delete_ticket_comment(
ticket_id: int,
comment_id: int,
workspaceCode: str | None = None,
abcFeedbackId: int | None = None,
principal: SsoPrincipal = Depends(get_principal),
db: Session = Depends(get_db),
) -> dict[str, int | bool]:
return service.delete_ticket_comment(
db,
ticket_id,
comment_id,
principal.user_id,
principal.tenant_id,
workspaceCode,
abcFeedbackId,
can_manage_ticket_comments(
db,
principal,
ticket_id,
workspaceCode,
abcFeedbackId,
),
)
@ticket_router.post("/{ticket_id}/approve", response_model=TicketMutationResponse, dependencies=[Depends(require_ticket_write_access)])
def approve_ticket(ticket_id: int, db: Session = Depends(get_db)) -> TicketMutationResponse:
return service.approve_ticket(db, ticket_id)
@ticket_router.post("/{ticket_id}/issue", response_model=TicketMutationResponse, dependencies=[Depends(require_ticket_write_access)])
def create_issue(ticket_id: int, db: Session = Depends(get_db)) -> TicketMutationResponse:
return service.create_issue(db, ticket_id)
@ticket_router.post("/{ticket_id}/issue-link-status", dependencies=[Depends(require_ticket_write_access)])
def sync_issue_link_status(
ticket_id: int,
payload: TicketIssueLinkStatusSyncRequest,
workspaceCode: str | None = None,
db: Session = Depends(get_db),
) -> dict[str, int | str | bool]:
return service.sync_issue_link_status(db, ticket_id, payload.linked, workspaceCode)
+1
View File
@@ -0,0 +1 @@
"""Core configuration and shared services."""
+92
View File
@@ -0,0 +1,92 @@
from dataclasses import dataclass
import logging
from typing import Any
import jwt
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jwt import InvalidTokenError
from app.core.config import settings
logger = logging.getLogger(__name__)
_bearer = HTTPBearer(auto_error=False)
@dataclass(frozen=True)
class SsoPrincipal:
user_id: str
tenant_id: str
local_user_id: int | None
email: str | None
name: str | None
department: str | None
phone_number: str | None
user_type: str | None
tenant_ids: list[str]
def get_principal(
credentials: HTTPAuthorizationCredentials | None = Depends(_bearer),
) -> SsoPrincipal:
if credentials is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="BARON-SSO 로그인 세션이 필요합니다.",
)
if not settings.jwt_secret:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="JWT_SECRET이 secretary-api에 설정되지 않았습니다.",
)
try:
payload: dict[str, Any] = jwt.decode(
credentials.credentials,
settings.jwt_secret,
algorithms=["HS256"],
# Older ABC-issued sessions encode the local mirror id as a JSON
# number. Secretary authorization uses sso_sub + tenant_id, so
# accepting either numeric or string local `sub` keeps those
# sessions valid without weakening signature/expiry validation.
options={"require": ["exp", "sub"], "verify_sub": False},
)
except InvalidTokenError as error:
# Never log the bearer token itself. The exception type is enough to
# distinguish expiry, signature, format, and claim validation errors.
logger.warning("JWT validation failed: %s", type(error).__name__)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="유효하지 않은 로그인 세션입니다.",
) from error
sso_subject = payload.get("sso_sub")
tenant_id = payload.get("tenant_id")
if not isinstance(sso_subject, str) or not sso_subject:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="SSO 사용자 식별자가 세션에 없습니다.",
)
if not isinstance(tenant_id, str) or not tenant_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="SSO tenant_id가 세션에 없습니다.",
)
local_user_id = payload.get("sub")
raw_tenant_ids = payload.get("tenant_ids")
return SsoPrincipal(
user_id=sso_subject,
tenant_id=tenant_id,
local_user_id=int(local_user_id) if str(local_user_id).isdigit() else None,
email=payload.get("email") if isinstance(payload.get("email"), str) else None,
name=payload.get("name") if isinstance(payload.get("name"), str) else None,
department=payload.get("department") if isinstance(payload.get("department"), str) else None,
phone_number=payload.get("phone_number") if isinstance(payload.get("phone_number"), str) else None,
user_type=payload.get("type") if isinstance(payload.get("type"), str) else None,
tenant_ids=[
tenant_id
for tenant_id in (raw_tenant_ids if isinstance(raw_tenant_ids, list) else [])
if isinstance(tenant_id, str) and tenant_id
],
)
+64
View File
@@ -0,0 +1,64 @@
from pathlib import Path
from pydantic import BaseModel, Field
from pydantic_settings import BaseSettings, SettingsConfigDict
REPO_ROOT = Path(__file__).resolve().parents[4]
DEFAULT_UPLOAD_ROOT = REPO_ROOT / "uploads"
class ABCProjectTarget(BaseModel):
project_id: int = Field(gt=0)
channel_id: int = Field(gt=0)
project_name: str | None = None
channel_name: str | None = None
api_key: str | None = None
class Settings(BaseSettings):
app_name: str = "secretary-api"
app_env: str = "local"
app_host: str = "0.0.0.0"
app_port: int = 8010
docs_root_path: str = ""
database_url: str = "mysql+pymysql://baron_support:baron_support@127.0.0.1:13308/baron_support"
abc_api_base_url: str = "http://localhost:4000"
abc_api_key: str = ""
master_api_key: str = ""
upload_root_dir: str = str(DEFAULT_UPLOAD_ROOT)
upload_max_file_size_mb: int = 30
storage_provider: str = "LOCAL"
r2_endpoint: str = ""
r2_access_key_id: str = ""
r2_secret_access_key: str = ""
r2_bucket: str = ""
r2_region: str = "auto"
r2_download_url_expiry_seconds: int = 300
sso_issuer: str = ""
sso_client_id: str = ""
sso_client_secret: str = ""
jwt_secret: str = ""
admin_candidate_tenant_id: str = ""
initial_super_admin_phone_number: str = ""
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
@property
def is_r2_enabled(self) -> bool:
# LOCAL remains the safe local-development default. Once R2 is
# explicitly selected, incomplete credentials must fail the upload
# instead of silently writing a new binary to disk.
return self.storage_provider.upper() == "R2"
@property
def has_r2_config(self) -> bool:
return (
bool(self.r2_endpoint)
and bool(self.r2_access_key_id)
and bool(self.r2_secret_access_key)
and bool(self.r2_bucket)
)
settings = Settings()
+1
View File
@@ -0,0 +1 @@
"""Database package."""
+5
View File
@@ -0,0 +1,5 @@
from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase):
pass
+326
View File
@@ -0,0 +1,326 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import JSON, BigInteger, Boolean, ForeignKey, String, Text, TIMESTAMP, UniqueConstraint, text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base
class SupportUser(Base):
"""Local support identity keyed by the BARON-SSO subject and tenant."""
__tablename__ = "support_users"
__table_args__ = (
UniqueConstraint("sso_subject", "tenant_id", name="uq_support_users_subject_tenant"),
)
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
sso_subject: Mapped[str] = mapped_column(String(100))
tenant_id: Mapped[str] = mapped_column(String(100))
email: Mapped[str | None] = mapped_column(String(320), nullable=True)
name: Mapped[str | None] = mapped_column(String(100), nullable=True)
department: Mapped[str | None] = mapped_column(String(100), nullable=True)
phone_number: Mapped[str | None] = mapped_column(String(50), nullable=True)
tenant_ids: Mapped[list[str] | None] = mapped_column(JSON, nullable=True)
status: Mapped[str] = mapped_column(String(20), server_default=text("'ACTIVE'"))
created_at: Mapped[datetime] = mapped_column(TIMESTAMP, server_default=text("CURRENT_TIMESTAMP"))
updated_at: Mapped[datetime] = mapped_column(
TIMESTAMP,
server_default=text("CURRENT_TIMESTAMP"),
server_onupdate=text("CURRENT_TIMESTAMP"),
)
role_assignments: Mapped[list[SupportRoleAssignment]] = relationship(back_populates="user")
class SupportRole(Base):
__tablename__ = "support_roles"
code: Mapped[str] = mapped_column(String(40), primary_key=True)
name: Mapped[str] = mapped_column(String(100))
description: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(TIMESTAMP, server_default=text("CURRENT_TIMESTAMP"))
assignments: Mapped[list[SupportRoleAssignment]] = relationship(back_populates="role")
class SupportRoleAssignment(Base):
__tablename__ = "support_role_assignments"
__table_args__ = (
UniqueConstraint(
"support_user_id",
"role_code",
"workspace_id",
name="uq_support_role_assignment_user_role_workspace",
),
)
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
support_user_id: Mapped[int] = mapped_column(ForeignKey("support_users.id"))
role_code: Mapped[str] = mapped_column(ForeignKey("support_roles.code"))
workspace_id: Mapped[int | None] = mapped_column(ForeignKey("workspaces.id"), nullable=True)
created_at: Mapped[datetime] = mapped_column(TIMESTAMP, server_default=text("CURRENT_TIMESTAMP"))
user: Mapped[SupportUser] = relationship(back_populates="role_assignments")
role: Mapped[SupportRole] = relationship(back_populates="assignments")
workspace: Mapped[Workspace | None] = relationship()
class Workspace(Base):
__tablename__ = "workspaces"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
workspace_type: Mapped[str] = mapped_column(String(20))
software_app_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
service_type_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
workspace_code: Mapped[str] = mapped_column(String(50), unique=True)
workspace_name: Mapped[str] = mapped_column(String(100))
is_active: Mapped[bool] = mapped_column(Boolean, server_default=text("1"))
created_at: Mapped[datetime] = mapped_column(TIMESTAMP, server_default=text("CURRENT_TIMESTAMP"))
tickets: Mapped[list[SupportTicket]] = relationship(back_populates="workspace")
approval_policies: Mapped[list[WorkspaceApprovalPolicy]] = relationship(back_populates="workspace")
attachments: Mapped[list[Attachment]] = relationship(back_populates="workspace")
access_entries: Mapped[list[UserWorkspaceAccess]] = relationship(back_populates="workspace")
role_assignments: Mapped[list[SupportRoleAssignment]] = relationship(back_populates="workspace")
channel_mappings: Mapped[list[WorkspaceChannelMapping]] = relationship(
"WorkspaceChannelMapping",
back_populates="workspace",
)
class WorkspaceChannelMapping(Base):
__tablename__ = "workspace_channel_mappings"
__table_args__ = (
UniqueConstraint(
"workspace_id",
"abc_channel_id",
name="uq_workspace_channel_mappings_workspace_channel",
),
)
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
workspace_id: Mapped[int] = mapped_column(ForeignKey("workspaces.id"))
abc_project_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
abc_channel_id: Mapped[str] = mapped_column(String(100))
abc_channel_key: Mapped[str | None] = mapped_column(String(100), nullable=True)
form_template_version: Mapped[str | None] = mapped_column(String(30), nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, server_default=text("1"))
created_at: Mapped[datetime] = mapped_column(
TIMESTAMP,
server_default=text("CURRENT_TIMESTAMP"),
)
workspace: Mapped[Workspace] = relationship(
"Workspace",
back_populates="channel_mappings",
)
class UserWorkspaceAccess(Base):
__tablename__ = "user_workspace_access"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
user_id: Mapped[str] = mapped_column(String(100))
tenant_id: Mapped[str] = mapped_column(String(100))
workspace_id: Mapped[int] = mapped_column(ForeignKey("workspaces.id"))
workspace_role: Mapped[str] = mapped_column(String(30), server_default=text("'END_USER'"))
can_read: Mapped[bool] = mapped_column(Boolean, server_default=text("1"))
can_write: Mapped[bool] = mapped_column(Boolean, server_default=text("0"))
can_manage: Mapped[bool] = mapped_column(Boolean, server_default=text("0"))
can_approve: Mapped[bool] = mapped_column(Boolean, server_default=text("0"))
page_scope: Mapped[str | None] = mapped_column(String(50), nullable=True)
created_at: Mapped[datetime] = mapped_column(TIMESTAMP, server_default=text("CURRENT_TIMESTAMP"))
workspace: Mapped[Workspace] = relationship(back_populates="access_entries")
class SupportTicket(Base):
__tablename__ = "support_tickets"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
workspace_id: Mapped[int] = mapped_column(ForeignKey("workspaces.id"))
requester_id: Mapped[str] = mapped_column(String(100))
requester_tenant_id: Mapped[str] = mapped_column(String(100))
requester_contact: Mapped[str | None] = mapped_column(String(100), nullable=True)
requester_email: Mapped[str | None] = mapped_column(String(320), nullable=True)
requester_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
requester_department: Mapped[str | None] = mapped_column(String(100), nullable=True)
requester_phone_number: Mapped[str | None] = mapped_column(String(50), nullable=True)
ticket_type: Mapped[str] = mapped_column(String(30))
source_system: Mapped[str] = mapped_column(String(30), server_default=text("'ABC'"))
title: Mapped[str] = mapped_column(String(255))
description: Mapped[str] = mapped_column(Text)
category_code: Mapped[str | None] = mapped_column(String(20), nullable=True)
status_code: Mapped[str] = mapped_column(String(20))
feedback_status: Mapped[str] = mapped_column(
String(20),
nullable=False,
server_default=text("'NEW'"),
)
approval_status: Mapped[str] = mapped_column(String(20), server_default=text("'NOT_REQUIRED'"))
sync_status: Mapped[str] = mapped_column(String(20), server_default=text("'PENDING'"))
issue_link_status: Mapped[str] = mapped_column(String(20), server_default=text("'NOT_REQUIRED'"))
external_issue_status: Mapped[str | None] = mapped_column(String(30), nullable=True)
is_secret: Mapped[bool] = mapped_column(Boolean, server_default=text("0"))
requires_approval: Mapped[bool] = mapped_column(Boolean, server_default=text("0"))
current_assignee_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
current_assignee_tenant_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
requested_start_at: Mapped[datetime | None] = mapped_column(TIMESTAMP, nullable=True)
requested_end_at: Mapped[datetime | None] = mapped_column(TIMESTAMP, nullable=True)
priority: Mapped[str] = mapped_column(String(20), server_default=text("'NORMAL'"))
extra_fields: Mapped[dict[str, str] | None] = mapped_column(JSON, nullable=True)
created_at: Mapped[datetime] = mapped_column(TIMESTAMP, server_default=text("CURRENT_TIMESTAMP"))
updated_at: Mapped[datetime] = mapped_column(
TIMESTAMP,
server_default=text("CURRENT_TIMESTAMP"),
server_onupdate=text("CURRENT_TIMESTAMP"),
)
workspace: Mapped[Workspace] = relationship(back_populates="tickets")
feedback_mapping: Mapped[ABCFeedbackMapping | None] = relationship(back_populates="ticket", uselist=False)
approvals: Mapped[list[RequestApproval]] = relationship(back_populates="ticket")
comments: Mapped[list[TicketComment]] = relationship(back_populates="ticket")
attachments: Mapped[list[Attachment]] = relationship(back_populates="ticket")
class ABCFeedbackMapping(Base):
__tablename__ = "abc_feedback_mappings"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
ticket_id: Mapped[int] = mapped_column(ForeignKey("support_tickets.id"), unique=True)
workspace_id: Mapped[int] = mapped_column(ForeignKey("workspaces.id"))
abc_channel_id: Mapped[str] = mapped_column(String(100))
abc_feedback_id: Mapped[str] = mapped_column(String(100), unique=True)
abc_feedback_url: Mapped[str | None] = mapped_column(Text, nullable=True)
sync_status: Mapped[str] = mapped_column(String(20), server_default=text("'SYNCED'"))
last_synced_at: Mapped[datetime] = mapped_column(TIMESTAMP, server_default=text("CURRENT_TIMESTAMP"))
created_at: Mapped[datetime] = mapped_column(TIMESTAMP, server_default=text("CURRENT_TIMESTAMP"))
ticket: Mapped[SupportTicket] = relationship(back_populates="feedback_mapping")
class RequestApproval(Base):
__tablename__ = "request_approvals"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
ticket_id: Mapped[int] = mapped_column(ForeignKey("support_tickets.id"))
workflow_policy_id: Mapped[int | None] = mapped_column(ForeignKey("workspace_approval_policies.id"), nullable=True)
workflow_step_id: Mapped[int | None] = mapped_column(ForeignKey("workspace_approval_steps.id"), nullable=True)
step_order: Mapped[int | None] = mapped_column(nullable=True)
approver_id: Mapped[str] = mapped_column(String(100))
approver_tenant_id: Mapped[str] = mapped_column(String(100))
approval_status: Mapped[str] = mapped_column(String(20))
comment: Mapped[str | None] = mapped_column(Text, nullable=True)
approved_at: Mapped[datetime | None] = mapped_column(TIMESTAMP, nullable=True)
created_at: Mapped[datetime] = mapped_column(TIMESTAMP, server_default=text("CURRENT_TIMESTAMP"))
ticket: Mapped[SupportTicket] = relationship(back_populates="approvals")
workflow_policy: Mapped[WorkspaceApprovalPolicy | None] = relationship(back_populates="approval_history")
workflow_step: Mapped[WorkspaceApprovalStep | None] = relationship(back_populates="approval_history")
class WorkspaceApprovalPolicy(Base):
__tablename__ = "workspace_approval_policies"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
workspace_id: Mapped[int] = mapped_column(ForeignKey("workspaces.id"))
request_category_code: Mapped[str | None] = mapped_column(String(50), nullable=True)
ticket_type: Mapped[str | None] = mapped_column(String(30), nullable=True)
rule_name: Mapped[str] = mapped_column(String(100))
description: Mapped[str | None] = mapped_column(Text, nullable=True)
conditions: Mapped[dict[str, str] | None] = mapped_column(JSON, nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, server_default=text("1"))
version: Mapped[int] = mapped_column(server_default=text("1"))
created_by: Mapped[str | None] = mapped_column(String(100), nullable=True)
created_at: Mapped[datetime] = mapped_column(TIMESTAMP, server_default=text("CURRENT_TIMESTAMP"))
updated_at: Mapped[datetime] = mapped_column(
TIMESTAMP,
server_default=text("CURRENT_TIMESTAMP"),
server_onupdate=text("CURRENT_TIMESTAMP"),
)
workspace: Mapped[Workspace] = relationship(back_populates="approval_policies")
steps: Mapped[list[WorkspaceApprovalStep]] = relationship(back_populates="policy")
approval_history: Mapped[list[RequestApproval]] = relationship(back_populates="workflow_policy")
class WorkspaceApprovalStep(Base):
__tablename__ = "workspace_approval_steps"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
policy_id: Mapped[int] = mapped_column(ForeignKey("workspace_approval_policies.id"))
step_order: Mapped[int] = mapped_column()
approver_type: Mapped[str] = mapped_column(String(30))
approver_key: Mapped[str] = mapped_column(String(100))
approver_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
approval_mode: Mapped[str] = mapped_column(String(20), server_default=text("'ALL'"))
is_required: Mapped[bool] = mapped_column(Boolean, server_default=text("1"))
sla_hours: Mapped[int | None] = mapped_column(nullable=True)
created_at: Mapped[datetime] = mapped_column(TIMESTAMP, server_default=text("CURRENT_TIMESTAMP"))
policy: Mapped[WorkspaceApprovalPolicy] = relationship(back_populates="steps")
approval_history: Mapped[list[RequestApproval]] = relationship(back_populates="workflow_step")
class TicketComment(Base):
__tablename__ = "ticket_comments"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
ticket_id: Mapped[int] = mapped_column(ForeignKey("support_tickets.id"))
parent_comment_id: Mapped[int | None] = mapped_column(ForeignKey("ticket_comments.id"), nullable=True)
author_id: Mapped[str] = mapped_column(String(100))
author_tenant_id: Mapped[str] = mapped_column(String(100))
author_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
comment_type: Mapped[str] = mapped_column(String(20), server_default=text("'COMMENT'"))
content: Mapped[str] = mapped_column(Text)
is_internal: Mapped[bool] = mapped_column(Boolean, server_default=text("0"))
abc_comment_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
sync_status: Mapped[str] = mapped_column(String(20), server_default=text("'PENDING'"))
edited_at: Mapped[datetime | None] = mapped_column(TIMESTAMP, nullable=True)
created_at: Mapped[datetime] = mapped_column(TIMESTAMP, server_default=text("CURRENT_TIMESTAMP"))
updated_at: Mapped[datetime] = mapped_column(
TIMESTAMP,
server_default=text("CURRENT_TIMESTAMP"),
server_onupdate=text("CURRENT_TIMESTAMP"),
)
deleted_at: Mapped[datetime | None] = mapped_column(TIMESTAMP, nullable=True)
ticket: Mapped[SupportTicket] = relationship(back_populates="comments")
parent_comment: Mapped[TicketComment | None] = relationship(remote_side=[id])
attachments: Mapped[list[Attachment]] = relationship(back_populates="comment")
class Attachment(Base):
__tablename__ = "attachments"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
ticket_id: Mapped[int] = mapped_column(ForeignKey("support_tickets.id"))
comment_id: Mapped[int | None] = mapped_column(ForeignKey("ticket_comments.id"), nullable=True)
workspace_id: Mapped[int] = mapped_column(ForeignKey("workspaces.id"))
uploader_id: Mapped[str] = mapped_column(String(100))
uploader_tenant_id: Mapped[str] = mapped_column(String(100))
original_file_name: Mapped[str] = mapped_column(String(255))
stored_file_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
storage_provider: Mapped[str] = mapped_column(String(30), server_default=text("'LOCAL'"))
storage_bucket: Mapped[str | None] = mapped_column(String(255), nullable=True)
storage_key: Mapped[str] = mapped_column(String(500))
mime_type: Mapped[str | None] = mapped_column(String(100), nullable=True)
file_extension: Mapped[str | None] = mapped_column(String(20), nullable=True)
file_size: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
checksum_sha256: Mapped[str | None] = mapped_column(String(64), nullable=True)
abc_attachment_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
attachment_status: Mapped[str] = mapped_column(String(20), server_default=text("'ACTIVE'"))
created_at: Mapped[datetime] = mapped_column(TIMESTAMP, server_default=text("CURRENT_TIMESTAMP"))
updated_at: Mapped[datetime] = mapped_column(
TIMESTAMP,
server_default=text("CURRENT_TIMESTAMP"),
server_onupdate=text("CURRENT_TIMESTAMP"),
)
deleted_at: Mapped[datetime | None] = mapped_column(TIMESTAMP, nullable=True)
ticket: Mapped[SupportTicket] = relationship(back_populates="attachments")
comment: Mapped[TicketComment | None] = relationship(back_populates="attachments")
workspace: Mapped[Workspace] = relationship(back_populates="attachments")
+16
View File
@@ -0,0 +1,16 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from app.core.config import settings
engine = create_engine(settings.database_url, future=True)
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
def get_db() -> Session:
database = SessionLocal()
try:
yield database
finally:
database.close()
+103
View File
@@ -0,0 +1,103 @@
from http import HTTPStatus
import re
from typing import Any
from fastapi import FastAPI, HTTPException, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from app.api.router import api_router
from app.core.config import settings
from app.schemas.errors import ApiErrorResponse
def _error_name(status_code: int) -> str:
try:
phrase = HTTPStatus(status_code).phrase
except ValueError:
return "HTTP_ERROR"
return re.sub(r"[^A-Z0-9]+", "_", phrase.upper()).strip("_")
def _error_message(detail: Any) -> str | list[str]:
if isinstance(detail, str):
return detail
if isinstance(detail, list):
messages = [
str(item.get("msg", item)) if isinstance(item, dict) else str(item)
for item in detail
]
return messages or "Request validation failed."
return str(detail)
def _error_payload(
request: Request,
status_code: int,
message: str | list[str],
error: str,
) -> dict[str, Any]:
return ApiErrorResponse(
code=error,
message=message,
error=error,
statusCode=status_code,
path=request.url.path,
).model_dump(by_alias=True)
async def _http_exception_handler(
request: Request,
exception: HTTPException,
) -> JSONResponse:
error = _error_name(exception.status_code)
return JSONResponse(
status_code=exception.status_code,
content=_error_payload(
request,
exception.status_code,
_error_message(exception.detail),
error,
),
)
async def _validation_exception_handler(
request: Request,
exception: RequestValidationError,
) -> JSONResponse:
return JSONResponse(
status_code=422,
content=_error_payload(
request,
422,
_error_message(exception.errors()),
"VALIDATION_ERROR",
),
)
def _register_exception_handlers(application: FastAPI) -> None:
application.add_exception_handler(HTTPException, _http_exception_handler)
application.add_exception_handler(
RequestValidationError,
_validation_exception_handler,
)
def create_app() -> FastAPI:
error_responses = {
status_code: {"model": ApiErrorResponse}
for status_code in (400, 401, 403, 404, 422, 500)
}
application = FastAPI(
title=settings.app_name,
root_path=settings.docs_root_path,
responses=error_responses,
)
_register_exception_handlers(application)
application.include_router(api_router)
return application
app = create_app()
@@ -0,0 +1 @@
"""Pydantic schemas for secretary-api."""
+19
View File
@@ -0,0 +1,19 @@
"""Shared error response schemas for the public Secretary API contract."""
from pydantic import BaseModel, ConfigDict, Field
class ApiErrorResponse(BaseModel):
"""Stable error shape shared with the NestJS API."""
model_config = ConfigDict(populate_by_name=True)
code: str = Field(description="Machine-readable error code", examples=["NOT_FOUND"])
message: str | list[str] = Field(description="Human-readable error message")
error: str = Field(description="Error category")
status_code: int = Field(
alias="statusCode",
description="HTTP status code",
examples=[404],
)
path: str = Field(description="Request path", examples=["/api/tickets/1"])
+222
View File
@@ -0,0 +1,222 @@
from typing import Literal
from pydantic import BaseModel, Field
class TicketCreateRequest(BaseModel):
workspace_code: str = Field(default="", max_length=50)
requester_id: str = Field(..., min_length=1, max_length=100)
requester_tenant_id: str = Field(..., min_length=1, max_length=100)
requester_contact: str | None = Field(default=None, max_length=100)
requester_email: str | None = Field(default=None, max_length=320)
requester_name: str | None = Field(default=None, max_length=100)
requester_department: str | None = Field(default=None, max_length=100)
requester_phone_number: str | None = Field(default=None, max_length=50)
title: str = Field(..., min_length=1, max_length=255)
description: str = Field(..., min_length=1)
category_code: str | None = Field(default=None, max_length=20)
ticket_type: str = Field(default="GENERAL")
is_secret: bool = False
requires_approval: bool = False
extra_fields: dict[str, str] = Field(default_factory=dict)
class TicketUpdateRequest(BaseModel):
title: str | None = Field(default=None, min_length=1, max_length=255)
description: str | None = Field(default=None, min_length=1)
requester_contact: str | None = Field(default=None, max_length=100)
is_secret: bool | None = None
extra_fields: dict[str, str] | None = None
class TicketCreateResponse(BaseModel):
ticket_id: int
workspace_code: str
status_code: str
feedback_status: str
sync_status: str
message: str
FeedbackStatus = Literal["INIT", "ON_REVIEW", "DETAILED_REVIEW", "IN_PROGRESS", "RESOLVED", "PENDING"]
class FeedbackStatusUpdateRequest(BaseModel):
feedback_status: FeedbackStatus
class SupportFeedbackStatusRecord(BaseModel):
feedback_id: int
feedback_status: FeedbackStatus
updated_at: str
class SupportFeedbackMetrics(BaseModel):
project_id: int
channel_id: int | None = None
today_first_count: int
answer_waiting_count: int
issue_linked_count: int
issue_link_rate: float
average_processing_minutes: float | None = None
class WorkspaceSummary(BaseModel):
workspace_code: str
workspace_name: str
workspace_type: str
class WorkspaceFormField(BaseModel):
field_code: str
label: str
field_type: str
required: bool = False
class WorkspaceFormTemplateResponse(BaseModel):
workspace_code: str
workspace_name: str
requires_approval: bool
fields: list[WorkspaceFormField]
class SupportActivityItem(BaseModel):
id: str
label: str
detail: str
timestamp: str
class SupportAttachmentRecord(BaseModel):
attachment_id: int = Field(description="첨부파일 식별자")
original_file_name: str = Field(description="사용자가 업로드한 원본 파일명")
mime_type: str | None = Field(default=None, description="MIME 타입")
file_size: int | None = Field(default=None, description="파일 크기(bytes)")
created_at: str = Field(description="첨부파일 생성 시각")
class TicketCommentCreateRequest(BaseModel):
requester_id: str = Field(
...,
min_length=1,
max_length=100,
description="인증된 작성자 식별자. API 서버가 로그인 사용자 정보로 덮어쓴다.",
)
requester_tenant_id: str = Field(
...,
min_length=1,
max_length=100,
description="인증된 작성자 테넌트 식별자. API 서버가 로그인 사용자 정보로 덮어쓴다.",
)
author_name: str | None = Field(
default=None,
max_length=100,
description="작성자 표시명. 인증 사용자 정보로 서버가 결정한다.",
)
content: str = Field(default="", description="댓글 본문")
is_internal: bool = Field(
default=False,
description="서버 내부용 필드. 일반 댓글 API에서는 무시되며 내부 메모 API가 true로 설정한다.",
)
class TicketCommentUpdateRequest(BaseModel):
requester_id: str = Field(..., min_length=1, max_length=100)
requester_tenant_id: str = Field(..., min_length=1, max_length=100)
content: str = Field(..., min_length=1)
class InternalMemoCreateRequest(BaseModel):
content: str = Field(..., min_length=1, description="관리자만 조회할 내부 메모 본문")
class InternalMemoUpdateRequest(BaseModel):
content: str = Field(..., min_length=1, description="수정할 내부 메모 본문")
class TicketIssueLinkStatusSyncRequest(BaseModel):
linked: bool
class TicketAssigneeCandidate(BaseModel):
user_id: str
tenant_id: str
name: str | None = None
email: str | None = None
department: str | None = None
phone_number: str | None = None
role_code: str
class TicketAssigneeUpdateRequest(BaseModel):
assignee_id: str | None = None
assignee_tenant_id: str | None = None
class SupportTicketCommentRecord(BaseModel):
comment_id: int = Field(description="댓글 또는 내부 메모 식별자")
ticket_id: int = Field(description="지원 요청 식별자")
author_id: str = Field(description="작성자 식별자")
author_tenant_id: str = Field(description="작성자 테넌트 식별자")
author_name: str = Field(description="작성자 표시명")
content: str = Field(description="댓글 또는 내부 메모 본문")
is_internal: bool = Field(description="관리자 전용 내부 메모 여부")
comment_type: str = Field(default="COMMENT", description="COMMENT 또는 INTERNAL_MEMO")
created_at: str = Field(description="작성 시각")
updated_at: str = Field(description="수정 시각")
edited_at: str | None = Field(default=None, description="마지막 수정 시각")
attachments: list[SupportAttachmentRecord] = Field(default_factory=list)
can_edit: bool = False
can_delete: bool = False
class TicketDeleteResponse(BaseModel):
deleted: bool = Field(description="삭제 처리 여부")
ticket_id: int = Field(description="지원 요청 식별자")
comment_id: int | None = Field(default=None, description="삭제된 댓글 또는 내부 메모 식별자")
feedback_id: int | None = Field(default=None, description="연결된 ABC 피드백 식별자")
class SupportTicketRecord(BaseModel):
ticket_id: int
workspace_code: str
workspace_name: str
title: str
description: str
requester_id: str
requester_tenant_id: str
requester_contact: str
requester_email: str | None
requester_name: str | None
requester_department: str | None
requester_phone_number: str | None
category_code: str
ticket_type: str
requires_approval: bool
status_code: str
feedback_status: str
approval_status: str
sync_status: str
issue_link_status: str
external_issue_status: str | None = None
assignee_id: str | None = None
assignee_tenant_id: str | None = None
assignee_name: str | None = None
assignee_email: str | None = None
is_secret: bool
created_at: str
updated_at: str
extra_fields: dict[str, str]
attachments: list[SupportAttachmentRecord] = Field(default_factory=list)
activity: list[SupportActivityItem]
class TicketMutationResponse(BaseModel):
ticket_id: int
status_code: str
feedback_status: str
approval_status: str
issue_link_status: str
external_issue_status: str | None = None
updated_at: str
@@ -0,0 +1 @@
"""Service layer for secretary-api."""
@@ -0,0 +1,376 @@
from sqlalchemy import and_, func, or_, select
from sqlalchemy.dialects.mysql import insert as mysql_insert
from sqlalchemy.orm import Session
from app.core.auth import SsoPrincipal
from app.db.models import (
SupportRoleAssignment,
SupportUser,
UserWorkspaceAccess,
Workspace,
)
class AccessService:
ROLE_PRIORITY = {
"SYSTEM_ADMIN": 5,
"SUPER_ADMIN": 4,
"PROJECT_MANAGER": 3,
"FEEDBACK_PROVIDER": 2,
"END_USER": 1,
}
ROLE_PERMISSIONS = {
"SYSTEM_ADMIN": (True, True, True, True),
"SUPER_ADMIN": (True, True, True, True),
"PROJECT_MANAGER": (True, True, True, True),
"FEEDBACK_PROVIDER": (True, True, False, False),
"END_USER": (True, True, False, False),
}
GLOBAL_ADMIN_ROLES = {"SYSTEM_ADMIN", "SUPER_ADMIN"}
MANAGER_ROLES = {"SYSTEM_ADMIN", "SUPER_ADMIN", "PROJECT_MANAGER"}
SUPPORTED_WORKSPACE_ROLES = {
"PROJECT_MANAGER",
"END_USER",
"FEEDBACK_PROVIDER",
}
@staticmethod
def default_user_workspace_code(db: Session) -> str | None:
"""Return the first active ABC project workspace.
The old Q&A_Platform seed workspace must not determine the first SSO
redirect after ABC projects are synchronized.
"""
return db.execute(
select(Workspace.workspace_code)
.where(
Workspace.is_active.is_(True),
Workspace.workspace_type == "SOFTWARE_APP",
)
.order_by(Workspace.id.asc()),
).scalar()
@staticmethod
def _upsert_support_user(
db: Session,
*,
user_id: str,
tenant_id: str,
email: str | None = None,
name: str | None = None,
department: str | None = None,
phone_number: str | None = None,
tenant_ids: list[str] | None = None,
) -> SupportUser:
"""Create or refresh an SSO identity without concurrent insert races.
The access endpoint is called several times in parallel during the
first SSO redirect. A select-then-insert sequence can deadlock on the
unique (sso_subject, tenant_id) index, so use MySQL's atomic upsert.
Empty profile fields never erase values already synchronized.
"""
table = SupportUser.__table__
statement = mysql_insert(table).values(
sso_subject=user_id,
tenant_id=tenant_id,
email=email,
name=name,
department=department,
phone_number=phone_number,
tenant_ids=tenant_ids or None,
)
statement = statement.on_duplicate_key_update(
email=func.coalesce(statement.inserted.email, table.c.email),
name=func.coalesce(statement.inserted.name, table.c.name),
department=func.coalesce(
statement.inserted.department,
table.c.department,
),
phone_number=func.coalesce(
statement.inserted.phone_number,
table.c.phone_number,
),
tenant_ids=func.coalesce(
statement.inserted.tenant_ids,
table.c.tenant_ids,
),
)
db.execute(statement)
return db.execute(
select(SupportUser).where(
SupportUser.sso_subject == user_id,
SupportUser.tenant_id == tenant_id,
),
).scalar_one()
@staticmethod
def ensure_support_user(db: Session, principal: SsoPrincipal) -> SupportUser:
return AccessService._upsert_support_user(
db,
user_id=principal.user_id,
tenant_id=principal.tenant_id,
email=principal.email,
name=principal.name,
department=principal.department,
phone_number=principal.phone_number,
tenant_ids=principal.tenant_ids,
)
@staticmethod
def ensure_support_user_identity(
db: Session,
*,
user_id: str,
tenant_id: str,
email: str | None = None,
name: str | None = None,
department: str | None = None,
phone_number: str | None = None,
tenant_ids: list[str] | None = None,
) -> SupportUser:
return AccessService._upsert_support_user(
db,
user_id=user_id,
tenant_id=tenant_id,
email=email,
name=name,
department=department,
phone_number=phone_number,
tenant_ids=tenant_ids,
)
@staticmethod
def _role_codes(
db: Session,
user: SupportUser,
workspace_id: int | None = None,
*,
global_only: bool = False,
) -> list[str]:
conditions = [SupportRoleAssignment.support_user_id == user.id]
if global_only:
# Global admin roles are global by role code. Older seed data may
# still contain a workspace_id on these assignments.
conditions.append(
SupportRoleAssignment.role_code.in_(AccessService.GLOBAL_ADMIN_ROLES),
)
elif workspace_id is not None:
# Only global administrator roles apply to every workspace. A
# project-scoped role with a NULL workspace is not a shortcut to
# global access.
conditions.append(
or_(
SupportRoleAssignment.workspace_id == workspace_id,
and_(
SupportRoleAssignment.workspace_id.is_(None),
SupportRoleAssignment.role_code.in_(AccessService.GLOBAL_ADMIN_ROLES),
),
),
)
return list(
db.execute(
select(SupportRoleAssignment.role_code).where(*conditions),
).scalars().all(),
)
def is_system_admin(self, db: Session, principal: SsoPrincipal) -> bool:
"""Check only Secretary's internal global role assignments.
The ABC `users.type` claim is intentionally not consulted here. ABC's
users/roles/members tables remain responsible for ABC's own console API.
"""
user = self.ensure_support_user(db, principal)
return bool(
set(self._role_codes(db, user, global_only=True))
& AccessService.GLOBAL_ADMIN_ROLES,
)
def _workspace_role(
self,
db: Session,
user: SupportUser,
workspace_id: int,
) -> str | None:
roles = self._role_codes(db, user, workspace_id)
if not roles:
return None
return max(roles, key=lambda role: self.ROLE_PRIORITY.get(role, 0))
def _apply_role_permissions(
self,
access: UserWorkspaceAccess,
role: str,
) -> UserWorkspaceAccess:
can_read, can_write, can_manage, can_approve = self.ROLE_PERMISSIONS.get(
role,
(False, False, False, False),
)
access.workspace_role = role
access.can_read = can_read
access.can_write = can_write
access.can_manage = can_manage
access.can_approve = can_approve
return access
@staticmethod
def _synthetic_access(
principal: SsoPrincipal,
workspace: Workspace,
role: str,
) -> UserWorkspaceAccess:
access = UserWorkspaceAccess(
user_id=principal.user_id,
tenant_id=principal.tenant_id,
workspace_id=workspace.id,
workspace=workspace,
)
return AccessService()._apply_role_permissions(access, role)
def find_workspace_access(
self,
db: Session,
principal: SsoPrincipal,
workspace_code: str,
default_workspace_code: str | None = None,
) -> UserWorkspaceAccess | None:
user = self.ensure_support_user(db, principal)
workspace = db.execute(
select(Workspace).where(
Workspace.workspace_code == workspace_code,
Workspace.is_active.is_(True),
),
).scalar_one_or_none()
if workspace is None or user.status != "ACTIVE":
return None
global_roles = set(self._role_codes(db, user, global_only=True))
if global_roles & AccessService.GLOBAL_ADMIN_ROLES:
return self._synthetic_access(principal, workspace, "SYSTEM_ADMIN")
role = self._workspace_role(db, user, workspace.id)
if role is None:
# A first-time user can submit feedback in the default workspace
# without creating an END_USER role assignment. Role assignments
# are reserved for explicit workspace administration.
if workspace.workspace_code != (
default_workspace_code or self.default_user_workspace_code(db)
):
return None
role = "END_USER"
access = db.execute(
select(UserWorkspaceAccess).where(
UserWorkspaceAccess.user_id == principal.user_id,
UserWorkspaceAccess.tenant_id == principal.tenant_id,
UserWorkspaceAccess.workspace_id == workspace.id,
),
).scalar_one_or_none()
if access is None:
access = UserWorkspaceAccess(
user_id=principal.user_id,
tenant_id=principal.tenant_id,
workspace_id=workspace.id,
workspace=workspace,
)
else:
access.workspace = workspace
return self._apply_role_permissions(access, role)
def assert_workspace_access(
self,
db: Session,
principal: SsoPrincipal,
workspace_code: str,
*,
write: bool = False,
) -> UserWorkspaceAccess:
access = self.find_workspace_access(db, principal, workspace_code)
if access is None:
from fastapi import HTTPException
raise HTTPException(status_code=403, detail="이 workspace에 대한 권한이 없습니다.")
if write and not access.can_write:
from fastapi import HTTPException
raise HTTPException(status_code=403, detail="이 workspace에 작성 권한이 없습니다.")
if not write and not access.can_read:
from fastapi import HTTPException
raise HTTPException(status_code=403, detail="이 workspace를 조회할 권한이 없습니다.")
return access
def list_accessible_workspaces(
self,
db: Session,
principal: SsoPrincipal,
default_workspace_code: str | None = None,
) -> list[tuple[Workspace, UserWorkspaceAccess]]:
default_workspace_code = (
default_workspace_code or self.default_user_workspace_code(db)
)
workspaces = db.execute(
select(Workspace)
.where(Workspace.is_active.is_(True))
.order_by(Workspace.id.asc()),
).scalars().all()
entries: list[tuple[Workspace, UserWorkspaceAccess]] = []
for workspace in workspaces:
access = self.find_workspace_access(
db,
principal,
workspace.workspace_code,
default_workspace_code=default_workspace_code,
)
if access is not None and (access.can_read or access.can_manage):
entries.append((workspace, access))
return entries
def ensure_default_support_access(
self,
db: Session,
principal: SsoPrincipal,
default_workspace_code: str | None = None,
) -> list[tuple[Workspace, UserWorkspaceAccess]]:
user = self.ensure_support_user(db, principal)
if self.is_system_admin(db, principal):
return self.list_accessible_workspaces(
db,
principal,
default_workspace_code=default_workspace_code,
)
default_workspace_code = (
default_workspace_code or self.default_user_workspace_code(db)
)
if default_workspace_code is None:
return []
workspace = db.execute(
select(Workspace).where(
Workspace.workspace_code == default_workspace_code,
Workspace.is_active.is_(True),
),
).scalar_one_or_none()
if workspace is None:
return []
role = self._workspace_role(db, user, workspace.id) or "END_USER"
access = db.execute(
select(UserWorkspaceAccess).where(
UserWorkspaceAccess.user_id == principal.user_id,
UserWorkspaceAccess.tenant_id == principal.tenant_id,
UserWorkspaceAccess.workspace_id == workspace.id,
),
).scalar_one_or_none()
if access is None:
access = UserWorkspaceAccess(
user_id=principal.user_id,
tenant_id=principal.tenant_id,
workspace_id=workspace.id,
)
db.add(access)
access.workspace = workspace
self._apply_role_permissions(access, role)
db.commit()
db.refresh(access)
access.workspace = workspace
return [(workspace, access)]
@@ -0,0 +1,61 @@
from __future__ import annotations
from typing import Any
from app.core.config import settings
class StorageService:
"""Small S3-compatible adapter used for R2-backed support attachments."""
def __init__(self) -> None:
self._client: Any | None = None
@property
def enabled(self) -> bool:
return settings.is_r2_enabled
def _get_client(self) -> Any:
if not self.enabled or not settings.has_r2_config:
raise RuntimeError("R2 storage is not fully configured.")
if self._client is None:
try:
import boto3
except ImportError as exc:
raise RuntimeError(
"R2 storage requires the boto3 dependency."
) from exc
self._client = boto3.client(
"s3",
endpoint_url=settings.r2_endpoint,
aws_access_key_id=settings.r2_access_key_id,
aws_secret_access_key=settings.r2_secret_access_key,
region_name=settings.r2_region,
)
return self._client
def put_bytes(self, key: str, body: bytes, content_type: str | None) -> None:
self._get_client().put_object(
Bucket=settings.r2_bucket,
Key=key,
Body=body,
ContentType=content_type or "application/octet-stream",
)
def get_bytes(self, key: str, bucket: str | None = None) -> bytes:
response = self._get_client().get_object(
Bucket=bucket or settings.r2_bucket,
Key=key,
)
return response["Body"].read()
def delete(self, key: str, bucket: str | None = None) -> None:
self._get_client().delete_object(
Bucket=bucket or settings.r2_bucket,
Key=key,
)
storage_service = StorageService()
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,318 @@
from __future__ import annotations
import json
from urllib import error, parse, request
from sqlalchemy import select, text
from sqlalchemy.dialects.mysql import insert as mysql_insert
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.core.config import settings
from app.db.models import Workspace, WorkspaceChannelMapping
class WorkspaceMappingService:
def _fetch_remote_mappings(
self,
workspace_codes: list[str],
) -> dict[str, dict[str, object]]:
if not workspace_codes or not settings.master_api_key:
return {}
query = parse.urlencode({"workspaceCodes": ",".join(workspace_codes)})
endpoint = (
f"{settings.abc_api_base_url.rstrip('/')}/api/internal/support/workspace-mappings"
f"?{query}"
)
req = request.Request(
endpoint,
headers={"x-api-key": settings.master_api_key},
method="GET",
)
try:
with request.urlopen(req, timeout=5) as response:
payload = json.loads(response.read().decode("utf-8"))
except (error.HTTPError, error.URLError, TimeoutError, json.JSONDecodeError):
return {}
raw_items = payload.get("items") if isinstance(payload, dict) else None
if not isinstance(raw_items, list):
return {}
mappings: dict[str, dict[str, object]] = {}
for item in raw_items:
if not isinstance(item, dict):
continue
workspace_code = item.get("workspace_code")
project_id = item.get("project_id")
channel_id = item.get("channel_id")
if (
not isinstance(workspace_code, str)
or not isinstance(project_id, int)
or not isinstance(channel_id, int)
or project_id <= 0
or channel_id <= 0
):
continue
mappings[workspace_code] = item
return mappings
def _fetch_remote_projects(self) -> list[dict[str, object]] | None:
if not settings.master_api_key:
return None
endpoint = (
f"{settings.abc_api_base_url.rstrip('/')}/api/internal/support/projects"
)
req = request.Request(
endpoint,
headers={"x-api-key": settings.master_api_key},
method="GET",
)
try:
with request.urlopen(req, timeout=5) as response:
payload = json.loads(response.read().decode("utf-8"))
except (error.HTTPError, error.URLError, TimeoutError, json.JSONDecodeError):
return None
raw_items = payload.get("items") if isinstance(payload, dict) else None
if not isinstance(raw_items, list):
return None
projects: list[dict[str, object]] = []
for item in raw_items:
if not isinstance(item, dict):
continue
project_id = item.get("project_id")
project_name = item.get("project_name")
channel_id = item.get("channel_id")
channel_name = item.get("channel_name")
if (
not isinstance(project_id, int)
or project_id <= 0
or not isinstance(project_name, str)
or not project_name.strip()
or len(project_name) > 50
):
continue
projects.append(
{
"project_id": project_id,
"project_name": project_name.strip(),
"channel_id": channel_id if isinstance(channel_id, int) else None,
"channel_name": channel_name if isinstance(channel_name, str) else None,
},
)
return projects
def sync_project_workspaces(self, db: Session) -> list[str] | None:
"""Mirror ABC projects as selectable Secretary workspaces.
Project creation remains owned by ABC. The management/access APIs call
this lightweight synchronization before returning workspace options, so
newly created projects become assignable without hard-coded seed data.
``None`` means the ABC project list could not be fetched. An empty
list is a successful response with no projects.
"""
projects = self._fetch_remote_projects()
if projects is None:
return None
project_codes = list(
dict.fromkeys(str(project["project_name"]) for project in projects),
)
# Keep the Secretary workspace catalog aligned with ABC. Older seed
# rows such as Q&A_Platform must not become the default SSO target
# after the corresponding ABC project no longer exists.
stale_query = db.query(Workspace).filter(
Workspace.workspace_type == "SOFTWARE_APP",
Workspace.is_active.is_(True),
)
if project_codes:
stale_query = stale_query.filter(
~Workspace.workspace_code.in_(project_codes),
)
stale_count = stale_query.update(
{Workspace.is_active: False},
synchronize_session=False,
)
if not projects:
db.commit()
return project_codes
changed = 0
for project in projects:
project_name = str(project["project_name"])
db.execute(
text(
"""
INSERT INTO software_apps
(app_code, app_name, description, is_active)
VALUES (:app_code, :app_name, :description, 1)
ON DUPLICATE KEY UPDATE
app_name = VALUES(app_name),
description = VALUES(description),
is_active = 1
""",
),
{
"app_code": project_name,
"app_name": project_name,
"description": f"{project_name} Q&A",
},
)
software_app_id = db.execute(
text("SELECT id FROM software_apps WHERE app_code = :app_code"),
{"app_code": project_name},
).scalar_one()
statement = mysql_insert(Workspace.__table__).values(
workspace_type="SOFTWARE_APP",
software_app_id=int(software_app_id),
service_type_id=None,
workspace_code=project_name,
workspace_name=project_name,
is_active=True,
)
statement = statement.on_duplicate_key_update(
software_app_id=statement.inserted.software_app_id,
workspace_name=statement.inserted.workspace_name,
is_active=True,
)
db.execute(statement)
workspace = db.execute(
select(Workspace).where(Workspace.workspace_code == project_name),
).scalar_one()
changed += 1
channel_id = project["channel_id"]
if not isinstance(channel_id, int) or channel_id <= 0:
continue
existing = db.execute(
select(WorkspaceChannelMapping)
.where(
WorkspaceChannelMapping.workspace_id == workspace.id,
WorkspaceChannelMapping.is_active.is_(True),
)
.order_by(WorkspaceChannelMapping.id.asc()),
).scalars().first()
if existing is not None:
existing.abc_project_id = int(project["project_id"])
existing.abc_channel_id = str(channel_id)
existing.abc_channel_key = str(project.get("channel_name") or "")
continue
mapping_statement = mysql_insert(WorkspaceChannelMapping.__table__).values(
workspace_id=workspace.id,
abc_project_id=int(project["project_id"]),
abc_channel_id=str(channel_id),
abc_channel_key=str(project.get("channel_name") or ""),
is_active=True,
)
mapping_statement = mapping_statement.on_duplicate_key_update(
abc_project_id=mapping_statement.inserted.abc_project_id,
abc_channel_key=mapping_statement.inserted.abc_channel_key,
is_active=True,
)
db.execute(mapping_statement)
if changed or stale_count:
try:
db.commit()
except IntegrityError:
# Another access request may have synchronized the same
# project concurrently. Its committed row is authoritative.
db.rollback()
return project_codes
def sync_missing_mappings(
self,
db: Session,
workspace_codes: list[str] | None = None,
) -> int:
statement = select(Workspace).where(Workspace.is_active.is_(True))
if workspace_codes:
statement = statement.where(Workspace.workspace_code.in_(workspace_codes))
workspaces = db.execute(statement.order_by(Workspace.id.asc())).scalars().all()
if not workspaces:
return 0
missing_workspaces: list[Workspace] = []
for workspace in workspaces:
mappings = db.execute(
select(WorkspaceChannelMapping)
.where(
WorkspaceChannelMapping.workspace_id == workspace.id,
WorkspaceChannelMapping.is_active.is_(True),
)
.order_by(WorkspaceChannelMapping.id.asc()),
).scalars().all()
has_valid_mapping = any(
mapping.abc_project_id is not None
and int(mapping.abc_project_id) > 0
and str(mapping.abc_channel_id).isdigit()
and int(mapping.abc_channel_id) > 0
for mapping in mappings
)
if not has_valid_mapping:
missing_workspaces.append(workspace)
if not missing_workspaces:
return 0
remote_mappings = self._fetch_remote_mappings(
[workspace.workspace_code for workspace in missing_workspaces],
)
changed = 0
for workspace in missing_workspaces:
remote = remote_mappings.get(workspace.workspace_code)
if remote is None:
continue
project_id = int(remote["project_id"])
channel_id = str(remote["channel_id"])
existing = db.execute(
select(WorkspaceChannelMapping)
.where(
WorkspaceChannelMapping.workspace_id == workspace.id,
WorkspaceChannelMapping.is_active.is_(True),
)
.order_by(WorkspaceChannelMapping.id.asc()),
).scalars().first()
if existing is None:
existing = WorkspaceChannelMapping(
workspace_id=workspace.id,
abc_project_id=project_id,
abc_channel_id=channel_id,
abc_channel_key=str(remote.get("channel_name") or ""),
is_active=True,
)
db.add(existing)
else:
existing.abc_project_id = project_id
existing.abc_channel_id = channel_id
existing.abc_channel_key = str(remote.get("channel_name") or "")
existing.is_active = True
changed += 1
if changed:
try:
db.commit()
except IntegrityError:
# Another request may have synchronized the same workspace
# between our read and insert. Keep the request successful.
db.rollback()
return changed
workspace_mapping_service = WorkspaceMappingService()