# Fixera marketing flow — Luna implementation plan

Prepared 21 August 2026 from a read-only audit of the current `fixera` frontend and `fixera-server` backend. This is a build handoff, not an instruction to rewrite working marketing behavior.

## Outcome

Deliver one coherent marketing flow that:

- makes user subscription and unsubscribe paths obvious;
- lets marketing admins inspect subscribers and manage imported professional leads;
- supports canonical country, service, language, role, and audience-source filtering;
- previews the exact deduplicated audience that the send path will use;
- supports newsletters, promotions, re-engagement, and invitations;
- sends test emails without mutating a campaign;
- always applies a safe first-name greeting and unsubscribe footer;
- prevents duplicate delivery across subscribers, leads, locales, and retries;
- displays scheduled state, delivered counts, and campaign list filters;
- preserves existing campaigns and rolls out lead outreach behind an explicit safety gate.

## Non-negotiable compatibility rules

1. Preserve the existing campaign types `newsletter`, `promotion`, and `reengagement`; add `invitation`.
2. Existing campaigns default to `audienceType: subscribers` and continue to open and send.
3. Preserve current consent, unsubscribe, Brevo blacklist reconciliation, delivery leases/retries, template selection, and the 5,000-recipient safety limit.
4. Do not grant marketing admins `services.manage`. Expose a read-only service-options route under `campaigns.manage`.
5. Do not infer consent from an import. Leads are a separate audience source, and lead sending stays disabled until legal/business approval is recorded in configuration.
6. Preview and production sending must call the same audience resolver. The UI must never calculate the authoritative audience itself.

## Product decisions Luna should implement

### Campaign and audience types

```ts
type CampaignType =
  | 'newsletter'
  | 'promotion'
  | 'reengagement'
  | 'invitation';

type AudienceType = 'subscribers' | 'leads' | 'both';
```

- Existing and new non-invitation campaigns default to `subscribers`.
- New invitation campaigns default to `leads`.
- `leads` and `both` are allowed only for `invitation` campaigns while `MARKETING_LEAD_OUTREACH_ENABLED=true`; enforce this server-side, not only in the UI.
- Imported leads are treated as `professional` for the role filter. Selecting customer-only excludes leads.

### Language behavior

- Add one server-owned marketing-language catalog returned by API. Start with `en`, `nl`, `fr`, and `de` plus the supported country-to-language mapping.
- Country narrows/suggests available campaign languages; it does not override an explicit user preference.
- Recipient locale resolution order is: explicit user marketing locale → existing subscriber/lead locale → country default → English fallback.
- The campaign editor shows the union of languages supported by the selected countries and retains any locale already present on an older campaign.
- Each selected audience locale requires campaign content. Unknown/missing recipient locales use the configured fallback; the preview reports how many recipients will use it.
- Store campaign content as a dynamic locale map rather than schema fields hard-coded to EN/NL/FR.

### Services

- Use canonical service values from `ServiceConfiguration`; admins select them through a searchable multi-select.
- Add stable `serviceKeys` as the new audience field while temporarily reading legacy `interestedServices`.
- The service-options route returns key, display label, and active countries. Empty selection means all services.
- Backfill subscriber and campaign service strings through an explicit mapping; report unresolved values rather than silently dropping them.

### Email identity and duplicates

- Add one `normalizeEmail()` helper: trim and lowercase only. Do not strip plus-tags or provider-specific dots.
- `emailNormalized` is the delivery identity.
- Deduplicate before locale splitting. Subscriber data wins over lead data for name/locale when both are sendable.
- An explicit unsubscribe/suppression wins over every source. A duplicate lead must never bypass a subscriber opt-out.
- The preview count and actual send count use the same normalized recipient set.

### Greeting and footer

- The effective email body always starts with a localized greeting such as `Hi John,`.
- First-name resolution: imported `firstName` → subscriber/user first name → first token of the current full name → localized generic fallback (`Hi there,`).
- Escape derived names before inserting them into HTML.
- Inline HTML is wrapped by the server renderer. Template-backed content must be validated to contain the greeting contract at the start; block test/production send when it does not.
- Append the existing signed unsubscribe and preferences links, but localize their visible text by delivery locale.
- The same renderer is used by production send and test send.

### Lead import duplicate policy

- Required columns: `Email`, `Country`, `Language`, `Service`.
- Optional columns: `First name`, `Last name`. Add these to the downloadable/example format because personalized greetings cannot otherwise be guaranteed.
- Duplicate rows inside one file: keep the first and count later identical rows as skipped duplicates.
- Existing lead with identical normalized data: skip and count as duplicate.
- Existing lead with the same normalized email but changed data: show as `will update` during validation and update on commit.
- Existing subscriber with the same email: keep the lead record for provenance, link it where possible, and deduplicate at audience resolution.
- Never store the uploaded workbook after processing; retain import metadata and row-level validation results.

### Scheduling

- This request requires the card to show `scheduled` plus the scheduled date/time while unsent. The current backend already schedules future campaigns and dispatches them during the first daily run after eligibility.
- Preserve that behavior for this scope and label it honestly in the editor: `Scheduled for eligibility at …; delivery runs during the next campaign job`.
- Store UTC and display in the admin’s local timezone. Do not claim exact-minute delivery.
- If exact-minute delivery is requested later, treat it as a separate infrastructure change; do not silently turn the existing daily cron into an unreviewed scheduler.

## Target server design

### Shared audience resolver

Create a deep module under `src/utils/marketing/audienceResolver.ts` with one public interface used by preview and send:

```ts
resolveMarketingAudience({
  campaignType,
  audienceType,
  filters,
  contentLocales,
  inactiveDays,
  limitMode,
}): Promise<{
  recipients: ResolvedMarketingRecipient[];
  exactTotal: number;
  bySource: { subscribers: number; leads: number };
  deduplicated: number;
  excluded: {
    suppressed: number;
    invalidEmail: number;
    missingLocale: number;
    roleMismatch: number;
  };
  fallbackLocaleCount: number;
  overLimit: boolean;
  criteriaHash: string;
}>;
```

`ResolvedMarketingRecipient` contains normalized email, first name, one resolved locale, country, service keys, role, source, and source IDs. The resolver must:

1. validate campaign/audience combinations;
2. query only requested sources;
3. apply consent and suppression;
4. apply country, service, locale, role, and re-engagement filters;
5. resolve exactly one locale per recipient;
6. normalize and deduplicate;
7. return exact totals and the sendable recipient set.

Keep the current `audience.ts` subscriber-sync and Brevo reconciliation functions, but move campaign audience selection behind the new resolver.

### Models

#### Extend `User`

```ts
marketingLocale?: string;
marketingLocaleSource?: 'explicit' | 'country_default' | 'fallback';
```

#### Extend `MarketingSubscriber`

```ts
emailNormalized: string;
firstName?: string;
locale: string;          // remove the hard-coded enum
localeSource?: string;
serviceKeys: string[];
```

Retain legacy `email`, `name`, `region`, `interestedServices`, consent, unsubscribe, Brevo reconciliation, and engagement fields during migration.

#### Add `MarketingLead`

```ts
{
  email: string;
  emailNormalized: string;
  firstName?: string;
  lastName?: string;
  country: string;
  locale: string;
  serviceKeys: string[];
  sourceImportId: ObjectId;
  matchedSubscriberId?: ObjectId;
  status: 'active' | 'deleted';
  unsubscribedAt?: Date | null;
  deletedAt?: Date | null;
  createdAt: Date;
  updatedAt: Date;
}
```

Indexes: unique active identity on `emailNormalized`, plus country, locale, serviceKeys, status, sourceImportId, and createdAt.

#### Add `MarketingLeadImport`

```ts
{
  filename: string;
  uploadedBy: ObjectId;
  uploadedAt: Date;
  status: 'validated' | 'committed' | 'failed';
  totalRows: number;
  validRows: number;
  insertedRows: number;
  updatedRows: number;
  duplicateRows: number;
  rejectedRows: number;
  errors: Array<{ row: number; field?: string; message: string }>;
  committedAt?: Date;
}
```

Retain history after lead edits/deletion. Cap stored row errors (for example 500) and retain aggregate counts.

#### Add `MarketingSuppression`

```ts
{
  emailNormalized: string;
  reason: 'unsubscribe' | 'provider' | 'admin';
  source: 'subscriber' | 'lead' | 'user' | 'brevo';
  createdAt: Date;
  updatedAt: Date;
}
```

Use a unique index on `emailNormalized`. Backfill existing explicit unsubscribes. User explicit re-opt-in may clear an unsubscribe suppression only through the existing consent endpoint; sync jobs must never clear it accidentally.

#### Extend `MarketingCampaign`

- Add `invitation` to the existing campaign type enum.
- Add top-level `audienceType`, default `subscribers`.
- Add `audience.serviceKeys`; keep legacy `interestedServices` readable.
- Change `content` to a dynamic locale map (`Map<string, localeContentSchema>` or equivalent serialized object).
- Keep all existing statuses, schedule fields, send lease/retry fields, UTM data, and per-locale deliveries.
- Extend delivery records with subscriber count, lead count, and deduplicated recipient count for audit.
- Optionally persist `lastPreviewCount`, `lastPreviewAt`, and `lastPreviewCriteriaHash` for card display; never treat a saved preview as the send-time audience.

### API contracts

#### Languages and user preference

```http
GET /api/public/marketing/languages
PATCH /api/user/notification-preferences
```

Extend the existing preference endpoint to accept and validate `marketingLocale`; avoid a second competing preference resource.

#### Marketing service options

```http
GET /api/admin/marketing-campaigns/service-options?countries=BE,NL
```

This route lives under `campaigns.manage`, queries active `ServiceConfiguration` rows read-only, and returns unique canonical choices.

#### Subscribers

```http
GET /api/admin/marketing-subscribers
  ?q=&region=&locale=&serviceKey=&status=&page=&limit=
POST /api/admin/marketing-subscribers/sync
```

Extend the existing list endpoint with service filtering and expose it through an admin subscriber page.

#### Campaign list and CRUD

```http
GET /api/admin/marketing-campaigns
  ?q=&status=&type=&audienceType=&page=&limit=
```

Validate status/type/audienceType. Existing `q`, status, and type behavior remains; add audienceType.

Create/update accepts the new `audienceType`, dynamic locale content, and `serviceKeys`, while continuing to read legacy campaign records.

#### Audience preview

```http
POST /api/admin/marketing-campaigns/preview-audience
```

Return exact `total`, source counts, deduplicated count, exclusions, fallback count, `overLimit`, and `criteriaHash`. Count beyond 5,000 exactly, but mark it unsendable.

#### Test send (supports unsaved campaigns)

```http
POST /api/admin/marketing-campaigns/test-send
{
  "to": "admin@example.com",
  "locale": "en",
  "campaign": { /* current editor payload, saved or unsaved */ }
}
```

- Validate one recipient, selected locale, content, and template contract.
- Use Brevo transactional email through the existing adapter pattern.
- Use a generic test first name or optional `firstName` request value.
- Use the production renderer and add `[TEST]` to the subject.
- Do not resolve an audience or mutate campaign status, deliveries, statistics, or timestamps.
- Apply admin permission, audit logging, and a conservative rate limit.

#### Leads and import history

```http
POST /api/admin/marketing-lead-imports/validate   // multipart workbook
POST /api/admin/marketing-lead-imports/:id/commit
GET  /api/admin/marketing-lead-imports
GET  /api/admin/marketing-leads
PATCH /api/admin/marketing-leads/:id
DELETE /api/admin/marketing-leads/:id
```

- Add a dedicated in-memory Multer policy for `.xlsx`/`.xls` with MIME sniffing, 5 MB file cap, and 5,000 data-row cap.
- Add `exceljs` (or an equivalent maintained parser) and process the first non-empty worksheet.
- Validation normalizes headers case-insensitively, validates ISO country/language/service values, and returns row errors plus insert/update/duplicate counts.
- Commit is idempotent; a second commit returns the original result.
- Lead list supports search, filters for every column, allowlisted sorting, pagination, edit, and soft delete.

#### Unsubscribe

Keep the existing signed public route. Extend its token/payload and mutation so a click:

1. upserts global suppression;
2. disables matching user/subscriber consent;
3. marks matching leads unsubscribed;
4. attempts Brevo suppression;
5. remains locally effective and retryable when Brevo fails;
6. is idempotent.

### Delivery pipeline changes

Keep the current atomic campaign claim, lease, retry, Brevo list-per-locale, provider campaign IDs, and stats refresh. Refactor the send path to:

```text
claim existing campaign
→ resolve through shared audience resolver
→ reject empty/over-limit/missing-content audience
→ persist criteria hash and source/dedupe counts
→ split the already-deduplicated set by one resolved locale
→ render localized greeting/body/footer
→ sync each locale list and persist provider IDs/checkpoints
→ send through Brevo
→ update subscriber/lead engagement timestamps
→ mark sent
```

Retries reuse persisted per-locale provider IDs and must not rebuild a second delivery for an already-completed locale.

## Target frontend design

### Campaign page

Refactor the large page into focused components while preserving behavior:

- `CampaignFilters`: search, status, campaign type, audience type.
- `CampaignCard`: badges, scheduled local date/time, recipients, sent, delivered, opens, clicks.
- `CampaignEditorDialog`: country multi-select, searchable service multi-select, audience type, roles, dynamic locales/content, preview, scheduling, test-send.
- `AudiencePreview`: subscriber/lead/raw/deduplicated/excluded counts and over-limit warning.
- `TestSendControl`: email, locale, send button, success/error state.

Filters reset pagination and are encoded in the API request (prefer URL query state so the page is shareable).

### Subscription visibility

- Rename/clarify the profile preference as `Promotional emails` with explicit subscribe/unsubscribe copy.
- Add `Marketing email language` beside that preference.
- Keep the campaign footer link to `/profile?tab=notifications`.
- Add `Subscribers` and `Leads` navigation actions beside `Sync subscribers` on the campaign page.

### Subscriber admin page

Create `/admin/campaigns/subscribers` using the existing API: search, country, language, service, active/unsubscribed, pagination, consent timestamp, suppression state, and last send. It is read-only except the existing sync action.

### Leads admin page

Create `/admin/campaigns/leads`: upload → validation preview → commit; import history; table search/filter/sort/pagination; edit; soft delete; duplicate/subscriber-match/suppression indicators.

## Dependency-ordered implementation slices

Each slice should be a small, independently testable commit or short commit group.

### Slice 1 — Contracts, compatibility, and utilities

- Add `invitation`, `AudienceType`, language catalog, `normalizeEmail`, resolved-recipient types, and feature flag.
- Add optional schema fields only; keep behavior unchanged.
- Add model/handler tests proving existing campaigns still parse and default to subscribers.

### Slice 2 — User language and dynamic campaign content

- Add user marketing locale and preference UI/API.
- Make subscriber locale a validated string and backfill locale source.
- Convert campaign content to a dynamic locale map with legacy serialization tests.
- Serve the language/country catalog and drive campaign tabs from it.

### Slice 3 — Canonical country/service controls

- Add read-only marketing service-options route.
- Add `serviceKeys`, migration adapter, and unresolved-value reporting.
- Replace country/service free text with searchable multi-selects.
- Ensure marketing role access works without `services.manage`.

### Slice 4 — Shared subscriber resolver and exact preview

- Introduce the resolver using subscribers only first.
- Move preview and send to the same interface.
- Return exclusions, fallback count, exact count, over-limit flag, and hash.
- Compare old/new preview results in tests before removing old selection logic.

### Slice 5 — Suppression and shared rendering

- Add/backfill global suppression.
- Extend unsubscribe for users, subscribers, and future leads.
- Add localized greeting/footer renderer and template validation.
- Use renderer in production without changing audience sources.

### Slice 6 — Test send

- Add unsaved-payload test-send endpoint and UI.
- Reuse renderer and Brevo transactional API.
- Verify test send never changes campaign state/statistics.

### Slice 7 — Lead models and import workflow

- Add lead/import models and indexes.
- Add parser, validation report, idempotent commit, and import-history API/UI.
- Keep all lead sending disabled.

### Slice 8 — Lead database UI and CRUD

- Add list/filter/sort/pagination/edit/soft-delete routes and page.
- Add audit events and PII permission checks.

### Slice 9 — Unified audiences and invitations

- Add lead and both branches to the resolver.
- Enforce invitation/feature-flag legal gate.
- Apply global suppression, cross-source dedupe, and one-locale assignment.
- Extend preview/delivery audit counts and campaign editor audience type.

### Slice 10 — Campaign list polish and existing partial items

- Connect existing backend q/type/status filters and new audienceType filter.
- Display delivered count already stored by Brevo stats.
- Display `scheduled` plus local date/time while unsent.
- Add subscriber/lead links, exact last preview, empty/loading/error states.
- Verify the existing unsubscribe footer in inline and template-backed sends.

## Migration and deployment order

1. Deploy additive fields, catalogs, and compatibility readers with all new sending flags off.
2. Seed/serve language definitions and add `marketingLocale` without changing existing recipient selection.
3. Backfill `emailNormalized`; report and resolve collisions before creating unique indexes.
4. Backfill subscriber locale/source and service keys with resumable, idempotent scripts.
5. Convert/read campaign content dynamically and verify every existing campaign round-trips.
6. Backfill global suppression from explicit unsubscribes and Brevo-suppressed subscribers.
7. Enable the shared resolver for preview only; compare counts against the old resolver.
8. Enable the resolver for subscriber sends after parity is proven.
9. Deploy lead import/CRUD behind the feature flag; import and inspect data without sending.
10. Obtain legal/business approval and configure sender/legal footer for invitations.
11. Enable lead audience preview; verify exact counts and collision/suppression exclusions.
12. Enable invitation sends first to an internal lead fixture, then a limited live batch.
13. Remove legacy service writes and hard-coded locale assumptions only after all stored data is migrated.

Rollback disables behavior flags; it does not delete additive data. Never delete suppression, import history, or delivery audit records during rollback.

## Verification matrix

### Unit tests

- Email normalization and deduplication, including case/whitespace and subscriber/lead collisions.
- Language resolution: explicit preference, country default, unsupported locale, fallback.
- Service-key normalization and unresolved legacy values.
- Greeting fallback and HTML escaping.
- Localized footer/token rendering.
- Spreadsheet headers, row validation, duplicate/update policy, row/file limits.
- Campaign type/audience type validation.

### Handler/model tests

- Existing campaign defaults and dynamic locale content round-trip.
- Campaign q/status/type/audienceType filters and pagination.
- Service-options authorization for marketing admins.
- Subscriber list service/status filters.
- Exact preview source/dedupe/exclusion counts.
- Test-send validation, Brevo failure, rate limit, and no state mutation.
- Validate/commit import idempotency and import-history totals.
- Lead CRUD authorization, allowlisted sorting, edit, and soft delete.
- Unsubscribe idempotency and global suppression when Brevo is unavailable.
- Campaign claim/retry does not resend completed locale deliveries.

### End-to-end acceptance scenarios

1. A user enables Promotional email, chooses German, appears as an active subscriber, and can unsubscribe from the email or profile.
2. Marketing selects DE + Plumbing and sees German among the locale options without typing a service.
3. Audience preview exactly matches country, service, locale, and role filters.
4. A test email from an unsaved draft begins with a safe greeting, uses selected content, and contains working unsubscribe/preferences links without changing campaign status.
5. A future campaign card shows `scheduled` and the local date/time until the campaign starts; it continues to document next-job delivery semantics.
6. A sent campaign displays Brevo `delivered` alongside sent, opens, and clicks after stats refresh.
7. An Excel file reports inserts, updates, duplicates, and invalid rows before commit; commit is repeat-safe and upload history shows filename/date/time/counts.
8. Leads can be searched, filtered, sorted, edited, and soft-deleted without losing import history.
9. `both` audience preview deduplicates the same normalized email and prefers subscriber metadata.
10. An explicit opt-out excludes the email even when it also exists as a lead.
11. One campaign can never deliver twice to the same normalized email, including across locales and retries.
12. Invitation sending is impossible while the lead-outreach flag is off or when legal configuration is absent.

## Critical files Luna will touch

Frontend:

- `fixera/app/(pages)/admin/campaigns/page.tsx`
- `fixera/components/notifications/NotificationPreferences.tsx`
- new `fixera/app/(pages)/admin/campaigns/subscribers/page.tsx`
- new `fixera/app/(pages)/admin/campaigns/leads/page.tsx`
- new focused components under `fixera/components/admin/marketing/`
- `fixera/lib/adminRbac.ts` only if new paths need explicit client gating

Server:

- `fixera-server/src/models/user.ts`
- `fixera-server/src/models/marketingSubscriber.ts`
- `fixera-server/src/models/marketingCampaign.ts`
- new `marketingLead.ts`, `marketingLeadImport.ts`, and `marketingSuppression.ts`
- `fixera-server/src/handlers/Admin/marketingCampaigns.ts`
- new `fixera-server/src/handlers/Admin/marketingLeads.ts`
- `fixera-server/src/handlers/User/fcmHandler.ts`
- `fixera-server/src/handlers/Public/marketingUnsubscribe.ts`
- `fixera-server/src/utils/marketing/audience.ts`
- new `audienceResolver.ts`, `renderCampaign.ts`, `marketingCatalog.ts`, `normalizeEmail.ts`, and lead import parser
- `fixera-server/src/utils/marketing/sendCampaign.ts`
- `fixera-server/src/utils/marketing/brevoMarketing.ts`
- `fixera-server/src/routes/Admin/index.ts`, `routes/Public/index.ts`, and user route registration as required
- `fixera-server/src/utils/adminRbac/routePermissions.ts`
- marketing handler/utility/cron tests plus new lead import and resolver tests

## Definition of done

The work is complete only when every original issue maps to a passing acceptance scenario, existing campaign tests still pass, server TypeScript builds, frontend TypeScript/build succeeds, a Brevo dry-run proves preview/send parity, and a controlled internal live email proves greeting, localization, unsubscribe, scheduling display, deduplication, and delivered-stat refresh.

