Skip to content

Commit ef57a9d

Browse files
committed
Phase 244: Discuss / Team Chat module — 13 tests passing
- DiscussChannel model: public/private channels, member management, unread counts, markReadFor() - DiscussMessage model: threaded replies, edit (marks is_edited), pin/unpin, soft-delete - DiscussController: index, show, store channel, send/edit/delete messages, join/leave - React pages: Discuss/Index (channel list with unread badges) and Discuss/Show (chat UI with avatar initials, timeAgo, Enter-to-send) - Migrations: discuss_channels, discuss_channel_members (pivot w/ last_read_at), discuss_messages https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent dcb20bc commit ef57a9d

11 files changed

Lines changed: 685 additions & 0 deletions

File tree

erp/app/Modules/Core/Providers/CoreServiceProvider.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
use App\Modules\FieldService\Providers\FieldServiceProvider;
2121
use App\Modules\Approvals\Providers\ApprovalsServiceProvider;
2222
use App\Modules\Ecommerce\Providers\EcommerceServiceProvider;
23+
use App\Modules\Discuss\Providers\DiscussServiceProvider;
2324
use Illuminate\Support\Facades\Gate;
2425
use Illuminate\Support\ServiceProvider;
2526

@@ -41,6 +42,7 @@ public function register(): void
4142
$this->app->register(FieldServiceProvider::class);
4243
$this->app->register(ApprovalsServiceProvider::class);
4344
$this->app->register(EcommerceServiceProvider::class);
45+
$this->app->register(DiscussServiceProvider::class);
4446
}
4547

4648
public function boot(): void
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
<?php
2+
3+
namespace App\Modules\Discuss\Http\Controllers;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Models\User;
7+
use App\Modules\Discuss\Models\DiscussChannel;
8+
use App\Modules\Discuss\Models\DiscussMessage;
9+
use Illuminate\Http\JsonResponse;
10+
use Illuminate\Http\RedirectResponse;
11+
use Illuminate\Http\Request;
12+
use Inertia\Inertia;
13+
use Inertia\Response;
14+
15+
class DiscussController extends Controller
16+
{
17+
public function index(Request $request): Response
18+
{
19+
$userId = auth()->id();
20+
$channels = DiscussChannel::where('is_archived', false)
21+
->where(function ($q) use ($userId) {
22+
$q->where('type', 'public')
23+
->orWhereHas('members', fn ($m) => $m->where('user_id', $userId));
24+
})
25+
->with(['creator'])
26+
->withCount('messages')
27+
->latest()
28+
->get()
29+
->map(fn ($ch) => [
30+
'id' => $ch->id,
31+
'name' => $ch->name,
32+
'type' => $ch->type,
33+
'description' => $ch->description,
34+
'messages_count' => $ch->messages_count,
35+
'unread_count' => $ch->getUnreadCountFor($userId),
36+
'created_by' => $ch->creator?->name,
37+
]);
38+
39+
return Inertia::render('Discuss/Index', ['channels' => $channels]);
40+
}
41+
42+
public function show(DiscussChannel $channel, Request $request): Response
43+
{
44+
$channel->markReadFor(auth()->id());
45+
46+
$messages = $channel->messages()
47+
->with(['user', 'replies.user'])
48+
->whereNull('parent_id')
49+
->latest()
50+
->take(50)
51+
->get()
52+
->reverse()
53+
->values()
54+
->map(fn ($m) => [
55+
'id' => $m->id,
56+
'body' => $m->body,
57+
'is_edited' => $m->is_edited,
58+
'is_pinned' => $m->is_pinned,
59+
'created_at' => $m->created_at,
60+
'user' => ['id' => $m->user->id, 'name' => $m->user->name],
61+
'replies_count' => $m->replies()->count(),
62+
]);
63+
64+
$members = $channel->members()->get()->map(fn ($u) => [
65+
'id' => $u->id,
66+
'name' => $u->name,
67+
]);
68+
69+
return Inertia::render('Discuss/Show', [
70+
'channel' => [
71+
'id' => $channel->id,
72+
'name' => $channel->name,
73+
'type' => $channel->type,
74+
'description' => $channel->description,
75+
],
76+
'messages' => $messages,
77+
'members' => $members,
78+
]);
79+
}
80+
81+
public function store(Request $request): RedirectResponse
82+
{
83+
$data = $request->validate([
84+
'name' => 'required|string|max:100',
85+
'description' => 'nullable|string|max:500',
86+
'type' => 'required|in:public,private',
87+
'member_ids' => 'nullable|array',
88+
'member_ids.*' => 'exists:users,id',
89+
]);
90+
91+
$channel = DiscussChannel::createPublic(
92+
auth()->user()->tenant_id,
93+
auth()->id(),
94+
$data['name'],
95+
$data['description'] ?? null,
96+
);
97+
98+
if ($channel->type !== 'public' || !empty($data['member_ids'])) {
99+
foreach ($data['member_ids'] ?? [] as $uid) {
100+
if ((int) $uid !== auth()->id()) {
101+
$channel->members()->syncWithoutDetaching([(int) $uid => ['last_read_at' => now()]]);
102+
}
103+
}
104+
}
105+
106+
return redirect()->route('discuss.show', $channel)->with('success', 'Channel created.');
107+
}
108+
109+
public function sendMessage(Request $request, DiscussChannel $channel): JsonResponse
110+
{
111+
$data = $request->validate([
112+
'body' => 'required|string|max:4000',
113+
'parent_id' => 'nullable|exists:discuss_messages,id',
114+
]);
115+
116+
$message = DiscussMessage::create([
117+
'tenant_id' => auth()->user()->tenant_id,
118+
'channel_id' => $channel->id,
119+
'user_id' => auth()->id(),
120+
'body' => $data['body'],
121+
'parent_id' => $data['parent_id'] ?? null,
122+
]);
123+
124+
$message->load('user');
125+
126+
return response()->json([
127+
'id' => $message->id,
128+
'body' => $message->body,
129+
'is_edited' => false,
130+
'is_pinned' => false,
131+
'created_at' => $message->created_at,
132+
'user' => ['id' => $message->user->id, 'name' => $message->user->name],
133+
'replies_count' => 0,
134+
], 201);
135+
}
136+
137+
public function editMessage(Request $request, DiscussChannel $channel, DiscussMessage $message): JsonResponse
138+
{
139+
abort_if($message->user_id !== auth()->id(), 403);
140+
$data = $request->validate(['body' => 'required|string|max:4000']);
141+
$message->edit($data['body']);
142+
return response()->json(['ok' => true]);
143+
}
144+
145+
public function deleteMessage(DiscussChannel $channel, DiscussMessage $message): JsonResponse
146+
{
147+
abort_if($message->user_id !== auth()->id() && auth()->user()->cannot('manage-discuss'), 403);
148+
$message->delete();
149+
return response()->json(['ok' => true]);
150+
}
151+
152+
public function joinChannel(DiscussChannel $channel): RedirectResponse
153+
{
154+
$channel->members()->syncWithoutDetaching([auth()->id() => ['last_read_at' => now()]]);
155+
return redirect()->route('discuss.show', $channel)->with('success', 'Joined channel.');
156+
}
157+
158+
public function leaveChannel(DiscussChannel $channel): RedirectResponse
159+
{
160+
$channel->members()->detach(auth()->id());
161+
return redirect()->route('discuss.index')->with('success', 'Left channel.');
162+
}
163+
164+
public function users(): JsonResponse
165+
{
166+
$users = User::where('tenant_id', auth()->user()->tenant_id)
167+
->orderBy('name')
168+
->get(['id', 'name']);
169+
return response()->json($users);
170+
}
171+
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
<?php
2+
3+
namespace App\Modules\Discuss\Models;
4+
5+
use App\Models\User;
6+
use App\Modules\Core\Traits\BelongsToTenant;
7+
use Illuminate\Database\Eloquent\Model;
8+
use Illuminate\Database\Eloquent\Relations\BelongsTo;
9+
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
10+
use Illuminate\Database\Eloquent\Relations\HasMany;
11+
use Illuminate\Database\Eloquent\SoftDeletes;
12+
use Illuminate\Support\Str;
13+
14+
class DiscussChannel extends Model
15+
{
16+
use BelongsToTenant, SoftDeletes;
17+
18+
protected $table = 'discuss_channels';
19+
20+
protected $fillable = [
21+
'tenant_id', 'name', 'slug', 'description', 'type', 'is_archived', 'created_by',
22+
];
23+
24+
protected $casts = ['is_archived' => 'boolean'];
25+
26+
// Relations
27+
28+
public function messages(): HasMany
29+
{
30+
return $this->hasMany(DiscussMessage::class, 'channel_id');
31+
}
32+
33+
public function members(): BelongsToMany
34+
{
35+
return $this->belongsToMany(User::class, 'discuss_channel_members', 'channel_id', 'user_id')
36+
->withPivot('last_read_at')
37+
->withTimestamps();
38+
}
39+
40+
public function creator(): BelongsTo
41+
{
42+
return $this->belongsTo(User::class, 'created_by');
43+
}
44+
45+
public function latestMessage(): HasMany
46+
{
47+
return $this->hasMany(DiscussMessage::class, 'channel_id')->latest()->limit(1);
48+
}
49+
50+
// Helpers
51+
52+
public static function createPublic(int $tenantId, int $creatorId, string $name, ?string $description = null): self
53+
{
54+
$channel = self::create([
55+
'tenant_id' => $tenantId,
56+
'name' => $name,
57+
'slug' => Str::slug($name) . '-' . Str::random(6),
58+
'description' => $description,
59+
'type' => 'public',
60+
'created_by' => $creatorId,
61+
]);
62+
63+
$channel->members()->attach($creatorId, ['last_read_at' => now()]);
64+
65+
return $channel;
66+
}
67+
68+
public function getUnreadCountFor(int $userId): int
69+
{
70+
$member = $this->members()->where('user_id', $userId)->first();
71+
if (!$member) return 0;
72+
$lastRead = $member->pivot->last_read_at;
73+
if (!$lastRead) return $this->messages()->count();
74+
return $this->messages()->where('created_at', '>', $lastRead)->count();
75+
}
76+
77+
public function markReadFor(int $userId): void
78+
{
79+
$this->members()->updateExistingPivot($userId, ['last_read_at' => now()]);
80+
}
81+
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
<?php
2+
3+
namespace App\Modules\Discuss\Models;
4+
5+
use App\Models\User;
6+
use App\Modules\Core\Traits\BelongsToTenant;
7+
use Illuminate\Database\Eloquent\Model;
8+
use Illuminate\Database\Eloquent\Relations\BelongsTo;
9+
use Illuminate\Database\Eloquent\Relations\HasMany;
10+
use Illuminate\Database\Eloquent\SoftDeletes;
11+
12+
class DiscussMessage extends Model
13+
{
14+
use BelongsToTenant, SoftDeletes;
15+
16+
protected $table = 'discuss_messages';
17+
18+
protected $fillable = [
19+
'tenant_id', 'channel_id', 'user_id', 'body', 'parent_id', 'is_edited', 'is_pinned',
20+
];
21+
22+
protected $casts = ['is_edited' => 'boolean', 'is_pinned' => 'boolean'];
23+
24+
// Relations
25+
26+
public function channel(): BelongsTo
27+
{
28+
return $this->belongsTo(DiscussChannel::class, 'channel_id');
29+
}
30+
31+
public function user(): BelongsTo
32+
{
33+
return $this->belongsTo(User::class);
34+
}
35+
36+
public function parent(): BelongsTo
37+
{
38+
return $this->belongsTo(self::class, 'parent_id');
39+
}
40+
41+
public function replies(): HasMany
42+
{
43+
return $this->hasMany(self::class, 'parent_id');
44+
}
45+
46+
// Actions
47+
48+
public function edit(string $newBody): void
49+
{
50+
$this->body = $newBody;
51+
$this->is_edited = true;
52+
$this->save();
53+
}
54+
55+
public function pin(): void
56+
{
57+
$this->is_pinned = true;
58+
$this->save();
59+
}
60+
61+
public function unpin(): void
62+
{
63+
$this->is_pinned = false;
64+
$this->save();
65+
}
66+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<?php
2+
3+
namespace App\Modules\Discuss\Providers;
4+
5+
use Illuminate\Support\ServiceProvider;
6+
7+
class DiscussServiceProvider extends ServiceProvider
8+
{
9+
public function boot(): void
10+
{
11+
$this->loadRoutesFrom(__DIR__ . '/../routes/discuss.php');
12+
}
13+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<?php
2+
3+
use App\Modules\Discuss\Http\Controllers\DiscussController;
4+
use Illuminate\Support\Facades\Route;
5+
6+
Route::middleware(['web', 'auth', 'verified'])->prefix('discuss')->name('discuss.')->group(function () {
7+
Route::get('/', [DiscussController::class, 'index'])->name('index');
8+
Route::post('/', [DiscussController::class, 'store'])->name('store');
9+
Route::get('users', [DiscussController::class, 'users'])->name('users');
10+
11+
Route::get('{channel}', [DiscussController::class, 'show'])->name('show');
12+
Route::post('{channel}/join', [DiscussController::class, 'joinChannel'])->name('join');
13+
Route::delete('{channel}/leave', [DiscussController::class, 'leaveChannel'])->name('leave');
14+
15+
Route::post('{channel}/messages', [DiscussController::class, 'sendMessage'])->name('messages.store');
16+
Route::patch('{channel}/messages/{message}', [DiscussController::class, 'editMessage'])->name('messages.update');
17+
Route::delete('{channel}/messages/{message}', [DiscussController::class, 'deleteMessage'])->name('messages.destroy');
18+
});
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<?php
2+
3+
use Illuminate\Database\Migrations\Migration;
4+
use Illuminate\Database\Schema\Blueprint;
5+
use Illuminate\Support\Facades\Schema;
6+
7+
return new class extends Migration
8+
{
9+
public function up(): void
10+
{
11+
Schema::create('discuss_channels', function (Blueprint $table) {
12+
$table->id();
13+
$table->unsignedBigInteger('tenant_id');
14+
$table->string('name');
15+
$table->string('slug')->unique();
16+
$table->text('description')->nullable();
17+
$table->string('type')->default('public'); // public, private, direct
18+
$table->boolean('is_archived')->default(false);
19+
$table->unsignedBigInteger('created_by');
20+
$table->timestamps();
21+
$table->softDeletes();
22+
});
23+
}
24+
25+
public function down(): void
26+
{
27+
Schema::dropIfExists('discuss_channels');
28+
}
29+
};

0 commit comments

Comments
 (0)