Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a92334125 | ||
|
|
93e8f035e6 | ||
|
|
3c414e1ef1 | ||
|
|
b9d2988e8c | ||
|
|
69762667bc | ||
|
|
f493be6512 | ||
|
|
eeb3bf85a9 | ||
|
|
ef00a73daa | ||
|
|
ae6a4304f9 | ||
|
|
d01091d5a6 | ||
|
|
8f9c4c9d1f | ||
|
|
5b1a634d80 | ||
|
|
379543a351 | ||
|
|
06cd53fe91 | ||
|
|
5404b1a535 | ||
|
|
cb01acd6e0 |
@@ -58,6 +58,12 @@ This project has domain-specific skills available in `**/skills/**`. You MUST ac
|
||||
- Stick to existing directory structure; don't create new base folders without approval.
|
||||
- Do not change the application's dependencies without approval.
|
||||
|
||||
## Activity Service
|
||||
|
||||
- The `app/Services/ActivityService.php` class is used to create system messages and activities.
|
||||
- To create a system message, use the `createMessage` method. The `$user` parameter should be `null` to indicate a system message.
|
||||
- The `createMutation` method can be used to create a mutation and its associated system message.
|
||||
|
||||
## Frontend Bundling
|
||||
|
||||
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them.
|
||||
|
||||
+33
-5
@@ -19,18 +19,46 @@ This document outlines the decisions made during the development of the Ledgerrz
|
||||
|
||||
## Architectural & Integration Decisions
|
||||
|
||||
### 1. Style Architecture Shift to BEM
|
||||
To improve front-end maintainability, we transitioned the core reusable layout and page components away from raw utility-class markup. Styles are now strictly encapsulated within Vue Single File Component (SFC) `<style scoped>` blocks. To keep the look and feel 100% intact, we used Tailwind v4 `@apply` directives inside these blocks, referencing our central stylesheet (`@reference "../../css/app.css"`) to pull in custom themes and variables cleanly.
|
||||
*Note:* Third-party shadcn-vue library primitives (located in `components/ui/`) were kept intact to preserve vendor update integrity.
|
||||
### 1. Style Architecture Shift to BEM & Modern CSS Nesting
|
||||
To improve front-end maintainability, we transitioned the core reusable layout and page components away from raw utility-class markup. Styles are now strictly encapsulated within Vue Single File Component (SFC) `<style scoped>` blocks (or dedicated BEM component files under `/resources/css/components/`).
|
||||
* **CSS Nesting Standard (Critical Learning):** Standard CSS/PostCSS nesting (unlike SASS) **does not** support class suffix concatenation (e.g. `.block { &__element { ... } }` is invalid). All nested selectors must declare the full class name explicitly (e.g. `.block { .block__element { ... } }`), compiling to standard descendant selectors (`.block .block__element`). Suffix nesting was refactored across all 12 component stylesheets to ensure native CSS browser and LightningCSS compatibility.
|
||||
* **Encapsulation:** Used Tailwind v4 `@apply` directives inside these blocks, referencing our central stylesheet (`@reference "../../css/app.css"`) to pull in custom themes and variables cleanly.
|
||||
* **Third-Party Safety:** Third-party shadcn-vue library primitives (located in `components/ui/`) were kept intact to preserve vendor update integrity.
|
||||
|
||||
### 2. Robust Real-time Echo Fallback & Deduplication
|
||||
### 2. BelongsToMany Pivot Loading caveat
|
||||
When accessing pivot attributes on relationship models on the front-end (e.g. `participant.pivot.role`), the relationship definition in the Eloquent model **must** explicitly call `->withPivot('role')`. Omitting this will cause the pivot object to fail loading role attributes silently in Javascript, breaking role-based template gates. We added this to `App\Models\Dynamic::participants()`.
|
||||
|
||||
### 3. Robust Real-time Echo Fallback & Deduplication
|
||||
To address potential initialization and bundling errors under local-development:
|
||||
* **Module Deduplication:** Configured `resolve.dedupe: ['@laravel/echo-vue']` in `vite.config.ts` to ensure exactly one instance of the Echo plugin and configuration state is shared across main and lazy-loaded bundles.
|
||||
* **Defensive Initialization:** Added checks using `echoIsConfigured()` and configured fallback connection parameters (e.g., `'mock-key'`) in both `app.ts` and `Chat.vue`'s setup functions. This ensures that missing environment variables (such as `VITE_REVERB_APP_KEY`) do not trigger fatal Pusher initialization crashes that block component rendering, allowing the websocket to fail silently (which is correct when Reverb is not running locally).
|
||||
|
||||
### 3. Controller Authorization Fix
|
||||
### 4. Controller Authorization Fix
|
||||
We imported the `Illuminate\Foundation\Auth\Access\AuthorizesRequests` trait directly into `LedgerController.php`. This fixes the 500 Internal Server Error when loading the ledger page, which was caused by `LedgerController` calling `$this->authorize('view', ...)` without inheriting the required method from the Laravel 11 base `Controller`.
|
||||
|
||||
### 5. Polymorphic Multiple-Media Attachment Support
|
||||
We added a polymorphic attachments system allowing any database model to attach multiple photos and videos cleanly:
|
||||
* Created `Media` polymorphic model and a migration mapping `mediable_id` and `mediable_type`.
|
||||
* Attached and verified uploads across `Message` (inline chat images/videos), `Ledger` (cover documents), and `Mutation` (chore submission receipts and proof).
|
||||
* Built a highly reusable `.c-lightbox` CSS component for modal previews of both image and video uploads, avoiding duplication in individual modules.
|
||||
|
||||
### 6. Cryptographically Secure Dynamic Invitation System
|
||||
We implemented a secure Dynamic Invitation flow to allow owners to invite new members to a Dynamic under a specific role:
|
||||
* **Signed temporary URLs:** Invitation links dispatched in mailable emails (`DynamicInvitationMail.php`) use Laravel's temporary signed URLs, expiring in 7 days, to prevent link tampering.
|
||||
* **Intended-Email Check Gate:** To prevent link hijacking, the accept gate strictly checks that the authenticated user's email matches the exact email specified on the invitation:
|
||||
```php
|
||||
if ($request->user()->email !== $invitation->email) {
|
||||
abort(403, 'This invitation was sent to a different email address.');
|
||||
}
|
||||
```
|
||||
* **Access Control:** Access to invitations and creation is fully protected under Owner-only authorization checks.
|
||||
|
||||
### 7. Centralized Activity Service for System Messages
|
||||
We created `app/Services/ActivityService.php` to centralize the creation of system messages and activities.
|
||||
* **System Messages as `null` user_id**: System messages are stored as `Message` records with a `null` `user_id`, cleanly distinguishing them from user-generated content.
|
||||
* **Polymorphic Subject Linking**: System messages are linked to relevant entities (e.g., a `User` who joined a dynamic, a `Ledger` that was created) via a polymorphic `subject` relationship on the `messages` table. This allows system messages on the dashboard to link directly to the relevant entity.
|
||||
* **Seeder Refactoring**: The `DatabaseSeeder` was refactored to use the `ActivityService` to generate all system messages, ensuring consistency.
|
||||
|
||||
## Initial Database Schema
|
||||
|
||||
I will start with a basic schema and evolve it as I build features.
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\StoreDynamicRequest;
|
||||
use App\Http\Requests\UpdateDynamicRequest;
|
||||
use App\Models\Dynamic;
|
||||
use App\Services\ActivityService;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
|
||||
use App\Services\ActivityService;
|
||||
|
||||
class DynamicController extends Controller
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
@@ -46,11 +46,11 @@ class DynamicController extends Controller
|
||||
/**
|
||||
* Display the specified resource.
|
||||
*/
|
||||
public function show(Dynamic $dynamic, ActivityService $activityService)
|
||||
public function show(Request $request, Dynamic $dynamic, ActivityService $activityService)
|
||||
{
|
||||
$this->authorize('view', $dynamic);
|
||||
|
||||
$activityService->updateCursor(auth()->user(), $dynamic);
|
||||
$activityService->updateCursor($request->user(), $dynamic);
|
||||
|
||||
$dynamic->load([
|
||||
'ledgers.media',
|
||||
@@ -59,8 +59,14 @@ class DynamicController extends Controller
|
||||
'chat.messages.media'
|
||||
]);
|
||||
|
||||
$isOwner = $dynamic->participants()
|
||||
->where('user_id', $request->user()->id)
|
||||
->where('role', 'owner')
|
||||
->exists();
|
||||
|
||||
return Inertia::render('Dynamics/Show', [
|
||||
'dynamic' => $dynamic,
|
||||
'isOwner' => $isOwner,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -69,15 +75,21 @@ class DynamicController extends Controller
|
||||
*/
|
||||
public function edit(Dynamic $dynamic)
|
||||
{
|
||||
//
|
||||
$this->authorize('update', $dynamic);
|
||||
|
||||
return Inertia::render('Dynamics/Settings', [
|
||||
'dynamic' => $dynamic,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the specified resource in storage.
|
||||
*/
|
||||
public function update(Request $request, Dynamic $dynamic)
|
||||
public function update(UpdateDynamicRequest $request, Dynamic $dynamic)
|
||||
{
|
||||
//
|
||||
$dynamic->update($request->validated());
|
||||
|
||||
return redirect()->route('dynamics.show', $dynamic);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Mail\DynamicInvitationMail;
|
||||
use App\Models\Dynamic;
|
||||
use App\Models\DynamicInvitation;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class DynamicInvitationController extends Controller
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
/**
|
||||
* Show the form for creating a new invitation.
|
||||
*/
|
||||
public function create(Request $request, Dynamic $dynamic)
|
||||
{
|
||||
// Authorize - only owners can view the invite page!
|
||||
$this->authorize('update', $dynamic);
|
||||
|
||||
return Inertia::render('Dynamics/Invite', [
|
||||
'dynamic' => $dynamic,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a newly created invitation in storage.
|
||||
*/
|
||||
public function store(Request $request, Dynamic $dynamic)
|
||||
{
|
||||
// 1. Authorize - only owners can send invitations!
|
||||
$isOwner = $dynamic->participants()
|
||||
->where('user_id', $request->user()->id)
|
||||
->where('role', 'owner')
|
||||
->exists();
|
||||
|
||||
if (!$isOwner) {
|
||||
abort(403, 'Only dynamic owners can invite other users.');
|
||||
}
|
||||
|
||||
// 2. Validate
|
||||
$request->validate([
|
||||
'email' => ['required', 'email'],
|
||||
'role' => ['required', 'string', 'in:owner,participant,editor,viewer'],
|
||||
]);
|
||||
|
||||
$email = $request->input('email');
|
||||
$role = $request->input('role');
|
||||
|
||||
// Check if user is already a participant of this dynamic
|
||||
$isParticipant = $dynamic->participants()->where('email', $email)->exists();
|
||||
if ($isParticipant) {
|
||||
return redirect()->back()->withErrors([
|
||||
'email' => 'This user is already a participant of this dynamic.',
|
||||
]);
|
||||
}
|
||||
|
||||
// Check if there is an active pending invitation for this user
|
||||
$hasPendingInvite = $dynamic->invitations()
|
||||
->where('email', $email)
|
||||
->where('expires_at', '>', now())
|
||||
->exists();
|
||||
|
||||
if ($hasPendingInvite) {
|
||||
return redirect()->back()->withErrors([
|
||||
'email' => 'An active invitation is already pending for this email address.',
|
||||
]);
|
||||
}
|
||||
|
||||
// 3. Create Invitation
|
||||
$invitation = $dynamic->invitations()->create([
|
||||
'email' => $email,
|
||||
'role' => $role,
|
||||
'token' => Str::random(40),
|
||||
'expires_at' => now()->addDays(7),
|
||||
]);
|
||||
|
||||
// 4. Send Email
|
||||
Mail::to($email)->send(new DynamicInvitationMail($invitation, $request->user()->name));
|
||||
|
||||
return redirect()->back()->with('success', 'Invitation successfully sent!');
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept the specified invitation.
|
||||
*/
|
||||
public function accept(Request $request, string $token)
|
||||
{
|
||||
// Must be signed!
|
||||
if (!$request->hasValidSignature()) {
|
||||
abort(401, 'Invalid or expired signature.');
|
||||
}
|
||||
|
||||
$invitation = DynamicInvitation::where('token', $token)->firstOrFail();
|
||||
|
||||
if ($invitation->isExpired()) {
|
||||
abort(403, 'This invitation has expired.');
|
||||
}
|
||||
|
||||
// Ensure the logged in user's email matches the invitation's email!
|
||||
// "Only the user with the specified email address should be able to access the link."
|
||||
if ($request->user()->email !== $invitation->email) {
|
||||
abort(403, 'This invitation was sent to a different email address.');
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($request, $invitation) {
|
||||
// Attach user to dynamic as a participant with the specified role
|
||||
$dynamic = $invitation->dynamic;
|
||||
$dynamic->participants()->attach($request->user()->id, ['role' => $invitation->role]);
|
||||
|
||||
// Log to Dynamic chat activity log!
|
||||
$dynamic->chat->messages()->create([
|
||||
'user_id' => null,
|
||||
'content' => "{$request->user()->name} joined the Dynamic as a " . strtoupper($invitation->role),
|
||||
'subject_id' => $request->user()->id,
|
||||
'subject_type' => \App\Models\User::class,
|
||||
]);
|
||||
|
||||
// Delete the invitation record
|
||||
$invitation->delete();
|
||||
});
|
||||
|
||||
return redirect()->route('dynamics.show', $invitation->dynamic_id)->with('success', 'Successfully joined the dynamic!');
|
||||
}
|
||||
}
|
||||
@@ -25,9 +25,13 @@ class LedgerController extends Controller
|
||||
/**
|
||||
* Show the form for creating a new resource.
|
||||
*/
|
||||
public function create()
|
||||
public function create(Request $request, Dynamic $dynamic)
|
||||
{
|
||||
//
|
||||
$this->authorize('update', $dynamic);
|
||||
|
||||
return Inertia::render('Ledgers/Create', [
|
||||
'dynamic' => $dynamic,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Dynamic;
|
||||
use App\Models\PredefinedMutation;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class PredefinedMutationController extends Controller
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
/**
|
||||
* Display a listing of the resource.
|
||||
*/
|
||||
public function index(Dynamic $dynamic)
|
||||
{
|
||||
$this->authorize('update', $dynamic);
|
||||
|
||||
return Inertia::render('Dynamics/PredefinedMutations/Index', [
|
||||
'dynamic' => $dynamic,
|
||||
'predefined_mutations' => $dynamic->predefinedMutations()->latest()->get(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a newly created resource in storage.
|
||||
*/
|
||||
public function store(Request $request, Dynamic $dynamic)
|
||||
{
|
||||
$this->authorize('update', $dynamic);
|
||||
|
||||
$request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'description' => ['nullable', 'string'],
|
||||
'amount' => ['required', 'integer'],
|
||||
'type' => ['required', 'string', 'in:reward,penalty'],
|
||||
]);
|
||||
|
||||
$dynamic->predefinedMutations()->create($request->all());
|
||||
|
||||
return redirect()->route('dynamics.predefined-mutations.index', $dynamic);
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ class StoreLedgerRequest extends FormRequest
|
||||
{
|
||||
$dynamic = $this->route('dynamic');
|
||||
|
||||
return $dynamic && $this->user()->can('view', $dynamic);
|
||||
return $dynamic && $this->user()->can('update', $dynamic);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
use App\Models\Dynamic;
|
||||
|
||||
class UpdateDynamicRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
$dynamic = $this->route('dynamic');
|
||||
|
||||
return $dynamic && $this->user()->can('update', $dynamic);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'rules' => ['nullable', 'string'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Models\DynamicInvitation;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
|
||||
class DynamicInvitationMail extends Mailable {
|
||||
use Queueable, SerializesModels;
|
||||
|
||||
public function __construct(public DynamicInvitation $invitation, public string $inviterName) {
|
||||
//
|
||||
}
|
||||
|
||||
public function envelope(): Envelope {
|
||||
return new Envelope(
|
||||
subject: 'Invitation to Join Dynamic: ' . $this->invitation->dynamic->name,
|
||||
);
|
||||
}
|
||||
|
||||
public function content(): Content {
|
||||
$acceptUrl = URL::temporarySignedRoute(
|
||||
'dynamics.invitations.accept',
|
||||
$this->invitation->expires_at,
|
||||
['token' => $this->invitation->token]
|
||||
);
|
||||
|
||||
return new Content(
|
||||
markdown: 'emails.dynamics.invitation',
|
||||
with: [
|
||||
'acceptUrl' => $acceptUrl,
|
||||
'dynamicName' => $this->invitation->dynamic->name,
|
||||
'role' => $this->invitation->role,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,11 @@ class Dynamic extends Model
|
||||
return $this->hasMany(Ledger::class);
|
||||
}
|
||||
|
||||
public function invitations(): HasMany
|
||||
{
|
||||
return $this->hasMany(DynamicInvitation::class);
|
||||
}
|
||||
|
||||
public function chat(): MorphOne
|
||||
{
|
||||
return $this->morphOne(Chat::class, 'chatable');
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class DynamicInvitation extends Model {
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'dynamic_id',
|
||||
'email',
|
||||
'role',
|
||||
'token',
|
||||
'expires_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'expires_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function dynamic(): BelongsTo {
|
||||
return $this->belongsTo(Dynamic::class);
|
||||
}
|
||||
|
||||
public function isExpired(): bool {
|
||||
return $this->expires_at->isPast();
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,8 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
|
||||
class Message extends Model
|
||||
{
|
||||
/** @use HasFactory<MessageFactory> */
|
||||
@@ -16,6 +18,8 @@ class Message extends Model
|
||||
'chat_id',
|
||||
'user_id',
|
||||
'content',
|
||||
'subject_id',
|
||||
'subject_type',
|
||||
];
|
||||
|
||||
public function chat(): BelongsTo
|
||||
@@ -28,6 +32,11 @@ class Message extends Model
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function subject(): MorphTo
|
||||
{
|
||||
return $this->morphTo();
|
||||
}
|
||||
|
||||
public function media(): \Illuminate\Database\Eloquent\Relations\MorphMany
|
||||
{
|
||||
return $this->morphMany(Media::class, 'mediable');
|
||||
|
||||
@@ -20,6 +20,7 @@ class Mutation extends Model
|
||||
'amount',
|
||||
'description',
|
||||
'status',
|
||||
'predefined_mutation_id',
|
||||
];
|
||||
|
||||
public function ledger(): BelongsTo
|
||||
@@ -32,6 +33,11 @@ class Mutation extends Model
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function predefinedMutation(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(PredefinedMutation::class);
|
||||
}
|
||||
|
||||
public function chat(): MorphOne
|
||||
{
|
||||
return $this->morphOne(Chat::class, 'chatable');
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class PredefinedMutation extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'dynamic_id',
|
||||
'name',
|
||||
'description',
|
||||
'amount',
|
||||
'type',
|
||||
];
|
||||
|
||||
public function dynamic(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Dynamic::class);
|
||||
}
|
||||
}
|
||||
@@ -36,7 +36,7 @@ class DynamicPolicy
|
||||
*/
|
||||
public function update(User $user, Dynamic $dynamic): bool
|
||||
{
|
||||
return false;
|
||||
return $dynamic->participants()->where('user_id', $user->id)->where('role', 'owner')->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Models\Mutation;
|
||||
use App\Models\Message;
|
||||
use App\Models\ReadCursor;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class ActivityService
|
||||
{
|
||||
@@ -44,121 +45,54 @@ class ActivityService
|
||||
return $cursor ? $cursor->read_at : Carbon::parse('1970-01-01');
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve all activities for a Dynamic.
|
||||
*/
|
||||
public function getDynamicActivities(Dynamic $dynamic): array
|
||||
public function createMessage($chat, $user, $content, $subject = null)
|
||||
{
|
||||
$activities = [];
|
||||
$message = $chat->messages()->create([
|
||||
'user_id' => $user ? $user->id : null,
|
||||
'content' => $content,
|
||||
'subject_id' => $subject ? $subject->id : null,
|
||||
'subject_type' => $subject ? get_class($subject) : null,
|
||||
]);
|
||||
|
||||
// 1. Chat Messages
|
||||
if ($dynamic->chat) {
|
||||
$messages = Message::where('chat_id', $dynamic->chat->id)
|
||||
->with('user')
|
||||
->get();
|
||||
return $message;
|
||||
}
|
||||
|
||||
foreach ($messages as $msg) {
|
||||
$activities[] = [
|
||||
'id' => "dynamic_msg_{$msg->id}",
|
||||
'type' => 'message',
|
||||
'description' => $msg->content,
|
||||
'user' => [
|
||||
'name' => $msg->user ? $msg->user->name : 'Unknown User',
|
||||
],
|
||||
'created_at' => $msg->created_at,
|
||||
];
|
||||
}
|
||||
}
|
||||
public function createMutation($ledger, $user, $type, $amount, $description, $status)
|
||||
{
|
||||
$mutation = $ledger->mutations()->create([
|
||||
'user_id' => $user->id,
|
||||
'type' => $type,
|
||||
'amount' => $amount,
|
||||
'description' => $description,
|
||||
'status' => $status,
|
||||
]);
|
||||
|
||||
// 2. Ledgers Created
|
||||
$ledgers = Ledger::where('dynamic_id', $dynamic->id)->get();
|
||||
foreach ($ledgers as $ledger) {
|
||||
$activities[] = [
|
||||
'id' => "ledger_created_{$ledger->id}",
|
||||
'type' => 'ledger_created',
|
||||
'description' => "New Ledger '{$ledger->name}' was created.",
|
||||
'user' => [
|
||||
'name' => 'System',
|
||||
],
|
||||
'created_at' => $ledger->created_at,
|
||||
];
|
||||
}
|
||||
|
||||
// Sort activities chronologically ascending
|
||||
usort($activities, fn($a, $b) => $a['created_at']->timestamp <=> $b['created_at']->timestamp);
|
||||
|
||||
return $activities;
|
||||
return $mutation;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve all activities for a Ledger.
|
||||
* Retrieve all activities for a given entity.
|
||||
*/
|
||||
public function getLedgerActivities(Ledger $ledger): array
|
||||
public function getActivitiesForEntity($entity): array
|
||||
{
|
||||
$activities = [];
|
||||
|
||||
// 1. Mutations (Creation and Status updates)
|
||||
$mutations = Mutation::where('ledger_id', $ledger->id)
|
||||
->with('user')
|
||||
->get();
|
||||
|
||||
foreach ($mutations as $mutation) {
|
||||
// Log creation of mutation
|
||||
$verb = $mutation->type === 'penalty' ? 'issued demerit' : ($mutation->type === 'reward' ? 'credited points' : 'submitted entry');
|
||||
$activities[] = [
|
||||
'id' => "mutation_created_{$mutation->id}",
|
||||
'type' => 'mutation_created',
|
||||
'description' => "{$verb} ({$mutation->amount}): \"{$mutation->description}\"",
|
||||
'user' => [
|
||||
'name' => $mutation->user ? $mutation->user->name : 'Unknown User',
|
||||
],
|
||||
'created_at' => $mutation->created_at,
|
||||
];
|
||||
|
||||
// Log status approval/rejection update if different from creation
|
||||
if ($mutation->status !== 'pending' && $mutation->updated_at->gt($mutation->created_at->addSeconds(2))) {
|
||||
$activities[] = [
|
||||
'id' => "mutation_updated_{$mutation->id}",
|
||||
'type' => 'mutation_updated',
|
||||
'description' => "Entry '{$mutation->description}' was " . strtoupper($mutation->status),
|
||||
'user' => [
|
||||
'name' => 'System',
|
||||
],
|
||||
'created_at' => $mutation->updated_at,
|
||||
];
|
||||
}
|
||||
if ($entity instanceof Dynamic) {
|
||||
$chatId = $entity->chat->id;
|
||||
} elseif ($entity instanceof Ledger) {
|
||||
$chatId = $entity->dynamic->chat->id;
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 2. Mutation Comments
|
||||
if ($mutations->isNotEmpty()) {
|
||||
$comments = Message::whereHas('chat', function ($q) use ($mutations) {
|
||||
$q->where('chatable_type', Mutation::class)
|
||||
->whereIn('chatable_id', $mutations->pluck('id'));
|
||||
})
|
||||
->with(['user', 'chat.chatable'])
|
||||
$messages = Message::where('chat_id', $chatId)
|
||||
->with(['user', 'subject'])
|
||||
->latest()
|
||||
->get();
|
||||
|
||||
foreach ($comments as $comment) {
|
||||
/** @var Mutation|null $mutationEntity */
|
||||
$mutationEntity = $comment->chat->chatable;
|
||||
$context = $mutationEntity ? " on \"{$mutationEntity->description}\"" : "";
|
||||
|
||||
$activities[] = [
|
||||
'id' => "comment_{$comment->id}",
|
||||
'type' => 'comment',
|
||||
'description' => "Commented{$context}: \"{$comment->content}\"",
|
||||
'user' => [
|
||||
'name' => $comment->user ? $comment->user->name : 'Unknown User',
|
||||
],
|
||||
'created_at' => $comment->created_at,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Sort activities chronologically ascending
|
||||
usort($activities, fn($a, $b) => $a['created_at']->timestamp <=> $b['created_at']->timestamp);
|
||||
|
||||
return $activities;
|
||||
return $messages->map(function ($message) {
|
||||
$messageData = $message->toArray();
|
||||
$messageData['url'] = $this->getUrlForMessage($message);
|
||||
return $messageData;
|
||||
})->all();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -167,27 +101,15 @@ class ActivityService
|
||||
public function getUnreadEntitiesGrouped(User $user): array
|
||||
{
|
||||
$groupedEntities = [];
|
||||
$participatingDynamics = $user->dynamics()->with('ledgers')->get();
|
||||
|
||||
// 1. Get all participating Dynamics
|
||||
$dynamics = $user->dynamics()->get();
|
||||
$entities = $participatingDynamics->concat($participatingDynamics->flatMap(fn ($d) => $d->ledgers));
|
||||
|
||||
foreach ($dynamics as $dynamic) {
|
||||
$readAt = $this->getCursorReadAt($user, $dynamic);
|
||||
$activities = $this->getDynamicActivities($dynamic);
|
||||
foreach ($entities as $entity) {
|
||||
$readAt = $this->getCursorReadAt($user, $entity);
|
||||
$activities = $this->getActivitiesForEntity($entity);
|
||||
|
||||
$this->partitionActivities($activities, $readAt, $dynamic, 'Dynamic', route('dynamics.show', $dynamic->id), $groupedEntities);
|
||||
}
|
||||
|
||||
// 2. Get all Ledgers under those Dynamics
|
||||
if ($dynamics->isNotEmpty()) {
|
||||
$ledgers = Ledger::whereIn('dynamic_id', $dynamics->pluck('id'))->get();
|
||||
|
||||
foreach ($ledgers as $ledger) {
|
||||
$readAt = $this->getCursorReadAt($user, $ledger);
|
||||
$activities = $this->getLedgerActivities($ledger);
|
||||
|
||||
$this->partitionActivities($activities, $readAt, $ledger, 'Ledger', route('dynamics.ledgers.show', [$ledger->dynamic_id, $ledger->id]), $groupedEntities);
|
||||
}
|
||||
$this->partitionActivities($activities, $readAt, $entity, get_class($entity), $this->getUrlForEntity($entity), $groupedEntities);
|
||||
}
|
||||
|
||||
return $groupedEntities;
|
||||
@@ -202,7 +124,7 @@ class ActivityService
|
||||
$unread = [];
|
||||
|
||||
foreach ($activities as $act) {
|
||||
if ($act['created_at']->gt($readAt)) {
|
||||
if (Carbon::parse($act['created_at'])->gt($readAt)) {
|
||||
$unread[] = $act;
|
||||
} else {
|
||||
$alreadyRead[] = $act;
|
||||
@@ -210,24 +132,43 @@ class ActivityService
|
||||
}
|
||||
|
||||
if (!empty($unread)) {
|
||||
// We have unread activity! Let's pull the last two read items as context
|
||||
$context = array_slice($alreadyRead, -2);
|
||||
|
||||
// Format timestamps for serializing to frontend
|
||||
$formatActivity = function ($act) {
|
||||
$act['created_at'] = $act['created_at']->toIso8601String();
|
||||
return $act;
|
||||
};
|
||||
$context = array_slice($alreadyRead, 0, 2);
|
||||
|
||||
$groupedEntities[] = [
|
||||
'id' => $entity->id,
|
||||
'name' => $entity->name,
|
||||
'type' => $type,
|
||||
'type' => Str::afterLast($type, '\\'),
|
||||
'url' => $url,
|
||||
'unread_count' => count($unread),
|
||||
'context_activities' => array_map($formatActivity, $context),
|
||||
'new_activities' => array_map($formatActivity, $unread),
|
||||
'context_activities' => $context,
|
||||
'new_activities' => $unread,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
private function getUrlForEntity($entity): string
|
||||
{
|
||||
if ($entity instanceof Dynamic) {
|
||||
return route('dynamics.show', $entity->id);
|
||||
}
|
||||
|
||||
if ($entity instanceof Ledger) {
|
||||
return route('dynamics.ledgers.show', [$entity->dynamic_id, $entity->id]);
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
private function getUrlForMessage(Message $message): string
|
||||
{
|
||||
if ($message->subject) {
|
||||
return $this->getUrlForEntity($message->subject);
|
||||
}
|
||||
|
||||
if ($message->chat->chatable) {
|
||||
return $this->getUrlForEntity($message->chat->chatable);
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
}
|
||||
+14
-9
@@ -10,22 +10,27 @@ use Illuminate\Http\Request;
|
||||
|
||||
return Application::configure(basePath: dirname(__DIR__))
|
||||
->withRouting(
|
||||
web: __DIR__.'/../routes/web.php',
|
||||
commands: __DIR__.'/../routes/console.php',
|
||||
channels: __DIR__.'/../routes/channels.php',
|
||||
web: __DIR__ . '/../routes/web.php',
|
||||
commands: __DIR__ . '/../routes/console.php',
|
||||
channels: __DIR__ . '/../routes/channels.php',
|
||||
health: '/up',
|
||||
)
|
||||
->withMiddleware(function (Middleware $middleware): void {
|
||||
$middleware->encryptCookies(except: ['appearance', 'sidebar_state']);
|
||||
|
||||
$middleware->web(append: [
|
||||
HandleAppearance::class,
|
||||
HandleInertiaRequests::class,
|
||||
AddLinkHeadersForPreloadedAssets::class,
|
||||
]);
|
||||
$middleware
|
||||
->web(append: [
|
||||
HandleAppearance::class,
|
||||
HandleInertiaRequests::class,
|
||||
AddLinkHeadersForPreloadedAssets::class,
|
||||
])
|
||||
->trustProxies(at: [
|
||||
'172.16.0.0/12',
|
||||
]);
|
||||
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions): void {
|
||||
$exceptions->shouldRenderJsonWhen(
|
||||
fn (Request $request) => $request->is('api/*'),
|
||||
fn(Request $request) => $request->is('api/*'),
|
||||
);
|
||||
})->create();
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration {
|
||||
public function up(): void {
|
||||
Schema::create('dynamic_invitations', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('dynamic_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('email');
|
||||
$table->string('role');
|
||||
$table->string('token')->unique();
|
||||
$table->timestamp('expires_at');
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void {
|
||||
Schema::dropIfExists('dynamic_invitations');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('messages', function (Blueprint $table) {
|
||||
$table->foreignId('user_id')->nullable()->change();
|
||||
$table->nullableMorphs('subject');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('messages', function (Blueprint $table) {
|
||||
$table->foreignId('user_id')->nullable(false)->change();
|
||||
$table->dropMorphs('subject');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('predefined_mutations', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('dynamic_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('name');
|
||||
$table->text('description')->nullable();
|
||||
$table->integer('amount');
|
||||
$table->string('type')->default('reward');
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('predefined_mutations');
|
||||
}
|
||||
};
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('mutations', function (Blueprint $table) {
|
||||
$table->foreignId('predefined_mutation_id')->nullable()->constrained()->onDelete('set null');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('mutations', function (Blueprint $table) {
|
||||
$table->dropForeign(['predefined_mutation_id']);
|
||||
$table->dropColumn('predefined_mutation_id');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -7,6 +7,7 @@ use App\Models\Dynamic;
|
||||
use App\Models\Ledger;
|
||||
use App\Models\Mutation;
|
||||
use App\Models\Message;
|
||||
use App\Services\ActivityService;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -16,9 +17,9 @@ class DatabaseSeeder extends Seeder
|
||||
/**
|
||||
* Seed the application's database.
|
||||
*/
|
||||
public function run(): void
|
||||
public function run(ActivityService $activityService): void
|
||||
{
|
||||
DB::transaction(function () {
|
||||
DB::transaction(function () use ($activityService) {
|
||||
// 1. Create Core Users
|
||||
$testUser = User::factory()->create([
|
||||
'name' => 'Test User',
|
||||
@@ -52,7 +53,9 @@ class DatabaseSeeder extends Seeder
|
||||
// ----------------------------------------------------
|
||||
$velvetSanctuary = Dynamic::create([
|
||||
'name' => 'The Velvet Sanctuary',
|
||||
'rules' => "1. Respect limits and boundaries at all times.\n2. Submit daily logs for curfew and chores.\n3. Maintain proper protocol in the general discussion.",
|
||||
'rules' => "1. Respect limits and boundaries at all times.
|
||||
2. Submit daily logs for curfew and chores.
|
||||
3. Maintain proper protocol in the general discussion.",
|
||||
]);
|
||||
|
||||
// Add participants (Test User is owner, Alice is owner, Bob is submissive/participant)
|
||||
@@ -64,29 +67,10 @@ class DatabaseSeeder extends Seeder
|
||||
$velvetChat = $velvetSanctuary->chat;
|
||||
|
||||
// Seed Dynamic Chat Messages
|
||||
Message::create([
|
||||
'chat_id' => $velvetChat->id,
|
||||
'user_id' => $alice->id,
|
||||
'content' => 'Good morning everyone. Bob, please ensure the Obsidian room is polished before 4 PM.',
|
||||
]);
|
||||
|
||||
Message::create([
|
||||
'chat_id' => $velvetChat->id,
|
||||
'user_id' => $testUser->id,
|
||||
'content' => 'I will review the curfew log later today.',
|
||||
]);
|
||||
|
||||
Message::create([
|
||||
'chat_id' => $velvetChat->id,
|
||||
'user_id' => $bob->id,
|
||||
'content' => "Yes, Sir. Yes, Ma'am. I am starting on the chores now.",
|
||||
]);
|
||||
|
||||
Message::create([
|
||||
'chat_id' => $velvetChat->id,
|
||||
'user_id' => $testUser->id,
|
||||
'content' => 'Excellent. Keep up the high standard.',
|
||||
]);
|
||||
$activityService->createMessage($velvetChat, $alice, 'Good morning everyone. Bob, please ensure the Obsidian room is polished before 4 PM.');
|
||||
$activityService->createMessage($velvetChat, $testUser, 'I will review the curfew log later today.');
|
||||
$activityService->createMessage($velvetChat, $bob, "Yes, Sir. Yes, Ma'am. I am starting on the chores now.");
|
||||
$activityService->createMessage($velvetChat, $testUser, 'Excellent. Keep up the high standard.');
|
||||
|
||||
// Add Ledgers
|
||||
$curfewLedger = Ledger::create([
|
||||
@@ -114,145 +98,37 @@ class DatabaseSeeder extends Seeder
|
||||
]);
|
||||
|
||||
// Seed Curfew Mutations
|
||||
Mutation::create([
|
||||
'ledger_id' => $curfewLedger->id,
|
||||
'user_id' => $bob->id,
|
||||
'type' => 'reward',
|
||||
'amount' => 10,
|
||||
'description' => 'Checked in by 10:45 PM on Friday',
|
||||
'status' => 'approved',
|
||||
]);
|
||||
|
||||
Mutation::create([
|
||||
'ledger_id' => $curfewLedger->id,
|
||||
'user_id' => $bob->id,
|
||||
'type' => 'reward',
|
||||
'amount' => 15,
|
||||
'description' => 'Checked in by 10:30 PM on Saturday',
|
||||
'status' => 'approved',
|
||||
]);
|
||||
|
||||
Mutation::create([
|
||||
'ledger_id' => $curfewLedger->id,
|
||||
'user_id' => $bob->id,
|
||||
'type' => 'reward',
|
||||
'amount' => 10,
|
||||
'description' => 'Checked in by 10:50 PM on Sunday',
|
||||
'status' => 'approved',
|
||||
]);
|
||||
$activityService->createMutation($curfewLedger, $bob, 'reward', 10, 'Checked in by 10:45 PM on Friday', 'approved');
|
||||
$activityService->createMutation($curfewLedger, $bob, 'reward', 15, 'Checked in by 10:30 PM on Saturday', 'approved');
|
||||
$activityService->createMutation($curfewLedger, $bob, 'reward', 10, 'Checked in by 10:50 PM on Sunday', 'approved');
|
||||
|
||||
// Seed Cleaning Mutations
|
||||
Mutation::create([
|
||||
'ledger_id' => $cleaningLedger->id,
|
||||
'user_id' => $bob->id,
|
||||
'type' => 'reward',
|
||||
'amount' => 15,
|
||||
'description' => 'Deep cleaning of the main chamber',
|
||||
'status' => 'approved',
|
||||
]);
|
||||
|
||||
Mutation::create([
|
||||
'ledger_id' => $cleaningLedger->id,
|
||||
'user_id' => $bob->id,
|
||||
'type' => 'reward',
|
||||
'amount' => 20,
|
||||
'description' => 'Arranged gear rack and polished leather accessories',
|
||||
'status' => 'approved',
|
||||
]);
|
||||
|
||||
Mutation::create([
|
||||
'ledger_id' => $cleaningLedger->id,
|
||||
'user_id' => $bob->id,
|
||||
'type' => 'reward',
|
||||
'amount' => 10,
|
||||
'description' => 'Mopped obsidian floors',
|
||||
'status' => 'approved',
|
||||
]);
|
||||
|
||||
Mutation::create([
|
||||
'ledger_id' => $cleaningLedger->id,
|
||||
'user_id' => $alice->id,
|
||||
'type' => 'penalty',
|
||||
'amount' => -10,
|
||||
'description' => 'Left keys in the locks unmonitored',
|
||||
'status' => 'approved',
|
||||
]);
|
||||
$activityService->createMutation($cleaningLedger, $bob, 'reward', 15, 'Deep cleaning of the main chamber', 'approved');
|
||||
$activityService->createMutation($cleaningLedger, $bob, 'reward', 20, 'Arranged gear rack and polished leather accessories', 'approved');
|
||||
$activityService->createMutation($cleaningLedger, $bob, 'reward', 10, 'Mopped obsidian floors', 'approved');
|
||||
$activityService->createMutation($cleaningLedger, $alice, 'penalty', -10, 'Left keys in the locks unmonitored', 'approved');
|
||||
|
||||
// Seed Pending Mutation with its own Chat Messages!
|
||||
$pendingMutation = Mutation::create([
|
||||
'ledger_id' => $cleaningLedger->id,
|
||||
'user_id' => $bob->id,
|
||||
'type' => 'addition',
|
||||
'amount' => 10,
|
||||
'description' => 'Weekly chore submission - dusting shelves',
|
||||
'status' => 'pending',
|
||||
]);
|
||||
|
||||
// Pending mutation chat messages (chat is auto-created on booted)
|
||||
$pendingMutation = $activityService->createMutation($cleaningLedger, $bob, 'addition', 10, 'Weekly chore submission - dusting shelves', 'pending');
|
||||
$pendingMutationChat = $pendingMutation->chat;
|
||||
|
||||
Message::create([
|
||||
'chat_id' => $pendingMutationChat->id,
|
||||
'user_id' => $bob->id,
|
||||
'content' => 'I have finished the shelves. Please approve when convenient.',
|
||||
]);
|
||||
|
||||
Message::create([
|
||||
'chat_id' => $pendingMutationChat->id,
|
||||
'user_id' => $alice->id,
|
||||
'content' => "I checked them; there is still some dust on the top shelf. I'll leave this pending until it's perfect.",
|
||||
]);
|
||||
|
||||
Message::create([
|
||||
'chat_id' => $pendingMutationChat->id,
|
||||
'user_id' => $bob->id,
|
||||
'content' => 'Apologies, Ma\'am. I will re-wipe the top section immediately!',
|
||||
]);
|
||||
$activityService->createMessage($pendingMutationChat, $bob, 'I have finished the shelves. Please approve when convenient.');
|
||||
$activityService->createMessage($pendingMutationChat, $alice, "I checked them; there is still some dust on the top shelf. I'll leave this pending until it's perfect.");
|
||||
$activityService->createMessage($pendingMutationChat, $bob, 'Apologies, Ma\'am. I will re-wipe the top section immediately!');
|
||||
|
||||
// Seed Etiquette Mutations
|
||||
Mutation::create([
|
||||
'ledger_id' => $etiquetteLedger->id,
|
||||
'user_id' => $alice->id,
|
||||
'type' => 'penalty',
|
||||
'amount' => 5,
|
||||
'description' => 'Interrupted Domina Alice during daily instructions',
|
||||
'status' => 'approved',
|
||||
]);
|
||||
|
||||
Mutation::create([
|
||||
'ledger_id' => $etiquetteLedger->id,
|
||||
'user_id' => $alice->id,
|
||||
'type' => 'penalty',
|
||||
'amount' => 10,
|
||||
'description' => 'Forgot correct posture during morning roll call',
|
||||
'status' => 'approved',
|
||||
]);
|
||||
|
||||
Mutation::create([
|
||||
'ledger_id' => $etiquetteLedger->id,
|
||||
'user_id' => $alice->id,
|
||||
'type' => 'penalty',
|
||||
'amount' => 5,
|
||||
'description' => 'Spoke out of turn in general chat',
|
||||
'status' => 'approved',
|
||||
]);
|
||||
|
||||
Mutation::create([
|
||||
'ledger_id' => $etiquetteLedger->id,
|
||||
'user_id' => $bob->id,
|
||||
'type' => 'reward',
|
||||
'amount' => -5,
|
||||
'description' => 'Excellent reciting of the house codes',
|
||||
'status' => 'approved',
|
||||
]);
|
||||
|
||||
$activityService->createMutation($etiquetteLedger, $alice, 'penalty', 5, 'Interrupted Domina Alice during daily instructions', 'approved');
|
||||
$activityService->createMutation($etiquetteLedger, $alice, 'penalty', 10, 'Forgot correct posture during morning roll call', 'approved');
|
||||
$activityService->createMutation($etiquetteLedger, $alice, 'penalty', 5, 'Spoke out of turn in general chat', 'approved');
|
||||
$activityService->createMutation($etiquetteLedger, $bob, 'reward', -5, 'Excellent reciting of the house codes', 'approved');
|
||||
|
||||
// ----------------------------------------------------
|
||||
// 3. Seed Dynamic 2: Obsidian Household Agreement
|
||||
// ----------------------------------------------------
|
||||
$obsidianHousehold = Dynamic::create([
|
||||
'name' => 'Obsidian Household Agreement',
|
||||
'rules' => "1. All residents must do their fair share of maintenance.\n2. Coffee machine must be refilled immediately when empty.",
|
||||
'rules' => "1. All residents must do their fair share of maintenance.
|
||||
2. Coffee machine must be refilled immediately when empty.",
|
||||
]);
|
||||
|
||||
$obsidianHousehold->participants()->attach($alice->id, ['role' => 'owner']);
|
||||
@@ -261,29 +137,10 @@ class DatabaseSeeder extends Seeder
|
||||
|
||||
$obsidianChat = $obsidianHousehold->chat;
|
||||
|
||||
Message::create([
|
||||
'chat_id' => $obsidianChat->id,
|
||||
'user_id' => $alice->id,
|
||||
'content' => "Who finished the coffee beans and didn't put them on the shopping list?",
|
||||
]);
|
||||
|
||||
Message::create([
|
||||
'chat_id' => $obsidianChat->id,
|
||||
'user_id' => $charles->id,
|
||||
'content' => "Wasn't me, I only drink tea.",
|
||||
]);
|
||||
|
||||
Message::create([
|
||||
'chat_id' => $obsidianChat->id,
|
||||
'user_id' => $testUser->id,
|
||||
'content' => 'My apologies! I did refill the hopper, but forgot to list the replacement bag. I will buy a new pack tonight.',
|
||||
]);
|
||||
|
||||
Message::create([
|
||||
'chat_id' => $obsidianChat->id,
|
||||
'user_id' => $alice->id,
|
||||
'content' => 'Thank you, Test User. Appreciate the honesty.',
|
||||
]);
|
||||
$activityService->createMessage($obsidianChat, $alice, "Who finished the coffee beans and didn't put them on the shopping list?");
|
||||
$activityService->createMessage($obsidianChat, $charles, "Wasn't me, I only drink tea.");
|
||||
$activityService->createMessage($obsidianChat, $testUser, 'My apologies! I did refill the hopper, but forgot to list the replacement bag. I will buy a new pack tonight.');
|
||||
$activityService->createMessage($obsidianChat, $alice, 'Thank you, Test User. Appreciate the honesty.');
|
||||
|
||||
// Add Ledgers
|
||||
$kitchenLedger = Ledger::create([
|
||||
@@ -303,33 +160,11 @@ class DatabaseSeeder extends Seeder
|
||||
]);
|
||||
|
||||
// Seed Chores Mutations
|
||||
Mutation::create([
|
||||
'ledger_id' => $kitchenLedger->id,
|
||||
'user_id' => $testUser->id,
|
||||
'type' => 'reward',
|
||||
'amount' => 25,
|
||||
'description' => 'Emptied and loaded the dishwasher',
|
||||
'status' => 'approved',
|
||||
]);
|
||||
|
||||
Mutation::create([
|
||||
'ledger_id' => $kitchenLedger->id,
|
||||
'user_id' => $testUser->id,
|
||||
'type' => 'reward',
|
||||
'amount' => 15,
|
||||
'description' => 'Took out recycling and trash bags',
|
||||
'status' => 'approved',
|
||||
]);
|
||||
$activityService->createMutation($kitchenLedger, $testUser, 'reward', 25, 'Emptied and loaded the dishwasher', 'approved');
|
||||
$activityService->createMutation($kitchenLedger, $testUser, 'reward', 15, 'Took out recycling and trash bags', 'approved');
|
||||
|
||||
// Seed Coffee Mutations
|
||||
Mutation::create([
|
||||
'ledger_id' => $coffeeLedger->id,
|
||||
'user_id' => $testUser->id,
|
||||
'type' => 'reward',
|
||||
'amount' => 10,
|
||||
'description' => 'Descaled and refilled coffee beans',
|
||||
'status' => 'approved',
|
||||
]);
|
||||
$activityService->createMutation($coffeeLedger, $testUser, 'reward', 10, 'Descaled and refilled coffee beans', 'approved');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,3 +14,4 @@
|
||||
@import './components/auth-layout.css';
|
||||
@import './components/chat.css';
|
||||
@import './components/lightbox.css';
|
||||
@import './components/invite-form.css';
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
}
|
||||
|
||||
.c-chat__system-inner {
|
||||
@apply flex items-center gap-2 px-3 py-1.5 text-xs;
|
||||
@apply mx-auto flex w-fit items-center justify-center gap-2 px-3 py-1.5 text-xs;
|
||||
background-color: var(--muted);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: calc(var(--radius) - 2px);
|
||||
@@ -69,7 +69,7 @@
|
||||
}
|
||||
|
||||
.c-chat__system-text {
|
||||
@apply flex-1 font-medium;
|
||||
@apply font-medium;
|
||||
}
|
||||
|
||||
.c-chat__system-time {
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
/* 13. InviteForm Component */
|
||||
.c-invite-form {
|
||||
@apply mt-8;
|
||||
|
||||
.c-invite-form__card {
|
||||
@apply overflow-hidden;
|
||||
background-color: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
|
||||
.c-invite-form__body {
|
||||
@apply p-6;
|
||||
color: var(--foreground);
|
||||
|
||||
.c-invite-form__title {
|
||||
@apply text-lg font-medium;
|
||||
}
|
||||
|
||||
.c-invite-form__form {
|
||||
@apply mt-6 space-y-6;
|
||||
|
||||
.c-invite-form__field {
|
||||
@apply block;
|
||||
|
||||
.c-invite-form__label {
|
||||
@apply block text-sm font-medium;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.c-invite-form__input {
|
||||
@apply mt-1 block w-full rounded-md border shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:focus:border-indigo-600 dark:focus:ring-indigo-600;
|
||||
border-color: var(--border);
|
||||
background-color: var(--background);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.c-invite-form__select {
|
||||
@apply mt-1 block w-full rounded-md border p-2 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:focus:border-indigo-600 dark:focus:ring-indigo-600;
|
||||
border-color: var(--border);
|
||||
background-color: var(--background);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.c-invite-form__error {
|
||||
@apply text-sm;
|
||||
color: var(--destructive);
|
||||
}
|
||||
}
|
||||
|
||||
.c-invite-form__actions {
|
||||
@apply flex items-center gap-4;
|
||||
|
||||
.c-invite-form__submit-btn {
|
||||
@apply inline-flex items-center border border-transparent px-4 py-2 text-xs font-semibold tracking-widest uppercase transition duration-150 ease-in-out focus:ring-2 focus:outline-none;
|
||||
border-radius: var(--radius);
|
||||
background-color: var(--primary);
|
||||
color: var(--primary-foreground);
|
||||
|
||||
&:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,6 @@ import AppLogoIcon from '@/components/AppLogoIcon.vue';
|
||||
<AppLogoIcon class="app-logo__icon" />
|
||||
</div>
|
||||
<div class="app-logo__text-container">
|
||||
<span class="app-logo__text">Laravel Starter Kit</span>
|
||||
<span class="app-logo__text">Ledgerrz</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { Link } from '@inertiajs/vue3';
|
||||
import { Link, router } from '@inertiajs/vue3';
|
||||
import { BookOpen, FolderGit2, LayoutGrid, Users } from '@lucide/vue';
|
||||
import { onMounted, onUnmounted } from 'vue';
|
||||
import AppLogo from '@/components/AppLogo.vue';
|
||||
import NavFooter from '@/components/NavFooter.vue';
|
||||
import NavMain from '@/components/NavMain.vue';
|
||||
@@ -13,11 +14,30 @@ import {
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from '@/components/ui/sidebar';
|
||||
import { dashboard } from '@/routes';
|
||||
import { index as dynamics } from '@/routes/dynamics';
|
||||
import type { NavItem } from '@/types';
|
||||
|
||||
const { isMobile, setOpenMobile } = useSidebar();
|
||||
|
||||
let unregister: () => void;
|
||||
|
||||
onMounted(() => {
|
||||
unregister = router.on('navigate', () => {
|
||||
if (isMobile.value) {
|
||||
setOpenMobile(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (unregister) {
|
||||
unregister();
|
||||
}
|
||||
});
|
||||
|
||||
const mainNavItems: NavItem[] = [
|
||||
{
|
||||
title: 'Dashboard',
|
||||
@@ -32,16 +52,16 @@ const mainNavItems: NavItem[] = [
|
||||
];
|
||||
|
||||
const footerNavItems: NavItem[] = [
|
||||
{
|
||||
title: 'Repository',
|
||||
href: 'https://github.com/laravel/vue-starter-kit',
|
||||
icon: FolderGit2,
|
||||
},
|
||||
{
|
||||
title: 'Documentation',
|
||||
href: 'https://laravel.com/docs/starter-kits#vue',
|
||||
icon: BookOpen,
|
||||
},
|
||||
// {
|
||||
// title: 'Repository',
|
||||
// href: 'https://github.com/laravel/vue-starter-kit',
|
||||
// icon: FolderGit2,
|
||||
// },
|
||||
// {
|
||||
// title: 'Documentation',
|
||||
// href: 'https://laravel.com/docs/starter-kits#vue',
|
||||
// icon: BookOpen,
|
||||
// },
|
||||
];
|
||||
</script>
|
||||
|
||||
|
||||
@@ -108,7 +108,7 @@ function closeLightbox() {
|
||||
'c-chat__message',
|
||||
{
|
||||
'c-chat__message--system':
|
||||
message.content.startsWith('System:'),
|
||||
message.user.id === 0,
|
||||
'c-chat__message--own': isOwnMessage(message.user.id),
|
||||
'c-chat__message--other': !isOwnMessage(
|
||||
message.user.id,
|
||||
@@ -192,7 +192,8 @@ function closeLightbox() {
|
||||
id="content"
|
||||
rows="3"
|
||||
class="c-chat__textarea"
|
||||
placeholder="Type a message..."
|
||||
placeholder="Type a message... (Press Enter to send, Shift+Enter for newline)"
|
||||
@keydown.enter.exact.prevent="submit"
|
||||
></textarea>
|
||||
<div v-if="form.errors.content" class="c-chat__error">
|
||||
{{ form.errors.content }}
|
||||
|
||||
@@ -21,18 +21,20 @@ defineProps<{
|
||||
url: string;
|
||||
unread_count: number;
|
||||
context_activities: Array<{
|
||||
id: string;
|
||||
type: string;
|
||||
description: string;
|
||||
user: { name: string };
|
||||
id: number;
|
||||
content: string;
|
||||
user: { name: string } | null;
|
||||
subject: { name: string; url: string } | null;
|
||||
created_at: string;
|
||||
url: string;
|
||||
}>;
|
||||
new_activities: Array<{
|
||||
id: string;
|
||||
type: string;
|
||||
description: string;
|
||||
user: { name: string };
|
||||
id: number;
|
||||
content: string;
|
||||
user: { name: string } | null;
|
||||
subject: { name: string; url: string } | null;
|
||||
created_at: string;
|
||||
url: string;
|
||||
}>;
|
||||
}>;
|
||||
}>();
|
||||
@@ -88,17 +90,19 @@ function formatTime(isoString: string): string {
|
||||
:key="activity.id"
|
||||
class="c-dashboard__activity-item c-dashboard__activity-item--read"
|
||||
>
|
||||
<div class="c-dashboard__activity-meta">
|
||||
<span class="c-dashboard__activity-user">
|
||||
{{ activity.user.name }}
|
||||
</span>
|
||||
<span class="c-dashboard__activity-time">
|
||||
{{ formatTime(activity.created_at) }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="c-dashboard__activity-desc">
|
||||
{{ activity.description }}
|
||||
</p>
|
||||
<Link :href="activity.url" class="block">
|
||||
<div class="c-dashboard__activity-meta">
|
||||
<span class="c-dashboard__activity-user">
|
||||
{{ activity.user?.name || 'System' }}
|
||||
</span>
|
||||
<span class="c-dashboard__activity-time">
|
||||
{{ formatTime(activity.created_at) }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="c-dashboard__activity-desc">
|
||||
{{ activity.content }}
|
||||
</p>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<!-- Unread Separator Line -->
|
||||
@@ -117,18 +121,20 @@ function formatTime(isoString: string): string {
|
||||
:key="activity.id"
|
||||
class="c-dashboard__activity-item c-dashboard__activity-item--unread"
|
||||
>
|
||||
<div class="c-dashboard__activity-meta">
|
||||
<span class="c-dashboard__activity-user">
|
||||
{{ activity.user.name }}
|
||||
</span>
|
||||
<span class="c-dashboard__activity-time">
|
||||
{{ formatTime(activity.created_at) }}
|
||||
</span>
|
||||
<span class="c-dashboard__new-badge">NEW</span>
|
||||
</div>
|
||||
<p class="c-dashboard__activity-desc">
|
||||
{{ activity.description }}
|
||||
</p>
|
||||
<Link :href="activity.url" class="block">
|
||||
<div class="c-dashboard__activity-meta">
|
||||
<span class="c-dashboard__activity-user">
|
||||
{{ activity.user?.name || 'System' }}
|
||||
</span>
|
||||
<span class="c-dashboard__activity-time">
|
||||
{{ formatTime(activity.created_at) }}
|
||||
</span>
|
||||
<span class="c-dashboard__new-badge">NEW</span>
|
||||
</div>
|
||||
<p class="c-dashboard__activity-desc">
|
||||
{{ activity.content }}
|
||||
</p>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
<script setup lang="ts">
|
||||
import { Head, useForm } from '@inertiajs/vue3';
|
||||
import { route } from 'ziggy-js';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
dynamic: {
|
||||
id: number;
|
||||
name: string;
|
||||
};
|
||||
}>();
|
||||
|
||||
const form = useForm({
|
||||
email: '',
|
||||
role: 'participant',
|
||||
});
|
||||
|
||||
const breadcrumbs = [
|
||||
{
|
||||
name: 'Dynamics',
|
||||
href: route('dynamics.index'),
|
||||
},
|
||||
{
|
||||
name: props.dynamic.name,
|
||||
href: route('dynamics.show', props.dynamic.id),
|
||||
},
|
||||
{
|
||||
name: 'Invite User',
|
||||
href: route('dynamics.invitations.create', props.dynamic.id),
|
||||
},
|
||||
];
|
||||
|
||||
function submit() {
|
||||
form.post(route('dynamics.invitations.store', props.dynamic.id), {
|
||||
onSuccess: () => form.reset(),
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Head title="Invite User" />
|
||||
|
||||
<div class="c-invite-user">
|
||||
<div class="c-invite-user__container">
|
||||
<div class="c-invite-user__card">
|
||||
<div class="c-invite-user__body">
|
||||
<h3 class="c-invite-user__title">
|
||||
Invite User to {{ dynamic.name }}
|
||||
</h3>
|
||||
|
||||
<form
|
||||
@submit.prevent="submit"
|
||||
class="c-invite-user__form"
|
||||
>
|
||||
<div class="c-invite-user__field">
|
||||
<label
|
||||
for="email"
|
||||
class="c-invite-user__label"
|
||||
>Email Address</label
|
||||
>
|
||||
<input
|
||||
v-model="form.email"
|
||||
id="email"
|
||||
type="email"
|
||||
required
|
||||
class="c-invite-user__input"
|
||||
/>
|
||||
<div
|
||||
v-if="form.errors.email"
|
||||
class="c-invite-user__error"
|
||||
>
|
||||
{{ form.errors.email }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="c-invite-user__field">
|
||||
<label
|
||||
for="role"
|
||||
class="c-invite-user__label"
|
||||
>Role</label
|
||||
>
|
||||
<select
|
||||
v-model="form.role"
|
||||
id="role"
|
||||
class="c-invite-user__select"
|
||||
>
|
||||
<option value="owner">Owner (Full Control)</option>
|
||||
<option value="participant">Participant</option>
|
||||
<option value="editor">Editor</option>
|
||||
<option value="viewer">Viewer</option>
|
||||
</select>
|
||||
<div
|
||||
v-if="form.errors.role"
|
||||
class="c-invite-user__error"
|
||||
>
|
||||
{{ form.errors.role }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="c-invite-user__actions">
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="form.processing"
|
||||
class="c-invite-user__submit-btn"
|
||||
>
|
||||
Send Invitation
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@reference "../../../css/app.css";
|
||||
|
||||
.c-invite-user {
|
||||
@apply py-12;
|
||||
}
|
||||
|
||||
.c-invite-user__container {
|
||||
@apply mx-auto max-w-7xl sm:px-6 lg:px-8;
|
||||
}
|
||||
|
||||
.c-invite-user__card {
|
||||
@apply overflow-hidden bg-white shadow-sm sm:rounded-lg dark:bg-gray-800;
|
||||
}
|
||||
|
||||
.c-invite-user__body {
|
||||
@apply p-6 text-gray-900 dark:text-gray-100;
|
||||
}
|
||||
|
||||
.c-invite-user__title {
|
||||
@apply text-lg font-medium;
|
||||
}
|
||||
|
||||
.c-invite-user__form {
|
||||
@apply mt-6 space-y-6;
|
||||
}
|
||||
|
||||
.c-invite-user__field {
|
||||
@apply block;
|
||||
}
|
||||
|
||||
.c-invite-user__label {
|
||||
@apply block text-sm font-medium text-gray-700 dark:text-gray-300;
|
||||
}
|
||||
|
||||
.c-invite-user__input {
|
||||
@apply mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 dark:focus:border-indigo-600 dark:focus:ring-indigo-600;
|
||||
}
|
||||
|
||||
.c-invite-user__select {
|
||||
@apply mt-1 block w-full rounded-md border-gray-300 bg-white p-2 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 dark:focus:border-indigo-600 dark:focus:ring-indigo-600;
|
||||
}
|
||||
|
||||
.c-invite-user__error {
|
||||
@apply text-sm text-red-600;
|
||||
}
|
||||
|
||||
.c-invite-user__actions {
|
||||
@apply flex items-center gap-4;
|
||||
}
|
||||
|
||||
.c-invite-user__submit-btn {
|
||||
@apply inline-flex items-center rounded-md border border-transparent bg-gray-800 px-4 py-2 text-xs font-semibold tracking-widest text-white uppercase transition duration-150 ease-in-out hover:bg-gray-700 focus:bg-gray-700 focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:outline-none active:bg-gray-900 dark:bg-gray-200 dark:text-gray-800 dark:hover:bg-white dark:focus:bg-white dark:focus:ring-offset-gray-800 dark:active:bg-gray-300;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,247 @@
|
||||
<script setup lang="ts">
|
||||
import { Head, useForm } from '@inertiajs/vue3';
|
||||
import { route } from 'ziggy-js';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
import { defineProps } from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
dynamic: {
|
||||
id: number;
|
||||
name: string;
|
||||
};
|
||||
predefined_mutations: Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
amount: number;
|
||||
type: string;
|
||||
}>;
|
||||
}>();
|
||||
|
||||
const form = useForm({
|
||||
name: '',
|
||||
description: '',
|
||||
amount: 0,
|
||||
type: 'reward',
|
||||
});
|
||||
|
||||
const breadcrumbs = [
|
||||
{
|
||||
name: 'Dynamics',
|
||||
href: route('dynamics.index'),
|
||||
},
|
||||
{
|
||||
name: props.dynamic.name,
|
||||
href: route('dynamics.show', props.dynamic.id),
|
||||
},
|
||||
{
|
||||
name: 'Predefined Mutations',
|
||||
href: route('dynamics.predefined-mutations.index', props.dynamic.id),
|
||||
},
|
||||
];
|
||||
|
||||
function submit() {
|
||||
form.post(route('dynamics.predefined-mutations.store', props.dynamic.id), {
|
||||
onSuccess: () => form.reset(),
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Head title="Predefined Mutations" />
|
||||
|
||||
<AppLayout :breadcrumbs="breadcrumbs">
|
||||
<div class="c-predefined-mutations">
|
||||
<div class="c-predefined-mutations__container">
|
||||
<div class="c-predefined-mutations__card">
|
||||
<div class="c-predefined-mutations__body">
|
||||
<h3 class="c-predefined-mutations__title">
|
||||
Predefined Mutations for {{ dynamic.name }}
|
||||
</h3>
|
||||
|
||||
<div class="c-predefined-mutations__list">
|
||||
<div
|
||||
v-for="mutation in predefined_mutations"
|
||||
:key="mutation.id"
|
||||
class="c-predefined-mutations__item"
|
||||
>
|
||||
<div class="c-predefined-mutations__item-details">
|
||||
<h4 class="c-predefined-mutations__item-name">
|
||||
{{ mutation.name }}
|
||||
</h4>
|
||||
<p class="c-predefined-mutations__item-description">
|
||||
{{ mutation.description }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="c-predefined-mutations__item-amount">
|
||||
{{ mutation.amount }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="c-predefined-mutations__card mt-8">
|
||||
<div class="c-predefined-mutations__body">
|
||||
<h3 class="c-predefined-mutations__title">
|
||||
Create New Predefined Mutation
|
||||
</h3>
|
||||
|
||||
<form
|
||||
@submit.prevent="submit"
|
||||
class="c-predefined-mutations__form"
|
||||
>
|
||||
<div class="c-predefined-mutations__field">
|
||||
<label
|
||||
for="name"
|
||||
class="c-predefined-mutations__label"
|
||||
>Name</label
|
||||
>
|
||||
<input
|
||||
v-model="form.name"
|
||||
id="name"
|
||||
type="text"
|
||||
class="c-predefined-mutations__input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="c-predefined-mutations__field">
|
||||
<label
|
||||
for="description"
|
||||
class="c-predefined-mutations__label"
|
||||
>Description</label
|
||||
>
|
||||
<textarea
|
||||
v-model="form.description"
|
||||
id="description"
|
||||
rows="4"
|
||||
class="c-predefined-mutations__textarea"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="c-predefined-mutations__field">
|
||||
<label
|
||||
for="amount"
|
||||
class="c-predefined-mutations__label"
|
||||
>Amount</label
|
||||
>
|
||||
<input
|
||||
v-model="form.amount"
|
||||
id="amount"
|
||||
type="number"
|
||||
class="c-predefined-mutations__input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="c-predefined-mutations__field">
|
||||
<label
|
||||
for="type"
|
||||
class="c-predefined-mutations__label"
|
||||
>Type</label
|
||||
>
|
||||
<select
|
||||
v-model="form.type"
|
||||
id="type"
|
||||
class="c-predefined-mutations__select"
|
||||
>
|
||||
<option value="reward">Reward</option>
|
||||
<option value="penalty">Penalty</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="c-predefined-mutations__actions">
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="form.processing"
|
||||
class="c-predefined-mutations__submit-btn"
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@reference "../../../../css/app.css";
|
||||
|
||||
.c-predefined-mutations {
|
||||
@apply py-12;
|
||||
}
|
||||
|
||||
.c-predefined-mutations__container {
|
||||
@apply mx-auto max-w-7xl sm:px-6 lg:px-8;
|
||||
}
|
||||
|
||||
.c-predefined-mutations__card {
|
||||
@apply overflow-hidden bg-white shadow-sm sm:rounded-lg dark:bg-gray-800;
|
||||
}
|
||||
|
||||
.c-predefined-mutations__body {
|
||||
@apply p-6 text-gray-900 dark:text-gray-100;
|
||||
}
|
||||
|
||||
.c-predefined-mutations__title {
|
||||
@apply text-lg font-medium;
|
||||
}
|
||||
|
||||
.c-predefined-mutations__list {
|
||||
@apply mt-6 space-y-4;
|
||||
}
|
||||
|
||||
.c-predefined-mutations__item {
|
||||
@apply flex items-center justify-between rounded-lg border p-4 dark:border-gray-700;
|
||||
}
|
||||
|
||||
.c-predefined-mutations__item-details {
|
||||
@apply flex-1;
|
||||
}
|
||||
|
||||
.c-predefined-mutations__item-name {
|
||||
@apply font-semibold;
|
||||
}
|
||||
|
||||
.c-predefined-mutations__item-description {
|
||||
@apply text-sm text-gray-600 dark:text-gray-400;
|
||||
}
|
||||
|
||||
.c-predefined-mutations__item-amount {
|
||||
@apply text-lg font-semibold;
|
||||
}
|
||||
|
||||
.c-predefined-mutations__form {
|
||||
@apply mt-6 space-y-6;
|
||||
}
|
||||
|
||||
.c-predefined-mutations__field {
|
||||
@apply block;
|
||||
}
|
||||
|
||||
.c-predefined-mutations__label {
|
||||
@apply block text-sm font-medium text-gray-700 dark:text-gray-300;
|
||||
}
|
||||
|
||||
.c-predefined-mutations__input {
|
||||
@apply mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 dark:focus:border-indigo-600 dark:focus:ring-indigo-600;
|
||||
}
|
||||
|
||||
.c-predefined-mutations__textarea {
|
||||
@apply mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 dark:focus:border-indigo-600 dark:focus:ring-indigo-600;
|
||||
}
|
||||
|
||||
.c-predefined-mutations__select {
|
||||
@apply mt-1 block w-full rounded-md border-gray-300 bg-white p-2 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 dark:focus:border-indigo-600 dark:focus:ring-indigo-600;
|
||||
}
|
||||
|
||||
.c-predefined-mutations__actions {
|
||||
@apply flex items-center gap-4;
|
||||
}
|
||||
|
||||
.c-predefined-mutations__submit-btn {
|
||||
@apply inline-flex items-center rounded-md border border-transparent bg-gray-800 px-4 py-2 text-xs font-semibold tracking-widest text-white uppercase transition duration-150 ease-in-out hover:bg-gray-700 focus:bg-gray-700 focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:outline-none active:bg-gray-900 dark:bg-gray-200 dark:text-gray-800 dark:hover:bg-white dark:focus:bg-white dark:focus:ring-offset-gray-800 dark:active:bg-gray-300;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,137 @@
|
||||
<script setup lang="ts">
|
||||
import { Head, useForm, Link as InertiaLink } from '@inertiajs/vue3';
|
||||
import { route } from 'ziggy-js';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
dynamic: {
|
||||
id: number;
|
||||
name: string;
|
||||
rules: string;
|
||||
};
|
||||
}>();
|
||||
|
||||
const form = useForm({
|
||||
name: props.dynamic.name,
|
||||
rules: props.dynamic.rules,
|
||||
});
|
||||
|
||||
const breadcrumbs = [
|
||||
{
|
||||
name: 'Dynamics',
|
||||
href: route('dynamics.index'),
|
||||
},
|
||||
{
|
||||
name: props.dynamic.name,
|
||||
href: route('dynamics.show', props.dynamic.id),
|
||||
},
|
||||
{
|
||||
name: 'Settings',
|
||||
href: route('dynamics.edit', props.dynamic.id),
|
||||
},
|
||||
];
|
||||
|
||||
function submit() {
|
||||
form.patch(route('dynamics.update', props.dynamic.id));
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Head title="Dynamic Settings" />
|
||||
|
||||
<div class="c-dynamic-settings">
|
||||
<div class="c-dynamic-settings__container">
|
||||
<div class="c-dynamic-settings__card">
|
||||
<div class="c-dynamic-settings__body">
|
||||
<h3 class="c-dynamic-settings__title">Dynamic Settings</h3>
|
||||
|
||||
<form @submit.prevent="submit" class="c-dynamic-settings__form">
|
||||
<div class="c-dynamic-settings__field">
|
||||
<label for="name" class="c-dynamic-settings__label">Name</label>
|
||||
<input v-model="form.name" id="name" type="text" class="c-dynamic-settings__input" />
|
||||
<div v-if="form.errors.name" class="c-dynamic-settings__error">
|
||||
{{ form.errors.name }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="c-dynamic-settings__field">
|
||||
<label for="rules" class="c-dynamic-settings__label">Rules</label>
|
||||
<textarea v-model="form.rules" id="rules" rows="4" class="c-dynamic-settings__textarea"></textarea>
|
||||
<div v-if="form.errors.rules" class="c-dynamic-settings__error">
|
||||
{{ form.errors.rules }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="c-dynamic-settings__actions">
|
||||
<button type="submit" :disabled="form.processing" class="c-dynamic-settings__submit-btn">
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8">
|
||||
<InertiaLink :href="route('dynamics.predefined-mutations.index', dynamic.id)" class="c-dynamic-settings__submit-btn">
|
||||
Manage Predefined Mutations
|
||||
</InertiaLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@reference "../../../css/app.css";
|
||||
|
||||
.c-dynamic-settings {
|
||||
@apply py-12;
|
||||
}
|
||||
|
||||
.c-dynamic-settings__container {
|
||||
@apply mx-auto max-w-7xl sm:px-6 lg:px-8;
|
||||
}
|
||||
|
||||
.c-dynamic-settings__card {
|
||||
@apply overflow-hidden bg-white shadow-sm sm:rounded-lg dark:bg-gray-800;
|
||||
}
|
||||
|
||||
.c-dynamic-settings__body {
|
||||
@apply p-6 text-gray-900 dark:text-gray-100;
|
||||
}
|
||||
|
||||
.c-dynamic-settings__title {
|
||||
@apply text-lg font-medium;
|
||||
}
|
||||
|
||||
.c-dynamic-settings__form {
|
||||
@apply mt-6 space-y-6;
|
||||
}
|
||||
|
||||
.c-dynamic-settings__field {
|
||||
@apply block;
|
||||
}
|
||||
|
||||
.c-dynamic-settings__label {
|
||||
@apply block text-sm font-medium text-gray-700 dark:text-gray-300;
|
||||
}
|
||||
|
||||
.c-dynamic-settings__input {
|
||||
@apply mt-1 block w-full rounded-md border border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 dark:focus:border-indigo-600 dark:focus:ring-indigo-600;
|
||||
}
|
||||
|
||||
.c-dynamic-settings__textarea {
|
||||
@apply mt-1 block w-full rounded-md border border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 dark:focus:border-indigo-600 dark:focus:ring-indigo-600;
|
||||
}
|
||||
|
||||
.c-dynamic-settings__error {
|
||||
@apply text-sm text-red-600;
|
||||
}
|
||||
|
||||
.c-dynamic-settings__actions {
|
||||
@apply flex items-center gap-4;
|
||||
}
|
||||
|
||||
.c-dynamic-settings__submit-btn {
|
||||
@apply inline-flex items-center rounded-md border border-transparent bg-gray-800 px-4 py-2 text-xs font-semibold tracking-widest text-white uppercase transition duration-150 ease-in-out hover:bg-gray-700 focus:bg-gray-700 focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:outline-none active:bg-gray-900 dark:bg-gray-200 dark:text-gray-800 dark:hover:bg-white dark:focus:bg-white dark:focus:ring-offset-gray-800 dark:active:bg-gray-300;
|
||||
}
|
||||
</style>
|
||||
@@ -1,10 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { Head } from '@inertiajs/vue3';
|
||||
import { route } from 'ziggy-js';
|
||||
import Chat from '@/components/Chat.vue';
|
||||
import CreateLedgerForm from '@/components/CreateLedgerForm.vue';
|
||||
import LedgerList from '@/components/LedgerList.vue';
|
||||
import ParticipantsList from '@/components/ParticipantsList.vue';
|
||||
import LedgerList from '@/components/LedgerList.vue';
|
||||
import { Head, Link as InertiaLink } from '@inertiajs/vue3';
|
||||
import { route } from 'ziggy-js';
|
||||
|
||||
const props = defineProps<{
|
||||
dynamic: {
|
||||
@@ -21,6 +20,7 @@ const props = defineProps<{
|
||||
media?: Array<{ id: number; url: string; mime_type: string }>;
|
||||
}>;
|
||||
};
|
||||
isOwner: boolean;
|
||||
}>();
|
||||
|
||||
const breadcrumbs = [
|
||||
@@ -42,10 +42,17 @@ const breadcrumbs = [
|
||||
<div class="c-dynamic-show__container">
|
||||
<div class="c-dynamic-show__card">
|
||||
<div class="c-dynamic-show__body">
|
||||
<h3 class="c-dynamic-show__title">{{ dynamic.name }}</h3>
|
||||
<p class="c-dynamic-show__rules">
|
||||
{{ dynamic.rules }}
|
||||
</p>
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
<h3 class="c-dynamic-show__title">{{ dynamic.name }}</h3>
|
||||
<p class="c-dynamic-show__rules">
|
||||
{{ dynamic.rules }}
|
||||
</p>
|
||||
</div>
|
||||
<InertiaLink v-if="isOwner" :href="route('dynamics.edit', dynamic.id)" class="c-dynamic-show__settings-btn">
|
||||
Settings
|
||||
</InertiaLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -58,8 +65,14 @@ const breadcrumbs = [
|
||||
<!-- Ledgers List Component -->
|
||||
<LedgerList :dynamic-id="dynamic.id" :ledgers="dynamic.ledgers" />
|
||||
|
||||
<!-- Create Ledger Form Component -->
|
||||
<CreateLedgerForm :dynamic-id="dynamic.id" />
|
||||
<div v-if="isOwner" class="mt-8 flex gap-4">
|
||||
<InertiaLink :href="route('dynamics.invitations.create', dynamic.id)" class="c-dynamic-show__action-btn">
|
||||
Invite User
|
||||
</InertiaLink>
|
||||
<InertiaLink :href="route('dynamics.ledgers.create', dynamic.id)" class="c-dynamic-show__action-btn">
|
||||
Create Ledger
|
||||
</InertiaLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -92,6 +105,15 @@ const breadcrumbs = [
|
||||
}
|
||||
|
||||
.c-dynamic-show__rules {
|
||||
@apply mt-2 text-sm text-gray-600 dark:text-gray-400;
|
||||
@apply mt-2 text-sm;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.c-dynamic-show__settings-btn {
|
||||
@apply inline-flex items-center rounded-md border border-transparent bg-gray-800 px-4 py-2 text-xs font-semibold tracking-widest text-white uppercase transition duration-150 ease-in-out hover:bg-gray-700 focus:bg-gray-700 focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:outline-none active:bg-gray-900 dark:bg-gray-200 dark:text-gray-800 dark:hover:bg-white dark:focus:bg-white dark:focus:ring-offset-gray-800 dark:active:bg-gray-300;
|
||||
}
|
||||
|
||||
.c-dynamic-show__action-btn {
|
||||
@apply inline-flex items-center rounded-md border border-transparent bg-gray-800 px-4 py-2 text-xs font-semibold tracking-widest text-white uppercase transition duration-150 ease-in-out hover:bg-gray-700 focus:bg-gray-700 focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:outline-none active:bg-gray-900 dark:bg-gray-200 dark:text-gray-800 dark:hover:bg-white dark:focus:bg-white dark:focus:ring-offset-gray-800 dark:active:bg-gray-300;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import { Head, useForm } from '@inertiajs/vue3';
|
||||
import { route } from 'ziggy-js';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
import CreateLedgerForm from '@/components/CreateLedgerForm.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
dynamic: {
|
||||
id: number;
|
||||
name: string;
|
||||
};
|
||||
}>();
|
||||
|
||||
const breadcrumbs = [
|
||||
{
|
||||
name: 'Dynamics',
|
||||
href: route('dynamics.index'),
|
||||
},
|
||||
{
|
||||
name: props.dynamic.name,
|
||||
href: route('dynamics.show', props.dynamic.id),
|
||||
},
|
||||
{
|
||||
name: 'Create Ledger',
|
||||
href: route('dynamics.ledgers.create', props.dynamic.id),
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Head title="Create Ledger" />
|
||||
|
||||
<div class="py-12">
|
||||
<div class="mx-auto max-w-7xl sm:px-6 lg:px-8">
|
||||
<CreateLedgerForm :dynamic-id="dynamic.id" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,18 @@
|
||||
<x-mail::message>
|
||||
# You have been invited!
|
||||
|
||||
Hello,
|
||||
|
||||
**{{ $inviterName }}** has invited you to join their Dynamic: **{{ $dynamicName }}** as a **{{ strtoupper($role) }}**.
|
||||
|
||||
Only the user registered with this email address can accept this invitation. The invitation link will be valid for 7 days.
|
||||
|
||||
<x-mail::button :url="$acceptUrl">
|
||||
Accept Invitation
|
||||
</x-mail::button>
|
||||
|
||||
If you do not have an account yet, please register using this email address first, then click the button above.
|
||||
|
||||
Thanks,<br>
|
||||
{{ config('app.name') }}
|
||||
</x-mail::message>
|
||||
+16
-2
@@ -12,12 +12,26 @@ Route::inertia('/', 'Welcome')->name('home');
|
||||
Route::middleware(['auth', 'verified'])->group(function () {
|
||||
Route::get('dashboard', [DashboardController::class, 'index'])->name('dashboard');
|
||||
|
||||
Route::resource('dynamics', DynamicController::class);
|
||||
Route::resource('dynamics.ledgers', LedgerController::class)->scoped();
|
||||
Route::resource('dynamics', DynamicController::class)->except(['edit', 'update']);
|
||||
Route::get('/dynamics/{dynamic}/settings', [DynamicController::class, 'edit'])->name('dynamics.edit');
|
||||
Route::patch('/dynamics/{dynamic}/settings', [DynamicController::class, 'update'])->name('dynamics.update');
|
||||
|
||||
Route::get('/dynamics/{dynamic}/ledgers/create', [LedgerController::class, 'create'])->name('dynamics.ledgers.create');
|
||||
Route::resource('dynamics.ledgers', LedgerController::class)->scoped()->except(['create']);
|
||||
|
||||
Route::resource('dynamics.predefined-mutations', \App\Http\Controllers\PredefinedMutationController::class)->scoped();
|
||||
|
||||
Route::resource('dynamics.ledgers.mutations', MutationController::class)->scoped();
|
||||
|
||||
Route::get('/dynamics/{dynamic}/invitations/create', [\App\Http\Controllers\DynamicInvitationController::class, 'create'])->name('dynamics.invitations.create');
|
||||
Route::post('/dynamics/{dynamic}/invitations', [\App\Http\Controllers\DynamicInvitationController::class, 'store'])->name('dynamics.invitations.store');
|
||||
|
||||
Route::post('/chats/{chat}/messages', [MessageController::class, 'store'])->name('chats.messages.store');
|
||||
});
|
||||
|
||||
Route::get('/invitations/accept/{token}', [\App\Http\Controllers\DynamicInvitationController::class, 'accept'])
|
||||
->middleware(['auth', 'signed'])
|
||||
->name('dynamics.invitations.accept');
|
||||
|
||||
\Illuminate\Support\Facades\Broadcast::routes();
|
||||
require __DIR__.'/settings.php';
|
||||
|
||||
@@ -104,9 +104,9 @@ test('dashboard groups and filters unread entities correctly based on cursor', f
|
||||
->where('unreadEntities.0.name', 'Testing Dynamic')
|
||||
->where('unreadEntities.0.unread_count', 1)
|
||||
->has('unreadEntities.0.context_activities', 1) // Should have old message as context
|
||||
->where('unreadEntities.0.context_activities.0.description', 'Old message context')
|
||||
->where('unreadEntities.0.context_activities.0.content', 'Old message context')
|
||||
->has('unreadEntities.0.new_activities', 1) // Should have unread message
|
||||
->where('unreadEntities.0.new_activities.0.description', 'New unread message alert')
|
||||
->where('unreadEntities.0.new_activities.0.content', 'New unread message alert')
|
||||
);
|
||||
|
||||
// Now visit the Dynamic, which clears the unread count
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\Dynamic;
|
||||
use App\Models\DynamicInvitation;
|
||||
use App\Mail\DynamicInvitationMail;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
|
||||
test('only owners can invite other users to a dynamic', function () {
|
||||
Mail::fake();
|
||||
|
||||
$owner = User::factory()->create();
|
||||
$participant = User::factory()->create();
|
||||
$dynamic = Dynamic::factory()->create();
|
||||
|
||||
$dynamic->participants()->attach($owner->id, ['role' => 'owner']);
|
||||
$dynamic->participants()->attach($participant->id, ['role' => 'participant']);
|
||||
|
||||
// 1. Participant tries to send an invite (forbidden)
|
||||
$response = $this->actingAs($participant)
|
||||
->post(route('dynamics.invitations.store', $dynamic), [
|
||||
'email' => 'invitee@example.com',
|
||||
'role' => 'participant',
|
||||
]);
|
||||
|
||||
$response->assertStatus(403);
|
||||
Mail::assertNothingSent();
|
||||
|
||||
// 2. Owner sends a valid invite (allowed)
|
||||
$response = $this->actingAs($owner)
|
||||
->post(route('dynamics.invitations.store', $dynamic), [
|
||||
'email' => 'invitee@example.com',
|
||||
'role' => 'participant',
|
||||
]);
|
||||
|
||||
$response->assertRedirect();
|
||||
$response->assertSessionHasNoErrors();
|
||||
|
||||
// Verify invitation is stored
|
||||
$invitation = DynamicInvitation::firstWhere('email', 'invitee@example.com');
|
||||
expect($invitation)->not->toBeNull();
|
||||
expect($invitation->role)->toBe('participant');
|
||||
expect($invitation->dynamic_id)->toBe($dynamic->id);
|
||||
|
||||
// Verify email was dispatched
|
||||
Mail::assertSent(DynamicInvitationMail::class, function ($mail) use ($invitation) {
|
||||
return $mail->hasTo($invitation->email);
|
||||
});
|
||||
});
|
||||
|
||||
test('only the user with the specified email address can accept the link', function () {
|
||||
$owner = User::factory()->create();
|
||||
$invitee = User::factory()->create(['email' => 'matching@example.com']);
|
||||
$hijacker = User::factory()->create(['email' => 'hijacker@example.com']);
|
||||
$dynamic = Dynamic::factory()->create();
|
||||
|
||||
$dynamic->participants()->attach($owner->id, ['role' => 'owner']);
|
||||
|
||||
// Create a pending invitation
|
||||
$invitation = $dynamic->invitations()->create([
|
||||
'email' => 'matching@example.com',
|
||||
'role' => 'editor',
|
||||
'token' => 'secure_test_token_123',
|
||||
'expires_at' => now()->addDays(7),
|
||||
]);
|
||||
|
||||
// Generate a secure, valid signed URL
|
||||
$signedUrl = URL::temporarySignedRoute(
|
||||
'dynamics.invitations.accept',
|
||||
$invitation->expires_at,
|
||||
['token' => $invitation->token]
|
||||
);
|
||||
|
||||
// 1. Unauthenticated user tries to accept (redirected to login)
|
||||
$response = $this->get($signedUrl);
|
||||
$response->assertRedirect('/login');
|
||||
|
||||
// 2. Different user (hijacker) tries to accept (forbidden / 403)
|
||||
$response = $this->actingAs($hijacker)->get($signedUrl);
|
||||
$response->assertStatus(403);
|
||||
expect($dynamic->participants()->where('user_id', $hijacker->id)->exists())->toBeFalse();
|
||||
|
||||
// 3. Intended user accepts the signed invitation link (success)
|
||||
$response = $this->actingAs($invitee)->get($signedUrl);
|
||||
$response->assertRedirect(route('dynamics.show', $dynamic));
|
||||
|
||||
// Verify invitee is joined as a participant with the specified role
|
||||
$isJoined = $dynamic->participants()
|
||||
->where('user_id', $invitee->id)
|
||||
->wherePivot('role', 'editor')
|
||||
->exists();
|
||||
|
||||
expect($isJoined)->toBeTrue();
|
||||
|
||||
// Verify invitation is deleted from the database
|
||||
expect(DynamicInvitation::where('token', 'secure_test_token_123')->exists())->toBeFalse();
|
||||
|
||||
// Verify system notification is added to Dynamic activity chat
|
||||
$chatMessages = $dynamic->chat->messages;
|
||||
expect($chatMessages)->not->toBeEmpty();
|
||||
expect($chatMessages->last()->content)->toBe("{$invitee->name} joined the Dynamic as a EDITOR");
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\Dynamic;
|
||||
use App\Models\Ledger;
|
||||
|
||||
test('dynamic owners can view ledger creation form and create ledgers', function () {
|
||||
$owner = User::factory()->create();
|
||||
$dynamic = Dynamic::factory()->create();
|
||||
$dynamic->participants()->attach($owner->id, ['role' => 'owner']);
|
||||
|
||||
$this->actingAs($owner);
|
||||
|
||||
// Can view form
|
||||
$this->get(route('dynamics.ledgers.create', $dynamic->id))->assertOk();
|
||||
|
||||
// Can store ledger
|
||||
$response = $this->post(route('dynamics.ledgers.store', $dynamic->id), [
|
||||
'name' => 'Chores Ledger',
|
||||
'rules' => 'Do the tasks.',
|
||||
'alignment' => 'positive',
|
||||
]);
|
||||
|
||||
$response->assertSessionHasNoErrors();
|
||||
$response->assertRedirect(route('dynamics.show', $dynamic->id));
|
||||
|
||||
$this->assertDatabaseHas('ledgers', [
|
||||
'dynamic_id' => $dynamic->id,
|
||||
'name' => 'Chores Ledger',
|
||||
'alignment' => 'positive',
|
||||
]);
|
||||
});
|
||||
|
||||
test('non-owners cannot view ledger creation form or store ledgers', function () {
|
||||
$owner = User::factory()->create();
|
||||
$participant = User::factory()->create();
|
||||
$dynamic = Dynamic::factory()->create();
|
||||
$dynamic->participants()->attach($owner->id, ['role' => 'owner']);
|
||||
$dynamic->participants()->attach($participant->id, ['role' => 'participant']);
|
||||
|
||||
$this->actingAs($participant);
|
||||
|
||||
// Cannot view form
|
||||
$this->get(route('dynamics.ledgers.create', $dynamic->id))->assertStatus(403);
|
||||
|
||||
// Cannot store ledger
|
||||
$response = $this->post(route('dynamics.ledgers.store', $dynamic->id), [
|
||||
'name' => 'Illegal Ledger',
|
||||
'rules' => 'This should fail.',
|
||||
'alignment' => 'positive',
|
||||
]);
|
||||
|
||||
$response->assertStatus(403);
|
||||
$this->assertDatabaseMissing('ledgers', [
|
||||
'name' => 'Illegal Ledger',
|
||||
]);
|
||||
});
|
||||
@@ -20,8 +20,8 @@ test('media can be attached to mutations, ledgers, and messages', function () {
|
||||
$this->actingAs($user);
|
||||
|
||||
// 1. Test attaching media to a mutation
|
||||
$file1 = UploadedFile::fake()->image('proof1.jpg');
|
||||
$file2 = UploadedFile::fake()->image('proof2.png');
|
||||
$file1 = UploadedFile::fake()->create('proof1.jpg', 100);
|
||||
$file2 = UploadedFile::fake()->create('proof2.png', 100);
|
||||
|
||||
$response = $this->post(route('dynamics.ledgers.mutations.store', [$dynamic, $ledger]), [
|
||||
'amount' => 50,
|
||||
@@ -42,7 +42,7 @@ test('media can be attached to mutations, ledgers, and messages', function () {
|
||||
Storage::disk('public')->assertExists($mutation->media->last()->file_path);
|
||||
|
||||
// 2. Test attaching media to a ledger
|
||||
$file3 = UploadedFile::fake()->image('rules.jpg');
|
||||
$file3 = UploadedFile::fake()->create('rules.jpg', 100);
|
||||
$response = $this->post(route('dynamics.ledgers.store', $dynamic), [
|
||||
'name' => 'Worship Ledger',
|
||||
'rules' => 'Specific rules',
|
||||
@@ -60,7 +60,7 @@ test('media can be attached to mutations, ledgers, and messages', function () {
|
||||
|
||||
// 3. Test attaching media to a chat message
|
||||
$chat = $dynamic->chat;
|
||||
$file4 = UploadedFile::fake()->image('chat_img.jpg');
|
||||
$file4 = UploadedFile::fake()->create('chat_img.jpg', 100);
|
||||
$response = $this->post(route('chats.messages.store', $chat), [
|
||||
'content' => 'Check this out!',
|
||||
'media' => [$file4],
|
||||
|
||||
Reference in New Issue
Block a user