Skip to content

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 carries kind: 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 dev per 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 ApiBody in communications.swagger.ts gains the two parts (same JSON-encoded-string encoding note).
  • CommunicationRecipientKind appears 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 in project_communications_module.
  • Per-audience split of skippedNoEmail/filteredOut in 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 on ccGroupIds, ccCount/merged counters in the 202, zero-To-with-sendable-CC → COMMUNICATION_NO_RECIPIENTS, CC no-email → SKIPPED_NO_EMAIL kind CC, aggregate insert receives kind on recipients and groups.
  • E2E specs: test/communications.e2e-spec.ts — extend the send+sweep describe: POST with cc + ccGroupIds, 202 carries ccCount, sweep delivers CC copies through the untouched sweeper (memory transport payloads), detail rows expose kind, dedupe To-wins end-to-end, recipientCounts.cc on 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.