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

334 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.