Skip to content

Commit 4e5f005

Browse files
Merge pull request #86 from swinn-io/feat/thread-ping-command
feat: thread:ping artisan command
2 parents 1cbd351 + 3d209f8 commit 4e5f005

9 files changed

Lines changed: 1172 additions & 5 deletions
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
<?php
2+
3+
namespace App\Console\Commands;
4+
5+
use App\Interfaces\MessageServiceInterface;
6+
use App\Models\Thread;
7+
use App\Models\User;
8+
use Illuminate\Console\Command;
9+
use Illuminate\Database\Eloquent\Collection;
10+
11+
class PingThreadUsers extends Command
12+
{
13+
/**
14+
* @var string
15+
*/
16+
protected $signature = 'thread:ping
17+
{--thread= : Existing thread UUID to ping into (omit to create a new thread)}
18+
{--from= : Sender / thread-creator user UUID}
19+
{--user=* : User UUID(s) to ping}
20+
{--subject= : Subject for a NEW thread (required when --thread is omitted)}';
21+
22+
/**
23+
* @var string
24+
*/
25+
protected $description = 'Ping certain users in an existing thread, or create a new thread whose opening message is the ping.';
26+
27+
public function handle(MessageServiceInterface $service): int
28+
{
29+
$threadId = $this->option('thread');
30+
$subject = $this->option('subject');
31+
$fromId = $this->option('from');
32+
/** @var array<int, string> $userIds */
33+
$userIds = array_values(array_unique($this->option('user')));
34+
35+
$threadId = is_string($threadId) && $threadId !== '' ? $threadId : null;
36+
$subject = is_string($subject) && $subject !== '' ? $subject : null;
37+
38+
if ($threadId !== null && $subject !== null) {
39+
$this->error('Provide either --thread (ping an existing thread) or --subject (create one), not both.');
40+
41+
return self::FAILURE;
42+
}
43+
44+
if ($threadId === null && $subject === null) {
45+
$this->error('Give --thread to ping an existing thread, or --subject to create one.');
46+
47+
return self::FAILURE;
48+
}
49+
50+
if (! is_string($fromId) || $fromId === '') {
51+
$this->error('The --from option (sender user UUID) is required.');
52+
53+
return self::FAILURE;
54+
}
55+
56+
if ($userIds === []) {
57+
$this->error('Provide at least one --user to ping.');
58+
59+
return self::FAILURE;
60+
}
61+
62+
$sender = User::find($fromId);
63+
if ($sender === null) {
64+
$this->error("Sender user {$fromId} not found.");
65+
66+
return self::FAILURE;
67+
}
68+
69+
/** @var Collection<int, User> $users */
70+
$users = User::findMany($userIds);
71+
if ($users->count() !== count($userIds)) {
72+
$found = $users->map(fn (User $user): string => $user->id)->all();
73+
$missing = array_diff($userIds, $found);
74+
$this->error('These users were not found: '.implode(', ', $missing));
75+
76+
return self::FAILURE;
77+
}
78+
79+
$envelope = [
80+
'type' => 'ping',
81+
'version' => '1.0',
82+
'payload' => ['user_ids' => $userIds],
83+
];
84+
85+
if ($threadId === null) {
86+
$thread = $service->newThread((string) $subject, $sender, $envelope, $userIds);
87+
$this->info("Created thread {$thread->id} and pinged {$users->count()} user(s).");
88+
89+
return self::SUCCESS;
90+
}
91+
92+
$thread = Thread::find($threadId);
93+
if ($thread === null) {
94+
$this->error("Thread {$threadId} not found.");
95+
96+
return self::FAILURE;
97+
}
98+
99+
$participantIds = array_values(array_filter(
100+
$thread->users()->get()->pluck('id')->all(),
101+
'is_string',
102+
));
103+
104+
if (! in_array($sender->id, $participantIds, true)) {
105+
$this->error("Sender {$sender->id} is not a participant of thread {$thread->id}.");
106+
107+
return self::FAILURE;
108+
}
109+
110+
$notParticipants = array_diff($users->map(fn (User $user): string => $user->id)->all(), $participantIds);
111+
if ($notParticipants !== []) {
112+
$this->error('These users are not in the thread: '.implode(', ', $notParticipants));
113+
114+
return self::FAILURE;
115+
}
116+
117+
$message = $service->newMessage($thread, $sender, $envelope);
118+
$this->info("Pinged {$users->count()} user(s) in thread {$thread->id} (message {$message->id}).");
119+
120+
return self::SUCCESS;
121+
}
122+
}

app/MessageTypes/PingType.php

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
<?php
2+
3+
namespace App\MessageTypes;
4+
5+
use App\Interfaces\MessageTypeInterface;
6+
7+
class PingType implements MessageTypeInterface
8+
{
9+
public function name(): string
10+
{
11+
return 'ping';
12+
}
13+
14+
public function version(): string
15+
{
16+
return '1.0';
17+
}
18+
19+
public function purpose(): string
20+
{
21+
return 'Nudge specific participants of a thread. The payload lists the pinged user IDs (a targeted mention) so clients may highlight the message for those users.';
22+
}
23+
24+
/**
25+
* @return array<string, mixed>
26+
*/
27+
public function schema(): array
28+
{
29+
return [
30+
'type' => 'object',
31+
'additionalProperties' => false,
32+
'required' => ['user_ids'],
33+
'properties' => [
34+
'user_ids' => [
35+
'type' => 'array',
36+
'minItems' => 1,
37+
'uniqueItems' => true,
38+
'items' => ['type' => 'string'],
39+
],
40+
],
41+
];
42+
}
43+
44+
public function rendererHint(): string
45+
{
46+
return 'PingCard';
47+
}
48+
}

app/Providers/Project/MessageTypeServiceProvider.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
use App\MessageTypes\LocationType;
88
use App\MessageTypes\MetricType;
99
use App\MessageTypes\MoodType;
10+
use App\MessageTypes\PingType;
1011
use App\MessageTypes\StatusType;
1112
use App\Services\TypeRegistry;
1213
use Illuminate\Support\ServiceProvider;
@@ -23,6 +24,7 @@ public function register(): void
2324
new FileReferenceType,
2425
new MetricType,
2526
new MoodType,
27+
new PingType,
2628
]);
2729
});
2830
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
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

Comments
 (0)