agmission/Development/client/docs/NOTIFICATION-DEEP-LINKS.md

11 KiB
Raw Blame History

Notification Deep-Links

Reference for handling external deep-link URLs sent in customer notification emails (e.g. "Manage your subscription", "Update payment method").


Table of Contents

  1. Overview
  2. Registered Routes
  3. How It Works — Full Flow
  4. Key Files
  5. Adding a New Notification URL
  6. Design Decisions

1. Overview

Notification emails link customers to top-level URLs such as /#/manage-subscription. These URLs must:

  • Not require authentication themselves — the customer may be logged out
  • Skip the shell layout — they live outside AppMainComponent
  • Redirect instantly — no blank-page flash, no component rendered
  • Show a contextual notice on the login screen when authentication is required

All of this is handled by a single NotificationRedirectGuard combined with route data. Adding a new notification URL requires one route entry and zero new files.


2. Registered Routes

All notification routes are declared at the top level in app-routing.module.ts, outside the AppMainComponent shell:

/#/manage-subscription    → /profile/myservices  (or /profile/services if no subs)
/#/update-pm              → /profile/payment-method-list
/#/update-bill-address    → /profile/billing-address

Each route in source:

// app-routing.module.ts

{
  path: 'manage-subscription',
  component: PageNotFoundComponent,       // never rendered — guard always redirects
  canActivate: [NotificationRedirectGuard],
  data: {
    redirectTo: ['profile', 'myservices'],
    redirectToNoSubs: ['profile', 'services'],
    loginNotice: $localize`@@manageSubLoginNotice:Please log in with your Master account to manage subscriptions.`
  }
},
{
  path: 'update-pm',
  component: PageNotFoundComponent,
  canActivate: [NotificationRedirectGuard],
  data: {
    redirectTo: ['profile', 'payment-method-list'],
    loginNotice: $localize`@@updatePmLoginNotice:Please log in with your Master account to update your payment method.`
  }
},
{
  path: 'update-bill-address',
  component: PageNotFoundComponent,
  canActivate: [NotificationRedirectGuard],
  data: {
    redirectTo: ['profile', 'billing-address'],
    loginNotice: $localize`@@updateBillAddrLoginNotice:Please log in with your Master account to update your billing address.`
  }
},

PageNotFoundComponent is the placeholder — it is already declared in AppModule and is never displayed because the guard always returns a UrlTree before any component activates.


3. How It Works — Full Flow

Authenticated user

User clicks link in email
        │
        ▼
Browser opens  /#/manage-subscription
        │
        ▼
Angular router matches route
        │
        ▼
NotificationRedirectGuard.canActivate()
        │
        ├─ authSvc.loggedIn = true
        │
        ├─[redirectToNoSubs defined AND master AND no subs]
        │       └──→  UrlTree: /profile/services
        │
        └─[all other authenticated cases]
                └──→  UrlTree: /profile/myservices
                        │
                        ▼
              AppMainComponent loads normally
              /profile/myservices renders

The guard returns a UrlTree synchronously (auth state is rehydrated from sessionStorage before any guard runs). Angular processes the redirect before deactivating the current route or activating any component — no blank page, no shell teardown.


Unauthenticated user

User clicks link in email (not logged in)
        │
        ▼
Browser opens  /#/manage-subscription
        │
        ▼
NotificationRedirectGuard.canActivate()
        │
        ├─ authSvc.loggedIn = false
        │
        └──→  UrlTree: /login?returnUrl=manage-subscription
                          &loginNotice=<i18n message>
                        │
                        ▼
              LoginComponent constructor reads queryParams
                  nav.extractedUrl.queryParams['loginNotice']
                        │
                        ▼
              Pushes { severity: 'info', detail: loginNotice }
              into this.msgs → rendered by <p-messages>
                        │
                        ▼
              ┌──────────────────────────────────────────┐
              │  [AgMission logo]                        │
              │                                          │
              │   Please log in with your Master        │
              │    account to manage subscriptions.      │
              │                                          │
              │  Username ________________________       │
              │  Password ________________________       │
              │  [ LOGIN ]                               │
              └──────────────────────────────────────────┘
                        │
                  User logs in
                        │
                        ▼
              authActions.LoginSuccess dispatched
                        │
                        ▼
              AuthEffects.navigateDefault()
                  router.parseUrl(router.url)
                    .queryParams['returnUrl']
                  → 'manage-subscription'
                        │
                        ▼
              window.location.replace('/#/manage-subscription')
                        │
                        ▼
              NotificationRedirectGuard runs again
              (now authenticated)
                        │
                        ▼
              Redirects to /profile/myservices

4. Key Files

src/app/domain/guards/notification-redirect.guard.ts

The single guard that handles all notification deep-links.

Route data contract:

Field Type Required Description
redirectTo string[] Yes Router path segments for authenticated users
redirectToNoSubs string[] No Alternate path when master account has no subscriptions
loginNotice string No Message shown in the <p-messages> bar on the login screen
canActivate(route: ActivatedRouteSnapshot): UrlTree {
  const { redirectTo, redirectToNoSubs } = route.data;

  if (!this.authSvc.loggedIn) {
    const { loginNotice } = route.data;
    return this.router.createUrlTree(['/login'], {
      queryParams: {
        returnUrl: route.url.map(s => s.path).join('/'),
        ...(loginNotice ? { loginNotice } : {})
      }
    });
  }

  const isMaster = !this.authSvc.user?.parent;
  if (redirectToNoSubs && isMaster && !this.authSvc.hasSubs()) {
    return this.router.createUrlTree(redirectToNoSubs);
  }
  return this.router.createUrlTree(redirectTo);
}

src/app/auth/effects/auth.effects.tsnavigateDefault()

After a successful login, reads returnUrl from the current router URL and replaces the browser location to trigger the notification route again (now authenticated):

private navigateDefault(lang) {
  const hash = (this.router.url.indexOf('#') == -1) ? '/#/' : '/';
  const returnUrl = this.router.parseUrl(this.router.url).queryParams['returnUrl'] || 'home';
  window.location.replace((lang === 'en' ? hash : `/${lang}${hash}`) + returnUrl);
}

Uses router.parseUrl() instead of string splitting — correctly handles all URL encodings.

If no returnUrl is present (normal login), falls back to 'home' as before.


src/app/auth/login/login.component.ts — constructor

Generic loginNotice handling — no hardcoded route names:

const nav = this.router.getCurrentNavigation();
if (nav) {
  const msgs: any[] = [];
  if (nav.extras?.state?.changedPwd) {
    msgs.push({ severity: 'info', summary: '', detail: globals.pwdChangedOk });
  }
  const loginNotice =
    nav.finalUrl?.queryParams?.['loginNotice'] ??
    nav.extractedUrl?.queryParams?.['loginNotice'];
  if (loginNotice) {
    msgs.push({ severity: 'info', summary: '', detail: loginNotice });
  }
  if (msgs.length) this.msgs = msgs;
}

Reads from getCurrentNavigation() — the only safe place to read query params on a login redirect since the router replaces the URL before ngOnInit runs.


5. Adding a New Notification URL

Zero new files required. Add one entry to app-routing.module.ts:

{
  path: 'renew-subscription',        // URL path: /#/renew-subscription
  component: PageNotFoundComponent,  // never rendered
  canActivate: [NotificationRedirectGuard],
  data: {
    redirectTo: ['profile', 'checkout'],          // authenticated destination
    // redirectToNoSubs: ['profile', 'services'], // optional alternate
    loginNotice: $localize`:@@renewSubLoginNotice:Please log in with your Master account to renew your subscription.`
  }
},

That's it. The guard, the login notice, and the post-login redirect all work automatically.

Checklist:

  • Add route entry with redirectTo (required) and optionally redirectToNoSubs / loginNotice
  • Add the loginNotice i18n key to all locale .xlf translation files if the app is translated
  • Provide the deep-link URL to the notifications/email team: https://app.agmission.com/#/renew-subscription

6. Design Decisions

Why a guard returning UrlTree instead of a redirect component?

A component that calls router.navigate() in ngOnInit causes:

  • The current shell (AppMainComponent) to deactivate and re-activate — visible flicker
  • A blank template to render briefly while ngOnInit executes

A guard returning a UrlTree is processed by Angular before any component is activated or deactivated. The redirect is invisible and instantaneous.

Why route data instead of a guard per URL?

One guard file per URL creates N files for N routes with identical logic. Putting the routing targets in data makes the guard a pure engine and the route definition the configuration — consistent with Angular's canActivate+data idiom used throughout the app (e.g. RoleGuard + data.roles).

Why PageNotFoundComponent as the placeholder?

Angular requires a component on every non-lazy route. The component is never rendered (the guard always redirects), so any already-declared component works. PageNotFoundComponent is the most semantically appropriate fallback if the guard ever fails to redirect for an unexpected reason.

Why window.location.replace() instead of router.navigate() in navigateDefault?

This was the pre-existing pattern to prevent the login page from appearing in the browser's Back history after authentication. location.replace replaces the current history entry rather than pushing a new one.

Sub-accounts vs. Master accounts

Subscription management (/profile/myservices) is only meaningful for master accounts, but sub-accounts (users with user.parent set) are redirected there too — the manage-subscription view enforces its own access rules once loaded. redirectToNoSubs is only evaluated for master accounts with zero subscriptions, sending them to the /profile/services plan picker instead.