|
| 1 | +# `thread:ping` Artisan Command — Design |
| 2 | + |
| 3 | +**Status:** Approved 2026-07-14 |
| 4 | +**Source:** ops/admin need to nudge specific participants of a thread from the CLI — either inside an existing thread or by spinning up a new thread whose opening message is the ping. |
| 5 | + |
| 6 | +> **Amendment 2026-07-15:** The optional free-text `note` field (and the `--note` option) described below were **removed** before merge. Every message type in this app deliberately forbids free text (e.g. `MoodType` "No free text", `StatusType` "not a notes field"), so a prose `note` violated that convention. The ping payload is now just `{ user_ids: [...] }`. Because that removed the only user-controlled value that could produce an invalid envelope, the `InvalidEnvelopeException` try/catch in the command was also dropped (the envelope is now valid by construction). |
| 7 | +
|
| 8 | +## 1. Goal |
| 9 | + |
| 10 | +Add an artisan command, `thread:ping`, that an operator/admin runs to "ping" a set of users. It works in two modes: |
| 11 | + |
| 12 | +- **Ping an existing thread** — post a ping message into a given thread, targeting certain existing participants. |
| 13 | +- **Create a ping thread** — start a new thread with the given users as recipients, whose opening message is the ping. |
| 14 | + |
| 15 | +A "ping" is a new typed message (`ping`) whose payload lists the pinged user IDs (a mention/target list). It is a normal thread message: it is broadcast to **all** participants over the existing Reverb pipeline; the `payload.user_ids` records who was pinged so clients can highlight it. |
| 16 | + |
| 17 | +## 2. Constraints & Decisions |
| 18 | + |
| 19 | +- **A ping is a message type, not a bespoke notification.** The command builds a `ping` envelope and calls the existing `MessageService` methods, which already persist the message and broadcast `MessageCreated` to every participant. No new notification/broadcast plumbing. Rejected a dedicated `Pinged` notification (contradicts the "visible in-thread message that notifies all" decision and would duplicate the pipeline) and rejected free-text system messages (the app has no untyped messages — every message is a JSON-Schema-validated `{type, version, payload}` envelope enforced by `MessageService::assertValidEnvelope()`). |
| 20 | +- **"Certain users" = mention targets in the payload.** The ping notifies all participants (per the approved behavior); the selected user IDs live in `payload.user_ids` so those users' clients can highlight/badge the message. It is not a restriction on who gets notified. |
| 21 | +- **Dual mode maps onto existing service methods.** `--thread` given → `MessageService::newMessage($thread, $sender, $envelope)`. `--thread` omitted → `MessageService::newThread($subject, $sender, $envelope, $userIds)` (which creates the thread, adds the recipients as participants, posts the opening message, and broadcasts). Create-mode is essentially free. |
| 22 | +- **`--subject` is required in create mode** (explicit, no silent default), so a new thread always has a meaningful subject. |
| 23 | +- **Membership rules differ per mode.** Existing-thread mode: the sender and every pinged user must **already** be participants (that is what "ping certain users *in the same thread*" means) — validated, error otherwise, nothing added. Create mode: the pinged users are the new thread's recipients and are added as participants by `newThread()`; they only need to be valid users. |
| 24 | +- **Non-interactive.** Arg/option-driven, `--no-interaction`-friendly (fits ops use and any future scheduler). No Laravel Prompts UI (YAGNI). |
| 25 | +- **First command in the app.** `app/Console/Commands/` does not exist yet; this creates it. Follow Laravel command conventions and existing house style. |
| 26 | + |
| 27 | +## 3. Components |
| 28 | + |
| 29 | +| Component | Path | Change | |
| 30 | +|-----------|------|--------| |
| 31 | +| Ping message type | `app/MessageTypes/PingType.php` (new) | Implements `MessageTypeInterface` (mirrors `MoodType`): `name()='ping'`, `version()='1.0'`, `purpose()=…`, `rendererHint()='PingCard'`, and the JSON schema below. | |
| 32 | +| Type registration | `app/Providers/Project/MessageTypeServiceProvider.php` | Register `PingType` alongside the existing six types so `TypeRegistry` knows it. | |
| 33 | +| Command | `app/Console/Commands/PingThreadUsers.php` (new) | Signature `thread:ping` (below); dual-mode dispatch to `newMessage`/`newThread`. | |
| 34 | + |
| 35 | +**`PingType` JSON schema (payload):** |
| 36 | +``` |
| 37 | +type: object |
| 38 | +additionalProperties: false |
| 39 | +required: [user_ids] |
| 40 | +properties: |
| 41 | + user_ids: { type: array, minItems: 1, uniqueItems: true, items: { type: string } } |
| 42 | + note: { type: string, maxLength: 280 } # optional |
| 43 | +``` |
| 44 | + |
| 45 | +**Command signature:** |
| 46 | +``` |
| 47 | +thread:ping |
| 48 | + {--thread= : Existing thread UUID to ping into (omit to create a new thread)} |
| 49 | + {--from= : Sender / thread-creator user UUID} |
| 50 | + {--user=* : User UUID(s) to ping} |
| 51 | + {--subject= : Subject for a NEW thread (required when --thread is omitted)} |
| 52 | + {--note= : Optional short note included in the ping payload} |
| 53 | +``` |
| 54 | + |
| 55 | +## 4. Data Flow |
| 56 | + |
| 57 | +``` |
| 58 | +CLI options |
| 59 | + → resolve sender User (--from) |
| 60 | + → resolve pinged Users (--user*) |
| 61 | + → build envelope { type:'ping', version:'1.0', payload:{ user_ids:[…], note?:… } } |
| 62 | + → MODE: |
| 63 | + --thread given → resolve Thread; assert sender + all pinged users are participants |
| 64 | + → MessageService::newMessage(thread, sender, envelope) |
| 65 | + --thread absent → require --subject |
| 66 | + → MessageService::newThread(subject, sender, envelope, userIds) |
| 67 | + → Message persisted + MessageCreated broadcast to all participants (existing pipeline) |
| 68 | + → pinged users' clients highlight via payload.user_ids |
| 69 | +``` |
| 70 | + |
| 71 | +## 5. Error Handling |
| 72 | + |
| 73 | +Each of these prints an error line and returns a non-zero exit code, persisting nothing: |
| 74 | + |
| 75 | +- Neither `--thread` nor `--subject` given → "Give --thread to ping an existing thread, or --subject to create one." |
| 76 | +- Both `--thread` and `--subject` given → ambiguous-mode error. |
| 77 | +- `--from` missing, or the sender user not found. |
| 78 | +- No `--user` given (need at least one). |
| 79 | +- Any `--user` UUID not found. |
| 80 | +- **Existing mode:** thread not found; sender not a participant; any pinged user not a participant (error names which). |
| 81 | +- Envelope validity is additionally enforced by `MessageService::assertValidEnvelope()` via the new `PingType` schema (belt-and-suspenders; the command always builds a valid one). |
| 82 | + |
| 83 | +Use `Command::SUCCESS` / `Command::FAILURE` for exit codes. |
| 84 | + |
| 85 | +## 6. Testing |
| 86 | + |
| 87 | +This repo has real PHPUnit backend test infrastructure, so both are genuine tests: |
| 88 | + |
| 89 | +- **`tests/Unit/PingTypeTest.php`** — mirrors the existing message-type tests: a valid ping envelope passes `TypeRegistry::validate()`; envelopes missing `user_ids`, with an empty `user_ids`, or with extra properties fail. |
| 90 | +- **`tests/Feature/PingThreadUsersCommandTest.php`** — covers both modes and failures: |
| 91 | + - **Existing mode happy path:** thread with participants; run `thread:ping --thread --from --user…`; assert exit `SUCCESS`, a `Message` row created with `body.type === 'ping'` and `body.payload.user_ids` equal to the selected IDs, and (`Notification::fake`) `MessageCreated` sent to all participants. |
| 92 | + - **Create mode happy path:** run without `--thread` but with `--subject --from --user…`; assert a new `Thread` created, the pinged users are participants, the opening `Message` is the ping envelope, exit `SUCCESS`. |
| 93 | + - **Failure paths:** thread not found; a `--user` not a participant (existing mode); sender not a participant; neither/both of `--thread`/`--subject`; no `--user` — each returns non-zero and persists nothing. |
| 94 | +- `pint`, `phpstan` (level max) stay green. |
0 commit comments