Compare commits

..

24 Commits

Author SHA1 Message Date
Daan Meijer
d68fc33bcb extra documentation updates
Some checks failed
linter / quality (push) Failing after 1m3s
tests / ci (8.3) (push) Failing after 48s
tests / ci (8.4) (push) Failing after 1m5s
tests / ci (8.5) (push) Failing after 1m4s
2026-06-22 17:27:36 +02:00
Daan Meijer
9c270973ed chat improvements
Some checks failed
linter / quality (push) Failing after 1m3s
tests / ci (8.3) (push) Failing after 50s
tests / ci (8.4) (push) Failing after 1m4s
tests / ci (8.5) (push) Failing after 1m5s
2026-06-22 16:50:08 +02:00
Daan Meijer
de88658a48 show messages directly when sending them 2026-06-22 16:27:54 +02:00
Daan Meijer
0f8edfb827 polishing, mutationlist gerepareerd
Some checks failed
linter / quality (push) Failing after 1m2s
tests / ci (8.3) (push) Failing after 49s
tests / ci (8.4) (push) Failing after 1m5s
tests / ci (8.5) (push) Failing after 1m5s
2026-06-22 16:16:27 +02:00
Daan Meijer
188c4435cb standardization of policies/permissions
Some checks failed
linter / quality (push) Failing after 1m3s
tests / ci (8.3) (push) Failing after 48s
tests / ci (8.4) (push) Failing after 1m5s
tests / ci (8.5) (push) Failing after 1m4s
2026-06-22 16:13:32 +02:00
Daan Meijer
c60033b365 polishing, some breadcrumbs, predefined mutations implemented more 2026-06-22 15:57:04 +02:00
Daan Meijer
4ce510402c participants shouldn't show ids either
Some checks failed
linter / quality (push) Failing after 1m4s
tests / ci (8.3) (push) Failing after 49s
tests / ci (8.4) (push) Failing after 1m5s
tests / ci (8.5) (push) Failing after 1m5s
2026-06-22 15:32:59 +02:00
Daan Meijer
77c3e34d5b polishing
Some checks failed
linter / quality (push) Failing after 1m2s
tests / ci (8.3) (push) Failing after 47s
tests / ci (8.4) (push) Failing after 1m5s
tests / ci (8.5) (push) Failing after 1m5s
2026-06-22 15:21:27 +02:00
Daan Meijer
f3d5be6a80 better target for message notifications
Some checks failed
linter / quality (push) Failing after 1m4s
tests / ci (8.3) (push) Failing after 48s
tests / ci (8.4) (push) Failing after 1m3s
tests / ci (8.5) (push) Failing after 1m10s
2026-06-22 13:55:38 +02:00
Daan Meijer
10bd46a53e formatting, juiste use voor UpdateDynamicRequest
Some checks failed
linter / quality (push) Failing after 1m2s
tests / ci (8.3) (push) Failing after 48s
tests / ci (8.4) (push) Failing after 1m5s
tests / ci (8.5) (push) Failing after 1m5s
2026-06-22 00:10:39 +02:00
Daan Meijer
3e473de826 correct checken of iemand dynamic settings kan aanpassen
Some checks failed
linter / quality (push) Failing after 1m4s
tests / ci (8.3) (push) Failing after 48s
tests / ci (8.4) (push) Failing after 1m6s
tests / ci (8.5) (push) Failing after 1m5s
2026-06-22 00:08:22 +02:00
Daan Meijer
64d6214aed removed vite-pwa
Some checks failed
linter / quality (push) Failing after 1m4s
tests / ci (8.3) (push) Failing after 48s
tests / ci (8.4) (push) Failing after 1m4s
tests / ci (8.5) (push) Failing after 1m4s
2026-06-22 00:01:50 +02:00
Daan Meijer
2b28831c2f web push nu zonder axios
Some checks failed
linter / quality (push) Failing after 1m4s
tests / ci (8.3) (push) Failing after 49s
tests / ci (8.5) (push) Failing after 1m7s
tests / ci (8.4) (push) Failing after 10m2s
2026-06-21 23:55:36 +02:00
Daan Meijer
5ad018ed6e retry voor web push
Some checks failed
linter / quality (push) Failing after 1m7s
tests / ci (8.3) (push) Failing after 50s
tests / ci (8.4) (push) Failing after 1m6s
tests / ci (8.5) (push) Failing after 1m6s
2026-06-21 23:45:50 +02:00
Daan Meijer
bcf583866a fix voor build locatie van notificatie service worker
Some checks failed
linter / quality (push) Failing after 1m7s
tests / ci (8.3) (push) Failing after 50s
tests / ci (8.4) (push) Failing after 1m9s
tests / ci (8.5) (push) Failing after 1m7s
2026-06-21 23:36:07 +02:00
Daan Meijer
98dc8659ba web push notifications
Some checks failed
linter / quality (push) Failing after 1m5s
tests / ci (8.3) (push) Failing after 48s
tests / ci (8.4) (push) Failing after 1m3s
tests / ci (8.5) (push) Failing after 1m2s
2026-06-21 23:17:33 +02:00
Daan Meijer
1e0782385b over naar uuids, policies, predefined mutations aan kunnen passen
Some checks failed
linter / quality (push) Failing after 1m5s
tests / ci (8.4) (push) Failing after 1m7s
tests / ci (8.5) (push) Failing after 1m5s
tests / ci (8.3) (push) Failing after 12m5s
2026-06-21 23:09:38 +02:00
Daan Meijer
06e5600447 ui improvements
Some checks failed
linter / quality (push) Failing after 1m8s
tests / ci (8.3) (push) Failing after 52s
tests / ci (8.4) (push) Failing after 1m9s
tests / ci (8.5) (push) Failing after 1m9s
2026-06-17 23:07:59 +02:00
Daan Meijer
ed16d5dcda feat: Implement chat pagination, participant single page with mutations, and fix user link substitutions 2026-06-17 22:55:11 +02:00
Daan Meijer
11df4ef55c further development of the predefinedmutations
Some checks failed
linter / quality (push) Failing after 1m16s
tests / ci (8.3) (push) Failing after 59s
tests / ci (8.4) (push) Failing after 1m14s
tests / ci (8.5) (push) Failing after 1m15s
2026-06-17 13:30:55 +02:00
Daan Meijer
ed23bb2a78 docs: Document ActivityService and system message architecture in AGENTS.md and DECISIONS.md 2026-06-17 11:06:59 +02:00
Daan Meijer
ec8cbff770 refactor: Use ActivityService to create system messages in seeder 2026-06-17 11:06:59 +02:00
Daan Meijer
c6d482e3de better system messages, display name relocation
Some checks failed
linter / quality (push) Failing after 1m6s
tests / ci (8.3) (push) Failing after 54s
tests / ci (8.4) (push) Failing after 1m8s
tests / ci (8.5) (push) Failing after 1m13s
2026-06-17 11:00:35 +02:00
Daan Meijer
0fee3c1972 different names in different dynamics
Some checks failed
linter / quality (push) Failing after 1m9s
tests / ci (8.3) (push) Failing after 51s
tests / ci (8.4) (push) Failing after 1m7s
tests / ci (8.5) (push) Failing after 1m9s
2026-06-17 09:38:54 +02:00
110 changed files with 3947 additions and 595 deletions

View File

@ -63,3 +63,7 @@ AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}" VITE_APP_NAME="${APP_NAME}"
VAPID_PUBLIC_KEY=
VAPID_PRIVATE_KEY=
VITE_VAPID_PUBLIC_KEY="${VAPID_PUBLIC_KEY}"

2
.gitignore vendored
View File

@ -27,3 +27,5 @@ yarn-error.log
/.nova /.nova
/.vscode /.vscode
/.zed /.zed
/public/sw.js
/public/workbox-*.js

View File

@ -5,11 +5,6 @@
The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to ensure the best experience when building Laravel applications. The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to ensure the best experience when building Laravel applications.
## Persistent Project Context (IMPORTANT)
- You MUST read, understand, and strictly follow the underlying business logic and feature requests documented in `IDEA.md` in every session.
- You MUST adhere to all style architecture, backend transaction, and integration decisions recorded in `DECISIONS.md`. Update `DECISIONS.md` when any major design decisions are made during a session.
## Foundational Context ## Foundational Context
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions. This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
@ -58,12 +53,6 @@ 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. - Stick to existing directory structure; don't create new base folders without approval.
- Do not change the application's dependencies 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 ## 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. - 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.
@ -137,7 +126,6 @@ This project has domain-specific skills available in `**/skills/**`. You MUST ac
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass. - Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test --compact` with a specific filename or filter. - Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test --compact` with a specific filename or filter.
- **Environment Isolation during Test Runs (IMPORTANT)**: The test runner environment (e.g. `vendor/bin/pest` or `php artisan test`) can be polluted by the main project's local `.env` file settings if run directly in the active shell. This pollution can override test-specific configurations defined in `phpunit.xml` and lead to unexpected failures, such as CSRF (`419 Page Expired`) errors or database connection issues. Always prefix test commands with `env -i PATH=$PATH HOME=$HOME TERM=$TERM` (e.g., `env -i PATH=$PATH HOME=$HOME TERM=$TERM vendor/bin/pest`) to enforce a clean, isolated environment run.
=== inertia-laravel/core rules === === inertia-laravel/core rules ===

View File

@ -59,6 +59,27 @@ We created `app/Services/ActivityService.php` to centralize the creation of syst
* **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. * **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. * **Seeder Refactoring**: The `DatabaseSeeder` was refactored to use the `ActivityService` to generate all system messages, ensuring consistency.
### 8. Event-Driven Automated System Logging
We relocated the dynamic system activity message generation out of individual controller endpoints and into the **Eloquent `Mutation` Model's `booted` -> `created` event hook**.
* **Unified generation:** Any mutation creation (whether occurring via a controller submission, an automated Pest test factory, or seeders during `php artisan db:seed`) now automatically and reliably generates correct system log messages.
* **Database Seeder fixed:** Reverted the database seeder (`DatabaseSeeder.php`) back to using standard clean Eloquent creations. Since model events automatically trigger, `php artisan db:seed` executes cleanly and builds a rich, fully populated database history with system messages out-of-the-box.
### 9. Standardized Policy-Driven UI Capabilities (`can` prop)
To maintain strict data security and clean up front-end markup, we eliminated unstandardized, hardcoded client-side role checks (like `isOwner` properties or manual `pivot.role === 'owner'` checks) and replaced them completely with dynamic **policy-driven capabilities** returned directly from Laravel policies as `can` objects.
* **Centralized checks:** Both the dynamic and ledger show routes return a standard `can` prop to the frontend (e.g., `can: { update: boolean, close: boolean }`).
* **Polymorphic Resource Capabilities:** Each mutation model is wrapped in `MutationResource` which appends its own localized policy checks (`update` for approving suggestions, `void` for voiding) at the individual record level.
* **State-based policies:** The `MutationPolicy` methods enforce both ownership authorization and state-based business constraints simultaneously (e.g., a mutation can be approved/updated *only* if its status is currently `'pending'`; and can be voided *only* if its status is not `'voided'`). This keeps the Vue templates purely declarative (e.g., `v-if="mutation.can.update"`) and automatically protects the backend controllers against illegal state transitions.
### 10. Ledger-Scoped Predefined Mutation Templates ("Rewards")
Predefined mutations act as point-based templates ("purchases" or reusable chores) and belong strictly to specific **Ledgers** instead of broad Dynamics.
* **Domain Alignment:** Moving the resource nesting under ledgers (`dynamics.ledgers.predefined-mutations`) aligns perfectly with the mental model of spending points on a ledger.
* **Type-Less Rewards:** To simplify both the database schema and UI/UX, we eliminated the explicit `type` column ('reward' vs. 'penalty') from predefined mutations. They act as generic point-carrying "Rewards" whose amount can naturellement be positive (earning points) or negative (making a purchase / deducting points) without requiring restrictive explicit categorization.
### 11. Silent XHR Chat Pagination & Smooth Scrolling UX
To optimize chat-feed performance and improve overall user experience:
* **Silent pagination (No URL pollution):** Rather than using Inertia `router.get` visits which push `?page=x` into the browser URL and break history during page reloads, we implemented a **silent background fetch** (using native browser `fetch()`) that queries our dedicated messages JSON API endpoints and prepends older messages silently to the feed.
* **Scroll Preservation:** Added `preserveScroll: true` to the Inertia `form.post` call in `Chat.vue` to prevent the active page scroll position from jumping or shifting when a new message is successfully submitted.
## Initial Database Schema ## Initial Database Schema
I will start with a basic schema and evolve it as I build features. I will start with a basic schema and evolve it as I build features.

View File

@ -14,4 +14,19 @@ Welcome to the Ledgerrz codebase! This file defines the persistent guidelines, a
* **PHP/Laravel:** PHP 8.4 & Laravel 13. Adhere to typed parameters and return values. Ensure controllers extend properly and use required authorization traits (e.g., `AuthorizesRequests`). * **PHP/Laravel:** PHP 8.4 & Laravel 13. Adhere to typed parameters and return values. Ensure controllers extend properly and use required authorization traits (e.g., `AuthorizesRequests`).
* **Frontend Styling (BEM):** Replaced direct Tailwind inline utility-class markup with **BEM (Block, Element, Modifier)**. All custom component styles must live inside `<style scoped>` blocks with a relative `@reference "../../css/app.css"` directive to pull variables without duplications. * **Frontend Styling (BEM):** Replaced direct Tailwind inline utility-class markup with **BEM (Block, Element, Modifier)**. All custom component styles must live inside `<style scoped>` blocks with a relative `@reference "../../css/app.css"` directive to pull variables without duplications.
* **Real-time Broadcasting:** Powered by `@laravel/echo-vue` with fallback configurations and Vite deduplication rules configured in `vite.config.ts`. * **Real-time Broadcasting:** Powered by `@laravel/echo-vue` with fallback configurations and Vite deduplication rules configured in `vite.config.ts`.
* **Testing:** Powered by Pest PHP (v4). Every backend controller, event, or model change must be validated by running `vendor/bin/pest`. * **Testing & Isolation:** Powered by Pest PHP (v4). Every backend controller, event, or model change must be validated by running tests. To prevent local `.env` variables from polluting the CLI test execution (causing CSRF/session 419 errors), **always** run tests in an isolated environment using:
```bash
env -i PATH="$PATH" php artisan test
```
* **Standardized Authorization (`can` prop):** Never write manual role checks (such as `pivot.role === 'owner'`) or hardcoded boolean flags (such as `isOwner`) inside Vue pages or components. Instead, always leverage Laravel policies on the backend and pass permissions reactively to the frontend as structured `can` objects (e.g., `can: { update: boolean, close: boolean }`).
* **Vue-Defined Breadcrumbs Layout:** All page-specific breadcrumbs should be declared locally inside the page's `.vue` file rather than returned from controllers. For dynamic, prop-dependent paths, always use the Inertia v3 layout callback function inside `defineOptions`:
```typescript
defineOptions({
layout: (props: any) => ({
breadcrumbs: [
{ title: 'Dynamics', href: route('dynamics.index') },
{ title: props.dynamic.name, href: route('dynamics.show', props.dynamic.id) }
]
})
});
```

24
IDEA.md
View File

@ -41,3 +41,27 @@ During this session, we successfully built out and verified several core archite
* Configured real-time notifications utilizing Laravel Reverb. * Configured real-time notifications utilizing Laravel Reverb.
* Documented CLI environment test pollution learnings inside `AGENTS.md` to prevent future CSRF `419` errors. * Documented CLI environment test pollution learnings inside `AGENTS.md` to prevent future CSRF `419` errors.
* Ensured full production assets compilation (`npm run build`) and achieved **45/45 passing Pest PHP tests with 206 assertions**. * Ensured full production assets compilation (`npm run build`) and achieved **45/45 passing Pest PHP tests with 206 assertions**.
6. **Ledger-Scoped Predefined Mutation Templates ("Rewards")**:
* Associated reusable point-based predefined mutation templates under specific Ledgers rather than broad Dynamics, mapping perfectly to the mental model of spending points on a ledger.
* Designed them purely as "Rewards" with positive or negative point amounts (handling both demerit-purchases and chores), removing the obsolete `type` categorization for a simpler, type-less, and sleeker UI/UX.
7. **User Activity Profiling & Detail Pages**:
* Created a dynamic user detail page (`dynamics.users.show`) scoped to each dynamic. It displays a participant's role, custom display name, fallback real name, and a clean chronological listing of their 10 most recent mutations (activities) in that dynamic.
8. **Polymorphic System Message placeholders & Dynamic Client-Side Linking**:
* Refactored system log activity messages to use native `<user:userId>` placeholders and associated them with polymorphic `subject_id` and `subject_type` objects.
* On the client-side, the chat component parses these placeholders into rich, clickable links to User Profiles, and dynamically matches and wraps referenced ledger names into links pointing directly to the ledger show page.
* Added backend-side placeholder resolution inside `ActivityService` for the dashboard, ensuring unread system logs translate cleanly to real names across multiple dynamics.
9. **Vite/Inertia v3 Layout Callback Breadcrumbs**:
* Utilized Inertia v3's powerful new layout callback API inside Vue page `defineOptions` to reactively resolve page-specific dynamic breadcrumbs at runtime using parsed page props, making the pages self-contained and keeping PHP controllers beautifully slim.
10. **Silent background Chat Pagination & Smooth Scrolling UX**:
* Implemented silent background XHR queries (using native browser `fetch()`) on our dedicated message JSON API routes to load older chat pages, completely bypassing browser history/URL pollution and preserving page state on refreshes.
* Integrated `preserveScroll: true` inside chat form submissions to completely prevent scroll jumps when sending messages.
11. **Standardized Policy-Driven UI Capabilities**:
* Eliminated unstandardized client-side role checks and boolean flags, replacing them with structured `can` capability objects returned directly from Laravel policies.
* Combined permission validation with state-based business constraints in `MutationPolicy` (e.g., suggestions can be approved/rejected only if `'pending'`; and voided only if not `'voided'`), securing both the frontend action buttons and backend controllers simultaneously.
* Achieved **65/65 passing Pest PHP tests with 333 assertions**.

View File

@ -30,6 +30,7 @@ class MutationCreated implements ShouldBroadcast
public function broadcastOn(): array public function broadcastOn(): array
{ {
$chatId = $this->mutation->ledger->dynamic->chat->id; $chatId = $this->mutation->ledger->dynamic->chat->id;
return [ return [
new PrivateChannel('chats.'.$chatId), new PrivateChannel('chats.'.$chatId),
]; ];

View File

@ -30,6 +30,7 @@ class MutationUpdated implements ShouldBroadcast
public function broadcastOn(): array public function broadcastOn(): array
{ {
$chatId = $this->mutation->ledger->dynamic->chat->id; $chatId = $this->mutation->ledger->dynamic->chat->id;
return [ return [
new PrivateChannel('chats.'.$chatId), new PrivateChannel('chats.'.$chatId),
]; ];

View File

@ -11,10 +11,10 @@ class DashboardController extends Controller
public function index(Request $request, ActivityService $activityService) public function index(Request $request, ActivityService $activityService)
{ {
$user = $request->user(); $user = $request->user();
$unreadEntities = $activityService->getUnreadEntitiesGrouped($user); $unreadDynamics = $activityService->getUnreadDynamicsGrouped($user);
return Inertia::render('Dashboard', [ return Inertia::render('Dashboard', [
'unreadEntities' => $unreadEntities, 'unreadDynamics' => $unreadDynamics,
]); ]);
} }
} }

View File

@ -2,8 +2,11 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Http\Requests\StoreDynamicRequest;
use App\Http\Requests\UpdateDynamicRequest; use App\Http\Requests\UpdateDynamicRequest;
use App\Http\Resources\DynamicResource;
use App\Http\Resources\LedgerResource;
use App\Http\Resources\MessageResource;
use App\Http\Resources\UserResource;
use App\Models\Dynamic; use App\Models\Dynamic;
use App\Services\ActivityService; use App\Services\ActivityService;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
@ -13,13 +16,14 @@ use Inertia\Inertia;
class DynamicController extends Controller class DynamicController extends Controller
{ {
use AuthorizesRequests; use AuthorizesRequests;
/** /**
* Display a listing of the resource. * Display a listing of the resource.
*/ */
public function index(Request $request) public function index(Request $request)
{ {
return Inertia::render('Dynamics/Index', [ return Inertia::render('Dynamics/Index', [
'dynamics' => $request->user()->dynamics()->get(), 'dynamics' => DynamicResource::collection($request->user()->dynamics()->get()),
]); ]);
} }
@ -52,24 +56,26 @@ class DynamicController extends Controller
$activityService->updateCursor($request->user(), $dynamic); $activityService->updateCursor($request->user(), $dynamic);
$dynamic->load([ $dynamic->load(['ledgers.media', 'participants', 'chat']);
'ledgers.media',
'participants',
'chat.messages.user',
'chat.messages.media'
]);
$isOwner = $dynamic->participants()
->where('user_id', $request->user()->id)
->where('role', 'owner')
->exists();
return Inertia::render('Dynamics/Show', [ return Inertia::render('Dynamics/Show', [
'dynamic' => $dynamic, 'dynamic' => new DynamicResource($dynamic),
'isOwner' => $isOwner, 'ledgers' => LedgerResource::collection($dynamic->ledgers),
'participants' => UserResource::collection($dynamic->participants),
'messages' => MessageResource::collection($dynamic->chat->messages()->with(['user', 'media'])->latest()->paginate(\App\Models\Message::PAGINATION_COUNT)),
'can' => [
'update' => $request->user()->can('update', $dynamic),
],
]); ]);
} }
public function messages(Request $request, Dynamic $dynamic)
{
$this->authorize('view', $dynamic);
return MessageResource::collection($dynamic->chat->messages()->with(['user', 'media'])->latest()->paginate(\App\Models\Message::PAGINATION_COUNT));
}
/** /**
* Show the form for editing the specified resource. * Show the form for editing the specified resource.
*/ */
@ -78,7 +84,7 @@ class DynamicController extends Controller
$this->authorize('update', $dynamic); $this->authorize('update', $dynamic);
return Inertia::render('Dynamics/Settings', [ return Inertia::render('Dynamics/Settings', [
'dynamic' => $dynamic, 'dynamic' => new DynamicResource($dynamic),
]); ]);
} }

View File

@ -5,16 +5,18 @@ namespace App\Http\Controllers;
use App\Mail\DynamicInvitationMail; use App\Mail\DynamicInvitationMail;
use App\Models\Dynamic; use App\Models\Dynamic;
use App\Models\DynamicInvitation; use App\Models\DynamicInvitation;
use App\Models\User;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Inertia\Inertia; use Inertia\Inertia;
class DynamicInvitationController extends Controller class DynamicInvitationController extends Controller
{ {
use AuthorizesRequests; use AuthorizesRequests;
/** /**
* Show the form for creating a new invitation. * Show the form for creating a new invitation.
*/ */
@ -39,7 +41,7 @@ class DynamicInvitationController extends Controller
->where('role', 'owner') ->where('role', 'owner')
->exists(); ->exists();
if (!$isOwner) { if (! $isOwner) {
abort(403, 'Only dynamic owners can invite other users.'); abort(403, 'Only dynamic owners can invite other users.');
} }
@ -92,7 +94,7 @@ class DynamicInvitationController extends Controller
public function accept(Request $request, string $token) public function accept(Request $request, string $token)
{ {
// Must be signed! // Must be signed!
if (!$request->hasValidSignature()) { if (! $request->hasValidSignature()) {
abort(401, 'Invalid or expired signature.'); abort(401, 'Invalid or expired signature.');
} }
@ -116,15 +118,15 @@ class DynamicInvitationController extends Controller
// Log to Dynamic chat activity log! // Log to Dynamic chat activity log!
$dynamic->chat->messages()->create([ $dynamic->chat->messages()->create([
'user_id' => null, 'user_id' => null,
'content' => "{$request->user()->name} joined the Dynamic as a " . strtoupper($invitation->role), 'content' => "<user:{$request->user()->id}> joined the Dynamic as a ".strtoupper($invitation->role),
'subject_id' => $request->user()->id, 'subject_id' => $request->user()->id,
'subject_type' => \App\Models\User::class, 'subject_type' => User::class,
]); ]);
// Delete the invitation record // Delete the invitation record
$invitation->delete(); $invitation->delete();
}); });
return redirect()->route('dynamics.show', $invitation->dynamic_id)->with('success', 'Successfully joined the dynamic!'); return redirect()->route('dynamics.show', $invitation->dynamic)->with('success', 'Successfully joined the dynamic!');
} }
} }

View File

@ -3,11 +3,16 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Http\Requests\StoreLedgerRequest; use App\Http\Requests\StoreLedgerRequest;
use App\Http\Resources\DynamicResource;
use App\Http\Resources\LedgerResource;
use App\Http\Resources\MessageResource;
use App\Http\Resources\MutationResource;
use App\Http\Resources\UserResource;
use App\Models\Dynamic; use App\Models\Dynamic;
use App\Models\Ledger; use App\Models\Ledger;
use App\Services\ActivityService; use App\Services\ActivityService;
use Illuminate\Http\Request;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\Request;
use Inertia\Inertia; use Inertia\Inertia;
class LedgerController extends Controller class LedgerController extends Controller
@ -30,7 +35,7 @@ class LedgerController extends Controller
$this->authorize('update', $dynamic); $this->authorize('update', $dynamic);
return Inertia::render('Ledgers/Create', [ return Inertia::render('Ledgers/Create', [
'dynamic' => $dynamic, 'dynamic' => new DynamicResource($dynamic),
]); ]);
} }
@ -39,6 +44,7 @@ class LedgerController extends Controller
*/ */
public function store(StoreLedgerRequest $request, Dynamic $dynamic) public function store(StoreLedgerRequest $request, Dynamic $dynamic)
{ {
$this->authorize('create', [Ledger::class, $dynamic]);
$ledger = $dynamic->ledgers()->create($request->except('media')); $ledger = $dynamic->ledgers()->create($request->except('media'));
if ($request->hasFile('media')) { if ($request->hasFile('media')) {
@ -73,36 +79,61 @@ class LedgerController extends Controller
}, },
'mutations.user', 'mutations.user',
'mutations.media', 'mutations.media',
'mutations.chat.messages.user', 'mutations.chat',
'mutations.chat.messages.media'
]); ]);
$isOwner = $dynamic->participants()
->where('user_id', $request->user()->id)
->where('role', 'owner')
->exists();
return Inertia::render('Ledgers/Show', [ return Inertia::render('Ledgers/Show', [
'dynamic' => $dynamic, 'dynamic' => new DynamicResource($dynamic),
'ledger' => $ledger, 'ledger' => new LedgerResource($ledger),
'isOwner' => $isOwner, 'mutations' => MutationResource::collection($ledger->mutations),
'participants' => UserResource::collection($dynamic->participants),
'messages' => MessageResource::collection($dynamic->chat->messages()->with(['user', 'media'])->latest()->paginate(\App\Models\Message::PAGINATION_COUNT)),
'can' => [
'update' => $request->user()->can('update', $ledger),
'close' => $request->user()->can('close', $ledger),
],
]); ]);
} }
public function messages(Request $request, Dynamic $dynamic, Ledger $ledger)
{
$this->authorize('view', $ledger);
return MessageResource::collection($dynamic->chat->messages()->with(['user', 'media'])->latest()->paginate(\App\Models\Message::PAGINATION_COUNT));
}
/** /**
* Show the form for editing the specified resource. * Show the form for editing the specified resource.
*/ */
public function edit(Ledger $ledger) public function edit(Dynamic $dynamic, Ledger $ledger)
{ {
// $this->authorize('update', $ledger);
return Inertia::render('Ledgers/Edit', [
'dynamic' => new DynamicResource($dynamic),
'ledger' => new LedgerResource($ledger),
]);
} }
/** /**
* Update the specified resource in storage. * Update the specified resource in storage.
*/ */
public function update(Request $request, Ledger $ledger) public function update(StoreLedgerRequest $request, Dynamic $dynamic, Ledger $ledger)
{ {
// $this->authorize('update', $ledger);
$ledger->update($request->validated());
return redirect()->route('dynamics.ledgers.show', [$dynamic, $ledger]);
}
public function close(Request $request, Dynamic $dynamic, Ledger $ledger)
{
$this->authorize('close', $ledger);
$ledger->update(['status' => 'closed']);
return redirect()->route('dynamics.ledgers.show', [$dynamic, $ledger]);
} }
/** /**

View File

@ -2,15 +2,22 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Events\MessageSent;
use App\Events\MutationCreated;
use App\Events\MutationUpdated;
use App\Http\Requests\StoreMutationRequest; use App\Http\Requests\StoreMutationRequest;
use App\Http\Resources\MutationResource;
use App\Models\Dynamic; use App\Models\Dynamic;
use App\Models\Ledger; use App\Models\Ledger;
use App\Models\Mutation; use App\Models\Mutation;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
class MutationController extends Controller class MutationController extends Controller
{ {
use AuthorizesRequests;
/** /**
* Display a listing of the resource. * Display a listing of the resource.
*/ */
@ -32,13 +39,10 @@ class MutationController extends Controller
*/ */
public function store(StoreMutationRequest $request, Dynamic $dynamic, Ledger $ledger) public function store(StoreMutationRequest $request, Dynamic $dynamic, Ledger $ledger)
{ {
$isOwner = $dynamic->participants() $this->authorize('create', [Mutation::class, $ledger]);
->where('user_id', $request->user()->id)
->where('role', 'owner')
->exists();
// If the user is an owner, default status to 'approved'. Otherwise default to 'pending'. // If the user is an owner, default status to 'approved'. Otherwise default to 'pending'.
$status = $isOwner ? 'approved' : 'pending'; $status = $request->user()->can('update', $ledger) ? 'approved' : 'pending';
$mutation = DB::transaction(function () use ($request, $ledger, $status) { $mutation = DB::transaction(function () use ($request, $ledger, $status) {
$mutation = $ledger->mutations()->create([ $mutation = $ledger->mutations()->create([
@ -67,32 +71,8 @@ class MutationController extends Controller
return $mutation; return $mutation;
}); });
// Log to Mutation and Dynamic chats
$user = $request->user();
$mutationMsg = $mutation->chat->messages()->create([
'user_id' => $user->id,
'content' => $status === 'approved'
? "System: Entry was created by {$user->name}."
: "System: Suggestion was created by {$user->name}.",
]);
broadcast(new \App\Events\MessageSent($mutationMsg));
if ($status === 'approved') {
$dynamicMsg = $dynamic->chat->messages()->create([
'user_id' => $user->id,
'content' => "System: {$user->name} added entry \"{$mutation->description}\" for " . ($mutation->amount >= 0 ? '+' : '') . "{$mutation->amount} points on \"{$ledger->name}\" ledger.",
]);
} else {
$dynamicMsg = $dynamic->chat->messages()->create([
'user_id' => $user->id,
'content' => "System: {$user->name} suggested \"{$mutation->description}\" for " . ($mutation->amount >= 0 ? '+' : '') . "{$mutation->amount} points on \"{$ledger->name}\" ledger.",
]);
}
broadcast(new \App\Events\MessageSent($dynamicMsg));
// Broadcast the real-time creation event! // Broadcast the real-time creation event!
broadcast(new \App\Events\MutationCreated($mutation)); broadcast(new MutationCreated($mutation));
return redirect()->route('dynamics.ledgers.show', [$dynamic, $ledger]); return redirect()->route('dynamics.ledgers.show', [$dynamic, $ledger]);
} }
@ -102,7 +82,9 @@ class MutationController extends Controller
*/ */
public function show(Dynamic $dynamic, Ledger $ledger, Mutation $mutation) public function show(Dynamic $dynamic, Ledger $ledger, Mutation $mutation)
{ {
// $this->authorize('view', $mutation);
return new MutationResource($mutation);
} }
/** /**
@ -118,15 +100,7 @@ class MutationController extends Controller
*/ */
public function update(Request $request, Dynamic $dynamic, Ledger $ledger, Mutation $mutation) public function update(Request $request, Dynamic $dynamic, Ledger $ledger, Mutation $mutation)
{ {
// 1. Authorize - only owners can update mutation status! $this->authorize('update', $mutation);
$isOwner = $dynamic->participants()
->where('user_id', $request->user()->id)
->where('role', 'owner')
->exists();
if (!$isOwner) {
abort(403, 'Only dynamic owners can approve or reject mutations.');
}
$request->validate([ $request->validate([
'status' => ['required', 'string', 'in:approved,rejected'], 'status' => ['required', 'string', 'in:approved,rejected'],
@ -151,30 +125,45 @@ class MutationController extends Controller
$statusText = strtoupper($newStatus); $statusText = strtoupper($newStatus);
$mutationMsg = $mutation->chat->messages()->create([ $mutationMsg = $mutation->chat->messages()->create([
'user_id' => $user->id, 'user_id' => null,
'content' => "System: Suggestion was {$statusText} by {$user->name}.", 'content' => "Suggestion was {$statusText} by <user:{$user->id}>.",
'subject_id' => $mutation->id,
'subject_type' => Mutation::class,
]); ]);
broadcast(new \App\Events\MessageSent($mutationMsg)); broadcast(new MessageSent($mutationMsg));
if ($newStatus === 'approved') { if ($newStatus === 'approved') {
$dynamicMsg = $dynamic->chat->messages()->create([ $dynamicMsg = $dynamic->chat->messages()->create([
'user_id' => $user->id, 'user_id' => null,
'content' => "System: {$user->name} APPROVED the suggestion \"{$mutation->description}\" for " . ($mutation->amount >= 0 ? '+' : '') . "{$mutation->amount} points on \"{$ledger->name}\" ledger.", 'content' => "<user:{$user->id}> APPROVED the suggestion \"{$mutation->description}\" for ".($mutation->amount >= 0 ? '+' : '')."{$mutation->amount} points on \"{$ledger->name}\" ledger.",
'subject_id' => $mutation->id,
'subject_type' => Mutation::class,
]); ]);
} else { } else {
$dynamicMsg = $dynamic->chat->messages()->create([ $dynamicMsg = $dynamic->chat->messages()->create([
'user_id' => $user->id, 'user_id' => null,
'content' => "System: {$user->name} REJECTED the suggestion \"{$mutation->description}\" on \"{$ledger->name}\" ledger.", 'content' => "<user:{$user->id}> REJECTED the suggestion \"{$mutation->description}\" on \"{$ledger->name}\" ledger.",
'subject_id' => $mutation->id,
'subject_type' => Mutation::class,
]); ]);
} }
broadcast(new \App\Events\MessageSent($dynamicMsg)); broadcast(new MessageSent($dynamicMsg));
// Broadcast the real-time update event! // Broadcast the real-time update event!
broadcast(new \App\Events\MutationUpdated($mutation)); broadcast(new MutationUpdated($mutation));
return redirect()->back(); return redirect()->back();
} }
public function void(Request $request, Dynamic $dynamic, Ledger $ledger, Mutation $mutation)
{
$this->authorize('void', $mutation);
$mutation->update(['status' => 'voided']);
return redirect()->route('dynamics.ledgers.show', [$dynamic, $ledger]);
}
/** /**
* Remove the specified resource from storage. * Remove the specified resource from storage.
*/ */

View File

@ -0,0 +1,57 @@
<?php
namespace App\Http\Controllers;
use App\Models\Dynamic;
use App\Models\User;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\Request;
use Inertia\Inertia;
class ParticipantController extends Controller
{
use AuthorizesRequests;
public function update(Request $request, Dynamic $dynamic)
{
$request->validate([
'display_name' => ['required', 'string', 'max:255'],
]);
$participant = $dynamic->participants()->where('user_id', $request->user()->id)->firstOrFail();
$dynamic->participants()->updateExistingPivot($participant->id, [
'display_name' => $request->input('display_name'),
]);
return redirect()->back()->with('success', 'Display name updated successfully!');
}
public function show(Request $request, Dynamic $dynamic, User $user)
{
// Ensure both the authenticated user and the target user are in the dynamic
if (! $dynamic->participants()->where('user_id', $request->user()->id)->exists()) {
abort(403);
}
$participant = $dynamic->participants()->where('user_id', $user->id)->firstOrFail();
$mutations = $user->mutations()
->whereHas('ledger', fn ($query) => $query->where('dynamic_id', $dynamic->id))
->with('ledger')
->latest('id')
->take(10)
->get();
return Inertia::render('Dynamics/Participants/Show', [
'dynamic' => $dynamic,
'participant' => [
'id' => $user->id,
'name' => $user->name,
'display_name' => $participant->pivot->display_name,
'role' => $participant->pivot->role,
],
'mutations' => $mutations,
]);
}
}

View File

@ -3,6 +3,7 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Models\Dynamic; use App\Models\Dynamic;
use App\Models\Ledger;
use App\Models\PredefinedMutation; use App\Models\PredefinedMutation;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@ -15,20 +16,21 @@ class PredefinedMutationController extends Controller
/** /**
* Display a listing of the resource. * Display a listing of the resource.
*/ */
public function index(Dynamic $dynamic) public function index(Dynamic $dynamic, Ledger $ledger)
{ {
$this->authorize('update', $dynamic); $this->authorize('update', $dynamic);
return Inertia::render('Dynamics/PredefinedMutations/Index', [ return Inertia::render('Ledgers/PredefinedMutations/Index', [
'dynamic' => $dynamic, 'dynamic' => $dynamic,
'predefined_mutations' => $dynamic->predefinedMutations()->latest()->get(), 'ledger' => $ledger,
'predefined_mutations' => $ledger->predefinedMutations()->latest()->get(),
]); ]);
} }
/** /**
* Store a newly created resource in storage. * Store a newly created resource in storage.
*/ */
public function store(Request $request, Dynamic $dynamic) public function store(Request $request, Dynamic $dynamic, Ledger $ledger)
{ {
$this->authorize('update', $dynamic); $this->authorize('update', $dynamic);
@ -36,11 +38,54 @@ class PredefinedMutationController extends Controller
'name' => ['required', 'string', 'max:255'], 'name' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string'], 'description' => ['nullable', 'string'],
'amount' => ['required', 'integer'], 'amount' => ['required', 'integer'],
'type' => ['required', 'string', 'in:reward,penalty'],
]); ]);
$dynamic->predefinedMutations()->create($request->all()); $ledger->predefinedMutations()->create($request->all());
return redirect()->route('dynamics.predefined-mutations.index', $dynamic); return redirect()->route('dynamics.ledgers.predefined-mutations.index', [$dynamic, $ledger]);
}
/**
* Show the form for editing the specified resource.
*/
public function edit(Dynamic $dynamic, Ledger $ledger, PredefinedMutation $predefinedMutation)
{
$this->authorize('update', $dynamic);
return Inertia::render('Ledgers/PredefinedMutations/Edit', [
'dynamic' => $dynamic,
'ledger' => $ledger,
'predefined_mutation' => $predefinedMutation,
]);
}
/**
* Update the specified resource in storage.
*/
public function update(Request $request, Dynamic $dynamic, Ledger $ledger, PredefinedMutation $predefinedMutation)
{
$this->authorize('update', $dynamic);
$request->validate([
'name' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string'],
'amount' => ['required', 'integer'],
]);
$predefinedMutation->update($request->all());
return redirect()->route('dynamics.ledgers.predefined-mutations.index', [$dynamic, $ledger]);
}
/**
* Remove the specified resource from storage.
*/
public function destroy(Dynamic $dynamic, Ledger $ledger, PredefinedMutation $predefinedMutation)
{
$this->authorize('update', $dynamic);
$predefinedMutation->delete();
return redirect()->route('dynamics.ledgers.predefined-mutations.index', [$dynamic, $ledger]);
} }
} }

View File

@ -19,9 +19,14 @@ class ProfileController extends Controller
*/ */
public function edit(Request $request): Response public function edit(Request $request): Response
{ {
$dynamics = $request->user()->dynamics()
->withPivot('display_name')
->get();
return Inertia::render('settings/Profile', [ return Inertia::render('settings/Profile', [
'mustVerifyEmail' => $request->user() instanceof MustVerifyEmail, 'mustVerifyEmail' => $request->user() instanceof MustVerifyEmail,
'status' => $request->session()->get('status'), 'status' => $request->session()->get('status'),
'dynamics' => $dynamics,
]); ]);
} }

View File

@ -0,0 +1,37 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class WebPushController extends Controller
{
public function store(Request $request)
{
$request->validate([
'endpoint' => 'required',
'keys.p256dh' => 'required',
'keys.auth' => 'required',
]);
$endpoint = $request->endpoint;
$token = $request->keys['auth'];
$key = $request->keys['p256dh'];
Auth::user()->updatePushSubscription($endpoint, $key, $token);
return response()->json(['success' => true]);
}
public function destroy(Request $request)
{
$request->validate([
'endpoint' => 'required',
]);
Auth::user()->deletePushSubscription($request->endpoint);
return response()->json(['success' => true]);
}
}

View File

@ -2,6 +2,7 @@
namespace App\Http\Middleware; namespace App\Http\Middleware;
use App\Services\ActivityService;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Inertia\Middleware; use Inertia\Middleware;
@ -47,8 +48,9 @@ class HandleInertiaRequests extends Middleware
return 0; return 0;
} }
$service = app(\App\Services\ActivityService::class); $service = app(ActivityService::class);
return count($service->getUnreadEntitiesGrouped($request->user()));
return count($service->getUnreadDynamicsGrouped($request->user()));
}, },
]; ];
} }

View File

@ -5,8 +5,6 @@ namespace App\Http\Requests;
use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
use App\Models\Dynamic;
class UpdateDynamicRequest extends FormRequest class UpdateDynamicRequest extends FormRequest
{ {
/** /**
@ -22,7 +20,7 @@ class UpdateDynamicRequest extends FormRequest
/** /**
* Get the validation rules that apply to the request. * Get the validation rules that apply to the request.
* *
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array|string> * @return array<string, ValidationRule|array|string>
*/ */
public function rules(): array public function rules(): array
{ {

View File

@ -0,0 +1,25 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class BaseResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
$data = parent::toArray($request);
if (isset($data['id']) && isset($this->uuid)) {
$data['id'] = $this->uuid;
}
return $data;
}
}

View File

@ -0,0 +1,25 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
class DynamicResource extends BaseResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
$result = parent::toArray($request);
if ($this->ledgers) {
$result['ledgers'] = LedgerResource::collection($this->ledgers);
}
if ($this->participants) {
$result['participants'] = ParticipantResource::collection($this->participants);
}
return $result;
}
}

View File

@ -0,0 +1,18 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
class LedgerResource extends BaseResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return parent::toArray($request);
}
}

View File

@ -0,0 +1,18 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
class MessageResource extends BaseResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return parent::toArray($request);
}
}

View File

@ -0,0 +1,25 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
class MutationResource extends BaseResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
$data = parent::toArray($request);
$data['can'] = [
'update' => $request->user()?->can('update', $this->resource) ?? false,
'void' => $request->user()?->can('void', $this->resource) ?? false,
];
return $data;
}
}

View File

@ -0,0 +1,18 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
class ParticipantResource extends BaseResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return parent::toArray($request);
}
}

View File

@ -0,0 +1,18 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
class PredefinedMutationResource extends BaseResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return parent::toArray($request);
}
}

View File

@ -0,0 +1,18 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
class UserResource extends BaseResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return parent::toArray($request);
}
}

View File

@ -10,20 +10,24 @@ use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\URL; use Illuminate\Support\Facades\URL;
class DynamicInvitationMail extends Mailable { class DynamicInvitationMail extends Mailable
{
use Queueable, SerializesModels; use Queueable, SerializesModels;
public function __construct(public DynamicInvitation $invitation, public string $inviterName) { public function __construct(public DynamicInvitation $invitation, public string $inviterName)
{
// //
} }
public function envelope(): Envelope { public function envelope(): Envelope
{
return new Envelope( return new Envelope(
subject: 'Invitation to Join Dynamic: ' . $this->invitation->dynamic->name, subject: 'Invitation to Join Dynamic: '.$this->invitation->dynamic->name,
); );
} }
public function content(): Content { public function content(): Content
{
$acceptUrl = URL::temporarySignedRoute( $acceptUrl = URL::temporarySignedRoute(
'dynamics.invitations.accept', 'dynamics.invitations.accept',
$this->invitation->expires_at, $this->invitation->expires_at,

View File

@ -22,4 +22,8 @@ class Chat extends Model
{ {
return $this->hasMany(Message::class); return $this->hasMany(Message::class);
} }
public function getSubjectUrlAttribute(): string {
return $this->chatable?->url ?? '';
}
} }

View File

@ -8,6 +8,7 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphOne; use Illuminate\Database\Eloquent\Relations\MorphOne;
use Illuminate\Support\Str;
class Dynamic extends Model class Dynamic extends Model
{ {
@ -21,7 +22,7 @@ class Dynamic extends Model
public function participants(): BelongsToMany public function participants(): BelongsToMany
{ {
return $this->belongsToMany(User::class, 'participants')->withPivot('role'); return $this->belongsToMany(User::class, 'participants')->withPivot('role', 'display_name');
} }
public function ledgers(): HasMany public function ledgers(): HasMany
@ -34,6 +35,11 @@ class Dynamic extends Model
return $this->hasMany(DynamicInvitation::class); return $this->hasMany(DynamicInvitation::class);
} }
public function predefinedMutations(): HasMany
{
return $this->hasMany(PredefinedMutation::class);
}
public function chat(): MorphOne public function chat(): MorphOne
{ {
return $this->morphOne(Chat::class, 'chatable'); return $this->morphOne(Chat::class, 'chatable');
@ -41,8 +47,21 @@ class Dynamic extends Model
protected static function booted(): void protected static function booted(): void
{ {
static::creating(function ($model) {
$model->uuid = (string) Str::uuid();
});
static::created(function (Dynamic $dynamic) { static::created(function (Dynamic $dynamic) {
$dynamic->chat()->create([]); $dynamic->chat()->create([]);
}); });
} }
public function getRouteKeyName()
{
return 'uuid';
}
public function getUrlAttribute(): string {
return route('dynamics.show', $this);
}
} }

View File

@ -6,7 +6,8 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
class DynamicInvitation extends Model { class DynamicInvitation extends Model
{
use HasFactory; use HasFactory;
protected $fillable = [ protected $fillable = [
@ -21,11 +22,13 @@ class DynamicInvitation extends Model {
'expires_at' => 'datetime', 'expires_at' => 'datetime',
]; ];
public function dynamic(): BelongsTo { public function dynamic(): BelongsTo
{
return $this->belongsTo(Dynamic::class); return $this->belongsTo(Dynamic::class);
} }
public function isExpired(): bool { public function isExpired(): bool
{
return $this->expires_at->isPast(); return $this->expires_at->isPast();
} }
} }

View File

@ -7,6 +7,8 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Support\Str;
class Ledger extends Model class Ledger extends Model
{ {
@ -19,6 +21,7 @@ class Ledger extends Model
'rules', 'rules',
'score', 'score',
'alignment', 'alignment',
'status',
]; ];
public function dynamic(): BelongsTo public function dynamic(): BelongsTo
@ -31,8 +34,29 @@ class Ledger extends Model
return $this->hasMany(Mutation::class); return $this->hasMany(Mutation::class);
} }
public function media(): \Illuminate\Database\Eloquent\Relations\MorphMany public function predefinedMutations(): HasMany
{
return $this->hasMany(PredefinedMutation::class);
}
public function media(): MorphMany
{ {
return $this->morphMany(Media::class, 'mediable'); return $this->morphMany(Media::class, 'mediable');
} }
protected static function booted(): void
{
static::creating(function ($model) {
$model->uuid = (string) Str::uuid();
});
}
public function getRouteKeyName()
{
return 'uuid';
}
public function getUrlAttribute(): string {
return route('dynamics.ledgers.show', $this);
}
} }

View File

@ -6,7 +6,8 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphTo; use Illuminate\Database\Eloquent\Relations\MorphTo;
class Media extends Model { class Media extends Model
{
use HasFactory; use HasFactory;
protected $fillable = [ protected $fillable = [
@ -17,11 +18,13 @@ class Media extends Model {
protected $appends = ['url']; protected $appends = ['url'];
public function mediable(): MorphTo { public function mediable(): MorphTo
{
return $this->morphTo(); return $this->morphTo();
} }
public function getUrlAttribute(): string { public function getUrlAttribute(): string
return asset('storage/' . $this->file_path); {
return asset('storage/'.$this->file_path);
} }
} }

View File

@ -6,7 +6,7 @@ use Database\Factories\MessageFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Database\Eloquent\Relations\MorphTo; use Illuminate\Database\Eloquent\Relations\MorphTo;
class Message extends Model class Message extends Model
@ -14,6 +14,8 @@ class Message extends Model
/** @use HasFactory<MessageFactory> */ /** @use HasFactory<MessageFactory> */
use HasFactory; use HasFactory;
const PAGINATION_COUNT = 6;
protected $fillable = [ protected $fillable = [
'chat_id', 'chat_id',
'user_id', 'user_id',
@ -37,8 +39,12 @@ class Message extends Model
return $this->morphTo(); return $this->morphTo();
} }
public function media(): \Illuminate\Database\Eloquent\Relations\MorphMany public function media(): MorphMany
{ {
return $this->morphMany(Media::class, 'mediable'); return $this->morphMany(Media::class, 'mediable');
} }
public function getSubjectUrlAttribute(): string {
return $this->subject->url ?? '';
}
} }

View File

@ -2,11 +2,14 @@
namespace App\Models; namespace App\Models;
use App\Events\MessageSent;
use Database\Factories\MutationFactory; use Database\Factories\MutationFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Database\Eloquent\Relations\MorphOne; use Illuminate\Database\Eloquent\Relations\MorphOne;
use Illuminate\Support\Str;
class Mutation extends Model class Mutation extends Model
{ {
@ -43,15 +46,57 @@ class Mutation extends Model
return $this->morphOne(Chat::class, 'chatable'); return $this->morphOne(Chat::class, 'chatable');
} }
public function media(): \Illuminate\Database\Eloquent\Relations\MorphMany public function media(): MorphMany
{ {
return $this->morphMany(Media::class, 'mediable'); return $this->morphMany(Media::class, 'mediable');
} }
protected static function booted(): void protected static function booted(): void
{ {
static::creating(function ($model) {
$model->uuid = (string) Str::uuid();
});
static::created(function (Mutation $mutation) { static::created(function (Mutation $mutation) {
$mutation->chat()->create([]); $mutation->chat()->create([]);
// Create system messages automatically!
$user = $mutation->user;
$ledger = $mutation->ledger;
$dynamic = $ledger->dynamic;
$status = $mutation->status;
$mutationMsg = $mutation->chat->messages()->create([
'user_id' => null,
'content' => $status === 'approved'
? "Entry was created by <user:{$user->id}>."
: "Suggestion was created by <user:{$user->id}>.",
'subject_id' => $mutation->id,
'subject_type' => Mutation::class,
]);
broadcast(new MessageSent($mutationMsg));
if ($status === 'approved') {
$dynamicMsg = $dynamic->chat->messages()->create([
'user_id' => null,
'content' => "<user:{$user->id}> added entry \"{$mutation->description}\" for ".($mutation->amount >= 0 ? '+' : '')."{$mutation->amount} points on \"{$ledger->name}\" ledger.",
'subject_id' => $mutation->id,
'subject_type' => Mutation::class,
]);
} else {
$dynamicMsg = $dynamic->chat->messages()->create([
'user_id' => null,
'content' => "<user:{$user->id}> suggested \"{$mutation->description}\" for ".($mutation->amount >= 0 ? '+' : '')."{$mutation->amount} points on \"{$ledger->name}\" ledger.",
'subject_id' => $mutation->id,
'subject_type' => Mutation::class,
]);
}
broadcast(new MessageSent($dynamicMsg));
}); });
} }
public function getRouteKeyName()
{
return 'uuid';
}
} }

View File

@ -12,5 +12,6 @@ class Participant extends Pivot
'user_id', 'user_id',
'dynamic_id', 'dynamic_id',
'role', 'role',
'display_name',
]; ];
} }

View File

@ -5,21 +5,33 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Str;
class PredefinedMutation extends Model class PredefinedMutation extends Model
{ {
use HasFactory; use HasFactory;
protected $fillable = [ protected $fillable = [
'dynamic_id', 'ledger_id',
'name', 'name',
'description', 'description',
'amount', 'amount',
'type',
]; ];
public function dynamic(): BelongsTo public function ledger(): BelongsTo
{ {
return $this->belongsTo(Dynamic::class); return $this->belongsTo(Ledger::class);
}
protected static function booted(): void
{
static::creating(function ($model) {
$model->uuid = (string) Str::uuid();
});
}
public function getRouteKeyName()
{
return 'uuid';
} }
} }

View File

@ -10,9 +10,11 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable; use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
use Laravel\Fortify\Contracts\PasskeyUser; use Laravel\Fortify\Contracts\PasskeyUser;
use Laravel\Fortify\PasskeyAuthenticatable; use Laravel\Fortify\PasskeyAuthenticatable;
use Laravel\Fortify\TwoFactorAuthenticatable; use Laravel\Fortify\TwoFactorAuthenticatable;
use NotificationChannels\WebPush\HasPushSubscriptions;
/** /**
* @property int $id * @property int $id
@ -32,7 +34,7 @@ use Laravel\Fortify\TwoFactorAuthenticatable;
class User extends Authenticatable implements PasskeyUser class User extends Authenticatable implements PasskeyUser
{ {
/** @use HasFactory<UserFactory> */ /** @use HasFactory<UserFactory> */
use HasFactory, Notifiable, PasskeyAuthenticatable, TwoFactorAuthenticatable; use HasFactory, HasPushSubscriptions, Notifiable, PasskeyAuthenticatable, TwoFactorAuthenticatable;
public function dynamics() public function dynamics()
{ {
@ -49,6 +51,13 @@ class User extends Authenticatable implements PasskeyUser
return $this->hasMany(ReadCursor::class); return $this->hasMany(ReadCursor::class);
} }
public function displayNameFor(Dynamic $dynamic): string
{
$participant = $dynamic->participants()->where('user_id', $this->id)->first();
return $participant?->pivot?->display_name ?? $this->name;
}
/** /**
* Get the attributes that should be cast. * Get the attributes that should be cast.
* *
@ -62,4 +71,16 @@ class User extends Authenticatable implements PasskeyUser
'two_factor_confirmed_at' => 'datetime', 'two_factor_confirmed_at' => 'datetime',
]; ];
} }
protected static function booted(): void
{
static::creating(function ($model) {
$model->uuid = (string) Str::uuid();
});
}
public function getRouteKeyName()
{
return 'uuid';
}
} }

View File

@ -0,0 +1,72 @@
<?php
namespace App\Notifications;
use App\Models\Chat;
use App\Models\Message;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification;
use NotificationChannels\WebPush\WebPushChannel;
use NotificationChannels\WebPush\WebPushMessage;
class NewActivityNotification extends Notification
{
use Queueable;
public $activity;
/**
* Create a new notification instance.
*/
public function __construct($activity)
{
$this->activity = $activity;
}
/**
* Get the notification's delivery channels.
*
* @return array<int, string>
*/
public function via(object $notifiable): array
{
return [WebPushChannel::class];
}
/**
* Get the web push representation of the notification.
*/
public function toWebPush(object $notifiable): WebPushMessage
{
$result = (new WebPushMessage)
->title('New Activity')
->icon('/apple-touch-icon.png')
->body($this->activity['content'])
->action('View', 'view')
->data(['url' => $this->activity['url']]);
switch (get_class($this->activity)) {
case Message::class:
/** @var Chat $chat */
$chat = $this->activity->chat;
$result->data(['url' => $chat->subjectUrl]);
break;
}
return $result;
}
/**
* Get the array representation of the notification.
*
* @return array<string, mixed>
*/
public function toArray(object $notifiable): array
{
return [
//
];
}
}

View File

@ -37,7 +37,15 @@ class LedgerPolicy
*/ */
public function update(User $user, Ledger $ledger): bool public function update(User $user, Ledger $ledger): bool
{ {
return false; return $user->can('update', $ledger->dynamic);
}
/**
* Determine whether the user can close the model.
*/
public function close(User $user, Ledger $ledger): bool
{
return $user->can('update', $ledger->dynamic);
} }
/** /**

View File

@ -2,18 +2,41 @@
namespace App\Policies; namespace App\Policies;
use App\Models\Ledger;
use App\Models\Mutation; use App\Models\Mutation;
use App\Models\User; use App\Models\User;
class MutationPolicy class MutationPolicy
{ {
/** /**
* Determine whether the user can view the mutation. * Determine whether the user can create mutations.
*/ */
public function view(User $user, Mutation $mutation): bool public function create(User $user, Ledger $ledger): bool
{ {
$dynamic = $mutation->ledger->dynamic; $dynamic = $ledger->dynamic;
return $dynamic->participants()->where('user_id', $user->id)->exists(); return $dynamic->participants()->where('user_id', $user->id)->exists();
} }
/**
* Determine whether the user can update the mutation.
*/
public function update(User $user, Mutation $mutation): bool
{
$dynamic = $mutation->ledger->dynamic;
$isOwner = $dynamic->participants()->where('user_id', $user->id)->where('role', 'owner')->exists();
return $isOwner && $mutation->status === 'pending';
}
/**
* Determine whether the user can void the mutation.
*/
public function void(User $user, Mutation $mutation): bool
{
$dynamic = $mutation->ledger->dynamic;
$isOwner = $dynamic->participants()->where('user_id', $user->id)->where('role', 'owner')->exists();
return $isOwner && $mutation->status !== 'voided';
}
} }

View File

@ -3,6 +3,7 @@
namespace App\Providers; namespace App\Providers;
use Carbon\CarbonImmutable; use Carbon\CarbonImmutable;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Facades\Date; use Illuminate\Support\Facades\Date;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
@ -23,6 +24,7 @@ class AppServiceProvider extends ServiceProvider
*/ */
public function boot(): void public function boot(): void
{ {
JsonResource::withoutWrapping();
$this->configureDefaults(); $this->configureDefaults();
} }

View File

@ -2,14 +2,14 @@
namespace App\Services; namespace App\Services;
use App\Models\User;
use App\Models\Dynamic; use App\Models\Dynamic;
use App\Models\Ledger; use App\Models\Ledger;
use App\Models\Mutation;
use App\Models\Message; use App\Models\Message;
use App\Models\ReadCursor; use App\Models\ReadCursor;
use App\Models\User;
use App\Notifications\NewActivityNotification;
use Carbon\CarbonInterface;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
class ActivityService class ActivityService
{ {
@ -33,7 +33,7 @@ class ActivityService
/** /**
* Get the read cursor timestamp for a user on a specific entity. * Get the read cursor timestamp for a user on a specific entity.
*/ */
public function getCursorReadAt(User $user, $entity): \Carbon\CarbonInterface public function getCursorReadAt(User $user, $entity): CarbonInterface
{ {
$cursor = ReadCursor::where([ $cursor = ReadCursor::where([
'user_id' => $user->id, 'user_id' => $user->id,
@ -54,6 +54,8 @@ class ActivityService
'subject_type' => $subject ? get_class($subject) : null, 'subject_type' => $subject ? get_class($subject) : null,
]); ]);
$this->notify($message);
return $message; return $message;
} }
@ -70,27 +72,49 @@ class ActivityService
return $mutation; return $mutation;
} }
public function notify(Message $message)
{
$dynamic = $message->chat->chatable;
if ($dynamic instanceof Dynamic) {
$participants = $dynamic->participants;
foreach ($participants as $participant) {
if ($message->user_id !== $participant->id) {
$participant->notify(new NewActivityNotification($message));
}
}
}
}
/** /**
* Retrieve all activities for a given entity. * Retrieve all activities for a given entity.
*/ */
public function getActivitiesForEntity($entity): array public function getActivitiesForDynamic(Dynamic $dynamic): array
{ {
if ($entity instanceof Dynamic) { $participants = $dynamic->participants()->withPivot('display_name')->get();
$chatId = $entity->chat->id; $participantsMap = $participants->reduce(function ($acc, $p) {
} elseif ($entity instanceof Ledger) { $acc[$p->id] = $p->pivot->display_name ?? $p->name;
$chatId = $entity->dynamic->chat->id;
} else {
return [];
}
$messages = Message::where('chat_id', $chatId) return $acc;
}, []);
$messages = Message::where('chat_id', $dynamic->chat->id)
->with(['user', 'subject']) ->with(['user', 'subject'])
->latest() ->latest()
->get(); ->get();
return $messages->map(function ($message) { return $messages->map(function ($message) use ($participantsMap) {
$messageData = $message->toArray(); $messageData = $message->toArray();
$messageData['url'] = $this->getUrlForMessage($message); $messageData['url'] = $this->getUrlForMessage($message);
// Resolve <user:id> placeholders to actual names/display names
$messageData['content'] = preg_replace_callback('/<user:(\d+)>/', function ($matches) use ($participantsMap) {
$userId = $matches[1];
return $participantsMap[$userId] ?? "User #{$userId}";
}, $message->content);
return $messageData; return $messageData;
})->all(); })->all();
} }
@ -98,27 +122,25 @@ class ActivityService
/** /**
* Get unread activities grouped by active entities (Dynamics, Ledgers) for the given user. * Get unread activities grouped by active entities (Dynamics, Ledgers) for the given user.
*/ */
public function getUnreadEntitiesGrouped(User $user): array public function getUnreadDynamicsGrouped(User $user): array
{ {
$groupedEntities = []; $groupedDynamics = [];
$participatingDynamics = $user->dynamics()->with('ledgers')->get(); $participatingDynamics = $user->dynamics()->with('ledgers')->get();
$entities = $participatingDynamics->concat($participatingDynamics->flatMap(fn ($d) => $d->ledgers)); foreach ($participatingDynamics as $dynamic) {
$readAt = $this->getCursorReadAt($user, $dynamic);
$activities = $this->getActivitiesForDynamic($dynamic);
foreach ($entities as $entity) { $this->partitionAndGroupActivities($activities, $readAt, $dynamic, $groupedDynamics);
$readAt = $this->getCursorReadAt($user, $entity);
$activities = $this->getActivitiesForEntity($entity);
$this->partitionActivities($activities, $readAt, $entity, get_class($entity), $this->getUrlForEntity($entity), $groupedEntities);
} }
return $groupedEntities; return $groupedDynamics;
} }
/** /**
* Partition activities into read and unread, and construct the grouped entity metadata. * Partition activities into read and unread, and construct the grouped entity metadata.
*/ */
private function partitionActivities(array $activities, \Carbon\CarbonInterface $readAt, $entity, string $type, string $url, array &$groupedEntities): void private function partitionAndGroupActivities(array $activities, CarbonInterface $readAt, Dynamic $dynamic, array &$groupedDynamics): void
{ {
$alreadyRead = []; $alreadyRead = [];
$unread = []; $unread = [];
@ -131,17 +153,16 @@ class ActivityService
} }
} }
if (!empty($unread)) { if (! empty($unread)) {
$context = array_slice($alreadyRead, 0, 2); $context = array_slice($alreadyRead, 0, 2);
$groupedEntities[] = [ $groupedDynamics[] = [
'id' => $entity->id, 'id' => $dynamic->id,
'name' => $entity->name, 'name' => $dynamic->name,
'type' => Str::afterLast($type, '\\'), 'url' => route('dynamics.show', $dynamic->uuid),
'url' => $url,
'unread_count' => count($unread), 'unread_count' => count($unread),
'context_activities' => $context, 'context_activities' => $context,
'new_activities' => $unread, 'new_activities' => array_reverse($unread),
]; ];
} }
} }
@ -149,11 +170,16 @@ class ActivityService
private function getUrlForEntity($entity): string private function getUrlForEntity($entity): string
{ {
if ($entity instanceof Dynamic) { if ($entity instanceof Dynamic) {
return route('dynamics.show', $entity->id); return route('dynamics.show', $entity->uuid);
} }
if ($entity instanceof Ledger) { if ($entity instanceof Ledger) {
return route('dynamics.ledgers.show', [$entity->dynamic_id, $entity->id]); return route('dynamics.ledgers.show', [$entity->dynamic->uuid, $entity->uuid]);
}
if ($entity instanceof Message) {
$subject = $entity->subject;
return $this->getUrlForEntity($subject);
} }
return ''; return '';

View File

@ -10,9 +10,9 @@ use Illuminate\Http\Request;
return Application::configure(basePath: dirname(__DIR__)) return Application::configure(basePath: dirname(__DIR__))
->withRouting( ->withRouting(
web: __DIR__ . '/../routes/web.php', web: __DIR__.'/../routes/web.php',
commands: __DIR__ . '/../routes/console.php', commands: __DIR__.'/../routes/console.php',
channels: __DIR__ . '/../routes/channels.php', channels: __DIR__.'/../routes/channels.php',
health: '/up', health: '/up',
) )
->withMiddleware(function (Middleware $middleware): void { ->withMiddleware(function (Middleware $middleware): void {
@ -31,6 +31,6 @@ return Application::configure(basePath: dirname(__DIR__))
}) })
->withExceptions(function (Exceptions $exceptions): void { ->withExceptions(function (Exceptions $exceptions): void {
$exceptions->shouldRenderJsonWhen( $exceptions->shouldRenderJsonWhen(
fn(Request $request) => $request->is('api/*'), fn (Request $request) => $request->is('api/*'),
); );
})->create(); })->create();

View File

@ -3,11 +3,13 @@
use App\Providers\AppServiceProvider; use App\Providers\AppServiceProvider;
use App\Providers\AuthServiceProvider; use App\Providers\AuthServiceProvider;
use App\Providers\FortifyServiceProvider; use App\Providers\FortifyServiceProvider;
use Illuminate\Broadcasting\BroadcastServiceProvider;
use Tighten\Ziggy\ZiggyServiceProvider;
return [ return [
AppServiceProvider::class, AppServiceProvider::class,
AuthServiceProvider::class, AuthServiceProvider::class,
FortifyServiceProvider::class, FortifyServiceProvider::class,
\Illuminate\Broadcasting\BroadcastServiceProvider::class, BroadcastServiceProvider::class,
\Tighten\Ziggy\ZiggyServiceProvider::class, ZiggyServiceProvider::class,
]; ];

View File

@ -11,6 +11,7 @@
"require": { "require": {
"php": "^8.4", "php": "^8.4",
"inertiajs/inertia-laravel": "^3.0", "inertiajs/inertia-laravel": "^3.0",
"laravel-notification-channels/webpush": "^11.0",
"laravel/chisel": "^0.1.0", "laravel/chisel": "^0.1.0",
"laravel/fortify": "^1.37.2", "laravel/fortify": "^1.37.2",
"laravel/framework": "^13.7", "laravel/framework": "^13.7",

375
composer.lock generated
View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "4f6fe33dc680e6446bd6318d5bdd9ec9", "content-hash": "f4c79dcf0b7f9f54487404715d1085c1",
"packages": [ "packages": [
{ {
"name": "bacon/bacon-qr-code", "name": "bacon/bacon-qr-code",
@ -1461,6 +1461,72 @@
}, },
"time": "2026-04-30T15:30:29+00:00" "time": "2026-04-30T15:30:29+00:00"
}, },
{
"name": "laravel-notification-channels/webpush",
"version": "11.0.0",
"source": {
"type": "git",
"url": "https://github.com/laravel-notification-channels/webpush.git",
"reference": "85b577e64459a9df06a24062e2b300abbaa99fa9"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel-notification-channels/webpush/zipball/85b577e64459a9df06a24062e2b300abbaa99fa9",
"reference": "85b577e64459a9df06a24062e2b300abbaa99fa9",
"shasum": ""
},
"require": {
"illuminate/notifications": "^12.0|^13.0",
"illuminate/support": "^12.0|^13.0",
"minishlink/web-push": "^10.0.1",
"php": "^8.2"
},
"require-dev": {
"larastan/larastan": "^3.1",
"laravel/pint": "^1.25",
"mockery/mockery": "^1.0",
"orchestra/testbench": "^9.2|^10.0|^11.0",
"phpunit/phpunit": "^11.5.3|^12.5.12|^13.1.11",
"rector/rector": "^2.0"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"NotificationChannels\\WebPush\\WebPushServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"NotificationChannels\\WebPush\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Cretu Eusebiu",
"email": "me@cretueusebiu.com",
"homepage": "http://cretueusebiu.com",
"role": "Developer"
},
{
"name": "Joost de Bruijn",
"email": "joost@aqualabs.nl",
"role": "Maintainer"
}
],
"description": "Web Push Notifications driver for Laravel.",
"homepage": "https://github.com/laravel-notification-channels/webpush",
"support": {
"issues": "https://github.com/laravel-notification-channels/webpush/issues",
"source": "https://github.com/laravel-notification-channels/webpush/tree/11.0.0"
},
"time": "2026-05-24T13:22:27+00:00"
},
{ {
"name": "laravel/chisel", "name": "laravel/chisel",
"version": "v0.1.1", "version": "v0.1.1",
@ -2759,6 +2825,77 @@
], ],
"time": "2026-03-08T20:05:35+00:00" "time": "2026-03-08T20:05:35+00:00"
}, },
{
"name": "minishlink/web-push",
"version": "v10.1.0",
"source": {
"type": "git",
"url": "https://github.com/web-push-libs/web-push-php.git",
"reference": "c922021b4ed1a61e6604d8dc33a2e0378b4382e3"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/web-push-libs/web-push-php/zipball/c922021b4ed1a61e6604d8dc33a2e0378b4382e3",
"reference": "c922021b4ed1a61e6604d8dc33a2e0378b4382e3",
"shasum": ""
},
"require": {
"ext-curl": "*",
"ext-json": "*",
"ext-mbstring": "*",
"ext-openssl": "*",
"guzzlehttp/guzzle": "^7.9.2",
"php": ">=8.2",
"psr/log": "^2.0|^3.0",
"spomky-labs/base64url": "^2.0.4",
"symfony/polyfill-php83": "^1.33",
"web-token/jwt-library": "^3.4.9|^4.0.6"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^v3.92.2",
"phpstan/phpstan": "^2.1.33",
"phpstan/phpstan-deprecation-rules": "^2.0",
"phpstan/phpstan-phpunit": "^2.0",
"phpstan/phpstan-strict-rules": "^2.0",
"phpunit/phpunit": "^11.5.46|^12.5.2",
"symfony/polyfill-iconv": "^1.33"
},
"suggest": {
"ext-bcmath": "Optional for performance.",
"ext-gmp": "Optional for performance."
},
"type": "library",
"autoload": {
"psr-4": {
"Minishlink\\WebPush\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Louis Lagrange",
"email": "lagrange.louis@gmail.com",
"homepage": "https://github.com/Minishlink"
}
],
"description": "Web Push library for PHP",
"homepage": "https://github.com/web-push-libs/web-push-php",
"keywords": [
"Push API",
"WebPush",
"notifications",
"push",
"web"
],
"support": {
"issues": "https://github.com/web-push-libs/web-push-php/issues",
"source": "https://github.com/web-push-libs/web-push-php/tree/v10.1.0"
},
"time": "2026-05-28T09:37:37+00:00"
},
{ {
"name": "monolog/monolog", "name": "monolog/monolog",
"version": "3.10.0", "version": "3.10.0",
@ -5027,6 +5164,71 @@
], ],
"time": "2024-06-11T12:45:25+00:00" "time": "2024-06-11T12:45:25+00:00"
}, },
{
"name": "spomky-labs/base64url",
"version": "v2.0.4",
"source": {
"type": "git",
"url": "https://github.com/Spomky-Labs/base64url.git",
"reference": "7752ce931ec285da4ed1f4c5aa27e45e097be61d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Spomky-Labs/base64url/zipball/7752ce931ec285da4ed1f4c5aa27e45e097be61d",
"reference": "7752ce931ec285da4ed1f4c5aa27e45e097be61d",
"shasum": ""
},
"require": {
"php": ">=7.1"
},
"require-dev": {
"phpstan/extension-installer": "^1.0",
"phpstan/phpstan": "^0.11|^0.12",
"phpstan/phpstan-beberlei-assert": "^0.11|^0.12",
"phpstan/phpstan-deprecation-rules": "^0.11|^0.12",
"phpstan/phpstan-phpunit": "^0.11|^0.12",
"phpstan/phpstan-strict-rules": "^0.11|^0.12"
},
"type": "library",
"autoload": {
"psr-4": {
"Base64Url\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Florent Morselli",
"homepage": "https://github.com/Spomky-Labs/base64url/contributors"
}
],
"description": "Base 64 URL Safe Encoding/Decoding PHP Library",
"homepage": "https://github.com/Spomky-Labs/base64url",
"keywords": [
"base64",
"rfc4648",
"safe",
"url"
],
"support": {
"issues": "https://github.com/Spomky-Labs/base64url/issues",
"source": "https://github.com/Spomky-Labs/base64url/tree/v2.0.4"
},
"funding": [
{
"url": "https://github.com/Spomky",
"type": "github"
},
{
"url": "https://www.patreon.com/FlorentMorselli",
"type": "patreon"
}
],
"time": "2020-11-03T09:10:25+00:00"
},
{ {
"name": "spomky-labs/cbor-php", "name": "spomky-labs/cbor-php",
"version": "3.2.3", "version": "3.2.3",
@ -6702,6 +6904,86 @@
], ],
"time": "2026-04-10T16:19:22+00:00" "time": "2026-04-10T16:19:22+00:00"
}, },
{
"name": "symfony/polyfill-php83",
"version": "v1.38.2",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-php83.git",
"reference": "796a26abb75ce49f3a84433cd81bf1009d73d5f8"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/796a26abb75ce49f3a84433cd81bf1009d73d5f8",
"reference": "796a26abb75ce49f3a84433cd81bf1009d73d5f8",
"shasum": ""
},
"require": {
"php": ">=7.2"
},
"type": "library",
"extra": {
"thanks": {
"url": "https://github.com/symfony/polyfill",
"name": "symfony/polyfill"
}
},
"autoload": {
"files": [
"bootstrap.php"
],
"psr-4": {
"Symfony\\Polyfill\\Php83\\": ""
},
"classmap": [
"Resources/stubs"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nicolas Grekas",
"email": "p@tchwork.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Symfony polyfill backporting some PHP 8.3+ features to lower PHP versions",
"homepage": "https://symfony.com",
"keywords": [
"compatibility",
"polyfill",
"portable",
"shim"
],
"support": {
"source": "https://github.com/symfony/polyfill-php83/tree/v1.38.2"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-05-27T06:51:48+00:00"
},
{ {
"name": "symfony/polyfill-php84", "name": "symfony/polyfill-php84",
"version": "v1.38.1", "version": "v1.38.1",
@ -8475,6 +8757,95 @@
], ],
"time": "2026-05-31T15:00:08+00:00" "time": "2026-05-31T15:00:08+00:00"
}, },
{
"name": "web-token/jwt-library",
"version": "4.1.7",
"source": {
"type": "git",
"url": "https://github.com/web-token/jwt-library.git",
"reference": "fbcbf2c276d04d8b056f5c2957815abd5dfb704d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/web-token/jwt-library/zipball/fbcbf2c276d04d8b056f5c2957815abd5dfb704d",
"reference": "fbcbf2c276d04d8b056f5c2957815abd5dfb704d",
"shasum": ""
},
"require": {
"brick/math": "^0.12|^0.13|^0.14|^0.15|^0.16|^0.17",
"php": ">=8.2",
"psr/clock": "^1.0",
"spomky-labs/pki-framework": "^1.2.1"
},
"conflict": {
"spomky-labs/jose": "*"
},
"suggest": {
"ext-bcmath": "GMP or BCMath is highly recommended to improve the library performance",
"ext-gmp": "GMP or BCMath is highly recommended to improve the library performance",
"ext-openssl": "For key management (creation, optimization, etc.) and some algorithms (AES, RSA, ECDSA, etc.)",
"ext-sodium": "Sodium is required for OKP key creation, EdDSA signature algorithm and ECDH-ES key encryption with OKP keys",
"paragonie/sodium_compat": "Sodium is required for OKP key creation, EdDSA signature algorithm and ECDH-ES key encryption with OKP keys",
"spomky-labs/aes-key-wrap": "For all Key Wrapping algorithms (AxxxKW, AxxxGCMKW, PBES2-HSxxx+AyyyKW...)",
"symfony/console": "Needed to use console commands",
"symfony/http-client": "To enable JKU/X5U support."
},
"type": "library",
"autoload": {
"psr-4": {
"Jose\\Component\\": ""
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Florent Morselli",
"homepage": "https://github.com/Spomky"
},
{
"name": "All contributors",
"homepage": "https://github.com/web-token/jwt-framework/contributors"
}
],
"description": "JWT library",
"homepage": "https://github.com/web-token",
"keywords": [
"JOSE",
"JWE",
"JWK",
"JWKSet",
"JWS",
"Jot",
"RFC7515",
"RFC7516",
"RFC7517",
"RFC7518",
"RFC7519",
"RFC7520",
"bundle",
"jwa",
"jwt",
"symfony"
],
"support": {
"issues": "https://github.com/web-token/jwt-library/issues",
"source": "https://github.com/web-token/jwt-library/tree/4.1.7"
},
"funding": [
{
"url": "https://github.com/Spomky",
"type": "github"
},
{
"url": "https://www.patreon.com/FlorentMorselli",
"type": "patreon"
}
],
"time": "2026-06-06T18:12:39+00:00"
},
{ {
"name": "webmozart/assert", "name": "webmozart/assert",
"version": "2.4.0", "version": "2.4.0",
@ -12251,5 +12622,5 @@
"php": "^8.4" "php": "^8.4"
}, },
"platform-dev": {}, "platform-dev": {},
"plugin-api-version": "2.6.0" "plugin-api-version": "2.9.0"
} }

View File

@ -23,7 +23,7 @@ class DynamicFactory extends Factory
'Obsidian Household Agreement', 'Obsidian Household Agreement',
'Crimson Castle Protocol', 'Crimson Castle Protocol',
'Dungeon Master-Sub Board', 'Dungeon Master-Sub Board',
'Coffee Club Ledger' 'Coffee Club Ledger',
]), ]),
'rules' => "1. All rules must be strictly adhered to.\n2. Scores must be updated after every task.\n3. Disputed scores can be discussed in the dedicated chat.", 'rules' => "1. All rules must be strictly adhered to.\n2. Scores must be updated after every task.\n3. Disputed scores can be discussed in the dedicated chat.",
]; ];

View File

@ -2,8 +2,8 @@
namespace Database\Factories; namespace Database\Factories;
use App\Models\Ledger;
use App\Models\Dynamic; use App\Models\Dynamic;
use App\Models\Ledger;
use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\Factory;
/** /**
@ -26,7 +26,7 @@ class LedgerFactory extends Factory
'Dungeon Cleaning', 'Dungeon Cleaning',
'Silence Protocol', 'Silence Protocol',
'Task Completion', 'Task Completion',
'Tribute Points' 'Tribute Points',
]), ]),
'rules' => 'Scores are added by Doms and subtracted for protocol breaches.', 'rules' => 'Scores are added by Doms and subtracted for protocol breaches.',
'score' => 0, 'score' => 0,

View File

@ -2,8 +2,8 @@
namespace Database\Factories; namespace Database\Factories;
use App\Models\Message;
use App\Models\Chat; use App\Models\Chat;
use App\Models\Message;
use App\Models\User; use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\Factory;

View File

@ -2,8 +2,8 @@
namespace Database\Factories; namespace Database\Factories;
use App\Models\Mutation;
use App\Models\Ledger; use App\Models\Ledger;
use App\Models\Mutation;
use App\Models\User; use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\Factory;

View File

@ -18,7 +18,7 @@ return new class extends Migration
$table->string('type'); $table->string('type');
$table->integer('amount'); $table->integer('amount');
$table->text('description')->nullable(); $table->text('description')->nullable();
$table->string('status')->default('pending'); $table->string('status')->default('pending'); // pending, approved, rejected, voided
$table->timestamps(); $table->timestamps();
}); });
} }

View File

@ -4,8 +4,10 @@ use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint; use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Schema;
return new class extends Migration { return new class extends Migration
public function up(): void { {
public function up(): void
{
Schema::create('media', function (Blueprint $table) { Schema::create('media', function (Blueprint $table) {
$table->id(); $table->id();
$table->morphs('mediable'); // mediable_type, mediable_id $table->morphs('mediable'); // mediable_type, mediable_id
@ -16,7 +18,8 @@ return new class extends Migration {
}); });
} }
public function down(): void { public function down(): void
{
Schema::dropIfExists('media'); Schema::dropIfExists('media');
} }
}; };

View File

@ -4,8 +4,10 @@ use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint; use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Schema;
return new class extends Migration { return new class extends Migration
public function up(): void { {
public function up(): void
{
Schema::create('dynamic_invitations', function (Blueprint $table) { Schema::create('dynamic_invitations', function (Blueprint $table) {
$table->id(); $table->id();
$table->foreignId('dynamic_id')->constrained()->cascadeOnDelete(); $table->foreignId('dynamic_id')->constrained()->cascadeOnDelete();
@ -17,7 +19,8 @@ return new class extends Migration {
}); });
} }
public function down(): void { public function down(): void
{
Schema::dropIfExists('dynamic_invitations'); Schema::dropIfExists('dynamic_invitations');
} }
}; };

View File

@ -13,11 +13,10 @@ return new class extends Migration
{ {
Schema::create('predefined_mutations', function (Blueprint $table) { Schema::create('predefined_mutations', function (Blueprint $table) {
$table->id(); $table->id();
$table->foreignId('dynamic_id')->constrained()->cascadeOnDelete(); $table->foreignId('ledger_id')->constrained()->cascadeOnDelete();
$table->string('name'); $table->string('name');
$table->text('description')->nullable(); $table->text('description')->nullable();
$table->integer('amount'); $table->integer('amount');
$table->string('type')->default('reward');
$table->timestamps(); $table->timestamps();
}); });
} }

View File

@ -0,0 +1,28 @@
<?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('participants', function (Blueprint $table) {
$table->string('display_name')->nullable()->after('role');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('participants', function (Blueprint $table) {
$table->dropColumn('display_name');
});
}
};

View File

@ -0,0 +1,28 @@
<?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('ledgers', function (Blueprint $table) {
$table->string('status')->default('open');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('ledgers', function (Blueprint $table) {
$table->dropColumn('status');
});
}
};

View File

@ -0,0 +1,28 @@
<?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->string('status')->default('pending')->change();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('mutations', function (Blueprint $table) {
$table->string('status')->default('pending')->change();
});
}
};

View File

@ -0,0 +1,40 @@
<?php
use App\Models\Dynamic;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('dynamics', function (Blueprint $table) {
$table->uuid('uuid')->after('id')->nullable();
});
// Populate existing rows with UUIDs
Dynamic::all()->each(function ($model) {
$model->uuid = Str::uuid();
$model->save();
});
Schema::table('dynamics', function (Blueprint $table) {
$table->uuid('uuid')->unique()->change();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('dynamics', function (Blueprint $table) {
$table->dropColumn('uuid');
});
}
};

View File

@ -0,0 +1,40 @@
<?php
use App\Models\Ledger;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('ledgers', function (Blueprint $table) {
$table->uuid('uuid')->after('id')->nullable();
});
// Populate existing rows with UUIDs
Ledger::all()->each(function ($model) {
$model->uuid = Str::uuid();
$model->save();
});
Schema::table('ledgers', function (Blueprint $table) {
$table->uuid('uuid')->unique()->change();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('ledgers', function (Blueprint $table) {
$table->dropColumn('uuid');
});
}
};

View File

@ -0,0 +1,40 @@
<?php
use App\Models\Mutation;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('mutations', function (Blueprint $table) {
$table->uuid('uuid')->after('id')->nullable();
});
// Populate existing rows with UUIDs
Mutation::all()->each(function ($model) {
$model->uuid = Str::uuid();
$model->save();
});
Schema::table('mutations', function (Blueprint $table) {
$table->uuid('uuid')->unique()->change();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('mutations', function (Blueprint $table) {
$table->dropColumn('uuid');
});
}
};

View File

@ -0,0 +1,40 @@
<?php
use App\Models\PredefinedMutation;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('predefined_mutations', function (Blueprint $table) {
$table->uuid('uuid')->after('id')->nullable();
});
// Populate existing rows with UUIDs
PredefinedMutation::all()->each(function ($model) {
$model->uuid = Str::uuid();
$model->save();
});
Schema::table('predefined_mutations', function (Blueprint $table) {
$table->uuid('uuid')->unique()->change();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('predefined_mutations', function (Blueprint $table) {
$table->dropColumn('uuid');
});
}
};

View File

@ -0,0 +1,40 @@
<?php
use App\Models\User;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->uuid('uuid')->after('id')->nullable();
});
// Populate existing rows with UUIDs
User::all()->each(function ($model) {
$model->uuid = Str::uuid();
$model->save();
});
Schema::table('users', function (Blueprint $table) {
$table->uuid('uuid')->unique()->change();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('uuid');
});
}
};

View File

@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::connection(config('webpush.database_connection'))->create(config('webpush.table_name'), function (Blueprint $table) {
$table->bigIncrements('id');
$table->morphs('subscribable', 'push_subscriptions_subscribable_morph_idx');
$table->string('endpoint', 500)->unique();
$table->string('public_key')->nullable();
$table->string('auth_token')->nullable();
$table->string('content_encoding')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::connection(config('webpush.database_connection'))->dropIfExists(config('webpush.table_name'));
}
};

View File

@ -2,24 +2,23 @@
namespace Database\Seeders; namespace Database\Seeders;
use App\Models\User;
use App\Models\Dynamic; use App\Models\Dynamic;
use App\Models\Ledger; use App\Models\Ledger;
use App\Models\Mutation;
use App\Models\Message; use App\Models\Message;
use App\Services\ActivityService; use App\Models\Mutation;
use App\Models\User;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
class DatabaseSeeder extends Seeder class DatabaseSeeder extends Seeder
{ {
/** /**
* Seed the application's database. * Seed the application's database.
*/ */
public function run(ActivityService $activityService): void public function run(): void
{ {
DB::transaction(function () use ($activityService) { DB::transaction(function () {
// 1. Create Core Users // 1. Create Core Users
$testUser = User::factory()->create([ $testUser = User::factory()->create([
'name' => 'Test User', 'name' => 'Test User',
@ -53,24 +52,41 @@ class DatabaseSeeder extends Seeder
// ---------------------------------------------------- // ----------------------------------------------------
$velvetSanctuary = Dynamic::create([ $velvetSanctuary = Dynamic::create([
'name' => 'The Velvet Sanctuary', 'name' => 'The Velvet Sanctuary',
'rules' => "1. Respect limits and boundaries at all times. '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.",
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) // Add participants (Test User is owner, Alice is owner, Bob is submissive/participant)
$velvetSanctuary->participants()->attach($testUser->id, ['role' => 'owner']); $velvetSanctuary->participants()->attach($testUser->id, ['role' => 'owner', 'display_name' => 'The Master']);
$velvetSanctuary->participants()->attach($alice->id, ['role' => 'owner']); $velvetSanctuary->participants()->attach($alice->id, ['role' => 'owner']);
$velvetSanctuary->participants()->attach($bob->id, ['role' => 'participant']); $velvetSanctuary->participants()->attach($bob->id, ['role' => 'participant', 'display_name' => 'Bitch Boi']);
// Chat has been auto-created by the booted hook on Dynamic // Chat has been auto-created by the booted hook on Dynamic
$velvetChat = $velvetSanctuary->chat; $velvetChat = $velvetSanctuary->chat;
// Seed Dynamic Chat Messages // Seed Dynamic Chat Messages
$activityService->createMessage($velvetChat, $alice, 'Good morning everyone. Bob, please ensure the Obsidian room is polished before 4 PM.'); Message::create([
$activityService->createMessage($velvetChat, $testUser, 'I will review the curfew log later today.'); 'chat_id' => $velvetChat->id,
$activityService->createMessage($velvetChat, $bob, "Yes, Sir. Yes, Ma'am. I am starting on the chores now."); 'user_id' => $alice->id,
$activityService->createMessage($velvetChat, $testUser, 'Excellent. Keep up the high standard.'); '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.',
]);
// Add Ledgers // Add Ledgers
$curfewLedger = Ledger::create([ $curfewLedger = Ledger::create([
@ -98,37 +114,144 @@ class DatabaseSeeder extends Seeder
]); ]);
// Seed Curfew Mutations // Seed Curfew Mutations
$activityService->createMutation($curfewLedger, $bob, 'reward', 10, 'Checked in by 10:45 PM on Friday', 'approved'); Mutation::create([
$activityService->createMutation($curfewLedger, $bob, 'reward', 15, 'Checked in by 10:30 PM on Saturday', 'approved'); 'ledger_id' => $curfewLedger->id,
$activityService->createMutation($curfewLedger, $bob, 'reward', 10, 'Checked in by 10:50 PM on Sunday', 'approved'); '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',
]);
// Seed Cleaning Mutations // Seed Cleaning Mutations
$activityService->createMutation($cleaningLedger, $bob, 'reward', 15, 'Deep cleaning of the main chamber', 'approved'); Mutation::create([
$activityService->createMutation($cleaningLedger, $bob, 'reward', 20, 'Arranged gear rack and polished leather accessories', 'approved'); 'ledger_id' => $cleaningLedger->id,
$activityService->createMutation($cleaningLedger, $bob, 'reward', 10, 'Mopped obsidian floors', 'approved'); 'user_id' => $bob->id,
$activityService->createMutation($cleaningLedger, $alice, 'penalty', -10, 'Left keys in the locks unmonitored', 'approved'); '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',
]);
// Seed Pending Mutation with its own Chat Messages! // Seed Pending Mutation with its own Chat Messages!
$pendingMutation = $activityService->createMutation($cleaningLedger, $bob, 'addition', 10, 'Weekly chore submission - dusting shelves', 'pending'); $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)
$pendingMutationChat = $pendingMutation->chat; $pendingMutationChat = $pendingMutation->chat;
$activityService->createMessage($pendingMutationChat, $bob, 'I have finished the shelves. Please approve when convenient.'); Message::create([
$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."); 'chat_id' => $pendingMutationChat->id,
$activityService->createMessage($pendingMutationChat, $bob, 'Apologies, Ma\'am. I will re-wipe the top section immediately!'); '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!',
]);
// Seed Etiquette Mutations // Seed Etiquette Mutations
$activityService->createMutation($etiquetteLedger, $alice, 'penalty', 5, 'Interrupted Domina Alice during daily instructions', 'approved'); Mutation::create([
$activityService->createMutation($etiquetteLedger, $alice, 'penalty', 10, 'Forgot correct posture during morning roll call', 'approved'); 'ledger_id' => $etiquetteLedger->id,
$activityService->createMutation($etiquetteLedger, $alice, 'penalty', 5, 'Spoke out of turn in general chat', 'approved'); 'user_id' => $bob->id,
$activityService->createMutation($etiquetteLedger, $bob, 'reward', -5, 'Excellent reciting of the house codes', 'approved'); 'type' => 'penalty',
'amount' => 5,
'description' => 'Interrupted Domina Alice during daily instructions',
'status' => 'approved',
]);
Mutation::create([
'ledger_id' => $etiquetteLedger->id,
'user_id' => $bob->id,
'type' => 'penalty',
'amount' => 10,
'description' => 'Forgot correct posture during morning roll call',
'status' => 'approved',
]);
Mutation::create([
'ledger_id' => $etiquetteLedger->id,
'user_id' => $bob->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',
]);
// ---------------------------------------------------- // ----------------------------------------------------
// 3. Seed Dynamic 2: Obsidian Household Agreement // 3. Seed Dynamic 2: Obsidian Household Agreement
// ---------------------------------------------------- // ----------------------------------------------------
$obsidianHousehold = Dynamic::create([ $obsidianHousehold = Dynamic::create([
'name' => 'Obsidian Household Agreement', 'name' => 'Obsidian Household Agreement',
'rules' => "1. All residents must do their fair share of maintenance. 'rules' => "1. All residents must do their fair share of maintenance.\n2. Coffee machine must be refilled immediately when empty.",
2. Coffee machine must be refilled immediately when empty.",
]); ]);
$obsidianHousehold->participants()->attach($alice->id, ['role' => 'owner']); $obsidianHousehold->participants()->attach($alice->id, ['role' => 'owner']);
@ -137,10 +260,29 @@ class DatabaseSeeder extends Seeder
$obsidianChat = $obsidianHousehold->chat; $obsidianChat = $obsidianHousehold->chat;
$activityService->createMessage($obsidianChat, $alice, "Who finished the coffee beans and didn't put them on the shopping list?"); Message::create([
$activityService->createMessage($obsidianChat, $charles, "Wasn't me, I only drink tea."); 'chat_id' => $obsidianChat->id,
$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.'); 'user_id' => $alice->id,
$activityService->createMessage($obsidianChat, $alice, 'Thank you, Test User. Appreciate the honesty.'); '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.',
]);
// Add Ledgers // Add Ledgers
$kitchenLedger = Ledger::create([ $kitchenLedger = Ledger::create([
@ -160,11 +302,33 @@ class DatabaseSeeder extends Seeder
]); ]);
// Seed Chores Mutations // Seed Chores Mutations
$activityService->createMutation($kitchenLedger, $testUser, 'reward', 25, 'Emptied and loaded the dishwasher', 'approved'); Mutation::create([
$activityService->createMutation($kitchenLedger, $testUser, 'reward', 15, 'Took out recycling and trash bags', 'approved'); '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',
]);
// Seed Coffee Mutations // Seed Coffee Mutations
$activityService->createMutation($coffeeLedger, $testUser, 'reward', 10, 'Descaled and refilled coffee beans', 'approved'); Mutation::create([
'ledger_id' => $coffeeLedger->id,
'user_id' => $testUser->id,
'type' => 'reward',
'amount' => 10,
'description' => 'Descaled and refilled coffee beans',
'status' => 'approved',
]);
}); });
} }
} }

226
package-lock.json generated
View File

@ -636,9 +636,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@lucide/vue": { "node_modules/@lucide/vue": {
"version": "1.18.0", "version": "1.21.0",
"resolved": "https://registry.npmjs.org/@lucide/vue/-/vue-1.18.0.tgz", "resolved": "https://registry.npmjs.org/@lucide/vue/-/vue-1.21.0.tgz",
"integrity": "sha512-DmnUpDB85PlMZ+ofjZLcKq3JoJnaD1bk7SIj9xwUvqerfNqA6hCLa0/m3gIybH6rdrErABbqvTD8yYJdNqiZ3Q==", "integrity": "sha512-eoFn3tppjKAc12ZqdnRSMFdtwQ1ZMRQFb6SV1Eub6Y8kU28ccnqKeSFdnur9hMg8gIbosU2Y3WFJr/J/xS/IlQ==",
"license": "ISC", "license": "ISC",
"peerDependencies": { "peerDependencies": {
"vue": ">=3.0.1" "vue": ">=3.0.1"
@ -1297,9 +1297,9 @@
} }
}, },
"node_modules/@tanstack/virtual-core": { "node_modules/@tanstack/virtual-core": {
"version": "3.17.0", "version": "3.17.1",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.0.tgz", "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.1.tgz",
"integrity": "sha512-gOxY/hFkPh/XQYhnThBHzkbkX3Ed+z/iushyz+R+JAr213aXxUDgQoTgTdrDpBSRsjFM73P/KfUyWmaF9WHMkQ==", "integrity": "sha512-VZyW2Uiml5tmBZwPGrSD3Sz73OxzljQMCmzYHsUTPEuTsERf5xwa+uWb01xEzkz3ZSYTjj8NEb/mKHvgKxyZdA==",
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"type": "github", "type": "github",
@ -1307,12 +1307,12 @@
} }
}, },
"node_modules/@tanstack/vue-virtual": { "node_modules/@tanstack/vue-virtual": {
"version": "3.13.28", "version": "3.13.29",
"resolved": "https://registry.npmjs.org/@tanstack/vue-virtual/-/vue-virtual-3.13.28.tgz", "resolved": "https://registry.npmjs.org/@tanstack/vue-virtual/-/vue-virtual-3.13.29.tgz",
"integrity": "sha512-A+jWpXtMpWXKhGLKQrXeC9mk1VgYeMWSJ+o0CTCEi+HLYMSQFdVmPG9lJz7d4XJyIkc5xVwZU9QY67QpScqnxA==", "integrity": "sha512-MWb9tNHjpar3sP34b8+3A4I5j9akveoPXIYqqp7/ipyWd49a/kso+1S1LqEmAVR/+g/k1WWTJC4ktvdCGWgXYQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@tanstack/virtual-core": "3.17.0" "@tanstack/virtual-core": "3.17.1"
}, },
"funding": { "funding": {
"type": "github", "type": "github",
@ -1354,9 +1354,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "22.19.21", "version": "22.20.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.21.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.0.tgz",
"integrity": "sha512-VMeFBSCKQKmm2swI2kW51SFusDqekC6q9trBCvJ/JliDchFSuoYYKN7yVNjPthP1HKZcx3U1gI/wTcEBjEFKTA==", "integrity": "sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -1370,17 +1370,17 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@typescript-eslint/eslint-plugin": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.61.0", "version": "8.61.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.61.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.61.1.tgz",
"integrity": "sha512-bFNvl9ZczlVb+wR2Akszf3gHfKVj/8WanXaGJ3UstTA7brNKg0cNdk6X1Psu5V7MZ2oQtzZKOEzIUehaoxbDGw==", "integrity": "sha512-ZPlVl3PB3et/59Ne0fv/sci6ZXz4T4Hp4nTJ56i/Y0gR89ARb+KphojTq6j+56E5PIezmOIOOWyY+aWQFd+IkQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@eslint-community/regexpp": "^4.12.2", "@eslint-community/regexpp": "^4.12.2",
"@typescript-eslint/scope-manager": "8.61.0", "@typescript-eslint/scope-manager": "8.61.1",
"@typescript-eslint/type-utils": "8.61.0", "@typescript-eslint/type-utils": "8.61.1",
"@typescript-eslint/utils": "8.61.0", "@typescript-eslint/utils": "8.61.1",
"@typescript-eslint/visitor-keys": "8.61.0", "@typescript-eslint/visitor-keys": "8.61.1",
"ignore": "^7.0.5", "ignore": "^7.0.5",
"natural-compare": "^1.4.0", "natural-compare": "^1.4.0",
"ts-api-utils": "^2.5.0" "ts-api-utils": "^2.5.0"
@ -1393,7 +1393,7 @@
"url": "https://opencollective.com/typescript-eslint" "url": "https://opencollective.com/typescript-eslint"
}, },
"peerDependencies": { "peerDependencies": {
"@typescript-eslint/parser": "^8.61.0", "@typescript-eslint/parser": "^8.61.1",
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0" "typescript": ">=4.8.4 <6.1.0"
} }
@ -1409,16 +1409,16 @@
} }
}, },
"node_modules/@typescript-eslint/parser": { "node_modules/@typescript-eslint/parser": {
"version": "8.61.0", "version": "8.61.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.61.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.61.1.tgz",
"integrity": "sha512-5B7PfA2e1NQGCnDHd/0lW7W3gvp3d59Ryw54FYO8Uswxo9f6ikw3AZV+Xj/TvpImmpsiYyUqAfhC6kJID1jF6w==", "integrity": "sha512-PJ5vePq5/ognBbrIcoC5+SHO5dfpeLPzP9FpLkzWrguoYQEeeSjlJpVwOpo1JRSTEi7dRcwNy4h4dzV70PqHcg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.61.0", "@typescript-eslint/scope-manager": "8.61.1",
"@typescript-eslint/types": "8.61.0", "@typescript-eslint/types": "8.61.1",
"@typescript-eslint/typescript-estree": "8.61.0", "@typescript-eslint/typescript-estree": "8.61.1",
"@typescript-eslint/visitor-keys": "8.61.0", "@typescript-eslint/visitor-keys": "8.61.1",
"debug": "^4.4.3" "debug": "^4.4.3"
}, },
"engines": { "engines": {
@ -1434,14 +1434,14 @@
} }
}, },
"node_modules/@typescript-eslint/project-service": { "node_modules/@typescript-eslint/project-service": {
"version": "8.61.0", "version": "8.61.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.61.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.61.1.tgz",
"integrity": "sha512-DV42F7MLJO6Rax7SK1yg43tcnEfGUrurSpSxKuVX+a3RCTzBlH3fuxprrOJXKCJGAaw82xXocikJ0uQaqwXgGA==", "integrity": "sha512-PrC4JYGmR241lYnfhmKGTXkFqv8+ymbTFgSAY0fVXpY82/QkMw5TZPl+vGzuDDU2QYJk9fIDOBTntF+yDv9LEA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.61.0", "@typescript-eslint/tsconfig-utils": "^8.61.1",
"@typescript-eslint/types": "^8.61.0", "@typescript-eslint/types": "^8.61.1",
"debug": "^4.4.3" "debug": "^4.4.3"
}, },
"engines": { "engines": {
@ -1456,14 +1456,14 @@
} }
}, },
"node_modules/@typescript-eslint/scope-manager": { "node_modules/@typescript-eslint/scope-manager": {
"version": "8.61.0", "version": "8.61.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.61.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.61.1.tgz",
"integrity": "sha512-IWdXFHFSb6mlC3HPc7QsLDm5zYEbUla6trDEHf32D3/dnuUyXd87plScSNXSbm0/RxMvObpI17sv/EDTGrGZkA==", "integrity": "sha512-L2bdIeoQS8FlKAvONAr20w6OcLXeB+qiDKbAooS9A0Ben+iSIkBef0FxqwKWYqt5sa0i4KJtxVyVmhMylKzF5w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/types": "8.61.0", "@typescript-eslint/types": "8.61.1",
"@typescript-eslint/visitor-keys": "8.61.0" "@typescript-eslint/visitor-keys": "8.61.1"
}, },
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@ -1474,9 +1474,9 @@
} }
}, },
"node_modules/@typescript-eslint/tsconfig-utils": { "node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.61.0", "version": "8.61.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.61.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.61.1.tgz",
"integrity": "sha512-O5Amvdv9ztMpxpf+vmFULGG78IE6Qwdr3bCGvqwG4nwc9H2qXkOYJJnRbRHyMkQTjv1d03olqwwwzHLMqpFePQ==", "integrity": "sha512-UN/H4di+OO7EWx2ovME+8t31YO+KVnK0RRKEHR3kOt21/Ay8BOq3M1OMvWs5vNiqcFCYGYoxK3MXPZzmMUE+yg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@ -1491,15 +1491,15 @@
} }
}, },
"node_modules/@typescript-eslint/type-utils": { "node_modules/@typescript-eslint/type-utils": {
"version": "8.61.0", "version": "8.61.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.61.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.61.1.tgz",
"integrity": "sha512-TuBiQYIkd97yBfInHCTKVYMbX4kvEmpOEuixIuzCU9p8BGT1SfyyO0d0IfDMbPIHcjn/hWnusUX5e8v5Xg+X8A==", "integrity": "sha512-GYRicKmVK0C4fsKgaACaknOUAq9Oa2kwsjnpFhFcS/5p4Ht5IP9OVLbgIgcK4SRk92nVHFluurg1lumD9dBcLw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/types": "8.61.0", "@typescript-eslint/types": "8.61.1",
"@typescript-eslint/typescript-estree": "8.61.0", "@typescript-eslint/typescript-estree": "8.61.1",
"@typescript-eslint/utils": "8.61.0", "@typescript-eslint/utils": "8.61.1",
"debug": "^4.4.3", "debug": "^4.4.3",
"ts-api-utils": "^2.5.0" "ts-api-utils": "^2.5.0"
}, },
@ -1516,9 +1516,9 @@
} }
}, },
"node_modules/@typescript-eslint/types": { "node_modules/@typescript-eslint/types": {
"version": "8.61.0", "version": "8.61.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.61.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.61.1.tgz",
"integrity": "sha512-9QTQpZ5Iin4CdIodfbDQFSeiSJKidgYJYug1P9CC2xWgUTvlmixViqDZNciMjwLBZyJnG4tGmPl97rVAFb1AJg==", "integrity": "sha512-G+CRlPqLv7Bz1IZVs03x5K59F1veqL0EJUROAdGhKsEq8qOiRiZbI+HUojPq5l0fEGOKModD9br6lObhB8zkoA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@ -1530,16 +1530,16 @@
} }
}, },
"node_modules/@typescript-eslint/typescript-estree": { "node_modules/@typescript-eslint/typescript-estree": {
"version": "8.61.0", "version": "8.61.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.61.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.61.1.tgz",
"integrity": "sha512-42zatd5qSvvcV1JdDBCLxYRznvP4eIHpPoZXdkPFnAmanA4FuZ5dibSnCBggY8hQnqajPpoGjXFdZ7fIJKQnlA==", "integrity": "sha512-u+oQD3BqYWPc8YV9Zab4vaJElJuwOLPRc10Jm1o/qS+6Qwen14HCWwx0Seo4LnSn2wxea2Ik8DxPt2/FHmuhrg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/project-service": "8.61.0", "@typescript-eslint/project-service": "8.61.1",
"@typescript-eslint/tsconfig-utils": "8.61.0", "@typescript-eslint/tsconfig-utils": "8.61.1",
"@typescript-eslint/types": "8.61.0", "@typescript-eslint/types": "8.61.1",
"@typescript-eslint/visitor-keys": "8.61.0", "@typescript-eslint/visitor-keys": "8.61.1",
"debug": "^4.4.3", "debug": "^4.4.3",
"minimatch": "^10.2.2", "minimatch": "^10.2.2",
"semver": "^7.7.3", "semver": "^7.7.3",
@ -1558,16 +1558,16 @@
} }
}, },
"node_modules/@typescript-eslint/utils": { "node_modules/@typescript-eslint/utils": {
"version": "8.61.0", "version": "8.61.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.61.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.61.1.tgz",
"integrity": "sha512-3bzFt7ImFMW/jVYwJamDoe/dMOdFLSC6pom6rRjdh4SZJEYupyMzem8e7vKZLclLfpHjlwSAXOUxtKxGXUiLqA==", "integrity": "sha512-1+P/3Dj6jvtybE1q0HQ6yBt/gq+oKJyLdEv4HdnqasaEXRSYCAsD59mXEVQnM/ULNdQxbX77tdG4jPRjIS6knA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.9.1", "@eslint-community/eslint-utils": "^4.9.1",
"@typescript-eslint/scope-manager": "8.61.0", "@typescript-eslint/scope-manager": "8.61.1",
"@typescript-eslint/types": "8.61.0", "@typescript-eslint/types": "8.61.1",
"@typescript-eslint/typescript-estree": "8.61.0" "@typescript-eslint/typescript-estree": "8.61.1"
}, },
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@ -1582,13 +1582,13 @@
} }
}, },
"node_modules/@typescript-eslint/visitor-keys": { "node_modules/@typescript-eslint/visitor-keys": {
"version": "8.61.0", "version": "8.61.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.61.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.61.1.tgz",
"integrity": "sha512-QVLZu3ZPQEE+HICQyAMZ2yLQhxf0meY/wx6Hx14YcTNj13JB3qHlX3lJ02L3fLGHgERRH71kvYDwiXIguT3AjQ==", "integrity": "sha512-6fJ9MHWtK14C1DSkiMlHUSOmrVebL7150xZJBlJiL62jjhIA4JmOq6flwBgDxIdBKKdoiZRel+dfPD5MLfny3w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/types": "8.61.0", "@typescript-eslint/types": "8.61.1",
"eslint-visitor-keys": "^5.0.0" "eslint-visitor-keys": "^5.0.0"
}, },
"engines": { "engines": {
@ -2033,9 +2033,9 @@
} }
}, },
"node_modules/@vue/eslint-config-typescript": { "node_modules/@vue/eslint-config-typescript": {
"version": "14.8.0", "version": "14.9.0",
"resolved": "https://registry.npmjs.org/@vue/eslint-config-typescript/-/eslint-config-typescript-14.8.0.tgz", "resolved": "https://registry.npmjs.org/@vue/eslint-config-typescript/-/eslint-config-typescript-14.9.0.tgz",
"integrity": "sha512-yIquzhXH7ZsrwSSm+rYvoGCRY6wcuF4qBi76e0l7hHLq7YU0f9aC+RcR5fL+XJNfmBZxgX5cVl4sppt4x7ZCBg==", "integrity": "sha512-E3j9hDlfVf10F30MRcLTPY2IIhWIx1nsvkVukk14kTcuA+oBVot9zsP1hzsO+PAMDxV3Fd9FimBJtUBNBL5KFA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -2044,6 +2044,9 @@
"typescript-eslint": "^8.60.0", "typescript-eslint": "^8.60.0",
"vue-eslint-parser": "^10.4.0" "vue-eslint-parser": "^10.4.0"
}, },
"bin": {
"vue-eslint-config-typescript": "dist/bin.js"
},
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}, },
@ -2639,15 +2642,15 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/concurrently": { "node_modules/concurrently": {
"version": "9.2.1", "version": "9.2.3",
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz", "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.3.tgz",
"integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==", "integrity": "sha512-ihjs0E2SxvDgq/MK418hX6YycQgKhsqxpbZuZbHo0yKfqDWdymWMjWYIpCIzqDDLLKClHlXev8whW/8WXmJ0BA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"chalk": "4.1.2", "chalk": "4.1.2",
"rxjs": "7.8.2", "rxjs": "7.8.2",
"shell-quote": "1.8.3", "shell-quote": "1.8.4",
"supports-color": "8.1.1", "supports-color": "8.1.1",
"tree-kill": "1.2.2", "tree-kill": "1.2.2",
"yargs": "17.7.2" "yargs": "17.7.2"
@ -2964,6 +2967,25 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/es-abstract-get": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/es-abstract-get/-/es-abstract-get-1.0.0.tgz",
"integrity": "sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.2",
"is-callable": "^1.2.7",
"object-inspect": "^1.13.4"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/es-define-property": { "node_modules/es-define-property": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
@ -3027,15 +3049,17 @@
} }
}, },
"node_modules/es-to-primitive": { "node_modules/es-to-primitive": {
"version": "1.3.0", "version": "1.3.1",
"resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.1.tgz",
"integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", "integrity": "sha512-CxN9N56HYfd2m/acc/NOFrZQsN9kU4eh+2kk6A707Kz1krH8tKmfrs5RnftB8WNX80T0NS7vSQsDOlg23diR2g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"es-abstract-get": "^1.0.0",
"es-errors": "^1.3.0",
"is-callable": "^1.2.7", "is-callable": "^1.2.7",
"is-date-object": "^1.0.5", "is-date-object": "^1.1.0",
"is-symbol": "^1.0.4" "is-symbol": "^1.1.1"
}, },
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
@ -3045,9 +3069,9 @@
} }
}, },
"node_modules/es-toolkit": { "node_modules/es-toolkit": {
"version": "1.47.1", "version": "1.48.1",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.47.1.tgz", "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.48.1.tgz",
"integrity": "sha512-5RAqEwf4P4E17p+W75KLOWw/nOvKZzSQpxM32IpI2KZLaVonjTrZ0Ai5ghMaVI9eKC2p8eoQgcBdkEDgzFk6+Q==", "integrity": "sha512-wfnXlwd5I75eXRtdD2vuEs50xHHESECDsGD7yiQnfFVNoa5522NwXEbmgo98LfiukSQHs+mBM7/YG3qKJB9/mQ==",
"license": "MIT", "license": "MIT",
"workspaces": [ "workspaces": [
"docs", "docs",
@ -5090,9 +5114,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.12", "version": "3.3.15",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@ -5687,9 +5711,9 @@
} }
}, },
"node_modules/reka-ui": { "node_modules/reka-ui": {
"version": "2.9.10", "version": "2.10.0",
"resolved": "https://registry.npmjs.org/reka-ui/-/reka-ui-2.9.10.tgz", "resolved": "https://registry.npmjs.org/reka-ui/-/reka-ui-2.10.0.tgz",
"integrity": "sha512-yuvZVTp4fWH2G3qk+ze/x6YYlyc2Xl1d+eMUlIYrKqzTowBKteoDoN17fitURmqSUck3mc7JbcYgp49DnGu2EQ==", "integrity": "sha512-HIUVfSBM/AyGkcUI7aiOxxMc4N+0UD2ZEun8dcrT0H4fveotEoeDdvzyZu97eeEvEa1H9oGHoOpApkfxlgnC7g==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@floating-ui/dom": "^1.6.13", "@floating-ui/dom": "^1.6.13",
@ -5937,9 +5961,9 @@
} }
}, },
"node_modules/semver": { "node_modules/semver": {
"version": "7.8.4", "version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"dev": true, "dev": true,
"license": "ISC", "license": "ISC",
"bin": { "bin": {
@ -6022,9 +6046,9 @@
} }
}, },
"node_modules/shell-quote": { "node_modules/shell-quote": {
"version": "1.8.3", "version": "1.8.4",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz",
"integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", "integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@ -6519,16 +6543,16 @@
} }
}, },
"node_modules/typescript-eslint": { "node_modules/typescript-eslint": {
"version": "8.61.0", "version": "8.61.1",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.61.0.tgz", "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.61.1.tgz",
"integrity": "sha512-8y31Rd0eGTrDKqhy6vT0HtzhN+YLjQizwX3aA3hPXP/ynSfnrBXcQY5IzsP9/DM7+klX4IUncZZjkchP0z+rUw==", "integrity": "sha512-V7PayAfJokV3pEHgN7/v03D1SpujhRfQtYLbLIiBfDDncdg4PAiRBfoS4cnCANK4jmAPncczi59QO3afiXUlNw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/eslint-plugin": "8.61.0", "@typescript-eslint/eslint-plugin": "8.61.1",
"@typescript-eslint/parser": "8.61.0", "@typescript-eslint/parser": "8.61.1",
"@typescript-eslint/typescript-estree": "8.61.0", "@typescript-eslint/typescript-estree": "8.61.1",
"@typescript-eslint/utils": "8.61.0" "@typescript-eslint/utils": "8.61.1"
}, },
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"

38
public/sw.js Normal file
View File

@ -0,0 +1,38 @@
self.addEventListener('push', function (event) {
let data = {};
try {
data = event.data?.json() ?? {};
} catch (e) {
data = { title: 'Notification', body: event.data?.text() ?? '' };
}
event.waitUntil(
self.registration.showNotification(data.title, {
body: data.body,
icon: data.icon,
badge: data.badge,
data: data.data,
actions: data.actions,
}),
);
});
self.addEventListener('notificationclick', function (event) {
event.notification.close();
const url = event.notification.data?.url ?? '/';
event.waitUntil(
self.clients
.matchAll({ type: 'window', includeUncontrolled: true })
.then(function (clientList) {
for (const client of clientList) {
if (client.url === url && 'focus' in client) {
return client.focus();
}
}
return self.clients.openWindow(url);
}),
);
});

View File

@ -13,5 +13,6 @@
@import './components/password-input.css'; @import './components/password-input.css';
@import './components/auth-layout.css'; @import './components/auth-layout.css';
@import './components/chat.css'; @import './components/chat.css';
@import './components/lightbox.css'; @import "./components/lightbox.css";
@import './components/invite-form.css'; @import "./components/invite-form.css";
/*@import "./components/display-name-form.css";*/

View File

@ -10,6 +10,19 @@
.c-chat__list { .c-chat__list {
@apply mt-4 flex flex-col gap-3; @apply mt-4 flex flex-col gap-3;
.c-chat__load-more {
@apply mb-4 text-center;
.c-chat__load-more-btn {
@apply text-sm font-semibold;
color: var(--primary);
&:hover {
text-decoration: underline;
}
}
}
.c-chat__message { .c-chat__message {
@apply overflow-hidden p-4 shadow-sm sm:rounded-lg; @apply overflow-hidden p-4 shadow-sm sm:rounded-lg;
border: 1px solid var(--border); border: 1px solid var(--border);
@ -83,7 +96,7 @@
.c-chat__message-author { .c-chat__message-author {
@apply font-semibold; @apply font-semibold;
color: var(--foreground); /*color: var(--foreground);*/
} }
.c-chat__message-time { .c-chat__message-time {
@ -208,3 +221,4 @@
} }
} }
} }
.c-chat__user-link { @apply font-semibold text-blue-500 hover:underline; }

View File

@ -49,3 +49,7 @@ initializeTheme();
// This will listen for flash toast data from the server... // This will listen for flash toast data from the server...
initializeFlashToast(); initializeFlashToast();
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js');
}

View File

@ -3,8 +3,8 @@ import { useForm } from '@inertiajs/vue3';
import { route } from 'ziggy-js'; import { route } from 'ziggy-js';
const props = defineProps<{ const props = defineProps<{
dynamicId: number; dynamicId: string;
ledgerId: number; ledgerId: string;
}>(); }>();
const form = useForm({ const form = useForm({

View File

@ -1,18 +1,22 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue'; import { useForm, usePage, router } from '@inertiajs/vue3';
import { useForm, usePage } from '@inertiajs/vue3';
import { useEcho, echoIsConfigured, configureEcho } from '@laravel/echo-vue'; import { useEcho, echoIsConfigured, configureEcho } from '@laravel/echo-vue';
import { route } from 'ziggy-js';
import { Paperclip, Info } from '@lucide/vue'; import { Paperclip, Info } from '@lucide/vue';
import { ref, computed, watch } from 'vue';
import { route } from 'ziggy-js';
const props = defineProps<{ const props = withDefaults(
defineProps<{
chat: { chat: {
id: number; id: number;
messages: Array<{ messages?: Array<{
id: number; id: number;
user: { id: number; name: string }; user: { id: number; name: string } | null;
content: string; content: string;
created_at: string; created_at: string;
subject_id?: number | null;
subject_type?: string | null;
subject?: any;
media?: Array<{ media?: Array<{
id: number; id: number;
url: string; url: string;
@ -21,7 +25,95 @@ const props = defineProps<{
}>; }>;
}>; }>;
}; };
}>(); participants?: Array<{
id: number;
name: string;
pivot?: {
display_name: string | null;
} | null;
}>;
dynamicId: string;
ledgerId?: string | null;
initialMessages?: {
data: Array<any>;
next_page_url?: string | null;
links?: {
next: string | null;
} | null;
current_page?: number;
meta?: {
current_page: number;
} | null;
} | null;
}>(),
{
participants: () => [],
initialMessages: null,
ledgerId: null,
}
);
const getNextPageUrl = (paginator: any) => {
return paginator?.links?.next ?? paginator?.next_page_url ?? null;
};
const getCurrentPage = (paginator: any) => {
return paginator?.meta?.current_page ?? paginator?.current_page ?? 1;
};
const messages = ref(
props.initialMessages
? props.initialMessages.data.slice().reverse()
: (props.chat.messages || []).slice()
);
const nextPageUrl = ref(getNextPageUrl(props.initialMessages));
const currentPageNum = ref(1);
watch(
() => props.initialMessages,
(newVal) => {
if (newVal && getCurrentPage(newVal) === 1) {
messages.value = newVal.data.slice().reverse();
nextPageUrl.value = getNextPageUrl(newVal);
currentPageNum.value = 1;
}
},
{ deep: true }
);
watch(
() => props.chat.messages,
(newVal) => {
if (!props.initialMessages && newVal) {
messages.value = newVal.slice();
}
},
{ deep: true }
);
function loadMoreMessages() {
if (!nextPageUrl.value) {
return;
}
currentPageNum.value++;
const apiRouteName = props.ledgerId ? 'dynamics.ledgers.messages' : 'dynamics.messages';
const apiParams = props.ledgerId ? [props.dynamicId, props.ledgerId] : [props.dynamicId];
const url = route(apiRouteName, [...apiParams, { page: currentPageNum.value }]);
fetch(url)
.then((res) => res.json())
.then((json) => {
const data = json?.data || [];
messages.value = [...data.slice().reverse(), ...messages.value];
nextPageUrl.value = getNextPageUrl(json);
})
.catch((err) => {
console.error('Failed to load older messages:', err);
currentPageNum.value--;
});
}
if (!echoIsConfigured()) { if (!echoIsConfigured()) {
configureEcho({ configureEcho({
@ -47,11 +139,108 @@ const form = useForm({
}); });
useEcho(`chats.${props.chat.id}`, 'MessageSent', (e: any) => { useEcho(`chats.${props.chat.id}`, 'MessageSent', (e: any) => {
props.chat.messages.push(e.message); messages.value.push(e.message);
}); });
function formatTimestamp(isoString: string): { full: string; time: string } {
const date = new Date(isoString);
return {
full: date.toLocaleString(),
time: date.toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
hour12: false,
}),
};
}
const participantsById = computed(() => {
const list = props.participants || [];
return list.reduce(
(acc, p) => {
acc[p.id] = p;
return acc;
},
{} as Record<
number,
{
id: number;
name: string;
pivot?: { display_name: string | null } | null;
}
>,
);
});
function parseMessageContent(message: {
content: string;
subject_id?: number | null;
subject_type?: string | null;
subject?: any;
}) {
let content = message.content;
// 1. Replace <user:id> placeholders with links to their dynamic profile
const userRegex = /<user:(\d+)>/g;
content = content.replace(userRegex, (match, userId) => {
const user = participantsById.value[Number(userId)];
if (user) {
const url = route('dynamics.users.show', [props.dynamicId, Number(userId)]);
return `<a href="${url}" class="c-chat__user-link font-semibold text-blue-500 hover:underline">${
user.pivot?.display_name ?? user.name
}</a>`;
}
return `User #${userId}`;
});
// 2. Link subjects if found in the text
if (message.subject_id && message.subject_type) {
if (
message.subject_type === 'App\\Models\\Mutation' ||
message.subject_type === 'App\\Models\\Ledger'
) {
const ledgerId =
message.subject_type === 'App\\Models\\Mutation'
? message.subject?.ledger_id
: message.subject?.id;
const ledgerName =
message.subject_type === 'App\\Models\\Mutation'
? message.subject?.ledger?.name
: message.subject?.name;
if (ledgerId && ledgerName) {
const ledgerUrl = route('dynamics.ledgers.show', [
props.dynamicId,
ledgerId,
]);
const escapedName = ledgerName.replace(
/[-\/\\^$*+?.()|[\]{}]/g,
'\\$&',
);
const nameRegex = new RegExp(`"${escapedName}"`, 'g');
content = content.replace(
nameRegex,
`"<a href="${ledgerUrl}" class="c-chat__subject-link font-semibold text-blue-500 hover:underline">${ledgerName}</a>"`,
);
}
}
}
return content;
}
function handleFileChange(event: Event) { function handleFileChange(event: Event) {
const files = (event.target as HTMLInputElement).files; const files = (event.target as HTMLInputElement).files;
if (files) { if (files) {
for (let i = 0; i < files.length; i++) { for (let i = 0; i < files.length; i++) {
form.media.push(files[i]); form.media.push(files[i]);
@ -65,14 +254,20 @@ function removeFile(index: number) {
const currentUser = computed(() => usePage().props.auth?.user); const currentUser = computed(() => usePage().props.auth?.user);
function isOwnMessage(messageUserId: number): boolean { function isOwnMessage(messageUserId: number | null): boolean {
if (messageUserId === null) {
return false;
}
return currentUser.value && currentUser.value.id === messageUserId; return currentUser.value && currentUser.value.id === messageUserId;
} }
function submit() { function submit() {
form.post(route('chats.messages.store', props.chat.id), { form.post(route('chats.messages.store', props.chat.id), {
preserveScroll: true,
onSuccess: () => { onSuccess: () => {
form.reset(); form.reset();
if (fileInput.value) { if (fileInput.value) {
fileInput.value.value = ''; fileInput.value.value = '';
} }
@ -101,34 +296,37 @@ function closeLightbox() {
<div class="c-chat"> <div class="c-chat">
<h4 class="c-chat__title">Chat</h4> <h4 class="c-chat__title">Chat</h4>
<div class="c-chat__list"> <div class="c-chat__list">
<div v-if="nextPageUrl" class="c-chat__load-more">
<button @click="loadMoreMessages" class="c-chat__load-more-btn">
Load More
</button>
</div>
<div <div
v-for="message in chat.messages" v-for="message in messages"
:key="message.id" :key="message.id"
:class="[ :class="[
'c-chat__message', 'c-chat__message',
{ {
'c-chat__message--system': 'c-chat__message--system': message.user === null,
message.user.id === 0, 'c-chat__message--own': isOwnMessage(message.user?.id),
'c-chat__message--own': isOwnMessage(message.user.id), 'c-chat__message--other': message.user !== null && !isOwnMessage(message.user?.id)
'c-chat__message--other': !isOwnMessage(
message.user.id,
),
}, },
]" ]"
> >
<!-- Standard User Chat Message --> <!-- Standard User Chat Message -->
<template v-if="!message.content.startsWith('System:')"> <template v-if="message.user">
<div class="c-chat__message-header"> <div class="c-chat__message-header">
<span class="c-chat__message-author">{{ <span class="c-chat__message-author">{{
message.user.name message.user.name
}}</span> }}</span>
<span class="c-chat__message-time">{{ <span
new Date(message.created_at).toLocaleString() class="c-chat__message-time"
}}</span> :title="formatTimestamp(message.created_at).full"
>
{{ formatTimestamp(message.created_at).time }}
</span>
</div> </div>
<p class="c-chat__message-text"> <p class="c-chat__message-text" v-html="parseMessageContent(message)"></p>
{{ message.content }}
</p>
<!-- Attached Media Display --> <!-- Attached Media Display -->
<div <div
@ -166,21 +364,20 @@ function closeLightbox() {
<template v-else> <template v-else>
<div class="c-chat__system-inner"> <div class="c-chat__system-inner">
<Info class="c-chat__system-icon" /> <Info class="c-chat__system-icon" />
<span class="c-chat__system-text"> <span
{{ message.content.replace(/^System:\s*/, '') }} class="c-chat__system-text"
</span> v-html="parseMessageContent(message)"
<span class="c-chat__system-time"> ></span>
{{ <span
new Date(message.created_at).toLocaleTimeString( class="c-chat__system-time"
[], :title="formatTimestamp(message.created_at).full"
{ hour: '2-digit', minute: '2-digit' }, >
) {{ formatTimestamp(message.created_at).time }}
}}
</span> </span>
</div> </div>
</template> </template>
</div> </div>
<div v-if="chat.messages.length === 0" class="c-chat__empty"> <div v-if="messages.length === 0" class="c-chat__empty">
No messages yet. No messages yet.
</div> </div>
</div> </div>

View File

@ -0,0 +1,31 @@
<script setup lang="ts">
import { computed } from 'vue';
import { Link } from '@inertiajs/vue3';
import { route } from 'ziggy-js';
const props = defineProps<{
message: {
id: number;
user: { id: number; name: string } | null;
content: string;
created_at: string;
media?: Array<{
id: number;
url: string;
file_name: string;
mime_type: string;
}>;
};
}>();
const processedContent = computed(() => {
return props.message.content.replace(/<user:(\d+)>/g, (match, userId) => {
// This is a placeholder for a more robust user lookup
return `<a href="${route('users.show', userId)}" class="text-blue-500 hover:underline">@user${userId}</a>`;
});
});
</script>
<template>
<div v-html="processedContent"></div>
</template>

View File

@ -0,0 +1,47 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import { route } from 'ziggy-js';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import InputError from '@/components/InputError.vue';
const props = defineProps<{
dynamic: {
id: number;
name: string;
pivot: {
display_name: string | null;
};
};
}>();
const form = useForm({
display_name: props.dynamic.pivot?.display_name ?? '',
});
function submit() {
form.put(route('dynamics.participant.update', props.dynamic.id), {
preserveScroll: true,
});
}
</script>
<template>
<form @submit.prevent="submit" class="flex flex-col sm:flex-row items-start sm:items-center gap-4 p-4 border rounded-lg bg-card text-card-foreground">
<div class="flex-1 w-full">
<div class="font-medium text-sm mb-1">{{ dynamic.name }}</div>
<Input
v-model="form.display_name"
class="w-full"
placeholder="Enter custom display name"
required
/>
<InputError class="mt-1" :message="form.errors.display_name" />
</div>
<div class="sm:self-end">
<Button type="submit" size="sm" :disabled="form.processing">
Save
</Button>
</div>
</form>
</template>

View File

@ -3,9 +3,9 @@ import { Link } from '@inertiajs/vue3';
import { route } from 'ziggy-js'; import { route } from 'ziggy-js';
defineProps<{ defineProps<{
dynamicId: number; dynamicId: string;
ledgers: Array<{ ledgers: Array<{
id: number; id: string;
name: string; name: string;
score: number; score: number;
alignment: string; alignment: string;

View File

@ -4,8 +4,8 @@ import { route } from 'ziggy-js';
import Chat from '@/components/Chat.vue'; import Chat from '@/components/Chat.vue';
const props = defineProps<{ const props = defineProps<{
dynamicId: number; dynamicId: string;
ledgerId: number; ledgerId: string;
ledgerAlignment?: string; ledgerAlignment?: string;
mutations: Array<{ mutations: Array<{
id: number; id: number;
@ -17,13 +17,16 @@ const props = defineProps<{
created_at: string; created_at: string;
chat: any; chat: any;
media?: Array<{ id: number; url: string; mime_type: string }>; media?: Array<{ id: number; url: string; mime_type: string }>;
can: {
update: boolean;
void: boolean;
};
}>; }>;
participants?: Array<{ participants?: Array<{
id: number; id: number;
name: string; name: string;
pivot?: { role: string }; pivot?: { role: string };
}>; }>;
isOwner: boolean;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
@ -40,6 +43,16 @@ function updateStatus(mutationId: number, status: 'approved' | 'rejected') {
); );
} }
function voidMutation(mutationId: number) {
useForm({}).put(
route('dynamics.ledgers.mutations.void', {
dynamic: props.dynamicId,
ledger: props.ledgerId,
mutation: mutationId,
}),
);
}
function isOwnerUser(userId: number): boolean { function isOwnerUser(userId: number): boolean {
const participant = props.participants?.find((p) => p.id === userId); const participant = props.participants?.find((p) => p.id === userId);
@ -157,24 +170,33 @@ function getAmountClass(amount: number): string {
<!-- Owner Approve/Reject Actions --> <!-- Owner Approve/Reject Actions -->
<div <div
v-if="isOwner && mutation.status === 'pending'" v-if="mutation.can?.update || mutation.can?.void"
class="c-mutation-list__actions" class="c-mutation-list__actions"
> >
<button <button
v-if="mutation.can?.update"
@click="updateStatus(mutation.id, 'approved')" @click="updateStatus(mutation.id, 'approved')"
class="c-mutation-list__approve-btn" class="c-mutation-list__approve-btn"
> >
Approve Approve
</button> </button>
<button <button
v-if="mutation.can?.update"
@click="updateStatus(mutation.id, 'rejected')" @click="updateStatus(mutation.id, 'rejected')"
class="c-mutation-list__reject-btn" class="c-mutation-list__reject-btn"
> >
Reject Reject
</button> </button>
<button
v-if="mutation.can?.void"
@click="voidMutation(mutation.id)"
class="c-mutation-list__void-btn"
>
Void
</button>
</div> </div>
<Chat :chat="mutation.chat" /> <Chat :chat="mutation.chat" :dynamic-id="dynamicId" :participants="participants" />
</li> </li>
</ul> </ul>
<div v-if="mutations.length === 0" class="c-mutation-list__empty"> <div v-if="mutations.length === 0" class="c-mutation-list__empty">
@ -290,6 +312,10 @@ function getAmountClass(amount: number): string {
@apply inline-flex cursor-pointer items-center rounded bg-red-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-red-500; @apply inline-flex cursor-pointer items-center rounded bg-red-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-red-500;
} }
.c-mutation-list__void-btn {
@apply inline-flex cursor-pointer items-center rounded bg-gray-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-gray-500;
}
.c-mutation-list__empty { .c-mutation-list__empty {
@apply mt-4 text-gray-500; @apply mt-4 text-gray-500;
} }

View File

@ -1,8 +1,15 @@
<script setup lang="ts"> <script setup lang="ts">
import { Link } from '@inertiajs/vue3';
import { route } from 'ziggy-js';
defineProps<{ defineProps<{
dynamicId: string;
participants: Array<{ participants: Array<{
id: number; id: string;
name: string; name: string;
pivot: {
display_name: string | null;
};
}>; }>;
}>(); }>();
</script> </script>
@ -16,7 +23,12 @@ defineProps<{
:key="participant.id" :key="participant.id"
class="c-participants-list__item" class="c-participants-list__item"
> >
{{ participant.name }} <Link
:href="route('dynamics.users.show', [dynamicId, participant.id])"
class="block"
>
{{ participant.pivot.display_name ?? participant.name }}
</Link>
</li> </li>
</ul> </ul>
</div> </div>

View File

@ -0,0 +1,72 @@
import { ref } from 'vue';
export function usePushNotifications() {
const isSubscribed = ref(false);
async function subscribe() {
if (!('serviceWorker' in navigator)) {
return;
}
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(
document.querySelector('meta[name="vapid-public-key"]')?.getAttribute('content') || '',
),
});
await fetch('/subscriptions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '',
},
body: JSON.stringify(subscription),
});
isSubscribed.value = true;
}
async function unsubscribe() {
if (!('serviceWorker' in navigator)) {
return;
}
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
if (subscription) {
await fetch('/subscriptions/delete', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '',
},
body: JSON.stringify({ endpoint: subscription.endpoint }),
});
await subscription.unsubscribe();
}
isSubscribed.value = false;
}
function urlBase64ToUint8Array(base64String: string) {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
return {
isSubscribed,
subscribe,
unsubscribe,
};
}

View File

@ -1,14 +1,31 @@
<script setup lang="ts"> <script setup lang="ts">
import AppLayout from '@/layouts/app/AppSidebarLayout.vue'; import AppLayout from '@/layouts/app/AppSidebarLayout.vue';
import type { BreadcrumbItem } from '@/types'; import type { BreadcrumbItem } from '@/types';
import { usePushNotifications } from '@/composables/usePushNotifications';
import { computed } from 'vue';
import { usePage } from '@inertiajs/vue3';
const { breadcrumbs = [] } = defineProps<{ const props = defineProps<{
breadcrumbs?: BreadcrumbItem[]; breadcrumbs?: BreadcrumbItem[];
}>(); }>();
const resolvedBreadcrumbs = computed(() => {
return props.breadcrumbs || (usePage().props.breadcrumbs as BreadcrumbItem[]) || [];
});
const { isSubscribed, subscribe, unsubscribe } = usePushNotifications();
</script> </script>
<template> <template>
<AppLayout :breadcrumbs="breadcrumbs"> <AppLayout :breadcrumbs="resolvedBreadcrumbs">
<slot /> <slot />
<div class="fixed bottom-4 right-4">
<button
@click="isSubscribed ? unsubscribe() : subscribe()"
class="rounded-full bg-blue-500 px-4 py-2 text-white"
>
{{ isSubscribed ? 'Unsubscribe' : 'Subscribe' }}
</button>
</div>
</AppLayout> </AppLayout>
</template> </template>

View File

@ -14,10 +14,9 @@ defineOptions({
}); });
defineProps<{ defineProps<{
unreadEntities: Array<{ unreadDynamics: Array<{
id: number; id: number;
name: string; name: string;
type: 'Dynamic' | 'Ledger';
url: string; url: string;
unread_count: number; unread_count: number;
context_activities: Array<{ context_activities: Array<{
@ -51,34 +50,27 @@ function formatTime(isoString: string): string {
<div class="c-dashboard__container"> <div class="c-dashboard__container">
<h2 class="c-dashboard__title">Recent Activity</h2> <h2 class="c-dashboard__title">Recent Activity</h2>
<div v-if="unreadEntities.length > 0" class="c-dashboard__grid"> <div v-if="unreadDynamics.length > 0" class="c-dashboard__grid">
<div <div
v-for="entity in unreadEntities" v-for="dynamic in unreadDynamics"
:key="`${entity.type}_${entity.id}`" :key="dynamic.id"
class="c-dashboard__card" class="c-dashboard__card"
> >
<div class="c-dashboard__card-header"> <div class="c-dashboard__card-header">
<div class="c-dashboard__entity-meta"> <div class="c-dashboard__entity-meta">
<span <span class="c-dashboard__badge-type c-dashboard__badge-type--dynamic">
:class="[ Dynamic
'c-dashboard__badge-type',
entity.type === 'Dynamic'
? 'c-dashboard__badge-type--dynamic'
: 'c-dashboard__badge-type--ledger',
]"
>
{{ entity.type }}
</span> </span>
<span class="c-dashboard__unread-count"> <span class="c-dashboard__unread-count">
{{ entity.unread_count }} New {{ dynamic.unread_count }} New
</span> </span>
</div> </div>
<Link <Link
:href="entity.url" :href="dynamic.url"
class="c-dashboard__entity-link" class="c-dashboard__entity-link"
> >
<h3 class="c-dashboard__entity-title"> <h3 class="c-dashboard__entity-title">
{{ entity.name }} {{ dynamic.name }}
</h3> </h3>
</Link> </Link>
</div> </div>
@ -86,7 +78,7 @@ function formatTime(isoString: string): string {
<div class="c-dashboard__activity-list"> <div class="c-dashboard__activity-list">
<!-- Context / Read Activities --> <!-- Context / Read Activities -->
<div <div
v-for="activity in entity.context_activities" v-for="activity in dynamic.context_activities"
:key="activity.id" :key="activity.id"
class="c-dashboard__activity-item c-dashboard__activity-item--read" class="c-dashboard__activity-item c-dashboard__activity-item--read"
> >
@ -107,17 +99,17 @@ function formatTime(isoString: string): string {
<!-- Unread Separator Line --> <!-- Unread Separator Line -->
<div <div
v-if="entity.context_activities.length > 0" v-if="dynamic.new_activities.length > 0"
class="c-dashboard__divider" class="c-dashboard__divider"
> >
<span class="c-dashboard__divider-text" <span class="c-dashboard__divider-text"
>New Activity Below</span >NEW</span
> >
</div> </div>
<!-- New / Unread Activities --> <!-- New / Unread Activities -->
<div <div
v-for="activity in entity.new_activities" v-for="activity in dynamic.new_activities"
:key="activity.id" :key="activity.id"
class="c-dashboard__activity-item c-dashboard__activity-item--unread" class="c-dashboard__activity-item c-dashboard__activity-item--unread"
> >
@ -129,7 +121,6 @@ function formatTime(isoString: string): string {
<span class="c-dashboard__activity-time"> <span class="c-dashboard__activity-time">
{{ formatTime(activity.created_at) }} {{ formatTime(activity.created_at) }}
</span> </span>
<span class="c-dashboard__new-badge">NEW</span>
</div> </div>
<p class="c-dashboard__activity-desc"> <p class="c-dashboard__activity-desc">
{{ activity.content }} {{ activity.content }}

View File

@ -2,22 +2,26 @@
import { Head, useForm } from '@inertiajs/vue3'; import { Head, useForm } from '@inertiajs/vue3';
import { route } from 'ziggy-js'; import { route } from 'ziggy-js';
defineOptions({
layout: {
breadcrumbs: [
{
title: 'Dynamics',
href: route('dynamics.index'),
},
{
title: 'Create',
href: route('dynamics.create'),
},
],
},
});
const form = useForm({ const form = useForm({
name: '', name: '',
rules: '', rules: '',
}); });
const breadcrumbs = [
{
name: 'Dynamics',
href: route('dynamics.index'),
},
{
name: 'Create',
href: route('dynamics.create'),
},
];
function submit() { function submit() {
form.post(route('dynamics.store')); form.post(route('dynamics.store'));
} }

View File

@ -1,17 +1,25 @@
<script setup> <script setup lang="ts">
import { Head, Link } from '@inertiajs/vue3'; import { Head, Link } from '@inertiajs/vue3';
import { route } from 'ziggy-js'; import { route } from 'ziggy-js';
defineProps({ defineOptions({
dynamics: Array, layout: {
}); breadcrumbs: [
const breadcrumbs = [
{ {
name: 'Dynamics', title: 'Dynamics',
href: route('dynamics.index'), href: route('dynamics.index'),
}, },
]; ],
},
});
defineProps<{
dynamics: Array<{
id: string;
name: string;
rules: string;
}>;
}>();
</script> </script>
<template> <template>
@ -106,6 +114,7 @@ const breadcrumbs = [
.c-dynamics-index__item-desc { .c-dynamics-index__item-desc {
@apply mt-2 text-sm text-gray-600 dark:text-gray-400; @apply mt-2 text-sm text-gray-600 dark:text-gray-400;
white-space: pre-line;
} }
.c-dynamics-index__empty { .c-dynamics-index__empty {

View File

@ -1,7 +1,25 @@
<script setup lang="ts"> <script setup lang="ts">
import { Head, useForm } from '@inertiajs/vue3'; import { Head, useForm } from '@inertiajs/vue3';
import { route } from 'ziggy-js'; import { route } from 'ziggy-js';
import AppLayout from '@/layouts/AppLayout.vue';
defineOptions({
layout: (props: any) => ({
breadcrumbs: [
{
title: 'Dynamics',
href: route('dynamics.index'),
},
{
title: props.dynamic.name,
href: route('dynamics.show', props.dynamic.id),
},
{
title: 'Invite User',
href: route('dynamics.invitations.create', props.dynamic.id),
},
],
}),
});
const props = defineProps<{ const props = defineProps<{
dynamic: { dynamic: {
@ -15,21 +33,6 @@ const form = useForm({
role: 'participant', 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() { function submit() {
form.post(route('dynamics.invitations.store', props.dynamic.id), { form.post(route('dynamics.invitations.store', props.dynamic.id), {
onSuccess: () => form.reset(), onSuccess: () => form.reset(),

View File

@ -0,0 +1,161 @@
<script setup lang="ts">
import { Head, Link } from '@inertiajs/vue3';
import { route } from 'ziggy-js';
defineOptions({
layout: (props: any) => ({
breadcrumbs: [
{
title: 'Dynamics',
href: route('dynamics.index'),
},
{
title: props.dynamic.name,
href: route('dynamics.show', props.dynamic.id),
},
{
title: props.participant.display_name ?? props.participant.name,
href: route('dynamics.users.show', [
props.dynamic.id,
props.participant.id,
]),
},
],
}),
});
const props = defineProps<{
dynamic: {
id: number;
name: string;
};
participant: {
id: number;
name: string;
display_name: string | null;
role: string;
};
mutations: Array<{
id: number;
amount: number;
description: string;
status: string;
created_at: string;
ledger: { id: number; name: string };
}>;
}>();
</script>
<template>
<Head :title="participant.display_name ?? participant.name" />
<div class="c-participant-show">
<div class="c-participant-show__container">
<h2 class="c-participant-show__title">
Activity for
{{ participant.display_name ?? participant.name }} ({{
participant.role.toUpperCase()
}}) in {{ dynamic.name }}
</h2>
<div class="c-participant-show__activity-list">
<div
v-for="mutation in mutations"
:key="mutation.id"
class="c-participant-show__activity-item"
>
<Link
:href="
route('dynamics.ledgers.show', [
dynamic.id,
mutation.ledger.id,
])
"
class="block"
>
<div class="c-participant-show__activity-meta">
<span class="c-participant-show__activity-time">
{{
new Date(
mutation.created_at,
).toLocaleString()
}}
</span>
<span
class="ml-2 font-semibold"
:class="
mutation.amount > 0
? 'text-green-600 dark:text-green-400'
: 'text-red-600 dark:text-red-400'
"
>
{{ mutation.amount > 0 ? '+' : ''
}}{{ mutation.amount }}
</span>
<span class="ml-2 text-neutral-400"
>on {{ mutation.ledger.name }}</span
>
<span
class="ml-auto rounded px-1.5 py-0.5 text-xs uppercase"
:class="
mutation.status === 'approved'
? 'bg-green-100 text-green-700 dark:bg-green-950/30 dark:text-green-400'
: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-950/30 dark:text-yellow-400'
"
>
{{ mutation.status }}
</span>
</div>
<p class="c-participant-show__activity-desc">
{{ mutation.description }}
</p>
</Link>
</div>
<div
v-if="mutations.length === 0"
class="text-sm text-neutral-500"
>
No mutations recorded for this participant in this Dynamic
yet.
</div>
</div>
</div>
</div>
</template>
<style scoped>
@reference "../../../../css/app.css";
.c-participant-show {
@apply py-12;
}
.c-participant-show__container {
@apply mx-auto max-w-7xl sm:px-6 lg:px-8;
}
.c-participant-show__title {
@apply mb-6 text-xl font-bold tracking-tight text-neutral-900 dark:text-neutral-100;
}
.c-participant-show__activity-list {
@apply space-y-4;
}
.c-participant-show__activity-item {
@apply rounded-lg border p-4 transition-colors hover:bg-neutral-50 dark:border-neutral-800 dark:hover:bg-neutral-900;
background-color: var(--card);
}
.c-participant-show__activity-meta {
@apply mb-1.5 flex items-center text-xs;
}
.c-participant-show__activity-time {
@apply text-neutral-400 dark:text-neutral-500;
}
.c-participant-show__activity-desc {
@apply mt-1 text-sm text-neutral-600 dark:text-neutral-400;
}
</style>

View File

@ -0,0 +1,154 @@
<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;
};
predefined_mutation: {
id: number;
name: string;
description: string;
amount: number;
type: string;
};
}>();
const form = useForm({
name: props.predefined_mutation.name,
description: props.predefined_mutation.description,
amount: props.predefined_mutation.amount,
type: props.predefined_mutation.type,
});
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),
},
{
name: 'Edit',
href: route('dynamics.predefined-mutations.edit', [props.dynamic.id, props.predefined_mutation.id]),
},
];
function submit() {
form.put(route('dynamics.predefined-mutations.update', [props.dynamic.id, props.predefined_mutation.id]));
}
</script>
<template>
<Head title="Edit Predefined Mutation" />
<AppLayout :breadcrumbs="breadcrumbs">
<div class="c-predefined-mutation-edit">
<div class="c-predefined-mutation-edit__container">
<div class="c-predefined-mutation-edit__card">
<div class="c-predefined-mutation-edit__body">
<h3 class="c-predefined-mutation-edit__title">
Edit {{ predefined_mutation.name }}
</h3>
<form @submit.prevent="submit" class="c-predefined-mutation-edit__form">
<div class="c-predefined-mutation-edit__field">
<label for="name" class="c-predefined-mutation-edit__label">Name</label>
<input v-model="form.name" id="name" type="text" class="c-predefined-mutation-edit__input" />
</div>
<div class="c-predefined-mutation-edit__field">
<label for="description" class="c-predefined-mutation-edit__label">Description</label>
<textarea v-model="form.description" id="description" rows="4" class="c-predefined-mutation-edit__textarea"></textarea>
</div>
<div class="c-predefined-mutation-edit__field">
<label for="amount" class="c-predefined-mutation-edit__label">Amount</label>
<input v-model="form.amount" id="amount" type="number" class="c-predefined-mutation-edit__input" />
</div>
<div class="c-predefined-mutation-edit__field">
<label for="type" class="c-predefined-mutation-edit__label">Type</label>
<select v-model="form.type" id="type" class="c-predefined-mutation-edit__select">
<option value="reward">Reward</option>
<option value="penalty">Penalty</option>
</select>
</div>
<div class="c-predefined-mutation-edit__actions">
<button type="submit" :disabled="form.processing" class="c-predefined-mutation-edit__submit-btn">
Save
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</AppLayout>
</template>
<style scoped>
@reference "../../../../css/app.css";
.c-predefined-mutation-edit {
@apply py-12;
}
.c-predefined-mutation-edit__container {
@apply mx-auto max-w-7xl sm:px-6 lg:px-8;
}
.c-predefined-mutation-edit__card {
@apply overflow-hidden bg-white shadow-sm sm:rounded-lg dark:bg-gray-800;
}
.c-predefined-mutation-edit__body {
@apply p-6 text-gray-900 dark:text-gray-100;
}
.c-predefined-mutation-edit__title {
@apply text-lg font-medium;
}
.c-predefined-mutation-edit__form {
@apply mt-6 space-y-6;
}
.c-predefined-mutation-edit__field {
@apply block;
}
.c-predefined-mutation-edit__label {
@apply block text-sm font-medium text-gray-700 dark:text-gray-300;
}
.c-predefined-mutation-edit__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-mutation-edit__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-mutation-edit__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-mutation-edit__actions {
@apply flex items-center gap-4;
}
.c-predefined-mutation-edit__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>

View File

@ -1,8 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { Head, useForm } from '@inertiajs/vue3'; import { Head, useForm, Link } from '@inertiajs/vue3';
import { route } from 'ziggy-js'; import { route } from 'ziggy-js';
import AppLayout from '@/layouts/AppLayout.vue'; import AppLayout from '@/layouts/AppLayout.vue';
import { defineProps } from 'vue';
const props = defineProps<{ const props = defineProps<{
dynamic: { dynamic: {
@ -76,6 +75,14 @@ function submit() {
<div class="c-predefined-mutations__item-amount"> <div class="c-predefined-mutations__item-amount">
{{ mutation.amount }} {{ mutation.amount }}
</div> </div>
<div class="c-predefined-mutations__item-actions">
<Link :href="route('dynamics.predefined-mutations.edit', [dynamic.id, mutation.id])" class="c-predefined-mutations__item-action-btn">
Edit
</Link>
<Link :href="route('dynamics.predefined-mutations.destroy', [dynamic.id, mutation.id])" method="delete" as="button" class="c-predefined-mutations__item-action-btn c-predefined-mutations__item-action-btn--danger">
Delete
</Link>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -213,6 +220,18 @@ function submit() {
@apply text-lg font-semibold; @apply text-lg font-semibold;
} }
.c-predefined-mutations__item-actions {
@apply flex gap-2;
}
.c-predefined-mutations__item-action-btn {
@apply inline-flex items-center rounded-md border border-transparent bg-gray-800 px-3 py-1.5 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-predefined-mutations__item-action-btn--danger {
@apply bg-red-600 hover:bg-red-500 focus:bg-red-500 active:bg-red-700 dark:bg-red-500 dark:hover:bg-red-400 dark:focus:bg-red-400 dark:active:bg-red-600;
}
.c-predefined-mutations__form { .c-predefined-mutations__form {
@apply mt-6 space-y-6; @apply mt-6 space-y-6;
} }

View File

@ -1,7 +1,25 @@
<script setup lang="ts"> <script setup lang="ts">
import { Head, useForm, Link as InertiaLink } from '@inertiajs/vue3'; import { Head, useForm, Link as InertiaLink } from '@inertiajs/vue3';
import { route } from 'ziggy-js'; import { route } from 'ziggy-js';
import AppLayout from '@/layouts/AppLayout.vue';
defineOptions({
layout: (props: any) => ({
breadcrumbs: [
{
title: 'Dynamics',
href: route('dynamics.index'),
},
{
title: props.dynamic.name,
href: route('dynamics.show', props.dynamic.id),
},
{
title: 'Settings',
href: route('dynamics.edit', props.dynamic.id),
},
],
}),
});
const props = defineProps<{ const props = defineProps<{
dynamic: { dynamic: {
@ -16,21 +34,6 @@ const form = useForm({
rules: props.dynamic.rules, 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() { function submit() {
form.patch(route('dynamics.update', props.dynamic.id)); form.patch(route('dynamics.update', props.dynamic.id));
} }
@ -70,12 +73,6 @@ function submit() {
</form> </form>
</div> </div>
</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>
</div> </div>
</template> </template>

View File

@ -5,13 +5,28 @@ import LedgerList from '@/components/LedgerList.vue';
import { Head, Link as InertiaLink } from '@inertiajs/vue3'; import { Head, Link as InertiaLink } from '@inertiajs/vue3';
import { route } from 'ziggy-js'; import { route } from 'ziggy-js';
defineOptions({
layout: (props: any) => ({
breadcrumbs: [
{
title: 'Dynamics',
href: route('dynamics.index'),
},
{
title: props.dynamic.name,
href: route('dynamics.show', props.dynamic.uuid),
},
],
}),
});
const props = defineProps<{ const props = defineProps<{
dynamic: { dynamic: {
id: number; id: string;
name: string; name: string;
rules: string; rules: string;
chat: any; chat: any;
participants: Array<{ id: number; name: string }>; participants: Array<{ id: number; name: string, pivot: { display_name: string | null } }>;
ledgers: Array<{ ledgers: Array<{
id: number; id: number;
name: string; name: string;
@ -20,19 +35,14 @@ const props = defineProps<{
media?: Array<{ id: number; url: string; mime_type: string }>; media?: Array<{ id: number; url: string; mime_type: string }>;
}>; }>;
}; };
isOwner: boolean; messages: {
data: Array<any>;
next_page_url: string | null;
};
can: {
update: boolean;
}
}>(); }>();
const breadcrumbs = [
{
name: 'Dynamics',
href: route('dynamics.index'),
},
{
name: props.dynamic.name,
href: route('dynamics.show', props.dynamic.id),
},
];
</script> </script>
<template> <template>
@ -49,7 +59,7 @@ const breadcrumbs = [
{{ dynamic.rules }} {{ dynamic.rules }}
</p> </p>
</div> </div>
<InertiaLink v-if="isOwner" :href="route('dynamics.edit', dynamic.id)" class="c-dynamic-show__settings-btn"> <InertiaLink v-if="can.update" :href="route('dynamics.edit', dynamic.id)" class="c-dynamic-show__settings-btn">
Settings Settings
</InertiaLink> </InertiaLink>
</div> </div>
@ -57,15 +67,15 @@ const breadcrumbs = [
</div> </div>
<!-- Dynamic Chat --> <!-- Dynamic Chat -->
<Chat :chat="dynamic.chat" /> <Chat :chat="dynamic.chat" :initial-messages="messages" :participants="dynamic.participants" :dynamic-id="dynamic.id" />
<!-- Participants Component --> <!-- Participants Component -->
<ParticipantsList :participants="dynamic.participants" /> <ParticipantsList :dynamic-id="dynamic.id" :participants="dynamic.participants" />
<!-- Ledgers List Component --> <!-- Ledgers List Component -->
<LedgerList :dynamic-id="dynamic.id" :ledgers="dynamic.ledgers" /> <LedgerList :dynamic-id="dynamic.id" :ledgers="dynamic.ledgers" />
<div v-if="isOwner" class="mt-8 flex gap-4"> <div v-if="can.update" class="mt-8 flex gap-4">
<InertiaLink :href="route('dynamics.invitations.create', dynamic.id)" class="c-dynamic-show__action-btn"> <InertiaLink :href="route('dynamics.invitations.create', dynamic.id)" class="c-dynamic-show__action-btn">
Invite User Invite User
</InertiaLink> </InertiaLink>
@ -107,6 +117,7 @@ const breadcrumbs = [
.c-dynamic-show__rules { .c-dynamic-show__rules {
@apply mt-2 text-sm; @apply mt-2 text-sm;
color: var(--muted-foreground); color: var(--muted-foreground);
white-space: pre-line;
} }
.c-dynamic-show__settings-btn { .c-dynamic-show__settings-btn {

View File

@ -1,30 +1,33 @@
<script setup lang="ts"> <script setup lang="ts">
import { Head, useForm } from '@inertiajs/vue3'; import { Head, useForm } from '@inertiajs/vue3';
import { route } from 'ziggy-js'; import { route } from 'ziggy-js';
import AppLayout from '@/layouts/AppLayout.vue';
import CreateLedgerForm from '@/components/CreateLedgerForm.vue'; import CreateLedgerForm from '@/components/CreateLedgerForm.vue';
defineOptions({
layout: (props: any) => ({
breadcrumbs: [
{
title: 'Dynamics',
href: route('dynamics.index'),
},
{
title: props.dynamic.name,
href: route('dynamics.show', props.dynamic.id),
},
{
title: 'Create Ledger',
href: route('dynamics.ledgers.create', props.dynamic.id),
},
],
}),
});
const props = defineProps<{ const props = defineProps<{
dynamic: { dynamic: {
id: number; id: number;
name: string; 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> </script>
<template> <template>

View File

@ -0,0 +1,146 @@
<script setup lang="ts">
import { Head, useForm, Link } from '@inertiajs/vue3';
import { route } from 'ziggy-js';
defineOptions({
layout: (props: any) => ({
breadcrumbs: [
{
title: 'Dynamics',
href: route('dynamics.index'),
},
{
title: props.dynamic.name,
href: route('dynamics.show', props.dynamic.id),
},
{
title: props.ledger.name,
href: route('dynamics.ledgers.show', [props.dynamic.id, props.ledger.id]),
},
{
title: 'Edit',
href: route('dynamics.ledgers.edit', [props.dynamic.id, props.ledger.id]),
},
],
}),
});
const props = defineProps<{
dynamic: {
id: number;
name: string;
};
ledger: {
id: number;
name: string;
rules: string;
};
}>();
const form = useForm({
name: props.ledger.name,
rules: props.ledger.rules,
});
function submit() {
form.put(route('dynamics.ledgers.update', [props.dynamic.id, props.ledger.id]));
}
</script>
<template>
<Head title="Edit Ledger" />
<div class="c-ledger-edit">
<div class="c-ledger-edit__container">
<div class="c-ledger-edit__card">
<div class="c-ledger-edit__body">
<h3 class="c-ledger-edit__title">
Edit {{ ledger.name }}
</h3>
<form @submit.prevent="submit" class="c-ledger-edit__form">
<div class="c-ledger-edit__field">
<label for="name" class="c-ledger-edit__label">Name</label>
<input v-model="form.name" id="name" type="text" class="c-ledger-edit__input" />
</div>
<div class="c-ledger-edit__field">
<label for="rules" class="c-ledger-edit__label">Rules</label>
<textarea v-model="form.rules" id="rules" rows="4" class="c-ledger-edit__textarea"></textarea>
</div>
<div class="c-ledger-edit__actions">
<button type="submit" :disabled="form.processing" class="c-ledger-edit__submit-btn">
Save
</button>
<Link
:href="route('dynamics.ledgers.close', [dynamic.id, ledger.id])"
method="put"
as="button"
class="c-ledger-edit__submit-btn c-ledger-edit__submit-btn--danger"
>
Close Ledger
</Link>
</div>
</form>
</div>
</div>
</div>
</div>
</template>
<style scoped>
@reference "../../../css/app.css";
.c-ledger-edit {
@apply py-12;
}
.c-ledger-edit__container {
@apply mx-auto max-w-7xl sm:px-6 lg:px-8;
}
.c-ledger-edit__card {
@apply overflow-hidden bg-white shadow-sm sm:rounded-lg dark:bg-gray-800;
}
.c-ledger-edit__body {
@apply p-6 text-gray-900 dark:text-gray-100;
}
.c-ledger-edit__title {
@apply text-lg font-medium;
}
.c-ledger-edit__form {
@apply mt-6 space-y-6;
}
.c-ledger-edit__field {
@apply block;
}
.c-ledger-edit__label {
@apply block text-sm font-medium text-gray-700 dark:text-gray-300;
}
.c-ledger-edit__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-ledger-edit__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-ledger-edit__actions {
@apply flex items-center gap-4;
}
.c-ledger-edit__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;
}
.c-ledger-edit__submit-btn--danger {
@apply bg-red-600 hover:bg-red-500 focus:bg-red-500 active:bg-red-700 dark:bg-red-500 dark:hover:bg-red-400 dark:focus:bg-red-400 dark:active:bg-red-600;
}
</style>

View File

@ -0,0 +1,150 @@
<script setup lang="ts">
import { Head, useForm } from '@inertiajs/vue3';
import { route } from 'ziggy-js';
defineOptions({
layout: (props: any) => ({
breadcrumbs: [
{
title: 'Dynamics',
href: route('dynamics.index'),
},
{
title: props.dynamic.name,
href: route('dynamics.show', props.dynamic.id),
},
{
title: props.ledger.name,
href: route('dynamics.ledgers.show', [props.dynamic.id, props.ledger.id]),
},
{
title: 'Predefined Mutations',
href: route('dynamics.ledgers.predefined-mutations.index', [props.dynamic.id, props.ledger.id]),
},
{
title: 'Edit',
href: route('dynamics.ledgers.predefined-mutations.edit', [props.dynamic.id, props.ledger.id, props.predefined_mutation.uuid]),
},
],
}),
});
const props = defineProps<{
dynamic: {
id: number;
name: string;
};
ledger: {
id: number;
name: string;
};
predefined_mutation: {
id: number;
name: string;
description: string;
amount: number;
uuid: string;
};
}>();
const form = useForm({
name: props.predefined_mutation.name,
description: props.predefined_mutation.description,
amount: props.predefined_mutation.amount,
});
function submit() {
form.put(route('dynamics.ledgers.predefined-mutations.update', [props.dynamic.id, props.ledger.id, props.predefined_mutation.uuid]));
}
</script>
<template>
<Head title="Edit Predefined Mutation" />
<div class="c-predefined-mutation-edit">
<div class="c-predefined-mutation-edit__container">
<div class="c-predefined-mutation-edit__card">
<div class="c-predefined-mutation-edit__body">
<h3 class="c-predefined-mutation-edit__title">
Edit {{ predefined_mutation.name }} on {{ ledger.name }}
</h3>
<form @submit.prevent="submit" class="c-predefined-mutation-edit__form">
<div class="c-predefined-mutation-edit__field">
<label for="name" class="c-predefined-mutation-edit__label">Name</label>
<input v-model="form.name" id="name" type="text" class="c-predefined-mutation-edit__input" />
</div>
<div class="c-predefined-mutation-edit__field">
<label for="description" class="c-predefined-mutation-edit__label">Description</label>
<textarea v-model="form.description" id="description" rows="4" class="c-predefined-mutation-edit__textarea"></textarea>
</div>
<div class="c-predefined-mutation-edit__field">
<label for="amount" class="c-predefined-mutation-edit__label">Amount</label>
<input v-model="form.amount" id="amount" type="number" class="c-predefined-mutation-edit__input" />
</div>
<div class="c-predefined-mutation-edit__actions">
<button type="submit" :disabled="form.processing" class="c-predefined-mutation-edit__submit-btn">
Save
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</template>
<style scoped>
@reference "../../../../css/app.css";
.c-predefined-mutation-edit {
@apply py-12;
}
.c-predefined-mutation-edit__container {
@apply mx-auto max-w-7xl sm:px-6 lg:px-8;
}
.c-predefined-mutation-edit__card {
@apply overflow-hidden bg-white shadow-sm sm:rounded-lg dark:bg-gray-800;
}
.c-predefined-mutation-edit__body {
@apply p-6 text-gray-900 dark:text-gray-100;
}
.c-predefined-mutation-edit__title {
@apply text-lg font-medium;
}
.c-predefined-mutation-edit__form {
@apply mt-6 space-y-6;
}
.c-predefined-mutation-edit__field {
@apply block;
}
.c-predefined-mutation-edit__label {
@apply block text-sm font-medium text-gray-700 dark:text-gray-300;
}
.c-predefined-mutation-edit__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-mutation-edit__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-mutation-edit__actions {
@apply flex items-center gap-4;
}
.c-predefined-mutation-edit__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>

View File

@ -0,0 +1,266 @@
<script setup lang="ts">
import { Head, useForm, Link as InertiaLink } from '@inertiajs/vue3';
import { route } from 'ziggy-js';
import { defineProps } from 'vue';
defineOptions({
layout: (props: any) => ({
breadcrumbs: [
{
title: 'Dynamics',
href: route('dynamics.index'),
},
{
title: props.dynamic.name,
href: route('dynamics.show', props.dynamic.id),
},
{
title: props.ledger.name,
href: route('dynamics.ledgers.show', [props.dynamic.id, props.ledger.id]),
},
{
title: 'Predefined Mutations',
href: route('dynamics.ledgers.predefined-mutations.index', [props.dynamic.id, props.ledger.id]),
},
],
}),
});
const props = defineProps<{
dynamic: {
id: number;
name: string;
};
ledger: {
id: number;
name: string;
};
predefined_mutations: Array<{
id: number;
name: string;
description: string;
amount: number;
uuid: string;
}>;
}>();
const form = useForm({
name: '',
description: '',
amount: 0,
});
function submit() {
form.post(route('dynamics.ledgers.predefined-mutations.store', [props.dynamic.id, props.ledger.id]), {
onSuccess: () => form.reset(),
});
}
function destroy(uuid: string) {
if (confirm('Are you sure you want to delete this predefined mutation?')) {
const deleteForm = useForm({});
deleteForm.delete(route('dynamics.ledgers.predefined-mutations.destroy', [props.dynamic.id, props.ledger.id, uuid]));
}
}
</script>
<template>
<Head title="Predefined Mutations" />
<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 {{ ledger.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="flex items-center gap-6">
<div class="c-predefined-mutations__item-amount">
{{ mutation.amount }}
</div>
<div class="flex items-center gap-2">
<InertiaLink
:href="route('dynamics.ledgers.predefined-mutations.edit', [dynamic.id, ledger.id, mutation.uuid])"
class="c-predefined-mutations__edit-btn"
>
Edit
</InertiaLink>
<button
@click="destroy(mutation.uuid)"
class="c-predefined-mutations__delete-btn"
>
Delete
</button>
</div>
</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__actions">
<button
type="submit"
:disabled="form.processing"
class="c-predefined-mutations__submit-btn"
>
Create
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</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__edit-btn {
@apply inline-flex cursor-pointer items-center rounded border border-gray-300 bg-white px-2.5 py-1.5 text-xs font-semibold text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600;
}
.c-predefined-mutations__delete-btn {
@apply inline-flex cursor-pointer items-center rounded border border-transparent bg-red-600 px-2.5 py-1.5 text-xs font-semibold text-white shadow-sm hover:bg-red-500 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2;
}
.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__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>

View File

@ -1,11 +1,31 @@
<script setup lang="ts"> <script setup lang="ts">
import { Head } from '@inertiajs/vue3'; import { Head, Link as InertiaLink } from '@inertiajs/vue3';
import { useEcho } from '@laravel/echo-vue'; import { useEcho } from '@laravel/echo-vue';
import { ref } from 'vue'; import { ref } from 'vue';
import { route } from 'ziggy-js'; import { route } from 'ziggy-js';
import AddMutationForm from '@/components/AddMutationForm.vue'; import AddMutationForm from '@/components/AddMutationForm.vue';
import Chat from '@/components/Chat.vue';
import MutationList from '@/components/MutationList.vue'; import MutationList from '@/components/MutationList.vue';
defineOptions({
layout: (props: any) => ({
breadcrumbs: [
{
title: 'Dynamics',
href: route('dynamics.index'),
},
{
title: props.dynamic.name,
href: route('dynamics.show', props.dynamic.id),
},
{
title: props.ledger.name,
href: route('dynamics.ledgers.show', [props.dynamic.id, props.ledger.id]),
},
],
}),
});
const props = defineProps<{ const props = defineProps<{
dynamic: { dynamic: {
id: number; id: number;
@ -23,6 +43,7 @@ const props = defineProps<{
score: number; score: number;
rules: string; rules: string;
alignment: string; alignment: string;
status: string;
media?: Array<{ id: number; url: string; mime_type: string }>; media?: Array<{ id: number; url: string; mime_type: string }>;
mutations: Array<{ mutations: Array<{
id: number; id: number;
@ -36,26 +57,15 @@ const props = defineProps<{
media?: Array<{ id: number; url: string; mime_type: string }>; media?: Array<{ id: number; url: string; mime_type: string }>;
}>; }>;
}; };
isOwner: boolean; can: {
}>(); update: boolean;
close: boolean;
const breadcrumbs = [ };
{ messages: {
name: 'Dynamics', data: Array<any>;
href: route('dynamics.index'), next_page_url: string | null;
}, };
{ }>();
name: props.dynamic.name,
href: route('dynamics.show', props.dynamic.id),
},
{
name: props.ledger.name,
href: route('dynamics.ledgers.show', {
dynamic: props.dynamic.id,
ledger: props.ledger.id,
}),
},
];
// Lightbox Modal state // Lightbox Modal state
const activeLightboxUrl = ref<string | null>(null); const activeLightboxUrl = ref<string | null>(null);
@ -168,6 +178,8 @@ function isOwnerUser(userId: number): boolean {
<div class="c-ledger-show__container"> <div class="c-ledger-show__container">
<div class="c-ledger-show__card"> <div class="c-ledger-show__card">
<div class="c-ledger-show__body"> <div class="c-ledger-show__body">
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-start gap-4">
<div>
<h3 class="c-ledger-show__title">{{ ledger.name }}</h3> <h3 class="c-ledger-show__title">{{ ledger.name }}</h3>
<p class="c-ledger-show__score"> <p class="c-ledger-show__score">
Score: {{ ledger.score }} Score: {{ ledger.score }}
@ -175,6 +187,22 @@ function isOwnerUser(userId: number): boolean {
<p class="c-ledger-show__rules"> <p class="c-ledger-show__rules">
{{ ledger.rules }} {{ ledger.rules }}
</p> </p>
</div>
<div v-if="can.update" class="flex flex-col gap-2">
<InertiaLink
:href="route('dynamics.ledgers.predefined-mutations.index', [dynamic.id, ledger.id])"
class="c-ledger-show__manage-btn"
>
Predefined Mutations
</InertiaLink>
<InertiaLink
:href="route('dynamics.ledgers.edit', [dynamic.id, ledger.id])"
class="c-ledger-show__manage-btn"
>
Edit Ledger
</InertiaLink>
</div>
</div>
<!-- Ledger Alignment Badge / Subtitle --> <!-- Ledger Alignment Badge / Subtitle -->
<div class="c-ledger-show__alignment-wrapper"> <div class="c-ledger-show__alignment-wrapper">
@ -242,10 +270,12 @@ function isOwnerUser(userId: number): boolean {
:ledger-id="ledger.id" :ledger-id="ledger.id"
:mutations="ledger.mutations" :mutations="ledger.mutations"
:participants="dynamic.participants" :participants="dynamic.participants"
:is-owner="isOwner" :can-update="can.update"
:ledger-alignment="ledger.alignment" :ledger-alignment="ledger.alignment"
@open-lightbox="openLightbox" @open-lightbox="openLightbox"
/> />
<Chat :chat="dynamic.chat" :initial-messages="messages" :participants="dynamic.participants" :dynamic-id="dynamic.id" :ledger-id="ledger.id" />
</div> </div>
</div> </div>
@ -288,6 +318,10 @@ function isOwnerUser(userId: number): boolean {
@apply py-12; @apply py-12;
} }
.c-ledger-show__manage-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-ledger-show__container { .c-ledger-show__container {
@apply mx-auto max-w-7xl sm:px-6 lg:px-8; @apply mx-auto max-w-7xl sm:px-6 lg:px-8;
} }

View File

@ -6,6 +6,7 @@ import ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileCo
import DeleteUser from '@/components/DeleteUser.vue'; import DeleteUser from '@/components/DeleteUser.vue';
import Heading from '@/components/Heading.vue'; import Heading from '@/components/Heading.vue';
import InputError from '@/components/InputError.vue'; import InputError from '@/components/InputError.vue';
import DynamicDisplayNameItem from '@/components/DynamicDisplayNameItem.vue';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
@ -23,6 +24,18 @@ defineOptions({
}, },
}); });
defineProps<{
mustVerifyEmail: boolean;
status: string | null;
dynamics: Array<{
id: number;
name: string;
pivot: {
display_name: string | null;
};
}>;
}>();
const page = usePage(); const page = usePage();
const user = computed(() => page.props.auth.user); const user = computed(() => page.props.auth.user);
</script> </script>
@ -99,6 +112,27 @@ const user = computed(() => page.props.auth.user);
> >
</div> </div>
</Form> </Form>
<!-- Dynamic-Specific Display Names Section -->
<div class="pt-8 border-t">
<Heading
variant="small"
title="Dynamic Display Names"
description="Customize your display name for each of your dynamics"
/>
<div class="mt-6 space-y-4">
<div v-if="dynamics && dynamics.length > 0" class="space-y-4">
<DynamicDisplayNameItem
v-for="dyn in dynamics"
:key="dyn.id"
:dynamic="dyn"
/>
</div>
<p v-else class="text-sm text-muted-foreground">
You are not a participant in any dynamics yet.
</p>
</div>
</div>
</div> </div>
<DeleteUser /> <DeleteUser />

View File

@ -3,6 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
{{-- Inline script to detect system dark mode preference and apply it immediately --}} {{-- Inline script to detect system dark mode preference and apply it immediately --}}
<script> <script>
@ -34,6 +35,8 @@
<link rel="icon" href="/favicon.svg" type="image/svg+xml"> <link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/apple-touch-icon.png"> <link rel="apple-touch-icon" href="/apple-touch-icon.png">
<meta name="vapid-public-key" content="{{ config('webpush.vapid.public_key') }}">
@fonts @fonts
@routes @routes

View File

@ -2,36 +2,51 @@
use App\Http\Controllers\DashboardController; use App\Http\Controllers\DashboardController;
use App\Http\Controllers\DynamicController; use App\Http\Controllers\DynamicController;
use App\Http\Controllers\DynamicInvitationController;
use App\Http\Controllers\LedgerController; use App\Http\Controllers\LedgerController;
use App\Http\Controllers\MessageController; use App\Http\Controllers\MessageController;
use App\Http\Controllers\MutationController; use App\Http\Controllers\MutationController;
use App\Http\Controllers\ParticipantController;
use App\Http\Controllers\PredefinedMutationController;
use App\Http\Controllers\WebPushController;
use Illuminate\Support\Facades\Broadcast;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
Route::inertia('/', 'Welcome')->name('home'); Route::inertia('/', 'Welcome')->name('home');
Route::post('subscriptions', [WebPushController::class, 'store'])->name('subscriptions.store');
Route::post('subscriptions/delete', [WebPushController::class, 'destroy'])->name('subscriptions.destroy');
Route::middleware(['auth', 'verified'])->group(function () { Route::middleware(['auth', 'verified'])->group(function () {
Route::get('dashboard', [DashboardController::class, 'index'])->name('dashboard'); Route::get('dashboard', [DashboardController::class, 'index'])->name('dashboard');
Route::resource('dynamics', DynamicController::class)->except(['edit', 'update']); Route::resource('dynamics', DynamicController::class)->except(['edit', 'update']);
Route::get('/dynamics/{dynamic}/settings', [DynamicController::class, 'edit'])->name('dynamics.edit'); Route::get('/dynamics/{dynamic}/settings', [DynamicController::class, 'edit'])->name('dynamics.edit');
Route::patch('/dynamics/{dynamic}/settings', [DynamicController::class, 'update'])->name('dynamics.update'); Route::patch('/dynamics/{dynamic}/settings', [DynamicController::class, 'update'])->name('dynamics.update');
Route::get('/dynamics/{dynamic}/messages', [DynamicController::class, 'messages'])->name('dynamics.messages');
Route::get('/dynamics/{dynamic}/ledgers/create', [LedgerController::class, 'create'])->name('dynamics.ledgers.create'); Route::get('/dynamics/{dynamic}/ledgers/create', [LedgerController::class, 'create'])->name('dynamics.ledgers.create');
Route::get('/dynamics/{dynamic}/ledgers/{ledger}/messages', [LedgerController::class, 'messages'])->name('dynamics.ledgers.messages');
Route::put('/dynamics/{dynamic}/ledgers/{ledger}/close', [LedgerController::class, 'close'])->name('dynamics.ledgers.close');
Route::resource('dynamics.ledgers', LedgerController::class)->scoped()->except(['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.predefined-mutations', PredefinedMutationController::class)->scoped();
Route::put('/dynamics/{dynamic}/ledgers/{ledger}/mutations/{mutation}/void', [MutationController::class, 'void'])->name('dynamics.ledgers.mutations.void');
Route::resource('dynamics.ledgers.mutations', MutationController::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::get('/dynamics/{dynamic}/invitations/create', [DynamicInvitationController::class, 'create'])->name('dynamics.invitations.create');
Route::post('/dynamics/{dynamic}/invitations', [\App\Http\Controllers\DynamicInvitationController::class, 'store'])->name('dynamics.invitations.store'); Route::post('/dynamics/{dynamic}/invitations', [DynamicInvitationController::class, 'store'])->name('dynamics.invitations.store');
Route::post('/chats/{chat}/messages', [MessageController::class, 'store'])->name('chats.messages.store'); Route::post('/chats/{chat}/messages', [MessageController::class, 'store'])->name('chats.messages.store');
Route::put('/dynamics/{dynamic}/participant', [ParticipantController::class, 'update'])->name('dynamics.participant.update');
Route::get('/dynamics/{dynamic}/users/{user}', [ParticipantController::class, 'show'])->name('dynamics.users.show');
}); });
Route::get('/invitations/accept/{token}', [\App\Http\Controllers\DynamicInvitationController::class, 'accept']) Route::get('/invitations/accept/{token}', [DynamicInvitationController::class, 'accept'])
->middleware(['auth', 'signed']) ->middleware(['auth', 'signed'])
->name('dynamics.invitations.accept'); ->name('dynamics.invitations.accept');
\Illuminate\Support\Facades\Broadcast::routes(); Broadcast::routes();
require __DIR__.'/settings.php'; require __DIR__.'/settings.php';

View File

@ -4,7 +4,6 @@ namespace Tests\Browser;
use App\Models\User; use App\Models\User;
use Laravel\Dusk\Browser; use Laravel\Dusk\Browser;
use Tests\DuskTestCase;
test('user can register, log in, and log out', function () { test('user can register, log in, and log out', function () {
$this->browse(function (Browser $browser) { $this->browse(function (Browser $browser) {

View File

@ -2,12 +2,11 @@
namespace Tests\Browser; namespace Tests\Browser;
use App\Models\User;
use App\Models\Dynamic; use App\Models\Dynamic;
use App\Models\Ledger; use App\Models\Ledger;
use App\Models\Mutation; use App\Models\Mutation;
use App\Models\User;
use Laravel\Dusk\Browser; use Laravel\Dusk\Browser;
use Tests\DuskTestCase;
test('access control and actions are enforced for owners and participants', function () { test('access control and actions are enforced for owners and participants', function () {
// Create database state // Create database state

View File

@ -2,11 +2,9 @@
namespace Tests\Browser; namespace Tests\Browser;
use App\Models\User;
use App\Models\Dynamic; use App\Models\Dynamic;
use App\Models\Ledger; use App\Models\User;
use Laravel\Dusk\Browser; use Laravel\Dusk\Browser;
use Tests\DuskTestCase;
test('multiple sessions can communicate in real time through websockets', function () { test('multiple sessions can communicate in real time through websockets', function () {
// 1. Create realistic database state // 1. Create realistic database state

View File

@ -2,10 +2,10 @@
namespace Tests; namespace Tests;
use Laravel\Dusk\TestCase as BaseTestCase;
use Facebook\WebDriver\Chrome\ChromeOptions; use Facebook\WebDriver\Chrome\ChromeOptions;
use Facebook\WebDriver\Remote\RemoteWebDriver;
use Facebook\WebDriver\Remote\DesiredCapabilities; use Facebook\WebDriver\Remote\DesiredCapabilities;
use Facebook\WebDriver\Remote\RemoteWebDriver;
use Laravel\Dusk\TestCase as BaseTestCase;
abstract class DuskTestCase extends BaseTestCase abstract class DuskTestCase extends BaseTestCase
{ {

Some files were not shown because too many files have changed in this diff Show More