334 lines
11 KiB
Markdown
334 lines
11 KiB
Markdown
# 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](#1-overview)
|
||
2. [Registered Routes](#2-registered-routes)
|
||
3. [How It Works — Full Flow](#3-how-it-works--full-flow)
|
||
- [Authenticated user](#authenticated-user)
|
||
- [Unauthenticated user](#unauthenticated-user)
|
||
4. [Key Files](#4-key-files)
|
||
5. [Adding a New Notification URL](#5-adding-a-new-notification-url)
|
||
6. [Design Decisions](#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:
|
||
|
||
```typescript
|
||
// 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 |
|
||
|
||
```typescript
|
||
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.ts` — `navigateDefault()`
|
||
|
||
After a successful login, reads `returnUrl` from the current router URL and
|
||
replaces the browser location to trigger the notification route again
|
||
(now authenticated):
|
||
|
||
```typescript
|
||
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:
|
||
|
||
```typescript
|
||
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`:
|
||
|
||
```typescript
|
||
{
|
||
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.
|