Spaces:
Sleeping
Sleeping
added invited name to user invitaitons
Browse files- alembic/versions/20251120_add_invited_name_to_invitations.py +29 -0
- docs/devlogs/browser/browserconsole.txt +13 -13
- src/app/api/v1/invitations.py +23 -1
- src/app/api/v1/projects.py +20 -14
- src/app/models/invitation.py +1 -0
- src/app/schemas/invitation.py +8 -0
- src/app/services/invitation_service.py +5 -4
alembic/versions/20251120_add_invited_name_to_invitations.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""add invited_name to user_invitations
|
| 2 |
+
|
| 3 |
+
Revision ID: 20251120_invited_name
|
| 4 |
+
Revises:
|
| 5 |
+
Create Date: 2025-11-20 19:15:00
|
| 6 |
+
|
| 7 |
+
"""
|
| 8 |
+
from alembic import op
|
| 9 |
+
import sqlalchemy as sa
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
# revision identifiers, used by Alembic.
|
| 13 |
+
revision = '20251120_invited_name'
|
| 14 |
+
down_revision = None
|
| 15 |
+
branch_labels = None
|
| 16 |
+
depends_on = None
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def upgrade() -> None:
|
| 20 |
+
"""Add invited_name column to user_invitations table"""
|
| 21 |
+
op.add_column(
|
| 22 |
+
'user_invitations',
|
| 23 |
+
sa.Column('invited_name', sa.String(length=200), nullable=True)
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def downgrade() -> None:
|
| 28 |
+
"""Remove invited_name column from user_invitations table"""
|
| 29 |
+
op.drop_column('user_invitations', 'invited_name')
|
docs/devlogs/browser/browserconsole.txt
CHANGED
|
@@ -32,22 +32,22 @@ flushPassiveEffects @ chunk-276SZO74.js?v=23244404:19447
|
|
| 32 |
workLoop @ chunk-276SZO74.js?v=23244404:197
|
| 33 |
flushWork @ chunk-276SZO74.js?v=23244404:176
|
| 34 |
performWorkUntilDeadline @ chunk-276SZO74.js?v=23244404:384
|
| 35 |
-
core.ts:167 GET https://kamau1-swiftops-backend.hf.space/api/v1/auth/me → 200 (
|
| 36 |
-
core.ts:117 ℹ️ [
|
| 37 |
-
core.ts:124 ℹ️ [
|
| 38 |
core.ts:167 %cGET%c https://kamau1-swiftops-backend.hf.space/api/v1/users?skip=0&limit=20
|
| 39 |
core.ts:167 %cGET%c https://kamau1-swiftops-backend.hf.space/api/v1/projects?skip=0&limit=20
|
| 40 |
core.ts:167 %cGET%c https://kamau1-swiftops-backend.hf.space/api/v1/auth/me/preferences
|
| 41 |
-
core.ts:167 GET https://kamau1-swiftops-backend.hf.space/api/v1/
|
| 42 |
-
core.ts:167 GET https://kamau1-swiftops-backend.hf.space/api/v1/projects?skip=0&limit=20 → 200 (
|
| 43 |
-
core.ts:
|
| 44 |
-
core.ts:
|
| 45 |
core.ts:167 %cGET%c https://kamau1-swiftops-backend.hf.space/api/v1/audit-logs?skip=0&limit=20
|
| 46 |
-
core.ts:
|
| 47 |
-
core.ts:117 ℹ️ [18:57:01] [DATA] Fetching available partner organizations {search: undefined, industry: undefined, isActive: true}
|
| 48 |
core.ts:167 %cGET%c https://kamau1-swiftops-backend.hf.space/api/v1/organizations/partners?is_active=true
|
| 49 |
-
core.ts:167 GET https://kamau1-swiftops-backend.hf.space/api/v1/
|
| 50 |
-
core.ts:
|
|
|
|
| 51 |
core.ts:167 %cPOST%c https://kamau1-swiftops-backend.hf.space/api/v1/projects
|
| 52 |
organization.service.ts:414 POST https://kamau1-swiftops-backend.hf.space/api/v1/projects 500 (Internal Server Error)
|
| 53 |
createOrgProject @ organization.service.ts:414
|
|
@@ -67,8 +67,8 @@ dispatchEventForPluginEventSystem @ chunk-276SZO74.js?v=23244404:7173
|
|
| 67 |
dispatchEventWithEnableCapturePhaseSelectiveHydrationWithoutDiscreteEventReplay @ chunk-276SZO74.js?v=23244404:5478
|
| 68 |
dispatchEvent @ chunk-276SZO74.js?v=23244404:5472
|
| 69 |
dispatchDiscreteEvent @ chunk-276SZO74.js?v=23244404:5449
|
| 70 |
-
core.ts:167 POST https://kamau1-swiftops-backend.hf.space/api/v1/projects → 500 (
|
| 71 |
-
core.ts:117 ❌ [
|
| 72 |
log @ core.ts:117
|
| 73 |
error @ core.ts:157
|
| 74 |
createOrgProject @ organization.service.ts:426
|
|
|
|
| 32 |
workLoop @ chunk-276SZO74.js?v=23244404:197
|
| 33 |
flushWork @ chunk-276SZO74.js?v=23244404:176
|
| 34 |
performWorkUntilDeadline @ chunk-276SZO74.js?v=23244404:384
|
| 35 |
+
core.ts:167 GET https://kamau1-swiftops-backend.hf.space/api/v1/auth/me → 200 (561ms)
|
| 36 |
+
core.ts:117 ℹ️ [19:03:33] [COMPONENT] Dashboard component selected {role: 'client_admin', componentName: 'ClientAdminDashboard', componentExists: true}
|
| 37 |
+
core.ts:124 ℹ️ [19:03:33] [DATA] Fetching organization admin dashboard data
|
| 38 |
core.ts:167 %cGET%c https://kamau1-swiftops-backend.hf.space/api/v1/users?skip=0&limit=20
|
| 39 |
core.ts:167 %cGET%c https://kamau1-swiftops-backend.hf.space/api/v1/projects?skip=0&limit=20
|
| 40 |
core.ts:167 %cGET%c https://kamau1-swiftops-backend.hf.space/api/v1/auth/me/preferences
|
| 41 |
+
core.ts:167 GET https://kamau1-swiftops-backend.hf.space/api/v1/auth/me/preferences → 200 (437ms)
|
| 42 |
+
core.ts:167 GET https://kamau1-swiftops-backend.hf.space/api/v1/projects?skip=0&limit=20 → 200 (802ms)
|
| 43 |
+
core.ts:167 GET https://kamau1-swiftops-backend.hf.space/api/v1/users?skip=0&limit=20 → 200 (803ms)
|
| 44 |
+
core.ts:117 ℹ️ [19:03:34] [DATA] Organization dashboard data fetched {usersCount: 1, projectsCount: 0}
|
| 45 |
core.ts:167 %cGET%c https://kamau1-swiftops-backend.hf.space/api/v1/audit-logs?skip=0&limit=20
|
| 46 |
+
core.ts:117 ℹ️ [19:03:35] [DATA] Fetching available partner organizations {search: undefined, industry: undefined, isActive: true}
|
|
|
|
| 47 |
core.ts:167 %cGET%c https://kamau1-swiftops-backend.hf.space/api/v1/organizations/partners?is_active=true
|
| 48 |
+
core.ts:167 GET https://kamau1-swiftops-backend.hf.space/api/v1/audit-logs?skip=0&limit=20 → 200 (1.92s)
|
| 49 |
+
core.ts:167 GET https://kamau1-swiftops-backend.hf.space/api/v1/organizations/partners?is_active=true → 200 (1.05s)
|
| 50 |
+
core.ts:117 ℹ️ [19:04:00] [DATA] Creating organization project {title: 'Atomio Fttx'}
|
| 51 |
core.ts:167 %cPOST%c https://kamau1-swiftops-backend.hf.space/api/v1/projects
|
| 52 |
organization.service.ts:414 POST https://kamau1-swiftops-backend.hf.space/api/v1/projects 500 (Internal Server Error)
|
| 53 |
createOrgProject @ organization.service.ts:414
|
|
|
|
| 67 |
dispatchEventWithEnableCapturePhaseSelectiveHydrationWithoutDiscreteEventReplay @ chunk-276SZO74.js?v=23244404:5478
|
| 68 |
dispatchEvent @ chunk-276SZO74.js?v=23244404:5472
|
| 69 |
dispatchDiscreteEvent @ chunk-276SZO74.js?v=23244404:5449
|
| 70 |
+
core.ts:167 POST https://kamau1-swiftops-backend.hf.space/api/v1/projects → 500 (781ms)
|
| 71 |
+
core.ts:117 ❌ [19:04:01] [DATA] Failed to create project {title: 'Atomio Fttx', error: {…}}
|
| 72 |
log @ core.ts:117
|
| 73 |
error @ core.ts:157
|
| 74 |
createOrgProject @ organization.service.ts:426
|
src/app/api/v1/invitations.py
CHANGED
|
@@ -273,6 +273,26 @@ async def validate_invitation_token(
|
|
| 273 |
organization_name = 'SwiftOps Platform'
|
| 274 |
organization_type = 'platform'
|
| 275 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 276 |
return InvitationPublicResponse(
|
| 277 |
id=invitation.id,
|
| 278 |
email=invitation.email,
|
|
@@ -282,7 +302,9 @@ async def validate_invitation_token(
|
|
| 282 |
organization_name=organization_name,
|
| 283 |
organization_type=organization_type,
|
| 284 |
is_expired=invitation.is_expired,
|
| 285 |
-
is_valid=invitation.is_pending
|
|
|
|
|
|
|
| 286 |
)
|
| 287 |
|
| 288 |
|
|
|
|
| 273 |
organization_name = 'SwiftOps Platform'
|
| 274 |
organization_type = 'platform'
|
| 275 |
|
| 276 |
+
# Split invited_name into suggested first and last names for form pre-filling
|
| 277 |
+
suggested_first_name = None
|
| 278 |
+
suggested_last_name = None
|
| 279 |
+
|
| 280 |
+
if invitation.invited_name:
|
| 281 |
+
# Smart name splitting
|
| 282 |
+
name_parts = invitation.invited_name.strip().split()
|
| 283 |
+
if len(name_parts) == 1:
|
| 284 |
+
# Single name - use as first name
|
| 285 |
+
suggested_first_name = name_parts[0]
|
| 286 |
+
elif len(name_parts) == 2:
|
| 287 |
+
# Two parts - first and last
|
| 288 |
+
suggested_first_name = name_parts[0]
|
| 289 |
+
suggested_last_name = name_parts[1]
|
| 290 |
+
else:
|
| 291 |
+
# Multiple parts - first word is first name, rest is last name
|
| 292 |
+
# e.g., "John Michael Doe" → first="John", last="Michael Doe"
|
| 293 |
+
suggested_first_name = name_parts[0]
|
| 294 |
+
suggested_last_name = " ".join(name_parts[1:])
|
| 295 |
+
|
| 296 |
return InvitationPublicResponse(
|
| 297 |
id=invitation.id,
|
| 298 |
email=invitation.email,
|
|
|
|
| 302 |
organization_name=organization_name,
|
| 303 |
organization_type=organization_type,
|
| 304 |
is_expired=invitation.is_expired,
|
| 305 |
+
is_valid=invitation.is_pending,
|
| 306 |
+
suggested_first_name=suggested_first_name,
|
| 307 |
+
suggested_last_name=suggested_last_name
|
| 308 |
)
|
| 309 |
|
| 310 |
|
src/app/api/v1/projects.py
CHANGED
|
@@ -60,12 +60,13 @@ async def create_project(
|
|
| 60 |
# Audit log
|
| 61 |
AuditService.log_action(
|
| 62 |
db=db,
|
| 63 |
-
|
| 64 |
action="create",
|
| 65 |
entity_type="project",
|
| 66 |
-
entity_id=project.id,
|
|
|
|
| 67 |
changes={"status": "created"},
|
| 68 |
-
|
| 69 |
)
|
| 70 |
|
| 71 |
# Prepare response with nested data
|
|
@@ -214,10 +215,11 @@ async def update_project(
|
|
| 214 |
# Audit log
|
| 215 |
AuditService.log_action(
|
| 216 |
db=db,
|
| 217 |
-
|
| 218 |
action="update",
|
| 219 |
entity_type="project",
|
| 220 |
-
entity_id=project.id,
|
|
|
|
| 221 |
changes={
|
| 222 |
"old": old_data,
|
| 223 |
"new": {
|
|
@@ -226,7 +228,7 @@ async def update_project(
|
|
| 226 |
"service_type": project.service_type
|
| 227 |
}
|
| 228 |
},
|
| 229 |
-
|
| 230 |
)
|
| 231 |
|
| 232 |
# Prepare response
|
|
@@ -282,16 +284,18 @@ async def update_project_status(
|
|
| 282 |
# Audit log
|
| 283 |
AuditService.log_action(
|
| 284 |
db=db,
|
| 285 |
-
|
| 286 |
action="update",
|
| 287 |
entity_type="project",
|
| 288 |
-
entity_id=project.id,
|
|
|
|
| 289 |
changes={
|
| 290 |
"field": "status",
|
| 291 |
"old": old_status,
|
| 292 |
"new": data.status,
|
| 293 |
"reason": data.reason
|
| 294 |
},
|
|
|
|
| 295 |
ip_address=request.client.host if request.client else None
|
| 296 |
)
|
| 297 |
|
|
@@ -347,15 +351,16 @@ async def close_project(
|
|
| 347 |
# Audit log
|
| 348 |
AuditService.log_action(
|
| 349 |
db=db,
|
| 350 |
-
|
| 351 |
action="archive",
|
| 352 |
entity_type="project",
|
| 353 |
-
entity_id=project.id,
|
|
|
|
| 354 |
changes={
|
| 355 |
"action": "closed",
|
| 356 |
"reason": data.reason
|
| 357 |
},
|
| 358 |
-
|
| 359 |
)
|
| 360 |
|
| 361 |
# Prepare response
|
|
@@ -401,12 +406,13 @@ async def delete_project(
|
|
| 401 |
# Audit log
|
| 402 |
AuditService.log_action(
|
| 403 |
db=db,
|
| 404 |
-
|
| 405 |
action="delete",
|
| 406 |
entity_type="project",
|
| 407 |
-
entity_id=project_id,
|
|
|
|
| 408 |
changes={"action": "soft_deleted"},
|
| 409 |
-
|
| 410 |
)
|
| 411 |
|
| 412 |
return None
|
|
|
|
| 60 |
# Audit log
|
| 61 |
AuditService.log_action(
|
| 62 |
db=db,
|
| 63 |
+
user=current_user,
|
| 64 |
action="create",
|
| 65 |
entity_type="project",
|
| 66 |
+
entity_id=str(project.id),
|
| 67 |
+
description=f"Created project '{project.title}'",
|
| 68 |
changes={"status": "created"},
|
| 69 |
+
request=request
|
| 70 |
)
|
| 71 |
|
| 72 |
# Prepare response with nested data
|
|
|
|
| 215 |
# Audit log
|
| 216 |
AuditService.log_action(
|
| 217 |
db=db,
|
| 218 |
+
user=current_user,
|
| 219 |
action="update",
|
| 220 |
entity_type="project",
|
| 221 |
+
entity_id=str(project.id),
|
| 222 |
+
description=f"Updated project '{project.title}'",
|
| 223 |
changes={
|
| 224 |
"old": old_data,
|
| 225 |
"new": {
|
|
|
|
| 228 |
"service_type": project.service_type
|
| 229 |
}
|
| 230 |
},
|
| 231 |
+
request=request
|
| 232 |
)
|
| 233 |
|
| 234 |
# Prepare response
|
|
|
|
| 284 |
# Audit log
|
| 285 |
AuditService.log_action(
|
| 286 |
db=db,
|
| 287 |
+
user=current_user,
|
| 288 |
action="update",
|
| 289 |
entity_type="project",
|
| 290 |
+
entity_id=str(project.id),
|
| 291 |
+
description=f"Changed project '{project.title}' status from {old_status} to {data.status}",
|
| 292 |
changes={
|
| 293 |
"field": "status",
|
| 294 |
"old": old_status,
|
| 295 |
"new": data.status,
|
| 296 |
"reason": data.reason
|
| 297 |
},
|
| 298 |
+
request=request
|
| 299 |
ip_address=request.client.host if request.client else None
|
| 300 |
)
|
| 301 |
|
|
|
|
| 351 |
# Audit log
|
| 352 |
AuditService.log_action(
|
| 353 |
db=db,
|
| 354 |
+
user=current_user,
|
| 355 |
action="archive",
|
| 356 |
entity_type="project",
|
| 357 |
+
entity_id=str(project.id),
|
| 358 |
+
description=f"Closed project '{project.title}'",
|
| 359 |
changes={
|
| 360 |
"action": "closed",
|
| 361 |
"reason": data.reason
|
| 362 |
},
|
| 363 |
+
request=request
|
| 364 |
)
|
| 365 |
|
| 366 |
# Prepare response
|
|
|
|
| 406 |
# Audit log
|
| 407 |
AuditService.log_action(
|
| 408 |
db=db,
|
| 409 |
+
user=current_user,
|
| 410 |
action="delete",
|
| 411 |
entity_type="project",
|
| 412 |
+
entity_id=str(project_id),
|
| 413 |
+
description=f"Deleted project (ID: {project_id})",
|
| 414 |
changes={"action": "soft_deleted"},
|
| 415 |
+
request=request
|
| 416 |
)
|
| 417 |
|
| 418 |
return None
|
src/app/models/invitation.py
CHANGED
|
@@ -18,6 +18,7 @@ class UserInvitation(BaseModel):
|
|
| 18 |
# Invitation Details
|
| 19 |
email = Column(String(255), nullable=False)
|
| 20 |
phone = Column(String(50), nullable=True)
|
|
|
|
| 21 |
invited_role = Column(String(50), nullable=False) # app_role ENUM
|
| 22 |
|
| 23 |
# Organization Links
|
|
|
|
| 18 |
# Invitation Details
|
| 19 |
email = Column(String(255), nullable=False)
|
| 20 |
phone = Column(String(50), nullable=True)
|
| 21 |
+
invited_name = Column(String(200), nullable=True) # Optional full name of invitee
|
| 22 |
invited_role = Column(String(50), nullable=False) # app_role ENUM
|
| 23 |
|
| 24 |
# Organization Links
|
src/app/schemas/invitation.py
CHANGED
|
@@ -11,6 +11,11 @@ class InvitationCreate(BaseModel):
|
|
| 11 |
"""Schema for creating a user invitation"""
|
| 12 |
email: EmailStr
|
| 13 |
phone: Optional[str] = Field(None, max_length=50)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
invited_role: str = Field(..., description="User role (app_role enum)")
|
| 15 |
client_id: Optional[UUID] = None
|
| 16 |
contractor_id: Optional[UUID] = None
|
|
@@ -56,6 +61,7 @@ class InvitationResponse(BaseModel):
|
|
| 56 |
id: UUID
|
| 57 |
email: str
|
| 58 |
phone: Optional[str]
|
|
|
|
| 59 |
invited_role: str
|
| 60 |
client_id: Optional[UUID]
|
| 61 |
contractor_id: Optional[UUID]
|
|
@@ -89,6 +95,8 @@ class InvitationPublicResponse(BaseModel):
|
|
| 89 |
organization_type: Optional[str] = None
|
| 90 |
is_expired: bool
|
| 91 |
is_valid: bool
|
|
|
|
|
|
|
| 92 |
|
| 93 |
|
| 94 |
class InvitationValidate(BaseModel):
|
|
|
|
| 11 |
"""Schema for creating a user invitation"""
|
| 12 |
email: EmailStr
|
| 13 |
phone: Optional[str] = Field(None, max_length=50)
|
| 14 |
+
invited_name: Optional[str] = Field(
|
| 15 |
+
None,
|
| 16 |
+
max_length=200,
|
| 17 |
+
description="Full name of invitee (optional, will derive from email if not provided)"
|
| 18 |
+
)
|
| 19 |
invited_role: str = Field(..., description="User role (app_role enum)")
|
| 20 |
client_id: Optional[UUID] = None
|
| 21 |
contractor_id: Optional[UUID] = None
|
|
|
|
| 61 |
id: UUID
|
| 62 |
email: str
|
| 63 |
phone: Optional[str]
|
| 64 |
+
invited_name: Optional[str]
|
| 65 |
invited_role: str
|
| 66 |
client_id: Optional[UUID]
|
| 67 |
contractor_id: Optional[UUID]
|
|
|
|
| 95 |
organization_type: Optional[str] = None
|
| 96 |
is_expired: bool
|
| 97 |
is_valid: bool
|
| 98 |
+
suggested_first_name: Optional[str] = Field(None, description="Suggested first name from invited_name")
|
| 99 |
+
suggested_last_name: Optional[str] = Field(None, description="Suggested last name from invited_name")
|
| 100 |
|
| 101 |
|
| 102 |
class InvitationValidate(BaseModel):
|
src/app/services/invitation_service.py
CHANGED
|
@@ -98,6 +98,7 @@ class InvitationService:
|
|
| 98 |
invitation = UserInvitation(
|
| 99 |
email=invitation_data.email,
|
| 100 |
phone=invitation_data.phone,
|
|
|
|
| 101 |
invited_role=invitation_data.invited_role,
|
| 102 |
client_id=invitation_data.client_id,
|
| 103 |
contractor_id=invitation_data.contractor_id,
|
|
@@ -113,8 +114,8 @@ class InvitationService:
|
|
| 113 |
db.commit()
|
| 114 |
db.refresh(invitation)
|
| 115 |
|
| 116 |
-
# Send notification
|
| 117 |
-
name = invitation_data.email.split('@')[0].title()
|
| 118 |
await self._send_invitation_notification(
|
| 119 |
invitation=invitation,
|
| 120 |
name=name,
|
|
@@ -271,8 +272,8 @@ class InvitationService:
|
|
| 271 |
if method:
|
| 272 |
invitation.invitation_method = method
|
| 273 |
|
| 274 |
-
# Send notification (with new token if regenerated)
|
| 275 |
-
name = invitation.email.split('@')[0].title()
|
| 276 |
await self._send_invitation_notification(
|
| 277 |
invitation=invitation,
|
| 278 |
name=name,
|
|
|
|
| 98 |
invitation = UserInvitation(
|
| 99 |
email=invitation_data.email,
|
| 100 |
phone=invitation_data.phone,
|
| 101 |
+
invited_name=invitation_data.invited_name,
|
| 102 |
invited_role=invitation_data.invited_role,
|
| 103 |
client_id=invitation_data.client_id,
|
| 104 |
contractor_id=invitation_data.contractor_id,
|
|
|
|
| 114 |
db.commit()
|
| 115 |
db.refresh(invitation)
|
| 116 |
|
| 117 |
+
# Send notification - use provided name or derive from email
|
| 118 |
+
name = invitation_data.invited_name or invitation_data.email.split('@')[0].title()
|
| 119 |
await self._send_invitation_notification(
|
| 120 |
invitation=invitation,
|
| 121 |
name=name,
|
|
|
|
| 272 |
if method:
|
| 273 |
invitation.invitation_method = method
|
| 274 |
|
| 275 |
+
# Send notification (with new token if regenerated) - use provided name or derive from email
|
| 276 |
+
name = invitation.invited_name or invitation.email.split('@')[0].title()
|
| 277 |
await self._send_invitation_notification(
|
| 278 |
invitation=invitation,
|
| 279 |
name=name,
|