06 — Screen Specifications (Communication Module)
- 1. Announcement Feed (
/announcements) - 2. Announcement Detail (
/announcements/:id) - 3. Compose Announcement (
/announcements/compose) - 4. Read Receipts (
/announcements/:id/reads) - 5. Threads List (
/threads) — secondary - 6. Thread Detail (
/threads/:id) — secondary - 7. Cross-screen invariants
- 8. Open questions
Detailed, build-ready specs for the announcement surface. Biggest file in this package. Data contracts are verbatim from
announcement.schema.ts,create-announcement.dto.ts,announcement.service.ts,announcements.controller.ts; gaps are flagged(planned)/(forward-looking)and must not be invented as shipped behavior.
1. Announcement Feed (/announcements)
1.1 Purpose & entry
Recipient-first consumption surface. Entry: dashboard Communication hub, cold start,
push deep link (forward-looking).
1.2 Data contract
GET /announcements → AnnouncementDocument[] (no pagination in backend;
announcement.service.ts:56-60 returns full array sorted createdAt: -1).
Item shape (schema announcement.schema.ts:31-53 + BaseSchema):
{
"_id": "…", "tenantId": "…", "title": "Diwali holiday",
"body": "School closed Fri.",
"audience": { "type": "grade", "value": "Grade 10" },
"targetUserIds": ["…", "…"],
"published": true, "publishedAt": "2026-…",
"readBy": [{ "userId": "…", "readAt": "2026-…" }],
"attachments": ["https://…/notice.pdf"],
"createdBy": "…", "createdAt": "…", "updatedAt": "…"
}
1.3 Layout (mobile)
┌────────────────────────────┐
│ AppBar "Announcements" [FAB+]│
│ TabBar: For me | All │
│ ┌──────────────────────────┐│
│ │ AnnouncementCard ││
│ │ [•unread] Diwali holiday ││
│ │ School closed Fri.… ││
│ │ [grade] [Published] 2d ││
│ └──────────────────────────┘│
│ … (list) │
└────────────────────────────┘
1.4 Behavior
- "For me" tab (client-side): include item if
audience.type === 'all'OR my userId ∈targetUserIds. Backend never filters per user (announcement.service.ts:56-60) → client filter(forward-looking). - Unread indicator:
readBylacks my userId → unread dot (announcement.schema.ts:51-52). - Audience chip: derived from
audience.type+value(e.g.role: teacher,section: 10-A) (announcement.schema.ts:7-21). - Status badge: draft (
published: false) vs published (:45-49); draft rows only meaningful on "All"/"Mine". - Pull-to-refresh; infinite scroll not supported (no pagination API — gap,
(planned)when tenant grows). - Tablet: list + detail two-pane (master-detail per
00-shared/05).
1.5 States
| State | Render |
|---|---|
| loading | AppSkeleton rows ×6 |
| success | list |
| empty | AppEmptyState "No announcements yet" + compose CTA (authors) |
| error offline | AppOfflineBanner + cached list (forward-looking); retry |
| auth expired | 401 → login redirect |
1.6 Events
comm.announcements.open|refresh|tab_switch (proposed).
2. Announcement Detail (/announcements/:id)
2.1 Data contract — GAP
No GET /announcements/:id route exists (announcements.controller.ts:19-52). The client
must carry the item object from the feed/list, or the row must be a summary-only sheet.
A dedicated detail fetch is (planned).
2.2 Layout (sheet on mobile / pane on tablet)
┌────────────────────────────┐
│ [badge: published] [chip] │
│ Title (headline) │
│ To: Grade 10 · 120 people │ ← audience.value + targetUserIds.length
│ Published 3 Aug 2026 │
│ ─────────────────────────── │
│ Body paragraph text │
│ ─────────────────────────── │
│ [attachment] notice.pdf ↗ │
│ ─────────────────────────── │
│ [ Mark as read ] (filled) │
└────────────────────────────┘
2.3 Behavior
- Mark read:
POST /announcements/:id/read—$addToSetreceipt, idempotent (announcement.repository.ts:20-33); NotFound → snackbar (announcement.service.ts:100). - Auto-mark on open is a product decision
(proposed); explicit button also shown for drafts-only confusion. - Audience line:
audience.valuefor role/grade/section/custom; "Everyone" forall; count =targetUserIds.length(0 forall—announcement.service.ts:144-146; show "Everyone" without a count in that case to avoid "0 people" confusion). - Drafts: authors see draft detail (no read button; "Publish" CTA instead).
2.4 States
loading (if fetched (planned)), success, not-found (pop + snackbar), offline.
2.5 Events
comm.announcement.detail.{open,mark_read,attachment_open} (proposed).
3. Compose Announcement (/announcements/compose)
3.1 Layout (scrollable form)
┌────────────────────────────┐
│ AppBar "New announcement" │
│ [Save draft] [Publish] │ ← Publish = save + publish
│ Title* │ AppTextField, min 1 char
│ Body* │ multiline, min 1 char
│ Audience* │ [type picker] [value field/selector]
│ type: all | role | grade | section | custom
│ value: string or string[] (see 3.3)
│ ⚠ 0 recipients warning (planned)
│ Attachments (planned) │ chips + picker
│ Priority / Expiry (planned) │ toggle + date picker — NO schema fields yet
└────────────────────────────┘
3.2 Data contract (verbatim create-announcement.dto.ts:29-49)
{
"title": "string (min 1)",
"body": "string (min 1)",
"audience": {
"type": "all|role|grade|section|custom",
"value": "string | string[]" // required per type; absent for "all"
},
"attachments": ["string"] // optional
}
3.3 Audience picker rules
Type (announcement.schema.ts:7-13) | Value input | Resolves to |
|---|---|---|
all | none (value omitted) | [] — broadcast marker only (announcement.service.ts:144-146) |
role | enum string — e.g. teacher | active org members with role |
(announcement.service.ts:114-119) | ||
grade | grade name string | students of grade (:120-125) |
section | section name string | students of section (:126-135) |
custom | userId chips | the listed users (:136-143) |
- Grade/section inputs are names, not ids (
findOne({ name: audience.value }),announcement.service.ts:121,128) — present a picker fed by the academics module, not free text, to avoid silent empty audiences. - Zero-recipient warning
(planned): after publish attempt backend resolves silently to[](announcement.service.ts:122,129) — client should preview count(planned).
3.4 Save vs Publish
- Save draft:
POST /announcements→published: false+createdByfrom token context (announcement.service.ts:31-37); emitsAnnouncementCreated(:39-51). - Publish: same POST then
POST /announcements/:id/publish→ resolves audience totargetUserIds, setspublished: true,publishedAt(:68-93); emitsAnnouncementPublished(:80-91). Idempotent re-publish returns early (:68-70). - Editing an existing draft not supported (no update route) — gap; re-create.
(planned).
3.5 States & validation
| Check | Rule | Source |
|---|---|---|
| title | required, min 1 char, trimmed | create-announcement.dto.ts:30-33; announcement.schema.ts:33-34 |
| body | required, min 1 char, trimmed | create-announcement.dto.ts:35-38; announcement.schema.ts:36-37 |
| audience.type | required enum | create-announcement.dto.ts:14-16; announcement.schema.ts:16-17 |
| audience.value | optional string | string[], strings only | create-announcement.dto.ts:24-26 |
| attachments | optional string[] | create-announcement.dto.ts:45-49 |
Publish with empty audience.value for role/grade/section → block + inline error
(planned); today backend accepts and resolves [] (announcement.service.ts:120-143).
4. Read Receipts (/announcements/:id/reads)
4.1 Data contract
GET /announcements/:id/reads → AnnouncementDocument['readBy'] — array of
{ userId, readAt } (announcement.service.ts:104-107, announcement.schema.ts:23-29).
Does not include targetUserIds — client needs the list payload for the unread
complement.
4.2 Layout
┌────────────────────────────┐
│ "Diwali holiday" │
│ 72 of 120 read · 48 unread │ ← readBy.length / targetUserIds.length
│ [Read] [Unread] (segmented)│
│ • Aisha Idris 3 Aug 9:04 │
│ • Samuel Eze 3 Aug 9:12 │
└────────────────────────────┘
4.3 Behavior
- Unread set =
targetUserIds − readBy.userId(client-side; user display names from user lookup(forward-looking)— no join in API). - Refresh on pull; auto-refresh 30 s while open
(proposed). - Export / remind laggards
(planned). - For
all-type announcementstargetUserIdsis[]→ counts meaningless; hide numbers, show receipts list only.
5. Threads List (/threads) — secondary
GET /threads (threads.controller.ts); rows: partner avatar, last message preview,
unread badge. Empty state + FAB. Tablet two-pane.
6. Thread Detail (/threads/:id) — secondary
GET /threads/:id + POST /messages + PATCH /threads/:id/read
(docs/IMPLEMENTATION_PLAN.md:107-112). Bubbles, input bar, optimistic send, offline
outbox (forward-looking). WebSocket realtime (planned) (IMPLEMENTATION_PLAN.md:119).
Full spec deferred to a dedicated messaging package.
7. Cross-screen invariants
published: false⇒ never in recipient "For me"; authors see "Draft" badge.- Unread state =
myUserId ∉ readBy[].userId— always derived, never cached server-side. AudienceType.ALL⇒targetUserIds.length === 0— treat as "everyone", never as "0".- Every write that touches a doc emits an event (
AnnouncementCreated/AnnouncementPublished,announcement.service.ts:39-51,80-91) — client may subscribe via WS(planned)for live feed updates. - All reads tenant-scoped by
BaseRepository(announcement.repository.ts:12-18) — cross-tenant leakage impossible at API level; client must not assume the same for display names.
8. Open questions
- OQ-1: Should
GET /announcementspaginate before feed ships? (backend today returns unbounded array —announcement.service.ts:56-60) - OQ-2: Should opening detail auto-mark read, or require explicit tap?
- OQ-3: Are grade/section pickers fed by academics API (
GET /academics/…) — needs module contract check. - OQ-4: When
communication.*RBAC perms arrive, which roles may compose/publish? (blueprint:notification.*analog —studylyon-blueprint/04-Modules/Notifications.md:65-71)