---
id: AmendmentApplyRequested
name: Amendment Apply Requested
version: 1.0.0
summary: >-
A user finished reviewing a contract amendment and asked for its changes to be
applied. cna-nexus's amendment consumer executes the changes on the contract
and the franchise.
owners:
- cna-platform
schemaPath: schema.json
badges:
- content: "Topic: Contract"
backgroundColor: purple
textColor: purple
icon: kafka
- content: "Key: contractId"
backgroundColor: gray
textColor: gray
x-contract-owner: NEXUS
x-kafka-topic: Contract
x-message-key: contractId
x-source: cna-nexus api/app/events/nexus/contract/AmendmentApplyRequested.ts
---
## Overview
Fact: an amendment was saved in the state `APPLYING` and its application is requested.
**When it is published.** [[service|cna-nexus]], in `persistAmendmentUpdate`, reached from the mutations that update an amendment or a rescission: after the amendment's change rows and participants are replaced and its `signedAt` and `status = APPLYING` are written. No transaction wraps those writes and the producer is awaited afterwards, not registered with `afterCommit`. The parent contract is untouched at this point.
**What the consumer does.** cna-nexus (`amendmentApplyConsumer`, group `contract-amendment-apply`), using only `amendmentId` (`contractId` is there for routing):
1. Loads the amendment; a missing one throws (logged and reported to Sentry).
2. Idempotency guard: `APPLIED` or `APPLY_FAILED` are skipped.
3. `applyAmendmentChanges`: in one transaction, dispatches each change to its handler (name, legal name, CNPJ, category, estimated opening date, address, territory, end date, corporate change; `ADDITIONAL_CLAUSE` and `OTHER` have no handler and are skipped), runs the contract termination for a `RESCISSION`, writes field-level audit rows with the uploader as author, and marks the amendment `APPLIED`.
4. On failure: logs and sets the amendment to `APPLY_FAILED`. Not rethrown.
**Follow-up message.** Exactly one `FranchiseUpdated` per amendment, registered with `manager.afterCommit()` (see the `Franchise` topic).
## Kafka
| | |
| -------------------------- | -------------------------- |
| Topic (`aggregateRoot`) | `Contract` |
| Message key (`routingKey`) | `payload.contractId` |
| Contract owner | `NEXUS` |
| `metadata.event` | `AmendmentApplyRequested` |
| Consumer group | `contract-amendment-apply` |
## Payload schema
## Source of truth
```typescript
type AmendmentApplyRequestedPayload = {
contractId: string;
amendmentId: string;
};
export class AmendmentApplyRequested extends Event {
static readonly owner = "NEXUS";
static readonly aggregateRoot = "Contract";
static readonly routingKey = "contractId";
}
```
The class exists only in cna-nexus.
## Raw Schema:schema.json
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "AmendmentApplyRequested payload",
"type": "object",
"additionalProperties": false,
"properties": {
"contractId": {
"type": "string",
"description": "Contract the amendment modifies (UUID). Kafka message key, so amendments and activations of one contract stay ordered."
},
"amendmentId": {
"type": "string",
"description": "Amendment id (UUID) in contract.contract_amendments. The only field the consumer reads."
}
},
"required": ["contractId", "amendmentId"]
}
---
id: ContractActivationRequested
name: Contract Activation Requested
version: 1.0.0
summary: >-
A user saved a contract for activation. cna-nexus's activation consumer turns
it ACTIVE and creates or updates the franchise unit it governs.
owners:
- cna-platform
schemaPath: schema.json
badges:
- content: "Topic: Contract"
backgroundColor: purple
textColor: purple
icon: kafka
- content: "Key: contractId"
backgroundColor: gray
textColor: gray
x-contract-owner: NEXUS
x-kafka-topic: Contract
x-message-key: contractId
x-source: cna-nexus api/app/events/nexus/contract/ContractActivationRequested.ts
---
## Overview
Fact: a contract was saved in the state `ACTIVATING` and its activation is requested. It is the hand-off between the API request and the background work.
**When it is published.** [[service|cna-nexus]], in `createContract` and `updateContract`, right after the transaction that writes the contract with its financial terms, franchise data, address, territory and participants returns with `status = ACTIVATING`. Every update of a contract publishes it again, so the franchise aggregate is brought back in sync. Published after the write, not registered with `afterCommit`: if the producer fails, the contract stays `ACTIVATING` with no message.
**What the consumer does.** cna-nexus (`contractActivationConsumer`, group `contract-activation`):
1. Loads the contract; a missing contract throws (logged and reported to Sentry).
2. Idempotency guard: `ACTIVE` or `ACTIVATION_FAILED` are skipped, so redelivery after success is harmless and a failed activation needs a manual retry.
3. `activateContract(contractId, userId)`: re-checks `ACTIVATING`, then in one transaction sets the contract `ACTIVE` with `activatedAt`, and projects it onto the franchise. The first activation of an `OPENING` with no franchise creates the `Franchise` aggregate (unit, brand, address, territory, partners) and links the contract to it; any other case applies the contract snapshot onto the existing franchise. A `RENEWAL` flips the previous active or expired contract to `RENEWED`. Field-level audit rows are written with the `userId` from the payload.
4. On failure: logs and sets the contract to `ACTIVATION_FAILED`. Not rethrown, so Sentry is not notified from this path.
**Follow-up messages.** Inside the same transaction, via `manager.afterCommit()`: `FranchiseCreated` on the first activation of an opening, `FranchiseUpdated` otherwise (see the `Franchise` topic).
## Kafka
| | |
| -------------------------- | ----------------------------- |
| Topic (`aggregateRoot`) | `Contract` |
| Message key (`routingKey`) | `payload.contractId` |
| Contract owner | `NEXUS` |
| `metadata.event` | `ContractActivationRequested` |
| Consumer group | `contract-activation` |
## Payload schema
## Source of truth
```typescript
type ContractActivationRequestedPayload = {
contractId: string;
userId: string;
};
export class ContractActivationRequested extends Event {
static readonly owner = "NEXUS";
static readonly aggregateRoot = "Contract";
static readonly routingKey = "contractId";
}
```
The class exists only in cna-nexus.
## Known drift
- Docstrings in `createContract`, `createContractAndRelations` and `updateContractAndRelations` say a failed activation moves the contract to `DRAFT`; the consumer writes `ACTIVATION_FAILED`.
- Published without `afterCommit`, unlike the Franchise events it triggers.
## Raw Schema:schema.json
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "ContractActivationRequested payload",
"type": "object",
"additionalProperties": false,
"properties": {
"contractId": {
"type": "string",
"description": "Contract id (UUID) in cna-nexus contract.contracts. Kafka message key."
},
"userId": {
"type": "string",
"description": "User who saved the contract (authorization.users). Recorded as the author of the audit rows written by the activation."
}
},
"required": ["contractId", "userId"]
}
---
id: DocumentAnalysisRequested
name: Document Analysis Requested
version: 1.0.0
summary: >-
A contract or amendment document was uploaded and an AI analysis row created
for it. cna-nexus's analysis worker extracts the structured data and leaves
the contract or amendment in DRAFT for review.
owners:
- cna-platform
schemaPath: schema.json
badges:
- content: "Topic: AI"
backgroundColor: purple
textColor: purple
icon: kafka
- content: "Key: analysisId"
backgroundColor: gray
textColor: gray
x-contract-owner: NEXUS
x-kafka-topic: AI
x-message-key: analysisId
x-source: cna-nexus api/app/events/nexus/ai/DocumentAnalysisRequested.ts
---
## Overview
Fact: an `AiAnalysis` row exists in `PENDING` for an uploaded document and someone asked for it to be processed. `type` says whether the document is a contract or an amendment; the row itself points at the file, and the file back to the contract or amendment.
**When it is published.** [[service|cna-nexus]], right after creating the analysis row, in four places: `uploadContract` (file to S3, contract created `PENDING` with its related rows) and `reprocessContractFile` (a contract in `PROCESSING_FAILED` set back to `PENDING` with a new analysis row), both with `type = CONTRACT`; `persistAmendmentUpload` (only when the amendment has analysable changes and the file is not a spreadsheet) and `reprocessAmendmentFile`, both with `type = AMENDMENT`. Published after the write, not registered with `afterCommit`.
**What the consumer does.** cna-nexus (`documentAnalysisConsumer`, group `ai-document-analysis`):
1. Loads the analysis. `COMPLETED` or `FAILED`: skipped. `PROCESSING`: skipped as a redelivery being handled by another worker.
2. Claims it atomically: `PENDING` to `PROCESSING`, `startedAt` set, `attempts` incremented. Losing the claim means another worker got it; stop.
3. Dispatches on `type`. For a contract: loads the file and the contract, builds the Portuguese prompt with all brands and categories, downloads the file from S3, extracts its text (PDF or DOCX) and sends it to Anthropic with a JSON Schema for structured output. The extracted data is applied to the contract, its financial terms, franchise data, territory and participants; the contract goes to `DRAFT` with a generated identifier and a title; the analysis goes to `COMPLETED`; the uploader is notified that the document is ready for review. For an amendment: same flow scoped to the change fields the user selected, ending with the amendment in `DRAFT`.
4. On failure: the analysis goes to `FAILED` with the error, the contract or amendment to `PROCESSING_FAILED`, the uploader is notified, and the error is rethrown so it reaches Sentry. The offset is committed either way; recovery is the manual reprocess mutation. A scheduled job marks analyses stuck in `PROCESSING` for more than 30 minutes as `FAILED`.
Model, temperature and token limit come from the `ANTHROPIC_*` environment variables; the raw provider response is stored in the analysis metadata.
## Kafka
| | |
| -------------------------- | --------------------------- |
| Topic (`aggregateRoot`) | `AI` |
| Message key (`routingKey`) | `payload.analysisId` |
| Contract owner | `NEXUS` |
| `metadata.event` | `DocumentAnalysisRequested` |
| Consumer group | `ai-document-analysis` |
## Payload schema
## Source of truth
```typescript
import { DocumentAnalysisType } from "domains/ai/enums/DocumentAnalysisType";
type DocumentAnalysisRequestedPayload = {
analysisId: string;
type: DocumentAnalysisType; // CONTRACT | AMENDMENT
};
export class DocumentAnalysisRequested extends Event {
static readonly owner = "NEXUS";
static readonly aggregateRoot = "AI";
static readonly routingKey = "analysisId";
}
```
The class exists only in cna-nexus.
## Raw Schema:schema.json
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "DocumentAnalysisRequested payload",
"type": "object",
"additionalProperties": false,
"properties": {
"analysisId": {
"type": "string",
"description": "AiAnalysis id (UUID) in cna-nexus ai.analyses. Kafka message key. The row points at the file, which points at the contract or amendment."
},
"type": {
"type": "string",
"enum": ["CONTRACT", "AMENDMENT"],
"description": "Kind of document (cna-nexus DocumentAnalysisType). Fixed per producer: contract uploads send CONTRACT, amendment uploads send AMENDMENT."
}
},
"required": ["analysisId", "type"]
}
---
id: EconomicGroupCreated
name: Economic Group Created
version: 1.0.0
summary: >-
An economic group was created in CNA Nexus. cna-one mirrors it by id so that
franchises can reference it.
owners:
- cna-platform
schemaPath: schema.json
badges:
- content: 'Topic: Franchise'
backgroundColor: purple
textColor: purple
icon: kafka
- content: 'Key: id'
backgroundColor: gray
textColor: gray
- content: Payload drift
backgroundColor: yellow
textColor: yellow
x-contract-owner: NEXUS
x-kafka-topic: Franchise
x-message-key: id
x-source: cna-nexus api/app/events/nexus/franchise/EconomicGroupCreated.ts
x-drift: >-
cna-one's copy of EconomicGroupPayload lacks createdAt and updatedAt and lives
under events/nexus/economicGroup/ instead of events/nexus/franchise/.
---
## Overview
Fact: an [[entity|EconomicGroup]] now exists in Nexus. The payload is the group row.
**When it is published.** [[service|cna-nexus]], in `createEconomicGroup`, right after the insert. Published after the write, without `afterCommit`.
**What the consumer does.** [[service|cna-one]] (`franchiseConsumer`, handler `syncEconomicGroup`, shared with [[event|EconomicGroupUpdated]]): upserts the group by `id` with `name`, `isActive` and `deletedAt`, storing them as they arrive without validation. `createdAt` and `updatedAt` are ignored. The mirrored group is what a later [[event|FranchiseCreated]] needs to resolve `economicGroup.id`; a franchise arriving before its group fails to mirror.
Failures are logged and reported to Sentry, never rethrown.
## Kafka
| | |
| -------------------------- | ---------------------------- |
| Topic (`aggregateRoot`) | `Franchise` |
| Message key (`routingKey`) | `payload.id` |
| Contract owner | `NEXUS` |
| `metadata.event` | `EconomicGroupCreated` |
| Consumer group | `franchise-franchise-events` |
## Payload schema
## Source of truth
```typescript
export type EconomicGroupPayload = {
id: string;
name: string;
isActive: boolean;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
};
export class EconomicGroupCreated extends Event {
static readonly owner = 'NEXUS';
static readonly aggregateRoot = 'Franchise';
static readonly routingKey = 'id';
}
```
## Known drift
cna-one `api/app/events/nexus/economicGroup/EconomicGroupCreated.ts` has no `createdAt` or `updatedAt`, and is stored under a different folder than the canonical copy.
## Raw Schema:schema.json
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "EconomicGroupCreated payload",
"type": "object",
"additionalProperties": false,
"properties": {
"id": {
"type": "string",
"description": "Economic group id (UUID). Kafka message key; cna-one mirrors the group under this id."
},
"name": {
"type": "string",
"description": "Group name, unique among non-deleted groups."
},
"isActive": {
"type": "boolean",
"description": "Whether the group is active. False after a soft delete."
},
"createdAt": {
"type": "string",
"format": "date-time",
"description": "Creation instant. Absent from cna-one's copy of the type."
},
"updatedAt": {
"type": "string",
"format": "date-time",
"description": "Last update instant. Absent from cna-one's copy of the type."
},
"deletedAt": {
"type": ["string", "null"],
"format": "date-time",
"description": "Soft-delete instant, or null. Always present as a key."
}
},
"required": ["id", "name", "isActive", "createdAt", "updatedAt", "deletedAt"]
}
---
id: EconomicGroupUpdated
name: Economic Group Updated
version: 1.0.0
summary: >-
An economic group changed in CNA Nexus - renamed, toggled active or
soft-deleted (there is no delete event). cna-one mirrors the new state by id.
owners:
- cna-platform
schemaPath: schema.json
badges:
- content: 'Topic: Franchise'
backgroundColor: purple
textColor: purple
icon: kafka
- content: 'Key: id'
backgroundColor: gray
textColor: gray
- content: Payload drift
backgroundColor: yellow
textColor: yellow
x-contract-owner: NEXUS
x-kafka-topic: Franchise
x-message-key: id
x-source: cna-nexus api/app/events/nexus/franchise/EconomicGroupUpdated.ts
x-drift: cna-one's copy of EconomicGroupPayload lacks createdAt and updatedAt.
---
## Overview
Fact: an [[entity|EconomicGroup]] changed. Same payload as [[event|EconomicGroupCreated]].
**When it is published.** [[service|cna-nexus]], after the write and without `afterCommit`, from `updateEconomicGroup` (name or `isActive`, including the toggle-status service) and from `deleteEconomicGroup`: a soft delete sets `deletedAt` and `isActive = false` and publishes this event with the deleted state, so consumers see the group leave the active set. There is no `EconomicGroupDeleted` event.
**What the consumer does.** [[service|cna-one]] runs the same handler as for `EconomicGroupCreated` (`syncEconomicGroup`): upsert by `id`, storing `name`, `isActive` and `deletedAt` as they arrive.
## Kafka
| | |
| -------------------------- | ---------------------------- |
| Topic (`aggregateRoot`) | `Franchise` |
| Message key (`routingKey`) | `payload.id` |
| Contract owner | `NEXUS` |
| `metadata.event` | `EconomicGroupUpdated` |
| Consumer group | `franchise-franchise-events` |
## Payload schema
Same payload type as `EconomicGroupCreated` (`EconomicGroupPayload`).
## Source of truth
```typescript
import { EconomicGroupPayload } from 'events/nexus/franchise/EconomicGroupCreated';
export class EconomicGroupUpdated extends Event {
static readonly owner = 'NEXUS';
static readonly aggregateRoot = 'Franchise';
static readonly routingKey = 'id';
}
```
## Raw Schema:schema.json
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "EconomicGroupUpdated payload",
"type": "object",
"additionalProperties": false,
"properties": {
"id": {
"type": "string",
"description": "Economic group id (UUID). Kafka message key; cna-one mirrors the group under this id."
},
"name": {
"type": "string",
"description": "Group name, unique among non-deleted groups."
},
"isActive": {
"type": "boolean",
"description": "Whether the group is active. False after a soft delete."
},
"createdAt": {
"type": "string",
"format": "date-time",
"description": "Creation instant. Absent from cna-one's copy of the type."
},
"updatedAt": {
"type": "string",
"format": "date-time",
"description": "Last update instant. Absent from cna-one's copy of the type."
},
"deletedAt": {
"type": ["string", "null"],
"format": "date-time",
"description": "Soft-delete instant, or null. Always present as a key."
}
},
"required": ["id", "name", "isActive", "createdAt", "updatedAt", "deletedAt"]
}
---
id: EmployeeCreated
name: Employee Created
version: 1.0.0
summary: >-
An employee was created in CNA One, with the person it points at. Consumed by
cna-auth to provision the account and the login user.
owners:
- cna-platform
schemaPath: schema.json
badges:
- content: "Topic: Employee"
backgroundColor: purple
textColor: purple
icon: kafka
- content: "Key: id"
backgroundColor: gray
textColor: gray
x-contract-owner: ONE
x-kafka-topic: Employee
x-message-key: id
x-source: cna-one api/app/events/one/employee/EmployeeCreated.ts
x-drift: >-
cna-auth's copy names the type EmployeeEventPayload and types person.birthdate
as Date, while the wire carries a YYYY-MM-DD string.
---
## Overview
Fact: an employee now exists in CNA One. The payload is a snapshot of the employee and of the person it points at, enough for [[service|cna-auth]] to provision identity without calling back.
**When it is published.** [[service|cna-one]] publishes it after the write completes, not inside `afterCommit`:
- `createEmployee`, when a franchise manager creates an employee: after e-mail validation, `findOrCreatePerson` and the transaction that saves the employee and its inactive staff link.
- `syncFranchisePartners`, while handling a `FranchiseCreated` from Nexus: one message per partner that did not yet exist as an employee (partners that already existed get `EmployeeUpdated` instead).
It is deliberately **not** published for the employee created by `InviteUser`: that flow provisions the login through `GrantApplicationAccess` instead.
**What the consumer does.** cna-auth (`accountConsumer`, handler `syncEmployeeEvent`, shared with `EmployeeUpdated`):
1. Rejects the message when `person.type` is not `CPF` (logged, reported to Sentry, dropped).
2. Finds or creates the `Account` by `person.document`, setting `name` (name and surname joined), `employeeId` (the payload `id`) and `personId`. A new account gets an OTP authentication method when it has none.
3. Finds the `User` in that account by `employeeId`, falling back to `email`; creates it when missing and sends the first-access e-mail for application `cna-one`. Existing users are updated: `name`, `email`, `employeeId`, and `blockedAt` set when `isActive` is false, cleared when true.
Failures are logged and reported to Sentry, never rethrown; the offset is committed either way.
## Kafka
| | |
| -------------------------- | ------------------ |
| Topic (`aggregateRoot`) | `Employee` |
| Message key (`routingKey`) | `payload.id` |
| Contract owner | `ONE` |
| `metadata.event` | `EmployeeCreated` |
| Consumer group | `auth-user-events` |
## Payload schema
## Source of truth
```typescript
export type EmployeePayload = {
id: string;
email: string;
isActive: boolean;
person: {
id: string;
name: string;
surname: string;
birthdate?: string | null;
document: string;
type: string;
email?: string | null;
phone?: string | null;
};
};
class EmployeeCreated extends Event {
public static owner = "ONE";
public static aggregateRoot = "Employee";
public static routingKey = "id";
}
```
## Known drift
- cna-auth `api/app/events/one/employee/EmployeeCreated.ts` names the type `EmployeeEventPayload`, extracts the nested object as `EmployeePersonData`, and types `birthdate` as `Date | null`. On the wire it is the `YYYY-MM-DD` string TypeORM returns for a `date` column.
- cna-auth's handler passes the literal `cna-one` as the application of the first-access e-mail although its docstring says it uses `metadata.owner`.
## Raw Schema:schema.json
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "EmployeeCreated payload",
"type": "object",
"additionalProperties": false,
"properties": {
"id": {
"type": "string",
"description": "Employee id (UUID) in cna-one franchise.employees. Kafka message key; stored as employeeId on the cna-auth account and user."
},
"email": {
"type": "string",
"description": "Employee work e-mail, unique across employees. Becomes the cna-auth user e-mail."
},
"isActive": {
"type": "boolean",
"description": "Whether the employee is active. cna-auth sets blockedAt on the user when false and clears it when true."
},
"person": {
"type": "object",
"description": "Snapshot of the Person the employee points at (cna-one common.persons).",
"additionalProperties": false,
"properties": {
"id": {
"type": "string",
"description": "Person id (UUID). Stored as personId on the cna-auth account."
},
"name": {
"type": "string",
"description": "First name(s)."
},
"surname": {
"type": "string",
"description": "Surname. May be empty when the origin provided a single-word name."
},
"birthdate": {
"type": ["string", "null"],
"format": "date",
"description": "Birth date as YYYY-MM-DD, or null."
},
"document": {
"type": "string",
"description": "Person document without mask: 11 digits for CPF, 14 for CNPJ, alphanumeric for RNM."
},
"type": {
"type": "string",
"enum": ["CPF", "CNPJ", "RNM"],
"description": "Document type (cna-one DocumentType). cna-auth only accepts CPF and drops the others."
},
"email": {
"type": ["string", "null"],
"description": "Personal e-mail, or null."
},
"phone": {
"type": ["string", "null"],
"description": "Phone number, or null."
}
},
"required": ["id", "name", "surname", "document", "type"]
}
},
"required": ["id", "email", "isActive", "person"]
}
---
id: EmployeeUpdated
name: Employee Updated
version: 1.0.0
summary: >-
An employee or the person it points at changed in CNA One. Consumed by
cna-auth to update the account and the user, including blocking or unblocking
the login.
owners:
- cna-platform
schemaPath: schema.json
badges:
- content: "Topic: Employee"
backgroundColor: purple
textColor: purple
icon: kafka
- content: "Key: id"
backgroundColor: gray
textColor: gray
x-contract-owner: ONE
x-kafka-topic: Employee
x-message-key: id
x-source: cna-one api/app/events/one/employee/EmployeeUpdated.ts
x-drift: >-
cna-auth's copy types person.birthdate as Date, while the wire carries a
YYYY-MM-DD string.
---
## Overview
Fact: an employee, or the person behind it, changed in CNA One. Same payload as [[event|EmployeeCreated]]: a full snapshot, not a delta.
**When it is published.** [[service|cna-one]] publishes it after the write completes:
- `updateEmployee` (franchise scope) and `franchisorUpdateEmployee` (franchisor scope), after the transaction that updates the person and the employee.
- `syncFranchisePartners`, while handling a `FranchiseCreated` from Nexus, for every partner that already existed as an employee. Re-processing the same `FranchiseCreated` therefore re-announces partners as updates, never as duplicate creations.
**What the consumer does.** [[service|cna-auth]] runs the same handler as for `EmployeeCreated` (`syncEmployeeEvent`): rejects non-CPF persons, upserts the `Account` by document, then finds the `User` by `employeeId` or `email` and updates `name`, `email`, `employeeId` and `blockedAt` (set when `isActive` is false, cleared when true). A user missing at this point is created, with the first-access e-mail.
## Kafka
| | |
| -------------------------- | ------------------ |
| Topic (`aggregateRoot`) | `Employee` |
| Message key (`routingKey`) | `payload.id` |
| Contract owner | `ONE` |
| `metadata.event` | `EmployeeUpdated` |
| Consumer group | `auth-user-events` |
## Payload schema
Same payload type as `EmployeeCreated` (`EmployeePayload`).
## Source of truth
```typescript
import { EmployeePayload } from "events/one/employee/EmployeeCreated";
class EmployeeUpdated extends Event {
public static owner = "ONE";
public static aggregateRoot = "Employee";
public static routingKey = "id";
}
```
## Known drift
Same as `EmployeeCreated`: cna-auth's copy types `person.birthdate` as `Date | null`; the wire value is a `YYYY-MM-DD` string.
## Raw Schema:schema.json
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "EmployeeUpdated payload",
"type": "object",
"additionalProperties": false,
"properties": {
"id": {
"type": "string",
"description": "Employee id (UUID) in cna-one franchise.employees. Kafka message key; stored as employeeId on the cna-auth account and user."
},
"email": {
"type": "string",
"description": "Employee work e-mail, unique across employees. Becomes the cna-auth user e-mail."
},
"isActive": {
"type": "boolean",
"description": "Whether the employee is active. cna-auth sets blockedAt on the user when false and clears it when true."
},
"person": {
"type": "object",
"description": "Snapshot of the Person the employee points at (cna-one common.persons).",
"additionalProperties": false,
"properties": {
"id": {
"type": "string",
"description": "Person id (UUID). Stored as personId on the cna-auth account."
},
"name": {
"type": "string",
"description": "First name(s)."
},
"surname": {
"type": "string",
"description": "Surname. May be empty when the origin provided a single-word name."
},
"birthdate": {
"type": ["string", "null"],
"format": "date",
"description": "Birth date as YYYY-MM-DD, or null."
},
"document": {
"type": "string",
"description": "Person document without mask: 11 digits for CPF, 14 for CNPJ, alphanumeric for RNM."
},
"type": {
"type": "string",
"enum": ["CPF", "CNPJ", "RNM"],
"description": "Document type (cna-one DocumentType). cna-auth only accepts CPF and drops the others."
},
"email": {
"type": ["string", "null"],
"description": "Personal e-mail, or null."
},
"phone": {
"type": ["string", "null"],
"description": "Phone number, or null."
}
},
"required": ["id", "name", "surname", "document", "type"]
}
},
"required": ["id", "email", "isActive", "person"]
}
---
id: EntityAudited
name: Entity Audited
version: 1.0.0
summary: >-
A row of an audited table in CNA One was inserted, updated or deleted, with
the old and new values. Infrastructure is wired but no entity opts in and
nobody consumes it.
owners:
- cna-platform
schemaPath: schema.json
badges:
- content: "Topic: Audit"
backgroundColor: purple
textColor: purple
icon: kafka
- content: "Key: entityId"
backgroundColor: gray
textColor: gray
- content: No consumer
backgroundColor: yellow
textColor: yellow
- content: Not published today
backgroundColor: yellow
textColor: yellow
x-contract-owner: AUDIT
x-kafka-topic: Audit
x-message-key: entityId
x-source: cna-one api/app/events/one/audit/EntityAudited.ts
x-drift: >-
Never published in practice - no cna-one entity carries @Auditable(). No
consumer in any application. The class declares owner AUDIT, which is not an
application.
---
## Overview
Fact: a row of an audited table changed. The payload records which table and row, the operation, the values before and after, and who did it.
**When it is published.** [[service|cna-one]] has a TypeORM subscriber (`AuditSubscriber`) hooked on `afterInsert`, `afterUpdate` and `afterRemove` of every entity marked with the `@Auditable()` decorator. For an audited entity it builds the payload and calls `auditService.register`, which publishes the event. The call is not awaited: publication starts inside the write transaction, before commit, and a failure is swallowed by the producer. The subscriber is disabled in tests.
**Today no entity carries `@Auditable()`**, so the subscriber never fires and no message reaches the topic. The decorator accepts `ignore` (fields dropped from the values) and `sensitive` (fields replaced by `***`).
**Values.** `entityName` is the table name (for example `employees`), not the class name. `oldValues` / `newValues` are keyed by property name: on insert only `newValues`, on delete only `oldValues`, on update only the columns whose value changed (an update with no change publishes nothing). `userId` and `ip` come from the request audit context, which nothing populates today, so both are `null`. `timestamp` is a `Date` in code and an ISO-8601 string on the wire.
**Consumers.** None in cna-auth, cna-nexus, cna-one or cna-placement.
## Kafka
| | |
| -------------------------- | ------------------ |
| Topic (`aggregateRoot`) | `Audit` |
| Message key (`routingKey`) | `payload.entityId` |
| Contract owner | `AUDIT` |
| `metadata.event` | `EntityAudited` |
| Consumer group | none |
## Payload schema
## Source of truth
```typescript
import { AuditData } from "audit/types";
class EntityAudited extends Event {
public static owner = "AUDIT";
public static readonly aggregateRoot = "Audit";
public static readonly routingKey = "entityId";
}
// api/app/audit/types.ts
export enum AuditOperation {
INSERT = "INSERT",
UPDATE = "UPDATE",
DELETE = "DELETE",
}
export type AuditData = {
entityName: string;
entityId: string;
operation: AuditOperation;
oldValues: Record | null;
newValues: Record | null;
userId: string | null;
ip: string | null;
timestamp: Date;
};
```
## Known drift
- No entity opts in with `@Auditable()`: the event is defined and wired but never published. Decide which entities to audit, or remove the topic.
- No consumer anywhere. If an external sink is intended, document it as an external service.
- `owner = 'AUDIT'` is not an application; `metadata.owner` on this topic reads `AUDIT`, with `producedBy` `ONE` once the envelope change lands.
- Publication happens inside the transaction, unawaited, so a rolled-back write can still emit an audit message and a failed publish is lost.
## Raw Schema:schema.json
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "EntityAudited payload",
"type": "object",
"additionalProperties": false,
"properties": {
"entityName": {
"type": "string",
"description": "Table name of the audited entity (TypeORM tableName, not schema-qualified), e.g. employees."
},
"entityId": {
"type": "string",
"description": "Id of the audited row. Kafka message key."
},
"operation": {
"type": "string",
"enum": ["INSERT", "UPDATE", "DELETE"],
"description": "Kind of change (cna-one AuditOperation)."
},
"oldValues": {
"type": ["object", "null"],
"additionalProperties": true,
"description": "Values before the change, keyed by property name. Null on INSERT. On UPDATE only the changed columns. Sensitive fields are replaced by ***."
},
"newValues": {
"type": ["object", "null"],
"additionalProperties": true,
"description": "Values after the change, keyed by property name. Null on DELETE. On UPDATE only the changed columns. Sensitive fields are replaced by ***."
},
"userId": {
"type": ["string", "null"],
"description": "User who made the change, from the request audit context. Always null today."
},
"ip": {
"type": ["string", "null"],
"description": "Client IP, from the request audit context. Always null today."
},
"timestamp": {
"type": "string",
"format": "date-time",
"description": "When the change was recorded. A Date in code, serialised as ISO-8601."
}
},
"required": [
"entityName",
"entityId",
"operation",
"oldValues",
"newValues",
"userId",
"ip",
"timestamp"
]
}
---
id: FranchiseAddressCreated
name: Franchise Address Created
version: 1.0.0
summary: >-
A franchise unit received its address in CNA Nexus. The address block of the
franchise snapshot plus the franchise id. Published by cna-nexus, consumed by
cna-one to mirror the address.
owners:
- cna-platform
schemaPath: schema.json
badges:
- content: 'Topic: Franchise'
backgroundColor: purple
textColor: purple
icon: kafka
- content: 'Key: franchiseId'
backgroundColor: gray
textColor: gray
- content: Payload drift
backgroundColor: yellow
textColor: yellow
x-contract-owner: NEXUS
x-kafka-topic: Franchise
x-message-key: franchiseId
x-source: cna-nexus api/app/events/nexus/franchise/FranchiseAddressCreated.ts
x-drift: >-
cna-nexus has no class yet: the contract is written against the pending
cna-nexus pull request. cna-one's copy matches the class above and lives on
branch jl/feature/CWH-48874-consume-franchise-address-events, not merged.
---
## Overview
Fact: an address row now exists for a franchise unit in `common.addresses`. The payload is the same address block that travels inside [[event|FranchiseCreated]] and [[event|FranchiseUpdated]] (`FranchiseAddressData`), plus `franchiseId` so the consumer can find the unit without the full snapshot. See [[entity|FranchiseAddress]].
**When it is published.** [[service|cna-nexus]], always through `manager.afterCommit()`, whenever an address row is inserted for a franchise: the `createAddress` GraphQL mutation (`createFranchiseAddress`) and `createFranchiseForOpening`, when the unit is created from an opening [[entity|Contract]]. In the second case the same commit also emits `FranchiseCreated`, which carries the same address in `payload.address`; the consumer's upsert is idempotent, so the overlap is harmless. Every later change goes out as [[event|FranchiseAddressUpdated]].
**What the consumer does.** [[service|cna-one]] (`franchiseConsumer`, group `franchise-franchise-events`, handler `syncFranchiseAddress`): loads the franchise by `franchiseId` and, when the unit is not mirrored yet, logs a warning and drops the message — a unit is created without a CNPJ and cna-one mirrors it only once the CNPJ arrives, so the address of an opening, and every address edit before the CNPJ, reaches cna-one ahead of the unit; the [[event|FranchiseUpdated]] that brings the CNPJ brings the address too. Otherwise it requires `street`, `number`, `neighborhood`, `postalCode` and an IBGE `cityId` known to cna-one, turns `latitude` and `longitude` into one PostGIS point when both are present and on the globe — a pair with one side missing, unreadable or off the globe is logged and the address is stored without a point — and upserts the unit's row in its own `common.addresses`. `cityName`, `state`, `countryId`, `foreignCity` and `foreignState` are ignored, so an address outside Brazil is not mirrored. Failures are logged and reported to Sentry, never rethrown.
## Kafka
| | |
| -------------------------- | ---------------------------- |
| Topic (`aggregateRoot`) | `Franchise` |
| Message key (`routingKey`) | `payload.franchiseId` |
| Contract owner | `NEXUS` |
| `metadata.event` | `FranchiseAddressCreated` |
| Consumer group | `franchise-franchise-events` |
## Payload schema
`FranchiseAddressData` from `FranchiseCreated` plus `franchiseId`. `countryId` is the only required address field; a Brazilian address fills `cityId`, `cityName` and `state`, an address outside Brazil fills `foreignCity` and `foreignState` instead.
## Source of truth
```typescript
import { FranchiseAddressData } from 'events/nexus/franchise/FranchiseCreated';
export type FranchiseAddressPayload = FranchiseAddressData & {
franchiseId: string;
};
export class FranchiseAddressCreated extends Event {
static readonly owner = 'NEXUS';
static readonly aggregateRoot = 'Franchise';
static readonly routingKey = 'franchiseId';
static readonly version = '1.0.0';
}
```
## Known drift
Observed 2026-09-23.
- cna-nexus, the canonical repository, has no `FranchiseAddressCreated` class and emits nothing from the address write paths yet. The class above is the target; this page is written against the pending cna-nexus pull request and the link goes here when it opens.
- cna-one `api/app/events/nexus/franchise/FranchiseAddressCreated.ts` exists only on branch `jl/feature/CWH-48874-consume-franchise-address-events` (not merged into `staging`) and matches the class above: same fields, key and version.
## Raw Schema:schema.json
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "FranchiseAddressCreated payload",
"type": "object",
"additionalProperties": false,
"properties": {
"franchiseId": {
"type": "string",
"description": "Franchise id (UUID). Kafka message key; cna-one upserts the address of the unit it mirrors under this id and, when the unit is not mirrored yet (no CNPJ), logs a warning and drops the message."
},
"street": {
"type": ["string", "null"],
"description": "Street. cna-one requires it (streetAddress)."
},
"number": {
"type": ["string", "null"],
"description": "Number. cna-one requires it."
},
"complement": {
"type": ["string", "null"],
"description": "Complement. Mirrored by cna-one as additionalAddress."
},
"neighborhood": {
"type": ["string", "null"],
"description": "Neighborhood. cna-one requires it (district)."
},
"postalCode": {
"type": ["string", "null"],
"description": "Postal code as stored; cna-one requires it and strips it to 8 digits."
},
"cityId": {
"type": ["integer", "null"],
"description": "IBGE city code (common.cities.id). cna-one requires it and the city must exist there. Null for an address outside Brazil."
},
"cityName": {
"type": ["string", "null"],
"description": "City name. Null for an address outside Brazil. Ignored by cna-one."
},
"state": {
"type": ["string", "null"],
"description": "State code (UF). Null for an address outside Brazil. Ignored by cna-one."
},
"countryId": {
"type": "string",
"description": "Country as an ISO 3166-1 alpha-2 code (BR for Brazil). Always present. Ignored by cna-one."
},
"foreignCity": {
"type": ["string", "null"],
"description": "Free-text city name for an address outside Brazil; null for a Brazilian address. Ignored by cna-one."
},
"foreignState": {
"type": ["string", "null"],
"description": "Free-text state or province name for an address outside Brazil; null for a Brazilian address. Ignored by cna-one."
},
"latitude": {
"type": ["string", "null"],
"description": "Latitude in WGS84 as decimal text (ST_Y over the location point), or null when the address has no coordinates. cna-one stores latitude and longitude as one PostGIS point; a pair with one side missing, unreadable or off the globe is logged and the address is stored without a point."
},
"longitude": {
"type": ["string", "null"],
"description": "Longitude in WGS84 as decimal text (ST_X over the location point), or null when the address has no coordinates. cna-one stores latitude and longitude as one PostGIS point; a pair with one side missing, unreadable or off the globe is logged and the address is stored without a point."
}
},
"required": ["franchiseId", "countryId"]
}
---
id: FranchiseAddressUpdated
name: Franchise Address Updated
version: 1.0.0
summary: >-
The address of a franchise unit changed in CNA Nexus. The address block of the
franchise snapshot plus the franchise id. Published by cna-nexus, consumed by
cna-one to mirror the change.
owners:
- cna-platform
schemaPath: schema.json
badges:
- content: 'Topic: Franchise'
backgroundColor: purple
textColor: purple
icon: kafka
- content: 'Key: franchiseId'
backgroundColor: gray
textColor: gray
- content: Payload drift
backgroundColor: yellow
textColor: yellow
x-contract-owner: NEXUS
x-kafka-topic: Franchise
x-message-key: franchiseId
x-source: cna-nexus api/app/events/nexus/franchise/FranchiseAddressUpdated.ts
x-drift: >-
cna-nexus has no class yet: the contract is written against the pending
cna-nexus pull request. cna-one's copy matches the class above and lives on
branch jl/feature/CWH-48874-consume-franchise-address-events, not merged.
---
## Overview
Fact: the address row of a franchise unit changed in `common.addresses`. Same payload as [[event|FranchiseAddressCreated]]: the full address after the change (`FranchiseAddressData`) plus `franchiseId`, never a delta. See [[entity|FranchiseAddress]].
**When it is published.** [[service|cna-nexus]], always through `manager.afterCommit()`, whenever an existing address row of a franchise is updated: the `updateAddress` GraphQL mutation (`updateFranchiseAddress`), `applyContractToFranchiseAggregate` (a renewal, resale or re-activation projecting a [[entity|Contract]] onto the unit, see [[event|ContractActivationRequested]]) and the amendment handler `handleAddressChange` (an `ADDRESS` [[entity|ContractAmendment]], see [[event|AmendmentApplyRequested]]). The last two commits also emit [[event|FranchiseUpdated]] with the same address in `payload.address`; the consumer's upsert is idempotent. `refreshAddressLocation`, the post-commit geocoding that rewrites only the coordinates, emits nothing of its own: the producer is registered after it, `afterCommit` callbacks run in order, and the producer re-reads the row, so the message carries the geocoded `latitude` and `longitude`. Through the mutation the coordinates are resolved before the write.
**What the consumer does.** [[service|cna-one]] (`franchiseConsumer`, group `franchise-franchise-events`, handler `syncFranchiseAddress`): the same as for `FranchiseAddressCreated`. It loads the franchise by `franchiseId` and, when the unit is not mirrored yet, logs a warning and drops the message (the unit has no CNPJ yet, see `FranchiseAddressCreated`); otherwise requires `street`, `number`, `neighborhood`, `postalCode` and an IBGE `cityId` known to cna-one, turns `latitude` and `longitude` into one PostGIS point when both are present and on the globe — a broken pair is logged and the address keeps no point — and updates the unit's row in place. `cityName`, `state`, `countryId`, `foreignCity` and `foreignState` are ignored. Failures are logged and reported to Sentry, never rethrown.
## Kafka
| | |
| -------------------------- | ---------------------------- |
| Topic (`aggregateRoot`) | `Franchise` |
| Message key (`routingKey`) | `payload.franchiseId` |
| Contract owner | `NEXUS` |
| `metadata.event` | `FranchiseAddressUpdated` |
| Consumer group | `franchise-franchise-events` |
## Payload schema
Same payload type as `FranchiseAddressCreated` (`FranchiseAddressPayload`).
## Source of truth
```typescript
import { FranchiseAddressPayload } from 'events/nexus/franchise/FranchiseAddressCreated';
export class FranchiseAddressUpdated extends Event {
static readonly owner = 'NEXUS';
static readonly aggregateRoot = 'Franchise';
static readonly routingKey = 'franchiseId';
static readonly version = '1.0.0';
}
```
## Known drift
Observed 2026-09-23.
- cna-nexus, the canonical repository, has no `FranchiseAddressUpdated` class and emits nothing from the address write paths yet. The class above is the target; this page is written against the pending cna-nexus pull request and the link goes here when it opens.
- cna-one `api/app/events/nexus/franchise/FranchiseAddressUpdated.ts` exists only on branch `jl/feature/CWH-48874-consume-franchise-address-events` (not merged into `staging`) and matches the class above: same fields, key and version.
## Raw Schema:schema.json
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "FranchiseAddressUpdated payload",
"type": "object",
"additionalProperties": false,
"properties": {
"franchiseId": {
"type": "string",
"description": "Franchise id (UUID). Kafka message key; cna-one upserts the address of the unit it mirrors under this id and, when the unit is not mirrored yet (no CNPJ), logs a warning and drops the message."
},
"street": {
"type": ["string", "null"],
"description": "Street. cna-one requires it (streetAddress)."
},
"number": {
"type": ["string", "null"],
"description": "Number. cna-one requires it."
},
"complement": {
"type": ["string", "null"],
"description": "Complement. Mirrored by cna-one as additionalAddress."
},
"neighborhood": {
"type": ["string", "null"],
"description": "Neighborhood. cna-one requires it (district)."
},
"postalCode": {
"type": ["string", "null"],
"description": "Postal code as stored; cna-one requires it and strips it to 8 digits."
},
"cityId": {
"type": ["integer", "null"],
"description": "IBGE city code (common.cities.id). cna-one requires it and the city must exist there. Null for an address outside Brazil."
},
"cityName": {
"type": ["string", "null"],
"description": "City name. Null for an address outside Brazil. Ignored by cna-one."
},
"state": {
"type": ["string", "null"],
"description": "State code (UF). Null for an address outside Brazil. Ignored by cna-one."
},
"countryId": {
"type": "string",
"description": "Country as an ISO 3166-1 alpha-2 code (BR for Brazil). Always present. Ignored by cna-one."
},
"foreignCity": {
"type": ["string", "null"],
"description": "Free-text city name for an address outside Brazil; null for a Brazilian address. Ignored by cna-one."
},
"foreignState": {
"type": ["string", "null"],
"description": "Free-text state or province name for an address outside Brazil; null for a Brazilian address. Ignored by cna-one."
},
"latitude": {
"type": ["string", "null"],
"description": "Latitude in WGS84 as decimal text (ST_Y over the location point), or null when the address has no coordinates. cna-one stores latitude and longitude as one PostGIS point; a pair with one side missing, unreadable or off the globe is logged and the address is stored without a point."
},
"longitude": {
"type": ["string", "null"],
"description": "Longitude in WGS84 as decimal text (ST_X over the location point), or null when the address has no coordinates. cna-one stores latitude and longitude as one PostGIS point; a pair with one side missing, unreadable or off the globe is logged and the address is stored without a point."
}
},
"required": ["franchiseId", "countryId"]
}
---
id: FranchiseCreated
name: Franchise Created
version: 1.0.0
summary: >-
A franchise unit was created in CNA Nexus by activating an opening contract.
Full snapshot of the unit; cna-one mirrors it, its address and its partners as
employees.
owners:
- cna-platform
schemaPath: schema.json
badges:
- content: 'Topic: Franchise'
backgroundColor: purple
textColor: purple
icon: kafka
- content: 'Key: id'
backgroundColor: gray
textColor: gray
- content: Payload drift
backgroundColor: yellow
textColor: yellow
x-contract-owner: NEXUS
x-kafka-topic: Franchise
x-message-key: id
x-source: cna-nexus api/app/events/nexus/franchise/FranchiseCreated.ts
x-drift: >-
cna-one's copy of FranchisePayload has email, whatsapp and phone (never sent
by nexus) and lacks firstContractStartsOn; enums are typed as string and the
nested types are renamed.
---
## Overview
Fact: a franchise unit now exists in Nexus. The payload is a full snapshot of the aggregate re-read after commit: unit data, statuses, category, economic group, region, address, partners (without roles) and products.
**When it is published.** [[service|cna-nexus]], in `createFranchiseForOpening`, the only place a franchise is created: the activation of an `OPENING` [[entity|Contract]] with no franchise yet (see [[event|ContractActivationRequested]]). Registered with `manager.afterCommit()`, so it goes out only after the unit, its brand, address, territory and partners are committed. The same commit also emits one [[event|FranchisePartnerAdded]] per partner.
**What the consumer does.** [[service|cna-one]] (`franchiseConsumer`, handler `syncFranchise`), in three blocks:
1. **Franchise**: requires and validates `cnpj` and `legalName`, checks name, slug and e-mail uniqueness, resolves `economicGroup.id` (it must have been mirrored by [[event|EconomicGroupCreated]] first, otherwise the message fails), maps the enums onto cna-one's own, derives `status` (`ACTIVE` when `operationalStatus` is `OPERATIONAL`), keeps only the distinct product sub-categories, and upserts by `id`. If this block fails nothing is written.
2. **Address**: skipped when absent; requires street, number, neighborhood, postal code and an IBGE `cityId` known to cna-one; upserts the unit's address in place. Failure here is logged and skipped.
3. **Partners**: one per distinct document; each becomes a person, an employee, an employment contract with the `FRANCHISEE` job role and an active staff link, in one transaction. cna-one then publishes its own [[event|EmployeeCreated]] (new employee) or [[event|EmployeeUpdated]] (already known), which is what provisions the login in cna-auth. One bad partner is skipped without stopping the others.
Failures are logged and reported to Sentry, never rethrown.
## Kafka
| | |
| -------------------------- | ---------------------------- |
| Topic (`aggregateRoot`) | `Franchise` |
| Message key (`routingKey`) | `payload.id` |
| Contract owner | `NEXUS` |
| `metadata.event` | `FranchiseCreated` |
| Consumer group | `franchise-franchise-events` |
## Payload schema
## Source of truth
```typescript
export type FranchisePayload = {
id: string;
name: string;
code?: string | null;
cnpj?: string | null;
legalName?: string | null;
stateRegistration?: string | null;
taxRegime?: string | null;
protheusId?: string | null;
conectaEnabled: boolean;
openingStatus: string;
operationalStatus: string;
financialStatus?: string | null;
operationalStartAt?: string | null;
operationalEndAt?: string | null;
firstContractStartsOn?: string | null;
category: FranchiseCategoryData;
economicGroup?: FranchiseEconomicGroupData | null;
operationalRegion?: FranchiseOperationalRegionData | null;
address?: FranchiseAddressData | null;
partners: FranchisePartnerData[];
products: FranchiseProductData[];
};
export class FranchiseCreated extends Event {
static readonly owner = 'NEXUS';
static readonly aggregateRoot = 'Franchise';
static readonly routingKey = 'id';
}
```
## Known drift
cna-one `api/app/events/nexus/franchise/FranchiseCreated.ts` differs from the canonical type:
| Field | cna-nexus | cna-one |
| ---------------------------- | ------------------------- | ---------------------------------------------------------------- |
| `email`, `whatsapp`, `phone` | absent (never sent) | present; the consumer reads them, so they are always `undefined` |
| `firstContractStartsOn` | present | absent (ignored) |
| `products[].subCategory` | `ProductSubCategory` enum | `string` |
| nested type names | `*Data` | `*Payload` |
## Raw Schema:schema.json
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "FranchiseCreated payload",
"type": "object",
"additionalProperties": false,
"properties": {
"id": {
"type": "string",
"description": "Franchise id (UUID). Kafka message key; cna-one mirrors the unit under this id."
},
"name": { "type": "string", "description": "Unit name." },
"code": {
"type": ["string", "null"],
"description": "Internal unit code, unique."
},
"cnpj": {
"type": ["string", "null"],
"description": "Company registration number as stored (no normalisation). cna-one requires and validates it."
},
"legalName": {
"type": ["string", "null"],
"description": "Legal company name. cna-one requires it."
},
"stateRegistration": {
"type": ["string", "null"],
"description": "State registration number."
},
"taxRegime": {
"type": ["string", "null"],
"enum": ["SIMPLES_NACIONAL", "LUCRO_PRESUMIDO", "LUCRO_REAL", null],
"description": "Tax regime (FranchiseTaxRegime), typed as string in the class."
},
"protheusId": {
"type": ["string", "null"],
"description": "Id in the Protheus ERP."
},
"conectaEnabled": {
"type": "boolean",
"description": "Inter-school sharing enabled."
},
"openingStatus": {
"type": "string",
"enum": [
"CREATED",
"BUILT",
"TRAINED",
"SUSPENDED",
"UNDER_REVIEW",
"DEFAULTING"
],
"description": "Lifecycle stage (FranchiseOpeningStatus), typed as string in the class."
},
"operationalStatus": {
"type": "string",
"enum": ["OPERATIONAL", "NON_OPERATIONAL", "UNDER_DEVELOPMENT"],
"description": "Operational status (FranchiseOperationalStatus). cna-one maps OPERATIONAL to ACTIVE, anything else to INACTIVE."
},
"financialStatus": {
"type": ["string", "null"],
"enum": [
"ACTIVE",
"INACTIVE",
"SUSPENDED",
"DEFAULTING",
"UNDER_REVIEW",
null
],
"description": "Financial standing (FranchiseFinancialStatus)."
},
"operationalStartAt": {
"type": ["string", "null"],
"format": "date-time",
"description": "ISO-8601 instant when operations started."
},
"operationalEndAt": {
"type": ["string", "null"],
"format": "date-time",
"description": "ISO-8601 instant when operations ended."
},
"firstContractStartsOn": {
"type": ["string", "null"],
"format": "date",
"description": "YYYY-MM-DD start of the earliest opening contract, or null. Ignored by cna-one."
},
"category": {
"type": "object",
"additionalProperties": false,
"description": "Size/type category of the unit.",
"properties": {
"id": { "type": "string", "description": "Category id (UUID)." },
"name": { "type": "string", "description": "Category name." },
"isActive": {
"type": "boolean",
"description": "Whether the category is active."
}
},
"required": ["id", "name", "isActive"]
},
"economicGroup": {
"type": ["object", "null"],
"additionalProperties": false,
"description": "Economic group of the unit, or null. cna-one requires the group to have been mirrored first (EconomicGroupCreated).",
"properties": {
"id": { "type": "string", "description": "Economic group id (UUID)." },
"name": { "type": "string", "description": "Group name." },
"isActive": {
"type": "boolean",
"description": "Whether the group is active."
}
},
"required": ["id", "name", "isActive"]
},
"operationalRegion": {
"type": ["object", "null"],
"additionalProperties": false,
"description": "Operational region, or null.",
"properties": {
"id": { "type": "string", "description": "Region id (UUID)." },
"name": { "type": "string", "description": "Region name." }
},
"required": ["id", "name"]
},
"address": {
"type": ["object", "null"],
"additionalProperties": false,
"description": "Unit address, or null. cna-one requires street, number, neighborhood, postalCode and cityId to mirror it.",
"properties": {
"street": { "type": ["string", "null"], "description": "Street." },
"number": { "type": ["string", "null"], "description": "Number." },
"complement": {
"type": ["string", "null"],
"description": "Complement."
},
"neighborhood": {
"type": ["string", "null"],
"description": "Neighborhood."
},
"postalCode": {
"type": ["string", "null"],
"description": "Postal code as stored; cna-one strips it to 8 digits."
},
"cityId": {
"type": ["integer", "null"],
"description": "IBGE city code (common.cities.id). Must exist in cna-one."
},
"cityName": {
"type": ["string", "null"],
"description": "City name. Ignored by cna-one."
},
"state": {
"type": ["string", "null"],
"description": "State code (UF). Ignored by cna-one."
}
}
},
"partners": {
"type": "array",
"description": "People behind the unit. May be empty. Roles are not included; cna-one turns each into a FRANCHISEE employee.",
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"personId": {
"type": "string",
"description": "Person id (UUID) in cna-nexus."
},
"document": {
"type": "string",
"description": "Person document; format depends on documentType."
},
"documentType": {
"type": "string",
"enum": ["CPF", "CNPJ", "RNM"],
"description": "Document type (PersonsDocumentType), typed as string in the class."
},
"name": { "type": "string", "description": "Person name." },
"legalName": {
"type": ["string", "null"],
"description": "Legal name, for companies."
},
"email": {
"type": ["string", "null"],
"description": "E-mail. cna-one requires an e-mail to create the employee."
}
},
"required": ["personId", "document", "documentType", "name"]
}
},
"products": {
"type": "array",
"description": "Products the unit is enabled to sell. cna-one keeps only the distinct sub-categories.",
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"id": { "type": "string", "description": "Product id (UUID)." },
"name": { "type": "string", "description": "Product name." },
"subCategory": {
"type": "string",
"enum": [
"ENGLISH",
"SPANISH",
"PROGRAMMING",
"AI",
"CREATOR_ECONOMY"
],
"description": "Catalog sub-category (ProductSubCategory)."
}
},
"required": ["id", "name", "subCategory"]
}
}
},
"required": [
"id",
"name",
"conectaEnabled",
"openingStatus",
"operationalStatus",
"category",
"partners",
"products"
]
}
---
id: FranchiseCreated
name: Franchise Created
version: 1.1.0
summary: >-
A franchise unit was created in CNA Nexus by activating an opening contract.
Full snapshot of the unit; cna-one mirrors it, its address and its partners as
employees.
owners:
- cna-platform
schemaPath: schema.json
badges:
- content: 'Topic: Franchise'
backgroundColor: purple
textColor: purple
icon: kafka
- content: 'Key: id'
backgroundColor: gray
textColor: gray
- content: Payload drift
backgroundColor: yellow
textColor: yellow
x-contract-owner: NEXUS
x-kafka-topic: Franchise
x-message-key: id
x-source: cna-nexus api/app/events/nexus/franchise/FranchiseCreated.ts
x-drift: >-
cna-one's copy of FranchisePayload lags 1.1.0: it already has email, whatsapp
and phone but lacks firstContractStartsOn; products[].subCategory typed as
string; nested types named *Payload instead of *Data.
---
## Overview
Fact: a franchise unit now exists in Nexus. The payload is a full snapshot of the aggregate re-read after commit: unit data, statuses, category, economic group, region, address, partners (without roles) and products.
**When it is published.** [[service|cna-nexus]], in `createFranchiseForOpening`, the only place a franchise is created: the activation of an `OPENING` [[entity|Contract]] with no franchise yet (see [[event|ContractActivationRequested]]). Registered with `manager.afterCommit()`, so it goes out only after the unit, its brand, address, territory and partners are committed. The same commit also emits one [[event|FranchisePartnerAdded]] per partner.
**What the consumer does.** [[service|cna-one]] (`franchiseConsumer`, handler `syncFranchise`), in three blocks:
1. **Franchise**: requires and validates `cnpj` and `legalName`, checks name, slug and e-mail uniqueness, resolves `economicGroup.id` (it must have been mirrored by [[event|EconomicGroupCreated]] first, otherwise the message fails), maps the enums onto cna-one's own, derives `status` (`ACTIVE` when `operationalStatus` is `OPERATIONAL`), keeps only the distinct product sub-categories, and upserts by `id`. Since 1.1.0 the snapshot also carries the unit's primary contact (`email`, `whatsapp`, `phone`), which cna-one mirrors into the franchise; the e-mail must be unique among its franchises. If this block fails nothing is written.
2. **Address**: skipped when absent; requires street, number, neighborhood, postal code and an IBGE `cityId` known to cna-one; upserts the unit's address in place. Failure here is logged and skipped.
3. **Partners**: one per distinct document; each becomes a person, an employee, an employment contract with the `FRANCHISEE` job role and an active staff link, in one transaction. cna-one then publishes its own [[event|EmployeeCreated]] (new employee) or [[event|EmployeeUpdated]] (already known), which is what provisions the login in cna-auth. One bad partner is skipped without stopping the others.
Failures are logged and reported to Sentry, never rethrown.
## Kafka
| | |
| -------------------------- | ---------------------------- |
| Topic (`aggregateRoot`) | `Franchise` |
| Message key (`routingKey`) | `payload.id` |
| Contract owner | `NEXUS` |
| `metadata.event` | `FranchiseCreated` |
| Consumer group | `franchise-franchise-events` |
## Payload schema
## Source of truth
```typescript
export type FranchisePayload = {
id: string;
name: string;
code?: string | null;
cnpj?: string | null;
legalName?: string | null;
email?: string | null;
whatsapp?: string | null;
phone?: string | null;
stateRegistration?: string | null;
taxRegime?: string | null;
protheusId?: string | null;
conectaEnabled: boolean;
openingStatus: string;
operationalStatus: string;
financialStatus?: string | null;
operationalStartAt?: string | null;
operationalEndAt?: string | null;
firstContractStartsOn?: string | null;
category: FranchiseCategoryData;
economicGroup?: FranchiseEconomicGroupData | null;
operationalRegion?: FranchiseOperationalRegionData | null;
address?: FranchiseAddressData | null;
partners: FranchisePartnerData[];
products: FranchiseProductData[];
};
export class FranchiseCreated extends Event {
static readonly owner = 'NEXUS';
static readonly aggregateRoot = 'Franchise';
static readonly routingKey = 'id';
static readonly version = '1.1.0';
}
```
## Known drift
cna-one `api/app/events/nexus/franchise/FranchiseCreated.ts` differs from the canonical type. Since 1.1.0 the contact fields (`email`, `whatsapp`, `phone`) match on both sides; the rest is still behind, so cna-one's `receives` is pinned to 1.0.0 until its copy catches up.
| Field | cna-nexus | cna-one |
| ------------------------ | ------------------------- | ---------------- |
| `firstContractStartsOn` | present | absent (ignored) |
| `products[].subCategory` | `ProductSubCategory` enum | `string` |
| nested type names | `*Data` | `*Payload` |
## Raw Schema:schema.json
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "FranchiseCreated payload",
"type": "object",
"additionalProperties": false,
"properties": {
"id": {
"type": "string",
"description": "Franchise id (UUID). Kafka message key; cna-one mirrors the unit under this id."
},
"name": { "type": "string", "description": "Unit name." },
"code": {
"type": ["string", "null"],
"description": "Internal unit code, unique."
},
"cnpj": {
"type": ["string", "null"],
"description": "Company registration number as stored (no normalisation). cna-one requires and validates it."
},
"legalName": {
"type": ["string", "null"],
"description": "Legal company name. cna-one requires it."
},
"email": {
"type": ["string", "null"],
"format": "email",
"description": "Primary contact e-mail of the unit (franchises.email in cna-nexus, not unique there). cna-one mirrors it and requires it to be unique across its franchises."
},
"whatsapp": {
"type": ["string", "null"],
"pattern": "^\\+[1-9]\\d{6,14}$",
"description": "WhatsApp number in E.164 (+5511912345678). Mirrored by cna-one."
},
"phone": {
"type": ["string", "null"],
"pattern": "^\\+[1-9]\\d{6,14}$",
"description": "Primary contact phone in E.164. Mirrored by cna-one."
},
"stateRegistration": {
"type": ["string", "null"],
"description": "State registration number."
},
"taxRegime": {
"type": ["string", "null"],
"enum": ["SIMPLES_NACIONAL", "LUCRO_PRESUMIDO", "LUCRO_REAL", null],
"description": "Tax regime (FranchiseTaxRegime), typed as string in the class."
},
"protheusId": {
"type": ["string", "null"],
"description": "Id in the Protheus ERP."
},
"conectaEnabled": {
"type": "boolean",
"description": "Inter-school sharing enabled."
},
"openingStatus": {
"type": "string",
"enum": [
"CREATED",
"BUILT",
"TRAINED",
"SUSPENDED",
"UNDER_REVIEW",
"DEFAULTING"
],
"description": "Lifecycle stage (FranchiseOpeningStatus), typed as string in the class."
},
"operationalStatus": {
"type": "string",
"enum": ["OPERATIONAL", "NON_OPERATIONAL", "UNDER_DEVELOPMENT"],
"description": "Operational status (FranchiseOperationalStatus). cna-one maps OPERATIONAL to ACTIVE, anything else to INACTIVE."
},
"financialStatus": {
"type": ["string", "null"],
"enum": [
"ACTIVE",
"INACTIVE",
"SUSPENDED",
"DEFAULTING",
"UNDER_REVIEW",
null
],
"description": "Financial standing (FranchiseFinancialStatus)."
},
"operationalStartAt": {
"type": ["string", "null"],
"format": "date-time",
"description": "ISO-8601 instant when operations started."
},
"operationalEndAt": {
"type": ["string", "null"],
"format": "date-time",
"description": "ISO-8601 instant when operations ended."
},
"firstContractStartsOn": {
"type": ["string", "null"],
"format": "date",
"description": "YYYY-MM-DD start of the earliest opening contract, or null. Ignored by cna-one."
},
"category": {
"type": "object",
"additionalProperties": false,
"description": "Size/type category of the unit.",
"properties": {
"id": { "type": "string", "description": "Category id (UUID)." },
"name": { "type": "string", "description": "Category name." },
"isActive": {
"type": "boolean",
"description": "Whether the category is active."
}
},
"required": ["id", "name", "isActive"]
},
"economicGroup": {
"type": ["object", "null"],
"additionalProperties": false,
"description": "Economic group of the unit, or null. cna-one requires the group to have been mirrored first (EconomicGroupCreated).",
"properties": {
"id": { "type": "string", "description": "Economic group id (UUID)." },
"name": { "type": "string", "description": "Group name." },
"isActive": {
"type": "boolean",
"description": "Whether the group is active."
}
},
"required": ["id", "name", "isActive"]
},
"operationalRegion": {
"type": ["object", "null"],
"additionalProperties": false,
"description": "Operational region, or null.",
"properties": {
"id": { "type": "string", "description": "Region id (UUID)." },
"name": { "type": "string", "description": "Region name." }
},
"required": ["id", "name"]
},
"address": {
"type": ["object", "null"],
"additionalProperties": false,
"description": "Unit address, or null. cna-one requires street, number, neighborhood, postalCode and cityId to mirror it.",
"properties": {
"street": { "type": ["string", "null"], "description": "Street." },
"number": { "type": ["string", "null"], "description": "Number." },
"complement": {
"type": ["string", "null"],
"description": "Complement."
},
"neighborhood": {
"type": ["string", "null"],
"description": "Neighborhood."
},
"postalCode": {
"type": ["string", "null"],
"description": "Postal code as stored; cna-one strips it to 8 digits."
},
"cityId": {
"type": ["integer", "null"],
"description": "IBGE city code (common.cities.id). Must exist in cna-one."
},
"cityName": {
"type": ["string", "null"],
"description": "City name. Ignored by cna-one."
},
"state": {
"type": ["string", "null"],
"description": "State code (UF). Ignored by cna-one."
}
}
},
"partners": {
"type": "array",
"description": "People behind the unit. May be empty. Roles are not included; cna-one turns each into a FRANCHISEE employee.",
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"personId": {
"type": "string",
"description": "Person id (UUID) in cna-nexus."
},
"document": {
"type": "string",
"description": "Person document; format depends on documentType."
},
"documentType": {
"type": "string",
"enum": ["CPF", "CNPJ", "RNM"],
"description": "Document type (PersonsDocumentType), typed as string in the class."
},
"name": { "type": "string", "description": "Person name." },
"legalName": {
"type": ["string", "null"],
"description": "Legal name, for companies."
},
"email": {
"type": ["string", "null"],
"description": "E-mail. cna-one requires an e-mail to create the employee."
}
},
"required": ["personId", "document", "documentType", "name"]
}
},
"products": {
"type": "array",
"description": "Products the unit is enabled to sell. cna-one keeps only the distinct sub-categories.",
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"id": { "type": "string", "description": "Product id (UUID)." },
"name": { "type": "string", "description": "Product name." },
"subCategory": {
"type": "string",
"enum": [
"ENGLISH",
"SPANISH",
"PROGRAMMING",
"AI",
"CREATOR_ECONOMY"
],
"description": "Catalog sub-category (ProductSubCategory)."
}
},
"required": ["id", "name", "subCategory"]
}
}
},
"required": [
"id",
"name",
"conectaEnabled",
"openingStatus",
"operationalStatus",
"category",
"partners",
"products"
]
}
---
id: FranchiseCreated
name: Franchise Created
version: 1.2.0
summary: >-
A franchise unit was created in CNA Nexus by activating an opening contract.
Full snapshot of the unit; cna-one mirrors it, its address and its partners as
employees.
owners:
- cna-platform
schemaPath: schema.json
badges:
- content: 'Topic: Franchise'
backgroundColor: purple
textColor: purple
icon: kafka
- content: 'Key: id'
backgroundColor: gray
textColor: gray
- content: Payload drift
backgroundColor: yellow
textColor: yellow
x-contract-owner: NEXUS
x-kafka-topic: Franchise
x-message-key: id
x-source: cna-nexus api/app/events/nexus/franchise/FranchiseCreated.ts
x-drift: >-
cna-one's copy of FranchisePayload matches 1.1.0 (cna-br/cna-one#962, in
staging) and lags 1.2.0: address lacks countryId, foreignCity, foreignState,
latitude and longitude; partners[] still uses personId instead of id.
Production runs the 1.0.0 copy, so cna-one's receives is pinned to 1.0.0
until #962 and the 1.2.0 alignment are in production.
---
## Overview
Fact: a franchise unit now exists in Nexus. The payload is a full snapshot of the aggregate re-read after commit: unit data, statuses, category, economic group, region, address, partners (without roles) and products.
**When it is published.** [[service|cna-nexus]], in `createFranchiseForOpening`, the only place a franchise is created: the activation of an `OPENING` [[entity|Contract]] with no franchise yet (see [[event|ContractActivationRequested]]). Registered with `manager.afterCommit()`, so it goes out only after the unit, its brand, address, territory and partners are committed. The same commit also emits one [[event|FranchisePartnerAdded]] per partner.
**What the consumer does.** [[service|cna-one]] (`franchiseConsumer`, handler `syncFranchise`), in three blocks:
1. **Franchise**: requires and validates `cnpj` and `legalName`, checks name, slug and e-mail uniqueness, resolves `economicGroup.id` (it must have been mirrored by [[event|EconomicGroupCreated]] first, otherwise the message fails), maps the enums onto cna-one's own, derives `status` (`ACTIVE` when `operationalStatus` is `OPERATIONAL`), keeps only the distinct product sub-categories, and upserts by `id`. Since 1.1.0 the snapshot also carries the unit's primary contact (`email`, `whatsapp`, `phone`), which cna-one mirrors into the franchise; the e-mail must be unique among its franchises. If this block fails nothing is written.
2. **Address**: skipped when absent; requires street, number, neighborhood, postal code and an IBGE `cityId` known to cna-one; upserts the unit's address in place. Failure here is logged and skipped. Since 1.2.0 the block also carries `countryId` (ISO 3166-1 alpha-2, always present), `foreignCity` and `foreignState` (filled only for an address outside Brazil, where `cityId`, `cityName` and `state` are null) and the coordinates `latitude` and `longitude` as decimal text; cna-one ignores the five.
3. **Partners**: one per distinct document; each becomes a person, an employee, an employment contract with the `FRANCHISEE` job role and an active staff link, in one transaction. cna-one then publishes its own [[event|EmployeeCreated]] (new employee) or [[event|EmployeeUpdated]] (already known), which is what provisions the login in cna-auth. One bad partner is skipped without stopping the others. Since 1.2.0 each partner is identified by `id`, the person id the [[event|FranchisePartnerAdded]] family already carries in `person.id`; until 1.1.0 the field was `personId`. cna-one never read it: it correlates partners by `document`.
Failures are logged and reported to Sentry, never rethrown.
## Kafka
| | |
| -------------------------- | ---------------------------- |
| Topic (`aggregateRoot`) | `Franchise` |
| Message key (`routingKey`) | `payload.id` |
| Contract owner | `NEXUS` |
| `metadata.event` | `FranchiseCreated` |
| Consumer group | `franchise-franchise-events` |
## Payload schema
## Source of truth
```typescript
export type FranchisePartnerData = {
id: string;
document: string;
documentType: string;
name: string;
legalName?: string | null;
email?: string | null;
};
export type FranchiseAddressData = {
street?: string | null;
number?: string | null;
complement?: string | null;
neighborhood?: string | null;
postalCode?: string | null;
cityId?: number | null;
cityName?: string | null;
state?: string | null;
countryId: string;
foreignCity?: string | null;
foreignState?: string | null;
latitude?: string | null;
longitude?: string | null;
};
export type FranchisePayload = {
id: string;
name: string;
code?: string | null;
cnpj?: string | null;
legalName?: string | null;
email?: string | null;
whatsapp?: string | null;
phone?: string | null;
stateRegistration?: string | null;
taxRegime?: string | null;
protheusId?: string | null;
conectaEnabled: boolean;
openingStatus: string;
operationalStatus: string;
financialStatus?: string | null;
operationalStartAt?: string | null;
operationalEndAt?: string | null;
firstContractStartsOn?: string | null;
category: FranchiseCategoryData;
economicGroup?: FranchiseEconomicGroupData | null;
operationalRegion?: FranchiseOperationalRegionData | null;
address?: FranchiseAddressData | null;
partners: FranchisePartnerData[];
products: FranchiseProductData[];
};
export class FranchiseCreated extends Event {
static readonly owner = 'NEXUS';
static readonly aggregateRoot = 'Franchise';
static readonly routingKey = 'id';
static readonly version = '1.2.0';
}
```
## Known drift
cna-one `api/app/events/nexus/franchise/FranchiseCreated.ts` differs from the canonical type. Its copy was aligned to 1.1.0 by cna-br/cna-one#962 (merged into `staging`, not yet in production) and does not have the 1.2.0 changes. Production still runs the 1.0.0 copy, so cna-one's `receives` is pinned to 1.0.0 until #962 and the 1.2.0 alignment are in production. None of the differences changes what cna-one stores: it ignores the address fields below and correlates partners by `document`.
| Field | cna-nexus (1.2.0) | cna-one (staging, 1.1.0) |
| --------------------------------------------------------------------------- | ----------------- | ------------------------ |
| `address.countryId`, `foreignCity`, `foreignState`, `latitude`, `longitude` | present | absent (ignored) |
| partner identifier | `partners[].id` | `partners[].personId` |
## Raw Schema:schema.json
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "FranchiseCreated payload",
"type": "object",
"additionalProperties": false,
"properties": {
"id": {
"type": "string",
"description": "Franchise id (UUID). Kafka message key; cna-one mirrors the unit under this id."
},
"name": { "type": "string", "description": "Unit name." },
"code": {
"type": ["string", "null"],
"description": "Internal unit code, unique."
},
"cnpj": {
"type": ["string", "null"],
"description": "Company registration number as stored (no normalisation). cna-one requires and validates it."
},
"legalName": {
"type": ["string", "null"],
"description": "Legal company name. cna-one requires it."
},
"email": {
"type": ["string", "null"],
"format": "email",
"description": "Primary contact e-mail of the unit (franchises.email in cna-nexus, not unique there). cna-one mirrors it and requires it to be unique across its franchises."
},
"whatsapp": {
"type": ["string", "null"],
"pattern": "^\\+[1-9]\\d{6,14}$",
"description": "WhatsApp number in E.164 (+5511912345678). Mirrored by cna-one."
},
"phone": {
"type": ["string", "null"],
"pattern": "^\\+[1-9]\\d{6,14}$",
"description": "Primary contact phone in E.164. Mirrored by cna-one."
},
"stateRegistration": {
"type": ["string", "null"],
"description": "State registration number."
},
"taxRegime": {
"type": ["string", "null"],
"enum": ["SIMPLES_NACIONAL", "LUCRO_PRESUMIDO", "LUCRO_REAL", null],
"description": "Tax regime (FranchiseTaxRegime), typed as string in the class."
},
"protheusId": {
"type": ["string", "null"],
"description": "Id in the Protheus ERP."
},
"conectaEnabled": {
"type": "boolean",
"description": "Inter-school sharing enabled."
},
"openingStatus": {
"type": "string",
"enum": [
"CREATED",
"BUILT",
"TRAINED",
"SUSPENDED",
"UNDER_REVIEW",
"DEFAULTING"
],
"description": "Lifecycle stage (FranchiseOpeningStatus), typed as string in the class."
},
"operationalStatus": {
"type": "string",
"enum": ["OPERATIONAL", "NON_OPERATIONAL", "UNDER_DEVELOPMENT"],
"description": "Operational status (FranchiseOperationalStatus). cna-one maps OPERATIONAL to ACTIVE, anything else to INACTIVE."
},
"financialStatus": {
"type": ["string", "null"],
"enum": [
"ACTIVE",
"INACTIVE",
"SUSPENDED",
"DEFAULTING",
"UNDER_REVIEW",
null
],
"description": "Financial standing (FranchiseFinancialStatus)."
},
"operationalStartAt": {
"type": ["string", "null"],
"format": "date-time",
"description": "ISO-8601 instant when operations started."
},
"operationalEndAt": {
"type": ["string", "null"],
"format": "date-time",
"description": "ISO-8601 instant when operations ended."
},
"firstContractStartsOn": {
"type": ["string", "null"],
"format": "date",
"description": "YYYY-MM-DD start of the earliest opening contract, or null. Ignored by cna-one."
},
"category": {
"type": "object",
"additionalProperties": false,
"description": "Size/type category of the unit.",
"properties": {
"id": { "type": "string", "description": "Category id (UUID)." },
"name": { "type": "string", "description": "Category name." },
"isActive": {
"type": "boolean",
"description": "Whether the category is active."
}
},
"required": ["id", "name", "isActive"]
},
"economicGroup": {
"type": ["object", "null"],
"additionalProperties": false,
"description": "Economic group of the unit, or null. cna-one requires the group to have been mirrored first (EconomicGroupCreated).",
"properties": {
"id": { "type": "string", "description": "Economic group id (UUID)." },
"name": { "type": "string", "description": "Group name." },
"isActive": {
"type": "boolean",
"description": "Whether the group is active."
}
},
"required": ["id", "name", "isActive"]
},
"operationalRegion": {
"type": ["object", "null"],
"additionalProperties": false,
"description": "Operational region, or null.",
"properties": {
"id": { "type": "string", "description": "Region id (UUID)." },
"name": { "type": "string", "description": "Region name." }
},
"required": ["id", "name"]
},
"address": {
"type": ["object", "null"],
"additionalProperties": false,
"description": "Unit address, or null. cna-one requires street, number, neighborhood, postalCode and cityId to mirror it; it ignores countryId, foreignCity, foreignState, latitude and longitude.",
"properties": {
"street": { "type": ["string", "null"], "description": "Street." },
"number": { "type": ["string", "null"], "description": "Number." },
"complement": {
"type": ["string", "null"],
"description": "Complement."
},
"neighborhood": {
"type": ["string", "null"],
"description": "Neighborhood."
},
"postalCode": {
"type": ["string", "null"],
"description": "Postal code as stored; cna-one strips it to 8 digits."
},
"cityId": {
"type": ["integer", "null"],
"description": "IBGE city code (common.cities.id). Must exist in cna-one. Null for an address outside Brazil."
},
"cityName": {
"type": ["string", "null"],
"description": "City name. Null for an address outside Brazil. Ignored by cna-one."
},
"state": {
"type": ["string", "null"],
"description": "State code (UF). Null for an address outside Brazil. Ignored by cna-one."
},
"countryId": {
"type": "string",
"description": "Country as an ISO 3166-1 alpha-2 code (BR for Brazil). Always present. Ignored by cna-one."
},
"foreignCity": {
"type": ["string", "null"],
"description": "Free-text city name for an address outside Brazil; null for a Brazilian address. Ignored by cna-one."
},
"foreignState": {
"type": ["string", "null"],
"description": "Free-text state or province name for an address outside Brazil; null for a Brazilian address. Ignored by cna-one."
},
"latitude": {
"type": ["string", "null"],
"description": "Latitude in WGS84 as decimal text (ST_Y over the location point), or null when the address has no coordinates. Ignored by cna-one."
},
"longitude": {
"type": ["string", "null"],
"description": "Longitude in WGS84 as decimal text (ST_X over the location point), or null when the address has no coordinates. Ignored by cna-one."
}
},
"required": ["countryId"]
},
"partners": {
"type": "array",
"description": "People behind the unit. May be empty. Roles are not included; cna-one turns each into a FRANCHISEE employee.",
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"id": {
"type": "string",
"description": "Person id (UUID) in cna-nexus, the same id the FranchisePartner events carry in person.id. Was personId until 1.1.0. cna-one correlates partners by document, not by this id."
},
"document": {
"type": "string",
"description": "Person document; format depends on documentType."
},
"documentType": {
"type": "string",
"enum": ["CPF", "CNPJ", "RNM"],
"description": "Document type (PersonsDocumentType), typed as string in the class."
},
"name": { "type": "string", "description": "Person name." },
"legalName": {
"type": ["string", "null"],
"description": "Legal name, for companies."
},
"email": {
"type": ["string", "null"],
"description": "E-mail. cna-one requires an e-mail to create the employee."
}
},
"required": ["id", "document", "documentType", "name"]
}
},
"products": {
"type": "array",
"description": "Products the unit is enabled to sell. cna-one keeps only the distinct sub-categories.",
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"id": { "type": "string", "description": "Product id (UUID)." },
"name": { "type": "string", "description": "Product name." },
"subCategory": {
"type": "string",
"enum": [
"ENGLISH",
"SPANISH",
"PROGRAMMING",
"AI",
"CREATOR_ECONOMY"
],
"description": "Catalog sub-category (ProductSubCategory)."
}
},
"required": ["id", "name", "subCategory"]
}
}
},
"required": [
"id",
"name",
"conectaEnabled",
"openingStatus",
"operationalStatus",
"category",
"partners",
"products"
]
}
---
id: FranchisePartnerAdded
name: Franchise Partner Added
version: 1.0.0
summary: >-
A person became a partner of a franchise unit, with their roles. One message
per partner. Consumed by cna-nexus itself to invalidate the economic group
statistics cache.
owners:
- cna-platform
schemaPath: schema.json
badges:
- content: 'Topic: Franchise'
backgroundColor: purple
textColor: purple
icon: kafka
- content: 'Key: franchiseId'
backgroundColor: gray
textColor: gray
x-contract-owner: NEXUS
x-kafka-topic: Franchise
x-message-key: franchiseId
x-source: cna-nexus api/app/events/nexus/franchise/FranchisePartnerAdded.ts
x-drift: >-
person.birthDate is typed Date but serialised as a full ISO-8601 timestamp for
a date column; isFranchisee is global across franchises, not scoped to
franchiseId.
---
## Overview
Fact: a [[entity|FranchisePartner]] row was added. The payload carries the unit, the partner's roles and a snapshot of the person.
**When it is published.** [[service|cna-nexus]], in `addPartners`, which runs inside `replaceFranchisePartners` whenever a franchise is created from a contract, its legal information is edited, or a contract is applied onto it. Registered with `manager.afterCommit()`, one message per added partner, batched in a single send. The same commit may also carry [[event|FranchisePartnerRemoved]] and [[event|FranchisePartnerUpdated]] messages.
**What the consumer does.** cna-nexus (`touchEconomicGroupOnFranchisePartnerChange`, group `economic-group-partner-touch`): loads the franchise; if it has an economic group, bumps the group's `updatedAt`. That timestamp versions the Redis cache of the economic group statistics (units, franchisees, active groups), so the next read recomputes them. Franchises without a group are ignored. [[service|cna-one]] has no class for this event and drops it.
## Kafka
| | |
| -------------------------- | ------------------------------ |
| Topic (`aggregateRoot`) | `Franchise` |
| Message key (`routingKey`) | `payload.franchiseId` |
| Contract owner | `NEXUS` |
| `metadata.event` | `FranchisePartnerAdded` |
| Consumer group | `economic-group-partner-touch` |
## Payload schema
## Source of truth
```typescript
export type FranchisePartnerEventPerson = {
id: string;
document: string;
documentType: PersonsDocumentType;
name: string;
email?: string | null;
phone?: string | null;
birthDate?: Date | null;
legalName?: string | null;
isFranchisee: boolean;
};
export type FranchisePartnerEventPayload = {
franchiseId: string;
roles: FranchisePartnerRole[];
person: FranchisePartnerEventPerson;
};
export class FranchisePartnerAdded extends Event {
static readonly owner = 'NEXUS';
static readonly aggregateRoot = 'Franchise';
static readonly routingKey = 'franchiseId';
}
```
## Known drift
- `person.birthDate` is a `Date` in code, sourced from a `date` column, and reaches the wire as a full ISO-8601 timestamp rather than `YYYY-MM-DD`.
- `person.isFranchisee` is true when the person holds `FRANCHISEE` in any franchise, not only in `franchiseId`.
## Raw Schema:schema.json
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "FranchisePartnerAdded payload",
"type": "object",
"additionalProperties": false,
"properties": {
"franchiseId": {
"type": "string",
"description": "Franchise id (UUID). Kafka message key."
},
"roles": {
"type": "array",
"items": {
"type": "string",
"enum": ["FRANCHISEE", "OPERATING_PARTNER"]
},
"description": "Roles of the partner in this unit (FranchisePartnerRole). Never empty."
},
"person": {
"type": "object",
"additionalProperties": false,
"description": "Snapshot of the person (common.persons).",
"properties": {
"id": { "type": "string", "description": "Person id (UUID)." },
"document": {
"type": "string",
"description": "Person document; format depends on documentType."
},
"documentType": {
"type": "string",
"enum": ["CPF", "CNPJ", "RNM"],
"description": "Document type (PersonsDocumentType)."
},
"name": { "type": "string", "description": "Person name." },
"email": {
"type": ["string", "null"],
"description": "E-mail, or null."
},
"phone": {
"type": ["string", "null"],
"description": "Phone, or null."
},
"birthDate": {
"type": ["string", "null"],
"format": "date-time",
"description": "Birth date. A Date in code, serialised as a full ISO-8601 timestamp although the column is a date."
},
"legalName": {
"type": ["string", "null"],
"description": "Legal name, for companies."
},
"isFranchisee": {
"type": "boolean",
"description": "Whether the person holds the FRANCHISEE role in at least one franchise (any unit, not only this one). Derived, not stored."
}
},
"required": ["id", "document", "documentType", "name", "isFranchisee"]
}
},
"required": ["franchiseId", "roles", "person"]
}
---
id: FranchisePartnerAdded
name: Franchise Partner Added
version: 1.1.0
summary: >-
A person became a partner of a franchise unit, with their roles. One message
per partner. Consumed by cna-nexus itself to invalidate the economic group
statistics cache.
owners:
- cna-platform
schemaPath: schema.json
badges:
- content: 'Topic: Franchise'
backgroundColor: purple
textColor: purple
icon: kafka
- content: 'Key: franchiseId'
backgroundColor: gray
textColor: gray
- content: Payload drift
backgroundColor: yellow
textColor: yellow
x-contract-owner: NEXUS
x-kafka-topic: Franchise
x-message-key: franchiseId
x-source: cna-nexus api/app/events/nexus/franchise/FranchisePartnerAdded.ts
x-drift: >-
cna-nexus publishes birthDate as YYYY-MM-DD (cna-br/cna-nexus#562, staging)
but its class still declares version 1.0.0, and production sends the ISO
timestamp until the next release; isFranchisee is global across franchises,
not scoped to franchiseId.
---
## Overview
Fact: a [[entity|FranchisePartner]] row was added. The payload carries the unit, the partner's roles and a snapshot of the person. Since 1.1.0 `person.birthDate` is a calendar day, `YYYY-MM-DD`, with no time zone: the column is a `date`, and a consumer that built a `Date` from the former midnight-UTC timestamp and stored it in a negative offset landed on the day before.
**When it is published.** [[service|cna-nexus]], in `addPartners`, which runs inside `replaceFranchisePartners` whenever a franchise is created from a contract, its legal information is edited, or a contract is applied onto it. Registered with `manager.afterCommit()`, one message per added partner, batched in a single send. The same commit may also carry [[event|FranchisePartnerRemoved]] and [[event|FranchisePartnerUpdated]] messages. A later change to the person's own data does not republish this event; it travels as [[event|PartnerUpdated]] on [[channel|kafka.Partner]] (target contract).
**What the consumer does.** cna-nexus (`touchEconomicGroupOnFranchisePartnerChange`, group `economic-group-partner-touch`): loads the franchise; if it has an economic group, bumps the group's `updatedAt`. That timestamp versions the Redis cache of the economic group statistics (units, franchisees, active groups), so the next read recomputes them. Franchises without a group are ignored. [[service|cna-one]] has no class for this event and drops it.
## Kafka
| | |
| -------------------------- | ------------------------------ |
| Topic (`aggregateRoot`) | `Franchise` |
| Message key (`routingKey`) | `payload.franchiseId` |
| Contract owner | `NEXUS` |
| `metadata.event` | `FranchisePartnerAdded` |
| Consumer group | `economic-group-partner-touch` |
## Payload schema
## Source of truth
```typescript
export type FranchisePartnerEventPerson = {
id: string;
document: string;
documentType: PersonsDocumentType;
name: string;
email?: string | null;
phone?: string | null;
birthDate?: string | null;
legalName?: string | null;
isFranchisee: boolean;
};
export type FranchisePartnerEventPayload = {
franchiseId: string;
roles: FranchisePartnerRole[];
person: FranchisePartnerEventPerson;
};
export class FranchisePartnerAdded extends Event {
static readonly owner = 'NEXUS';
static readonly aggregateRoot = 'Franchise';
static readonly routingKey = 'franchiseId';
static readonly version = '1.0.0'; // wire format is 1.1.0, bump pending (Known drift)
}
```
## Known drift
Observed 2026-09-24.
- cna-nexus `api/app/events/nexus/franchise/FranchisePartnerAdded.ts` on `staging` (cna-br/cna-nexus#562) sends `person.birthDate` as `YYYY-MM-DD`, the 1.1.0 contract, but still declares `static readonly version = '1.0.0'`, so `metadata.version` will read `1.0.0`. Production (v0.13.0) runs the 1.0.0 contract, with the ISO timestamp, until the next release. No pin: cna-nexus is the producer and the only consumer, and its handler reads only `franchiseId`. The badge and this note go when the class declares `1.1.0` in production.
- cna-one has no class for this event on `main` and drops it. Branch `jl/feature/CWH-49388-consume-franchise-partner-events` (not merged) types `birthDate` as `YYYY-MM-DD` text, has no `static version`, and does not carry `roles` or `isFranchisee`.
- `person.isFranchisee` is true when the person holds `FRANCHISEE` in any franchise, not only in `franchiseId`.
## Raw Schema:schema.json
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "FranchisePartnerAdded payload",
"type": "object",
"additionalProperties": false,
"properties": {
"franchiseId": {
"type": "string",
"description": "Franchise id (UUID). Kafka message key."
},
"roles": {
"type": "array",
"items": {
"type": "string",
"enum": ["FRANCHISEE", "OPERATING_PARTNER"]
},
"description": "Roles of the partner in this unit (FranchisePartnerRole). Never empty."
},
"person": {
"type": "object",
"additionalProperties": false,
"description": "Snapshot of the person (common.persons).",
"properties": {
"id": {
"type": "string",
"description": "Person id (UUID)."
},
"document": {
"type": "string",
"description": "Person document; format depends on documentType."
},
"documentType": {
"type": "string",
"enum": ["CPF", "CNPJ", "RNM"],
"description": "Document type (PersonsDocumentType)."
},
"name": {
"type": "string",
"description": "Person name."
},
"email": {
"type": ["string", "null"],
"description": "E-mail, or null."
},
"phone": {
"type": ["string", "null"],
"description": "Phone, or null."
},
"birthDate": {
"type": ["string", "null"],
"format": "date",
"description": "Birth date as a calendar day, YYYY-MM-DD, no time zone. Null when unknown."
},
"legalName": {
"type": ["string", "null"],
"description": "Legal name, for companies."
},
"isFranchisee": {
"type": "boolean",
"description": "Whether the person holds the FRANCHISEE role in at least one franchise (any unit, not only this one). Derived, not stored."
}
},
"required": ["id", "document", "documentType", "name", "isFranchisee"]
}
},
"required": ["franchiseId", "roles", "person"]
}
---
id: FranchisePartnerRemoved
name: Franchise Partner Removed
version: 1.0.0
summary: >-
A person stopped being a partner of a franchise unit. One message per partner.
Consumed by cna-nexus itself to invalidate the economic group statistics
cache, which cannot see hard-deleted rows otherwise.
owners:
- cna-platform
schemaPath: schema.json
badges:
- content: 'Topic: Franchise'
backgroundColor: purple
textColor: purple
icon: kafka
- content: 'Key: franchiseId'
backgroundColor: gray
textColor: gray
x-contract-owner: NEXUS
x-kafka-topic: Franchise
x-message-key: franchiseId
x-source: cna-nexus api/app/events/nexus/franchise/FranchisePartnerRemoved.ts
---
## Overview
Fact: a [[entity|FranchisePartner]] row was deleted. Same payload as [[event|FranchisePartnerAdded]], with the roles and person as they were.
**When it is published.** [[service|cna-nexus]], in `removePartners` inside `replaceFranchisePartners`, registered with `manager.afterCommit()`, one message per removed partner. Partner rows are hard-deleted, which is exactly why this event matters: the statistics cache is versioned by `MAX(updated_at)` across groups, franchises and partners, and a deleted row cannot move that timestamp.
**What the consumer does.** Same as for `FranchisePartnerAdded`: cna-nexus bumps the `updatedAt` of the franchise's economic group, if any, so the statistics cache is recomputed on the next read.
## Kafka
| | |
| -------------------------- | ------------------------------ |
| Topic (`aggregateRoot`) | `Franchise` |
| Message key (`routingKey`) | `payload.franchiseId` |
| Contract owner | `NEXUS` |
| `metadata.event` | `FranchisePartnerRemoved` |
| Consumer group | `economic-group-partner-touch` |
## Payload schema
Same payload type as `FranchisePartnerAdded` (`FranchisePartnerEventPayload`).
## Source of truth
```typescript
import { FranchisePartnerEventPayload } from 'events/nexus/franchise/FranchisePartnerAdded';
export class FranchisePartnerRemoved extends Event {
static readonly owner = 'NEXUS';
static readonly aggregateRoot = 'Franchise';
static readonly routingKey = 'franchiseId';
}
```
## Raw Schema:schema.json
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "FranchisePartnerRemoved payload",
"type": "object",
"additionalProperties": false,
"properties": {
"franchiseId": {
"type": "string",
"description": "Franchise id (UUID). Kafka message key."
},
"roles": {
"type": "array",
"items": {
"type": "string",
"enum": ["FRANCHISEE", "OPERATING_PARTNER"]
},
"description": "Roles of the partner in this unit (FranchisePartnerRole). Never empty."
},
"person": {
"type": "object",
"additionalProperties": false,
"description": "Snapshot of the person (common.persons).",
"properties": {
"id": { "type": "string", "description": "Person id (UUID)." },
"document": {
"type": "string",
"description": "Person document; format depends on documentType."
},
"documentType": {
"type": "string",
"enum": ["CPF", "CNPJ", "RNM"],
"description": "Document type (PersonsDocumentType)."
},
"name": { "type": "string", "description": "Person name." },
"email": {
"type": ["string", "null"],
"description": "E-mail, or null."
},
"phone": {
"type": ["string", "null"],
"description": "Phone, or null."
},
"birthDate": {
"type": ["string", "null"],
"format": "date-time",
"description": "Birth date. A Date in code, serialised as a full ISO-8601 timestamp although the column is a date."
},
"legalName": {
"type": ["string", "null"],
"description": "Legal name, for companies."
},
"isFranchisee": {
"type": "boolean",
"description": "Whether the person holds the FRANCHISEE role in at least one franchise (any unit, not only this one). Derived, not stored."
}
},
"required": ["id", "document", "documentType", "name", "isFranchisee"]
}
},
"required": ["franchiseId", "roles", "person"]
}
---
id: FranchisePartnerRemoved
name: Franchise Partner Removed
version: 1.1.0
summary: >-
A person stopped being a partner of a franchise unit. One message per partner.
Consumed by cna-nexus itself to invalidate the economic group statistics
cache, which cannot see hard-deleted rows otherwise.
owners:
- cna-platform
schemaPath: schema.json
badges:
- content: 'Topic: Franchise'
backgroundColor: purple
textColor: purple
icon: kafka
- content: 'Key: franchiseId'
backgroundColor: gray
textColor: gray
- content: Payload drift
backgroundColor: yellow
textColor: yellow
x-contract-owner: NEXUS
x-kafka-topic: Franchise
x-message-key: franchiseId
x-source: cna-nexus api/app/events/nexus/franchise/FranchisePartnerRemoved.ts
x-drift: >-
cna-nexus publishes birthDate as YYYY-MM-DD (cna-br/cna-nexus#562, staging)
but its class still declares version 1.0.0, and production sends the ISO
timestamp until the next release.
---
## Overview
Fact: a [[entity|FranchisePartner]] row was deleted. Same payload as [[event|FranchisePartnerAdded]], with the roles and person as they were. Since 1.1.0 `person.birthDate` is a calendar day, `YYYY-MM-DD`, as on `FranchisePartnerAdded`.
**When it is published.** [[service|cna-nexus]], in `removePartners` inside `replaceFranchisePartners`, registered with `manager.afterCommit()`, one message per removed partner. Partner rows are hard-deleted, which is exactly why this event matters: the statistics cache is versioned by `MAX(updated_at)` across groups, franchises and partners, and a deleted row cannot move that timestamp.
**What the consumer does.** Same as for `FranchisePartnerAdded`: cna-nexus bumps the `updatedAt` of the franchise's economic group, if any, so the statistics cache is recomputed on the next read. Changes to the person's own data are a different fact, [[event|PartnerUpdated]] on [[channel|kafka.Partner]] (target contract).
## Kafka
| | |
| -------------------------- | ------------------------------ |
| Topic (`aggregateRoot`) | `Franchise` |
| Message key (`routingKey`) | `payload.franchiseId` |
| Contract owner | `NEXUS` |
| `metadata.event` | `FranchisePartnerRemoved` |
| Consumer group | `economic-group-partner-touch` |
## Payload schema
Same payload type as `FranchisePartnerAdded` (`FranchisePartnerEventPayload`).
## Source of truth
```typescript
import { FranchisePartnerEventPayload } from 'events/nexus/franchise/FranchisePartnerAdded';
export class FranchisePartnerRemoved extends Event {
static readonly owner = 'NEXUS';
static readonly aggregateRoot = 'Franchise';
static readonly routingKey = 'franchiseId';
static readonly version = '1.0.0'; // wire format is 1.1.0, bump pending (Known drift)
}
```
## Known drift
Observed 2026-09-24.
- cna-nexus `api/app/events/nexus/franchise/FranchisePartnerRemoved.ts` on `staging` (cna-br/cna-nexus#562) sends `person.birthDate` as `YYYY-MM-DD`, the 1.1.0 contract, but still declares `static readonly version = '1.0.0'`, so `metadata.version` will read `1.0.0`. Production (v0.13.0) runs the 1.0.0 contract, with the ISO timestamp, until the next release. No pin: cna-nexus is the producer and the only consumer, and its handler reads only `franchiseId`. The badge and this note go when the class declares `1.1.0` in production.
- cna-one has no class for this event on `main` and drops it. Branch `jl/feature/CWH-49388-consume-franchise-partner-events` (not merged) types `birthDate` as `YYYY-MM-DD` text, has no `static version`, and does not carry `roles` or `isFranchisee`.
## Raw Schema:schema.json
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "FranchisePartnerRemoved payload",
"type": "object",
"additionalProperties": false,
"properties": {
"franchiseId": {
"type": "string",
"description": "Franchise id (UUID). Kafka message key."
},
"roles": {
"type": "array",
"items": {
"type": "string",
"enum": ["FRANCHISEE", "OPERATING_PARTNER"]
},
"description": "Roles of the partner in this unit (FranchisePartnerRole). Never empty."
},
"person": {
"type": "object",
"additionalProperties": false,
"description": "Snapshot of the person (common.persons).",
"properties": {
"id": {
"type": "string",
"description": "Person id (UUID)."
},
"document": {
"type": "string",
"description": "Person document; format depends on documentType."
},
"documentType": {
"type": "string",
"enum": ["CPF", "CNPJ", "RNM"],
"description": "Document type (PersonsDocumentType)."
},
"name": {
"type": "string",
"description": "Person name."
},
"email": {
"type": ["string", "null"],
"description": "E-mail, or null."
},
"phone": {
"type": ["string", "null"],
"description": "Phone, or null."
},
"birthDate": {
"type": ["string", "null"],
"format": "date",
"description": "Birth date as a calendar day, YYYY-MM-DD, no time zone. Null when unknown."
},
"legalName": {
"type": ["string", "null"],
"description": "Legal name, for companies."
},
"isFranchisee": {
"type": "boolean",
"description": "Whether the person holds the FRANCHISEE role in at least one franchise (any unit, not only this one). Derived, not stored."
}
},
"required": ["id", "document", "documentType", "name", "isFranchisee"]
}
},
"required": ["franchiseId", "roles", "person"]
}
---
id: FranchisePartnerUpdated
name: Franchise Partner Updated
version: 1.0.0
summary: >-
The roles of a franchise partner changed - roles are the only mutable field.
Carries the roles before and after. Consumed by cna-nexus itself to invalidate
the economic group statistics cache.
owners:
- cna-platform
schemaPath: schema.json
badges:
- content: 'Topic: Franchise'
backgroundColor: purple
textColor: purple
icon: kafka
- content: 'Key: franchiseId'
backgroundColor: gray
textColor: gray
x-contract-owner: NEXUS
x-kafka-topic: Franchise
x-message-key: franchiseId
x-source: cna-nexus api/app/events/nexus/franchise/FranchisePartnerUpdated.ts
---
## Overview
Fact: the roles of a [[entity|FranchisePartner]] changed. The payload is the [[event|FranchisePartnerAdded]] payload plus `previousRoles`.
**When it is published.** [[service|cna-nexus]], in `updatePartners` inside `replaceFranchisePartners`, only for partners whose incoming roles differ from the stored ones. Registered with `manager.afterCommit()`, one message per changed partner.
**What the consumer does.** Same as for `FranchisePartnerAdded`: cna-nexus bumps the `updatedAt` of the franchise's economic group, if any. A role change can move a person in or out of the franchisee count.
## Kafka
| | |
| -------------------------- | ------------------------------ |
| Topic (`aggregateRoot`) | `Franchise` |
| Message key (`routingKey`) | `payload.franchiseId` |
| Contract owner | `NEXUS` |
| `metadata.event` | `FranchisePartnerUpdated` |
| Consumer group | `economic-group-partner-touch` |
## Payload schema
## Source of truth
```typescript
export type FranchisePartnerUpdatedPayload = {
franchiseId: string;
roles: FranchisePartnerRole[];
previousRoles: FranchisePartnerRole[];
person: FranchisePartnerEventPerson;
};
export class FranchisePartnerUpdated extends Event {
static readonly owner = 'NEXUS';
static readonly aggregateRoot = 'Franchise';
static readonly routingKey = 'franchiseId';
}
```
## Raw Schema:schema.json
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "FranchisePartnerUpdated payload",
"type": "object",
"additionalProperties": false,
"properties": {
"franchiseId": {
"type": "string",
"description": "Franchise id (UUID). Kafka message key."
},
"roles": {
"type": "array",
"items": {
"type": "string",
"enum": ["FRANCHISEE", "OPERATING_PARTNER"]
},
"description": "Roles after the change (FranchisePartnerRole). Never empty."
},
"previousRoles": {
"type": "array",
"items": {
"type": "string",
"enum": ["FRANCHISEE", "OPERATING_PARTNER"]
},
"description": "Roles before the change."
},
"person": {
"type": "object",
"additionalProperties": false,
"description": "Snapshot of the person (common.persons).",
"properties": {
"id": {
"type": "string",
"description": "Person id (UUID)."
},
"document": {
"type": "string",
"description": "Person document; format depends on documentType."
},
"documentType": {
"type": "string",
"enum": ["CPF", "CNPJ", "RNM"],
"description": "Document type (PersonsDocumentType)."
},
"name": {
"type": "string",
"description": "Person name."
},
"email": {
"type": ["string", "null"],
"description": "E-mail, or null."
},
"phone": {
"type": ["string", "null"],
"description": "Phone, or null."
},
"birthDate": {
"type": ["string", "null"],
"format": "date-time",
"description": "Birth date. A Date in code, serialised as a full ISO-8601 timestamp although the column is a date."
},
"legalName": {
"type": ["string", "null"],
"description": "Legal name, for companies."
},
"isFranchisee": {
"type": "boolean",
"description": "Whether the person holds the FRANCHISEE role in at least one franchise (any unit, not only this one). Derived, not stored."
}
},
"required": ["id", "document", "documentType", "name", "isFranchisee"]
}
},
"required": ["franchiseId", "roles", "previousRoles", "person"]
}
---
id: FranchisePartnerUpdated
name: Franchise Partner Updated
version: 1.1.0
summary: >-
The roles of a franchise partner changed - roles are the only mutable field.
Carries the roles before and after. Consumed by cna-nexus itself to invalidate
the economic group statistics cache.
owners:
- cna-platform
schemaPath: schema.json
badges:
- content: 'Topic: Franchise'
backgroundColor: purple
textColor: purple
icon: kafka
- content: 'Key: franchiseId'
backgroundColor: gray
textColor: gray
- content: Payload drift
backgroundColor: yellow
textColor: yellow
x-contract-owner: NEXUS
x-kafka-topic: Franchise
x-message-key: franchiseId
x-source: cna-nexus api/app/events/nexus/franchise/FranchisePartnerUpdated.ts
x-drift: >-
cna-nexus publishes birthDate as YYYY-MM-DD (cna-br/cna-nexus#562, staging)
but its class still declares version 1.0.0, and production sends the ISO
timestamp until the next release.
---
## Overview
Fact: the roles of a [[entity|FranchisePartner]] changed. The payload is the [[event|FranchisePartnerAdded]] payload plus `previousRoles`. Since 1.1.0 `person.birthDate` is a calendar day, `YYYY-MM-DD`, as on `FranchisePartnerAdded`.
**When it is published.** [[service|cna-nexus]], in `updatePartners` inside `replaceFranchisePartners`, only for partners whose incoming roles differ from the stored ones. Registered with `manager.afterCommit()`, one message per changed partner. A change to the person's own data (name, contacts, birth date) is not a role change and does not publish this event; it travels as [[event|PartnerUpdated]] on [[channel|kafka.Partner]] (target contract).
**What the consumer does.** Same as for `FranchisePartnerAdded`: cna-nexus bumps the `updatedAt` of the franchise's economic group, if any. A role change can move a person in or out of the franchisee count.
## Kafka
| | |
| -------------------------- | ------------------------------ |
| Topic (`aggregateRoot`) | `Franchise` |
| Message key (`routingKey`) | `payload.franchiseId` |
| Contract owner | `NEXUS` |
| `metadata.event` | `FranchisePartnerUpdated` |
| Consumer group | `economic-group-partner-touch` |
## Payload schema
## Source of truth
```typescript
export type FranchisePartnerUpdatedPayload = {
franchiseId: string;
roles: FranchisePartnerRole[];
previousRoles: FranchisePartnerRole[];
person: FranchisePartnerEventPerson;
};
export class FranchisePartnerUpdated extends Event {
static readonly owner = 'NEXUS';
static readonly aggregateRoot = 'Franchise';
static readonly routingKey = 'franchiseId';
static readonly version = '1.0.0'; // wire format is 1.1.0, bump pending (Known drift)
}
```
## Known drift
Observed 2026-09-24.
- cna-nexus `api/app/events/nexus/franchise/FranchisePartnerUpdated.ts` on `staging` (cna-br/cna-nexus#562) sends `person.birthDate` as `YYYY-MM-DD`, the 1.1.0 contract, but still declares `static readonly version = '1.0.0'`, so `metadata.version` will read `1.0.0`. Production (v0.13.0) runs the 1.0.0 contract, with the ISO timestamp, until the next release. No pin: cna-nexus is the producer and the only consumer, and its handler reads only `franchiseId`. The badge and this note go when the class declares `1.1.0` in production.
- cna-one has no class for this event on `main` and drops it. Branch `jl/feature/CWH-49388-consume-franchise-partner-events` (not merged) types `birthDate` as `YYYY-MM-DD` text, has no `static version`, and does not carry `roles` or `isFranchisee`.
## Raw Schema:schema.json
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "FranchisePartnerUpdated payload",
"type": "object",
"additionalProperties": false,
"properties": {
"franchiseId": {
"type": "string",
"description": "Franchise id (UUID). Kafka message key."
},
"roles": {
"type": "array",
"items": {
"type": "string",
"enum": ["FRANCHISEE", "OPERATING_PARTNER"]
},
"description": "Roles after the change (FranchisePartnerRole). Never empty."
},
"previousRoles": {
"type": "array",
"items": {
"type": "string",
"enum": ["FRANCHISEE", "OPERATING_PARTNER"]
},
"description": "Roles before the change."
},
"person": {
"type": "object",
"additionalProperties": false,
"description": "Snapshot of the person (common.persons).",
"properties": {
"id": {
"type": "string",
"description": "Person id (UUID)."
},
"document": {
"type": "string",
"description": "Person document; format depends on documentType."
},
"documentType": {
"type": "string",
"enum": ["CPF", "CNPJ", "RNM"],
"description": "Document type (PersonsDocumentType)."
},
"name": {
"type": "string",
"description": "Person name."
},
"email": {
"type": ["string", "null"],
"description": "E-mail, or null."
},
"phone": {
"type": ["string", "null"],
"description": "Phone, or null."
},
"birthDate": {
"type": ["string", "null"],
"format": "date",
"description": "Birth date as a calendar day, YYYY-MM-DD, no time zone. Null when unknown."
},
"legalName": {
"type": ["string", "null"],
"description": "Legal name, for companies."
},
"isFranchisee": {
"type": "boolean",
"description": "Whether the person holds the FRANCHISEE role in at least one franchise (any unit, not only this one). Derived, not stored."
}
},
"required": ["id", "document", "documentType", "name", "isFranchisee"]
}
},
"required": ["franchiseId", "roles", "previousRoles", "person"]
}
---
id: FranchiseUpdated
name: Franchise Updated
version: 1.0.0
summary: >-
A franchise unit changed in CNA Nexus - edited in one of the franchise
screens, re-activated from a contract, or amended. Full snapshot. Nobody
consumes it today; cna-one has no class for it and drops it.
owners:
- cna-platform
schemaPath: schema.json
badges:
- content: 'Topic: Franchise'
backgroundColor: purple
textColor: purple
icon: kafka
- content: 'Key: id'
backgroundColor: gray
textColor: gray
- content: No consumer
backgroundColor: yellow
textColor: yellow
x-contract-owner: NEXUS
x-kafka-topic: Franchise
x-message-key: id
x-source: cna-nexus api/app/events/nexus/franchise/FranchiseUpdated.ts
x-drift: >-
Produced by cna-nexus in six places; cna-one has no class for it, so franchise
changes made in Nexus never reach One.
---
## Overview
Fact: a franchise unit changed. Same payload as [[event|FranchiseCreated]]: a full snapshot re-read after commit.
**When it is published.** [[service|cna-nexus]], always through `manager.afterCommit()`, from six places: the four franchise edit commands (legal information, operational data, financial data, location and contact), `applyContractToFranchiseAggregate` (a renewal, resale or re-activation projecting a [[entity|Contract]] onto the unit, see [[event|ContractActivationRequested]]) and `applyAmendmentChanges` (one message per applied [[entity|ContractAmendment]], see [[event|AmendmentApplyRequested]]).
**Consumers.** None. [[service|cna-one]] subscribes to the topic but has no `FranchiseUpdated` class, so its consumer skips the message with a debug log. Franchise changes made in Nexus after creation are therefore not mirrored into One. Tracked in the drift list as a decision to make: consume it in One or stop producing it.
## Kafka
| | |
| -------------------------- | ------------------ |
| Topic (`aggregateRoot`) | `Franchise` |
| Message key (`routingKey`) | `payload.id` |
| Contract owner | `NEXUS` |
| `metadata.event` | `FranchiseUpdated` |
| Consumer group | none |
## Payload schema
Same payload type as `FranchiseCreated` (`FranchisePayload`).
## Source of truth
```typescript
import { FranchisePayload } from 'events/nexus/franchise/FranchiseCreated';
export class FranchiseUpdated extends Event {
static readonly owner = 'NEXUS';
static readonly aggregateRoot = 'Franchise';
static readonly routingKey = 'id';
}
```
The class exists only in cna-nexus.
## Raw Schema:schema.json
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "FranchiseUpdated payload",
"type": "object",
"additionalProperties": false,
"properties": {
"id": {
"type": "string",
"description": "Franchise id (UUID). Kafka message key; cna-one mirrors the unit under this id."
},
"name": { "type": "string", "description": "Unit name." },
"code": {
"type": ["string", "null"],
"description": "Internal unit code, unique."
},
"cnpj": {
"type": ["string", "null"],
"description": "Company registration number as stored (no normalisation). cna-one requires and validates it."
},
"legalName": {
"type": ["string", "null"],
"description": "Legal company name. cna-one requires it."
},
"stateRegistration": {
"type": ["string", "null"],
"description": "State registration number."
},
"taxRegime": {
"type": ["string", "null"],
"enum": ["SIMPLES_NACIONAL", "LUCRO_PRESUMIDO", "LUCRO_REAL", null],
"description": "Tax regime (FranchiseTaxRegime), typed as string in the class."
},
"protheusId": {
"type": ["string", "null"],
"description": "Id in the Protheus ERP."
},
"conectaEnabled": {
"type": "boolean",
"description": "Inter-school sharing enabled."
},
"openingStatus": {
"type": "string",
"enum": [
"CREATED",
"BUILT",
"TRAINED",
"SUSPENDED",
"UNDER_REVIEW",
"DEFAULTING"
],
"description": "Lifecycle stage (FranchiseOpeningStatus), typed as string in the class."
},
"operationalStatus": {
"type": "string",
"enum": ["OPERATIONAL", "NON_OPERATIONAL", "UNDER_DEVELOPMENT"],
"description": "Operational status (FranchiseOperationalStatus). cna-one maps OPERATIONAL to ACTIVE, anything else to INACTIVE."
},
"financialStatus": {
"type": ["string", "null"],
"enum": [
"ACTIVE",
"INACTIVE",
"SUSPENDED",
"DEFAULTING",
"UNDER_REVIEW",
null
],
"description": "Financial standing (FranchiseFinancialStatus)."
},
"operationalStartAt": {
"type": ["string", "null"],
"format": "date-time",
"description": "ISO-8601 instant when operations started."
},
"operationalEndAt": {
"type": ["string", "null"],
"format": "date-time",
"description": "ISO-8601 instant when operations ended."
},
"firstContractStartsOn": {
"type": ["string", "null"],
"format": "date",
"description": "YYYY-MM-DD start of the earliest opening contract, or null. Ignored by cna-one."
},
"category": {
"type": "object",
"additionalProperties": false,
"description": "Size/type category of the unit.",
"properties": {
"id": { "type": "string", "description": "Category id (UUID)." },
"name": { "type": "string", "description": "Category name." },
"isActive": {
"type": "boolean",
"description": "Whether the category is active."
}
},
"required": ["id", "name", "isActive"]
},
"economicGroup": {
"type": ["object", "null"],
"additionalProperties": false,
"description": "Economic group of the unit, or null. cna-one requires the group to have been mirrored first (EconomicGroupCreated).",
"properties": {
"id": { "type": "string", "description": "Economic group id (UUID)." },
"name": { "type": "string", "description": "Group name." },
"isActive": {
"type": "boolean",
"description": "Whether the group is active."
}
},
"required": ["id", "name", "isActive"]
},
"operationalRegion": {
"type": ["object", "null"],
"additionalProperties": false,
"description": "Operational region, or null.",
"properties": {
"id": { "type": "string", "description": "Region id (UUID)." },
"name": { "type": "string", "description": "Region name." }
},
"required": ["id", "name"]
},
"address": {
"type": ["object", "null"],
"additionalProperties": false,
"description": "Unit address, or null. cna-one requires street, number, neighborhood, postalCode and cityId to mirror it.",
"properties": {
"street": { "type": ["string", "null"], "description": "Street." },
"number": { "type": ["string", "null"], "description": "Number." },
"complement": {
"type": ["string", "null"],
"description": "Complement."
},
"neighborhood": {
"type": ["string", "null"],
"description": "Neighborhood."
},
"postalCode": {
"type": ["string", "null"],
"description": "Postal code as stored; cna-one strips it to 8 digits."
},
"cityId": {
"type": ["integer", "null"],
"description": "IBGE city code (common.cities.id). Must exist in cna-one."
},
"cityName": {
"type": ["string", "null"],
"description": "City name. Ignored by cna-one."
},
"state": {
"type": ["string", "null"],
"description": "State code (UF). Ignored by cna-one."
}
}
},
"partners": {
"type": "array",
"description": "People behind the unit. May be empty. Roles are not included; cna-one turns each into a FRANCHISEE employee.",
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"personId": {
"type": "string",
"description": "Person id (UUID) in cna-nexus."
},
"document": {
"type": "string",
"description": "Person document; format depends on documentType."
},
"documentType": {
"type": "string",
"enum": ["CPF", "CNPJ", "RNM"],
"description": "Document type (PersonsDocumentType), typed as string in the class."
},
"name": { "type": "string", "description": "Person name." },
"legalName": {
"type": ["string", "null"],
"description": "Legal name, for companies."
},
"email": {
"type": ["string", "null"],
"description": "E-mail. cna-one requires an e-mail to create the employee."
}
},
"required": ["personId", "document", "documentType", "name"]
}
},
"products": {
"type": "array",
"description": "Products the unit is enabled to sell. cna-one keeps only the distinct sub-categories.",
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"id": { "type": "string", "description": "Product id (UUID)." },
"name": { "type": "string", "description": "Product name." },
"subCategory": {
"type": "string",
"enum": [
"ENGLISH",
"SPANISH",
"PROGRAMMING",
"AI",
"CREATOR_ECONOMY"
],
"description": "Catalog sub-category (ProductSubCategory)."
}
},
"required": ["id", "name", "subCategory"]
}
}
},
"required": [
"id",
"name",
"conectaEnabled",
"openingStatus",
"operationalStatus",
"category",
"partners",
"products"
]
}
---
id: FranchiseUpdated
name: Franchise Updated
version: 1.1.0
summary: >-
A franchise unit changed in CNA Nexus - edited in one of the franchise
screens, re-activated from a contract, or amended. Full snapshot. Nobody
consumes it today; cna-one has no class for it and drops it.
owners:
- cna-platform
schemaPath: schema.json
badges:
- content: 'Topic: Franchise'
backgroundColor: purple
textColor: purple
icon: kafka
- content: 'Key: id'
backgroundColor: gray
textColor: gray
- content: No consumer
backgroundColor: yellow
textColor: yellow
x-contract-owner: NEXUS
x-kafka-topic: Franchise
x-message-key: id
x-source: cna-nexus api/app/events/nexus/franchise/FranchiseUpdated.ts
x-drift: >-
Produced by cna-nexus in six places; cna-one has no class for it, so franchise
changes made in Nexus never reach One.
---
## Overview
Fact: a franchise unit changed. Same payload as [[event|FranchiseCreated]]: a full snapshot re-read after commit. Since 1.1.0 it carries the unit's primary contact (`email`, `whatsapp`, `phone`).
**When it is published.** [[service|cna-nexus]], always through `manager.afterCommit()`, from six places: the four franchise edit commands (legal information, operational data, financial data, location and contact), `applyContractToFranchiseAggregate` (a renewal, resale or re-activation projecting a [[entity|Contract]] onto the unit, see [[event|ContractActivationRequested]]) and `applyAmendmentChanges` (one message per applied [[entity|ContractAmendment]], see [[event|AmendmentApplyRequested]]).
**Consumers.** None. [[service|cna-one]] subscribes to the topic but has no `FranchiseUpdated` class, so its consumer skips the message with a debug log. Franchise changes made in Nexus after creation are therefore not mirrored into One. Tracked in the drift list as a decision to make: consume it in One or stop producing it.
## Kafka
| | |
| -------------------------- | ------------------ |
| Topic (`aggregateRoot`) | `Franchise` |
| Message key (`routingKey`) | `payload.id` |
| Contract owner | `NEXUS` |
| `metadata.event` | `FranchiseUpdated` |
| Consumer group | none |
## Payload schema
Same payload type as `FranchiseCreated` (`FranchisePayload`).
## Source of truth
```typescript
import { FranchisePayload } from 'events/nexus/franchise/FranchiseCreated';
export class FranchiseUpdated extends Event {
static readonly owner = 'NEXUS';
static readonly aggregateRoot = 'Franchise';
static readonly routingKey = 'id';
static readonly version = '1.1.0';
}
```
The class exists only in cna-nexus.
## Raw Schema:schema.json
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "FranchiseUpdated payload",
"type": "object",
"additionalProperties": false,
"properties": {
"id": {
"type": "string",
"description": "Franchise id (UUID). Kafka message key; cna-one mirrors the unit under this id."
},
"name": { "type": "string", "description": "Unit name." },
"code": {
"type": ["string", "null"],
"description": "Internal unit code, unique."
},
"cnpj": {
"type": ["string", "null"],
"description": "Company registration number as stored (no normalisation). cna-one requires and validates it."
},
"legalName": {
"type": ["string", "null"],
"description": "Legal company name. cna-one requires it."
},
"email": {
"type": ["string", "null"],
"format": "email",
"description": "Primary contact e-mail of the unit (franchises.email in cna-nexus, not unique there). cna-one mirrors it and requires it to be unique across its franchises."
},
"whatsapp": {
"type": ["string", "null"],
"pattern": "^\\+[1-9]\\d{6,14}$",
"description": "WhatsApp number in E.164 (+5511912345678). Mirrored by cna-one."
},
"phone": {
"type": ["string", "null"],
"pattern": "^\\+[1-9]\\d{6,14}$",
"description": "Primary contact phone in E.164. Mirrored by cna-one."
},
"stateRegistration": {
"type": ["string", "null"],
"description": "State registration number."
},
"taxRegime": {
"type": ["string", "null"],
"enum": ["SIMPLES_NACIONAL", "LUCRO_PRESUMIDO", "LUCRO_REAL", null],
"description": "Tax regime (FranchiseTaxRegime), typed as string in the class."
},
"protheusId": {
"type": ["string", "null"],
"description": "Id in the Protheus ERP."
},
"conectaEnabled": {
"type": "boolean",
"description": "Inter-school sharing enabled."
},
"openingStatus": {
"type": "string",
"enum": [
"CREATED",
"BUILT",
"TRAINED",
"SUSPENDED",
"UNDER_REVIEW",
"DEFAULTING"
],
"description": "Lifecycle stage (FranchiseOpeningStatus), typed as string in the class."
},
"operationalStatus": {
"type": "string",
"enum": ["OPERATIONAL", "NON_OPERATIONAL", "UNDER_DEVELOPMENT"],
"description": "Operational status (FranchiseOperationalStatus). cna-one maps OPERATIONAL to ACTIVE, anything else to INACTIVE."
},
"financialStatus": {
"type": ["string", "null"],
"enum": [
"ACTIVE",
"INACTIVE",
"SUSPENDED",
"DEFAULTING",
"UNDER_REVIEW",
null
],
"description": "Financial standing (FranchiseFinancialStatus)."
},
"operationalStartAt": {
"type": ["string", "null"],
"format": "date-time",
"description": "ISO-8601 instant when operations started."
},
"operationalEndAt": {
"type": ["string", "null"],
"format": "date-time",
"description": "ISO-8601 instant when operations ended."
},
"firstContractStartsOn": {
"type": ["string", "null"],
"format": "date",
"description": "YYYY-MM-DD start of the earliest opening contract, or null. Ignored by cna-one."
},
"category": {
"type": "object",
"additionalProperties": false,
"description": "Size/type category of the unit.",
"properties": {
"id": { "type": "string", "description": "Category id (UUID)." },
"name": { "type": "string", "description": "Category name." },
"isActive": {
"type": "boolean",
"description": "Whether the category is active."
}
},
"required": ["id", "name", "isActive"]
},
"economicGroup": {
"type": ["object", "null"],
"additionalProperties": false,
"description": "Economic group of the unit, or null. cna-one requires the group to have been mirrored first (EconomicGroupCreated).",
"properties": {
"id": { "type": "string", "description": "Economic group id (UUID)." },
"name": { "type": "string", "description": "Group name." },
"isActive": {
"type": "boolean",
"description": "Whether the group is active."
}
},
"required": ["id", "name", "isActive"]
},
"operationalRegion": {
"type": ["object", "null"],
"additionalProperties": false,
"description": "Operational region, or null.",
"properties": {
"id": { "type": "string", "description": "Region id (UUID)." },
"name": { "type": "string", "description": "Region name." }
},
"required": ["id", "name"]
},
"address": {
"type": ["object", "null"],
"additionalProperties": false,
"description": "Unit address, or null. cna-one requires street, number, neighborhood, postalCode and cityId to mirror it.",
"properties": {
"street": { "type": ["string", "null"], "description": "Street." },
"number": { "type": ["string", "null"], "description": "Number." },
"complement": {
"type": ["string", "null"],
"description": "Complement."
},
"neighborhood": {
"type": ["string", "null"],
"description": "Neighborhood."
},
"postalCode": {
"type": ["string", "null"],
"description": "Postal code as stored; cna-one strips it to 8 digits."
},
"cityId": {
"type": ["integer", "null"],
"description": "IBGE city code (common.cities.id). Must exist in cna-one."
},
"cityName": {
"type": ["string", "null"],
"description": "City name. Ignored by cna-one."
},
"state": {
"type": ["string", "null"],
"description": "State code (UF). Ignored by cna-one."
}
}
},
"partners": {
"type": "array",
"description": "People behind the unit. May be empty. Roles are not included; cna-one turns each into a FRANCHISEE employee.",
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"personId": {
"type": "string",
"description": "Person id (UUID) in cna-nexus."
},
"document": {
"type": "string",
"description": "Person document; format depends on documentType."
},
"documentType": {
"type": "string",
"enum": ["CPF", "CNPJ", "RNM"],
"description": "Document type (PersonsDocumentType), typed as string in the class."
},
"name": { "type": "string", "description": "Person name." },
"legalName": {
"type": ["string", "null"],
"description": "Legal name, for companies."
},
"email": {
"type": ["string", "null"],
"description": "E-mail. cna-one requires an e-mail to create the employee."
}
},
"required": ["personId", "document", "documentType", "name"]
}
},
"products": {
"type": "array",
"description": "Products the unit is enabled to sell. cna-one keeps only the distinct sub-categories.",
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"id": { "type": "string", "description": "Product id (UUID)." },
"name": { "type": "string", "description": "Product name." },
"subCategory": {
"type": "string",
"enum": [
"ENGLISH",
"SPANISH",
"PROGRAMMING",
"AI",
"CREATOR_ECONOMY"
],
"description": "Catalog sub-category (ProductSubCategory)."
}
},
"required": ["id", "name", "subCategory"]
}
}
},
"required": [
"id",
"name",
"conectaEnabled",
"openingStatus",
"operationalStatus",
"category",
"partners",
"products"
]
}
---
id: FranchiseUpdated
name: Franchise Updated
version: 1.2.0
summary: >-
A franchise unit changed in CNA Nexus - edited in one of the franchise
screens, re-activated from a contract, or amended. Full snapshot. Nobody
consumes it today; cna-one has no class for it and drops it.
owners:
- cna-platform
schemaPath: schema.json
badges:
- content: 'Topic: Franchise'
backgroundColor: purple
textColor: purple
icon: kafka
- content: 'Key: id'
backgroundColor: gray
textColor: gray
- content: No consumer
backgroundColor: yellow
textColor: yellow
x-contract-owner: NEXUS
x-kafka-topic: Franchise
x-message-key: id
x-source: cna-nexus api/app/events/nexus/franchise/FranchiseUpdated.ts
x-drift: >-
Produced by cna-nexus in six places; cna-one has no class for it, so franchise
changes made in Nexus never reach One.
---
## Overview
Fact: a franchise unit changed. Same payload as [[event|FranchiseCreated]]: a full snapshot re-read after commit. Since 1.1.0 it carries the unit's primary contact (`email`, `whatsapp`, `phone`). Since 1.2.0 the address also carries `countryId`, `foreignCity`, `foreignState`, `latitude` and `longitude`, and each partner is identified by `id` (the person id) instead of `personId`.
**When it is published.** [[service|cna-nexus]], always through `manager.afterCommit()`, from six places: the four franchise edit commands (legal information, operational data, financial data, location and contact), `applyContractToFranchiseAggregate` (a renewal, resale or re-activation projecting a [[entity|Contract]] onto the unit, see [[event|ContractActivationRequested]]) and `applyAmendmentChanges` (one message per applied [[entity|ContractAmendment]], see [[event|AmendmentApplyRequested]]).
**Consumers.** None. [[service|cna-one]] subscribes to the topic but has no `FranchiseUpdated` class, so its consumer skips the message with a debug log. Franchise changes made in Nexus after creation are therefore not mirrored into One. Tracked in the drift list as a decision to make: consume it in One or stop producing it.
## Kafka
| | |
| -------------------------- | ------------------ |
| Topic (`aggregateRoot`) | `Franchise` |
| Message key (`routingKey`) | `payload.id` |
| Contract owner | `NEXUS` |
| `metadata.event` | `FranchiseUpdated` |
| Consumer group | none |
## Payload schema
Same payload type as `FranchiseCreated` (`FranchisePayload`).
## Source of truth
```typescript
import { FranchisePayload } from 'events/nexus/franchise/FranchiseCreated';
export class FranchiseUpdated extends Event {
static readonly owner = 'NEXUS';
static readonly aggregateRoot = 'Franchise';
static readonly routingKey = 'id';
static readonly version = '1.2.0';
}
```
The class exists only in cna-nexus.
## Raw Schema:schema.json
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "FranchiseUpdated payload",
"type": "object",
"additionalProperties": false,
"properties": {
"id": {
"type": "string",
"description": "Franchise id (UUID). Kafka message key; cna-one mirrors the unit under this id."
},
"name": { "type": "string", "description": "Unit name." },
"code": {
"type": ["string", "null"],
"description": "Internal unit code, unique."
},
"cnpj": {
"type": ["string", "null"],
"description": "Company registration number as stored (no normalisation). cna-one requires and validates it."
},
"legalName": {
"type": ["string", "null"],
"description": "Legal company name. cna-one requires it."
},
"email": {
"type": ["string", "null"],
"format": "email",
"description": "Primary contact e-mail of the unit (franchises.email in cna-nexus, not unique there). cna-one mirrors it and requires it to be unique across its franchises."
},
"whatsapp": {
"type": ["string", "null"],
"pattern": "^\\+[1-9]\\d{6,14}$",
"description": "WhatsApp number in E.164 (+5511912345678). Mirrored by cna-one."
},
"phone": {
"type": ["string", "null"],
"pattern": "^\\+[1-9]\\d{6,14}$",
"description": "Primary contact phone in E.164. Mirrored by cna-one."
},
"stateRegistration": {
"type": ["string", "null"],
"description": "State registration number."
},
"taxRegime": {
"type": ["string", "null"],
"enum": ["SIMPLES_NACIONAL", "LUCRO_PRESUMIDO", "LUCRO_REAL", null],
"description": "Tax regime (FranchiseTaxRegime), typed as string in the class."
},
"protheusId": {
"type": ["string", "null"],
"description": "Id in the Protheus ERP."
},
"conectaEnabled": {
"type": "boolean",
"description": "Inter-school sharing enabled."
},
"openingStatus": {
"type": "string",
"enum": [
"CREATED",
"BUILT",
"TRAINED",
"SUSPENDED",
"UNDER_REVIEW",
"DEFAULTING"
],
"description": "Lifecycle stage (FranchiseOpeningStatus), typed as string in the class."
},
"operationalStatus": {
"type": "string",
"enum": ["OPERATIONAL", "NON_OPERATIONAL", "UNDER_DEVELOPMENT"],
"description": "Operational status (FranchiseOperationalStatus). cna-one maps OPERATIONAL to ACTIVE, anything else to INACTIVE."
},
"financialStatus": {
"type": ["string", "null"],
"enum": [
"ACTIVE",
"INACTIVE",
"SUSPENDED",
"DEFAULTING",
"UNDER_REVIEW",
null
],
"description": "Financial standing (FranchiseFinancialStatus)."
},
"operationalStartAt": {
"type": ["string", "null"],
"format": "date-time",
"description": "ISO-8601 instant when operations started."
},
"operationalEndAt": {
"type": ["string", "null"],
"format": "date-time",
"description": "ISO-8601 instant when operations ended."
},
"firstContractStartsOn": {
"type": ["string", "null"],
"format": "date",
"description": "YYYY-MM-DD start of the earliest opening contract, or null. Ignored by cna-one."
},
"category": {
"type": "object",
"additionalProperties": false,
"description": "Size/type category of the unit.",
"properties": {
"id": { "type": "string", "description": "Category id (UUID)." },
"name": { "type": "string", "description": "Category name." },
"isActive": {
"type": "boolean",
"description": "Whether the category is active."
}
},
"required": ["id", "name", "isActive"]
},
"economicGroup": {
"type": ["object", "null"],
"additionalProperties": false,
"description": "Economic group of the unit, or null. cna-one requires the group to have been mirrored first (EconomicGroupCreated).",
"properties": {
"id": { "type": "string", "description": "Economic group id (UUID)." },
"name": { "type": "string", "description": "Group name." },
"isActive": {
"type": "boolean",
"description": "Whether the group is active."
}
},
"required": ["id", "name", "isActive"]
},
"operationalRegion": {
"type": ["object", "null"],
"additionalProperties": false,
"description": "Operational region, or null.",
"properties": {
"id": { "type": "string", "description": "Region id (UUID)." },
"name": { "type": "string", "description": "Region name." }
},
"required": ["id", "name"]
},
"address": {
"type": ["object", "null"],
"additionalProperties": false,
"description": "Unit address, or null. cna-one requires street, number, neighborhood, postalCode and cityId to mirror it; it ignores countryId, foreignCity, foreignState, latitude and longitude.",
"properties": {
"street": { "type": ["string", "null"], "description": "Street." },
"number": { "type": ["string", "null"], "description": "Number." },
"complement": {
"type": ["string", "null"],
"description": "Complement."
},
"neighborhood": {
"type": ["string", "null"],
"description": "Neighborhood."
},
"postalCode": {
"type": ["string", "null"],
"description": "Postal code as stored; cna-one strips it to 8 digits."
},
"cityId": {
"type": ["integer", "null"],
"description": "IBGE city code (common.cities.id). Must exist in cna-one. Null for an address outside Brazil."
},
"cityName": {
"type": ["string", "null"],
"description": "City name. Null for an address outside Brazil. Ignored by cna-one."
},
"state": {
"type": ["string", "null"],
"description": "State code (UF). Null for an address outside Brazil. Ignored by cna-one."
},
"countryId": {
"type": "string",
"description": "Country as an ISO 3166-1 alpha-2 code (BR for Brazil). Always present. Ignored by cna-one."
},
"foreignCity": {
"type": ["string", "null"],
"description": "Free-text city name for an address outside Brazil; null for a Brazilian address. Ignored by cna-one."
},
"foreignState": {
"type": ["string", "null"],
"description": "Free-text state or province name for an address outside Brazil; null for a Brazilian address. Ignored by cna-one."
},
"latitude": {
"type": ["string", "null"],
"description": "Latitude in WGS84 as decimal text (ST_Y over the location point), or null when the address has no coordinates. Ignored by cna-one."
},
"longitude": {
"type": ["string", "null"],
"description": "Longitude in WGS84 as decimal text (ST_X over the location point), or null when the address has no coordinates. Ignored by cna-one."
}
},
"required": ["countryId"]
},
"partners": {
"type": "array",
"description": "People behind the unit. May be empty. Roles are not included; cna-one turns each into a FRANCHISEE employee.",
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"id": {
"type": "string",
"description": "Person id (UUID) in cna-nexus, the same id the FranchisePartner events carry in person.id. Was personId until 1.1.0. cna-one correlates partners by document, not by this id."
},
"document": {
"type": "string",
"description": "Person document; format depends on documentType."
},
"documentType": {
"type": "string",
"enum": ["CPF", "CNPJ", "RNM"],
"description": "Document type (PersonsDocumentType), typed as string in the class."
},
"name": { "type": "string", "description": "Person name." },
"legalName": {
"type": ["string", "null"],
"description": "Legal name, for companies."
},
"email": {
"type": ["string", "null"],
"description": "E-mail. cna-one requires an e-mail to create the employee."
}
},
"required": ["id", "document", "documentType", "name"]
}
},
"products": {
"type": "array",
"description": "Products the unit is enabled to sell. cna-one keeps only the distinct sub-categories.",
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"id": { "type": "string", "description": "Product id (UUID)." },
"name": { "type": "string", "description": "Product name." },
"subCategory": {
"type": "string",
"enum": [
"ENGLISH",
"SPANISH",
"PROGRAMMING",
"AI",
"CREATOR_ECONOMY"
],
"description": "Catalog sub-category (ProductSubCategory)."
}
},
"required": ["id", "name", "subCategory"]
}
}
},
"required": [
"id",
"name",
"conectaEnabled",
"openingStatus",
"operationalStatus",
"category",
"partners",
"products"
]
}
---
id: LearningBookDiscontinued
name: Learning Book Discontinued
version: 1.0.0
summary: >-
A learning book was discontinued and left the catalog. cna-one's pricing
consumer removes its prices from current and future price tables.
owners:
- cna-platform
schemaPath: schema.json
badges:
- content: "Topic: LearningBook"
backgroundColor: purple
textColor: purple
icon: kafka
- content: "Key: id"
backgroundColor: gray
textColor: gray
x-contract-owner: ONE
x-kafka-topic: LearningBook
x-message-key: id
x-source: cna-one api/app/events/one/learningBook/LearningBookDiscontinued.ts
---
## Overview
Fact: a learning book reached the status `DISCONTINUED`. Same payload as [[event|LearningBookPublished]].
**When it is published.** [[service|cna-one]], in `franchisorUpdateLearningBookStatus`, when the new status is `DISCONTINUED`, after the status and its history row are written.
**What the consumer does.** Same handler as [[event|LearningBookDrafted]] (`syncLearningBookUnavailableToPrices`): soft-deletes every price of the book in the default groups of current and future price tables. Idempotent by construction.
## Kafka
| | |
| -------------------------- | -------------------------- |
| Topic (`aggregateRoot`) | `LearningBook` |
| Message key (`routingKey`) | `payload.id` |
| Contract owner | `ONE` |
| `metadata.event` | `LearningBookDiscontinued` |
| Consumer group | `pricing-price-events` |
## Payload schema
Same payload type as `LearningBookPublished` (`LearningBookPayload`); `status` is `DISCONTINUED`.
## Source of truth
```typescript
import { LearningBookPayload } from "events/one/learningBook/LearningBookPublished";
class LearningBookDiscontinued extends Event {
public static owner = "ONE";
public static aggregateRoot = "LearningBook";
public static routingKey = "id";
}
```
## Raw Schema:schema.json
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "LearningBookDiscontinued payload",
"type": "object",
"additionalProperties": false,
"properties": {
"id": {
"type": "string",
"description": "Learning book id (UUID). Kafka message key."
},
"productId": {
"type": "string",
"description": "Product the edition belongs to (product.products, type LEARNING_BOOK)."
},
"publisherId": {
"type": "string",
"description": "Publisher id (product.publishers)."
},
"edition": { "type": "integer", "description": "Edition number." },
"protheusId": {
"type": "string",
"description": "Id in the Protheus ERP (4 characters)."
},
"isbn": {
"type": ["string", "null"],
"description": "ISBN (13 characters), or null."
},
"url": {
"type": ["string", "null"],
"description": "URL of the digital book, or null."
},
"imageUrl": {
"type": ["string", "null"],
"description": "Cover image URL, or null."
},
"isDigitalContentAvailable": {
"type": "boolean",
"description": "Whether digital content is available."
},
"status": {
"type": "string",
"enum": ["DRAFT", "PUBLISHED", "DISCONTINUED", "UNDER_REVIEW"],
"description": "Publication status (cna-one LearningBookStatus). Matches the event: PUBLISHED, DRAFT or DISCONTINUED; UNDER_REVIEW never reaches the topic."
}
},
"required": [
"id",
"productId",
"publisherId",
"edition",
"protheusId",
"isDigitalContentAvailable",
"status"
]
}
---
id: LearningBookDrafted
name: Learning Book Drafted
version: 1.0.0
summary: >-
A learning book was created as a draft or moved back to draft, so it is not on
sale. cna-one's pricing consumer removes its prices from current and future
price tables.
owners:
- cna-platform
schemaPath: schema.json
badges:
- content: "Topic: LearningBook"
backgroundColor: purple
textColor: purple
icon: kafka
- content: "Key: id"
backgroundColor: gray
textColor: gray
x-contract-owner: ONE
x-kafka-topic: LearningBook
x-message-key: id
x-source: cna-one api/app/events/one/learningBook/LearningBookDrafted.ts
---
## Overview
Fact: a learning book is in status `DRAFT`. Same payload as [[event|LearningBookPublished]].
**When it is published.** [[service|cna-one]], on two occasions: when the franchisor creates a learning book (`franchisorCreateLearningBook` saves it as `DRAFT` and publishes right after), and when a status update lands on `DRAFT` (`franchisorUpdateLearningBookStatus`, for example unpublishing a book, which requires a reason). Published after the write, not inside `afterCommit`.
**What the consumer does.** cna-one's pricing process (`priceConsumer`, handler `syncLearningBookUnavailableToPrices`, shared with `LearningBookDiscontinued`): loads every price of the book in the default groups of current and future price tables and soft-deletes them. An already-deleted price is deleted again so its `deletedAt` reflects the last exit from the catalog; nothing to delete is a silent no-op. A freshly created draft therefore has nothing to do.
## Kafka
| | |
| -------------------------- | ---------------------- |
| Topic (`aggregateRoot`) | `LearningBook` |
| Message key (`routingKey`) | `payload.id` |
| Contract owner | `ONE` |
| `metadata.event` | `LearningBookDrafted` |
| Consumer group | `pricing-price-events` |
## Payload schema
Same payload type as `LearningBookPublished` (`LearningBookPayload`); `status` is `DRAFT`.
## Source of truth
```typescript
import { LearningBookPayload } from "events/one/learningBook/LearningBookPublished";
class LearningBookDrafted extends Event {
public static owner = "ONE";
public static aggregateRoot = "LearningBook";
public static routingKey = "id";
}
```
## Raw Schema:schema.json
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "LearningBookDrafted payload",
"type": "object",
"additionalProperties": false,
"properties": {
"id": {
"type": "string",
"description": "Learning book id (UUID). Kafka message key."
},
"productId": {
"type": "string",
"description": "Product the edition belongs to (product.products, type LEARNING_BOOK)."
},
"publisherId": {
"type": "string",
"description": "Publisher id (product.publishers)."
},
"edition": { "type": "integer", "description": "Edition number." },
"protheusId": {
"type": "string",
"description": "Id in the Protheus ERP (4 characters)."
},
"isbn": {
"type": ["string", "null"],
"description": "ISBN (13 characters), or null."
},
"url": {
"type": ["string", "null"],
"description": "URL of the digital book, or null."
},
"imageUrl": {
"type": ["string", "null"],
"description": "Cover image URL, or null."
},
"isDigitalContentAvailable": {
"type": "boolean",
"description": "Whether digital content is available."
},
"status": {
"type": "string",
"enum": ["DRAFT", "PUBLISHED", "DISCONTINUED", "UNDER_REVIEW"],
"description": "Publication status (cna-one LearningBookStatus). Matches the event: PUBLISHED, DRAFT or DISCONTINUED; UNDER_REVIEW never reaches the topic."
}
},
"required": [
"id",
"productId",
"publisherId",
"edition",
"protheusId",
"isDigitalContentAvailable",
"status"
]
}
---
id: LearningBookPublished
name: Learning Book Published
version: 1.0.0
summary: >-
A learning book was published and is on sale. cna-one's pricing consumer opens
a pending price for it in every current and future default price group.
owners:
- cna-platform
schemaPath: schema.json
badges:
- content: "Topic: LearningBook"
backgroundColor: purple
textColor: purple
icon: kafka
- content: "Key: id"
backgroundColor: gray
textColor: gray
x-contract-owner: ONE
x-kafka-topic: LearningBook
x-message-key: id
x-source: cna-one api/app/events/one/learningBook/LearningBookPublished.ts
---
## Overview
Fact: a learning book reached the status `PUBLISHED`. The payload is a snapshot of the book.
**When it is published.** [[service|cna-one]], in `franchisorUpdateLearningBookStatus`, after the status change and its publication history row are written: `produceLearningBookStatusEvent` picks the event from the stored status, so `status` in the payload is always `PUBLISHED` here. Published after the write completes, not inside `afterCommit`.
**What the consumer does.** cna-one's pricing process (`priceConsumer`, handler `syncLearningBookPublishedToPrices`):
1. Loads the default price groups (B2B and B2C) of the price tables that are current or future today, together with the prices they already hold for this book.
2. No group: logs a warning and stops.
3. For each group without a live price for the book, prepares a pending price (`type FIXED`, `amount null`). A soft-deleted price is recovered instead of duplicated; a live price is left untouched, which makes redelivery harmless.
4. Upserts the pending prices. Someone then fills the amounts in the price table screen.
Failures are logged and reported to Sentry, never rethrown.
## Kafka
| | |
| -------------------------- | ----------------------- |
| Topic (`aggregateRoot`) | `LearningBook` |
| Message key (`routingKey`) | `payload.id` |
| Contract owner | `ONE` |
| `metadata.event` | `LearningBookPublished` |
| Consumer group | `pricing-price-events` |
## Payload schema
## Source of truth
```typescript
import { LearningBookStatus } from "db/enums/product/LearningBook";
export type LearningBookPayload = {
id: string;
productId: string;
publisherId: string;
edition: number;
protheusId: string;
isbn?: string | null;
url?: string | null;
imageUrl?: string | null;
isDigitalContentAvailable: boolean;
status: LearningBookStatus;
};
class LearningBookPublished extends Event {
public static owner = "ONE";
public static aggregateRoot = "LearningBook";
public static routingKey = "id";
}
```
The class exists only in cna-one.
## Raw Schema:schema.json
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "LearningBookPublished payload",
"type": "object",
"additionalProperties": false,
"properties": {
"id": {
"type": "string",
"description": "Learning book id (UUID). Kafka message key."
},
"productId": {
"type": "string",
"description": "Product the edition belongs to (product.products, type LEARNING_BOOK)."
},
"publisherId": {
"type": "string",
"description": "Publisher id (product.publishers)."
},
"edition": { "type": "integer", "description": "Edition number." },
"protheusId": {
"type": "string",
"description": "Id in the Protheus ERP (4 characters)."
},
"isbn": {
"type": ["string", "null"],
"description": "ISBN (13 characters), or null."
},
"url": {
"type": ["string", "null"],
"description": "URL of the digital book, or null."
},
"imageUrl": {
"type": ["string", "null"],
"description": "Cover image URL, or null."
},
"isDigitalContentAvailable": {
"type": "boolean",
"description": "Whether digital content is available."
},
"status": {
"type": "string",
"enum": ["DRAFT", "PUBLISHED", "DISCONTINUED", "UNDER_REVIEW"],
"description": "Publication status (cna-one LearningBookStatus). Matches the event: PUBLISHED, DRAFT or DISCONTINUED; UNDER_REVIEW never reaches the topic."
}
},
"required": [
"id",
"productId",
"publisherId",
"edition",
"protheusId",
"isDigitalContentAvailable",
"status"
]
}
---
id: PartnerUpdated
name: Partner Updated
version: 1.0.0
summary: >-
The personal data of a franchise partner changed in CNA Nexus - name, legal
name, e-mail, phone or birth date. Snapshot of the person, keyed by the person
id. To be published by cna-nexus and consumed by cna-one; neither side has the
class yet.
owners:
- cna-platform
schemaPath: schema.json
badges:
- content: 'Topic: Partner'
backgroundColor: purple
textColor: purple
icon: kafka
- content: 'Key: id'
backgroundColor: gray
textColor: gray
- content: No producer
backgroundColor: yellow
textColor: yellow
- content: No consumer
backgroundColor: yellow
textColor: yellow
x-contract-owner: NEXUS
x-kafka-topic: Partner
x-message-key: id
x-source: >-
cna-nexus api/app/events/nexus/partner/PartnerUpdated.ts (target, not written
yet)
x-drift: >-
No class exists in cna-nexus or cna-one yet. The contract was defined on
2026-09-24 and this page is written against the tasks opened for the two
teams; the links go here when the pull requests open.
---
## Overview
Fact: the data of a [[entity|Partner]] changed. The payload is the snapshot of the person as `common.persons` holds it after the change: the same fields the [[event|FranchisePartnerAdded]] payload carries in `person`, without `isFranchisee`, and without the roles or the units, which belong to the [[entity|FranchisePartner]] links on [[channel|kafka.Franchise]].
**Why it exists.** Editing a person in Nexus (`updatePerson`) writes straight to `common.persons` and publishes nothing. The partner events only go out when a link is added, removed or changes roles, so a new e-mail, phone, name or birth date of a partner reached [[service|cna-one]] only by accident, inside the next `FranchiseUpdated` snapshot of one of their units. `PartnerUpdated` carries that change on its own, keyed by the person, and leaves the partner events with their single meaning: the link changed.
**When it is published (target).** [[service|cna-nexus]], in the `updatePerson` command, registered with `manager.afterCommit()`, when both hold:
1. the person holds at least one `FranchisePartner` link (`existsFranchisePartnerByPersonId`); a person with no link is not a partner and publishes nothing;
2. at least one field of the payload actually changed. A no-op update publishes nothing, which is what keeps a consumer that mirrors and re-announces the person from looping.
Not published by `createPerson` (the link that makes the person a partner arrives as `FranchisePartnerAdded` with the same snapshot), by `updatePersonSituation` (the situation is not in the payload) or by `deletePerson`. `birthDate` is formatted with `toCalendarDate`, as in the partner events since 1.1.0.
**What the consumer does (target).** cna-one, on a consumer of the `Partner` topic: normalises `document`, looks the person up by it, and drops the message with a log when no person is stored, since a partner it never mirrored is not one it has to update. When the person is known it rewrites the mirrored fields (name split into name and surname, e-mail under the same uniqueness rules the partner sync applies, phone, birth date) and re-announces the person as `EmployeeUpdated` when they are an employee, so [[service|cna-auth]] sees the new e-mail. cna-one does not publish a person event back.
## Kafka
| | |
| -------------------------- | --------------------------------- |
| Topic (`aggregateRoot`) | `Partner` |
| Message key (`routingKey`) | `payload.id` |
| Contract owner | `NEXUS` |
| `metadata.event` | `PartnerUpdated` |
| `metadata.version` | `1.0.0` |
| Consumer group | none yet (cna-one, to be created) |
## Payload schema
Flat snapshot of the person. `id`, `document`, `documentType` and `name` are always present; the rest is optional and nullable. `document` cannot change in Nexus, so it is a stable correlation key.
## Source of truth
Target class, to be created in cna-nexus at `api/app/events/nexus/partner/PartnerUpdated.ts`:
```typescript
export type PartnerUpdatedPayload = {
id: string;
document: string;
documentType: PersonsDocumentType;
name: string;
legalName?: string | null;
email?: string | null;
phone?: string | null;
birthDate?: string | null; // YYYY-MM-DD
};
export class PartnerUpdated extends Event {
static readonly owner = 'NEXUS';
static readonly aggregateRoot = 'Partner';
static readonly routingKey = 'id';
static readonly version = '1.0.0';
}
```
## Known drift
Observed 2026-09-24.
- cna-nexus, the canonical repository, has no `PartnerUpdated` class and `updatePerson` (`api/app/domains/common/person/commands/updatePerson.ts`) emits nothing. The class above is the target; the `No producer` badge and this note go when the cna-nexus pull request is in production, and its link goes here when it opens.
- cna-one has no class and no consumer on the `Partner` topic. Its partner ingestion (branch `jl/feature/CWH-49388-consume-franchise-partner-events`, not merged) already reads the same person block from the partner events and correlates by document; the consumer of this event is a task of its own. The `No consumer` badge goes when it is in production.
## Raw Schema:schema.json
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "PartnerUpdated payload",
"type": "object",
"additionalProperties": false,
"properties": {
"id": {
"type": "string",
"description": "Person id (UUID) in common.persons. Kafka message key. Same value as person.id in the FranchisePartner events."
},
"document": {
"type": "string",
"description": "Person document; format depends on documentType. The key cna-one correlates the person by."
},
"documentType": {
"type": "string",
"enum": ["CPF", "CNPJ", "RNM"],
"description": "Document type (PersonsDocumentType)."
},
"name": {
"type": "string",
"description": "Person name."
},
"legalName": {
"type": ["string", "null"],
"description": "Legal name, for companies."
},
"email": {
"type": ["string", "null"],
"description": "E-mail, or null."
},
"phone": {
"type": ["string", "null"],
"description": "Phone, or null."
},
"birthDate": {
"type": ["string", "null"],
"format": "date",
"description": "Birth date as a calendar day, YYYY-MM-DD, no time zone. Null when unknown."
}
},
"required": ["id", "document", "documentType", "name"]
}
---
id: PendencyResolved
name: Pendency Resolved
version: 1.0.0
summary: >-
The responsible user resolved a pendency as done or declined. Carries only the
id; consumers are expected to re-read the pendency. No consumer today.
owners:
- cna-platform
schemaPath: schema.json
badges:
- content: 'Topic: Pendency'
backgroundColor: purple
textColor: purple
icon: kafka
- content: 'Key: id'
backgroundColor: gray
textColor: gray
- content: No consumer
backgroundColor: yellow
textColor: yellow
x-contract-owner: NEXUS
x-kafka-topic: Pendency
x-message-key: id
x-source: cna-nexus api/app/events/nexus/pendency/PendencyResolved.ts
x-drift: >-
No consumer in any application. Nothing outside the pendency domain creates
pendencies today either.
---
## Overview
Fact: a pendency left the `OPEN` state by the hand of its responsible, with outcome `DONE` or `DECLINED`. By design the payload carries only the `id`: a consumer re-reads the pendency's current state instead of trusting a snapshot.
**When it is published.** [[service|cna-nexus]], in `resolvePendency`, reached from the GraphQL mutation of the same name: after validating that the caller is the responsible and the pendency is still `OPEN`, the status, the note and `resolvedAt` are written and the producer is awaited. No transaction and no `afterCommit`. A `CANCELLED` closure does not publish.
**Consumers.** None in cna-auth, cna-nexus, cna-one or cna-placement. The intended reader is the process that created the pendency, so it can continue its flow; no such process exists yet.
## Kafka
| | |
| -------------------------- | ------------------ |
| Topic (`aggregateRoot`) | `Pendency` |
| Message key (`routingKey`) | `payload.id` |
| Contract owner | `NEXUS` |
| `metadata.event` | `PendencyResolved` |
| Consumer group | none |
## Payload schema
## Source of truth
```typescript
type PendencyResolvedPayload = {
id: string;
};
export class PendencyResolved extends Event {
static readonly owner = "NEXUS";
static readonly aggregateRoot = "Pendency";
static readonly routingKey = "id";
}
```
The class exists only in cna-nexus.
## Raw Schema:schema.json
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "PendencyResolved payload",
"type": "object",
"additionalProperties": false,
"properties": {
"id": {
"type": "string",
"description": "Pendency id (UUID) in cna-nexus pendency.pendencies. Kafka message key. Re-read the pendency for its outcome (DONE or DECLINED), note and resolvedAt."
}
},
"required": ["id"]
}
---
id: PriceTableCreated
name: Price Table Created
version: 1.0.0
summary: >-
A price table was created with its default price groups. cna-one's pricing
consumer seeds it with one price per available learning book, copying last
year's amounts when they exist.
owners:
- cna-platform
schemaPath: schema.json
badges:
- content: "Topic: PriceTable"
backgroundColor: purple
textColor: purple
icon: kafka
- content: "Key: id"
backgroundColor: gray
textColor: gray
x-contract-owner: ONE
x-kafka-topic: PriceTable
x-message-key: id
x-source: cna-one api/app/events/one/priceTable/PriceTableCreated.ts
---
## Overview
Fact: a price table exists, with its B2B and B2C default price groups. The payload is the table header.
**When it is published.** [[service|cna-one]], in `franchisorCreateLearningBookPriceTable`, after the command that creates the table and its default groups returns. Before that, the validity is checked: no overlap with the last table and a duration between 3 and 18 months; the year is derived from the last table (plus one) or from `startsOn`. `type` is always `LEARNING_BOOK` today. Published after the write, not inside `afterCommit`.
**What the consumer does.** cna-one's pricing process (`priceTableConsumer`, handler `syncPriceTableCreatedToLearningBookPrices`):
1. Ignores tables whose `type` is not `LEARNING_BOOK`.
2. Loads the table's default price groups and the learning books currently available. Either list empty: logs a warning and stops.
3. Idempotency guard: if the default groups already hold any price, stops without writing. Redelivery neither duplicates nor overwrites.
4. Builds one `FIXED` price per default group and learning book. The amount is copied from the previous year's table when that table has a price for the same sales channel and book; otherwise it is left `null` for someone to fill in.
5. Upserts the prices.
Failures are logged and reported to Sentry, never rethrown.
## Kafka
| | |
| -------------------------- | ---------------------------- |
| Topic (`aggregateRoot`) | `PriceTable` |
| Message key (`routingKey`) | `payload.id` |
| Contract owner | `ONE` |
| `metadata.event` | `PriceTableCreated` |
| Consumer group | `pricing-price-table-events` |
## Payload schema
## Source of truth
```typescript
import { ProductType } from "db/enums/product/Product";
export type PriceTablePayload = {
id: string;
type: ProductType;
year: number;
name: string;
startsOn: string;
endsOn: string;
};
class PriceTableCreated extends Event {
public static owner = "ONE";
public static aggregateRoot = "PriceTable";
public static routingKey = "id";
}
```
The class exists only in cna-one.
## Raw Schema:schema.json
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "PriceTableCreated payload",
"type": "object",
"additionalProperties": false,
"properties": {
"id": {
"type": "string",
"description": "Price table id (UUID). Kafka message key."
},
"type": {
"type": "string",
"enum": ["COURSE", "CERTIFICATION", "LEARNING_BOOK", "PRODUCT"],
"description": "Product type the table prices (cna-one ProductType). Always LEARNING_BOOK today; the consumer ignores other types."
},
"year": {
"type": "integer",
"description": "Calendar year the table applies to."
},
"name": { "type": "string", "description": "Display name of the table." },
"startsOn": {
"type": "string",
"format": "date",
"description": "First day of validity, YYYY-MM-DD."
},
"endsOn": {
"type": "string",
"format": "date",
"description": "Last day of validity, YYYY-MM-DD."
}
},
"required": ["id", "type", "year", "name", "startsOn", "endsOn"]
}
---
id: GrantApplicationAccess
name: Grant Application Access
version: 1.0.0
summary: >-
Asks CNA Auth to give a person access to an application, provisioning the
account and user when they are new. Published by cna-one when an invited user
is mirrored into a person; handled by cna-auth.
owners:
- cna-platform
schemaPath: schema.json
badges:
- content: "Topic: Application"
backgroundColor: purple
textColor: purple
icon: kafka
- content: "Key: document"
backgroundColor: gray
textColor: gray
x-contract-owner: AUTH
x-kafka-topic: Application
x-message-key: document
x-source: cna-auth api/app/events/auth/application/GrantApplicationAccess.ts
---
## Overview
Intent to grant a person access to an application. It is a command: [[service|cna-auth]] validates it and may reject it silently.
**When it is published.** [[service|cna-one]] publishes it from `syncInviteUserToPerson`, right after mirroring an `InviteUser` into a person and an employee. The access is requested on every invite, whether the person is new or already known, because provisioning the login is cna-auth's job and this command is what tells it to. `origin` is the system the invite came from, read from the `InviteUser` envelope: today `metadata.owner` (hard-coded by the publishers to their own name, fallback `ONE`), `metadata.producedBy` once the envelope change lands.
**What the consumer does.** cna-auth (`accountConsumer`, handler `grantApplicationAccess`):
1. Looks the application up by `payload.application` (`applications.identifier`). Unknown application: logged and dropped.
2. Checks `payload.origin` against the application's `allowedOrigins` (uppercase). Origin not allowed: logged and dropped. These two checks run before anything is written, so a rejected command leaves no account behind.
3. Finds or creates the `Account` by `document` (with its OTP authentication method) and the `User` inside it, storing `name`, `employeeId` and `id` as the person id.
4. Records the `ApplicationGrant`. The application only has to exist; `isActive` is a sign-in rule, so a grant on an inactive application becomes usable when it is turned back on.
Every failure is a log and a return. The consumer commits the offset either way, so there is no retry.
## Kafka
| | |
| -------------------------- | ------------------------ |
| Topic (`aggregateRoot`) | `Application` |
| Message key (`routingKey`) | `payload.document` |
| Contract owner | `AUTH` |
| `metadata.event` | `GrantApplicationAccess` |
| Consumer group | `auth-user-events` |
## Payload schema
## Source of truth
```typescript
export type ApplicationAccessPayload = {
id: string;
name: string;
email: string;
document: string;
employeeId: string;
application: string;
origin: string;
};
class GrantApplicationAccess extends Event {
public static owner = "AUTH";
public static aggregateRoot = "Application";
public static routingKey = "document";
}
```
The copies in cna-auth and cna-one are identical.
## Raw Schema:schema.json
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "GrantApplicationAccess payload",
"type": "object",
"additionalProperties": false,
"properties": {
"id": {
"type": "string",
"description": "Id of the person in the publishing application (cna-one Person.id). Stored on the account as its person id."
},
"name": {
"type": "string",
"description": "Full name of the person (name and surname joined)."
},
"email": {
"type": "string",
"description": "E-mail the login is provisioned on. Taken from the invite, not from what the publisher stores."
},
"document": {
"type": "string",
"description": "Person document (CPF). Kafka message key and the field both systems correlate the person by."
},
"employeeId": {
"type": "string",
"description": "CNA One employee id the access is keyed by. Stored on the account."
},
"application": {
"type": "string",
"description": "Identifier of the application in cna-auth (applications.identifier), e.g. cna-one."
},
"origin": {
"type": "string",
"description": "System requesting the access: ONE, NEXUS or PLACEMENT. Must be one of the application's allowed origins; compared uppercase."
}
},
"required": [
"id",
"name",
"email",
"document",
"employeeId",
"application",
"origin"
]
}
---
id: InviteUser
name: Invite User
version: 1.0.0
summary: >-
Invites a person into the CNA platform. Published by cna-nexus and
cna-placement when a user is created or re-invited; handled by cna-one, which
creates the person and employee and then requests application access from
cna-auth.
owners:
- cna-platform
schemaPath: schema.json
badges:
- content: "Topic: User"
backgroundColor: purple
textColor: purple
icon: kafka
- content: "Key: document"
backgroundColor: gray
textColor: gray
- content: Key drift
backgroundColor: yellow
textColor: yellow
x-contract-owner: AUTH
x-kafka-topic: User
x-message-key: document
x-source: cna-auth api/app/events/auth/user/InviteUser.ts
x-drift: >-
Four copies disagree. cna-placement keys on email and sends { email, name,
employeeId } without document or application; cna-one declares owner ONE;
cna-auth defines the class but never produces or consumes it.
---
## Overview
Intent to invite a person into the platform so that they can sign in to an application. It is a command owned by [[domain|Auth]], but the application that handles it is [[service|cna-one]]: it mirrors the invited person, gives them an employee record, and then asks [[service|cna-auth]] for access through [[command|GrantApplicationAccess]]. cna-auth itself never reads this topic.
**When it is published.**
- [[service|cna-nexus]], on two occasions: when a back-office user is created (`createUser`, inside `manager.afterCommit()`, so only after the user row and its notification preferences are committed) and when an existing user who never signed in is re-invited (`inviteUser`, which rejects users with a `lastSignInAt` or without a document). `application` is always the constant `cna-nexus`; `document` is the user's CPF, 11 digits, validated before publishing.
- [[service|cna-placement]], when an admin user is created or re-invited (`createUser` → `inviteUser`, rejecting users who already signed in or whose invite was revoked or used). Its copy of the class **does not follow this contract**: see Known drift.
**What the consumer does.** cna-one (`personConsumer`, handler `syncInviteUserToPerson`):
1. Normalises and validates `document` as a CPF. Anything else is rejected, which is how a placement invite ends: `document` is missing, the normalisation throws, the error is logged and sent to Sentry, and the message is dropped.
2. Looks the `Person` up by document. Not found: builds a new person from `name` (split into name and surname) and `email`.
3. If the person already has an `Employee`, writes nothing more. Otherwise, in one transaction, upserts the person and creates an employee with the invite `email`, active, with no franchise and no staff link. No `EmployeeCreated` is emitted for this employee: the access grant below is what provisions the login.
4. On every arrival, new or known person, publishes [[command|GrantApplicationAccess]] with the person, the employee, `application` from the payload and `origin` taken from the envelope of this message: today from `metadata.owner`, which the publishers hard-code to their own name (`NEXUS`, `PLACEMENT`, fallback `ONE`). Once the envelope carries `producedBy`, `owner` will be `AUTH` and cna-one must read the origin from `producedBy` (tracked in the drift list).
Failures are logged and reported to Sentry, never rethrown, so the partition never stalls and there is no retry.
## Kafka
| | |
| -------------------------- | ---------------------- |
| Topic (`aggregateRoot`) | `User` |
| Message key (`routingKey`) | `payload.document` |
| Contract owner | `AUTH` |
| `metadata.event` | `InviteUser` |
| Consumer group | `common-person-events` |
## Payload schema
## Source of truth
```typescript
export type InviteUserPayload = {
email: string;
name: string;
application: string;
document: string;
};
class InviteUser extends Event {
public static owner = "AUTH";
public static aggregateRoot = "User";
public static routingKey = "document";
}
```
## Known drift
| Copy | `owner` | `routingKey` | Payload | Note |
| -------------------- | ------- | ------------ | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| cna-auth (canonical) | `AUTH` | `document` | `{ email, name, application, document }` | Defined but never produced nor consumed here. |
| cna-nexus | `AUTH` | `document` | identical | Publisher. |
| cna-one | `ONE` | `document` | identical | Consumer. Wrong owner on the class; stored under `one/user/` instead of `auth/user/`. |
| cna-placement | `AUTH` | **`email`** | **`{ email, name, employeeId? }`** | Publisher. Different partition key; no `document` and no `application`, so cna-one drops the message with an error. |
The placement divergence is a live contract break: a placement-originated invite never becomes a person or an access grant. Tracked in the drift list for a code fix.
## Raw Schema:schema.json
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "InviteUser payload",
"type": "object",
"additionalProperties": false,
"properties": {
"email": {
"type": "string",
"description": "E-mail the person is invited on. Becomes the employee e-mail in cna-one and the login e-mail the access grant is provisioned on."
},
"name": {
"type": "string",
"description": "Full name. cna-one splits it into name (first word) and surname (the rest)."
},
"application": {
"type": "string",
"description": "Identifier of the application the person is invited to, as registered in cna-auth (applications.identifier). cna-nexus always sends cna-nexus."
},
"document": {
"type": "string",
"description": "Person document (CPF), 11 digits without mask. Kafka message key and the field cna-one correlates the person by."
}
},
"required": ["email", "name", "application", "document"]
}
---
id: RevokeApplicationAccess
name: Revoke Application Access
version: 1.0.0
summary: >-
Asks CNA Auth to take a person's access to an application away and drop the
sessions it holds. Handled by cna-auth; no application publishes it today.
owners:
- cna-platform
schemaPath: schema.json
badges:
- content: "Topic: Application"
backgroundColor: purple
textColor: purple
icon: kafka
- content: "Key: document"
backgroundColor: gray
textColor: gray
- content: No producer
backgroundColor: yellow
textColor: yellow
x-contract-owner: AUTH
x-kafka-topic: Application
x-message-key: document
x-source: cna-auth api/app/events/auth/application/RevokeApplication.ts
x-drift: >-
cna-one has a producer (revokeApplicationAccessProducer.ts) that nothing
calls, so no application publishes this command today. In cna-auth the class
lives in RevokeApplication.ts, a file name that differs from the class name.
---
## Overview
Intent to remove a person's access to an application. It is the counterpart of [[command|GrantApplicationAccess]] and carries the same payload.
**Who publishes it.** Nobody today. [[service|cna-one]] defines `revokeApplicationAccessProducer`, but no code path calls it. The command is documented because [[service|cna-auth]] handles it and because the producer is ready to be wired.
**What the consumer does.** cna-auth (`accountConsumer`, handler `revokeApplicationAccess`):
1. Looks the application up by `payload.application`. Unknown application: logged and dropped.
2. Checks `payload.origin` against the application's `allowedOrigins`. Origin not allowed: logged and dropped.
3. Looks the `Account` up by `document`. A revoke never provisions identity: unknown account is logged and dropped.
4. Removes the `ApplicationGrant` and kills the sessions the account had on that application. An inactive application keeps its grants, which still have to be revocable.
Every failure is a log and a return; the offset is committed either way.
## Kafka
| | |
| -------------------------- | ------------------------- |
| Topic (`aggregateRoot`) | `Application` |
| Message key (`routingKey`) | `payload.document` |
| Contract owner | `AUTH` |
| `metadata.event` | `RevokeApplicationAccess` |
| Consumer group | `auth-user-events` |
## Payload schema
Same payload type as `GrantApplicationAccess` (`ApplicationAccessPayload`).
## Source of truth
```typescript
import { ApplicationAccessPayload } from "events/auth/application/GrantApplicationAccess";
class RevokeApplicationAccess extends Event {
public static owner = "AUTH";
public static aggregateRoot = "Application";
public static routingKey = "document";
}
```
## Known drift
- **No producer.** cna-one `api/app/domains/franchise/employee/producers/revokeApplicationAccessProducer.ts` exists but is never called (only its unit tests reference it). Nothing publishes this command.
- **File name.** cna-auth stores the class in `RevokeApplication.ts`; the convention in `api/docs/EVENTS.md` is file name = class name. cna-one names it correctly.
## Raw Schema:schema.json
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "RevokeApplicationAccess payload",
"type": "object",
"additionalProperties": false,
"properties": {
"id": {
"type": "string",
"description": "Id of the person in the publishing application (cna-one Person.id). Stored on the account as its person id."
},
"name": {
"type": "string",
"description": "Full name of the person (name and surname joined)."
},
"email": {
"type": "string",
"description": "E-mail the login is provisioned on. Taken from the invite, not from what the publisher stores."
},
"document": {
"type": "string",
"description": "Person document (CPF). Kafka message key and the field both systems correlate the person by."
},
"employeeId": {
"type": "string",
"description": "CNA One employee id the access is keyed by. Stored on the account."
},
"application": {
"type": "string",
"description": "Identifier of the application in cna-auth (applications.identifier), e.g. cna-one."
},
"origin": {
"type": "string",
"description": "System requesting the access: ONE, NEXUS or PLACEMENT. Must be one of the application's allowed origins; compared uppercase."
}
},
"required": [
"id",
"name",
"email",
"document",
"employeeId",
"application",
"origin"
]
}
---
id: astro
name: CNA Astro
version: 1.0.0
summary: Learning management system (LMS) for students and teachers. Not on the Kafka bus yet; its producers and consumers will be documented as they are designed.
owners:
- cna-platform
repository:
language: TypeScript
url: https://github.com/cna-br/astro
x-status: not on Kafka yet
---
## Overview
astro is the CNA learning management system. It has no Kafka producers or consumers today. When it joins the bus, the expectation is that it adopts the shared event framework (`api/app/events/lib/`), declares its contracts with `static owner = 'ASTRO'` under `api/app/events/astro//`, and follows `docs/modelling/04-adding-a-message.md` for every message: channel per topic, entity per aggregate root, `sends` and `receives` wired here.
## Messages
## Kafka consumers
None yet.
---
id: cna-auth
name: CNA Auth
version: 1.0.0
summary: >-
Authentication and identity provider for the CNA platform (accounts, tokens,
OTP/2FA, per-application access grants). Consumes the Application and Employee
topics and publishes nothing.
owners:
- cna-platform
repository:
language: TypeScript
url: "https://github.com/cna-br/cna-auth"
receives:
- id: GrantApplicationAccess
from:
- id: kafka.Application
- id: RevokeApplicationAccess
from:
- id: kafka.Application
- id: EmployeeCreated
from:
- id: kafka.Employee
- id: EmployeeUpdated
from:
- id: kafka.Employee
entities:
- id: Application
- id: User
x-runtime: Express 5 + GraphQL Yoga + TypeORM/Postgres + kafkajs
x-events-path: api/app/events///.ts
x-drift: >-
Ships an unused copy of InviteUser.ts (owner AUTH) that is never produced nor
consumed here.
---
## Overview
cna-auth is the identity provider behind every CNA application. On the Kafka bus it is a **pure consumer**: it keeps accounts in sync with employees created and updated in [[service|cna-one]], and grants or revokes application access when asked to. It exposes a GraphQL API to the front ends and does not publish any message.
Consumers subscribe by topic (`aggregateRoot`) and dispatch on `metadata.event`. There is no schema registry: the TypeScript payload type in the event class is the contract.
## Messages
## Kafka consumers
Consumers run as a separate process from the API, started by `api/scripts/events/consume.ts`.
| Consumer group | Topics | Handler |
| ------------------ | ------------------------- | ------------------------------------------------------- |
| `auth-user-events` | `Employee`, `Application` | `api/app/domains/external/consumers/accountConsumer.ts` |
---
id: cna-nexus
name: CNA Nexus
version: 1.0.0
summary: >-
Franchise network back-office (franchises, economic groups, partners,
contracts and amendments, document storage, AI document analysis, pendencies).
Produces and consumes on the Franchise, Contract and AI topics; also publishes
InviteUser and PendencyResolved. PartnerUpdated on the Partner topic is a
target contract not emitted yet.
owners:
- cna-platform
repository:
language: TypeScript
url: 'https://github.com/cna-br/cna-nexus'
sends:
- id: InviteUser
to:
- id: kafka.User
- id: ContractActivationRequested
to:
- id: kafka.Contract
- id: AmendmentApplyRequested
to:
- id: kafka.Contract
- id: DocumentAnalysisRequested
to:
- id: kafka.AI
- id: PendencyResolved
to:
- id: kafka.Pendency
- id: FranchiseCreated
to:
- id: kafka.Franchise
- id: FranchiseUpdated
to:
- id: kafka.Franchise
- id: FranchiseAddressCreated
to:
- id: kafka.Franchise
- id: FranchiseAddressUpdated
to:
- id: kafka.Franchise
- id: EconomicGroupCreated
to:
- id: kafka.Franchise
- id: EconomicGroupUpdated
to:
- id: kafka.Franchise
- id: FranchisePartnerAdded
to:
- &ref_0
id: kafka.Franchise
- id: FranchisePartnerRemoved
to:
- &ref_1
id: kafka.Franchise
- id: FranchisePartnerUpdated
to:
- &ref_2
id: kafka.Franchise
- id: PartnerUpdated
to:
- id: kafka.Partner
receives:
- id: ContractActivationRequested
from:
- id: kafka.Contract
- id: AmendmentApplyRequested
from:
- id: kafka.Contract
- id: DocumentAnalysisRequested
from:
- id: kafka.AI
- id: FranchisePartnerAdded
from:
- *ref_0
- id: FranchisePartnerRemoved
from:
- *ref_1
- id: FranchisePartnerUpdated
from:
- *ref_2
entities:
- id: Contract
- id: ContractAmendment
- id: Pendency
- id: Franchise
- id: EconomicGroup
- id: FranchisePartner
- id: FranchiseAddress
- id: Partner
x-runtime: Express 5 + GraphQL Yoga + TypeORM/Postgres + kafkajs
x-events-path: api/app/events///.ts
---
## Overview
cna-nexus manages the franchise network for the franchisor: franchises and their economic groups and partners, contracts and amendments with their documents, AI-assisted document analysis, and pendencies. On the Kafka bus it is both producer and consumer. It publishes franchise and economic group changes for [[service|cna-one]] to mirror, invites users into the platform, and uses the `Contract` and `AI` topics as an internal work queue between its API and its consumer processes.
Producers are called through `manager.afterCommit()` so that a message is only published when the surrounding database transaction commits.
[[event|PartnerUpdated]] on [[channel|kafka.Partner]] is listed as sent but is a target: no class exists and `updatePerson` emits nothing yet (see the event page).
## Messages
## Kafka consumers
Consumers run as separate processes, one script per domain: `api/scripts/events/aiConsume.ts`, `contractConsume.ts` and `franchiseConsume.ts`.
| Consumer group | Topics | Handler |
| ------------------------------ | ----------- | -------------------------------------------------------------------------------------------------------- |
| `ai-document-analysis` | `AI` | `api/app/domains/ai/events/consumers/documentAnalysisConsumer.ts` |
| `contract-activation` | `Contract` | `api/app/domains/contract/contract/events/consumers/contractActivationConsumer.ts` |
| `contract-amendment-apply` | `Contract` | `api/app/domains/contract/amendment/events/consumers/amendmentApplyConsumer.ts` |
| `economic-group-partner-touch` | `Franchise` | `api/app/domains/franchise/economicGroup/events/consumers/touchEconomicGroupOnFranchisePartnerChange.ts` |
---
id: cna-one
name: CNA One
version: 1.0.0
summary: >-
Franchise operating system (franchises and employees, pricing, products and
learning books, school operations). Publishes access grants, employee,
learning book, price table and audit messages; consumes the User, Franchise,
LearningBook and PriceTable topics.
owners:
- cna-platform
repository:
language: TypeScript
url: 'https://github.com/cna-br/cna-one'
sends:
- id: GrantApplicationAccess
to:
- id: kafka.Application
- id: EmployeeCreated
to:
- id: kafka.Employee
- id: EmployeeUpdated
to:
- id: kafka.Employee
- id: LearningBookPublished
to:
- id: kafka.LearningBook
- id: LearningBookDrafted
to:
- id: kafka.LearningBook
- id: LearningBookDiscontinued
to:
- id: kafka.LearningBook
- id: PriceTableCreated
to:
- id: kafka.PriceTable
- id: EntityAudited
to:
- id: kafka.Audit
receives:
- id: InviteUser
from:
- id: kafka.User
- id: FranchiseCreated
version: 1.0.0 # cna-one's production copy; lifted when cna-br/cna-one#962 and the 1.2.0 alignment are in production
from:
- id: kafka.Franchise
- id: EconomicGroupCreated
from:
- id: kafka.Franchise
- id: EconomicGroupUpdated
from:
- id: kafka.Franchise
- id: FranchiseAddressCreated
from:
- id: kafka.Franchise
- id: FranchiseAddressUpdated
from:
- id: kafka.Franchise
- id: LearningBookPublished
from:
- id: kafka.LearningBook
- id: LearningBookDrafted
from:
- id: kafka.LearningBook
- id: LearningBookDiscontinued
from:
- id: kafka.LearningBook
- id: PriceTableCreated
from:
- id: kafka.PriceTable
- id: PartnerUpdated
from:
- id: kafka.Partner
entities:
- id: Employee
- id: LearningBook
- id: PriceTable
x-runtime: Express 5 + GraphQL Yoga + TypeORM/Postgres + kafkajs
x-events-path: api/app/events///.ts
---
## Overview
cna-one is the application franchises and the franchisor use to run the schools: franchise and employee management, pricing, products and learning books, classrooms, students and calendar. It serves three scopes (`franchise`, `franchisor`, `multi`) through one GraphQL API. On the Kafka bus it is the busiest participant: it mirrors data published by [[service|cna-nexus]], asks [[service|cna-auth]] to grant application access and to sync accounts, and uses the `LearningBook` and `PriceTable` topics as an internal work queue for pricing.
cna-one is the only application whose producer already reads `owner` from the event class, as the envelope contract requires, and the only one that guards against a missing routing key. Like the others it does not send `producedBy` yet.
## Messages
## Kafka consumers
Consumers run as separate processes: `api/scripts/consumers/commonConsumer.ts`, `franchiseConsumer.ts` and `pricingConsumer.ts`.
| Consumer group | Topics | Handler |
| ---------------------------- | -------------- | -------------------------------------------------------------------- | ----------------------------- |
| `common-person-events` | `User` | `api/app/domains/common/person/consumers/personConsumer.ts` |
| `franchise-franchise-events` | `Franchise` | `api/app/domains/franchise/franchise/consumers/franchiseConsumer.ts` |
| `pricing-price-events` | `LearningBook` | `api/app/domains/pricing/price/consumers/priceConsumer.ts` |
| `pricing-price-table-events` | `PriceTable` | `api/app/domains/pricing/priceTable/consumers/priceTableConsumer.ts` |
| (to be created) | `Partner` | target consumer of [[event | PartnerUpdated]], no code yet |
---
id: cna-placement
name: CNA Placement Test
version: 1.0.0
summary: >-
English placement test application (candidates, tests, attempts, admin users).
Publishes InviteUser and consumes nothing.
owners:
- cna-platform
repository:
language: TypeScript
url: "https://github.com/cna-br/cna-placement"
sends:
- id: InviteUser
to:
- id: kafka.User
x-runtime: Express 5 + GraphQL Yoga + TypeORM/Postgres + kafkajs
x-events-path: api/app/events///.ts
---
## Overview
cna-placement runs the English placement test taken by prospective students, and the admin area where staff manage tests and candidates. On the Kafka bus it is a **producer only**: when an admin user is invited, it publishes `InviteUser` so that [[service|cna-one]] creates the person and requests application access from [[service|cna-auth]]. It has no consumer processes.
## Messages
## Kafka consumers
None. The application does not subscribe to any topic.
---
id: Astro
name: Astro
version: 1.0.0
summary: Learning management system bounded context. Not on the Kafka bus yet - the next messages of the platform will be designed here. Owns the astro service.
owners:
- cna-platform
services:
- id: astro
x-contract-owner: ASTRO
x-status: not on Kafka yet
---
## Overview
Astro is the bounded context of the learning management system (LMS), where students and teachers meet the course content. It owns the [[service|astro]] service and will own the message contracts declared with `static owner = 'ASTRO'`. It is not related to the Astro web framework that EventCatalog itself is built on.
**Astro does not publish or consume Kafka messages yet.** It is the next application to join the bus, and its first contracts will be designed with the catalog first: which topics it owns, which messages it publishes, and which of the existing messages it consumes (candidates: [[command|InviteUser]], [[event|EmployeeCreated]], [[event|EmployeeUpdated]], [[command|GrantApplicationAccess]], [[event|LearningBookPublished]]). Until then this page and the service page exist so the domain stands alongside the other applications and the conventions in `docs/modelling/` apply from the first message.
## Messages flowing through this domain's services
---
id: Auth
name: Auth
version: 1.0.0
summary: Identity and access bounded context. Owns the AUTH message contracts (application access grants, user invitations) and the cna-auth service.
owners:
- cna-platform
services:
- id: cna-auth
entities:
- id: Application
- id: User
resourceGroups:
- id: auth-contracts
title: Contracts owned by AUTH
items:
- id: GrantApplicationAccess
type: command
- id: RevokeApplicationAccess
type: command
- id: InviteUser
type: command
x-contract-owner: AUTH
---
## Overview
Auth is the bounded context for identity and access across CNA applications: accounts, tokens, second factor, and which person may use which application. It owns the message contracts declared with `static owner = 'AUTH'` and the [[service|cna-auth]] service that implements them.
Owning a contract is not the same as producing it. The AUTH contracts are mostly **published by other applications** and handled by cna-auth: [[service|cna-one]] asks for application access to be granted or revoked, and [[service|cna-nexus]] and [[service|cna-placement]] invite users. Read the message pages for the exact producers and consumers.
## Contracts owned by this domain
Every message declared with `static owner = 'AUTH'`, whichever application publishes or handles it.
## Messages flowing through this domain's services
## Entities
## Outside the model
The `Subscription` Kafka topic used by cna-auth to fan out GraphQL subscriptions between pods is infrastructure, not a contract. It is intentionally not documented as a channel or message. See "Topics outside the model" in `docs/modelling/02-mapping.md`.
---
id: Nexus
name: Nexus
version: 1.0.0
summary: Franchise network back-office bounded context. Owns the NEXUS message contracts for franchises, economic groups, partners, contracts and amendments, AI document analysis and pendencies, and the cna-nexus service.
owners:
- cna-platform
services:
- id: cna-nexus
entities:
- id: Contract
- id: ContractAmendment
- id: Pendency
- id: Franchise
- id: EconomicGroup
- id: FranchisePartner
- id: FranchiseAddress
- id: Partner
resourceGroups:
- id: nexus-contracts
title: Contracts owned by NEXUS
items:
- id: ContractActivationRequested
type: event
- id: AmendmentApplyRequested
type: event
- id: DocumentAnalysisRequested
type: event
- id: PendencyResolved
type: event
- id: FranchiseCreated
type: event
- id: FranchiseUpdated
type: event
- id: FranchiseAddressCreated
type: event
- id: FranchiseAddressUpdated
type: event
- id: EconomicGroupCreated
type: event
- id: EconomicGroupUpdated
type: event
- id: FranchisePartnerAdded
type: event
- id: FranchisePartnerRemoved
type: event
- id: FranchisePartnerUpdated
type: event
- id: PartnerUpdated
type: event
x-contract-owner: NEXUS
---
## Overview
Nexus is the bounded context of the franchise network back-office: the franchises themselves, the economic groups that own them, their partners, the franchise contracts and amendments, the AI analysis of contract documents, and the pendencies raised along the way. It owns the contracts declared with `static owner = 'NEXUS'` and the [[service|cna-nexus]] service.
Most NEXUS messages are consumed inside Nexus itself, as asynchronous work between the API and its consumer processes. Franchise and economic group changes are also mirrored into [[service|cna-one]]; [[event|PartnerUpdated]], on the `Partner` topic, is the target contract for changes to a partner's personal data, not implemented yet. Read the message pages for the exact producers and consumers.
## Contracts owned by this domain
Every message declared with `static owner = 'NEXUS'`, whichever application publishes or handles it.
## Messages flowing through this domain's services
## Entities
## Outside the model
The `NexusSubscription` Kafka topic used by cna-nexus to fan out GraphQL subscriptions between pods is infrastructure, not a contract. It is intentionally not documented as a channel or message. See "Topics outside the model" in `docs/modelling/02-mapping.md`.
---
id: One
name: One
version: 1.0.0
summary: Franchise operating system bounded context. Owns the ONE message contracts for employees, learning books, price tables and audit, and the cna-one service.
owners:
- cna-platform
services:
- id: cna-one
entities:
- id: Employee
- id: LearningBook
- id: PriceTable
resourceGroups:
- id: one-contracts
title: Contracts owned by ONE
items:
- id: EmployeeCreated
type: event
- id: EmployeeUpdated
type: event
- id: LearningBookPublished
type: event
- id: LearningBookDrafted
type: event
- id: LearningBookDiscontinued
type: event
- id: PriceTableCreated
type: event
- id: EntityAudited
type: event
x-contract-owner: ONE
---
## Overview
One is the bounded context of the franchise operating system used day to day by franchises and by the franchisor: employees, pricing and price tables, products and learning books, and school operations. It owns the contracts declared with `static owner = 'ONE'` (and `'AUDIT'`, which is not an application) and the [[service|cna-one]] service.
One sits in the middle of the platform's flows. It mirrors franchises and economic groups published by [[service|cna-nexus]], turns user invitations into people and application access requests handled by [[service|cna-auth]], and publishes employee changes that cna-auth turns into accounts. Read the message pages for the exact producers and consumers.
## Contracts owned by this domain
Every message declared with `static owner = 'ONE'` (or `'AUDIT'`), whichever application publishes or handles it.
## Messages flowing through this domain's services
## Entities
## Outside the model
The `Subscription` Kafka topic used by cna-one to fan out GraphQL subscriptions between pods is infrastructure, not a contract. It is intentionally not documented as a channel or message. See "Topics outside the model" in `docs/modelling/02-mapping.md`.
---
id: Placement
name: Placement
version: 1.0.0
summary: English placement test bounded context. Owns the cna-placement service. Publishes the AUTH-owned InviteUser command and owns no message contracts of its own yet.
owners:
- cna-platform
services:
- id: cna-placement
x-contract-owner: PLACEMENT
---
## Overview
Placement is the bounded context of the English placement test: candidates, tests, sections and questions, test attempts and answers, plus an admin area. It owns the [[service|cna-placement]] service. No message class declares `static owner = 'PLACEMENT'` today, so this domain owns no contracts; it participates in the platform by inviting users through the AUTH-owned `InviteUser` command, handled by [[service|cna-one]].
## Messages flowing through this domain's services
## Outside the model
The `Subscription` Kafka topic used by cna-placement to fan out GraphQL subscriptions between pods is infrastructure, not a contract. It is intentionally not documented as a channel or message. See "Topics outside the model" in `docs/modelling/02-mapping.md`.
---
id: cna-platform
name: CNA Platform
summary: Owns the CNA applications (cna-auth, cna-nexus, cna-one, cna-placement, astro) and the shared Kafka event platform.
---
## Responsibilities
- Contract ownership for every domain, service, channel, entity and message in this catalog, until per-application teams are defined.
- The shared Kafka cluster and the event framework copied into each application (`api/app/events/lib/`).
- Keeping this catalog aligned with the code. Conventions live in `docs/modelling/`.
---
id: Application
name: Application
version: 1.0.0
summary: An application registered in CNA Auth (cna-one, cna-nexus, cna-placement, astro) that people sign in to. Aggregate root of the Application Kafka topic, which carries who may access it.
owners:
- cna-platform
aggregateRoot: true
identifier: identifier
properties:
- name: id
type: string
required: true
description: Internal id (UUID) in the cna-auth applications table.
- name: identifier
type: string
required: true
description: Unique identifier used by other systems to name the application, e.g. cna-one. This is the value the access commands carry in payload.application.
- name: name
type: string
required: true
description: Display name of the application.
- name: allowedOrigins
type: string[]
required: true
description: Systems allowed to grant or revoke access to this application (ONE, NEXUS, PLACEMENT). Compared uppercase against payload.origin; an empty or missing origin is rejected.
- name: isActive
type: boolean
required: true
description: Whether the application currently takes sign-ins. Not consulted when recording a grant; an inactive application keeps its grants.
- name: isDefault
type: boolean
required: true
description: Whether this is the default application, used by CNA Auth itself.
x-kafka-topic: Application
x-routing-key: document
---
## Overview
`Application` is what a person is granted access to. In cna-auth it is a row in `auth.applications`, and a person's access is an `ApplicationGrant` linking an `Account` (found by the person's document) to the application. The commands on the [[channel|kafka.Application]] topic create or remove those grants; they never change the application itself.
The topic is keyed by the person's `document`, not by the application: the aggregate the messages act on is the pair (person, application), and the person is the side the two systems correlate by. That is why `x-routing-key` here is `document` while the entity's own `identifier` is the application identifier.
---
id: Contract
name: Contract
version: 1.0.0
summary: A franchise contract in CNA Nexus - opening, renewal or resale - uploaded as a document, analysed by AI, reviewed by a person and activated, at which point it becomes the source of truth for a franchise unit. Aggregate root of the Contract Kafka topic.
owners:
- cna-platform
aggregateRoot: true
identifier: id
properties:
- name: id
type: string
required: true
description: Internal id (UUID) in contract.contracts. Kafka message key of the Contract topic.
- name: identifier
type: string
required: false
description: Human-readable identifier generated when the AI analysis completes.
- name: type
type: string
required: true
enum: [OPENING, RENEWAL, RESALE]
description: Kind of contract. Activating an OPENING with no franchise creates the franchise; RENEWAL and RESALE apply onto an existing one.
- name: status
type: string
required: true
enum:
[
PENDING,
PROCESSING_FAILED,
DRAFT,
ACTIVATING,
ACTIVATION_FAILED,
ACTIVE,
CANCELLED,
EXPIRED,
RENEWED,
DELETED,
INACTIVE,
]
description: Lifecycle. PENDING after upload; DRAFT after AI analysis; ACTIVATING when saved for activation; ACTIVE after activation; ACTIVATION_FAILED when the consumer fails; RENEWED when a renewal replaces it.
- name: franchiseId
type: string
required: false
description: The franchise unit this contract governs. Null until the first activation of an opening creates it.
- name: fileId
type: string
required: false
description: The uploaded document (storage.files) the AI analysis reads.
- name: createdById
type: string
required: true
description: User who uploaded the contract.
- name: currentAssigneeId
type: string
required: true
description: User currently responsible for the contract.
- name: signedAt
type: string
required: false
description: Signature date, extracted by the AI analysis or edited by the reviewer.
- name: activatedAt
type: string
required: false
description: When the contract was activated.
- name: startedAt
type: string
required: false
description: Start of the contract term.
- name: endedAt
type: string
required: false
description: End of the contract term.
x-kafka-topic: Contract
x-routing-key: contractId
---
## Overview
A contract enters Nexus as a document upload. It is analysed by AI (see the `AI` topic), lands in `DRAFT` for a person to review, and when saved goes to `ACTIVATING` and a [[event|ContractActivationRequested]] is published. The activation consumer turns it `ACTIVE` and projects it onto the franchise: creating the `Franchise` on the first activation of an opening, or overwriting the existing franchise aggregate for renewals, resales and re-activations. A renewal also flips the previous contract to `RENEWED`. Every later edit of an active contract re-runs the activation so the franchise never drifts from the contract.
Amendments (attachments, addenda, rescissions) are separate documents attached to the contract, see [[entity|ContractAmendment]].
---
id: ContractAmendment
name: Contract Amendment
version: 1.0.0
summary: A supplementary document attached to a contract - attachment, addendum or rescission - carrying one change per contract field it modifies. Applying it executes those changes on the contract and the franchise.
owners:
- cna-platform
aggregateRoot: false
identifier: id
properties:
- name: id
type: string
required: true
description: Internal id (UUID) in contract.contract_amendments. Carried as amendmentId in AmendmentApplyRequested.
- name: contractId
type: string
required: true
description: The contract this amendment modifies. Kafka message key of the Contract topic.
references: Contract
referencesIdentifier: id
relationType: amends
- name: type
type: string
required: true
enum: [ATTACHMENT, ADDENDUM, RESCISSION]
description: Kind of amendment. A RESCISSION terminates the contract when applied.
- name: status
type: string
required: true
enum:
[
PENDING,
PROCESSING_FAILED,
DRAFT,
APPLYING,
APPLY_FAILED,
APPLIED,
CANCELLED,
DELETED,
]
description: Lifecycle. PENDING after upload; DRAFT after AI analysis; APPLYING when the review is saved; APPLIED after the consumer applies the changes; APPLY_FAILED when it fails.
- name: uploadedById
type: string
required: true
description: User who uploaded the amendment.
- name: fileId
type: string
required: true
description: The uploaded document (storage.files).
- name: signedAt
type: string
required: false
description: Signature date, extracted by the AI analysis.
- name: changes
type: array
required: true
description: One row per contract field the amendment changes (LEGAL_NAME, NAME, CNPJ, CORPORATE_CHANGE, ADDITIONAL_CLAUSE, CATEGORY, ESTIMATED_OPENING_DATE, ADDRESS, TERRITORY, ENDED_AT, OTHER), each with its new value.
x-kafka-topic: Contract
x-routing-key: contractId
---
## Overview
An amendment is uploaded against an existing [[entity|Contract]] with the list of fields it changes. When it has analysable changes and is not a spreadsheet, it is sent to AI analysis (see the `AI` topic) to extract the new values, then reviewed by a person. Saving the review sets it to `APPLYING` and publishes [[event|AmendmentApplyRequested]]; the consumer applies each change to the contract and the franchise aggregate, terminates the contract for a rescission, marks the amendment `APPLIED`, and emits one `FranchiseUpdated`.
---
id: EconomicGroup
name: Economic Group
version: 1.0.0
summary: A grouping of franchise units under one economic owner, used for reporting and statistics. Optional on a franchise; soft-deleted rather than removed. Travels on the Franchise Kafka topic under its own key.
owners:
- cna-platform
aggregateRoot: false
identifier: id
properties:
- name: id
type: string
required: true
description: Internal id (UUID) in franchise.economic_groups. Kafka message key of the economic group events and the id cna-one mirrors the group under.
- name: name
type: string
required: true
description: Group name, unique among non-deleted groups.
- name: isActive
type: boolean
required: true
description: Whether the group is active. Set to false on soft delete.
- name: deletedAt
type: string
required: false
description: Soft-delete mark; null while the group exists. A deletion is published as EconomicGroupUpdated with isActive false and deletedAt set.
x-kafka-topic: Franchise
x-routing-key: id
---
## Overview
Economic groups aggregate several [[entity|Franchise]] units under the same owner. Nexus keeps counters per group (units, franchisees, active groups) in a Redis cache keyed by the latest `updatedAt` across groups, franchises and partners; the partner events on this topic exist mostly to bump that timestamp. There is no delete event: a soft delete is announced as [[event|EconomicGroupUpdated]] so that consumers see the group leave the active set.
---
id: Employee
name: Employee
version: 1.0.0
summary: A person working in the CNA network as seen by CNA One - a franchise employee, a partner or a franchisor user - linked to a Person and, through staff links, to franchises. Aggregate root of the Employee Kafka topic.
owners:
- cna-platform
aggregateRoot: true
identifier: id
properties:
- name: id
type: string
required: true
description: Internal id (UUID) in franchise.employees. Kafka message key of the Employee topic and the employeeId stored on cna-auth accounts and users.
- name: personId
type: string
required: true
description: The Person this employee is (common.persons), unique per employee. The person carries name, document and contact data.
- name: email
type: string
required: true
description: Work e-mail, unique across employees. Becomes the login e-mail in cna-auth.
- name: isActive
type: boolean
required: true
description: Whether the employee is active. cna-auth blocks the user when false.
- name: hasMultiFranchiseAccess
type: boolean
required: true
description: Whether the employee can act on more than one franchise.
- name: currentFranchiseId
type: string
required: false
description: Franchise currently selected by the employee, when any.
x-kafka-topic: Employee
x-routing-key: id
---
## Overview
An `Employee` is CNA One's view of someone who works in the network. It always points at a `Person` (name, surname, document, birthdate, contacts) and is attached to franchises through staff links and employment contracts. Employees are created by franchise managers, by the franchisor, when a `FranchiseCreated` from Nexus brings new partners, and when an `InviteUser` brings a new person into the platform.
The events on [[channel|kafka.Employee]] are how [[service|cna-auth]] learns about people: it creates the `Account` (by the person's document) and the `User` (by e-mail) and blocks or unblocks the user as `isActive` changes.
---
id: Franchise
name: Franchise
version: 1.0.0
summary: A franchise unit (school) of the CNA network as managed in Nexus - registration and tax data, lifecycle statuses, category, economic group, region, partners and products. Created by activating an opening contract. Aggregate root of the Franchise Kafka topic.
owners:
- cna-platform
aggregateRoot: true
identifier: id
properties:
- name: id
type: string
required: true
description: Internal id (UUID) in franchise.franchises. Kafka message key of the franchise events and the id cna-one mirrors the unit under.
- name: name
type: string
required: true
description: Unit name.
- name: code
type: string
required: false
description: Internal unit code, unique.
- name: cnpj
type: string
required: false
description: Company registration number, unique. Stored as typed; cna-one normalises and validates it when mirroring.
- name: legalName
type: string
required: false
description: Legal company name.
- name: categoryId
type: string
required: true
description: Size/type category of the unit (franchise.categories).
- name: economicGroupId
type: string
required: false
description: Economic group that owns the unit, when any.
references: EconomicGroup
referencesIdentifier: id
relationType: belongsTo
- name: operationalRegionId
type: string
required: false
description: Operational region the unit is managed in, when any.
- name: openingStatus
type: string
required: true
enum: [CREATED, BUILT, TRAINED, SUSPENDED, UNDER_REVIEW, DEFAULTING]
description: Lifecycle stage of the unit.
- name: operationalStatus
type: string
required: true
enum: [OPERATIONAL, NON_OPERATIONAL, UNDER_DEVELOPMENT]
description: Whether the unit operates. cna-one derives ACTIVE from OPERATIONAL.
- name: financialStatus
type: string
required: false
enum: [ACTIVE, INACTIVE, SUSPENDED, DEFAULTING, UNDER_REVIEW]
description: Financial standing.
- name: taxRegime
type: string
required: false
enum: [SIMPLES_NACIONAL, LUCRO_PRESUMIDO, LUCRO_REAL]
description: Tax regime.
- name: conectaEnabled
type: boolean
required: true
description: Whether the unit takes part in inter-school sharing (Conecta).
- name: operationalStartAt
type: string
required: false
description: When operations started.
- name: operationalEndAt
type: string
required: false
description: When operations ended.
x-kafka-topic: Franchise
x-routing-key: id
---
## Overview
A franchise is born when an opening [[entity|Contract]] is activated: the activation creates the unit with its brand, address, territory and partners, and links the contract to it. From then on it changes through contract amendments, renewals and resales (which re-apply the contract snapshot) and through the four franchise edit screens (legal, operational, financial, location and contact). Every change publishes a full snapshot as [[event|FranchiseUpdated]] on [[channel|kafka.Franchise]]. The unit's [[entity|FranchiseAddress]] also publishes its own [[event|FranchiseAddressCreated]] and [[event|FranchiseAddressUpdated]] whenever its row is inserted or changed.
The unit belongs to at most one [[entity|EconomicGroup]] and has people behind it as [[entity|FranchisePartner]]s. [[service|cna-one]] mirrors the unit, its address and its partners (as employees) when it receives [[event|FranchiseCreated]].
---
id: FranchiseAddress
name: Franchise Address
version: 1.0.0
summary: The address of a franchise unit - street, number, complement, neighborhood, postal code, IBGE city or foreign city and state, country and coordinates. One row per unit in common.addresses. Travels on the Franchise Kafka topic keyed by the franchise.
owners:
- cna-platform
aggregateRoot: false
identifier: id
properties:
- name: id
type: string
required: true
description: Internal id (UUID) in common.addresses. Not carried by the address events; cna-one keeps its own row id.
- name: franchiseId
type: string
required: true
description: The unit the address belongs to. Kafka message key of the address events and the id cna-one looks the unit up by.
references: Franchise
referencesIdentifier: id
relationType: addressOf
- name: street
type: string
required: false
description: Street.
- name: number
type: string
required: false
description: Number.
- name: complement
type: string
required: false
description: Complement.
- name: neighborhood
type: string
required: false
description: Neighborhood.
- name: postalCode
type: string
required: false
description: Postal code as typed. cna-one strips it to 8 digits.
- name: cityId
type: integer
required: false
description: IBGE city code (common.cities.id). Required for a Brazilian address, null outside Brazil.
- name: cityName
type: string
required: false
description: Name of the IBGE city, read from common.cities. Null outside Brazil.
- name: state
type: string
required: false
description: State code (UF) of the IBGE city, read from common.cities. Null outside Brazil.
- name: countryId
type: string
required: true
description: Country as an ISO 3166-1 alpha-2 code. Defaults to BR.
- name: foreignCity
type: string
required: false
description: Free-text city for an address outside Brazil. Null for a Brazilian address.
- name: foreignState
type: string
required: false
description: Free-text state or province for an address outside Brazil. Null for a Brazilian address.
- name: latitude
type: string
required: false
description: Latitude in WGS84 as decimal text, computed from the PostGIS location point. Null until the address is geocoded.
- name: longitude
type: string
required: false
description: Longitude in WGS84 as decimal text, computed from the PostGIS location point. Null until the address is geocoded.
x-kafka-topic: Franchise
x-routing-key: franchiseId
---
## Overview
A franchise unit has at most one address. In cna-nexus it is a row in `common.addresses`, a table shared with persons and contract franchises, linked to the unit by `franchise_id`; the one-per-unit rule is enforced by the application (`validateUniqueAddressForOwner`), not by a database constraint. The coordinates are stored as a PostGIS point and exposed as `latitude` and `longitude` text.
The row is inserted when the unit is created from an opening [[entity|Contract]] or through the `createAddress` mutation, and updated through the `updateAddress` mutation, when a contract is applied onto the unit, or when an `ADDRESS` [[entity|ContractAmendment]] is applied. Every insert publishes [[event|FranchiseAddressCreated]] and every update publishes [[event|FranchiseAddressUpdated]] on [[channel|kafka.Franchise]], keyed by the franchise so the address stays in order with the unit's other messages. The address also travels inside the [[event|FranchiseCreated]] and [[event|FranchiseUpdated]] snapshots as `payload.address`, without `franchiseId`.
[[service|cna-one]] mirrors the row into its own `common.addresses` (`streetAddress`, `number`, `district`, `additionalAddress`, an 8-digit `postalCode`, `cityId` and the `location` point). It requires a Brazilian address with a known IBGE city; the country and foreign fields are ignored.
---
id: FranchisePartner
name: Franchise Partner
version: 1.0.0
summary: The link between a franchise unit and a person behind it, with one or more roles - FRANCHISEE (leads the unit) or OPERATING_PARTNER (takes part in operating it). Travels on the Franchise Kafka topic keyed by the franchise.
owners:
- cna-platform
aggregateRoot: false
identifier: id
properties:
- name: id
type: string
required: true
description: Internal id (UUID) in franchise.franchise_partners.
- name: franchiseId
type: string
required: true
description: The unit the person is a partner of. Kafka message key of the partner events.
references: Franchise
referencesIdentifier: id
relationType: partnerOf
- name: personId
type: string
required: true
description: The person (common.persons) - name, document, contacts. Changes to the person's own data travel as PartnerUpdated on the Partner topic, keyed by this id.
references: Partner
referencesIdentifier: id
relationType: isPartner
- name: roles
type: string[]
required: true
description: One or more of FRANCHISEE and OPERATING_PARTNER. Roles are the only mutable field; changing them publishes FranchisePartnerUpdated.
x-kafka-topic: Franchise
x-routing-key: franchiseId
---
## Overview
Partners are replaced as a set whenever a franchise is created from a contract, its legal information is edited, or a contract is applied onto it: rows are removed, added and updated by diff, and one message per partner goes out ([[event|FranchisePartnerRemoved]], [[event|FranchisePartnerAdded]], [[event|FranchisePartnerUpdated]]) in the same commit. Rows are hard-deleted, which is why the partner events exist: without them the economic group statistics cache would never notice a removal.
Partners also travel inside the [[event|FranchiseCreated]] and [[event|FranchiseUpdated]] snapshots, without their roles. There each partner is identified by `id`, the person id (`personId` here), never by the id of the junction row; the partner events carry the same value in `person.id`. [[service|cna-one]] turns those into employees with the `FRANCHISEE` job role.
The link is not the person. A change to the person's own data (name, contacts, birth date) does not publish any partner event; it is the fact of [[entity|Partner]] and travels as [[event|PartnerUpdated]] on [[channel|kafka.Partner]] (target contract, not implemented yet).
---
id: LearningBook
name: Learning Book
version: 1.0.0
summary: An edition of a course book sold by the CNA network, printed or digital, with a publication lifecycle. Aggregate root of the LearningBook Kafka topic.
owners:
- cna-platform
aggregateRoot: true
identifier: id
properties:
- name: id
type: string
required: true
description: Internal id (UUID) in product.learning_books. Kafka message key.
- name: productId
type: string
required: true
description: The product (product.products, type LEARNING_BOOK) this edition belongs to. Prices are recorded per product and learning book.
- name: publisherId
type: string
required: true
description: The publisher (product.publishers).
- name: edition
type: number
required: true
description: Edition number, the previous edition of the product plus one.
- name: protheusId
type: string
required: true
description: Id in the Protheus ERP (4 characters).
- name: isbn
type: string
required: false
description: ISBN (13 characters). Required to publish a printed book.
- name: url
type: string
required: false
description: URL of the digital book, when any.
- name: imageUrl
type: string
required: false
description: Cover image URL, when any.
- name: isDigitalContentAvailable
type: boolean
required: true
description: Whether digital content is available for this edition.
- name: status
type: string
required: true
enum: [DRAFT, PUBLISHED, DISCONTINUED, UNDER_REVIEW]
description: Publication status. DRAFT on creation; PUBLISHED puts the book on sale; DISCONTINUED and DRAFT take it off; UNDER_REVIEW is an intermediate status with no event.
x-kafka-topic: LearningBook
x-routing-key: id
---
## Overview
A learning book is one edition of a course book, created by the franchisor as a `DRAFT` and moved through its lifecycle with `franchisorUpdateLearningBookStatus`, which records a publication history and publishes the matching event on [[channel|kafka.LearningBook]]. Publishing requires an ISBN for printed books and a Protheus id; moving from `PUBLISHED` back to `DRAFT` requires a reason.
Being on sale means having a price: the pricing consumer opens a pending price for the book in every current and future default price group when it is published, and soft-deletes those prices when it is drafted or discontinued.
---
id: Partner
name: Partner
version: 1.0.0
summary: A person (common.persons in Nexus) in their capacity as partner of one or more franchise units - the identity and contacts behind every FranchisePartner link. Aggregate root of the Partner Kafka topic, keyed by the person id. Exists on the wire only while the person holds at least one link.
owners:
- cna-platform
aggregateRoot: true
identifier: id
properties:
- name: id
type: string
required: true
description: Person id (UUID) in common.persons. Kafka message key of PartnerUpdated and the value the FranchisePartner events carry in person.id.
- name: document
type: string
required: true
description: Person document, formatted as stored; the key cna-one correlates the person by. Not editable in Nexus.
- name: documentType
type: string
required: true
enum: [CPF, CNPJ, RNM]
description: Document type (PersonsDocumentType).
- name: name
type: string
required: true
description: Person name.
- name: legalName
type: string
required: false
description: Legal name, for companies.
- name: email
type: string
required: false
description: E-mail, when any.
- name: phone
type: string
required: false
description: Phone, when any.
- name: birthDate
type: string
required: false
description: Birth date as a calendar day, YYYY-MM-DD, no time zone (a date column).
x-kafka-topic: Partner
x-routing-key: id
---
## Overview
A Partner is not a table of its own: it is a row of `common.persons` seen through the [[entity|FranchisePartner]] links that point at it. The links are replaced as a set whenever a franchise is created from a contract, its legal information is edited or a contract is applied onto it, and each change publishes one of the partner events on [[channel|kafka.Franchise]], keyed by the unit. The person's own data (name, legal name, e-mail, phone, birth date) changes through the `updatePerson` mutation, independently of any unit, and until now no message carried that change.
[[event|PartnerUpdated]] on [[channel|kafka.Partner]] closes that gap: it is the snapshot of the person, keyed by the person id, published when the person is edited and holds at least one link. A person with no link is not a Partner and publishes nothing. The situation of the person (`situation`, `situationChangedAt`) is not part of the aggregate.
[[service|cna-one]] mirrors partners as employees with the `FRANCHISEE` job role and correlates them by `document`, the only key both systems share.
---
id: Pendency
name: Pendency
version: 1.0.0
summary: A task in CNA Nexus that someone has to resolve, created by any process and addressed to a person, a department or a minimum role, with an optional deadline and an optional link to the business item it is about. Aggregate root of the Pendency Kafka topic.
owners:
- cna-platform
aggregateRoot: true
identifier: id
properties:
- name: id
type: string
required: true
description: Internal id (UUID) in pendency.pendencies. Kafka message key.
- name: title
type: string
required: true
description: Short title shown to the responsible.
- name: description
type: string
required: false
description: Longer description.
- name: status
type: string
required: true
enum: [OPEN, DONE, DECLINED, CANCELLED]
description: OPEN until resolved. DONE or DECLINED when the responsible resolves it (PendencyResolved is published). CANCELLED is a closure by the creating process, without event.
- name: responsibleId
type: string
required: false
description: User responsible for resolving it. At least one of responsibleId, department or minRole is set.
- name: department
type: string
required: false
enum: [OPERATIONS, JURIDICAL, IMPLEMENTATION, FINANCIAL, EXPANSION]
description: Department addressed, when not a specific user.
- name: minRole
type: number
required: false
description: Minimum role addressed (1 SUPER_ADMIN to 5 EMPLOYEE), when not a specific user.
- name: assignedById
type: string
required: false
description: User who assigned it.
- name: entity
type: string
required: false
description: Name of the business item the pendency is about, with entityId. Loose reference, no foreign key.
- name: entityId
type: string
required: false
description: Id of the business item the pendency is about.
- name: dueAt
type: string
required: false
description: Informative deadline.
- name: resolvedAt
type: string
required: false
description: When it left OPEN.
- name: note
type: string
required: false
description: Comment left by the responsible when resolving. Required when declining.
x-kafka-topic: Pendency
x-routing-key: id
---
## Overview
A pendency is a generic to-do owned by no domain: any process can create one, together with its in-app notification, and address it to a user, a department or a minimum role. The responsible user resolves it from the UI with the outcome `DONE` or `DECLINED` (with a note), which stamps `resolvedAt` and publishes [[event|PendencyResolved]]. A `CANCELLED` closure comes from the creating process and publishes nothing.
Today nothing outside the pendency domain creates pendencies, and nothing consumes the event: the domain is infrastructure waiting for its first producers and consumers.
---
id: PriceTable
name: Price Table
version: 1.0.0
summary: A yearly price list for one product type, valid between two dates, holding price groups (B2B and B2C by default) with one price per product and learning book. Aggregate root of the PriceTable Kafka topic.
owners:
- cna-platform
aggregateRoot: true
identifier: id
properties:
- name: id
type: string
required: true
description: Internal id (UUID) in pricing.price_tables. Kafka message key.
- name: type
type: string
required: true
enum: [COURSE, CERTIFICATION, LEARNING_BOOK, PRODUCT]
description: Product type the table prices. Only LEARNING_BOOK tables are created today.
- name: year
type: number
required: true
description: Calendar year the table applies to; unique together with type. The previous table's year plus one, or the year of startsOn for the first table.
- name: name
type: string
required: true
description: Display name given by the franchisor.
- name: startsOn
type: string
required: true
description: First day of validity (YYYY-MM-DD). Must not overlap the previous table.
- name: endsOn
type: string
required: true
description: Last day of validity (YYYY-MM-DD). Validity lasts between 3 and 18 months.
x-kafka-topic: PriceTable
x-routing-key: id
---
## Overview
The franchisor creates one price table per year and product type. A table is created together with its two default price groups, B2B and B2C, and a price row per group and learning book is opened when the table is created (seeded from the previous year's table) and whenever a book is published later. Tables are `CURRENT` or `FUTURE` relative to today, and only those receive price changes from the learning book events.
---
id: User
name: User
version: 1.0.0
summary: A login identity in CNA Auth, one per e-mail inside an Account. The Account is the person, found by document (CPF); the User is how that person signs in. Aggregate root of the User Kafka topic.
owners:
- cna-platform
aggregateRoot: true
identifier: id
properties:
- name: id
type: string
required: true
description: Internal id (UUID) in auth.users.
- name: accountId
type: string
required: true
description: The Account this user belongs to. An Account is the person, unique by document (CPF), with its employeeId and personId from CNA One.
- name: email
type: string
required: true
description: Login e-mail, unique across users.
- name: name
type: string
required: true
description: Display name.
- name: employeeId
type: string
required: false
description: CNA One employee id, unique across users. Set when the user was provisioned from an employee or an invite.
- name: blockedAt
type: string
required: false
description: When the user was blocked; null while active. Set from EmployeeCreated/EmployeeUpdated when the employee is inactive.
- name: lastSignInAt
type: string
required: false
description: Last successful sign-in. Null means the user never signed in, which is what allows an invite to be re-sent.
x-kafka-topic: User
x-routing-key: document
---
## Overview
In cna-auth a person is an `Account`, unique by `document` (CPF) and linked to CNA One by `employeeId` and `personId`. A `User` is a login identity inside that account, unique by `email`, with its own block state and sign-in history. `InviteUser` is the command that brings a new person into the platform; it is keyed by the account's `document`, which is why `x-routing-key` here is `document` while the entity's own key is its `id`.
The User topic is unusual: its owner, [[domain|Auth]], does not handle the invite. [[service|cna-one]] does, creating the person and the employee and then asking cna-auth for access through [[command|GrantApplicationAccess]] on the [[channel|kafka.Application]] topic. Accounts and users are created or updated by cna-auth when it handles that grant and the `EmployeeCreated` / `EmployeeUpdated` events.
---
id: kafka.AI
name: AI (Kafka topic)
version: 1.0.0
summary: Kafka topic for the AI document analysis queue of cna-nexus. Carries DocumentAnalysisRequested, published when a contract or amendment file is uploaded and consumed by the analysis worker, keyed by the analysis id.
owners:
- cna-platform
address: AI
protocols:
- kafka
deliveryGuarantee: at-least-once
badges:
- content: Kafka
backgroundColor: purple
textColor: purple
icon: kafka
x-message-key: analysisId
x-aggregate-root: AI
---
## Overview
`AI` is the verbatim `static aggregateRoot` of the [[event|DocumentAnalysisRequested]] class owned by [[domain|Nexus]]. It is a process topic, not a business aggregate, so it has no entity in the catalog; the record it drives is the `AiAnalysis` row (`ai.analyses`) with its lifecycle `PENDING`, `PROCESSING`, `COMPLETED`, `FAILED`.
This is an **internal work queue of [[service|cna-nexus]]**: the API publishes when a contract or amendment document needs to be read by the AI, and the worker process started by `api/scripts/events/aiConsume.ts` (`ai-document-analysis`, `fromBeginning: true`) does the extraction. No other application reads it, and handling a message publishes no further Kafka message: the results go to the database and to in-app notifications.
## Message key
Messages are keyed on `payload.analysisId`, the `AiAnalysis` id (`static routingKey = 'analysisId'`). Every upload or reprocess creates a new analysis row, so a key is only ever seen once.
## Envelope
```json
{
"payload": { "analysisId": "…", "type": "CONTRACT" },
"metadata": {
"event": "DocumentAnalysisRequested",
"producedAt": "2026-09-18T12:00:00.000Z",
"owner": "NEXUS",
"producedBy": "NEXUS"
}
}
```
`owner` is the contract owner from the class; `producedBy` is the application that emitted the message (see The envelope in `docs/modelling/02-mapping.md`). **Today.** cna-nexus hard-codes `owner` to `NEXUS`, which coincides with the contract owner on this topic; `producedBy` is not sent yet.
---
id: kafka.Application
name: Application (Kafka topic)
version: 1.0.0
summary: Kafka topic for the Application aggregate. Carries the AUTH commands that grant or revoke a person's access to an application, keyed by the person's document (CPF).
owners:
- cna-platform
address: Application
protocols:
- kafka
deliveryGuarantee: at-least-once
badges:
- content: Kafka
backgroundColor: purple
textColor: purple
icon: kafka
x-message-key: document
x-aggregate-root: Application
---
## Overview
One shared Kafka cluster, one topic per aggregate root. `Application` is the verbatim `static aggregateRoot` of the access-grant command classes owned by [[domain|Auth]]. [[service|cna-one]] publishes to it and [[service|cna-auth]] consumes it through the `auth-user-events` consumer group. The consumer creates the topic on start (1 partition, replication factor 1) and dispatches on `metadata.event`, so `GrantApplicationAccess` and `RevokeApplicationAccess` share the topic and are told apart by the class name.
## Message key
Messages are keyed on `payload.document` (`static routingKey = 'document'`), the person's CPF. It is the only field cna-one and cna-auth correlate a person by, so every access change for the same person keeps its order. The key does not identify the application: the same topic carries grants for any application, and the target application travels in `payload.application`.
## Envelope
Every message on every CNA topic uses the same envelope. `schema.json` on each message page describes `payload` only.
```json
{
"payload": { "...": "see the schema on each message page" },
"metadata": {
"event": "GrantApplicationAccess",
"producedAt": "2026-09-18T12:00:00.000Z",
"owner": "AUTH",
"producedBy": "ONE"
}
}
```
`owner` is the contract owner from the class; `producedBy` is the application that emitted the message (see The envelope in `docs/modelling/02-mapping.md`). **Today.** cna-one, the only publisher, already sets `owner` from the class but does not send `producedBy` yet.
---
id: kafka.Audit
name: Audit (Kafka topic)
version: 1.0.0
summary: Kafka topic for the audit trail of CNA One. Carries EntityAudited, one message per audited insert, update or delete, keyed by the audited row id. No consumer today, and no entity is audited yet.
owners:
- cna-platform
address: Audit
protocols:
- kafka
deliveryGuarantee: at-most-once
badges:
- content: Kafka
backgroundColor: purple
textColor: purple
icon: kafka
- content: No consumer
backgroundColor: yellow
textColor: yellow
x-message-key: entityId
x-aggregate-root: Audit
x-drift: No entity in cna-one carries the @Auditable decorator, so nothing is published on this topic today; and no application consumes it.
---
## Overview
`Audit` is the verbatim `static aggregateRoot` of the [[event|EntityAudited]] class. It is a process topic, not a business aggregate, so it has no entity in the catalog. Its owner constant is `AUDIT`, the only owner value that is not an application; the class lives in [[service|cna-one]], so the catalog files it under [[domain|One]].
The topic is meant to carry an audit trail of database changes in cna-one to an external sink. Two facts about its current state:
- **Nothing consumes it.** No consumer group subscribes to `Audit` in any of the four applications.
- **Nothing is published on it today.** The TypeORM subscriber that publishes only acts on entities marked `@Auditable()`, and no entity in cna-one carries the decorator.
## Message key
Messages are keyed on `payload.entityId`, the id of the audited row (`static routingKey = 'entityId'`), so the history of one row stays in order.
## Delivery
Unlike the other topics, publication here is fire-and-forget: the subscriber calls the producer from inside the write transaction without awaiting it, and cna-one's producer swallows send errors. A failed publish is lost silently, hence `at-most-once`.
## Envelope
```json
{
"payload": { "...": "see the schema on the message page" },
"metadata": {
"event": "EntityAudited",
"producedAt": "2026-09-18T12:00:00.000Z",
"owner": "AUDIT",
"producedBy": "ONE"
}
}
```
`owner` is the contract owner from the class; `producedBy` is the application that emitted the message (see The envelope in `docs/modelling/02-mapping.md`). **Today.** cna-one already sets `owner` from the class (`AUDIT`, the only owner that is not an application); `producedBy` is not sent yet.
---
id: kafka.Contract
name: Contract (Kafka topic)
version: 1.0.0
summary: Kafka topic for the Contract aggregate. Carries the asynchronous work requests of cna-nexus on franchise contracts - activation and amendment application - keyed by the contract id so both stay in order per contract.
owners:
- cna-platform
address: Contract
protocols:
- kafka
deliveryGuarantee: at-least-once
badges:
- content: Kafka
backgroundColor: purple
textColor: purple
icon: kafka
x-message-key: contractId
x-aggregate-root: Contract
---
## Overview
`Contract` is the verbatim `static aggregateRoot` of two event classes owned by [[domain|Nexus]]: `ContractActivationRequested` and `AmendmentApplyRequested`. This is an **internal work queue of [[service|cna-nexus]]**: the API publishes when a user saves a contract or finishes reviewing an amendment, and two consumer processes started by `api/scripts/events/contractConsume.ts` do the heavy work (`contract-activation` and `contract-amendment-apply`, both `fromBeginning: true`). No other application reads it.
## Message key
Both messages are keyed on `payload.contractId` (`static routingKey = 'contractId'`), so an activation and the amendments of the same contract land on the same partition and are processed in order.
## Envelope
```json
{
"payload": { "...": "see the schema on each message page" },
"metadata": {
"event": "ContractActivationRequested",
"producedAt": "2026-09-18T12:00:00.000Z",
"owner": "NEXUS",
"producedBy": "NEXUS"
}
}
```
`owner` is the contract owner from the class; `producedBy` is the application that emitted the message (see The envelope in `docs/modelling/02-mapping.md`). **Today.** cna-nexus hard-codes `owner` to `NEXUS`, which coincides with the contract owner on this topic; `producedBy` is not sent yet.
## Follow-up messages
Handling these messages publishes on the `Franchise` topic: activating a contract emits `FranchiseCreated` (first activation of an opening) or `FranchiseUpdated`; applying an amendment emits one `FranchiseUpdated`. Those are registered with `manager.afterCommit()`, unlike the requests on this topic, which are published after the write without `afterCommit`.
---
id: kafka.Employee
name: Employee (Kafka topic)
version: 1.0.0
summary: Kafka topic for the Employee aggregate. Carries EmployeeCreated and EmployeeUpdated, published by cna-one and consumed by cna-auth to keep accounts and users in sync, keyed by the employee id.
owners:
- cna-platform
address: Employee
protocols:
- kafka
deliveryGuarantee: at-least-once
badges:
- content: Kafka
backgroundColor: purple
textColor: purple
icon: kafka
x-message-key: id
x-aggregate-root: Employee
---
## Overview
`Employee` is the verbatim `static aggregateRoot` of the employee event classes owned by [[domain|One]]. [[service|cna-one]] publishes to it whenever an employee is created or changed, and [[service|cna-auth]] consumes it through the `auth-user-events` consumer group (`fromBeginning: false`) to create or update the person's account and login. The two events share the topic and are told apart by `metadata.event`.
## Message key
Messages are keyed on `payload.id`, the employee id (`static routingKey = 'id'`), so the creation and every later update of one employee stay in order.
## Envelope
Every message on every CNA topic uses the same envelope. `schema.json` on each message page describes `payload` only.
```json
{
"payload": { "...": "see the schema on each message page" },
"metadata": {
"event": "EmployeeCreated",
"producedAt": "2026-09-18T12:00:00.000Z",
"owner": "ONE",
"producedBy": "ONE"
}
}
```
`owner` is the contract owner from the class; `producedBy` is the application that emitted the message (see The envelope in `docs/modelling/02-mapping.md`). **Today.** cna-one already sets `owner` from the class; `producedBy` is not sent yet.
---
id: kafka.Franchise
name: Franchise (Kafka topic)
version: 1.0.0
summary: Kafka topic for the Franchise aggregate. Carries the nine NEXUS events about franchise units, their addresses, economic groups and partners, published by cna-nexus and consumed by cna-one (to mirror the network) and by cna-nexus itself, keyed by the franchise or economic group id.
owners:
- cna-platform
address: Franchise
protocols:
- kafka
deliveryGuarantee: at-least-once
badges:
- content: Kafka
backgroundColor: purple
textColor: purple
icon: kafka
x-message-key: id / franchiseId
x-aggregate-root: Franchise
---
## Overview
`Franchise` is the verbatim `static aggregateRoot` of nine event classes owned by [[domain|Nexus]]. It is the busiest topic of the platform and the one that crosses applications the most: [[service|cna-nexus]] publishes everything that changes in the franchise network, [[service|cna-one]] mirrors franchises, their addresses and economic groups into its own database (`franchise-franchise-events`, `fromBeginning: true`), and cna-nexus listens to its own partner events to invalidate a statistics cache (`economic-group-partner-touch`, `fromBeginning: true`).
| Event | Key | Consumed by |
| ------- | ------------------------- | --------------------- | --------- |
| [[event | FranchiseCreated]] | `id` (franchise) | cna-one |
| [[event | FranchiseUpdated]] | `id` (franchise) | nobody |
| [[event | FranchiseAddressCreated]] | `franchiseId` | cna-one |
| [[event | FranchiseAddressUpdated]] | `franchiseId` | cna-one |
| [[event | EconomicGroupCreated]] | `id` (economic group) | cna-one |
| [[event | EconomicGroupUpdated]] | `id` (economic group) | cna-one |
| [[event | FranchisePartnerAdded]] | `franchiseId` | cna-nexus |
| [[event | FranchisePartnerRemoved]] | `franchiseId` | cna-nexus |
| [[event | FranchisePartnerUpdated]] | `franchiseId` | cna-nexus |
Events the consumer has no class for are skipped with a debug log: cna-one drops `FranchiseUpdated` and the three partner events.
## Message key
Franchise, address and partner events are keyed by the franchise id (`id` or `franchiseId`), so everything about one unit stays in order. Economic group events are keyed by the economic group id, a different key space on the same topic.
## Envelope
```json
{
"payload": { "...": "see the schema on each message page" },
"metadata": {
"event": "FranchiseCreated",
"producedAt": "2026-09-18T12:00:00.000Z",
"owner": "NEXUS",
"producedBy": "NEXUS"
}
}
```
`owner` is the contract owner from the class; `producedBy` is the application that emitted the message (see The envelope in `docs/modelling/02-mapping.md`). **Today.** cna-nexus hard-codes `owner` to `NEXUS`, which coincides with the contract owner on this topic; `producedBy` is not sent yet.
Franchise, address and partner events are registered with `manager.afterCommit()`, so they only go out after the transaction commits, and `FranchiseCreated` / `FranchiseUpdated` re-read the aggregate after commit: they are full snapshots, not deltas. Economic group events are published right after the write, without `afterCommit`.
---
id: kafka.LearningBook
name: LearningBook (Kafka topic)
version: 1.0.0
summary: Kafka topic for the LearningBook aggregate. Carries the learning book status events that cna-one publishes and consumes itself to keep price tables in sync, keyed by the learning book id.
owners:
- cna-platform
address: LearningBook
protocols:
- kafka
deliveryGuarantee: at-least-once
badges:
- content: Kafka
backgroundColor: purple
textColor: purple
icon: kafka
x-message-key: id
x-aggregate-root: LearningBook
---
## Overview
`LearningBook` is the verbatim `static aggregateRoot` of the learning book event classes owned by [[domain|One]]. This is an **internal work queue of [[service|cna-one]]**: the API publishes when a learning book changes status, and the pricing consumer process (`pricing-price-events`, `fromBeginning: true`) reacts by opening or closing its prices in the current and future price tables. No other application reads it.
Three events share the topic and are told apart by `metadata.event`: `LearningBookPublished`, `LearningBookDrafted`, `LearningBookDiscontinued`. The status `UNDER_REVIEW` emits nothing.
## Message key
Messages are keyed on `payload.id`, the learning book id (`static routingKey = 'id'`), so status changes of one book stay in order.
## Envelope
```json
{
"payload": { "...": "see the schema on each message page" },
"metadata": {
"event": "LearningBookPublished",
"producedAt": "2026-09-18T12:00:00.000Z",
"owner": "ONE",
"producedBy": "ONE"
}
}
```
`owner` is the contract owner from the class; `producedBy` is the application that emitted the message (see The envelope in `docs/modelling/02-mapping.md`). **Today.** cna-one already sets `owner` from the class; `producedBy` is not sent yet.
---
id: kafka.Partner
name: Partner (Kafka topic)
version: 1.0.0
summary: Kafka topic for the Partner aggregate - a person in their capacity as partner of one or more franchise units. Carries PartnerUpdated, owned by NEXUS, to be published by cna-nexus when a partner's personal data changes and consumed by cna-one to refresh its mirror. Keyed by the person id. Nothing flows on it yet.
owners:
- cna-platform
address: Partner
protocols:
- kafka
deliveryGuarantee: at-least-once
badges:
- content: Kafka
backgroundColor: purple
textColor: purple
icon: kafka
- content: No producer
backgroundColor: yellow
textColor: yellow
- content: No consumer
backgroundColor: yellow
textColor: yellow
x-message-key: id
x-aggregate-root: Partner
x-drift: >-
Target topic, defined on 2026-09-24. No class exists in cna-nexus or cna-one
yet; the producer and the consumer are tasks of the two teams.
---
## Overview
`Partner` is the `static aggregateRoot` of [[event|PartnerUpdated]], owned by [[domain|Nexus]]. It separates two facts that the [[channel|kafka.Franchise]] topic cannot tell apart: the **link** between a person and a unit (`FranchisePartnerAdded`, `FranchisePartnerUpdated`, `FranchisePartnerRemoved`, keyed by the franchise) and the **person** behind those links, whose name, contacts and birth date change on their own in `common.persons`. See [[entity|Partner]].
| Event | Key | Consumed by |
| ------- | ---------------- | ------------- | ------------------------------- |
| [[event | PartnerUpdated]] | `id` (person) | cna-one (target, not yet built) |
**Today.** Neither application has the class. cna-nexus does not publish it and cna-one has no consumer on this topic; the tasks that create both were opened on 2026-09-24.
## Message key
Messages are keyed by the person id (`static routingKey = 'id'`), so every change about the same person lands on the same partition and keeps its order, whatever the number of units they are a partner of.
## Envelope
```json
{
"payload": { "...": "see the schema on the message page" },
"metadata": {
"event": "PartnerUpdated",
"producedAt": "2026-09-24T12:00:00.000Z",
"owner": "NEXUS",
"producedBy": "NEXUS",
"version": "1.0.0"
}
}
```
`owner` is the contract owner from the class; `producedBy` is the application that emitted the message; `version` is the class `static version` (see The envelope in `docs/modelling/02-mapping.md`). cna-nexus sends all three since cna-br/cna-nexus#562.
---
id: kafka.Pendency
name: Pendency (Kafka topic)
version: 1.0.0
summary: Kafka topic for the Pendency aggregate. Carries PendencyResolved, published by cna-nexus when a user closes a pendency, keyed by the pendency id. No consumer today.
owners:
- cna-platform
address: Pendency
protocols:
- kafka
deliveryGuarantee: at-least-once
badges:
- content: Kafka
backgroundColor: purple
textColor: purple
icon: kafka
- content: No consumer
backgroundColor: yellow
textColor: yellow
x-message-key: id
x-aggregate-root: Pendency
x-drift: Published by cna-nexus with no subscriber in any application.
---
## Overview
`Pendency` is the verbatim `static aggregateRoot` of the [[event|PendencyResolved]] class owned by [[domain|Nexus]]. [[service|cna-nexus]] publishes to it when the responsible user resolves a pendency. **No consumer group subscribes to it** in any of the applications: the event is fire-and-forget by design, meant for whichever process created the pendency to react to its closure.
## Message key
Messages are keyed on `payload.id`, the pendency id (`static routingKey = 'id'`).
## Envelope
```json
{
"payload": { "id": "…" },
"metadata": {
"event": "PendencyResolved",
"producedAt": "2026-09-18T12:00:00.000Z",
"owner": "NEXUS",
"producedBy": "NEXUS"
}
}
```
`owner` is the contract owner from the class; `producedBy` is the application that emitted the message (see The envelope in `docs/modelling/02-mapping.md`). **Today.** cna-nexus hard-codes `owner` to `NEXUS`, which coincides with the contract owner on this topic; `producedBy` is not sent yet.
---
id: kafka.PriceTable
name: PriceTable (Kafka topic)
version: 1.0.0
summary: Kafka topic for the PriceTable aggregate. Carries PriceTableCreated, which cna-one publishes and consumes itself to seed the learning book prices of a new table, keyed by the price table id.
owners:
- cna-platform
address: PriceTable
protocols:
- kafka
deliveryGuarantee: at-least-once
badges:
- content: Kafka
backgroundColor: purple
textColor: purple
icon: kafka
x-message-key: id
x-aggregate-root: PriceTable
---
## Overview
`PriceTable` is the verbatim `static aggregateRoot` of the [[event|PriceTableCreated]] class owned by [[domain|One]]. Like `LearningBook`, this is an **internal work queue of [[service|cna-one]]**: the API publishes when the franchisor creates a price table, and the pricing consumer process (`pricing-price-table-events`, `fromBeginning: true`) seeds the table with one price per available learning book. No other application reads it.
## Message key
Messages are keyed on `payload.id`, the price table id (`static routingKey = 'id'`).
## Envelope
```json
{
"payload": { "...": "see the schema on the message page" },
"metadata": {
"event": "PriceTableCreated",
"producedAt": "2026-09-18T12:00:00.000Z",
"owner": "ONE",
"producedBy": "ONE"
}
}
```
`owner` is the contract owner from the class; `producedBy` is the application that emitted the message (see The envelope in `docs/modelling/02-mapping.md`). **Today.** cna-one already sets `owner` from the class; `producedBy` is not sent yet.
---
id: kafka.User
name: User (Kafka topic)
version: 1.0.0
summary: Kafka topic for the User aggregate. Carries the AUTH command InviteUser, published by cna-nexus and cna-placement and handled by cna-one, keyed by the person's document (CPF).
owners:
- cna-platform
address: User
protocols:
- kafka
deliveryGuarantee: at-least-once
badges:
- content: Kafka
backgroundColor: purple
textColor: purple
icon: kafka
- content: Key drift
backgroundColor: yellow
textColor: yellow
x-message-key: document
x-aggregate-root: User
x-drift: cna-placement keys its InviteUser on email instead of document and sends a payload without document and application, which cna-one cannot process.
---
## Overview
`User` is the verbatim `static aggregateRoot` of the [[command|InviteUser]] class owned by [[domain|Auth]]. Two applications publish to it, [[service|cna-nexus]] and [[service|cna-placement]], and one consumes it, [[service|cna-one]], through the `common-person-events` consumer group (`fromBeginning: true`). Despite the `AUTH` owner, [[service|cna-auth]] neither publishes nor consumes on this topic; it only reacts later, to the `GrantApplicationAccess` that cna-one publishes after handling the invite.
## Message key
The canonical key is `payload.document`, the invited person's CPF (`static routingKey = 'document'`), so all invites for the same person stay ordered and land on the same partition as the access grants that follow them on other topics.
**Drift.** cna-placement's copy of the class keys on `email` and its payload has no `document`. Its messages therefore land on a different partition than a nexus invite for the same person, and cna-one's handler fails on them (see the command page). Tracked in the drift list.
## Envelope
Every message on every CNA topic uses the same envelope. `schema.json` on the message page describes `payload` only.
```json
{
"payload": { "...": "see the schema on the message page" },
"metadata": {
"event": "InviteUser",
"producedAt": "2026-09-18T12:00:00.000Z",
"owner": "AUTH",
"producedBy": "NEXUS"
}
}
```
`owner` is the contract owner from the class; `producedBy` is the application that emitted the message (see The envelope in `docs/modelling/02-mapping.md`). **Today.** Both publishers hard-code `owner` to their own name (`NEXUS`, `PLACEMENT`) and send no `producedBy`. cna-one derives the invite origin from `metadata.owner`; when the envelope changes, `owner` becomes `AUTH` and cna-one must read the origin from `producedBy` instead, or every grant will be rejected by cna-auth. Tracked in the drift list.