chore: remove deprecated localization files
This commit is contained in:
parent
d58af18e82
commit
8a2f853fd5
26 changed files with 1685 additions and 0 deletions
19
app/Actions/Signature/CreateSignature.php
Normal file
19
app/Actions/Signature/CreateSignature.php
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Signature;
|
||||
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceSignature;
|
||||
|
||||
class CreateSignature
|
||||
{
|
||||
public static function execute(Workspace $workspace, array $data): WorkspaceSignature
|
||||
{
|
||||
return $workspace->signatures()->create([
|
||||
'name' => data_get($data, 'name'),
|
||||
'content' => data_get($data, 'content'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
15
app/Actions/Signature/DeleteSignature.php
Normal file
15
app/Actions/Signature/DeleteSignature.php
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Signature;
|
||||
|
||||
use App\Models\WorkspaceSignature;
|
||||
|
||||
class DeleteSignature
|
||||
{
|
||||
public static function execute(WorkspaceSignature $signature): void
|
||||
{
|
||||
$signature->delete();
|
||||
}
|
||||
}
|
||||
20
app/Actions/Signature/UpdateSignature.php
Normal file
20
app/Actions/Signature/UpdateSignature.php
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Signature;
|
||||
|
||||
use App\Models\WorkspaceSignature;
|
||||
|
||||
class UpdateSignature
|
||||
{
|
||||
public static function execute(WorkspaceSignature $signature, array $data): WorkspaceSignature
|
||||
{
|
||||
$signature->update([
|
||||
'name' => data_get($data, 'name'),
|
||||
'content' => data_get($data, 'content'),
|
||||
]);
|
||||
|
||||
return $signature;
|
||||
}
|
||||
}
|
||||
58
app/Http/Controllers/Api/SignatureController.php
Normal file
58
app/Http/Controllers/Api/SignatureController.php
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Actions\Signature\CreateSignature;
|
||||
use App\Actions\Signature\DeleteSignature;
|
||||
use App\Actions\Signature\UpdateSignature;
|
||||
use App\Http\Requests\Api\Signature\StoreSignatureRequest;
|
||||
use App\Http\Requests\Api\Signature\UpdateSignatureRequest;
|
||||
use App\Http\Resources\Api\SignatureResource;
|
||||
use App\Models\WorkspaceSignature;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class SignatureController extends Controller
|
||||
{
|
||||
public function index(Request $request): AnonymousResourceCollection
|
||||
{
|
||||
$signatures = $request->workspace->signatures()->latest()->get();
|
||||
|
||||
return SignatureResource::collection($signatures);
|
||||
}
|
||||
|
||||
public function store(StoreSignatureRequest $request): JsonResponse
|
||||
{
|
||||
$signature = CreateSignature::execute($request->workspace, $request->validated());
|
||||
|
||||
return (new SignatureResource($signature))
|
||||
->response()
|
||||
->setStatusCode(Response::HTTP_CREATED);
|
||||
}
|
||||
|
||||
public function update(UpdateSignatureRequest $request, WorkspaceSignature $signature): SignatureResource
|
||||
{
|
||||
if ($signature->workspace_id !== $request->workspace->id) {
|
||||
abort(Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$signature = UpdateSignature::execute($signature, $request->validated());
|
||||
|
||||
return new SignatureResource($signature);
|
||||
}
|
||||
|
||||
public function destroy(Request $request, WorkspaceSignature $signature): JsonResponse
|
||||
{
|
||||
if ($signature->workspace_id !== $request->workspace->id) {
|
||||
abort(Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
DeleteSignature::execute($signature);
|
||||
|
||||
return response()->json(null, Response::HTTP_NO_CONTENT);
|
||||
}
|
||||
}
|
||||
113
app/Http/Controllers/App/WorkspaceSignatureController.php
Normal file
113
app/Http/Controllers/App/WorkspaceSignatureController.php
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\App;
|
||||
|
||||
use App\Actions\Signature\CreateSignature;
|
||||
use App\Actions\Signature\DeleteSignature;
|
||||
use App\Actions\Signature\UpdateSignature;
|
||||
use App\Models\WorkspaceSignature;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class WorkspaceSignatureController extends Controller
|
||||
{
|
||||
public function index(Request $request): Response|RedirectResponse
|
||||
{
|
||||
$workspace = $request->user()->currentWorkspace;
|
||||
|
||||
if (! $workspace) {
|
||||
return redirect()->route('app.workspaces.create');
|
||||
}
|
||||
|
||||
$this->authorize('createPost', $workspace);
|
||||
|
||||
$signatures = $workspace->signatures()
|
||||
->when($request->input('search'), fn ($query, $search) => $query->where('name', 'ilike', "%{$search}%"))
|
||||
->latest()
|
||||
->paginate(config('app.pagination.default'));
|
||||
|
||||
return Inertia::render('signatures/Index', [
|
||||
'workspace' => $workspace,
|
||||
'signatures' => Inertia::scroll(fn () => $signatures),
|
||||
'filters' => [
|
||||
'search' => $request->input('search', ''),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$workspace = $request->user()->currentWorkspace;
|
||||
|
||||
if (! $workspace) {
|
||||
return redirect()->route('app.workspaces.create');
|
||||
}
|
||||
|
||||
$this->authorize('createPost', $workspace);
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'content' => ['required', 'string'],
|
||||
]);
|
||||
|
||||
CreateSignature::execute($workspace, $validated);
|
||||
|
||||
session()->flash('flash.banner', __('signatures.flash.created'));
|
||||
session()->flash('flash.bannerStyle', 'success');
|
||||
|
||||
return redirect()->route('app.signatures.index');
|
||||
}
|
||||
|
||||
public function update(Request $request, WorkspaceSignature $signature): RedirectResponse
|
||||
{
|
||||
$workspace = $request->user()->currentWorkspace;
|
||||
|
||||
if (! $workspace) {
|
||||
return redirect()->route('app.workspaces.create');
|
||||
}
|
||||
|
||||
$this->authorize('createPost', $workspace);
|
||||
|
||||
if ($signature->workspace_id !== $workspace->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'content' => ['required', 'string'],
|
||||
]);
|
||||
|
||||
UpdateSignature::execute($signature, $validated);
|
||||
|
||||
session()->flash('flash.banner', __('signatures.flash.updated'));
|
||||
session()->flash('flash.bannerStyle', 'success');
|
||||
|
||||
return redirect()->route('app.signatures.index');
|
||||
}
|
||||
|
||||
public function destroy(Request $request, WorkspaceSignature $signature): RedirectResponse
|
||||
{
|
||||
$workspace = $request->user()->currentWorkspace;
|
||||
|
||||
if (! $workspace) {
|
||||
return redirect()->route('app.workspaces.create');
|
||||
}
|
||||
|
||||
$this->authorize('createPost', $workspace);
|
||||
|
||||
if ($signature->workspace_id !== $workspace->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
DeleteSignature::execute($signature);
|
||||
|
||||
session()->flash('flash.banner', __('signatures.flash.deleted'));
|
||||
session()->flash('flash.bannerStyle', 'success');
|
||||
|
||||
return redirect()->route('app.signatures.index');
|
||||
}
|
||||
}
|
||||
26
app/Http/Requests/Api/Signature/StoreSignatureRequest.php
Normal file
26
app/Http/Requests/Api/Signature/StoreSignatureRequest.php
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\Api\Signature;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreSignatureRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'content' => ['required', 'string'],
|
||||
];
|
||||
}
|
||||
}
|
||||
26
app/Http/Requests/Api/Signature/UpdateSignatureRequest.php
Normal file
26
app/Http/Requests/Api/Signature/UpdateSignatureRequest.php
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\Api\Signature;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class UpdateSignatureRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'content' => ['required', 'string'],
|
||||
];
|
||||
}
|
||||
}
|
||||
25
app/Http/Resources/Api/SignatureResource.php
Normal file
25
app/Http/Resources/Api/SignatureResource.php
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\Api;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class SignatureResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'name' => $this->name,
|
||||
'content' => $this->content,
|
||||
'created_at' => $this->created_at->format('Y-m-d H:i:s'),
|
||||
'updated_at' => $this->updated_at->format('Y-m-d H:i:s'),
|
||||
];
|
||||
}
|
||||
}
|
||||
37
app/Mcp/Tools/Signature/CreateSignatureTool.php
Normal file
37
app/Mcp/Tools/Signature/CreateSignatureTool.php
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tools\Signature;
|
||||
|
||||
use App\Actions\Signature\CreateSignature;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Laravel\Mcp\Request;
|
||||
use Laravel\Mcp\Response;
|
||||
use Laravel\Mcp\ResponseFactory;
|
||||
use Laravel\Mcp\Server\Attributes\Description;
|
||||
use Laravel\Mcp\Server\Tool;
|
||||
|
||||
#[Description('Create a new signature with a name and content (hashtags, links, custom text, etc.).')]
|
||||
class CreateSignatureTool extends Tool
|
||||
{
|
||||
public function handle(Request $request): ResponseFactory
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'content' => ['required', 'string'],
|
||||
]);
|
||||
|
||||
$signature = CreateSignature::execute($request->user()->currentWorkspace, $validated);
|
||||
|
||||
return Response::structured($signature->toArray());
|
||||
}
|
||||
|
||||
public function schema(JsonSchema $schema): array
|
||||
{
|
||||
return [
|
||||
'name' => $schema->string()->required()->description('The signature name.'),
|
||||
'content' => $schema->string()->required()->description('The signature content (hashtags, links, custom text — anything you want to append to posts).'),
|
||||
];
|
||||
}
|
||||
}
|
||||
39
app/Mcp/Tools/Signature/DeleteSignatureTool.php
Normal file
39
app/Mcp/Tools/Signature/DeleteSignatureTool.php
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tools\Signature;
|
||||
|
||||
use App\Actions\Signature\DeleteSignature;
|
||||
use App\Models\WorkspaceSignature;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Laravel\Mcp\Request;
|
||||
use Laravel\Mcp\Response;
|
||||
use Laravel\Mcp\ResponseFactory;
|
||||
use Laravel\Mcp\Server\Attributes\Description;
|
||||
use Laravel\Mcp\Server\Tool;
|
||||
|
||||
#[Description('Delete a signature by ID.')]
|
||||
class DeleteSignatureTool extends Tool
|
||||
{
|
||||
public function handle(Request $request): Response|ResponseFactory
|
||||
{
|
||||
$signature = WorkspaceSignature::where('workspace_id', $request->user()->current_workspace_id)
|
||||
->find(data_get($request->validate(['signature_id' => ['required', 'string']]), 'signature_id'));
|
||||
|
||||
if (! $signature) {
|
||||
return Response::error('Signature not found.');
|
||||
}
|
||||
|
||||
DeleteSignature::execute($signature);
|
||||
|
||||
return Response::structured(['deleted' => true]);
|
||||
}
|
||||
|
||||
public function schema(JsonSchema $schema): array
|
||||
{
|
||||
return [
|
||||
'signature_id' => $schema->string()->required()->description('The signature ID to delete.'),
|
||||
];
|
||||
}
|
||||
}
|
||||
24
app/Mcp/Tools/Signature/ListSignaturesTool.php
Normal file
24
app/Mcp/Tools/Signature/ListSignaturesTool.php
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tools\Signature;
|
||||
|
||||
use Laravel\Mcp\Request;
|
||||
use Laravel\Mcp\Response;
|
||||
use Laravel\Mcp\ResponseFactory;
|
||||
use Laravel\Mcp\Server\Attributes\Description;
|
||||
use Laravel\Mcp\Server\Tool;
|
||||
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
|
||||
|
||||
#[IsReadOnly]
|
||||
#[Description('List all signatures for the current workspace.')]
|
||||
class ListSignaturesTool extends Tool
|
||||
{
|
||||
public function handle(Request $request): ResponseFactory
|
||||
{
|
||||
$signatures = $request->user()->currentWorkspace->signatures()->latest()->get();
|
||||
|
||||
return Response::structured($signatures->toArray());
|
||||
}
|
||||
}
|
||||
47
app/Mcp/Tools/Signature/UpdateSignatureTool.php
Normal file
47
app/Mcp/Tools/Signature/UpdateSignatureTool.php
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tools\Signature;
|
||||
|
||||
use App\Actions\Signature\UpdateSignature;
|
||||
use App\Models\WorkspaceSignature;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Laravel\Mcp\Request;
|
||||
use Laravel\Mcp\Response;
|
||||
use Laravel\Mcp\ResponseFactory;
|
||||
use Laravel\Mcp\Server\Attributes\Description;
|
||||
use Laravel\Mcp\Server\Tool;
|
||||
|
||||
#[Description('Update a signature name or content.')]
|
||||
class UpdateSignatureTool extends Tool
|
||||
{
|
||||
public function handle(Request $request): Response|ResponseFactory
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'signature_id' => ['required', 'string'],
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'content' => ['required', 'string'],
|
||||
]);
|
||||
|
||||
$signature = WorkspaceSignature::where('workspace_id', $request->user()->current_workspace_id)
|
||||
->find(data_get($validated, 'signature_id'));
|
||||
|
||||
if (! $signature) {
|
||||
return Response::error('Signature not found.');
|
||||
}
|
||||
|
||||
$signature = UpdateSignature::execute($signature, $validated);
|
||||
|
||||
return Response::structured($signature->toArray());
|
||||
}
|
||||
|
||||
public function schema(JsonSchema $schema): array
|
||||
{
|
||||
return [
|
||||
'signature_id' => $schema->string()->required()->description('The signature ID.'),
|
||||
'name' => $schema->string()->required()->description('The new name.'),
|
||||
'content' => $schema->string()->required()->description('The new content (hashtags, links, custom text).'),
|
||||
];
|
||||
}
|
||||
}
|
||||
29
app/Models/WorkspaceSignature.php
Normal file
29
app/Models/WorkspaceSignature.php
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Database\Factories\WorkspaceSignatureFactory;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class WorkspaceSignature extends Model
|
||||
{
|
||||
/** @use HasFactory<WorkspaceSignatureFactory> */
|
||||
use HasFactory, HasUuids, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'workspace_id',
|
||||
'name',
|
||||
'content',
|
||||
];
|
||||
|
||||
public function workspace(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Workspace::class);
|
||||
}
|
||||
}
|
||||
29
database/factories/WorkspaceSignatureFactory.php
Normal file
29
database/factories/WorkspaceSignatureFactory.php
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceSignature;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<WorkspaceSignature>
|
||||
*/
|
||||
class WorkspaceSignatureFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'workspace_id' => Workspace::factory(),
|
||||
'name' => fake()->words(2, true),
|
||||
'content' => '#'.implode(' #', fake()->words(5)),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('workspace_signatures', function (Blueprint $table) {
|
||||
$table->uuid('id')->primary();
|
||||
$table->foreignUuid('workspace_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('name');
|
||||
$table->text('content');
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('workspace_signatures');
|
||||
}
|
||||
};
|
||||
54
lang/en/signatures.php
Normal file
54
lang/en/signatures.php
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'title' => 'Signatures',
|
||||
'description' => 'Create reusable signatures to quickly append to your posts',
|
||||
'search' => 'Search signatures...',
|
||||
'new' => 'New signature',
|
||||
'empty_title' => 'No signatures yet',
|
||||
'empty_description' => 'Create signatures to quickly append hashtags, links, or any reusable text to your posts',
|
||||
'no_search_results' => 'No signatures match your search',
|
||||
'try_different_search' => 'Try a different keyword or clear the search.',
|
||||
'table' => [
|
||||
'name' => 'Name',
|
||||
'content' => 'Content',
|
||||
'created_at' => 'Created',
|
||||
],
|
||||
|
||||
'create' => [
|
||||
'title' => 'Create signature',
|
||||
'description' => 'Give your signature a name and the content to append (hashtags, links, custom text — anything you reuse).',
|
||||
'name' => 'Name',
|
||||
'name_placeholder' => 'e.g. Marketing, Travel, Brand sign-off',
|
||||
'content' => 'Content',
|
||||
'content_placeholder' => "#marketing #socialmedia\nLearn more: https://yourbrand.com",
|
||||
'content_hint' => 'Hashtags, links, custom intros, signoffs — anything you append to posts.',
|
||||
'submit' => 'Create signature',
|
||||
'submitting' => 'Creating...',
|
||||
],
|
||||
|
||||
'edit' => [
|
||||
'title' => 'Edit signature',
|
||||
'description' => 'Update the name and content for this signature.',
|
||||
'name' => 'Name',
|
||||
'name_placeholder' => 'e.g. Marketing, Travel, Brand sign-off',
|
||||
'content' => 'Content',
|
||||
'content_placeholder' => "#marketing #socialmedia\nLearn more: https://yourbrand.com",
|
||||
'content_hint' => 'Hashtags, links, custom intros, signoffs — anything you append to posts.',
|
||||
'submit' => 'Save changes',
|
||||
'submitting' => 'Saving...',
|
||||
],
|
||||
|
||||
'delete' => [
|
||||
'title' => 'Delete signature',
|
||||
'description' => 'Are you sure you want to delete this signature? This action cannot be undone.',
|
||||
'confirm' => 'Delete',
|
||||
'cancel' => 'Cancel',
|
||||
],
|
||||
|
||||
'flash' => [
|
||||
'created' => 'Signature created.',
|
||||
'updated' => 'Signature updated.',
|
||||
'deleted' => 'Signature deleted.',
|
||||
],
|
||||
];
|
||||
54
lang/es/signatures.php
Normal file
54
lang/es/signatures.php
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'title' => 'Firmas',
|
||||
'description' => 'Crea firmas reutilizables para añadir rápidamente a tus posts',
|
||||
'search' => 'Buscar firmas...',
|
||||
'new' => 'Nueva firma',
|
||||
'empty_title' => 'Aún no hay firmas',
|
||||
'empty_description' => 'Crea firmas para añadir hashtags, links o cualquier texto reutilizable a tus posts',
|
||||
'no_search_results' => 'Ninguna firma coincide con tu búsqueda',
|
||||
'try_different_search' => 'Prueba otra palabra clave o limpia la búsqueda.',
|
||||
'table' => [
|
||||
'name' => 'Nombre',
|
||||
'content' => 'Contenido',
|
||||
'created_at' => 'Creado',
|
||||
],
|
||||
|
||||
'create' => [
|
||||
'title' => 'Crear firma',
|
||||
'description' => 'Dale un nombre a tu firma y el contenido para añadir (hashtags, links, texto libre — lo que reutilizas).',
|
||||
'name' => 'Nombre',
|
||||
'name_placeholder' => 'ej: Marketing, Viaje, Cierre de marca',
|
||||
'content' => 'Contenido',
|
||||
'content_placeholder' => "#marketing #socialmedia\nMás info: https://tumarca.com",
|
||||
'content_hint' => 'Hashtags, links, intros, cierres — cualquier cosa que añades a los posts.',
|
||||
'submit' => 'Crear firma',
|
||||
'submitting' => 'Creando...',
|
||||
],
|
||||
|
||||
'edit' => [
|
||||
'title' => 'Editar firma',
|
||||
'description' => 'Actualiza el nombre y el contenido de esta firma.',
|
||||
'name' => 'Nombre',
|
||||
'name_placeholder' => 'ej: Marketing, Viaje, Cierre de marca',
|
||||
'content' => 'Contenido',
|
||||
'content_placeholder' => "#marketing #socialmedia\nMás info: https://tumarca.com",
|
||||
'content_hint' => 'Hashtags, links, intros, cierres — cualquier cosa que añades a los posts.',
|
||||
'submit' => 'Guardar cambios',
|
||||
'submitting' => 'Guardando...',
|
||||
],
|
||||
|
||||
'delete' => [
|
||||
'title' => 'Eliminar firma',
|
||||
'description' => '¿Seguro que quieres eliminar esta firma? Esta acción no se puede deshacer.',
|
||||
'confirm' => 'Eliminar',
|
||||
'cancel' => 'Cancelar',
|
||||
],
|
||||
|
||||
'flash' => [
|
||||
'created' => 'Firma creada.',
|
||||
'updated' => 'Firma actualizada.',
|
||||
'deleted' => 'Firma eliminada.',
|
||||
],
|
||||
];
|
||||
54
lang/pt-BR/signatures.php
Normal file
54
lang/pt-BR/signatures.php
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'title' => 'Assinaturas',
|
||||
'description' => 'Crie assinaturas reutilizáveis pra anexar rapidamente nos seus posts',
|
||||
'search' => 'Buscar assinaturas...',
|
||||
'new' => 'Nova assinatura',
|
||||
'empty_title' => 'Nenhuma assinatura ainda',
|
||||
'empty_description' => 'Crie assinaturas pra anexar hashtags, links ou qualquer texto reutilizável nos seus posts',
|
||||
'no_search_results' => 'Nenhuma assinatura corresponde à busca',
|
||||
'try_different_search' => 'Tente outra palavra-chave ou limpe a busca.',
|
||||
'table' => [
|
||||
'name' => 'Nome',
|
||||
'content' => 'Conteúdo',
|
||||
'created_at' => 'Criado em',
|
||||
],
|
||||
|
||||
'create' => [
|
||||
'title' => 'Criar assinatura',
|
||||
'description' => 'Dê um nome à sua assinatura e o conteúdo pra anexar (hashtags, links, texto livre — o que você reutiliza).',
|
||||
'name' => 'Nome',
|
||||
'name_placeholder' => 'ex: Marketing, Viagem, Encerramento da marca',
|
||||
'content' => 'Conteúdo',
|
||||
'content_placeholder' => "#marketing #socialmedia\nSaiba mais: https://suamarca.com",
|
||||
'content_hint' => 'Hashtags, links, intros, assinaturas — qualquer coisa que você anexa nos posts.',
|
||||
'submit' => 'Criar assinatura',
|
||||
'submitting' => 'Criando...',
|
||||
],
|
||||
|
||||
'edit' => [
|
||||
'title' => 'Editar assinatura',
|
||||
'description' => 'Atualize o nome e o conteúdo desta assinatura.',
|
||||
'name' => 'Nome',
|
||||
'name_placeholder' => 'ex: Marketing, Viagem, Encerramento da marca',
|
||||
'content' => 'Conteúdo',
|
||||
'content_placeholder' => "#marketing #socialmedia\nSaiba mais: https://suamarca.com",
|
||||
'content_hint' => 'Hashtags, links, intros, assinaturas — qualquer coisa que você anexa nos posts.',
|
||||
'submit' => 'Salvar alterações',
|
||||
'submitting' => 'Salvando...',
|
||||
],
|
||||
|
||||
'delete' => [
|
||||
'title' => 'Deletar assinatura',
|
||||
'description' => 'Tem certeza que quer deletar esta assinatura? Esta ação não pode ser desfeita.',
|
||||
'confirm' => 'Deletar',
|
||||
'cancel' => 'Cancelar',
|
||||
],
|
||||
|
||||
'flash' => [
|
||||
'created' => 'Assinatura criada.',
|
||||
'updated' => 'Assinatura atualizada.',
|
||||
'deleted' => 'Assinatura deletada.',
|
||||
],
|
||||
];
|
||||
68
resources/js/components/posts/SignaturesModal.vue
Normal file
68
resources/js/components/posts/SignaturesModal.vue
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
|
||||
import {
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command';
|
||||
|
||||
interface Signature {
|
||||
id: string;
|
||||
name: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
signatures: Signature[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'select', signature: Signature): void;
|
||||
}>();
|
||||
|
||||
const isOpen = ref(false);
|
||||
|
||||
const handleSelect = (signature: Signature) => {
|
||||
emit('select', signature);
|
||||
isOpen.value = false;
|
||||
};
|
||||
|
||||
const open = () => {
|
||||
isOpen.value = true;
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
isOpen.value = false;
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
open,
|
||||
close,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CommandDialog v-model:open="isOpen">
|
||||
<CommandInput :placeholder="$t('posts.edit.signatures_modal.search')" />
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{{ $t('posts.edit.signatures_modal.no_results') }}
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem v-for="signature in signatures" :key="signature.id" :value="signature.name"
|
||||
class="flex flex-col items-start gap-1 py-3" @select="handleSelect(signature)">
|
||||
<div class="font-medium text-sm">
|
||||
{{ signature.name }}
|
||||
</div>
|
||||
<p class="text-xs line-clamp-2 w-full">
|
||||
{{ signature.content }}
|
||||
</p>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</CommandDialog>
|
||||
</template>
|
||||
95
resources/js/components/signatures/CreateDialog.vue
Normal file
95
resources/js/components/signatures/CreateDialog.vue
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
<script setup lang="ts">
|
||||
import { useForm } from '@inertiajs/vue3';
|
||||
import { trans } from 'laravel-vue-i18n';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { store as signaturesStore } from '@/routes/app/signatures';
|
||||
|
||||
const open = defineModel<boolean>('open', { default: false });
|
||||
|
||||
const form = useForm({
|
||||
name: '',
|
||||
content: '',
|
||||
});
|
||||
|
||||
const submit = () => {
|
||||
form.post(signaturesStore.url(), {
|
||||
onSuccess: () => {
|
||||
open.value = false;
|
||||
form.reset();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleOpenChange = (value: boolean) => {
|
||||
if (value) {
|
||||
form.reset();
|
||||
form.clearErrors();
|
||||
}
|
||||
open.value = value;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog :open="open" @update:open="handleOpenChange">
|
||||
<DialogContent class="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ $t('signatures.create.title') }}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{{ $t('signatures.create.description') }}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form @submit.prevent="submit" class="space-y-4">
|
||||
<div class="grid gap-2">
|
||||
<Label for="create-name">{{ $t('signatures.create.name') }}</Label>
|
||||
<Input
|
||||
id="create-name"
|
||||
v-model="form.name"
|
||||
:placeholder="trans('signatures.create.name_placeholder')"
|
||||
:class="{ 'border-destructive': form.errors.name }"
|
||||
/>
|
||||
<p v-if="form.errors.name" class="text-sm text-destructive">
|
||||
{{ form.errors.name }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="create-content">{{ $t('signatures.create.content') }}</Label>
|
||||
<Textarea
|
||||
id="create-content"
|
||||
v-model="form.content"
|
||||
:placeholder="trans('signatures.create.content_placeholder')"
|
||||
rows="4"
|
||||
:class="{ 'border-destructive': form.errors.content }"
|
||||
/>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ $t('signatures.create.content_hint') }}
|
||||
</p>
|
||||
<p v-if="form.errors.content" class="text-sm text-destructive">
|
||||
{{ form.errors.content }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="submit" :disabled="form.processing">
|
||||
{{ form.processing ? $t('signatures.create.submitting') : $t('signatures.create.submit') }}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" @click="open = false">
|
||||
{{ $t('common.cancel') }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
106
resources/js/components/signatures/EditDialog.vue
Normal file
106
resources/js/components/signatures/EditDialog.vue
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
<script setup lang="ts">
|
||||
import { useForm } from '@inertiajs/vue3';
|
||||
import { trans } from 'laravel-vue-i18n';
|
||||
import { watch } from 'vue';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { update as signaturesUpdate } from '@/routes/app/signatures';
|
||||
|
||||
interface Signature {
|
||||
id: string;
|
||||
name: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
signature: Signature | null;
|
||||
}>();
|
||||
|
||||
const open = defineModel<boolean>('open', { default: false });
|
||||
|
||||
const form = useForm({
|
||||
name: '',
|
||||
content: '',
|
||||
});
|
||||
|
||||
watch(() => props.signature, (signature) => {
|
||||
if (signature) {
|
||||
form.name = signature.name;
|
||||
form.content = signature.content;
|
||||
form.clearErrors();
|
||||
}
|
||||
}, { immediate: true });
|
||||
|
||||
const submit = () => {
|
||||
if (!props.signature) return;
|
||||
form.put(signaturesUpdate.url(props.signature.id), {
|
||||
onSuccess: () => {
|
||||
open.value = false;
|
||||
},
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog v-model:open="open">
|
||||
<DialogContent class="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ $t('signatures.edit.title') }}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{{ $t('signatures.edit.description') }}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form @submit.prevent="submit" class="space-y-4">
|
||||
<div class="grid gap-2">
|
||||
<Label for="edit-name">{{ $t('signatures.edit.name') }}</Label>
|
||||
<Input
|
||||
id="edit-name"
|
||||
v-model="form.name"
|
||||
:placeholder="trans('signatures.edit.name_placeholder')"
|
||||
:class="{ 'border-destructive': form.errors.name }"
|
||||
/>
|
||||
<p v-if="form.errors.name" class="text-sm text-destructive">
|
||||
{{ form.errors.name }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="edit-content">{{ $t('signatures.edit.content') }}</Label>
|
||||
<Textarea
|
||||
id="edit-content"
|
||||
v-model="form.content"
|
||||
:placeholder="trans('signatures.edit.content_placeholder')"
|
||||
rows="4"
|
||||
:class="{ 'border-destructive': form.errors.content }"
|
||||
/>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ $t('signatures.edit.content_hint') }}
|
||||
</p>
|
||||
<p v-if="form.errors.content" class="text-sm text-destructive">
|
||||
{{ form.errors.content }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="submit" :disabled="form.processing">
|
||||
{{ form.processing ? $t('signatures.edit.submitting') : $t('signatures.edit.submit') }}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" @click="open = false">
|
||||
{{ $t('common.cancel') }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
181
resources/js/pages/signatures/Index.vue
Normal file
181
resources/js/pages/signatures/Index.vue
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
<script setup lang="ts">
|
||||
import { Head, InfiniteScroll, router } from '@inertiajs/vue3';
|
||||
import { IconHash, IconPencil, IconSearch, IconTrash } from '@tabler/icons-vue';
|
||||
import { trans } from 'laravel-vue-i18n';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import ConfirmDeleteModal from '@/components/ConfirmDeleteModal.vue';
|
||||
import EmptyState from '@/components/EmptyState.vue';
|
||||
import PageHeader from '@/components/PageHeader.vue';
|
||||
import CreateDialog from '@/components/signatures/CreateDialog.vue';
|
||||
import EditDialog from '@/components/signatures/EditDialog.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableLoadMore,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import dayjs from '@/dayjs';
|
||||
import debounce from '@/debounce';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
import { destroy as signaturesDestroy, index as signaturesIndex } from '@/routes/app/signatures';
|
||||
import type { BreadcrumbItem } from '@/types';
|
||||
|
||||
interface Workspace {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface Signature {
|
||||
id: string;
|
||||
name: string;
|
||||
content: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface ScrollSignatures {
|
||||
data: Signature[];
|
||||
meta: { hasNextPage: boolean };
|
||||
}
|
||||
|
||||
interface Props {
|
||||
workspace: Workspace;
|
||||
signatures: ScrollSignatures;
|
||||
filters: { search: string };
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const searchQuery = ref(props.filters.search);
|
||||
|
||||
const search = debounce(() => {
|
||||
router.get(
|
||||
signaturesIndex.url(),
|
||||
{ search: searchQuery.value || undefined },
|
||||
{ preserveState: true, preserveScroll: true, reset: ['signatures'] },
|
||||
);
|
||||
}, 300);
|
||||
|
||||
watch(searchQuery, () => search());
|
||||
|
||||
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
||||
{ title: trans('signatures.title') },
|
||||
]);
|
||||
|
||||
const deleteModal = ref<InstanceType<typeof ConfirmDeleteModal> | null>(null);
|
||||
const isCreateDialogOpen = ref(false);
|
||||
const isEditDialogOpen = ref(false);
|
||||
const editingSignature = ref<Signature | null>(null);
|
||||
|
||||
const openEditDialog = (signature: Signature) => {
|
||||
editingSignature.value = signature;
|
||||
isEditDialogOpen.value = true;
|
||||
};
|
||||
|
||||
const handleDelete = (signatureId: string) => {
|
||||
deleteModal.value?.open({ url: signaturesDestroy.url(signatureId) });
|
||||
};
|
||||
|
||||
const formatDate = (date: string): string => dayjs.utc(date).local().format('D MMM YYYY');
|
||||
|
||||
const hasActiveSearch = computed(() => Boolean(searchQuery.value?.trim()));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Head :title="$t('signatures.title')" />
|
||||
|
||||
<AppLayout :breadcrumbs="breadcrumbs">
|
||||
<div class="flex h-full flex-1 flex-col gap-4 p-4">
|
||||
<PageHeader :title="$t('signatures.title')" />
|
||||
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="relative">
|
||||
<IconSearch class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
v-model="searchQuery"
|
||||
:placeholder="trans('signatures.search')"
|
||||
class="w-64 pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button @click="isCreateDialogOpen = true">{{ $t('signatures.new') }}</Button>
|
||||
</div>
|
||||
|
||||
<EmptyState
|
||||
v-if="signatures.data.length === 0"
|
||||
:icon="IconHash"
|
||||
:title="hasActiveSearch ? $t('signatures.no_search_results') : $t('signatures.empty_title')"
|
||||
:description="hasActiveSearch ? $t('signatures.try_different_search') : $t('signatures.empty_description')"
|
||||
/>
|
||||
|
||||
<div v-else class="rounded-md border">
|
||||
<InfiniteScroll data="signatures" items-element="#signatures-body" preserve-url>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{{ $t('signatures.table.name') }}</TableHead>
|
||||
<TableHead>{{ $t('signatures.table.content') }}</TableHead>
|
||||
<TableHead>{{ $t('signatures.table.created_at') }}</TableHead>
|
||||
<TableHead class="text-right" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody id="signatures-body">
|
||||
<TableRow
|
||||
v-for="signature in signatures.data"
|
||||
:key="signature.id"
|
||||
class="cursor-pointer"
|
||||
@click="openEditDialog(signature)"
|
||||
>
|
||||
<TableCell class="font-medium">{{ signature.name }}</TableCell>
|
||||
<TableCell class="max-w-md">
|
||||
<p class="truncate text-sm text-muted-foreground">{{ signature.content }}</p>
|
||||
</TableCell>
|
||||
<TableCell class="text-muted-foreground">{{ formatDate(signature.created_at) }}</TableCell>
|
||||
<TableCell class="text-right" @click.stop>
|
||||
<div class="flex justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="size-8"
|
||||
@click="openEditDialog(signature)"
|
||||
>
|
||||
<IconPencil class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="size-8 text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
|
||||
@click="handleDelete(signature.id)"
|
||||
>
|
||||
<IconTrash class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<template #next="{ loading }">
|
||||
<TableLoadMore v-if="loading" />
|
||||
</template>
|
||||
</InfiniteScroll>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
|
||||
<CreateDialog v-model:open="isCreateDialogOpen" />
|
||||
<EditDialog v-model:open="isEditDialogOpen" :signature="editingSignature" />
|
||||
|
||||
<ConfirmDeleteModal
|
||||
ref="deleteModal"
|
||||
:title="$t('signatures.delete.title')"
|
||||
:description="$t('signatures.delete.description')"
|
||||
:action="$t('signatures.delete.confirm')"
|
||||
:cancel="$t('signatures.delete.cancel')"
|
||||
/>
|
||||
</template>
|
||||
195
tests/Feature/Api/SignatureApiTest.php
Normal file
195
tests/Feature/Api/SignatureApiTest.php
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\ApiToken;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceSignature;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* @return array{token: ApiToken, plain_token: string, workspace: Workspace}
|
||||
*/
|
||||
function createSignatureApiToken(array $overrides = []): array
|
||||
{
|
||||
$plainToken = 'tp_'.Str::random(48);
|
||||
|
||||
$workspace = data_get($overrides, 'workspace') ?? Workspace::factory()->create();
|
||||
|
||||
$factoryOverrides = collect($overrides)->except('workspace')->toArray();
|
||||
|
||||
$apiToken = ApiToken::factory()->create(array_merge([
|
||||
'workspace_id' => $workspace->id,
|
||||
'token_lookup' => substr($plainToken, 3, 16),
|
||||
'token_hash' => Hash::make($plainToken),
|
||||
], $factoryOverrides));
|
||||
|
||||
return [
|
||||
'token' => $apiToken,
|
||||
'plain_token' => $plainToken,
|
||||
'workspace' => $workspace,
|
||||
];
|
||||
}
|
||||
|
||||
test('list signatures', function () {
|
||||
$result = createSignatureApiToken();
|
||||
|
||||
WorkspaceSignature::factory()->count(3)->create([
|
||||
'workspace_id' => $result['workspace']->id,
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$result['plain_token'],
|
||||
])->getJson(
|
||||
route('api.signatures.index'),
|
||||
['HTTP_HOST' => 'api.trypost.test']
|
||||
);
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJsonCount(3);
|
||||
});
|
||||
|
||||
test('create signature', function () {
|
||||
$result = createSignatureApiToken();
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$result['plain_token'],
|
||||
])->postJson(
|
||||
route('api.signatures.store'),
|
||||
[
|
||||
'name' => 'Marketing Tags',
|
||||
'content' => '#marketing #growth #saas',
|
||||
],
|
||||
['HTTP_HOST' => 'api.trypost.test']
|
||||
);
|
||||
|
||||
$response->assertCreated();
|
||||
$response->assertJsonPath('name', 'Marketing Tags');
|
||||
|
||||
expect($result['workspace']->signatures()->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('create signature validation errors', function () {
|
||||
$result = createSignatureApiToken();
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$result['plain_token'],
|
||||
])->postJson(
|
||||
route('api.signatures.store'),
|
||||
[],
|
||||
['HTTP_HOST' => 'api.trypost.test']
|
||||
);
|
||||
|
||||
$response->assertUnprocessable();
|
||||
$response->assertJsonValidationErrors(['name', 'content']);
|
||||
});
|
||||
|
||||
test('update signature', function () {
|
||||
$result = createSignatureApiToken();
|
||||
|
||||
$signature = WorkspaceSignature::factory()->create([
|
||||
'workspace_id' => $result['workspace']->id,
|
||||
'name' => 'Old Name',
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$result['plain_token'],
|
||||
])->putJson(
|
||||
route('api.signatures.update', $signature),
|
||||
[
|
||||
'name' => 'Updated Name',
|
||||
'content' => '#updated #tags',
|
||||
],
|
||||
['HTTP_HOST' => 'api.trypost.test']
|
||||
);
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJsonPath('name', 'Updated Name');
|
||||
});
|
||||
|
||||
test('delete signature', function () {
|
||||
$result = createSignatureApiToken();
|
||||
|
||||
$signature = WorkspaceSignature::factory()->create([
|
||||
'workspace_id' => $result['workspace']->id,
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$result['plain_token'],
|
||||
])->deleteJson(
|
||||
route('api.signatures.destroy', $signature),
|
||||
[],
|
||||
['HTTP_HOST' => 'api.trypost.test']
|
||||
);
|
||||
|
||||
$response->assertNoContent();
|
||||
|
||||
expect(WorkspaceSignature::find($signature->id))->toBeNull();
|
||||
});
|
||||
|
||||
test('cannot access signatures from another workspace', function () {
|
||||
$result = createSignatureApiToken();
|
||||
|
||||
$otherWorkspace = Workspace::factory()->create();
|
||||
$signature = WorkspaceSignature::factory()->create([
|
||||
'workspace_id' => $otherWorkspace->id,
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$result['plain_token'],
|
||||
])->putJson(
|
||||
route('api.signatures.update', $signature),
|
||||
[
|
||||
'name' => 'Hacked Name',
|
||||
'content' => '#hacked',
|
||||
],
|
||||
['HTTP_HOST' => 'api.trypost.test']
|
||||
);
|
||||
|
||||
$response->assertNotFound();
|
||||
});
|
||||
|
||||
test('cannot update signature from another workspace', function () {
|
||||
$result = createSignatureApiToken();
|
||||
$otherWorkspace = Workspace::factory()->create();
|
||||
$signature = WorkspaceSignature::factory()->create(['workspace_id' => $otherWorkspace->id]);
|
||||
|
||||
$this->withHeaders(['Authorization' => 'Bearer '.data_get($result, 'plain_token')])
|
||||
->putJson(route('api.signatures.update', $signature), [
|
||||
'name' => 'Hacked',
|
||||
'content' => '#hacked',
|
||||
])
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
test('update signature validation errors', function () {
|
||||
$result = createSignatureApiToken();
|
||||
$signature = WorkspaceSignature::factory()->create(['workspace_id' => data_get($result, 'workspace')->id]);
|
||||
|
||||
$this->withHeaders(['Authorization' => 'Bearer '.data_get($result, 'plain_token')])
|
||||
->putJson(route('api.signatures.update', $signature), [])
|
||||
->assertUnprocessable();
|
||||
});
|
||||
|
||||
test('list signatures returns correct structure', function () {
|
||||
$result = createSignatureApiToken();
|
||||
WorkspaceSignature::factory()->create(['workspace_id' => data_get($result, 'workspace')->id]);
|
||||
|
||||
$this->withHeaders(['Authorization' => 'Bearer '.data_get($result, 'plain_token')])
|
||||
->getJson(route('api.signatures.index'))
|
||||
->assertOk()
|
||||
->assertJsonStructure([
|
||||
'*' => ['id', 'name', 'content', 'created_at', 'updated_at'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('cannot delete signature from another workspace', function () {
|
||||
$result = createSignatureApiToken();
|
||||
$otherWorkspace = Workspace::factory()->create();
|
||||
$signature = WorkspaceSignature::factory()->create(['workspace_id' => $otherWorkspace->id]);
|
||||
|
||||
$this->withHeaders(['Authorization' => 'Bearer '.data_get($result, 'plain_token')])
|
||||
->deleteJson(route('api.signatures.destroy', $signature))
|
||||
->assertNotFound();
|
||||
});
|
||||
110
tests/Feature/Mcp/SignatureToolTest.php
Normal file
110
tests/Feature/Mcp/SignatureToolTest.php
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Enums\UserWorkspace\Role;
|
||||
use App\Mcp\Servers\TryPostServer;
|
||||
use App\Mcp\Tools\Signature\CreateSignatureTool;
|
||||
use App\Mcp\Tools\Signature\DeleteSignatureTool;
|
||||
use App\Mcp\Tools\Signature\ListSignaturesTool;
|
||||
use App\Mcp\Tools\Signature\UpdateSignatureTool;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceSignature;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->user = User::factory()->create();
|
||||
$this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
|
||||
$this->workspace->members()->attach($this->user->id, ['role' => Role::Member->value]);
|
||||
$this->user->update(['current_workspace_id' => $this->workspace->id]);
|
||||
});
|
||||
|
||||
test('can list signatures', function () {
|
||||
WorkspaceSignature::factory()->count(2)->create(['workspace_id' => $this->workspace->id]);
|
||||
|
||||
$response = TryPostServer::actingAs($this->user)
|
||||
->tool(ListSignaturesTool::class, []);
|
||||
|
||||
$response->assertOk();
|
||||
});
|
||||
|
||||
test('can create signature', function () {
|
||||
$response = TryPostServer::actingAs($this->user)
|
||||
->tool(CreateSignatureTool::class, [
|
||||
'name' => 'Marketing',
|
||||
'content' => '#marketing #social',
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertSee('Marketing');
|
||||
expect($this->workspace->signatures()->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('create signature validates required fields', function () {
|
||||
$response = TryPostServer::actingAs($this->user)
|
||||
->tool(CreateSignatureTool::class, []);
|
||||
|
||||
$response->assertHasErrors();
|
||||
});
|
||||
|
||||
test('can update signature', function () {
|
||||
$signature = WorkspaceSignature::factory()->create(['workspace_id' => $this->workspace->id]);
|
||||
|
||||
$response = TryPostServer::actingAs($this->user)
|
||||
->tool(UpdateSignatureTool::class, [
|
||||
'signature_id' => $signature->id,
|
||||
'name' => 'Updated',
|
||||
'content' => '#updated',
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertSee('Updated');
|
||||
});
|
||||
|
||||
test('cannot update signature from another workspace', function () {
|
||||
$otherWorkspace = Workspace::factory()->create();
|
||||
$signature = WorkspaceSignature::factory()->create(['workspace_id' => $otherWorkspace->id]);
|
||||
|
||||
$response = TryPostServer::actingAs($this->user)
|
||||
->tool(UpdateSignatureTool::class, [
|
||||
'signature_id' => $signature->id,
|
||||
'name' => 'Hacked',
|
||||
'content' => '#hacked',
|
||||
]);
|
||||
|
||||
$response->assertHasErrors(['Signature not found.']);
|
||||
});
|
||||
|
||||
test('can delete signature', function () {
|
||||
$signature = WorkspaceSignature::factory()->create(['workspace_id' => $this->workspace->id]);
|
||||
|
||||
$response = TryPostServer::actingAs($this->user)
|
||||
->tool(DeleteSignatureTool::class, ['signature_id' => $signature->id]);
|
||||
|
||||
$response->assertOk();
|
||||
expect(WorkspaceSignature::find($signature->id))->toBeNull();
|
||||
});
|
||||
|
||||
test('cannot delete signature from another workspace', function () {
|
||||
$otherWorkspace = Workspace::factory()->create();
|
||||
$signature = WorkspaceSignature::factory()->create(['workspace_id' => $otherWorkspace->id]);
|
||||
|
||||
$response = TryPostServer::actingAs($this->user)
|
||||
->tool(DeleteSignatureTool::class, ['signature_id' => $signature->id]);
|
||||
|
||||
$response->assertHasErrors(['Signature not found.']);
|
||||
});
|
||||
|
||||
test('update signature validates required fields', function () {
|
||||
$response = TryPostServer::actingAs($this->user)
|
||||
->tool(UpdateSignatureTool::class, []);
|
||||
|
||||
$response->assertHasErrors();
|
||||
});
|
||||
|
||||
test('delete signature validates signature_id required', function () {
|
||||
$response = TryPostServer::actingAs($this->user)
|
||||
->tool(DeleteSignatureTool::class, []);
|
||||
|
||||
$response->assertHasErrors();
|
||||
});
|
||||
193
tests/Feature/WorkspaceSignatureControllerTest.php
Normal file
193
tests/Feature/WorkspaceSignatureControllerTest.php
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Enums\UserWorkspace\Role;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceSignature;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->user = User::factory()->create([]);
|
||||
$this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
|
||||
$this->workspace->members()->attach($this->user->id, ['role' => Role::Member->value]);
|
||||
$this->user->update(['current_workspace_id' => $this->workspace->id]);
|
||||
});
|
||||
|
||||
// Index tests
|
||||
test('signatures index requires authentication', function () {
|
||||
$response = $this->get(route('app.signatures.index'));
|
||||
|
||||
$response->assertRedirect(route('login'));
|
||||
});
|
||||
|
||||
test('signatures index shows signatures for workspace', function () {
|
||||
WorkspaceSignature::factory()->count(3)->create(['workspace_id' => $this->workspace->id]);
|
||||
|
||||
$response = $this->actingAs($this->user)->get(route('app.signatures.index'));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn ($page) => $page
|
||||
->component('signatures/Index', false)
|
||||
->has('workspace')
|
||||
->has('signatures.data', 3)
|
||||
);
|
||||
});
|
||||
|
||||
test('signatures index redirects if no workspace', function () {
|
||||
$this->user->update(['current_workspace_id' => null]);
|
||||
|
||||
$response = $this->actingAs($this->user)->get(route('app.signatures.index'));
|
||||
|
||||
$response->assertRedirect(route('app.workspaces.create'));
|
||||
});
|
||||
|
||||
// Store tests
|
||||
test('store signature requires authentication', function () {
|
||||
$response = $this->post(route('app.signatures.store'), [
|
||||
'name' => 'Marketing',
|
||||
'content' => '#marketing #digital #growth',
|
||||
]);
|
||||
|
||||
$response->assertRedirect(route('login'));
|
||||
});
|
||||
|
||||
test('store signature creates signature', function () {
|
||||
$response = $this->actingAs($this->user)->post(route('app.signatures.store'), [
|
||||
'name' => 'Marketing',
|
||||
'content' => '#marketing #digital #growth',
|
||||
]);
|
||||
|
||||
$response->assertRedirect(route('app.signatures.index'));
|
||||
|
||||
$this->assertDatabaseHas('workspace_signatures', [
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'name' => 'Marketing',
|
||||
'content' => '#marketing #digital #growth',
|
||||
]);
|
||||
});
|
||||
|
||||
test('store signature validates required fields', function () {
|
||||
$response = $this->actingAs($this->user)->post(route('app.signatures.store'), [
|
||||
'name' => '',
|
||||
'content' => '',
|
||||
]);
|
||||
|
||||
$response->assertSessionHasErrors(['name', 'content']);
|
||||
});
|
||||
|
||||
// Update tests
|
||||
test('update signature requires authentication', function () {
|
||||
$signature = WorkspaceSignature::factory()->create(['workspace_id' => $this->workspace->id]);
|
||||
|
||||
$response = $this->put(route('app.signatures.update', $signature), [
|
||||
'name' => 'Updated Name',
|
||||
'content' => '#updated #content',
|
||||
]);
|
||||
|
||||
$response->assertRedirect(route('login'));
|
||||
});
|
||||
|
||||
test('update signature updates the signature', function () {
|
||||
$signature = WorkspaceSignature::factory()->create(['workspace_id' => $this->workspace->id]);
|
||||
|
||||
$response = $this->actingAs($this->user)->put(route('app.signatures.update', $signature), [
|
||||
'name' => 'Updated Name',
|
||||
'content' => '#updated #content',
|
||||
]);
|
||||
|
||||
$response->assertRedirect(route('app.signatures.index'));
|
||||
|
||||
$signature->refresh();
|
||||
expect($signature->name)->toBe('Updated Name');
|
||||
expect($signature->content)->toBe('#updated #content');
|
||||
});
|
||||
|
||||
test('update signature returns 404 for other workspace signature', function () {
|
||||
$otherWorkspace = Workspace::factory()->create();
|
||||
$signature = WorkspaceSignature::factory()->create(['workspace_id' => $otherWorkspace->id]);
|
||||
|
||||
$response = $this->actingAs($this->user)->put(route('app.signatures.update', $signature), [
|
||||
'name' => 'Updated Name',
|
||||
'content' => '#updated #content',
|
||||
]);
|
||||
|
||||
$response->assertNotFound();
|
||||
});
|
||||
|
||||
// Destroy tests
|
||||
test('destroy signature requires authentication', function () {
|
||||
$signature = WorkspaceSignature::factory()->create(['workspace_id' => $this->workspace->id]);
|
||||
|
||||
$response = $this->delete(route('app.signatures.destroy', $signature));
|
||||
|
||||
$response->assertRedirect(route('login'));
|
||||
});
|
||||
|
||||
test('destroy signature deletes the signature', function () {
|
||||
$signature = WorkspaceSignature::factory()->create(['workspace_id' => $this->workspace->id]);
|
||||
|
||||
$response = $this->actingAs($this->user)->delete(route('app.signatures.destroy', $signature));
|
||||
|
||||
$response->assertRedirect(route('app.signatures.index'));
|
||||
expect(WorkspaceSignature::find($signature->id))->toBeNull();
|
||||
});
|
||||
|
||||
test('destroy signature returns 404 for other workspace signature', function () {
|
||||
$otherWorkspace = Workspace::factory()->create();
|
||||
$signature = WorkspaceSignature::factory()->create(['workspace_id' => $otherWorkspace->id]);
|
||||
|
||||
$response = $this->actingAs($this->user)->delete(route('app.signatures.destroy', $signature));
|
||||
|
||||
$response->assertNotFound();
|
||||
});
|
||||
|
||||
test('signatures index filters by search query', function () {
|
||||
WorkspaceSignature::factory()->create(['workspace_id' => $this->workspace->id, 'name' => 'Marketing']);
|
||||
WorkspaceSignature::factory()->create(['workspace_id' => $this->workspace->id, 'name' => 'Travel']);
|
||||
WorkspaceSignature::factory()->create(['workspace_id' => $this->workspace->id, 'name' => 'Food']);
|
||||
|
||||
$response = $this->actingAs($this->user)->get(route('app.signatures.index', ['search' => 'market']));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn ($page) => $page
|
||||
->has('signatures.data', 1)
|
||||
->has('filters')
|
||||
->where('filters.search', 'market')
|
||||
);
|
||||
});
|
||||
|
||||
test('signatures index returns all when no search query', function () {
|
||||
WorkspaceSignature::factory()->count(3)->create(['workspace_id' => $this->workspace->id]);
|
||||
|
||||
$response = $this->actingAs($this->user)->get(route('app.signatures.index'));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn ($page) => $page
|
||||
->has('signatures.data', 3)
|
||||
->where('filters.search', '')
|
||||
);
|
||||
});
|
||||
|
||||
// Member authorization tests
|
||||
test('member can create signature', function () {
|
||||
$member = User::factory()->create(['account_id' => $this->workspace->account_id]);
|
||||
$this->workspace->members()->attach($member->id, ['role' => Role::Member->value]);
|
||||
$member->update(['current_workspace_id' => $this->workspace->id]);
|
||||
|
||||
$response = $this->actingAs($member)->post(route('app.signatures.store'), [
|
||||
'name' => 'Test Signature',
|
||||
'content' => '#test #signature',
|
||||
]);
|
||||
|
||||
$response->assertRedirect();
|
||||
expect($this->workspace->signatures()->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('update signature validates required fields', function () {
|
||||
$signature = WorkspaceSignature::factory()->create(['workspace_id' => $this->workspace->id]);
|
||||
|
||||
$response = $this->actingAs($this->user)->put(route('app.signatures.update', $signature), []);
|
||||
|
||||
$response->assertSessionHasErrors(['name', 'content']);
|
||||
});
|
||||
35
tests/Unit/Models/WorkspaceSignatureTest.php
Normal file
35
tests/Unit/Models/WorkspaceSignatureTest.php
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceSignature;
|
||||
|
||||
test('workspace signature belongs to workspace', function () {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$signature = WorkspaceSignature::factory()->create(['workspace_id' => $workspace->id]);
|
||||
|
||||
expect($signature->workspace->id)->toBe($workspace->id);
|
||||
});
|
||||
|
||||
test('workspace signature has fillable attributes', function () {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$signature = WorkspaceSignature::factory()->create([
|
||||
'workspace_id' => $workspace->id,
|
||||
'name' => 'Marketing',
|
||||
'content' => '#marketing #digital #social',
|
||||
]);
|
||||
|
||||
expect($signature->name)->toBe('Marketing');
|
||||
expect($signature->content)->toBe('#marketing #digital #social');
|
||||
});
|
||||
|
||||
test('workspace signature uses soft deletes', function () {
|
||||
$signature = WorkspaceSignature::factory()->create();
|
||||
$signatureId = $signature->id;
|
||||
|
||||
$signature->delete();
|
||||
|
||||
expect(WorkspaceSignature::find($signatureId))->toBeNull();
|
||||
expect(WorkspaceSignature::withTrashed()->find($signatureId))->not->toBeNull();
|
||||
});
|
||||
Loading…
Reference in a new issue