Skip to content

feat: notify platform admins on user creation and deletion (resolves #3040)#3042

Open
rvs1212 wants to merge 6 commits intoaccessibility-exchange:devfrom
rvs1212:feat/admin-user-notifications
Open

feat: notify platform admins on user creation and deletion (resolves #3040)#3042
rvs1212 wants to merge 6 commits intoaccessibility-exchange:devfrom
rvs1212:feat/admin-user-notifications

Conversation

@rvs1212
Copy link
Copy Markdown
Contributor

@rvs1212 rvs1212 commented Mar 18, 2026

Added notification feature for platform admins when:

  • A new individual or training participant registers
  • A new organization or regulated organization is created
  • A member accepts an invitation to join an organization
  • A user deletes their account

Platform admins receive both email and dashboard notifications.

Resolves #3040

Prerequisites

If this PR changes PHP code or dependencies:

  • I've run composer format and fixed any code formatting issues.
  • I've run composer analyze and addressed any static analysis issues.
  • I've run php artisan test and ensured that all tests pass.
  • I've run composer localize to update localization source files and committed any changes.

If this PR changes CSS or JavaScript code or dependencies:

  • I've run npm run lint and fixed any linting issues.
  • I've run npm run build and ensured that CSS and JavaScript assets can be compiled.

@rvs1212 rvs1212 requested a review from jobara March 18, 2026 22:37
@codecov
Copy link
Copy Markdown

codecov bot commented Mar 18, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 97.04%. Comparing base (bf76f37) to head (91045de).

Additional details and impacted files
@@             Coverage Diff              @@
##                dev    #3042      +/-   ##
============================================
+ Coverage     96.99%   97.04%   +0.04%     
- Complexity     2472     2505      +33     
============================================
  Files           379      389      +10     
  Lines         11287    11477     +190     
============================================
+ Hits          10948    11138     +190     
  Misses          339      339              

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

'name'
))->decryptValue($user->name);

$admins = User::whereAdministrator()->where('id', '!=', $user->id)->get();
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think the extra where is needed as it appears that this is only run when the $user is not an administrator.

Comment on lines +58 to +60
userName: $name,
userEmail: $email,
userContext: $user->context,
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will expose the users name and info outside of TAE's system. Is this information actually necessary?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Modified the email notification content considering privacy

if ($invitationable instanceof Organization || $invitationable instanceof RegulatedOrganization) {
$invitee = $this->retrieveUserByEmail($inviteeEmail);

if ($invitee) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a case where this is falsey? Depending on the case there may be something else that should be done.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There won't be a case where $invitee can be falsey. The request validation confirms the authenticated user's email matches the invitation email, so the user will exist. Removed that.


private function contextLabel(): string
{
return UserContext::labels()[$this->userContext] ?? $this->userContext;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Realistically there shouldn't be a case where the contextLabel doesn't return a value. I wonder if you want to return something like __('Unknown') or leave as null in this case and log an error.

Comment on lines +44 to +48
'title' => __('New member joined organization'),
'body' => __(':name has joined :organization as :role.', [
'name' => $this->memberName,
'organization' => $this->organization->getTranslation('name', locale()),
'role' => $this->memberRole,
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should follow a similar pattern as the other notifications. These strings are built up in the notification view component instead. This should just pass along the necessary parts needed by the view. Apart from consistency, I believe this will also mean that localization happens at the time the notification view is rendered, instead of when the notification is generated.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oke. Modified the approach.

Comment on lines +16 to +17
public string $memberRole,
public string $userContext,
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's likely you'll need to pass in the actually object instance instead of the string, so that you can pass it along to the notification view. This way it can do the localization at render time.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sure, modified the approach

Comment on lines +17 to +20
<p>
@lang('mail.salutation'),<br>
{{ config('app.name') }}
</p>
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this may already be included in another template.

{{-- Salutation --}}
@if (! empty($salutation))
{{ $salutation }}
@else
@lang('mail.salutation'),<br>
{{ config('app.name') }}
@endif

Copy link
Copy Markdown
Collaborator

@jobara jobara left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it would be be helpful

Comment on lines +15 to +18
$this->body = __('A new user, :name, has registered as :context.', [
'name' => $notification->data['user_name'],
'context' => UserContext::labels()[$notification->data['user_context']],
]);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this is actually enough information, for example multiple users could have the same name. Also if you just pass along the email address, the user could change that at any time, so it also may not match. This can be probably be mostly mitigated by passing along the User and displaying the email as it is at render time. In an ideal world, you'd provide a link directly to the user, but there isn't a page for that yet, and is something more along the lines of #1802.

screenshot of multiple users with the same name.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could also make the user name a link to their public page if one has been created/drafted.

NOTE: this isn't intended as a comment to address the previous comment above.

Comment on lines +57 to +60
Notification::send($admins, new NewUserRegistered(
userName: $name,
userContext: UserContext::from($user->context),
));
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is actually too soon to send, as the user hasn't actually complete the registration process yet. It kind of depends on what you want to do with the information. On one hand it would be useful to know when a user is created, even if they aren't fully registered. However, if the point is to use that information to go into the manage accounts, the user may not show up yet.

You can replicate this by creating a new individual user, and monitoring the emails in mailpit. You'll get the email notification before the registration process completes. I think right after the password is created. If you look in manage accounts, the user won't be visible. They need to complete the other registration steps before they'll show up in the manage accounts screen.


class OrganizationObserver
{
public function created(Organization $organization): void
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This may be too soon, and/or there may be a bug in the manage accounts view. The issue is that the Org does appear in the manage accounts screen but it isn't searchable until after the org has drafted a public page.

The org "Org" is in the manage accounts list.

screenshot showing the Org in the manage accounts list

However, searching for "Org" says there are two results but doesn't render the one we're actually looking for.

screenshot showing the Org not appearing in the search results

After drafting a public page for the Org, it is now searchable.

screenshot showing the Org in the search results

Comment on lines +19 to +23
Notification::send($admins, new NewOrganizationRegistered(
creatorName: $creator->name,
organization: $regulatedOrganization,
userContext: UserContext::from($creator->context),
));
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same notification class is used for both the Organizations and Regulated Organizations. Due to this and the nature of its implementation, it's harder to distinguish the actual type of organization created. The context is included in the app notification but there the title is still just organization. Maybe check with Erick to see if this is sufficient, but you might want to actually title these as Organization and Regulated Organization. If you do make this change, you could either created separate notification classes or you could include logic to distinguish the type based on the UserContext.

Comment on lines +1 to +4
<x-notification :notification="$notification">
<x-slot name="title">{{ $title }}</x-slot>
<x-slot name="body">{{ $body }}</x-slot>
</x-notification>
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For user types that can be managed, it would be helpful to add an actions slot with a link to the Manage Accounts page. This also applies to organization notifications.

Comment on lines +18 to +22
$this->body = __(':name has joined :organization as :role.', [
'name' => $notification->data['member_name'],
'organization' => $organization->getTranslation('name', locale()),
'role' => TeamRole::labels()[$notification->data['team_role']],
]);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The organization name should be a link to the organization's public page.

$organizationType = $notification->data['organization_type'];
$organization = $organizationType::find($notification->data['organization_id']);

$this->body = __(':name (:context) from :organization has deleted their account.', [
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think in this case the context is actually referring to the organization and should be moved after the organization's name instead of the user's name.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also may want to make the organization's name a link to the organizations public page.

Comment on lines +57 to +61
Notification::send($admins, new NewMemberJoinedOrganization(
memberName: $invitee->name,
organization: $invitationable,
teamRole: TeamRole::from($role),
));
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The NewMemeberJoinedOrganization is used for both organizations and regulated organizations but doesn't distinguish between them in the notification. As mentioned in another comment for new orgs, you should consider making this distinction to keep things clear on the system. Could either be separate classes or logic in the same class that distinguishes the two.

'organization' => $organization->getTranslation('name', locale()),
]);
} else {
$this->body = __(':name (:context) has deleted their account.', [
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is slightly confusing in the context of a user who was part of an organization but was removed from the organization before they deleted their account.

In this case this case the context is still "Community Organization" or "Regulated Organization". Probably best to check with Erick to see how he feels about this.

Steps to reproduce:

  1. Create an org or fro
  2. Invite a new user to it
  3. Create the new user account and accept the invite
  4. From the org/fro remove the new user
  5. As the invited user, delete their account

public function __construct(DatabaseNotification $notification)
{
$this->title = __('New user registered');
$this->body = __('A new user, :name, has registered as :context.', [
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the grammar is a bit off and will likely need to be toggled based on the context.

For example:

  • 'A new user, :name, has registered as a :context.' (e.g. Training Participant)
  • 'A new user, :name, has registered as an :context.' (e.g. Individual)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Platform admin notifications for user registration and account deletion

2 participants