From 823935c01662b1b545a8fe1e4de3b4c1037e21d6 Mon Sep 17 00:00:00 2001 From: Samir Boulahtit Date: Sun, 29 Mar 2026 16:48:12 +0200 Subject: [PATCH] feat(tenancy): add resend invitation for pending team members New resend_invitation() service method regenerates the token and resends the invitation email for pending members. Available on all frontends: - Merchant: POST /merchants/account/team/stores/{sid}/members/{uid}/resend - Store: POST /store/team/members/{uid}/resend UI: paper-airplane icon appears on pending members in both merchant and store team pages. i18n: resend_invitation + invitation_resent keys in 4 locales. Also translated previously untranslated invitation_sent_successfully in fr/de/lb. Co-Authored-By: Claude Opus 4.6 (1M context) --- app/modules/tenancy/locales/de.json | 4 +- app/modules/tenancy/locales/en.json | 2 + app/modules/tenancy/locales/fr.json | 4 +- app/modules/tenancy/locales/lb.json | 4 +- app/modules/tenancy/routes/api/merchant.py | 20 ++++++ app/modules/tenancy/routes/api/store_team.py | 20 ++++++ .../tenancy/services/store_team_service.py | 63 +++++++++++++++++++ .../static/merchant/js/merchant-team.js | 25 ++++++++ app/modules/tenancy/static/store/js/team.js | 19 ++++++ .../templates/tenancy/merchant/team.html | 7 +++ .../tenancy/templates/tenancy/store/team.html | 10 +++ 11 files changed, 175 insertions(+), 3 deletions(-) diff --git a/app/modules/tenancy/locales/de.json b/app/modules/tenancy/locales/de.json index f132713b..09cfdca8 100644 --- a/app/modules/tenancy/locales/de.json +++ b/app/modules/tenancy/locales/de.json @@ -51,6 +51,7 @@ "title": "Team", "total_members": "Mitglieder gesamt", "personal_info": "Persönliche Informationen", + "resend_invitation": "Einladung erneut senden", "save_profile": "Profil speichern", "view_member": "Mitglied anzeigen", "account_information": "Kontoinformationen", @@ -75,7 +76,8 @@ "please_fix_the_errors_before_saving": "Please fix the errors before saving", "profile_updated_successfully": "Profile updated successfully", "email_is_required": "Email is required", - "invitation_sent_successfully": "Invitation sent successfully", + "invitation_sent_successfully": "Einladung erfolgreich gesendet", + "invitation_resent": "Einladung erneut gesendet", "team_member_updated": "Team member updated", "team_member_removed": "Team member removed", "invalid_merchant_url": "Invalid merchant URL", diff --git a/app/modules/tenancy/locales/en.json b/app/modules/tenancy/locales/en.json index b98cdde0..62f70c69 100644 --- a/app/modules/tenancy/locales/en.json +++ b/app/modules/tenancy/locales/en.json @@ -51,6 +51,7 @@ "title": "Team", "total_members": "Total Members", "personal_info": "Personal Information", + "resend_invitation": "Resend Invitation", "save_profile": "Save Profile", "view_member": "View Member", "account_information": "Account Information", @@ -76,6 +77,7 @@ "profile_updated_successfully": "Profile updated successfully", "email_is_required": "Email is required", "invitation_sent_successfully": "Invitation sent successfully", + "invitation_resent": "Invitation resent successfully", "team_member_updated": "Team member updated", "team_member_removed": "Team member removed", "invalid_merchant_url": "Invalid merchant URL", diff --git a/app/modules/tenancy/locales/fr.json b/app/modules/tenancy/locales/fr.json index a7f54483..bcfc570c 100644 --- a/app/modules/tenancy/locales/fr.json +++ b/app/modules/tenancy/locales/fr.json @@ -51,6 +51,7 @@ "title": "Équipe", "total_members": "Membres totaux", "personal_info": "Informations personnelles", + "resend_invitation": "Renvoyer l'invitation", "save_profile": "Enregistrer le profil", "view_member": "Voir le membre", "account_information": "Informations du compte", @@ -75,7 +76,8 @@ "please_fix_the_errors_before_saving": "Please fix the errors before saving", "profile_updated_successfully": "Profile updated successfully", "email_is_required": "Email is required", - "invitation_sent_successfully": "Invitation sent successfully", + "invitation_sent_successfully": "Invitation envoyée avec succès", + "invitation_resent": "Invitation renvoyée avec succès", "team_member_updated": "Team member updated", "team_member_removed": "Team member removed", "invalid_merchant_url": "Invalid merchant URL", diff --git a/app/modules/tenancy/locales/lb.json b/app/modules/tenancy/locales/lb.json index 8ccd5a08..db6bf5ea 100644 --- a/app/modules/tenancy/locales/lb.json +++ b/app/modules/tenancy/locales/lb.json @@ -51,6 +51,7 @@ "title": "Team", "total_members": "Memberen total", "personal_info": "Perséinlech Informatiounen", + "resend_invitation": "Aluedung nei schécken", "save_profile": "Profil späicheren", "view_member": "Member kucken", "account_information": "Konto Informatiounen", @@ -75,7 +76,8 @@ "please_fix_the_errors_before_saving": "Please fix the errors before saving", "profile_updated_successfully": "Profile updated successfully", "email_is_required": "Email is required", - "invitation_sent_successfully": "Invitation sent successfully", + "invitation_sent_successfully": "Aluedung erfollegräich geschéckt", + "invitation_resent": "Aluedung nei geschéckt", "team_member_updated": "Team member updated", "team_member_removed": "Team member removed", "invalid_merchant_url": "Invalid merchant URL", diff --git a/app/modules/tenancy/routes/api/merchant.py b/app/modules/tenancy/routes/api/merchant.py index 2dc9b169..6efe8726 100644 --- a/app/modules/tenancy/routes/api/merchant.py +++ b/app/modules/tenancy/routes/api/merchant.py @@ -299,6 +299,26 @@ async def merchant_team_update_role( return {"message": "Role updated successfully"} +@_account_router.post("/team/stores/{store_id}/members/{user_id}/resend") +async def merchant_team_resend_invitation( + store_id: int, + user_id: int, + current_user: UserContext = Depends(get_current_merchant_api), + merchant=Depends(get_merchant_for_current_user), + db: Session = Depends(get_db), +): + """Resend invitation to a pending team member.""" + from app.modules.tenancy.services.store_team_service import store_team_service + + store = merchant_store_service.validate_store_ownership(db, merchant.id, store_id) + inviter = merchant_store_service.get_user(db, current_user.id) + result = store_team_service.resend_invitation( + db, store=store, user_id=user_id, inviter=inviter, + ) + db.commit() + return result + + @_account_router.delete("/team/stores/{store_id}/members/{user_id}", status_code=204) async def merchant_team_remove_member( store_id: int, diff --git a/app/modules/tenancy/routes/api/store_team.py b/app/modules/tenancy/routes/api/store_team.py index 218f0257..29cfd163 100644 --- a/app/modules/tenancy/routes/api/store_team.py +++ b/app/modules/tenancy/routes/api/store_team.py @@ -303,6 +303,26 @@ def update_team_member( return TeamMemberResponse(**member) +@store_team_router.post("/members/{user_id}/resend") +def resend_team_invitation( + user_id: int, + request: Request, + db: Session = Depends(get_db), + current_user: UserContext = Depends(require_store_owner), +): + """ + Resend invitation to a pending team member. + + **Required:** Store owner role + """ + store = request.state.store + result = store_team_service.resend_invitation( + db, store=store, user_id=user_id, inviter=current_user, + ) + db.commit() + return result + + @store_team_router.delete("/members/{user_id}") def remove_team_member( user_id: int, diff --git a/app/modules/tenancy/services/store_team_service.py b/app/modules/tenancy/services/store_team_service.py index 59079628..0fb2ea4d 100644 --- a/app/modules/tenancy/services/store_team_service.py +++ b/app/modules/tenancy/services/store_team_service.py @@ -907,6 +907,69 @@ class StoreTeamService: db.flush() return role + def resend_invitation( + self, + db: Session, + store: Store, + user_id: int, + inviter: User, + ) -> dict[str, Any]: + """ + Resend invitation to a pending team member. + + Generates a new token and resends the email. + Only works for pending invitations (not yet accepted). + """ + store_user = ( + db.query(StoreUser) + .filter( + StoreUser.store_id == store.id, + StoreUser.user_id == user_id, + ) + .first() + ) + + if not store_user: + raise UserNotFoundException(str(user_id)) + + if store_user.invitation_accepted_at is not None: + raise InvalidInvitationTokenException("Invitation already accepted") + + # Generate new token and update sent time + new_token = self._generate_invitation_token() + store_user.invitation_token = new_token + store_user.invitation_sent_at = datetime.utcnow() + db.flush() + + # Get role name for email + role_name = store_user.role.name if store_user.role else "member" + + # Send email + try: + self._send_invitation_email( + db=db, + email=store_user.user.email, + store=store, + token=new_token, + inviter=inviter, + role_name=role_name, + ) + except Exception: # noqa: EXC003 + logger.exception( + f"Failed to resend invitation email to {store_user.user.email}" + ) + + logger.info( + f"Resent invitation to {store_user.user.email} for store " + f"{store.store_code} by {inviter.username}" + ) + + return { + "email": store_user.user.email, + "store_code": store.store_code, + "invitation_sent": True, + } + def _send_invitation_email( self, db: Session, diff --git a/app/modules/tenancy/static/merchant/js/merchant-team.js b/app/modules/tenancy/static/merchant/js/merchant-team.js index 82aa2f42..929927f3 100644 --- a/app/modules/tenancy/static/merchant/js/merchant-team.js +++ b/app/modules/tenancy/static/merchant/js/merchant-team.js @@ -226,6 +226,31 @@ function merchantTeam() { } }, + /** + * Resend invitation to a pending member + */ + async resendInvitation(member) { + if (!member.stores || member.stores.length === 0) return; + + this.saving = true; + try { + // Resend for the first pending store + const pendingStore = member.stores.find(s => s.is_pending) || member.stores[0]; + await apiClient.post( + `/merchants/account/team/stores/${pendingStore.store_id}/members/${member.user_id}/resend` + ); + + Utils.showToast(I18n.t('tenancy.messages.invitation_resent'), 'success'); + merchantTeamLog.info('Resent invitation to:', member.email); + await this.loadTeamData(); + } catch (error) { + merchantTeamLog.error('Failed to resend invitation:', error); + Utils.showToast(error.message || 'Failed to resend invitation', 'error'); + } finally { + this.saving = false; + } + }, + /** * Update member role for a specific store */ diff --git a/app/modules/tenancy/static/store/js/team.js b/app/modules/tenancy/static/store/js/team.js index a4a0a6db..601cb2d5 100644 --- a/app/modules/tenancy/static/store/js/team.js +++ b/app/modules/tenancy/static/store/js/team.js @@ -212,6 +212,25 @@ function storeTeam() { } }, + /** + * Resend invitation to a pending member + */ + async resendInvitation(member) { + this.saving = true; + try { + await apiClient.post(`/store/team/members/${member.id}/resend`); + + Utils.showToast(I18n.t('tenancy.messages.invitation_resent'), 'success'); + storeTeamLog.info('Resent invitation to:', member.email); + await this.loadMembers(); + } catch (error) { + storeTeamLog.error('Failed to resend invitation:', error); + Utils.showToast(error.message || 'Failed to resend invitation', 'error'); + } finally { + this.saving = false; + } + }, + /** * Confirm remove member */ diff --git a/app/modules/tenancy/templates/tenancy/merchant/team.html b/app/modules/tenancy/templates/tenancy/merchant/team.html index 57245fec..758f9db9 100644 --- a/app/modules/tenancy/templates/tenancy/merchant/team.html +++ b/app/modules/tenancy/templates/tenancy/merchant/team.html @@ -151,6 +151,13 @@