44from app .api .routes .auth .users_repo import get_user_doc
55from app .api .routes .workspaces import invitations_repo , repo
66from app .api .routes .workspaces .schema import InvitationOut , InviteMemberRequest
7- from app .core .email import send_invitation_email
87from app .utils .utils import create_timestamp , new_id
98
109INVITATION_TTL_SECONDS = 14 * 24 * 3600
@@ -64,33 +63,35 @@ async def invite_to_org(
6463 email = body .email .lower ().strip ()
6564 if body .role not in ("owner" , "admin" , "member" , "viewer" ):
6665 raise HTTPException (400 , "Invalid org role" )
67- now = create_timestamp ()
66+ # Optional bundled workspace grant on accept.
67+ ws_id : str | None = body .workspace_id
68+ ws_role : str | None = body .workspace_role
69+ if ws_id :
70+ if ws_role not in ("admin" , "developer" , "viewer" ):
71+ raise HTTPException (400 , "Invalid workspace role" )
72+ ws = await repo .find_workspace (ws_id )
73+ if not ws or ws .get ("org_id" ) != org_id :
74+ raise HTTPException (400 , "Workspace does not belong to this org" )
6875 invited_uid = await _find_uid_by_email (email )
76+ if not invited_uid :
77+ raise HTTPException (404 , "No user found with that email. Ask them to sign up first." )
78+ now = create_timestamp ()
6979 doc = {
7080 "_id" : new_id (),
7181 "org_id" : org_id ,
72- "workspace_id" : None ,
82+ "workspace_id" : ws_id ,
7383 "invited_email" : email ,
7484 "invited_uid" : invited_uid ,
7585 "invited_role_org" : body .role ,
76- "invited_role_ws" : None ,
86+ "invited_role_ws" : ws_role ,
7787 "token" : _new_token (),
7888 "status" : "pending" ,
7989 "invited_by" : uid ,
8090 "created_at" : now ,
8191 "expires_at" : now + INVITATION_TTL_SECONDS * 1000 ,
8292 }
8393 await invitations_repo .create_invitation (doc )
84-
85- inviter = await get_user_doc (uid ) or {}
86- org = await repo .find_org (org_id ) or {}
87- await send_invitation_email (
88- to = email ,
89- token = doc ["token" ],
90- inviter_name = inviter .get ("displayName" ) or inviter .get ("email" ) or "A teammate" ,
91- org_name = org .get ("name" , "an org" ),
92- workspace_name = None ,
93- )
94+ # In-app notification only — the invited user sees this via the bell.
9495 return _doc_to_out (doc )
9596
9697
@@ -101,8 +102,10 @@ async def invite_to_workspace(
101102 email = body .email .lower ().strip ()
102103 if body .role not in ("admin" , "developer" , "viewer" ):
103104 raise HTTPException (400 , "Invalid ws role" )
104- now = create_timestamp ()
105105 invited_uid = await _find_uid_by_email (email )
106+ if not invited_uid :
107+ raise HTTPException (404 , "No user found with that email. Ask them to sign up first." )
108+ now = create_timestamp ()
106109 doc = {
107110 "_id" : new_id (),
108111 "org_id" : ws ["org_id" ],
@@ -119,16 +122,7 @@ async def invite_to_workspace(
119122 "expires_at" : now + INVITATION_TTL_SECONDS * 1000 ,
120123 }
121124 await invitations_repo .create_invitation (doc )
122-
123- inviter = await get_user_doc (uid ) or {}
124- org = await repo .find_org (ws ["org_id" ]) or {}
125- await send_invitation_email (
126- to = email ,
127- token = doc ["token" ],
128- inviter_name = inviter .get ("displayName" ) or inviter .get ("email" ) or "A teammate" ,
129- org_name = org .get ("name" , "an org" ),
130- workspace_name = ws ["name" ],
131- )
125+ # In-app notification only — the invited user sees this via PendingInvitationsBadge.
132126 return _doc_to_out (doc )
133127
134128
@@ -166,6 +160,11 @@ async def accept_invitation(uid: str, token: str) -> dict:
166160 await repo .upsert_ws_membership (
167161 inv ["workspace_id" ], inv ["org_id" ], uid , inv ["invited_role_ws" ],
168162 )
163+ else :
164+ # Org-only invite: seed a Personal workspace in this org so the user has
165+ # somewhere to land when they switch to it. Mirrors signup behavior.
166+ ws_id = await repo .upsert_personal_workspace (inv ["org_id" ], uid )
167+ await repo .upsert_ws_membership (ws_id , inv ["org_id" ], uid , "admin" )
169168 await invitations_repo .update_invitation_status (
170169 inv ["_id" ], "accepted" ,
171170 accepted_uid = uid , accepted_at = create_timestamp (),
@@ -177,10 +176,15 @@ async def revoke_invitation(uid: str, token: str) -> None:
177176 inv = await invitations_repo .find_invitation_by_token (token )
178177 if not inv :
179178 raise HTTPException (404 , "Invitation not found" )
179+ # Allowed: original inviter, org owner/admin, OR the invitee themself (decline).
180+ user = await get_user_doc (uid )
181+ is_invitee = bool (
182+ (inv .get ("invited_uid" ) and inv ["invited_uid" ] == uid )
183+ or (user and (user .get ("email" ) or "" ).lower () == inv ["invited_email" ])
184+ )
180185 org_mem = await repo .find_org_membership (inv ["org_id" ], uid )
181- if inv ["invited_by" ] != uid and not (
182- org_mem and org_mem ["org_role" ] in ("owner" , "admin" )
183- ):
186+ is_admin = bool (org_mem and org_mem ["org_role" ] in ("owner" , "admin" ))
187+ if not (inv ["invited_by" ] == uid or is_admin or is_invitee ):
184188 raise HTTPException (403 , "Cannot revoke this invitation" )
185189 if inv ["status" ] != "pending" :
186190 return
0 commit comments