Compare commits

..

30 Commits

Author SHA1 Message Date
f31f87aaf6 fix(users): pass mobile rule as a nested closure Filament can evaluate
Filament evaluates each entry of ->rules() as its own closure and tries
to inject dependencies, so a bare Laravel validation closure blew up with
"[$attribute] was unresolvable" when opening/saving the user form. Wrap it
so the outer closure returns the Laravel rule.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 00:38:22 +03:30
ac11fbeaa6 Merge pull request 'fix(wallets): capture $type in the deposit/withdraw action closure' (#2) from feat/persian-panel-fixes-and-model-tweaks into main
Reviewed-on: #2
2026-09-08 20:45:31 +00:00
d2873ebcc1 fix(wallets): capture $type in the deposit/withdraw action closure
The action closure referenced $type without importing it via use(), so
every credit/debit attempt in the admin panel threw "Undefined variable
$type" instead of adjusting the wallet.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 23:59:07 +03:30
84fe0e5d18 Merge pull request 'feat: Persian panel/API fixes and structural-model tweaks' (#1) from feat/persian-panel-fixes-and-model-tweaks into main
Reviewed-on: #1
2026-09-08 20:10:50 +00:00
936f789ac8 feat: Persian panel/API fixes and structural-model tweaks
- lang/fa: add validation/auth/passwords/pagination translations so admin
  and API validation errors are in Persian
- templates: soft-delete instead of cascade-deleting their generations;
  keep resolving trashed template on generations (labels/downloads)
- templates: add TemplateInfolist with a real thumbnail ImageEntry
- generations: auto-name template-based generations and use that name
  for the download file and as a searchable column
- admin: show all dates as Jalali via ->jalaliDate()/->jalaliDateTime()
  Filament macros (App\Support\Jalali)
- wallets: harden the deposit/withdraw action to surface the exact error
  instead of a 500
- profile: normalize Iranian mobile numbers (App\Support\Mobile),
  add social_links, coerce notification-preference booleans, require the
  current password to delete an account

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013P66ea1dC5CVM9t29496t5
2026-09-07 01:00:09 +03:30
46ae711a20 feat(api): add AI text-to-deck generation, wallet deposit structure, and profile/notification endpoints
Fills the gaps between the admin panel screens and the backend: a full
free-text + slide-count + language -> AI-generated presentation flow with
new slide-cloning logic in TemplateFillService, a "my presentations" list
endpoint, a swappable payment-gateway structure for wallet top-ups (fake
driver for now), and avatar upload / public-profile toggle / account
deletion / password change / notification preference endpoints.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 01:36:17 +03:30
fe2090a5af feat(generation): add retry action for failed template generations and update related schemas 2026-08-21 14:53:19 +03:30
e58dcd8581 fix(templates): fill pptx via direct zip/XML editing instead of PhpPresentation
Round-tripping real-world .pptx files through PhpPresentation's object model
was silently corrupting/losing content it doesn't fully model (themes, masters,
layouts, fonts), so placeholder replacement wasn't reliable. Now the service
edits the .pptx as a zip archive directly: only the touched slide XML text runs
and swapped image bytes change, everything else stays byte-identical to the
source file. Drops the now-unused phpoffice/phppresentation dependency.
2026-07-23 00:32:47 +03:30
0a7b7fea17 Merge branch 'main' of https://git.geekgrove.ir/soheilkhaledabadi/hoshpoint-backend 2026-07-22 08:10:39 +03:30
4a3c109bf3 feat(AI): add ai manager admin and create powerpoint with ai 2026-07-22 08:06:48 +03:30
774e1cd887 fix(dockerFile): fix bug gd php package 2026-07-07 09:26:39 +03:30
e4fe203d44 fix(dockerFile): fix bug gd php package 2026-07-07 09:16:53 +03:30
ae974c2afa feat: add ApiResponse trait for standardized API responses
chore: update composer dependencies to include phppresentation

chore: update composer.lock after adding phppresentation

feat: create categories migration for category management

feat: create templates migration for template storage

feat: create template generations migration for tracking template usage

feat: create template keys migration for template customization

feat: create notifications migration for user notifications

feat: update API routes for categories, templates, and user profile management

docs: update API documentation to include new endpoints for profile and wallet
2026-07-07 01:03:12 +03:30
cf0e6f82fd chore(nginx): enhance proxy handling by adding forwarded headers mapping in Nginx configuration 2026-06-12 18:38:28 +03:30
ca43502ffb chore(env): update production environment variables and enhance proxy handling in Nginx configuration 2026-06-07 22:20:03 +03:30
d1d42b38d1 feat(wallets): implement wallet and transaction management with associated models, policies, and resources 2026-06-07 00:18:32 +03:30
c2319a55cb fix(docker): bind HTTP port to localhost in docker-compose for enhanced security 2026-06-07 00:10:18 +03:30
12d149e5d9 chore(docker): add vendor to .dockerignore, update Composer installation in Dockerfile, and clean up README instructions 2026-06-06 23:26:21 +03:30
3029f80d16 chore(docker): remove Composer process timeout and git installation, update README for vendor folder setup 2026-06-06 23:15:18 +03:30
97fc7855ca chore(docker): increase Composer process timeout and add git installation for improved build reliability 2026-06-06 22:34:24 +03:30
f8de292f12 chore(docker): mark libzip4 and libicu72 as manually installed to prevent automatic removal 2026-06-06 22:12:03 +03:30
c57cf9bcb9 chore(docker): replace Composer installation command with a pre-packaged composer.phar for improved build efficiency 2026-06-06 22:09:08 +03:30
0607b45491 chore(docker): pin libssl3, openssl, and libssl-dev versions for compatibility 2026-06-06 21:35:53 +03:30
34ce6d810f chore(docker): add custom APT source and install libssl3 for compatibility 2026-06-06 21:34:36 +03:30
8ddce5e792 refactor(docker): simplify Dockerfile by removing custom APT source and unnecessary dependencies 2026-06-06 21:32:20 +03:30
e2a6d50cb6 refactor(docker): replace install-php-extensions script with direct docker-php-ext-install commands in Dockerfile 2026-06-06 21:31:28 +03:30
12b10db063 refactor(docker): remove unused PHP extensions from Dockerfile to streamline image size 2026-06-06 21:29:44 +03:30
e34744ad99 chore(docker): update Docker and docker-compose configurations for Arvan Cloud integration and add mirror settings 2026-06-06 21:24:20 +03:30
10a63b46bf refactor(docker): remove DOCKER_REGISTRY argument from Dockerfile and docker-compose.yml for simplification 2026-06-06 21:20:17 +03:30
6fff2d0285 refactor(docker): comment out asset build steps in Dockerfile for clarity 2026-06-06 21:16:01 +03:30
154 changed files with 7218 additions and 5824 deletions

View File

@@ -3,6 +3,7 @@ APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost
FRONTEND_URL=http://localhost:3000
APP_LOCALE=fa
APP_FALLBACK_LOCALE=en

View File

@@ -2,7 +2,7 @@ APP_NAME=Hoshpoint
APP_ENV=production
APP_KEY=
APP_DEBUG=false
APP_URL=https://example.com
APP_URL=https://hoshpoint-api.treenix.ir
HTTP_PORT=8080
APP_LOCALE=fa

View File

@@ -1,57 +1,35 @@
# syntax=docker/dockerfile:1.7
ARG DOCKER_REGISTRY=docker.arvancloud.ir
ARG PHP_VERSION=8.4
FROM ${DOCKER_REGISTRY}/node:24-alpine AS assets
WORKDIR /app
RUN sed -i 's|https://dl-cdn.alpinelinux.org|https://mirror.arvancloud.ir/alpine|g' /etc/apk/repositories
COPY package.json package-lock.json ./
RUN npm ci
COPY resources ./resources
COPY public ./public
COPY vite.config.js ./
RUN npm run build
ARG DOCKER_REGISTRY
ARG PHP_VERSION
FROM ${DOCKER_REGISTRY}/php:${PHP_VERSION}-fpm-bookworm AS app
FROM php:8.4-fpm-bookworm AS app
WORKDIR /var/www/html
ENV COMPOSER_ALLOW_SUPERUSER=1
ENV COMPOSER_PROCESS_TIMEOUT=600
COPY docker/php/install-php-extensions /usr/local/bin/install-php-extensions
RUN if [ -f /etc/apt/sources.list.d/debian.sources ]; then \
sed -i \
-e 's|https\?://deb\.debian\.org/debian|http://mirror.arvancloud.ir/debian|g' \
-e 's|https\?://security\.debian\.org/debian-security|http://mirror.arvancloud.ir/debian|g' \
-e 's| bookworm-updates||g' \
/etc/apt/sources.list.d/debian.sources; \
fi \
&& apt-get -o Acquire::Check-Valid-Until=false update \
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
curl \
default-mysql-client \
default-libmysqlclient-dev \
git \
libfreetype6-dev \
libicu-dev \
libjpeg62-turbo-dev \
libpng-dev \
libzip-dev \
unzip \
&& install-php-extensions \
bcmath \
dom \
intl \
mbstring \
opcache \
pcntl \
pdo_mysql \
xml \
xmlreader \
zip \
&& curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer \
&& docker-php-ext-configure gd --with-freetype --with-jpeg \
&& docker-php-ext-install -j"$(nproc)" bcmath gd intl pcntl pdo_mysql zip \
&& apt-mark manual libzip4 libicu72 libjpeg62-turbo libpng16-16 libfreetype6 \
&& apt-get purge -y --auto-remove \
default-libmysqlclient-dev \
libfreetype6-dev \
libicu-dev \
libjpeg62-turbo-dev \
libpng-dev \
libzip-dev \
&& rm -rf /var/lib/apt/lists/*
COPY docker/php/composer.phar /usr/local/bin/composer
RUN chmod +x /usr/local/bin/composer
COPY docker/php/php.ini /usr/local/etc/php/conf.d/99-production.ini
COPY docker/php/opcache.ini /usr/local/etc/php/conf.d/99-opcache.ini
@@ -65,7 +43,6 @@ RUN composer install \
&& composer check-platform-reqs --no-dev
COPY . .
COPY --from=assets /app/public/build ./public/build
RUN composer dump-autoload --no-dev --optimize --no-scripts \
&& php artisan package:discover --ansi \
@@ -90,12 +67,12 @@ EXPOSE 9000
ENTRYPOINT ["docker-entrypoint"]
CMD ["php-fpm"]
ARG DOCKER_REGISTRY
FROM ${DOCKER_REGISTRY}/nginx:1.27-alpine AS nginx
RUN sed -i 's|https://dl-cdn.alpinelinux.org|https://mirror.arvancloud.ir/alpine|g' /etc/apk/repositories
FROM nginx:1.27-alpine AS nginx
COPY docker/nginx/default.conf /etc/nginx/conf.d/default.conf
COPY --from=app /var/www/html/public /var/www/html/public
EXPOSE 80
# npm build (run locally before docker build):
# npm ci && npm run build

View File

@@ -93,3 +93,4 @@ docker compose --env-file .env.production down -v
```bash
docker compose --env-file .env.production restart app queue scheduler
```

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Contracts;
use App\Models\WalletDeposit;
interface PaymentGateway
{
/**
* Start a payment for the given deposit and return the URL the user should be
* redirected to in order to complete it.
*/
public function initiate(WalletDeposit $deposit): string;
/**
* Verify a gateway callback for the given deposit.
*
* @param array<string, mixed> $callbackPayload
*/
public function verify(WalletDeposit $deposit, array $callbackPayload): bool;
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Enums;
enum AiProviderDriver: string
{
case OpenAi = 'openai';
case GapGpt = 'gapgpt';
public function label(): string
{
return match ($this) {
self::OpenAi => 'OpenAI (GPT)',
self::GapGpt => 'GapGPT',
};
}
public function defaultBaseUrl(): string
{
return match ($this) {
self::OpenAi => 'https://api.openai.com/v1',
self::GapGpt => 'https://api.gapgpt.app/v1',
};
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Enums;
enum NotificationType: string
{
case System = 'system';
case Wallet = 'wallet';
case Template = 'template';
public function label(): string
{
return match ($this) {
self::System => 'سیستمی',
self::Wallet => 'کیف پول',
self::Template => 'قالب',
};
}
}

View File

@@ -0,0 +1,15 @@
<?php
namespace App\Enums;
enum PaymentGatewayDriver: string
{
case Fake = 'fake';
public function label(): string
{
return match ($this) {
self::Fake => 'درگاه آزمایشی',
};
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\Enums;
enum TemplateGenerationSource: string
{
case Template = 'template';
case AiText = 'ai_text';
public function label(): string
{
return match ($this) {
self::Template => 'قالب آماده',
self::AiText => 'ساخت با هوش مصنوعی از متن',
};
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Enums;
enum TemplateGenerationStatus: string
{
case Pending = 'pending';
case Processing = 'processing';
case Completed = 'completed';
case Failed = 'failed';
public function label(): string
{
return match ($this) {
self::Pending => 'در انتظار',
self::Processing => 'در حال تولید',
self::Completed => 'تکمیل شده',
self::Failed => 'ناموفق',
};
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Enums;
enum TemplateKeyType: string
{
case Text = 'text';
case Image = 'image';
case Chart = 'chart';
public function label(): string
{
return match ($this) {
self::Text => 'متن',
self::Image => 'عکس',
self::Chart => 'چارت',
};
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Enums;
enum WalletDepositStatus: string
{
case Pending = 'pending';
case Paid = 'paid';
case Failed = 'failed';
case Expired = 'expired';
public function label(): string
{
return match ($this) {
self::Pending => 'در انتظار پرداخت',
self::Paid => 'پرداخت‌شده',
self::Failed => 'ناموفق',
self::Expired => 'منقضی‌شده',
};
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\Enums;
enum WalletTransactionType: string
{
case Credit = 'credit';
case Debit = 'debit';
public function label(): string
{
return match ($this) {
self::Credit => 'واریز',
self::Debit => 'برداشت',
};
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace App\Filament\Resources\AiProviders;
use App\Filament\Resources\AiProviders\Pages\CreateAiProvider;
use App\Filament\Resources\AiProviders\Pages\EditAiProvider;
use App\Filament\Resources\AiProviders\Pages\ListAiProviders;
use App\Filament\Resources\AiProviders\Schemas\AiProviderForm;
use App\Filament\Resources\AiProviders\Tables\AiProvidersTable;
use App\Models\AiProvider;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
use UnitEnum;
class AiProviderResource extends Resource
{
protected static ?string $model = AiProvider::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedSparkles;
protected static string|UnitEnum|null $navigationGroup = 'هوش مصنوعی';
protected static ?string $recordTitleAttribute = 'name';
protected static ?string $modelLabel = 'سرویس هوش مصنوعی';
protected static ?string $pluralModelLabel = 'سرویس‌های هوش مصنوعی';
protected static ?string $navigationLabel = 'سرویس‌های AI';
protected static ?int $navigationSort = 30;
public static function form(Schema $schema): Schema
{
return AiProviderForm::configure($schema);
}
public static function table(Table $table): Table
{
return AiProvidersTable::configure($table);
}
public static function getPages(): array
{
return [
'index' => ListAiProviders::route('/'),
'create' => CreateAiProvider::route('/create'),
'edit' => EditAiProvider::route('/{record}/edit'),
];
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Filament\Resources\AiProviders\Pages;
use App\Filament\Resources\AiProviders\AiProviderResource;
use Filament\Resources\Pages\CreateRecord;
class CreateAiProvider extends CreateRecord
{
protected static string $resource = AiProviderResource::class;
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\AiProviders\Pages;
use App\Filament\Resources\AiProviders\AiProviderResource;
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord;
class EditAiProvider extends EditRecord
{
protected static string $resource = AiProviderResource::class;
protected function getHeaderActions(): array
{
return [
DeleteAction::make(),
];
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\AiProviders\Pages;
use App\Filament\Resources\AiProviders\AiProviderResource;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
class ListAiProviders extends ListRecords
{
protected static string $resource = AiProviderResource::class;
protected function getHeaderActions(): array
{
return [
CreateAction::make(),
];
}
}

View File

@@ -0,0 +1,58 @@
<?php
namespace App\Filament\Resources\AiProviders\Schemas;
use App\Enums\AiProviderDriver;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Schema;
class AiProviderForm
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
TextInput::make('name')
->label('نام')
->required()
->maxLength(255),
Select::make('driver')
->label('سرویس‌دهنده')
->options([
AiProviderDriver::OpenAi->value => AiProviderDriver::OpenAi->label(),
AiProviderDriver::GapGpt->value => AiProviderDriver::GapGpt->label(),
])
->default(AiProviderDriver::OpenAi->value)
->live()
->required(),
TextInput::make('model')
->label('مدل')
->helperText('مثال: gpt-4o-mini')
->required()
->maxLength(255),
TextInput::make('base_url')
->label('آدرس API (Base URL)')
->url()
->placeholder(fn (Get $get): string => AiProviderDriver::from($get('driver') ?? AiProviderDriver::OpenAi->value)->defaultBaseUrl())
->helperText('در صورت خالی بودن، آدرس پیش‌فرض همان سرویس‌دهنده استفاده می‌شود.')
->maxLength(255),
TextInput::make('api_key')
->label('API Key')
->password()
->revealable()
->autocomplete('new-password')
->required(fn (string $operation): bool => $operation === 'create')
->saved(fn (?string $state): bool => filled($state))
->maxLength(1000),
Toggle::make('is_active')
->label('فعال')
->default(true),
Toggle::make('is_default')
->label('پیش‌فرض')
->helperText('هنگام تولید محتوا در صورت وجود چند سرویس فعال، این سرویس اولویت دارد.'),
]);
}
}

View File

@@ -0,0 +1,63 @@
<?php
namespace App\Filament\Resources\AiProviders\Tables;
use App\Enums\AiProviderDriver;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\TernaryFilter;
use Filament\Tables\Table;
class AiProvidersTable
{
public static function configure(Table $table): Table
{
return $table
->columns([
TextColumn::make('name')
->label('نام')
->searchable()
->sortable(),
TextColumn::make('driver')
->label('سرویس‌دهنده')
->badge()
->formatStateUsing(fn (AiProviderDriver $state): string => $state->label()),
TextColumn::make('model')
->label('مدل')
->searchable(),
TextColumn::make('base_url')
->label('آدرس API')
->formatStateUsing(fn (?string $state, $record): string => $state ?: $record->driver->defaultBaseUrl())
->toggleable(),
IconColumn::make('is_active')
->label('فعال')
->boolean(),
IconColumn::make('is_default')
->label('پیش‌فرض')
->boolean(),
TextColumn::make('created_at')
->label('تاریخ ایجاد')
->jalaliDateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
TernaryFilter::make('is_active')
->label('وضعیت')
->placeholder('همه')
->trueLabel('فعال')
->falseLabel('غیرفعال'),
])
->recordActions([
EditAction::make(),
])
->toolbarActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
]),
]);
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace App\Filament\Resources\Categories;
use App\Filament\Resources\Categories\Pages\CreateCategory;
use App\Filament\Resources\Categories\Pages\EditCategory;
use App\Filament\Resources\Categories\Pages\ListCategories;
use App\Filament\Resources\Categories\Schemas\CategoryForm;
use App\Filament\Resources\Categories\Tables\CategoriesTable;
use App\Models\Category;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
use UnitEnum;
class CategoryResource extends Resource
{
protected static ?string $model = Category::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedRectangleStack;
protected static string|UnitEnum|null $navigationGroup = 'پاورپوینت‌ها';
protected static ?string $recordTitleAttribute = 'name';
protected static ?string $modelLabel = 'دسته‌بندی';
protected static ?string $pluralModelLabel = 'دسته‌بندی‌ها';
protected static ?string $navigationLabel = 'دسته‌بندی‌ها';
protected static ?int $navigationSort = 20;
public static function form(Schema $schema): Schema
{
return CategoryForm::configure($schema);
}
public static function table(Table $table): Table
{
return CategoriesTable::configure($table);
}
public static function getPages(): array
{
return [
'index' => ListCategories::route('/'),
'create' => CreateCategory::route('/create'),
'edit' => EditCategory::route('/{record}/edit'),
];
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Filament\Resources\Categories\Pages;
use App\Filament\Resources\Categories\CategoryResource;
use Filament\Resources\Pages\CreateRecord;
class CreateCategory extends CreateRecord
{
protected static string $resource = CategoryResource::class;
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\Categories\Pages;
use App\Filament\Resources\Categories\CategoryResource;
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord;
class EditCategory extends EditRecord
{
protected static string $resource = CategoryResource::class;
protected function getHeaderActions(): array
{
return [
DeleteAction::make(),
];
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\Categories\Pages;
use App\Filament\Resources\Categories\CategoryResource;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
class ListCategories extends ListRecords
{
protected static string $resource = CategoryResource::class;
protected function getHeaderActions(): array
{
return [
CreateAction::make(),
];
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Filament\Resources\Categories\Schemas;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Schemas\Schema;
class CategoryForm
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
TextInput::make('name')
->label('نام')
->required()
->maxLength(255),
TextInput::make('slug')
->label('شناسه')
->required()
->unique(ignoreRecord: true)
->maxLength(255),
Textarea::make('description')
->label('توضیحات')
->maxLength(1000)
->rows(3),
Toggle::make('is_active')
->label('فعال')
->default(true),
]);
}
}

View File

@@ -0,0 +1,55 @@
<?php
namespace App\Filament\Resources\Categories\Tables;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\TernaryFilter;
use Filament\Tables\Table;
class CategoriesTable
{
public static function configure(Table $table): Table
{
return $table
->columns([
TextColumn::make('name')
->label('نام')
->searchable()
->sortable(),
TextColumn::make('slug')
->label('شناسه')
->searchable()
->sortable(),
TextColumn::make('templates_count')
->label('تعداد قالب‌ها')
->counts('templates'),
IconColumn::make('is_active')
->label('فعال')
->boolean(),
TextColumn::make('created_at')
->label('تاریخ ایجاد')
->jalaliDateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
TernaryFilter::make('is_active')
->label('وضعیت')
->placeholder('همه')
->trueLabel('فعال')
->falseLabel('غیرفعال'),
])
->recordActions([
EditAction::make(),
])
->toolbarActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
]),
]);
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace App\Filament\Resources\Notifications;
use App\Filament\Resources\Notifications\Pages\CreateNotification;
use App\Filament\Resources\Notifications\Pages\EditNotification;
use App\Filament\Resources\Notifications\Pages\ListNotifications;
use App\Filament\Resources\Notifications\Pages\ViewNotification;
use App\Filament\Resources\Notifications\Schemas\NotificationForm;
use App\Filament\Resources\Notifications\Tables\NotificationsTable;
use App\Models\Notification;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
use UnitEnum;
class NotificationResource extends Resource
{
protected static ?string $model = Notification::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedBell;
protected static string|UnitEnum|null $navigationGroup = 'اعلان‌ها';
protected static ?string $recordTitleAttribute = 'title';
protected static ?string $modelLabel = 'نوتیفیکیشن';
protected static ?string $pluralModelLabel = 'نوتیفیکیشن‌ها';
protected static ?string $navigationLabel = 'نوتیفیکیشن‌ها';
protected static ?int $navigationSort = 30;
public static function form(Schema $schema): Schema
{
return NotificationForm::configure($schema);
}
public static function table(Table $table): Table
{
return NotificationsTable::configure($table);
}
public static function getPages(): array
{
return [
'index' => ListNotifications::route('/'),
'create' => CreateNotification::route('/create'),
'view' => ViewNotification::route('/{record}'),
'edit' => EditNotification::route('/{record}/edit'),
];
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Filament\Resources\Notifications\Pages;
use App\Filament\Resources\Notifications\NotificationResource;
use Filament\Resources\Pages\CreateRecord;
class CreateNotification extends CreateRecord
{
protected static string $resource = NotificationResource::class;
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Filament\Resources\Notifications\Pages;
use App\Filament\Resources\Notifications\NotificationResource;
use Filament\Actions\DeleteAction;
use Filament\Actions\ViewAction;
use Filament\Resources\Pages\EditRecord;
class EditNotification extends EditRecord
{
protected static string $resource = NotificationResource::class;
protected function getHeaderActions(): array
{
return [
ViewAction::make(),
DeleteAction::make(),
];
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\Notifications\Pages;
use App\Filament\Resources\Notifications\NotificationResource;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
class ListNotifications extends ListRecords
{
protected static string $resource = NotificationResource::class;
protected function getHeaderActions(): array
{
return [
CreateAction::make(),
];
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\Notifications\Pages;
use App\Filament\Resources\Notifications\NotificationResource;
use Filament\Actions\EditAction;
use Filament\Resources\Pages\ViewRecord;
class ViewNotification extends ViewRecord
{
protected static string $resource = NotificationResource::class;
protected function getHeaderActions(): array
{
return [
EditAction::make(),
];
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Filament\Resources\Notifications\Schemas;
use App\Enums\NotificationType;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Schemas\Schema;
class NotificationForm
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
Select::make('user_id')
->label('کاربر')
->relationship('user', 'name')
->searchable()
->required(),
Select::make('type')
->label('نوع')
->options(collect(NotificationType::cases())->mapWithKeys(fn (NotificationType $type) => [$type->value => $type->label()]))
->required(),
TextInput::make('title')
->label('عنوان')
->required()
->maxLength(255),
Textarea::make('body')
->label('متن')
->required()
->rows(4),
]);
}
}

View File

@@ -0,0 +1,69 @@
<?php
namespace App\Filament\Resources\Notifications\Tables;
use App\Enums\NotificationType;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Actions\ViewAction;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Filters\TernaryFilter;
use Filament\Tables\Table;
class NotificationsTable
{
public static function configure(Table $table): Table
{
return $table
->columns([
TextColumn::make('user.name')
->label('کاربر')
->searchable()
->sortable(),
TextColumn::make('type')
->label('نوع')
->badge()
->formatStateUsing(fn (NotificationType $state): string => $state->label()),
TextColumn::make('title')
->label('عنوان')
->searchable()
->limit(40),
IconColumn::make('read_at')
->label('خوانده شده')
->boolean()
->getStateUsing(fn ($record): bool => $record->read_at !== null),
TextColumn::make('created_at')
->label('تاریخ ایجاد')
->jalaliDateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->defaultSort('created_at', 'desc')
->filters([
SelectFilter::make('type')
->label('نوع')
->options(collect(NotificationType::cases())->mapWithKeys(fn (NotificationType $type) => [$type->value => $type->label()])),
TernaryFilter::make('read_at')
->label('وضعیت')
->placeholder('همه')
->trueLabel('خوانده شده')
->falseLabel('خوانده نشده')
->queries(
true: fn ($query) => $query->whereNotNull('read_at'),
false: fn ($query) => $query->whereNull('read_at'),
),
])
->recordActions([
ViewAction::make(),
EditAction::make(),
])
->toolbarActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
]),
]);
}
}

View File

@@ -28,7 +28,7 @@ class PermissionsTable
->searchable(),
TextColumn::make('created_at')
->label('تاریخ ایجاد')
->dateTime()
->jalaliDateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])

View File

@@ -28,7 +28,7 @@ class RolesTable
->searchable(),
TextColumn::make('created_at')
->label('تاریخ ایجاد')
->dateTime()
->jalaliDateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Filament\Resources\TemplateGenerations\Actions;
use App\Enums\TemplateGenerationStatus;
use App\Models\TemplateGeneration;
use Filament\Actions\Action;
use Filament\Support\Icons\Heroicon;
class DownloadGenerationAction
{
public static function make(): Action
{
return Action::make('download')
->label('دانلود')
->icon(Heroicon::OutlinedArrowDownTray)
->color('success')
->visible(fn (TemplateGeneration $record): bool => $record->status === TemplateGenerationStatus::Completed && filled($record->output_path))
->url(fn (TemplateGeneration $record): string => route('admin.generations.download', $record->uuid))
->openUrlInNewTab();
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Filament\Resources\TemplateGenerations\Actions;
use App\Enums\TemplateGenerationStatus;
use App\Jobs\GenerateTemplateJob;
use App\Models\TemplateGeneration;
use Filament\Actions\Action;
use Filament\Notifications\Notification;
use Filament\Support\Icons\Heroicon;
/**
* Re-runs the pptx fill step for a failed generation using its already-stored
* values (including any AI-generated text), without calling the AI service again.
*/
class RetryGenerationAction
{
public static function make(): Action
{
return Action::make('retry')
->label('تلاش دوباره')
->icon(Heroicon::OutlinedArrowPath)
->color('warning')
->requiresConfirmation()
->modalDescription('فایل پاورپوینت با همان محتوایی که قبلاً (از جمله متن‌های تولیدشده با هوش مصنوعی) ذخیره شده، دوباره ساخته می‌شود. نیازی به تماس مجدد با هوش مصنوعی نیست.')
->visible(fn (TemplateGeneration $record): bool => $record->status === TemplateGenerationStatus::Failed)
->action(function (TemplateGeneration $record): void {
$record->update([
'status' => TemplateGenerationStatus::Pending,
'error_message' => null,
]);
GenerateTemplateJob::dispatch($record);
Notification::make()
->title('درخواست دوباره ارسال شد')
->success()
->send();
});
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Filament\Resources\TemplateGenerations\Pages;
use App\Filament\Resources\TemplateGenerations\TemplateGenerationResource;
use Filament\Resources\Pages\ListRecords;
class ListTemplateGenerations extends ListRecords
{
protected static string $resource = TemplateGenerationResource::class;
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Filament\Resources\TemplateGenerations\Pages;
use App\Filament\Resources\TemplateGenerations\TemplateGenerationResource;
use Filament\Resources\Pages\ViewRecord;
class ViewTemplateGeneration extends ViewRecord
{
protected static string $resource = TemplateGenerationResource::class;
}

View File

@@ -0,0 +1,52 @@
<?php
namespace App\Filament\Resources\TemplateGenerations\Schemas;
use App\Enums\TemplateGenerationStatus;
use Filament\Infolists\Components\KeyValueEntry;
use Filament\Infolists\Components\TextEntry;
use Filament\Schemas\Schema;
class TemplateGenerationInfolist
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
TextEntry::make('title')
->label('نام فایل')
->placeholder('—')
->columnSpanFull(),
TextEntry::make('uuid')
->label('شناسه')
->copyable(),
TextEntry::make('template.title')
->label('قالب'),
TextEntry::make('status')
->label('وضعیت')
->badge()
->formatStateUsing(fn (TemplateGenerationStatus $state): string => $state->label())
->color(fn (TemplateGenerationStatus $state): string => match ($state) {
TemplateGenerationStatus::Pending => 'gray',
TemplateGenerationStatus::Processing => 'warning',
TemplateGenerationStatus::Completed => 'success',
TemplateGenerationStatus::Failed => 'danger',
}),
TextEntry::make('requester.name')
->label('درخواست‌دهنده')
->placeholder('—'),
TextEntry::make('error_message')
->label('پیام خطا')
->placeholder('—')
->columnSpanFull(),
KeyValueEntry::make('values')
->label('محتوای ذخیره‌شده (از جمله متن‌های تولیدشده با هوش مصنوعی)')
->keyLabel('کلید')
->valueLabel('مقدار')
->columnSpanFull(),
TextEntry::make('created_at')
->label('تاریخ ایجاد')
->jalaliDateTime(),
]);
}
}

View File

@@ -0,0 +1,74 @@
<?php
namespace App\Filament\Resources\TemplateGenerations\Tables;
use App\Enums\TemplateGenerationStatus;
use App\Filament\Resources\TemplateGenerations\Actions\DownloadGenerationAction;
use App\Filament\Resources\TemplateGenerations\Actions\RetryGenerationAction;
use Filament\Actions\ViewAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
class TemplateGenerationsTable
{
public static function configure(Table $table): Table
{
return $table
->columns([
TextColumn::make('title')
->label('نام فایل')
->searchable()
->sortable()
->description(fn ($record): string => $record->uuid)
->wrap(),
TextColumn::make('uuid')
->label('شناسه')
->searchable()
->copyable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('template.title')
->label('قالب')
->searchable()
->sortable(),
TextColumn::make('status')
->label('وضعیت')
->badge()
->formatStateUsing(fn (TemplateGenerationStatus $state): string => $state->label())
->color(fn (TemplateGenerationStatus $state): string => match ($state) {
TemplateGenerationStatus::Pending => 'gray',
TemplateGenerationStatus::Processing => 'warning',
TemplateGenerationStatus::Completed => 'success',
TemplateGenerationStatus::Failed => 'danger',
}),
TextColumn::make('requester.name')
->label('درخواست‌دهنده')
->searchable()
->placeholder('—'),
TextColumn::make('error_message')
->label('خطا')
->limit(50)
->placeholder('—'),
TextColumn::make('created_at')
->label('تاریخ')
->jalaliDateTime()
->sortable(),
])
->defaultSort('created_at', 'desc')
->filters([
SelectFilter::make('status')
->label('وضعیت')
->options([
TemplateGenerationStatus::Pending->value => TemplateGenerationStatus::Pending->label(),
TemplateGenerationStatus::Processing->value => TemplateGenerationStatus::Processing->label(),
TemplateGenerationStatus::Completed->value => TemplateGenerationStatus::Completed->label(),
TemplateGenerationStatus::Failed->value => TemplateGenerationStatus::Failed->label(),
]),
])
->recordActions([
ViewAction::make(),
DownloadGenerationAction::make(),
RetryGenerationAction::make(),
]);
}
}

View File

@@ -0,0 +1,67 @@
<?php
namespace App\Filament\Resources\TemplateGenerations;
use App\Filament\Resources\TemplateGenerations\Pages\ListTemplateGenerations;
use App\Filament\Resources\TemplateGenerations\Pages\ViewTemplateGeneration;
use App\Filament\Resources\TemplateGenerations\Schemas\TemplateGenerationInfolist;
use App\Filament\Resources\TemplateGenerations\Tables\TemplateGenerationsTable;
use App\Models\TemplateGeneration;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
use UnitEnum;
class TemplateGenerationResource extends Resource
{
protected static ?string $model = TemplateGeneration::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedDocumentArrowDown;
protected static string|UnitEnum|null $navigationGroup = 'پاورپوینت‌ها';
protected static ?string $recordTitleAttribute = 'uuid';
protected static ?string $modelLabel = 'تولید';
protected static ?string $pluralModelLabel = 'تولیدها';
protected static ?string $navigationLabel = 'تولیدها';
protected static ?int $navigationSort = 22;
public static function table(Table $table): Table
{
return TemplateGenerationsTable::configure($table);
}
public static function infolist(Schema $schema): Schema
{
return TemplateGenerationInfolist::configure($schema);
}
public static function getPages(): array
{
return [
'index' => ListTemplateGenerations::route('/'),
'view' => ViewTemplateGeneration::route('/{record}'),
];
}
public static function canCreate(): bool
{
return false;
}
public static function canEdit($record): bool
{
return false;
}
public static function canDelete($record): bool
{
return false;
}
}

View File

@@ -0,0 +1,99 @@
<?php
namespace App\Filament\Resources\Templates\Actions;
use App\Enums\TemplateGenerationStatus;
use App\Enums\TemplateKeyType;
use App\Jobs\GenerateTemplateJob;
use App\Models\AiProvider;
use App\Models\Template;
use App\Models\TemplateGeneration;
use App\Services\Ai\OpenAiCompatibleTextGenerator;
use Filament\Actions\Action;
use Filament\Forms\Components\FileUpload;
use Filament\Forms\Components\Textarea;
use Filament\Notifications\Notification;
use Filament\Support\Icons\Heroicon;
use Illuminate\Support\Str;
use Throwable;
class GenerateWithAiAction
{
public static function make(): Action
{
return Action::make('generateWithAi')
->label('تولید با هوش مصنوعی')
->icon(Heroicon::OutlinedSparkles)
->color('info')
->schema(function (Template $record): array {
$components = [
Textarea::make('topic')
->label('موضوع / محتوای خام')
->helperText('هوش مصنوعی بر اساس این متن، کلیدهای متنی قالب را تکمیل می‌کند.')
->required()
->rows(4),
];
foreach ($record->keys->where('type', TemplateKeyType::Image) as $key) {
$components[] = FileUpload::make("images.{$key->key}")
->label($key->label)
->image()
->disk('local')
->directory('templates/generations/inputs')
->required($key->is_required);
}
return $components;
})
->action(function (array $data, Template $record): void {
$provider = AiProvider::query()
->where('is_active', true)
->orderByDesc('is_default')
->first();
if (! $provider) {
Notification::make()
->title('هیچ سرویس هوش مصنوعی فعالی تنظیم نشده است.')
->body('ابتدا از بخش «سرویس‌های AI» یک سرویس فعال تعریف کنید.')
->danger()
->send();
return;
}
try {
$values = app(OpenAiCompatibleTextGenerator::class)->generateForTemplate($record, $data['topic'], $provider);
} catch (Throwable $exception) {
Notification::make()
->title('خطا در تولید محتوا با هوش مصنوعی')
->body($exception->getMessage())
->danger()
->send();
return;
}
foreach ($data['images'] ?? [] as $key => $path) {
if ($path) {
$values[$key] = $path;
}
}
$generation = TemplateGeneration::query()->create([
'template_id' => $record->id,
'uuid' => (string) Str::uuid(),
'status' => TemplateGenerationStatus::Pending,
'values' => $values,
'requested_by' => auth()->id(),
]);
GenerateTemplateJob::dispatch($generation);
Notification::make()
->title('درخواست تولید ثبت شد')
->body('نتیجه را می‌توانید در تب «تولیدها» مشاهده کنید.')
->success()
->send();
});
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Filament\Resources\Templates\Pages;
use App\Filament\Resources\Templates\TemplateResource;
use Filament\Resources\Pages\CreateRecord;
class CreateTemplate extends CreateRecord
{
protected static string $resource = TemplateResource::class;
/**
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
protected function mutateFormDataBeforeCreate(array $data): array
{
$data['created_by'] = auth()->id();
return $data;
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Filament\Resources\Templates\Pages;
use App\Filament\Resources\Templates\TemplateResource;
use Filament\Actions\DeleteAction;
use Filament\Actions\ForceDeleteAction;
use Filament\Actions\RestoreAction;
use Filament\Actions\ViewAction;
use Filament\Resources\Pages\EditRecord;
class EditTemplate extends EditRecord
{
protected static string $resource = TemplateResource::class;
protected function getHeaderActions(): array
{
return [
ViewAction::make(),
DeleteAction::make(),
RestoreAction::make(),
ForceDeleteAction::make(),
];
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\Templates\Pages;
use App\Filament\Resources\Templates\TemplateResource;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
class ListTemplates extends ListRecords
{
protected static string $resource = TemplateResource::class;
protected function getHeaderActions(): array
{
return [
CreateAction::make(),
];
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Filament\Resources\Templates\Pages;
use App\Filament\Resources\Templates\Actions\GenerateWithAiAction;
use App\Filament\Resources\Templates\TemplateResource;
use Filament\Actions\EditAction;
use Filament\Resources\Pages\ViewRecord;
class ViewTemplate extends ViewRecord
{
protected static string $resource = TemplateResource::class;
protected function getHeaderActions(): array
{
return [
GenerateWithAiAction::make(),
EditAction::make(),
];
}
}

View File

@@ -0,0 +1,76 @@
<?php
namespace App\Filament\Resources\Templates\RelationManagers;
use App\Enums\TemplateGenerationStatus;
use App\Filament\Resources\TemplateGenerations\Actions\DownloadGenerationAction;
use App\Filament\Resources\TemplateGenerations\Actions\RetryGenerationAction;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
class GenerationsRelationManager extends RelationManager
{
protected static string $relationship = 'generations';
protected static ?string $title = 'تولیدها';
protected static ?string $modelLabel = 'تولید';
protected static ?string $pluralModelLabel = 'تولیدها';
public function table(Table $table): Table
{
return $table
->recordTitleAttribute('uuid')
->columns([
TextColumn::make('title')
->label('نام فایل')
->searchable()
->wrap(),
TextColumn::make('uuid')
->label('شناسه')
->searchable()
->copyable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('status')
->label('وضعیت')
->badge()
->formatStateUsing(fn (TemplateGenerationStatus $state): string => $state->label())
->color(fn (TemplateGenerationStatus $state): string => match ($state) {
TemplateGenerationStatus::Pending => 'gray',
TemplateGenerationStatus::Processing => 'warning',
TemplateGenerationStatus::Completed => 'success',
TemplateGenerationStatus::Failed => 'danger',
}),
TextColumn::make('requester.name')
->label('درخواست‌دهنده')
->placeholder('—'),
TextColumn::make('error_message')
->label('خطا')
->limit(40)
->placeholder('—'),
TextColumn::make('created_at')
->label('تاریخ')
->jalaliDateTime()
->sortable(),
])
->defaultSort('created_at', 'desc')
->filters([
SelectFilter::make('status')
->label('وضعیت')
->options([
TemplateGenerationStatus::Pending->value => TemplateGenerationStatus::Pending->label(),
TemplateGenerationStatus::Processing->value => TemplateGenerationStatus::Processing->label(),
TemplateGenerationStatus::Completed->value => TemplateGenerationStatus::Completed->label(),
TemplateGenerationStatus::Failed->value => TemplateGenerationStatus::Failed->label(),
]),
])
->recordActions([
DownloadGenerationAction::make(),
RetryGenerationAction::make(),
])
->toolbarActions([]);
}
}

View File

@@ -0,0 +1,114 @@
<?php
namespace App\Filament\Resources\Templates\RelationManagers;
use App\Enums\TemplateKeyType;
use Filament\Actions\CreateAction;
use Filament\Actions\DeleteAction;
use Filament\Actions\EditAction;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
class KeysRelationManager extends RelationManager
{
protected static string $relationship = 'keys';
protected static ?string $title = 'کلیدها';
protected static ?string $modelLabel = 'کلید';
protected static ?string $pluralModelLabel = 'کلیدها';
public function form(Schema $schema): Schema
{
return $schema
->components([
TextInput::make('key')
->label('کلید')
->helperText('فقط نام کلید را وارد کنید، بدون آکولاد. مثلاً اگر داخل پاورپوینت نوشته‌اید {Main Title}، اینجا فقط Main Title را وارد کنید؛ آکولاد را خود سیستم موقع جایگزینی اضافه می‌کند. برای عکس/چارت به‌جای این، نام شکل (shape name) را در فیلد پایین وارد کنید.')
->required()
->maxLength(255),
TextInput::make('label')
->label('برچسب')
->required()
->maxLength(255),
Select::make('type')
->label('نوع')
->options([
TemplateKeyType::Text->value => TemplateKeyType::Text->label(),
TemplateKeyType::Image->value => TemplateKeyType::Image->label(),
TemplateKeyType::Chart->value => TemplateKeyType::Chart->label(),
])
->required()
->live(),
TextInput::make('shape_name')
->label('نام شکل (Shape Name)')
->helperText('نام شکل تعیین‌شده در Selection Pane نرم‌افزار PowerPoint')
->visible(fn (Get $get): bool => $get('type') !== TemplateKeyType::Text->value)
->maxLength(255),
TextInput::make('description')
->label('راهنما')
->helperText('توضیحی که برای مصرف‌کننده API نمایش داده می‌شود')
->maxLength(255),
Toggle::make('is_required')
->label('اجباری')
->default(true),
Toggle::make('is_repeatable')
->label('تکرارشونده (برای ساخت با هوش مصنوعی)')
->helperText('اگر فعال باشد، این کلید در اسلاید تکرارشونده قرار دارد و برای هر اسلاید تولیدشده با هوش مصنوعی مقدار جداگانه دریافت می‌کند.')
->default(false)
->visible(fn (Get $get): bool => $get('type') === TemplateKeyType::Text->value),
TextInput::make('sort_order')
->label('ترتیب نمایش')
->numeric()
->integer()
->default(0),
]);
}
public function table(Table $table): Table
{
return $table
->recordTitleAttribute('key')
->columns([
TextColumn::make('key')
->label('کلید')
->searchable()
->sortable(),
TextColumn::make('label')
->label('برچسب')
->searchable(),
TextColumn::make('type')
->label('نوع')
->badge()
->formatStateUsing(fn (TemplateKeyType $state): string => $state->label()),
TextColumn::make('shape_name')
->label('نام شکل')
->placeholder('—'),
IconColumn::make('is_required')
->label('اجباری')
->boolean(),
IconColumn::make('is_repeatable')
->label('تکرارشونده')
->boolean(),
TextColumn::make('sort_order')
->label('ترتیب')
->sortable(),
])
->defaultSort('sort_order')
->headerActions([
CreateAction::make(),
])
->recordActions([
EditAction::make(),
DeleteAction::make(),
]);
}
}

View File

@@ -0,0 +1,70 @@
<?php
namespace App\Filament\Resources\Templates\Schemas;
use App\Models\Category;
use Filament\Forms\Components\FileUpload;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Schema;
class TemplateForm
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
Select::make('category_id')
->label('دسته‌بندی')
->options(fn (): array => Category::query()->pluck('name', 'id')->all())
->searchable()
->required(),
TextInput::make('title')
->label('عنوان')
->required()
->maxLength(255),
TextInput::make('slug')
->label('شناسه')
->required()
->unique(ignoreRecord: true)
->maxLength(255),
Textarea::make('description')
->label('توضیحات')
->maxLength(1000)
->rows(3),
FileUpload::make('file_path')
->label('فایل پاورپوینت')
->disk('local')
->directory('templates/sources')
->acceptedFileTypes(['application/vnd.openxmlformats-officedocument.presentationml.presentation'])
->required()
->visibility('private'),
FileUpload::make('thumbnail_path')
->label('تصویر بندانگشتی')
->disk('public')
->directory('templates/thumbnails')
->visibility('public')
->image()
->imageEditor(),
Toggle::make('is_active')
->label('فعال')
->default(true),
Toggle::make('is_ai_enabled')
->label('امکان ساخت با هوش مصنوعی از متن')
->helperText('در صورت فعال بودن، این قالب در فرم «ساخت پاورپوینت از متن» به کاربران پیشنهاد داده می‌شود.')
->default(false)
->live(),
TextInput::make('ai_repeatable_slide_number')
->label('شماره اسلاید تکرارشونده')
->helperText('شماره اسلایدی از فایل پاورپوینت که برای هر بخش محتوای تولیدشده با هوش مصنوعی شبیه‌سازی می‌شود (مثلاً 2).')
->numeric()
->integer()
->minValue(1)
->visible(fn (Get $get): bool => (bool) $get('is_ai_enabled'))
->required(fn (Get $get): bool => (bool) $get('is_ai_enabled')),
]);
}
}

View File

@@ -0,0 +1,58 @@
<?php
namespace App\Filament\Resources\Templates\Schemas;
use Filament\Infolists\Components\IconEntry;
use Filament\Infolists\Components\ImageEntry;
use Filament\Infolists\Components\TextEntry;
use Filament\Schemas\Schema;
class TemplateInfolist
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
ImageEntry::make('thumbnail_path')
->label('تصویر بندانگشتی')
->disk('public')
->visibility('public')
->height(180)
->placeholder('بدون تصویر')
->columnSpanFull(),
TextEntry::make('title')
->label('عنوان'),
TextEntry::make('slug')
->label('شناسه')
->copyable(),
TextEntry::make('category.name')
->label('دسته‌بندی')
->placeholder('—'),
TextEntry::make('description')
->label('توضیحات')
->placeholder('—')
->columnSpanFull(),
IconEntry::make('is_active')
->label('فعال')
->boolean(),
IconEntry::make('is_ai_enabled')
->label('ساخت با هوش مصنوعی')
->boolean(),
TextEntry::make('ai_repeatable_slide_number')
->label('شماره اسلاید تکرارشونده')
->placeholder('—'),
TextEntry::make('generations_count')
->label('تعداد تولیدها')
->state(fn ($record): int => $record->generations()->count()),
TextEntry::make('creator.name')
->label('ایجادکننده')
->placeholder('—'),
TextEntry::make('created_at')
->label('تاریخ ایجاد')
->jalaliDateTime(),
TextEntry::make('updated_at')
->label('آخرین بروزرسانی')
->jalaliDateTime(),
]);
}
}

View File

@@ -0,0 +1,76 @@
<?php
namespace App\Filament\Resources\Templates\Tables;
use App\Filament\Resources\Templates\Actions\GenerateWithAiAction;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteAction;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Actions\ForceDeleteAction;
use Filament\Actions\ForceDeleteBulkAction;
use Filament\Actions\RestoreAction;
use Filament\Actions\RestoreBulkAction;
use Filament\Actions\ViewAction;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Filters\TernaryFilter;
use Filament\Tables\Filters\TrashedFilter;
use Filament\Tables\Table;
class TemplatesTable
{
public static function configure(Table $table): Table
{
return $table
->columns([
TextColumn::make('title')
->label('عنوان')
->searchable()
->sortable(),
TextColumn::make('category.name')
->label('دسته‌بندی')
->searchable()
->sortable(),
TextColumn::make('keys_count')
->label('تعداد کلیدها')
->counts('keys'),
IconColumn::make('is_active')
->label('فعال')
->boolean(),
TextColumn::make('created_at')
->label('تاریخ ایجاد')
->jalaliDateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
SelectFilter::make('category_id')
->label('دسته‌بندی')
->relationship('category', 'name'),
TernaryFilter::make('is_active')
->label('وضعیت')
->placeholder('همه')
->trueLabel('فعال')
->falseLabel('غیرفعال'),
TrashedFilter::make()
->label('وضعیت حذف'),
])
->recordActions([
ViewAction::make(),
EditAction::make(),
GenerateWithAiAction::make(),
DeleteAction::make(),
RestoreAction::make(),
ForceDeleteAction::make(),
])
->toolbarActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
RestoreBulkAction::make(),
ForceDeleteBulkAction::make(),
]),
]);
}
}

View File

@@ -0,0 +1,80 @@
<?php
namespace App\Filament\Resources\Templates;
use App\Filament\Resources\Templates\Pages\CreateTemplate;
use App\Filament\Resources\Templates\Pages\EditTemplate;
use App\Filament\Resources\Templates\Pages\ListTemplates;
use App\Filament\Resources\Templates\Pages\ViewTemplate;
use App\Filament\Resources\Templates\RelationManagers\GenerationsRelationManager;
use App\Filament\Resources\Templates\RelationManagers\KeysRelationManager;
use App\Filament\Resources\Templates\Schemas\TemplateForm;
use App\Filament\Resources\Templates\Schemas\TemplateInfolist;
use App\Filament\Resources\Templates\Tables\TemplatesTable;
use App\Models\Template;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\SoftDeletingScope;
use UnitEnum;
class TemplateResource extends Resource
{
protected static ?string $model = Template::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedPresentationChartBar;
protected static string|UnitEnum|null $navigationGroup = 'پاورپوینت‌ها';
protected static ?string $recordTitleAttribute = 'title';
protected static ?string $modelLabel = 'قالب';
protected static ?string $pluralModelLabel = 'قالب‌ها';
protected static ?string $navigationLabel = 'قالب‌ها';
protected static ?int $navigationSort = 21;
public static function form(Schema $schema): Schema
{
return TemplateForm::configure($schema);
}
public static function infolist(Schema $schema): Schema
{
return TemplateInfolist::configure($schema);
}
public static function table(Table $table): Table
{
return TemplatesTable::configure($table);
}
public static function getEloquentQuery(): Builder
{
return parent::getEloquentQuery()
->withoutGlobalScopes([SoftDeletingScope::class]);
}
public static function getRelations(): array
{
return [
KeysRelationManager::class,
GenerationsRelationManager::class,
];
}
public static function getPages(): array
{
return [
'index' => ListTemplates::route('/'),
'create' => CreateTemplate::route('/create'),
'view' => ViewTemplate::route('/{record}'),
'edit' => EditTemplate::route('/{record}/edit'),
];
}
}

View File

@@ -2,6 +2,7 @@
namespace App\Filament\Resources\Users\Schemas;
use App\Support\Mobile;
use Filament\Forms\Components\CheckboxList;
use Filament\Forms\Components\TextInput;
use Filament\Schemas\Schema;
@@ -25,6 +26,15 @@ class UserForm
TextInput::make('mobile')
->label('شماره موبایل')
->tel()
->helperText('مثال درست: 09123456789')
->dehydrateStateUsing(fn (?string $state): ?string => Mobile::normalize($state))
->rules([
fn (): \Closure => function (string $attribute, mixed $value, \Closure $fail): void {
if (filled($value) && ! preg_match(Mobile::REGEX, (string) Mobile::normalize((string) $value))) {
$fail('فرمت شماره موبایل صحیح نیست. مثال درست: 09123456789');
}
},
])
->unique(ignoreRecord: true)
->maxLength(20),
TextInput::make('password')

View File

@@ -33,7 +33,7 @@ class UsersTable
->searchable(),
TextColumn::make('created_at')
->label('تاریخ ایجاد')
->dateTime()
->jalaliDateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Filament\Resources\WalletTransactions\Pages;
use App\Filament\Resources\WalletTransactions\WalletTransactionResource;
use Filament\Resources\Pages\ListRecords;
class ListWalletTransactions extends ListRecords
{
protected static string $resource = WalletTransactionResource::class;
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Filament\Resources\WalletTransactions\Pages;
use App\Filament\Resources\WalletTransactions\WalletTransactionResource;
use Filament\Resources\Pages\ViewRecord;
class ViewWalletTransaction extends ViewRecord
{
protected static string $resource = WalletTransactionResource::class;
}

View File

@@ -0,0 +1,65 @@
<?php
namespace App\Filament\Resources\WalletTransactions\Tables;
use App\Enums\WalletTransactionType;
use Filament\Actions\ViewAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
class WalletTransactionsTable
{
public static function configure(Table $table): Table
{
return $table
->columns([
TextColumn::make('id')
->label('#')
->sortable(),
TextColumn::make('wallet.user.name')
->label('کاربر')
->searchable()
->sortable(),
TextColumn::make('wallet.user.mobile')
->label('موبایل')
->searchable()
->toggleable(),
TextColumn::make('type')
->label('نوع')
->badge()
->formatStateUsing(fn (WalletTransactionType $state): string => $state->label())
->color(fn (WalletTransactionType $state): string => $state === WalletTransactionType::Credit ? 'success' : 'danger'),
TextColumn::make('amount')
->label('مبلغ')
->sortable()
->formatStateUsing(fn (int $state): string => number_format($state).' تومان'),
TextColumn::make('balance_after')
->label('موجودی بعد')
->formatStateUsing(fn (int $state): string => number_format($state).' تومان'),
TextColumn::make('description')
->label('توضیحات')
->limit(50)
->searchable(),
TextColumn::make('creator.name')
->label('ثبت‌کننده')
->placeholder('سیستم'),
TextColumn::make('created_at')
->label('تاریخ')
->jalaliDateTime()
->sortable(),
])
->defaultSort('created_at', 'desc')
->filters([
SelectFilter::make('type')
->label('نوع')
->options([
WalletTransactionType::Credit->value => WalletTransactionType::Credit->label(),
WalletTransactionType::Debit->value => WalletTransactionType::Debit->label(),
]),
])
->recordActions([
ViewAction::make(),
]);
}
}

View File

@@ -0,0 +1,60 @@
<?php
namespace App\Filament\Resources\WalletTransactions;
use App\Filament\Resources\WalletTransactions\Pages\ListWalletTransactions;
use App\Filament\Resources\WalletTransactions\Pages\ViewWalletTransaction;
use App\Filament\Resources\WalletTransactions\Tables\WalletTransactionsTable;
use App\Models\WalletTransaction;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
use UnitEnum;
class WalletTransactionResource extends Resource
{
protected static ?string $model = WalletTransaction::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedArrowsRightLeft;
protected static string|UnitEnum|null $navigationGroup = 'مالی';
protected static ?string $recordTitleAttribute = 'id';
protected static ?string $modelLabel = 'تراکنش';
protected static ?string $pluralModelLabel = 'تراکنش‌ها';
protected static ?string $navigationLabel = 'تراکنش‌ها';
protected static ?int $navigationSort = 11;
public static function table(Table $table): Table
{
return WalletTransactionsTable::configure($table);
}
public static function getPages(): array
{
return [
'index' => ListWalletTransactions::route('/'),
'view' => ViewWalletTransaction::route('/{record}'),
];
}
public static function canCreate(): bool
{
return false;
}
public static function canEdit($record): bool
{
return false;
}
public static function canDelete($record): bool
{
return false;
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\Wallets\Pages;
use App\Filament\Resources\Wallets\WalletResource;
use Filament\Actions\ViewAction;
use Filament\Resources\Pages\EditRecord;
class EditWallet extends EditRecord
{
protected static string $resource = WalletResource::class;
protected function getHeaderActions(): array
{
return [
ViewAction::make(),
];
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Filament\Resources\Wallets\Pages;
use App\Filament\Resources\Wallets\WalletResource;
use Filament\Resources\Pages\ListRecords;
class ListWallets extends ListRecords
{
protected static string $resource = WalletResource::class;
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\Wallets\Pages;
use App\Filament\Resources\Wallets\WalletResource;
use Filament\Actions\EditAction;
use Filament\Resources\Pages\ViewRecord;
class ViewWallet extends ViewRecord
{
protected static string $resource = WalletResource::class;
protected function getHeaderActions(): array
{
return [
EditAction::make(),
];
}
}

View File

@@ -0,0 +1,62 @@
<?php
namespace App\Filament\Resources\Wallets\RelationManagers;
use App\Enums\WalletTransactionType;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
class TransactionsRelationManager extends RelationManager
{
protected static string $relationship = 'transactions';
protected static ?string $title = 'تراکنش‌ها';
protected static ?string $modelLabel = 'تراکنش';
protected static ?string $pluralModelLabel = 'تراکنش‌ها';
public function table(Table $table): Table
{
return $table
->columns([
TextColumn::make('type')
->label('نوع')
->badge()
->formatStateUsing(fn (WalletTransactionType $state): string => $state->label())
->color(fn (WalletTransactionType $state): string => $state === WalletTransactionType::Credit ? 'success' : 'danger'),
TextColumn::make('amount')
->label('مبلغ')
->formatStateUsing(fn (int $state): string => number_format($state).' تومان'),
TextColumn::make('balance_before')
->label('قبل')
->formatStateUsing(fn (int $state): string => number_format($state)),
TextColumn::make('balance_after')
->label('بعد')
->formatStateUsing(fn (int $state): string => number_format($state)),
TextColumn::make('description')
->label('توضیحات')
->limit(40),
TextColumn::make('creator.name')
->label('ثبت‌کننده')
->placeholder('سیستم'),
TextColumn::make('created_at')
->label('تاریخ')
->jalaliDateTime()
->sortable(),
])
->defaultSort('created_at', 'desc')
->filters([
SelectFilter::make('type')
->label('نوع')
->options([
WalletTransactionType::Credit->value => WalletTransactionType::Credit->label(),
WalletTransactionType::Debit->value => WalletTransactionType::Debit->label(),
]),
])
->recordActions([])
->toolbarActions([]);
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\Wallets\Schemas;
use Filament\Forms\Components\Toggle;
use Filament\Schemas\Schema;
class WalletForm
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
Toggle::make('is_active')
->label('فعال')
->default(true),
]);
}
}

View File

@@ -0,0 +1,112 @@
<?php
namespace App\Filament\Resources\Wallets\Tables;
use App\Models\Wallet;
use App\Services\WalletService;
use Filament\Actions\Action;
use Filament\Actions\EditAction;
use Filament\Actions\ViewAction;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\TernaryFilter;
use Filament\Tables\Table;
use Throwable;
class WalletsTable
{
public static function configure(Table $table): Table
{
return $table
->columns([
TextColumn::make('user.name')
->label('کاربر')
->searchable()
->sortable(),
TextColumn::make('user.email')
->label('ایمیل')
->searchable()
->toggleable(),
TextColumn::make('user.mobile')
->label('موبایل')
->searchable()
->toggleable(),
TextColumn::make('balance')
->label('موجودی (تومان)')
->numeric()
->sortable()
->formatStateUsing(fn (int $state): string => number_format($state).' تومان'),
IconColumn::make('is_active')
->label('فعال')
->boolean(),
TextColumn::make('updated_at')
->label('آخرین بروزرسانی')
->jalaliDateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->defaultSort('balance', 'desc')
->filters([
TernaryFilter::make('is_active')
->label('وضعیت')
->placeholder('همه')
->trueLabel('فعال')
->falseLabel('غیرفعال'),
])
->recordActions([
ViewAction::make(),
EditAction::make(),
self::adjustAction('credit', 'واریز', Heroicon::OutlinedPlusCircle, 'success'),
self::adjustAction('debit', 'برداشت', Heroicon::OutlinedMinusCircle, 'danger'),
]);
}
private static function adjustAction(string $type, string $label, Heroicon $icon, string $color): Action
{
return Action::make($type)
->label($label)
->icon($icon)
->color($color)
->schema([
TextInput::make('amount')
->label('مبلغ (تومان)')
->numeric()
->required()
->minValue(1)
->integer(),
Textarea::make('description')
->label('توضیحات')
->maxLength(500)
->rows(2),
])
->action(function (Wallet $record, array $data, WalletService $walletService) use ($type): void {
$amount = (int) $data['amount'];
$description = filled($data['description'] ?? null) ? $data['description'] : null;
try {
$transaction = $type === 'credit'
? $walletService->credit($record, $amount, $description, auth()->user())
: $walletService->debit($record, $amount, $description, auth()->user());
Notification::make()
->title($type === 'credit' ? 'واریز انجام شد' : 'برداشت انجام شد')
->body('موجودی جدید: '.number_format($transaction->balance_after).' تومان')
->success()
->send();
} catch (Throwable $exception) {
report($exception);
Notification::make()
->title($type === 'credit' ? 'واریز ناموفق بود' : 'برداشت ناموفق بود')
->body($exception->getMessage() ?: 'خطای نامشخص هنگام ثبت تراکنش.')
->danger()
->persistent()
->send();
}
});
}
}

View File

@@ -0,0 +1,67 @@
<?php
namespace App\Filament\Resources\Wallets;
use App\Filament\Resources\Wallets\Pages\EditWallet;
use App\Filament\Resources\Wallets\Pages\ListWallets;
use App\Filament\Resources\Wallets\Pages\ViewWallet;
use App\Filament\Resources\Wallets\RelationManagers\TransactionsRelationManager;
use App\Filament\Resources\Wallets\Schemas\WalletForm;
use App\Filament\Resources\Wallets\Tables\WalletsTable;
use App\Models\Wallet;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
use UnitEnum;
class WalletResource extends Resource
{
protected static ?string $model = Wallet::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedWallet;
protected static string|UnitEnum|null $navigationGroup = 'مالی';
protected static ?string $recordTitleAttribute = 'id';
protected static ?string $modelLabel = 'کیف پول';
protected static ?string $pluralModelLabel = 'کیف پول‌ها';
protected static ?string $navigationLabel = 'کیف پول‌ها';
protected static ?int $navigationSort = 10;
public static function form(Schema $schema): Schema
{
return WalletForm::configure($schema);
}
public static function table(Table $table): Table
{
return WalletsTable::configure($table);
}
public static function getRelations(): array
{
return [
TransactionsRelationManager::class,
];
}
public static function getPages(): array
{
return [
'index' => ListWallets::route('/'),
'view' => ViewWallet::route('/{record}'),
'edit' => EditWallet::route('/{record}/edit'),
];
}
public static function canCreate(): bool
{
return false;
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Enums\TemplateGenerationStatus;
use App\Http\Controllers\Controller;
use App\Models\TemplateGeneration;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\StreamedResponse;
class GenerationDownloadController extends Controller
{
public function __invoke(Request $request, TemplateGeneration $generation): StreamedResponse
{
$user = $request->user();
abort_unless($user && ($user->hasRole('admin') || $user->hasPermission('templates.manage')), 403);
abort_if($generation->status !== TemplateGenerationStatus::Completed || ! $generation->output_path, 404);
return Storage::disk('local')->download($generation->output_path, $generation->downloadFilename().'.pptx');
}
}

View File

@@ -7,6 +7,7 @@ use App\Http\Requests\Auth\LoginRequest;
use App\Http\Requests\Auth\RegisterRequest;
use App\Http\Resources\UserResource;
use App\Models\User;
use App\Traits\ApiResponse;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
@@ -14,6 +15,8 @@ use OpenApi\Attributes as OA;
class AuthController extends Controller
{
use ApiResponse;
#[OA\Post(
path: '/auth/register',
description: 'Register a new user account',
@@ -64,12 +67,9 @@ class AuthController extends Controller
$token = $user->createToken('auth-token')->plainTextToken;
return response()->json([
'message' => 'ثبت‌نام با موفقیت انجام شد.',
'data' => [
return $this->success('ثبت‌نام با موفقیت انجام شد.', [
'user' => new UserResource($user),
'token' => $token,
],
], 201);
}
@@ -121,22 +121,16 @@ class AuthController extends Controller
->first();
if (! $user || ! Hash::check($request->validated('password'), $user->password)) {
return response()->json([
'message' => 'اطلاعات ورود نادرست است.',
'errors' => [
return $this->error('اطلاعات ورود نادرست است.', [
'login' => ['ایمیل یا نام کاربری یا رمز عبور اشتباه است.'],
],
], 422);
]);
}
$token = $user->createToken('auth-token')->plainTextToken;
return response()->json([
'message' => 'ورود با موفقیت انجام شد.',
'data' => [
return $this->success('ورود با موفقیت انجام شد.', [
'user' => new UserResource($user),
'token' => $token,
],
]);
}

View File

@@ -0,0 +1,52 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Resources\CategoryResource;
use App\Models\Category;
use App\Traits\ApiResponse;
use Illuminate\Http\JsonResponse;
use OpenApi\Attributes as OA;
class CategoryController extends Controller
{
use ApiResponse;
#[OA\Get(
path: '/categories',
description: 'List active template categories',
summary: 'List categories',
tags: ['Templates'],
responses: [
new OA\Response(
response: 200,
description: 'Categories fetched successfully',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'message', type: 'string', example: 'دسته‌بندی‌ها با موفقیت دریافت شد.'),
new OA\Property(
property: 'data',
properties: [
new OA\Property(
property: 'categories',
type: 'array',
items: new OA\Items(ref: '#/components/schemas/Category')
),
],
type: 'object'
),
]
)
),
]
)]
public function index(): JsonResponse
{
$categories = Category::query()->where('is_active', true)->orderBy('name')->get();
return $this->success('دسته‌بندی‌ها با موفقیت دریافت شد.', [
'categories' => CategoryResource::collection($categories),
]);
}
}

View File

@@ -0,0 +1,181 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\UpdateNotificationPreferencesRequest;
use App\Http\Resources\NotificationPreferenceResource;
use App\Http\Resources\NotificationResource;
use App\Models\NotificationPreference;
use App\Services\NotificationService;
use App\Traits\ApiResponse;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use OpenApi\Attributes as OA;
class NotificationController extends Controller
{
use ApiResponse;
public function __construct(private readonly NotificationService $notificationService) {}
#[OA\Get(
path: '/notifications',
description: 'Get the authenticated user notifications',
summary: 'List notifications',
security: [['sanctum' => []]],
tags: ['Notifications'],
parameters: [
new OA\Parameter(name: 'per_page', description: 'Items per page', in: 'query', schema: new OA\Schema(type: 'integer', example: 15)),
],
responses: [
new OA\Response(
response: 200,
description: 'Notifications fetched successfully',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'message', type: 'string', example: 'نوتیفیکیشن‌ها با موفقیت دریافت شد.'),
new OA\Property(
property: 'data',
properties: [
new OA\Property(
property: 'notifications',
type: 'array',
items: new OA\Items(ref: '#/components/schemas/Notification')
),
],
type: 'object'
),
]
)
),
new OA\Response(response: 401, description: 'Unauthenticated'),
]
)]
public function index(Request $request): JsonResponse
{
$perPage = min(max((int) $request->integer('per_page', 15), 1), 100);
$notifications = $request->user()->notifications()->paginate($perPage);
return $this->success('نوتیفیکیشن‌ها با موفقیت دریافت شد.', [
'notifications' => NotificationResource::collection($notifications)->response()->getData(true),
]);
}
#[OA\Post(
path: '/notifications/read-all',
description: 'Mark all the authenticated user notifications as read',
summary: 'Mark all notifications as read',
security: [['sanctum' => []]],
tags: ['Notifications'],
responses: [
new OA\Response(
response: 200,
description: 'Notifications marked as read successfully',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'message', type: 'string', example: 'همه نوتیفیکیشن‌ها خوانده شد.'),
new OA\Property(property: 'data', type: 'object'),
]
)
),
new OA\Response(response: 401, description: 'Unauthenticated'),
]
)]
public function markAllAsRead(Request $request): JsonResponse
{
$this->notificationService->markAllAsRead($request->user());
return $this->success('همه نوتیفیکیشن‌ها خوانده شد.');
}
#[OA\Get(
path: '/notifications/preferences',
description: 'Get the authenticated user notification preferences',
summary: 'Show notification preferences',
security: [['sanctum' => []]],
tags: ['Notifications'],
responses: [
new OA\Response(
response: 200,
description: 'Notification preferences fetched successfully',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'message', type: 'string', example: 'تنظیمات اعلان‌ها با موفقیت دریافت شد.'),
new OA\Property(
property: 'data',
properties: [
new OA\Property(property: 'preferences', ref: '#/components/schemas/NotificationPreference'),
],
type: 'object'
),
]
)
),
new OA\Response(response: 401, description: 'Unauthenticated'),
]
)]
public function preferences(Request $request): JsonResponse
{
$preferences = $this->firstOrCreatePreferences($request);
return $this->success('تنظیمات اعلان‌ها با موفقیت دریافت شد.', [
'preferences' => new NotificationPreferenceResource($preferences),
]);
}
#[OA\Put(
path: '/notifications/preferences',
description: 'Update the authenticated user notification preferences',
summary: 'Update notification preferences',
security: [['sanctum' => []]],
tags: ['Notifications'],
requestBody: new OA\RequestBody(
required: true,
content: new OA\JsonContent(ref: '#/components/schemas/NotificationPreference')
),
responses: [
new OA\Response(
response: 200,
description: 'Notification preferences updated successfully',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'message', type: 'string', example: 'تنظیمات اعلان‌ها با موفقیت به‌روزرسانی شد.'),
new OA\Property(
property: 'data',
properties: [
new OA\Property(property: 'preferences', ref: '#/components/schemas/NotificationPreference'),
],
type: 'object'
),
]
)
),
new OA\Response(response: 401, description: 'Unauthenticated'),
new OA\Response(response: 422, description: 'Validation error'),
]
)]
public function updatePreferences(UpdateNotificationPreferencesRequest $request): JsonResponse
{
$preferences = $this->firstOrCreatePreferences($request);
$preferences->update($request->validated());
return $this->success('تنظیمات اعلان‌ها با موفقیت به‌روزرسانی شد.', [
'preferences' => new NotificationPreferenceResource($preferences->fresh()),
]);
}
private function firstOrCreatePreferences(Request $request): NotificationPreference
{
return $request->user()->notificationPreference()->firstOrCreate([], [
'comment_on_presentation' => true,
'form_reply' => false,
'new_follower' => false,
'news_announcements' => false,
'weekly_product_updates' => true,
'weekly_blog_digest' => false,
]);
}
}

View File

@@ -0,0 +1,201 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\Profile\ChangePasswordRequest;
use App\Http\Requests\Profile\DeleteAccountRequest;
use App\Http\Requests\Profile\UpdateProfileRequest;
use App\Http\Resources\UserResource;
use App\Traits\ApiResponse;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Storage;
use OpenApi\Attributes as OA;
class ProfileController extends Controller
{
use ApiResponse;
#[OA\Get(
path: '/profile',
description: 'Get the authenticated user profile',
summary: 'Show profile',
security: [['sanctum' => []]],
tags: ['Profile'],
responses: [
new OA\Response(
response: 200,
description: 'Profile fetched successfully',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'message', type: 'string', example: 'اطلاعات پروفایل با موفقیت دریافت شد.'),
new OA\Property(
property: 'data',
properties: [
new OA\Property(property: 'user', ref: '#/components/schemas/User'),
],
type: 'object'
),
]
)
),
new OA\Response(response: 401, description: 'Unauthenticated'),
]
)]
public function show(): JsonResponse
{
return $this->success('اطلاعات پروفایل با موفقیت دریافت شد.', [
'user' => new UserResource(request()->user()),
]);
}
#[OA\Put(
path: '/profile',
description: 'Update the authenticated user profile',
summary: 'Update profile',
security: [['sanctum' => []]],
tags: ['Profile'],
requestBody: new OA\RequestBody(
required: true,
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'name', type: 'string', example: 'علی رضایی'),
new OA\Property(property: 'username', type: 'string', example: 'ali'),
new OA\Property(property: 'email', type: 'string', format: 'email', example: 'ali@example.com'),
new OA\Property(property: 'mobile', type: 'string', example: '09123456789'),
new OA\Property(property: 'password', type: 'string', format: 'password', example: 'password123'),
]
)
),
responses: [
new OA\Response(
response: 200,
description: 'Profile updated successfully',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'message', type: 'string', example: 'پروفایل با موفقیت به‌روزرسانی شد.'),
new OA\Property(
property: 'data',
properties: [
new OA\Property(property: 'user', ref: '#/components/schemas/User'),
],
type: 'object'
),
]
)
),
new OA\Response(response: 401, description: 'Unauthenticated'),
new OA\Response(response: 422, description: 'Validation error'),
]
)]
public function update(UpdateProfileRequest $request): JsonResponse
{
$user = $request->user();
$data = $request->safe()->except('avatar');
if ($request->hasFile('avatar')) {
if ($user->avatar_path) {
Storage::disk('public')->delete($user->avatar_path);
}
$data['avatar_path'] = $request->file('avatar')->store('avatars', 'public');
}
$user->update($data);
return $this->success('پروفایل با موفقیت به‌روزرسانی شد.', [
'user' => new UserResource($user->fresh()),
]);
}
#[OA\Put(
path: '/profile/password',
description: 'Change the authenticated user password',
summary: 'Change password',
security: [['sanctum' => []]],
tags: ['Profile'],
requestBody: new OA\RequestBody(
required: true,
content: new OA\JsonContent(
required: ['current_password', 'password', 'password_confirmation'],
properties: [
new OA\Property(property: 'current_password', type: 'string', format: 'password'),
new OA\Property(property: 'password', type: 'string', format: 'password'),
new OA\Property(property: 'password_confirmation', type: 'string', format: 'password'),
]
)
),
responses: [
new OA\Response(
response: 200,
description: 'Password changed successfully',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'message', type: 'string', example: 'رمز عبور با موفقیت تغییر کرد.'),
new OA\Property(property: 'data', type: 'object'),
]
)
),
new OA\Response(response: 401, description: 'Unauthenticated'),
new OA\Response(response: 422, description: 'Validation error'),
]
)]
public function changePassword(ChangePasswordRequest $request): JsonResponse
{
$user = $request->user();
$user->update(['password' => $request->validated('password')]);
$currentTokenId = $user->currentAccessToken()?->id;
if ($currentTokenId) {
$user->tokens()->where('id', '!=', $currentTokenId)->delete();
}
return $this->success('رمز عبور با موفقیت تغییر کرد.');
}
#[OA\Delete(
path: '/profile',
description: 'Delete the authenticated user account. Requires the current password for confirmation.',
summary: 'Delete account',
security: [['sanctum' => []]],
tags: ['Profile'],
requestBody: new OA\RequestBody(
required: true,
content: new OA\JsonContent(
required: ['password'],
properties: [
new OA\Property(property: 'password', type: 'string', format: 'password', example: 'password123'),
]
)
),
responses: [
new OA\Response(
response: 200,
description: 'Account deleted successfully',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'message', type: 'string', example: 'حساب کاربری با موفقیت حذف شد.'),
new OA\Property(property: 'data', type: 'object'),
]
)
),
new OA\Response(response: 401, description: 'Unauthenticated'),
]
)]
public function destroy(DeleteAccountRequest $request): JsonResponse
{
$user = $request->user();
if ($user->avatar_path) {
Storage::disk('public')->delete($user->avatar_path);
}
$user->tokens()->delete();
$user->delete();
return $this->success('حساب کاربری با موفقیت حذف شد.');
}
}

View File

@@ -0,0 +1,104 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Resources\TemplateResource;
use App\Models\Template;
use App\Traits\ApiResponse;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use OpenApi\Attributes as OA;
class TemplateController extends Controller
{
use ApiResponse;
#[OA\Get(
path: '/templates',
description: 'List active templates, optionally filtered by category',
summary: 'List templates',
tags: ['Templates'],
parameters: [
new OA\Parameter(name: 'category', description: 'Category slug', in: 'query', schema: new OA\Schema(type: 'string')),
],
responses: [
new OA\Response(
response: 200,
description: 'Templates fetched successfully',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'message', type: 'string', example: 'قالب‌ها با موفقیت دریافت شد.'),
new OA\Property(
property: 'data',
properties: [
new OA\Property(
property: 'templates',
type: 'array',
items: new OA\Items(ref: '#/components/schemas/Template')
),
],
type: 'object'
),
]
)
),
]
)]
public function index(Request $request): JsonResponse
{
$templates = Template::query()
->with('category')
->where('is_active', true)
->when(
$request->string('category')->isNotEmpty(),
fn ($query) => $query->whereHas(
'category',
fn ($categoryQuery) => $categoryQuery->where('slug', $request->string('category'))
)
)
->orderBy('title')
->get();
return $this->success('قالب‌ها با موفقیت دریافت شد.', [
'templates' => TemplateResource::collection($templates),
]);
}
#[OA\Get(
path: '/templates/{slug}',
description: 'Get a template details including its fillable keys',
summary: 'Show template',
tags: ['Templates'],
parameters: [
new OA\Parameter(name: 'slug', description: 'Template slug', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
],
responses: [
new OA\Response(
response: 200,
description: 'Template fetched successfully',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'message', type: 'string', example: 'قالب با موفقیت دریافت شد.'),
new OA\Property(
property: 'data',
properties: [
new OA\Property(property: 'template', ref: '#/components/schemas/Template'),
],
type: 'object'
),
]
)
),
new OA\Response(response: 404, description: 'Template not found'),
]
)]
public function show(Template $template): JsonResponse
{
$template->load('category', 'keys');
return $this->success('قالب با موفقیت دریافت شد.', [
'template' => new TemplateResource($template),
]);
}
}

View File

@@ -0,0 +1,278 @@
<?php
namespace App\Http\Controllers\Api;
use App\Enums\TemplateGenerationSource;
use App\Enums\TemplateGenerationStatus;
use App\Enums\TemplateKeyType;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\GenerateAiPresentationRequest;
use App\Http\Requests\Api\GenerateTemplateRequest;
use App\Http\Resources\TemplateGenerationResource;
use App\Jobs\GenerateTemplateJob;
use App\Models\Template;
use App\Models\TemplateGeneration;
use App\Support\Jalali;
use App\Traits\ApiResponse;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use OpenApi\Attributes as OA;
use Symfony\Component\HttpFoundation\StreamedResponse;
class TemplateGenerationController extends Controller
{
use ApiResponse;
#[OA\Post(
path: '/templates/{slug}/generate',
description: 'Start generating a filled presentation from a template',
summary: 'Generate presentation',
security: [['sanctum' => []]],
tags: ['Templates'],
parameters: [
new OA\Parameter(name: 'slug', description: 'Template slug', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
],
responses: [
new OA\Response(
response: 202,
description: 'Generation started',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'message', type: 'string', example: 'درخواست تولید پاورپوینت ثبت شد.'),
new OA\Property(
property: 'data',
properties: [
new OA\Property(property: 'generation', ref: '#/components/schemas/TemplateGeneration'),
],
type: 'object'
),
]
)
),
new OA\Response(response: 401, description: 'Unauthenticated'),
new OA\Response(response: 422, description: 'Validation error'),
]
)]
public function store(GenerateTemplateRequest $request, Template $template): JsonResponse
{
$uuid = (string) Str::uuid();
$values = $request->validated('values', []);
$storedValues = [];
foreach ($template->keys as $key) {
if (! array_key_exists($key->key, $values)) {
continue;
}
if ($key->type === TemplateKeyType::Image) {
$storedValues[$key->key] = $request->file('values.'.$key->key)
->store('templates/generations/inputs/'.$uuid, 'local');
continue;
}
$storedValues[$key->key] = $values[$key->key];
}
$generation = TemplateGeneration::query()->create([
'template_id' => $template->id,
'uuid' => $uuid,
'title' => $this->buildGenerationTitle($template, $request),
'status' => TemplateGenerationStatus::Pending,
'values' => $storedValues,
'requested_by' => $request->user()->id,
]);
GenerateTemplateJob::dispatch($generation);
return $this->success('درخواست تولید پاورپوینت ثبت شد.', [
'generation' => new TemplateGenerationResource($generation),
], 202);
}
#[OA\Post(
path: '/templates/{slug}/generate-from-text',
description: 'Start generating a presentation from free-form text using AI',
summary: 'Generate presentation from text (AI)',
security: [['sanctum' => []]],
tags: ['Templates'],
parameters: [
new OA\Parameter(name: 'slug', description: 'Template slug', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
],
requestBody: new OA\RequestBody(
required: true,
content: new OA\JsonContent(
required: ['title', 'language', 'slide_count', 'text'],
properties: [
new OA\Property(property: 'title', type: 'string', example: 'گزارش فروش سال ۱۴۰۴'),
new OA\Property(property: 'language', type: 'string', example: 'fa'),
new OA\Property(property: 'slide_count', type: 'integer', example: 10),
new OA\Property(property: 'text', type: 'string', example: 'متن، عناوین، بولت‌ها و قوانین موردنظر...'),
]
)
),
responses: [
new OA\Response(
response: 202,
description: 'Generation started',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'message', type: 'string', example: 'درخواست تولید پاورپوینت ثبت شد.'),
new OA\Property(
property: 'data',
properties: [
new OA\Property(property: 'generation', ref: '#/components/schemas/TemplateGeneration'),
],
type: 'object'
),
]
)
),
new OA\Response(response: 401, description: 'Unauthenticated'),
new OA\Response(response: 422, description: 'Validation error'),
]
)]
public function storeFromText(GenerateAiPresentationRequest $request, Template $template): JsonResponse
{
$generation = TemplateGeneration::query()->create([
'template_id' => $template->id,
'uuid' => (string) Str::uuid(),
'source' => TemplateGenerationSource::AiText,
'title' => $request->validated('title'),
'language' => $request->validated('language'),
'slide_count' => $request->validated('slide_count'),
'source_text' => $request->validated('text'),
'status' => TemplateGenerationStatus::Pending,
'requested_by' => $request->user()->id,
]);
GenerateTemplateJob::dispatch($generation);
return $this->success('درخواست تولید پاورپوینت ثبت شد.', [
'generation' => new TemplateGenerationResource($generation),
], 202);
}
#[OA\Get(
path: '/generations',
description: 'List the authenticated user presentation generations (both template-based and AI-generated)',
summary: 'List my presentations',
security: [['sanctum' => []]],
tags: ['Templates'],
parameters: [
new OA\Parameter(name: 'per_page', description: 'Items per page', in: 'query', schema: new OA\Schema(type: 'integer', example: 15)),
],
responses: [
new OA\Response(
response: 200,
description: 'Generations fetched successfully',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'message', type: 'string', example: 'پاورپوینت‌های شما با موفقیت دریافت شد.'),
new OA\Property(
property: 'data',
properties: [
new OA\Property(
property: 'generations',
type: 'array',
items: new OA\Items(ref: '#/components/schemas/TemplateGeneration')
),
],
type: 'object'
),
]
)
),
new OA\Response(response: 401, description: 'Unauthenticated'),
]
)]
public function index(Request $request): JsonResponse
{
$perPage = min(max((int) $request->integer('per_page', 15), 1), 100);
$generations = TemplateGeneration::query()
->with('template')
->where('requested_by', $request->user()->id)
->latest()
->paginate($perPage);
return $this->success('پاورپوینت‌های شما با موفقیت دریافت شد.', [
'generations' => TemplateGenerationResource::collection($generations)->response()->getData(true),
]);
}
#[OA\Get(
path: '/generations/{uuid}',
description: 'Get the status of a presentation generation request',
summary: 'Show generation status',
security: [['sanctum' => []]],
tags: ['Templates'],
parameters: [
new OA\Parameter(name: 'uuid', description: 'Generation uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
],
responses: [
new OA\Response(
response: 200,
description: 'Generation status fetched successfully',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'message', type: 'string', example: 'وضعیت تولید با موفقیت دریافت شد.'),
new OA\Property(
property: 'data',
properties: [
new OA\Property(property: 'generation', ref: '#/components/schemas/TemplateGeneration'),
],
type: 'object'
),
]
)
),
new OA\Response(response: 401, description: 'Unauthenticated'),
new OA\Response(response: 403, description: 'Forbidden'),
new OA\Response(response: 404, description: 'Not found'),
]
)]
public function show(TemplateGeneration $generation): JsonResponse
{
abort_if($generation->requested_by !== request()->user()->id, 403);
return $this->success('وضعیت تولید با موفقیت دریافت شد.', [
'generation' => new TemplateGenerationResource($generation),
]);
}
#[OA\Get(
path: '/generations/{uuid}/download',
description: 'Download the generated presentation file',
summary: 'Download generated presentation',
security: [['sanctum' => []]],
tags: ['Templates'],
parameters: [
new OA\Parameter(name: 'uuid', description: 'Generation uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
],
responses: [
new OA\Response(response: 200, description: 'File download'),
new OA\Response(response: 401, description: 'Unauthenticated'),
new OA\Response(response: 403, description: 'Forbidden'),
new OA\Response(response: 404, description: 'Not found or not ready'),
]
)]
public function download(TemplateGeneration $generation): StreamedResponse
{
abort_if($generation->requested_by !== request()->user()->id, Response::HTTP_FORBIDDEN);
abort_if($generation->status !== TemplateGenerationStatus::Completed || ! $generation->output_path, Response::HTTP_NOT_FOUND);
return Storage::disk('local')->download($generation->output_path, $generation->downloadFilename().'.pptx');
}
private function buildGenerationTitle(Template $template, Request $request): string
{
$label = $request->user()?->name ?: 'کاربر';
return sprintf('%s - %s - %s', $template->title, $label, Jalali::date(now()));
}
}

View File

@@ -0,0 +1,100 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Resources\WalletResource;
use App\Http\Resources\WalletTransactionResource;
use App\Services\WalletService;
use App\Traits\ApiResponse;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use OpenApi\Attributes as OA;
class WalletController extends Controller
{
use ApiResponse;
public function __construct(private readonly WalletService $walletService) {}
#[OA\Get(
path: '/wallet',
description: 'Get the authenticated user wallet',
summary: 'Show wallet',
security: [['sanctum' => []]],
tags: ['Wallet'],
responses: [
new OA\Response(
response: 200,
description: 'Wallet fetched successfully',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'message', type: 'string', example: 'اطلاعات کیف پول با موفقیت دریافت شد.'),
new OA\Property(
property: 'data',
properties: [
new OA\Property(property: 'wallet', ref: '#/components/schemas/Wallet'),
],
type: 'object'
),
]
)
),
new OA\Response(response: 401, description: 'Unauthenticated'),
]
)]
public function show(Request $request): JsonResponse
{
$wallet = $this->walletService->createForUser($request->user());
return $this->success('اطلاعات کیف پول با موفقیت دریافت شد.', [
'wallet' => new WalletResource($wallet),
]);
}
#[OA\Get(
path: '/wallet/transactions',
description: 'Get the authenticated user wallet transactions',
summary: 'List wallet transactions',
security: [['sanctum' => []]],
tags: ['Wallet'],
parameters: [
new OA\Parameter(name: 'per_page', description: 'Items per page', in: 'query', schema: new OA\Schema(type: 'integer', example: 15)),
],
responses: [
new OA\Response(
response: 200,
description: 'Wallet transactions fetched successfully',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'message', type: 'string', example: 'تراکنش‌های کیف پول با موفقیت دریافت شد.'),
new OA\Property(
property: 'data',
properties: [
new OA\Property(
property: 'transactions',
type: 'array',
items: new OA\Items(ref: '#/components/schemas/WalletTransaction')
),
],
type: 'object'
),
]
)
),
new OA\Response(response: 401, description: 'Unauthenticated'),
]
)]
public function transactions(Request $request): JsonResponse
{
$wallet = $this->walletService->createForUser($request->user());
$perPage = min(max((int) $request->integer('per_page', 15), 1), 100);
$transactions = $wallet->transactions()->paginate($perPage);
return $this->success('تراکنش‌های کیف پول با موفقیت دریافت شد.', [
'transactions' => WalletTransactionResource::collection($transactions)->response()->getData(true),
]);
}
}

View File

@@ -0,0 +1,89 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\InitiateDepositRequest;
use App\Http\Resources\WalletDepositResource;
use App\Models\WalletDeposit;
use App\Services\WalletDepositService;
use App\Traits\ApiResponse;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use OpenApi\Attributes as OA;
class WalletDepositController extends Controller
{
use ApiResponse;
public function __construct(private readonly WalletDepositService $walletDepositService) {}
#[OA\Post(
path: '/wallet/deposits',
description: 'Initiate a wallet top-up and get a payment redirect URL',
summary: 'Initiate wallet deposit',
security: [['sanctum' => []]],
tags: ['Wallet'],
requestBody: new OA\RequestBody(
required: true,
content: new OA\JsonContent(
required: ['amount'],
properties: [
new OA\Property(property: 'amount', type: 'integer', example: 500000),
]
)
),
responses: [
new OA\Response(
response: 201,
description: 'Deposit initiated successfully',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'message', type: 'string', example: 'درخواست شارژ کیف پول ثبت شد.'),
new OA\Property(
property: 'data',
properties: [
new OA\Property(property: 'deposit', ref: '#/components/schemas/WalletDeposit'),
new OA\Property(property: 'payment_url', type: 'string'),
],
type: 'object'
),
]
)
),
new OA\Response(response: 401, description: 'Unauthenticated'),
new OA\Response(response: 422, description: 'Validation error'),
]
)]
public function store(InitiateDepositRequest $request): JsonResponse
{
$result = $this->walletDepositService->initiate($request->user(), $request->validated('amount'));
return $this->success('درخواست شارژ کیف پول ثبت شد.', [
'deposit' => new WalletDepositResource($result['deposit']),
'payment_url' => $result['payment_url'],
], 201);
}
#[OA\Get(
path: '/wallet/deposits/{deposit}/callback',
description: 'Public callback the payment gateway redirects to after a payment attempt',
summary: 'Wallet deposit callback',
tags: ['Wallet'],
parameters: [
new OA\Parameter(name: 'deposit', description: 'Deposit id', in: 'path', required: true, schema: new OA\Schema(type: 'integer')),
],
responses: [
new OA\Response(response: 302, description: 'Redirects to the frontend wallet page with a status query param'),
]
)]
public function callback(WalletDeposit $deposit, Request $request): RedirectResponse
{
$success = $this->walletDepositService->handleCallback($deposit, $request->query());
return redirect()->away(
rtrim((string) config('app.frontend_url'), '/').'/wallet?status='.($success ? 'success' : 'failed')
);
}
}

View File

@@ -0,0 +1,57 @@
<?php
namespace App\Http\Requests\Api;
use App\Models\Template;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Validator;
class GenerateAiPresentationRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'title' => ['required', 'string', 'max:255'],
'language' => ['required', 'string', 'in:fa,en,ar'],
'slide_count' => ['required', 'integer', 'min:3', 'max:30'],
'text' => ['required', 'string', 'min:20'],
];
}
public function withValidator(Validator $validator): void
{
$validator->after(function (Validator $validator): void {
/** @var Template $template */
$template = $this->route('template');
if (! $template->is_ai_enabled) {
$validator->errors()->add('template', 'این قالب برای ساخت پاورپوینت با هوش مصنوعی فعال نیست.');
}
});
}
/**
* @return array<string, string>
*/
public function messages(): array
{
return [
'title.required' => 'عنوان پاورپوینت الزامی است.',
'language.required' => 'زبان ارائه الزامی است.',
'language.in' => 'زبان انتخاب‌شده پشتیبانی نمی‌شود.',
'slide_count.required' => 'تعداد اسلاید الزامی است.',
'slide_count.min' => 'تعداد اسلاید باید حداقل :min باشد.',
'slide_count.max' => 'تعداد اسلاید باید حداکثر :max باشد.',
'text.required' => 'متن ورودی الزامی است.',
'text.min' => 'متن ورودی باید حداقل :min کاراکتر باشد.',
];
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace App\Http\Requests\Api;
use App\Enums\TemplateKeyType;
use App\Models\Template;
use Illuminate\Foundation\Http\FormRequest;
class GenerateTemplateRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
/** @var Template $template */
$template = $this->route('template');
$rules = [];
foreach ($template->keys as $key) {
$fieldRules = [$key->is_required ? 'required' : 'nullable'];
$fieldRules[] = match ($key->type) {
TemplateKeyType::Text => 'string',
TemplateKeyType::Image => 'file|image',
TemplateKeyType::Chart => 'prohibited',
};
$rules['values.'.$key->key] = $fieldRules;
}
return $rules;
}
/**
* @return array<string, string>
*/
public function messages(): array
{
return [
'*.prohibited' => 'کلید :attribute از نوع چارت است و فعلاً پشتیبانی نمی‌شود.',
];
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace App\Http\Requests\Api;
use Illuminate\Foundation\Http\FormRequest;
class InitiateDepositRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'amount' => ['required', 'integer', 'min:10000'],
];
}
/**
* @return array<string, string>
*/
public function messages(): array
{
return [
'amount.required' => 'مبلغ شارژ الزامی است.',
'amount.integer' => 'مبلغ باید عدد باشد.',
'amount.min' => 'حداقل مبلغ شارژ :min تومان است.',
];
}
}

View File

@@ -0,0 +1,62 @@
<?php
namespace App\Http\Requests\Api;
use Illuminate\Foundation\Http\FormRequest;
class UpdateNotificationPreferencesRequest extends FormRequest
{
/**
* @var list<string>
*/
private const FIELDS = [
'comment_on_presentation',
'form_reply',
'new_follower',
'news_announcements',
'weekly_product_updates',
'weekly_blog_digest',
];
public function authorize(): bool
{
return true;
}
/**
* Accept "true"/"false"/"1"/"0"/1/0 strings from the frontend and coerce them to
* real booleans before validation so the toggles actually persist.
*/
protected function prepareForValidation(): void
{
$normalized = [];
foreach (self::FIELDS as $field) {
if ($this->has($field)) {
$normalized[$field] = filter_var($this->input($field), FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? false;
}
}
if ($normalized !== []) {
$this->merge($normalized);
}
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return array_fill_keys(self::FIELDS, ['sometimes', 'boolean']);
}
/**
* @return array<string, string>
*/
public function messages(): array
{
return [
'*.boolean' => 'مقدار این تنظیم باید بله یا خیر باشد.',
];
}
}

View File

@@ -2,6 +2,7 @@
namespace App\Http\Requests\Auth;
use App\Support\Mobile;
use Illuminate\Foundation\Http\FormRequest;
class RegisterRequest extends FormRequest
@@ -11,6 +12,13 @@ class RegisterRequest extends FormRequest
return true;
}
protected function prepareForValidation(): void
{
if ($this->has('mobile')) {
$this->merge(['mobile' => Mobile::normalize($this->input('mobile'))]);
}
}
/**
* @return array<string, mixed>
*/
@@ -18,7 +26,7 @@ class RegisterRequest extends FormRequest
{
return [
'name' => ['required', 'string', 'max:255'],
'mobile' => ['required', 'string', 'regex:/^09\d{9}$/', 'unique:users,mobile'],
'mobile' => ['required', 'string', 'regex:'.Mobile::REGEX, 'unique:users,mobile'],
'email' => ['required', 'string', 'email', 'max:255', 'unique:users,email'],
'password' => ['required', 'string', 'min:8'],
];
@@ -32,7 +40,7 @@ class RegisterRequest extends FormRequest
return [
'name.required' => 'نام و نام خانوادگی الزامی است.',
'mobile.required' => 'شماره موبایل الزامی است.',
'mobile.regex' => 'فرمت شماره موبایل صحیح نیست.',
'mobile.regex' => 'فرمت شماره موبایل صحیح نیست. مثال درست: 09123456789',
'mobile.unique' => 'این شماره موبایل قبلاً ثبت شده است.',
'email.required' => 'ایمیل الزامی است.',
'email.email' => 'فرمت ایمیل صحیح نیست.',

View File

@@ -0,0 +1,38 @@
<?php
namespace App\Http\Requests\Profile;
use Illuminate\Foundation\Http\FormRequest;
class ChangePasswordRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'current_password' => ['required', 'string', 'current_password'],
'password' => ['required', 'string', 'min:8', 'confirmed'],
];
}
/**
* @return array<string, string>
*/
public function messages(): array
{
return [
'current_password.required' => 'رمز عبور فعلی الزامی است.',
'current_password.current_password' => 'رمز عبور فعلی صحیح نیست.',
'password.required' => 'رمز عبور جدید الزامی است.',
'password.min' => 'رمز عبور جدید باید حداقل ۸ کاراکتر باشد.',
'password.confirmed' => 'تکرار رمز عبور جدید مطابقت ندارد.',
];
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Http\Requests\Profile;
use Illuminate\Foundation\Http\FormRequest;
class DeleteAccountRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'password' => ['required', 'string', 'current_password'],
];
}
/**
* @return array<string, string>
*/
public function messages(): array
{
return [
'password.required' => 'برای حذف حساب کاربری، وارد کردن رمز عبور الزامی است.',
'password.current_password' => 'رمز عبور واردشده صحیح نیست.',
];
}
}

View File

@@ -0,0 +1,69 @@
<?php
namespace App\Http\Requests\Profile;
use App\Support\Mobile;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateProfileRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
protected function prepareForValidation(): void
{
if ($this->has('mobile')) {
$this->merge(['mobile' => Mobile::normalize($this->input('mobile'))]);
}
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
$userId = $this->user()->id;
return [
'name' => ['sometimes', 'required', 'string', 'max:255'],
'username' => ['sometimes', 'required', 'string', 'max:255', Rule::unique('users', 'username')->ignore($userId)],
'email' => ['sometimes', 'required', 'string', 'email', 'max:255', Rule::unique('users', 'email')->ignore($userId)],
'mobile' => ['sometimes', 'required', 'string', 'regex:'.Mobile::REGEX, Rule::unique('users', 'mobile')->ignore($userId)],
'password' => ['sometimes', 'required', 'string', 'min:8'],
'avatar' => ['sometimes', 'nullable', 'image', 'max:3072'],
'is_public' => ['sometimes', 'nullable', 'boolean'],
'social_links' => ['sometimes', 'nullable', 'array'],
'social_links.instagram' => ['nullable', 'string', 'max:255'],
'social_links.telegram' => ['nullable', 'string', 'max:255'],
'social_links.linkedin' => ['nullable', 'string', 'max:255'],
'social_links.twitter' => ['nullable', 'string', 'max:255'],
'social_links.website' => ['nullable', 'string', 'max:255'],
];
}
/**
* @return array<string, string>
*/
public function messages(): array
{
return [
'name.required' => 'نام و نام خانوادگی الزامی است.',
'username.required' => 'نام کاربری الزامی است.',
'username.unique' => 'این نام کاربری قبلاً استفاده شده است.',
'email.required' => 'ایمیل الزامی است.',
'email.email' => 'فرمت ایمیل صحیح نیست.',
'email.unique' => 'این ایمیل قبلاً ثبت شده است.',
'mobile.required' => 'شماره موبایل الزامی است.',
'mobile.regex' => 'فرمت شماره موبایل صحیح نیست. مثال درست: 09123456789',
'mobile.unique' => 'این شماره موبایل قبلاً ثبت شده است.',
'password.required' => 'رمز عبور الزامی است.',
'password.min' => 'رمز عبور باید حداقل ۸ کاراکتر باشد.',
'avatar.image' => 'فایل ارسالی باید تصویر باشد.',
'avatar.max' => 'حداکثر حجم تصویر ۳ مگابایت است.',
'social_links.array' => 'ساختار شبکه‌های اجتماعی نامعتبر است.',
];
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Http\Resources;
use App\Models\Category;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use OpenApi\Attributes as OA;
#[OA\Schema(
schema: 'Category',
properties: [
new OA\Property(property: 'id', type: 'integer', example: 1),
new OA\Property(property: 'name', type: 'string', example: 'کسب‌وکار'),
new OA\Property(property: 'slug', type: 'string', example: 'business'),
new OA\Property(property: 'description', type: 'string', example: 'قالب‌های ارائه کسب‌وکار', nullable: true),
]
)]
/** @mixin Category */
class CategoryResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'slug' => $this->slug,
'description' => $this->description,
];
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace App\Http\Resources;
use App\Models\NotificationPreference;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use OpenApi\Attributes as OA;
#[OA\Schema(
schema: 'NotificationPreference',
properties: [
new OA\Property(property: 'comment_on_presentation', type: 'boolean', example: true),
new OA\Property(property: 'form_reply', type: 'boolean', example: false),
new OA\Property(property: 'new_follower', type: 'boolean', example: false),
new OA\Property(property: 'news_announcements', type: 'boolean', example: false),
new OA\Property(property: 'weekly_product_updates', type: 'boolean', example: true),
new OA\Property(property: 'weekly_blog_digest', type: 'boolean', example: false),
]
)]
/** @mixin NotificationPreference */
class NotificationPreferenceResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'comment_on_presentation' => $this->comment_on_presentation,
'form_reply' => $this->form_reply,
'new_follower' => $this->new_follower,
'news_announcements' => $this->news_announcements,
'weekly_product_updates' => $this->weekly_product_updates,
'weekly_blog_digest' => $this->weekly_blog_digest,
];
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Http\Resources;
use App\Models\Notification;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use OpenApi\Attributes as OA;
#[OA\Schema(
schema: 'Notification',
properties: [
new OA\Property(property: 'id', type: 'integer', example: 1),
new OA\Property(property: 'type', type: 'string', example: 'system'),
new OA\Property(property: 'type_label', type: 'string', example: 'سیستمی'),
new OA\Property(property: 'title', type: 'string', example: 'خوش آمدید'),
new OA\Property(property: 'body', type: 'string', example: 'به هوش‌پوینت خوش آمدید.'),
new OA\Property(property: 'data', type: 'object', nullable: true),
new OA\Property(property: 'read_at', type: 'string', format: 'date-time', nullable: true),
new OA\Property(property: 'created_at', type: 'string', format: 'date-time'),
]
)]
/** @mixin Notification */
class NotificationResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'type' => $this->type->value,
'type_label' => $this->type->label(),
'title' => $this->title,
'body' => $this->body,
'data' => $this->data,
'read_at' => $this->read_at?->toIso8601String(),
'created_at' => $this->created_at?->toIso8601String(),
];
}
}

View File

@@ -0,0 +1,61 @@
<?php
namespace App\Http\Resources;
use App\Models\TemplateGeneration;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use OpenApi\Attributes as OA;
#[OA\Schema(
schema: 'TemplateGeneration',
properties: [
new OA\Property(property: 'uuid', type: 'string', example: '9d6f0b1e-9a3a-4f0a-8a1a-6c9a2e5b3d10'),
new OA\Property(property: 'title', type: 'string', example: 'گزارش فروش سال ۱۴۰۴'),
new OA\Property(property: 'source', type: 'string', example: 'ai_text'),
new OA\Property(property: 'source_label', type: 'string', example: 'ساخت با هوش مصنوعی از متن'),
new OA\Property(property: 'language', type: 'string', example: 'fa', nullable: true),
new OA\Property(property: 'slide_count', type: 'integer', example: 10, nullable: true),
new OA\Property(
property: 'template',
properties: [
new OA\Property(property: 'title', type: 'string'),
new OA\Property(property: 'slug', type: 'string'),
],
type: 'object',
nullable: true
),
new OA\Property(property: 'status', type: 'string', example: 'completed'),
new OA\Property(property: 'status_label', type: 'string', example: 'تکمیل شده'),
new OA\Property(property: 'error_message', type: 'string', example: null, nullable: true),
new OA\Property(property: 'download_url', type: 'string', example: 'https://example.com/api/generations/9d6f.../download', nullable: true),
new OA\Property(property: 'created_at', type: 'string', format: 'date-time'),
]
)]
/** @mixin TemplateGeneration */
class TemplateGenerationResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'uuid' => $this->uuid,
'title' => $this->title ?: $this->template?->title,
'source' => $this->source->value,
'source_label' => $this->source->label(),
'language' => $this->language,
'slide_count' => $this->slide_count,
'template' => $this->template ? [
'title' => $this->template->title,
'slug' => $this->template->slug,
] : null,
'status' => $this->status->value,
'status_label' => $this->status->label(),
'error_message' => $this->error_message,
'download_url' => $this->output_path ? route('api.generations.download', $this->uuid) : null,
'created_at' => $this->created_at?->toIso8601String(),
];
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace App\Http\Resources;
use App\Models\TemplateKey;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use OpenApi\Attributes as OA;
#[OA\Schema(
schema: 'TemplateKey',
properties: [
new OA\Property(property: 'key', type: 'string', example: 'title'),
new OA\Property(property: 'label', type: 'string', example: 'عنوان اسلاید'),
new OA\Property(property: 'type', type: 'string', example: 'text'),
new OA\Property(property: 'type_label', type: 'string', example: 'متن'),
new OA\Property(property: 'is_required', type: 'boolean', example: true),
new OA\Property(property: 'description', type: 'string', example: 'عنوان اصلی اسلاید نخست', nullable: true),
]
)]
/** @mixin TemplateKey */
class TemplateKeyResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'key' => $this->key,
'label' => $this->label,
'type' => $this->type->value,
'type_label' => $this->type->label(),
'is_required' => $this->is_required,
'description' => $this->description,
];
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Http\Resources;
use App\Models\Template;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use OpenApi\Attributes as OA;
#[OA\Schema(
schema: 'Template',
properties: [
new OA\Property(property: 'id', type: 'integer', example: 1),
new OA\Property(property: 'title', type: 'string', example: 'گزارش فروش ماهانه'),
new OA\Property(property: 'slug', type: 'string', example: 'monthly-sales-report'),
new OA\Property(property: 'description', type: 'string', example: 'قالب گزارش فروش ماهانه', nullable: true),
new OA\Property(property: 'thumbnail_url', type: 'string', example: 'https://example.com/storage/templates/thumbnails/1.png', nullable: true),
new OA\Property(property: 'category', ref: '#/components/schemas/Category'),
new OA\Property(
property: 'keys',
type: 'array',
items: new OA\Items(ref: '#/components/schemas/TemplateKey')
),
]
)]
/** @mixin Template */
class TemplateResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'title' => $this->title,
'slug' => $this->slug,
'description' => $this->description,
'thumbnail_url' => $this->thumbnail_path ? asset('storage/'.$this->thumbnail_path) : null,
'category' => new CategoryResource($this->whenLoaded('category')),
'keys' => TemplateKeyResource::collection($this->whenLoaded('keys')),
];
}
}

View File

@@ -5,6 +5,7 @@ namespace App\Http\Resources;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Facades\Storage;
use OpenApi\Attributes as OA;
#[OA\Schema(
@@ -15,6 +16,9 @@ use OpenApi\Attributes as OA;
new OA\Property(property: 'username', type: 'string', example: 'ali'),
new OA\Property(property: 'email', type: 'string', format: 'email', example: 'ali@example.com'),
new OA\Property(property: 'mobile', type: 'string', example: '09123456789'),
new OA\Property(property: 'avatar_url', type: 'string', example: 'https://example.com/storage/avatars/1.png', nullable: true),
new OA\Property(property: 'is_public', type: 'boolean', example: false),
new OA\Property(property: 'social_links', type: 'object', example: ['instagram' => 'https://instagram.com/handle'], nullable: true),
new OA\Property(property: 'created_at', type: 'string', format: 'date-time'),
]
)]
@@ -32,6 +36,9 @@ class UserResource extends JsonResource
'username' => $this->username,
'email' => $this->email,
'mobile' => $this->mobile,
'avatar_url' => $this->avatar_path ? Storage::disk('public')->url($this->avatar_path) : null,
'is_public' => $this->is_public,
'social_links' => $this->social_links ?: null,
'created_at' => $this->created_at?->toIso8601String(),
];
}

View File

@@ -0,0 +1,38 @@
<?php
namespace App\Http\Resources;
use App\Models\WalletDeposit;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use OpenApi\Attributes as OA;
#[OA\Schema(
schema: 'WalletDeposit',
properties: [
new OA\Property(property: 'id', type: 'integer', example: 1),
new OA\Property(property: 'amount', type: 'integer', example: 500000),
new OA\Property(property: 'status', type: 'string', example: 'pending'),
new OA\Property(property: 'status_label', type: 'string', example: 'در انتظار پرداخت'),
new OA\Property(property: 'gateway', type: 'string', example: 'fake'),
new OA\Property(property: 'created_at', type: 'string', format: 'date-time'),
]
)]
/** @mixin WalletDeposit */
class WalletDepositResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'amount' => $this->amount,
'status' => $this->status->value,
'status_label' => $this->status->label(),
'gateway' => $this->gateway,
'created_at' => $this->created_at?->toIso8601String(),
];
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Http\Resources;
use App\Models\Wallet;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use OpenApi\Attributes as OA;
#[OA\Schema(
schema: 'Wallet',
properties: [
new OA\Property(property: 'id', type: 'integer', example: 1),
new OA\Property(property: 'balance', type: 'integer', example: 500000),
new OA\Property(property: 'is_active', type: 'boolean', example: true),
new OA\Property(property: 'created_at', type: 'string', format: 'date-time'),
]
)]
/** @mixin Wallet */
class WalletResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'balance' => $this->balance,
'is_active' => $this->is_active,
'created_at' => $this->created_at?->toIso8601String(),
];
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Http\Resources;
use App\Models\WalletTransaction;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use OpenApi\Attributes as OA;
#[OA\Schema(
schema: 'WalletTransaction',
properties: [
new OA\Property(property: 'id', type: 'integer', example: 1),
new OA\Property(property: 'type', type: 'string', example: 'credit'),
new OA\Property(property: 'type_label', type: 'string', example: 'واریز'),
new OA\Property(property: 'amount', type: 'integer', example: 100000),
new OA\Property(property: 'balance_before', type: 'integer', example: 400000),
new OA\Property(property: 'balance_after', type: 'integer', example: 500000),
new OA\Property(property: 'description', type: 'string', example: 'شارژ کیف پول', nullable: true),
new OA\Property(property: 'created_at', type: 'string', format: 'date-time'),
]
)]
/** @mixin WalletTransaction */
class WalletTransactionResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'type' => $this->type->value,
'type_label' => $this->type->label(),
'amount' => $this->amount,
'balance_before' => $this->balance_before,
'balance_after' => $this->balance_after,
'description' => $this->description,
'created_at' => $this->created_at?->toIso8601String(),
];
}
}

View File

@@ -0,0 +1,65 @@
<?php
namespace App\Jobs;
use App\Enums\TemplateGenerationSource;
use App\Enums\TemplateGenerationStatus;
use App\Models\AiProvider;
use App\Models\TemplateGeneration;
use App\Services\Ai\OpenAiCompatibleTextGenerator;
use App\Services\TemplateFillService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use RuntimeException;
use Throwable;
class GenerateTemplateJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(private readonly TemplateGeneration $generation) {}
public function handle(TemplateFillService $templateFillService, OpenAiCompatibleTextGenerator $outlineGenerator): void
{
$this->generation->update(['status' => TemplateGenerationStatus::Processing]);
try {
$outputPath = $this->generation->source === TemplateGenerationSource::AiText
? $this->fillFromAiText($templateFillService, $outlineGenerator)
: $templateFillService->fill($this->generation);
$this->generation->update([
'status' => TemplateGenerationStatus::Completed,
'output_path' => $outputPath,
]);
} catch (Throwable $exception) {
$this->generation->update([
'status' => TemplateGenerationStatus::Failed,
'error_message' => $exception->getMessage(),
]);
}
}
private function fillFromAiText(TemplateFillService $templateFillService, OpenAiCompatibleTextGenerator $outlineGenerator): string
{
$provider = AiProvider::resolveActiveDefault();
if (! $provider) {
throw new RuntimeException('هیچ سرویس هوش مصنوعی فعالی تنظیم نشده است.');
}
$outline = $outlineGenerator->generateOutline(
$this->generation->template,
(string) $this->generation->title,
(string) $this->generation->source_text,
(int) $this->generation->slide_count,
(string) $this->generation->language,
$provider,
);
return $templateFillService->fillAiGeneration($this->generation, $outline);
}
}

39
app/Models/AiProvider.php Normal file
View File

@@ -0,0 +1,39 @@
<?php
namespace App\Models;
use App\Enums\AiProviderDriver;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Model;
#[Fillable(['name', 'driver', 'model', 'base_url', 'api_key', 'is_active', 'is_default'])]
#[Hidden(['api_key'])]
class AiProvider extends Model
{
public static function resolveActiveDefault(): ?self
{
return static::query()
->where('is_active', true)
->orderByDesc('is_default')
->first();
}
public function resolvedBaseUrl(): string
{
return $this->base_url ?: $this->driver->defaultBaseUrl();
}
/**
* @return array<string, string>
*/
protected function casts(): array
{
return [
'driver' => AiProviderDriver::class,
'api_key' => 'encrypted',
'is_active' => 'boolean',
'is_default' => 'boolean',
];
}
}

29
app/Models/Category.php Normal file
View File

@@ -0,0 +1,29 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable(['name', 'slug', 'description', 'is_active'])]
class Category extends Model
{
/**
* @return HasMany<Template, $this>
*/
public function templates(): HasMany
{
return $this->hasMany(Template::class);
}
/**
* @return array<string, string>
*/
protected function casts(): array
{
return [
'is_active' => 'boolean',
];
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace App\Models;
use App\Enums\NotificationType;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable(['user_id', 'type', 'title', 'body', 'data', 'read_at'])]
class Notification extends Model
{
/**
* @return BelongsTo<User, $this>
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function isRead(): bool
{
return $this->read_at !== null;
}
/**
* @return array<string, string>
*/
protected function casts(): array
{
return [
'type' => NotificationType::class,
'data' => 'array',
'read_at' => 'datetime',
];
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable([
'user_id',
'comment_on_presentation',
'form_reply',
'new_follower',
'news_announcements',
'weekly_product_updates',
'weekly_blog_digest',
])]
class NotificationPreference extends Model
{
/**
* @return BelongsTo<User, $this>
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/**
* @return array<string, string>
*/
protected function casts(): array
{
return [
'comment_on_presentation' => 'boolean',
'form_reply' => 'boolean',
'new_follower' => 'boolean',
'news_announcements' => 'boolean',
'weekly_product_updates' => 'boolean',
'weekly_blog_digest' => 'boolean',
];
}
}

70
app/Models/Template.php Normal file
View File

@@ -0,0 +1,70 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
#[Fillable([
'category_id',
'title',
'slug',
'description',
'file_path',
'thumbnail_path',
'is_active',
'is_ai_enabled',
'ai_repeatable_slide_number',
'created_by',
])]
class Template extends Model
{
use SoftDeletes;
/**
* @return BelongsTo<Category, $this>
*/
public function category(): BelongsTo
{
return $this->belongsTo(Category::class);
}
/**
* @return BelongsTo<User, $this>
*/
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
/**
* @return HasMany<TemplateKey, $this>
*/
public function keys(): HasMany
{
return $this->hasMany(TemplateKey::class)->orderBy('sort_order');
}
/**
* @return HasMany<TemplateGeneration, $this>
*/
public function generations(): HasMany
{
return $this->hasMany(TemplateGeneration::class)->latest();
}
/**
* @return array<string, string>
*/
protected function casts(): array
{
return [
'is_active' => 'boolean',
'is_ai_enabled' => 'boolean',
'ai_repeatable_slide_number' => 'integer',
];
}
}

View File

@@ -0,0 +1,82 @@
<?php
namespace App\Models;
use App\Enums\TemplateGenerationSource;
use App\Enums\TemplateGenerationStatus;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable([
'template_id',
'uuid',
'source',
'title',
'language',
'slide_count',
'source_text',
'status',
'values',
'output_path',
'error_message',
'requested_by',
])]
class TemplateGeneration extends Model
{
/**
* @return BelongsTo<Template, $this>
*/
public function template(): BelongsTo
{
// Keep resolving the template even after it has been soft-deleted, so past
// generations stay downloadable and keep their labels.
return $this->belongsTo(Template::class)->withTrashed();
}
/**
* @return BelongsTo<User, $this>
*/
public function requester(): BelongsTo
{
return $this->belongsTo(User::class, 'requested_by');
}
/**
* A human-friendly name for this generation, used in the admin panel and as the
* downloaded file name. Falls back to the template title plus a short id.
*/
public function displayName(): string
{
$base = $this->title ?: $this->template?->title ?: 'presentation';
return trim($base).' - '.$this->uuid;
}
/**
* Sanitized file name (without extension) for downloads. Keeps Persian characters
* but drops anything a filesystem or HTTP header would choke on.
*/
public function downloadFilename(): string
{
$base = $this->title ?: $this->template?->title ?: $this->template?->slug ?: 'presentation';
$clean = preg_replace('/[\/\\\\?%*:|"<>\r\n]+/u', ' ', (string) $base) ?? '';
$clean = trim(preg_replace('/\s+/u', ' ', $clean) ?? '');
return ($clean !== '' ? $clean : 'presentation').' - '.substr($this->uuid, 0, 8);
}
/**
* @return array<string, string>
*/
protected function casts(): array
{
return [
'source' => TemplateGenerationSource::class,
'status' => TemplateGenerationStatus::class,
'values' => 'array',
'slide_count' => 'integer',
];
}
}

View File

@@ -0,0 +1,55 @@
<?php
namespace App\Models;
use App\Enums\TemplateKeyType;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable([
'template_id',
'key',
'label',
'type',
'shape_name',
'is_required',
'is_repeatable',
'sort_order',
'description',
])]
class TemplateKey extends Model
{
/**
* @return BelongsTo<Template, $this>
*/
public function template(): BelongsTo
{
return $this->belongsTo(Template::class);
}
/**
* Strip any literal curly braces the admin may have copied from the pptx placeholder
* (e.g. "{Main Title}" -> "Main Title"), since braces are added automatically at match time.
*/
protected function key(): Attribute
{
return Attribute::make(
set: fn (string $value) => trim($value, "{} \t\n\r\0\x0B"),
);
}
/**
* @return array<string, string>
*/
protected function casts(): array
{
return [
'type' => TemplateKeyType::class,
'is_required' => 'boolean',
'is_repeatable' => 'boolean',
'sort_order' => 'integer',
];
}
}

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