date: 2026-08-05 slug: communications-cc-iteration-2 status: Approved # Draft | Approved | Superseded clickup_us: none — user-requested gap ("we miss a feature on the communication module: the CCs") epic: communications related_specs: - docs/superpowers/specs/2026-08-05-communications-module-design.md supersedes: none
Communications iteration 2 — CC audience¶
1. Problem distillation¶
- A communication today has exactly one audience (To). The product needs a second, CC audience: "also deliver a copy to these people, flagged as CC".
- CC picks must be governed by the exact same reach logic as recipients — no more, no less (user's words): same classifier, same picker, same mailing-group affordance, same filtered-out discipline.
- The v1 binding "one individual email per recipient" makes a literal
Cc:header wrong here: there is no single message to be in copy of, and a header-CC on every per-recipient email would flood each CC'd inbox with one copy per recipient. Decided in chat (2026-08-05): CC = one flagged copy — each CC'd person receives ONE copy of the communication, and their ledger row carrieskind: CC. Recipients' emails are unchanged (no visible Cc line).
Success criteria (observable behavior that proves this works):
- POST /communications accepts cc (inline refs) and ccGroupIds
(mailing groups); both are expanded and vetted exactly like the To sources.
- CC'd people receive the identical email through the existing sweeper with
full per-person delivery tracking (webhook ledger included) — the sweeper
code itself is untouched.
- The 202 response reports ccCount; the archive detail ledger rows carry
kind: 'TO' | 'CC'; list/detail expose a CC tally.
- A person present in both audiences receives exactly ONE email, as To.
Non-goals (in-scope-shaped things this iteration is explicitly not doing):
- No Cc: SMTP header anywhere — MailPayload and the three transports are
untouched.
- No BCC concept.
- No visible "in copia" line inside the email body/template (would require a
template variable change — deferred, see §9).
2. Patterns survey¶
| Analogous module/spec | What we'd borrow | What doesn't fit |
|---|---|---|
docs/superpowers/specs/2026-08-05-communications-module-design.md (base spec §9 POST flow) |
The 10-step create: group expansion → hidden 404 → classifier partition → snapshot persist. CC reuses steps 2–5 verbatim on a second audience. | Base flow assumes one audience; this iteration runs ONE classifier pass over the disjoint union and attributes kind afterwards (§4/§7). |
src/communications/recipient-policy.ts (partitionContactable) |
THE single reach authority — CC refs go through the very same function ("no more, no less"). | Fits cleanly. |
src/attendance/ day-shape / register enums (records[].row style discriminators) |
Response rows carrying a discriminator the FE branches on (kind on ledger rows), with no VALUE_LABELS entries for response-only enums (label-coverage scan c lesson, this module's own fix round). |
Fits cleanly. |
prisma/schema.prisma CommunicationRecipient.@@unique([communicationId, personType, personId]) |
The existing belt already enforces one row per person per communication — To-wins dedupe is structurally backed. | Fits cleanly. |
3. Architecture mapping¶
| Primitive | Apply? | How | Justify |
|---|---|---|---|
| Tenant scope | yes | Same as base module — every row carries tenantId, RLS already covers both touched tables |
no new table |
| Academic-year scope | no | n/a — communications are not AY-scoped (base spec decision) | unchanged |
| RBAC entity key | existing communications |
no delta | same send action gates the whole POST incl. CC |
| Scopes | existing (management gate-only) |
no delta | CC needs no new authority |
| Actions | none | no new action keys | |
| Service base | custom (existing CommunicationsService) |
extend create(); reads extended in place |
|
queries.ts shape |
existing named functions | insertCommunicationAggregate input gains kind on recipients/groups; list counts groupBy gains kind |
|
| Error codes | existing only | reuse MAILING_GROUP_NOT_FOUND (cc group miss) and COMMUNICATION_NO_RECIPIENTS (zero sendable To) |
no new codes |
| DTO conventions | existing multipart-part transforms | cc mirrors recipients (plainToInstance transform), ccGroupIds mirrors groupIds (@IsUUID('all', { each: true })) |
|
| File-backed sub-resources | n/a | attachments unchanged | |
| Custom fields | no | n/a | |
| Profile completeness | no | n/a |
4. Data model plan¶
Schema deltas¶
- New enum
CommunicationRecipientKind { TO, CC }(@@map("communication_recipient_kind")). CommunicationRecipient.kind CommunicationRecipientKind @default(TO)— the ledger discriminator.CommunicationMailingGroup.kind CommunicationRecipientKind @default(TO)— which audience a stored group fed (archive fidelity).
Migration shape¶
- Additive: one enum type + two columns with
DEFAULT 'TO'. - Data backfill: none — existing rows default to
TO, which is historically correct (v1 had only a To audience). - Hazards from chapter 12 checklist: none — additive columns with defaults on
small-to-medium tables; no new tenant-bearing model, so no RLS /
rls-coverage / tenanted-models / reset-tx / ENTITY_LABELS deltas. The
uncommitted-migration fold question applies (two communications migrations
already ride this branch — ask before
migrate devper ch12 Rule 1).
Indexes and uniqueness¶
- Unchanged.
@@unique([communicationId, personType, personId])now ALSO enforces the one-copy-per-person rule across kinds (a person can't be both To and CC rows).@@unique([communicationId, mailingGroupId])likewise forbids the same group feeding both audiences — service dedupes To-wins before insert (§7).
5. API surface¶
No new routes — three existing surfaces extended:
| Verb | Path | Decorators | Request DTO delta | Response DTO delta |
|---|---|---|---|---|
| POST | /communications |
unchanged (@RequireAction(COMMUNICATIONS, 'send')) |
CreateCommunicationDto + cc?: CommunicationRecipientRefDto[], ccGroupIds?: string[] (multipart JSON-string parts, same transforms as their To twins) |
CreateCommunicationResponseDto + ccCount: number; recipientCount stays To-sendable; skippedNoEmail/filteredOut become totals across both audiences |
| GET | /communications |
unchanged | — | list item: groups[] entries gain kind; recipientCounts gains cc: number (rows with kind CC, all statuses) |
| GET | /communications/:id |
unchanged | — | groups[] entries gain kind; recipients[] ledger rows gain kind; recipientCounts.cc as above |
Semantics locked:
- To-wins dedupe, applied on raw refs before the classifier: the CC raw
set is dedupe(ccGroups members ∪ cc refs) minus To raw keys; ditto
ccGroupIds minus groupIds for the groups snapshot. One classifier pass
runs over the disjoint union To ∪ CC; kind is attributed by raw-set
membership afterwards (no double reach-queries, no double-counted
filteredOut).
- COMMUNICATION_NO_RECIPIENTS stays keyed on zero sendable To — a
communication that is only CCs is a copy of nothing and is refused, even
if CC people are sendable.
- CC people without an email land as SKIPPED_NO_EMAIL rows with
kind: CC (counted in the merged skippedNoEmail).
- Missing/foreign ids in ccGroupIds answer the same hidden 404
MAILING_GROUP_NOT_FOUND as groupIds.
- Audit communication.sent data gains ccCount.
Swagger considerations¶
- Hand-written multipart
ApiBodyincommunications.swagger.tsgains the two parts (same JSON-encoded-string encoding note). CommunicationRecipientKindappears ONLY on response DTOs → no VALUE_LABELS entries (label-coverage scan c would flag them as dead; FE owns the badge copy — same rule as the status enums, FE guide §6).
6. RBAC seed plan¶
| Seed file | Delta |
|---|---|
PermissionScope (rbac-catalogue.ts) |
none |
PermissionAction (rbac-catalogue.ts) |
none |
ScopeFieldMapping (rbac-catalogue.ts) |
none |
| Role grants (roles.ts) | none — communications.send already gates the whole POST |
*_SCOPES runtime constant |
none |
No reseed needed for this iteration (the module's base reseed is still pending, unchanged).
7. Divergence ledger¶
| Pattern | We diverge by | Reason | Tradeoff accepted |
|---|---|---|---|
| Everyday email semantics of "CC" | CC'd people get one flagged copy; recipients do NOT see a Cc: line |
v1 binding: one individual email per recipient — a real header would mean N copies per CC on an N-recipient blast, and no per-CC delivery tracking (webhook events are per-message) | The social signal "you can see who was in copy" is lost; the archive (and future template iteration, §9) carries that information instead |
| Base spec §9 single-audience flow | One classifier pass over To ∪ CC with post-hoc kind attribution | Two passes would re-run the tier reach queries and double-count filteredOut for refs present in both audiences |
Slightly more bookkeeping in create() |
8. Pushback log¶
| US says | Conflicts with | Proposed instead | Status |
|---|---|---|---|
| "CCs" (implies mail-client Cc header) | 1-msg/recipient binding (guide PDF) | One flagged copy per CC'd person, ledger-tracked | Resolved — user chose "one flagged copy" in chat 2026-08-05 |
9. Deferrals¶
- Visible "in copia per conoscenza" line inside the delivered email — needs a
template variable (
{{{ccNote}}}) and re-published Resend templates — follow-up: revisit if product asks; noted inproject_communications_module. - Per-audience split of
skippedNoEmail/filteredOutin the 202 — merged totals for now; split only if the FE needs distinct warnings.
10. Open questions¶
None — both decision points (delivery semantics; CC sources) resolved in chat 2026-08-05 via the two-option questions: one flagged copy + groups + inline (full symmetry).
11. Verification plan¶
- Unit specs:
communications.service.spec.ts— new describes: To-wins dedupe (ref in both audiences → single TO row, one classifier pass), CC group expansion + hidden 404 onccGroupIds,ccCount/merged counters in the 202, zero-To-with-sendable-CC →COMMUNICATION_NO_RECIPIENTS, CC no-email →SKIPPED_NO_EMAILkind CC, aggregate insert receiveskindon recipients and groups. - E2E specs:
test/communications.e2e-spec.ts— extend the send+sweep describe: POST withcc+ccGroupIds, 202 carriesccCount, sweep delivers CC copies through the untouched sweeper (memory transport payloads), detail rows exposekind, dedupe To-wins end-to-end,recipientCounts.ccon list/detail. - Manual verification: none beyond the suite — no transport change.
12. Sign-off¶
- Approved by: Fabio
- Date: 2026-08-05
- Chat reference: "sign off" in chat 2026-08-05, after the two-option brainstorm (one flagged copy; groups + inline symmetry) and spec walkthrough
Until this section is filled, no implementation code is written. When you
fill it, flip the frontmatter status: to Approved in the same edit.