Compare commits
No commits in common. "main" and "feature/49-onboarding-service-broken" have entirely different histories.
main
...
feature/49
376 changed files with 22629 additions and 11987 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -23,6 +23,5 @@ yarn-error.log
|
|||
/.nova
|
||||
/.vscode
|
||||
/.zed
|
||||
/coverage-report*
|
||||
/coverage.xml
|
||||
/backend/coverage-report*
|
||||
/.claude
|
||||
|
|
|
|||
127
Dockerfile
127
Dockerfile
|
|
@ -1,127 +0,0 @@
|
|||
# Production Dockerfile with FrankenPHP
|
||||
FROM dunglas/frankenphp:latest-php8.3-alpine
|
||||
|
||||
# Install system dependencies
|
||||
RUN apk add --no-cache \
|
||||
nodejs \
|
||||
npm \
|
||||
git \
|
||||
mysql-client
|
||||
|
||||
# Install PHP extensions
|
||||
RUN install-php-extensions \
|
||||
pdo_mysql \
|
||||
opcache \
|
||||
zip \
|
||||
gd \
|
||||
intl \
|
||||
bcmath \
|
||||
redis \
|
||||
pcntl
|
||||
|
||||
# Install Composer
|
||||
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Set fixed production environment variables
|
||||
ENV APP_ENV=production \
|
||||
APP_DEBUG=false \
|
||||
DB_CONNECTION=mysql \
|
||||
DB_HOST=db \
|
||||
DB_PORT=3306 \
|
||||
SESSION_DRIVER=redis \
|
||||
CACHE_STORE=redis \
|
||||
QUEUE_CONNECTION=redis \
|
||||
LOG_CHANNEL=stack \
|
||||
LOG_LEVEL=error
|
||||
|
||||
# Copy application code first
|
||||
COPY . .
|
||||
|
||||
# Install PHP dependencies (production only)
|
||||
RUN composer install --no-dev --no-interaction --optimize-autoloader
|
||||
|
||||
# Install ALL Node dependencies (including dev for building)
|
||||
RUN npm ci
|
||||
|
||||
# Build frontend assets
|
||||
RUN npm run build
|
||||
|
||||
# Remove node_modules after build to save space
|
||||
RUN rm -rf node_modules
|
||||
|
||||
# Laravel optimizations
|
||||
RUN php artisan config:cache \
|
||||
&& php artisan route:cache \
|
||||
&& php artisan view:cache \
|
||||
&& composer dump-autoload --optimize
|
||||
|
||||
# Set permissions
|
||||
RUN chown -R www-data:www-data /app/storage /app/bootstrap/cache
|
||||
|
||||
# Configure Caddy
|
||||
RUN cat > /etc/caddy/Caddyfile <<EOF
|
||||
{
|
||||
frankenphp
|
||||
order php_server before file_server
|
||||
}
|
||||
|
||||
:8000 {
|
||||
root * /app/public
|
||||
|
||||
php_server {
|
||||
index index.php
|
||||
}
|
||||
|
||||
encode gzip
|
||||
|
||||
file_server
|
||||
|
||||
header {
|
||||
X-Frame-Options "SAMEORIGIN"
|
||||
X-Content-Type-Options "nosniff"
|
||||
X-XSS-Protection "1; mode=block"
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8000
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost:8000/up || exit 1
|
||||
|
||||
# Create startup script for production
|
||||
RUN cat > /start-prod.sh <<'EOF'
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# Wait for database to be ready
|
||||
echo "Waiting for database..."
|
||||
for i in $(seq 1 30); do
|
||||
if mysqladmin ping -h "$DB_HOST" -u "$DB_USERNAME" -p"$DB_PASSWORD" --silent 2>/dev/null; then
|
||||
echo "Database is ready!"
|
||||
break
|
||||
fi
|
||||
echo "Waiting for database... ($i/30)"
|
||||
sleep 2
|
||||
done
|
||||
|
||||
# Run migrations
|
||||
echo "Running migrations..."
|
||||
php artisan migrate --force || echo "Migrations failed or already up-to-date"
|
||||
|
||||
# Start Horizon in the background
|
||||
php artisan horizon &
|
||||
|
||||
# Start FrankenPHP
|
||||
exec frankenphp run --config /etc/caddy/Caddyfile
|
||||
EOF
|
||||
|
||||
RUN chmod +x /start-prod.sh
|
||||
|
||||
# Start with our script
|
||||
CMD ["/start-prod.sh"]
|
||||
127
Dockerfile.dev
127
Dockerfile.dev
|
|
@ -1,127 +0,0 @@
|
|||
# Development Dockerfile with FrankenPHP
|
||||
FROM dunglas/frankenphp:latest-php8.3-alpine
|
||||
|
||||
# Install system dependencies + development tools
|
||||
RUN apk add --no-cache \
|
||||
nodejs \
|
||||
npm \
|
||||
git \
|
||||
mysql-client \
|
||||
vim \
|
||||
bash \
|
||||
nano
|
||||
|
||||
# Install PHP extensions including xdebug for development
|
||||
RUN install-php-extensions \
|
||||
pdo_mysql \
|
||||
opcache \
|
||||
zip \
|
||||
gd \
|
||||
intl \
|
||||
bcmath \
|
||||
redis \
|
||||
pcntl \
|
||||
xdebug
|
||||
|
||||
# Install Composer
|
||||
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Configure PHP for development
|
||||
RUN mv "$PHP_INI_DIR/php.ini-development" "$PHP_INI_DIR/php.ini"
|
||||
|
||||
# Configure Xdebug (disabled by default to reduce noise)
|
||||
RUN echo "xdebug.mode=off" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini \
|
||||
&& echo ";xdebug.mode=debug" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini \
|
||||
&& echo ";xdebug.client_host=host.docker.internal" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini \
|
||||
&& echo ";xdebug.start_with_request=yes" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini
|
||||
|
||||
# Configure Caddy for development (simpler, no worker mode)
|
||||
RUN cat > /etc/caddy/Caddyfile <<EOF
|
||||
{
|
||||
frankenphp
|
||||
order php_server before file_server
|
||||
}
|
||||
|
||||
:8000 {
|
||||
root * /app/public
|
||||
|
||||
php_server {
|
||||
index index.php
|
||||
}
|
||||
|
||||
encode gzip
|
||||
|
||||
file_server
|
||||
|
||||
# Less strict headers for development
|
||||
header {
|
||||
X-Frame-Options "SAMEORIGIN"
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
# Install Node development dependencies globally
|
||||
RUN npm install -g nodemon
|
||||
|
||||
# Create startup script for development
|
||||
RUN cat > /start.sh <<'EOF'
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# Create .env file if it doesn't exist
|
||||
if [ ! -f ".env" ]; then
|
||||
echo "Creating .env file from .env.example..."
|
||||
cp .env.example .env
|
||||
fi
|
||||
|
||||
# Install dependencies if volumes are empty
|
||||
if [ ! -f "vendor/autoload.php" ]; then
|
||||
echo "Installing composer dependencies..."
|
||||
composer install
|
||||
fi
|
||||
|
||||
# Always reinstall node_modules in container to get correct native binaries for Alpine/musl
|
||||
echo "Installing npm dependencies..."
|
||||
rm -rf node_modules 2>/dev/null || true
|
||||
rm -rf /app/.npm 2>/dev/null || true
|
||||
npm install --cache /tmp/.npm
|
||||
|
||||
# Clear Laravel caches
|
||||
php artisan config:clear || true
|
||||
php artisan cache:clear || true
|
||||
|
||||
# Wait for database and run migrations
|
||||
echo "Waiting for database..."
|
||||
sleep 5
|
||||
php artisan migrate --force || echo "Migration failed or not needed"
|
||||
|
||||
# Run seeders
|
||||
echo "Running seeders..."
|
||||
php artisan db:seed --force || echo "Seeding skipped or already done"
|
||||
|
||||
# Generate app key if not set
|
||||
if [ -z "$APP_KEY" ] || [ "$APP_KEY" = "base64:YOUR_KEY_HERE" ]; then
|
||||
echo "Generating application key..."
|
||||
php artisan key:generate
|
||||
fi
|
||||
|
||||
# Start Vite dev server in background
|
||||
npm run dev &
|
||||
|
||||
# Start Horizon (queue worker) in background
|
||||
php artisan horizon &
|
||||
|
||||
# Start FrankenPHP
|
||||
exec frankenphp run --config /etc/caddy/Caddyfile
|
||||
EOF
|
||||
|
||||
RUN chmod +x /start.sh
|
||||
|
||||
# Expose ports
|
||||
EXPOSE 8000 5173
|
||||
|
||||
# Use the startup script
|
||||
CMD ["/start.sh"]
|
||||
255
README.md
255
README.md
|
|
@ -1,128 +1,205 @@
|
|||
# FFR (Feed to Fediverse Router)
|
||||
# Fedi Feed Router
|
||||
|
||||
A Laravel-based application for routing RSS/Atom feeds to Fediverse platforms like Lemmy. Built with Laravel, Livewire, and FrankenPHP for a modern, single-container deployment.
|
||||
<div align="center">
|
||||
<img src="backend/public/images/ffr-logo-600.png" alt="FFR Logo" width="200">
|
||||
</div>
|
||||
|
||||
`ffr` is a self-hosted tool for routing content from RSS/Atom feeds to the fediverse.
|
||||
|
||||
It watches feeds, matches entries based on keywords or rules, and publishes them to platforms like Lemmy, Mastodon, or anything ActivityPub-compatible.
|
||||
|
||||
## Features
|
||||
|
||||
- **Feed aggregation** - Fetch articles from multiple RSS/Atom feeds
|
||||
- **Fediverse publishing** - Automatically post to Lemmy communities
|
||||
- **Route configuration** - Map feeds to specific channels with keywords
|
||||
- **Approval workflow** - Optional manual approval before publishing
|
||||
- **Queue processing** - Background job handling with Laravel Horizon
|
||||
- **Single container deployment** - Simplified hosting with FrankenPHP
|
||||
- Keyword-based routing from any RSS/Atom feed
|
||||
- Publish to Lemmy, Mastodon, or other fediverse services
|
||||
- YAML or JSON route configs
|
||||
- CLI and/or daemon mode
|
||||
- Self-hosted, privacy-first, no SaaS dependencies
|
||||
|
||||
## Self-hosting
|
||||
|
||||
The production image is available at `codeberg.org/lvl0/ffr:latest`.
|
||||
## Docker Deployment
|
||||
|
||||
### docker-compose.yml
|
||||
### Building the Image
|
||||
|
||||
```bash
|
||||
docker build -t your-registry/lemmy-poster:latest .
|
||||
docker push your-registry/lemmy-poster:latest
|
||||
```
|
||||
|
||||
### Docker Compose
|
||||
|
||||
Create a `docker-compose.yml` file:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
app:
|
||||
image: codeberg.org/lvl0/ffr:latest
|
||||
container_name: ffr_app
|
||||
restart: always
|
||||
app-web:
|
||||
image: your-registry/lemmy-poster:latest
|
||||
command: ["web"]
|
||||
ports:
|
||||
- "8000:8000"
|
||||
environment:
|
||||
APP_KEY: "${APP_KEY}"
|
||||
APP_URL: "${APP_URL}"
|
||||
DB_DATABASE: "${DB_DATABASE}"
|
||||
DB_USERNAME: "${DB_USERNAME}"
|
||||
DB_PASSWORD: "${DB_PASSWORD}"
|
||||
REDIS_HOST: redis
|
||||
REDIS_PORT: 6379
|
||||
volumes:
|
||||
- app_storage:/app/storage
|
||||
- DB_DATABASE=${DB_DATABASE}
|
||||
- DB_USERNAME=${DB_USERNAME}
|
||||
- DB_PASSWORD=${DB_PASSWORD}
|
||||
- LEMMY_INSTANCE=${LEMMY_INSTANCE}
|
||||
- LEMMY_USERNAME=${LEMMY_USERNAME}
|
||||
- LEMMY_PASSWORD=${LEMMY_PASSWORD}
|
||||
- LEMMY_COMMUNITY=${LEMMY_COMMUNITY}
|
||||
depends_on:
|
||||
- db
|
||||
- redis
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8000/up"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
- mysql
|
||||
volumes:
|
||||
- storage_data:/var/www/html/storage/app
|
||||
restart: unless-stopped
|
||||
|
||||
db:
|
||||
image: mariadb:11
|
||||
container_name: ffr_db
|
||||
restart: always
|
||||
app-queue:
|
||||
image: your-registry/lemmy-poster:latest
|
||||
command: ["queue"]
|
||||
environment:
|
||||
MYSQL_DATABASE: "${DB_DATABASE}"
|
||||
MYSQL_USER: "${DB_USERNAME}"
|
||||
MYSQL_PASSWORD: "${DB_PASSWORD}"
|
||||
MYSQL_ROOT_PASSWORD: "${DB_ROOT_PASSWORD}"
|
||||
- DB_DATABASE=${DB_DATABASE}
|
||||
- DB_USERNAME=${DB_USERNAME}
|
||||
- DB_PASSWORD=${DB_PASSWORD}
|
||||
- LEMMY_INSTANCE=${LEMMY_INSTANCE}
|
||||
- LEMMY_USERNAME=${LEMMY_USERNAME}
|
||||
- LEMMY_PASSWORD=${LEMMY_PASSWORD}
|
||||
- LEMMY_COMMUNITY=${LEMMY_COMMUNITY}
|
||||
depends_on:
|
||||
- mysql
|
||||
volumes:
|
||||
- db_data:/var/lib/mysql
|
||||
- storage_data:/var/www/html/storage/app
|
||||
restart: unless-stopped
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: ffr_redis
|
||||
restart: always
|
||||
mysql:
|
||||
image: mysql:8.0
|
||||
command: --host-cache-size=0 --innodb-use-native-aio=0 --sql-mode=STRICT_TRANS_TABLES,NO_ZERO_DATE,NO_ZERO_IN_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_AUTO_CREATE_USER,NO_ENGINE_SUBSTITUTION --log-error-verbosity=1
|
||||
environment:
|
||||
- MYSQL_DATABASE=${DB_DATABASE}
|
||||
- MYSQL_USER=${DB_USERNAME}
|
||||
- MYSQL_PASSWORD=${DB_PASSWORD}
|
||||
- MYSQL_ROOT_PASSWORD=${DB_PASSWORD}
|
||||
- TZ=UTC
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
- mysql_data:/var/lib/mysql
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
db_data:
|
||||
redis_data:
|
||||
app_storage:
|
||||
mysql_data:
|
||||
storage_data:
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Required | Description |
|
||||
|----------|----------|-------------|
|
||||
| `APP_KEY` | Yes | Encryption key. Generate with: `echo "base64:$(openssl rand -base64 32)"` |
|
||||
| `APP_URL` | Yes | Your domain (e.g., `https://ffr.example.com`) |
|
||||
| `DB_DATABASE` | Yes | Database name |
|
||||
| `DB_USERNAME` | Yes | Database user |
|
||||
| `DB_PASSWORD` | Yes | Database password |
|
||||
| `DB_ROOT_PASSWORD` | Yes | MariaDB root password |
|
||||
Create a `.env` file with:
|
||||
|
||||
## Development
|
||||
```env
|
||||
# Database Settings
|
||||
DB_DATABASE=lemmy_poster
|
||||
DB_USERNAME=lemmy_user
|
||||
DB_PASSWORD=your-password
|
||||
|
||||
### NixOS / Nix
|
||||
|
||||
```bash
|
||||
git clone https://codeberg.org/lvl0/ffr.git
|
||||
cd ffr
|
||||
nix-shell
|
||||
# Lemmy Settings
|
||||
LEMMY_INSTANCE=your-lemmy-instance.com
|
||||
LEMMY_USERNAME=your-lemmy-username
|
||||
LEMMY_PASSWORD=your-lemmy-password
|
||||
LEMMY_COMMUNITY=your-target-community
|
||||
```
|
||||
|
||||
The shell will display available commands and optionally start the containers for you.
|
||||
### Deployment
|
||||
|
||||
#### Available Commands
|
||||
1. Build and push the image to your registry
|
||||
2. Copy the docker-compose.yml to your server
|
||||
3. Create the .env file with your environment variables
|
||||
4. Run: `docker compose up -d`
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `dev-up` | Start development environment |
|
||||
| `dev-down` | Stop development environment |
|
||||
| `dev-restart` | Restart containers |
|
||||
| `dev-logs` | Follow app logs |
|
||||
| `dev-logs-db` | Follow database logs |
|
||||
| `dev-shell` | Enter app container |
|
||||
| `dev-artisan <cmd>` | Run artisan commands |
|
||||
| `prod-build [tag]` | Build and push prod image (default: latest) |
|
||||
The application will automatically:
|
||||
- Wait for the database to be ready
|
||||
- Run database migrations on first startup
|
||||
- Start the queue worker after migrations complete
|
||||
- Handle race conditions between web and queue containers
|
||||
|
||||
#### Services
|
||||
### Initial Setup
|
||||
|
||||
| Service | URL |
|
||||
|---------|-----|
|
||||
| App | http://localhost:8000 |
|
||||
| Vite | http://localhost:5173 |
|
||||
| MariaDB | localhost:3307 |
|
||||
| Redis | localhost:6380 |
|
||||
After deployment, the article refresh will run every hour. To trigger the initial article fetch manually:
|
||||
|
||||
### Other Platforms
|
||||
```bash
|
||||
docker compose exec app-web php artisan article:refresh
|
||||
```
|
||||
|
||||
Contributions welcome for development setup instructions on other platforms.
|
||||
The application will then automatically:
|
||||
- Fetch new articles every hour
|
||||
- Publish valid articles every 5 minutes
|
||||
- Sync community posts every 10 minutes
|
||||
|
||||
## License
|
||||
The web interface will be available on port 8000.
|
||||
|
||||
This project is open-source software licensed under the [AGPL-3.0 license](LICENSE).
|
||||
### Architecture
|
||||
|
||||
## Support
|
||||
The application uses a multi-container setup:
|
||||
- **app-web**: Serves the Laravel web interface and handles HTTP requests
|
||||
- **app-queue**: Processes background jobs (article fetching, Lemmy posting)
|
||||
- **mysql**: Database storage for articles, logs, and application data
|
||||
|
||||
For issues and questions, please use [Codeberg Issues](https://codeberg.org/lvl0/ffr/issues).
|
||||
Both app containers use the same Docker image but with different commands (`web` or `queue`). Environment variables are passed from your `.env` file to configure database access and Lemmy integration.
|
||||
|
||||
## Development Setup
|
||||
|
||||
For local development with Podman:
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Podman and podman-compose installed
|
||||
- Git
|
||||
|
||||
### Quick Start
|
||||
|
||||
1. **Clone and start the development environment:**
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd ffr
|
||||
./docker/dev/podman/start-dev.sh
|
||||
```
|
||||
|
||||
2. **Access the application:**
|
||||
- **Web interface**: http://localhost:8000
|
||||
- **Vite dev server**: http://localhost:5173
|
||||
- **Database**: localhost:3307
|
||||
- **Redis**: localhost:6380
|
||||
|
||||
### Development Commands
|
||||
|
||||
**Load Sail-compatible aliases:**
|
||||
```bash
|
||||
source docker/dev/podman/podman-sail-alias.sh
|
||||
```
|
||||
|
||||
**Useful commands:**
|
||||
```bash
|
||||
# Run tests
|
||||
ffr-test
|
||||
|
||||
# Execute artisan commands
|
||||
ffr-artisan migrate
|
||||
ffr-artisan tinker
|
||||
|
||||
# View application logs
|
||||
ffr-logs
|
||||
|
||||
# Open container shell
|
||||
ffr-shell
|
||||
|
||||
# Stop environment
|
||||
podman-compose -f docker/dev/podman/docker-compose.yml down
|
||||
```
|
||||
|
||||
Run tests:
|
||||
```sh
|
||||
podman-compose -f docker/dev/podman/docker-compose.yml exec app bash -c "cd backend && XDEBUG_MODE=coverage php artisan test --coverage-html=coverage-report"
|
||||
```
|
||||
|
||||
|
||||
### Development Features
|
||||
|
||||
- **Hot reload**: Vite automatically reloads frontend changes
|
||||
- **Database**: Pre-configured MySQL with migrations and seeders
|
||||
- **Redis**: Configured for caching, sessions, and queues
|
||||
- **Laravel Horizon**: Available for queue monitoring
|
||||
- **No configuration needed**: Development environment uses preset configuration
|
||||
|
|
|
|||
|
|
@ -1,13 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Facades;
|
||||
|
||||
use Illuminate\Support\Facades\Facade;
|
||||
|
||||
class LogSaver extends Facade
|
||||
{
|
||||
protected static function getFacadeAccessor()
|
||||
{
|
||||
return \App\Services\Log\LogSaver::class;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,143 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Models\Feed;
|
||||
use App\Models\Keyword;
|
||||
use App\Models\PlatformChannel;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class KeywordsController extends BaseController
|
||||
{
|
||||
/**
|
||||
* Display keywords for a specific route
|
||||
*/
|
||||
public function index(Feed $feed, PlatformChannel $channel): JsonResponse
|
||||
{
|
||||
$keywords = Keyword::where('feed_id', $feed->id)
|
||||
->where('platform_channel_id', $channel->id)
|
||||
->orderBy('keyword')
|
||||
->get();
|
||||
|
||||
return $this->sendResponse(
|
||||
$keywords->toArray(),
|
||||
'Keywords retrieved successfully.'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a new keyword for a route
|
||||
*/
|
||||
public function store(Request $request, Feed $feed, PlatformChannel $channel): JsonResponse
|
||||
{
|
||||
try {
|
||||
$validated = $request->validate([
|
||||
'keyword' => 'required|string|max:255',
|
||||
'is_active' => 'boolean',
|
||||
]);
|
||||
|
||||
$validated['feed_id'] = $feed->id;
|
||||
$validated['platform_channel_id'] = $channel->id;
|
||||
$validated['is_active'] = $validated['is_active'] ?? true;
|
||||
|
||||
// Check if keyword already exists for this route
|
||||
$existingKeyword = Keyword::where('feed_id', $feed->id)
|
||||
->where('platform_channel_id', $channel->id)
|
||||
->where('keyword', $validated['keyword'])
|
||||
->first();
|
||||
|
||||
if ($existingKeyword) {
|
||||
return $this->sendError('Keyword already exists for this route.', [], 409);
|
||||
}
|
||||
|
||||
$keyword = Keyword::create($validated);
|
||||
|
||||
return $this->sendResponse(
|
||||
$keyword->toArray(),
|
||||
'Keyword created successfully!',
|
||||
201
|
||||
);
|
||||
} catch (ValidationException $e) {
|
||||
return $this->sendValidationError($e->errors());
|
||||
} catch (\Exception $e) {
|
||||
return $this->sendError('Failed to create keyword: ' . $e->getMessage(), [], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a keyword's status
|
||||
*/
|
||||
public function update(Request $request, Feed $feed, PlatformChannel $channel, Keyword $keyword): JsonResponse
|
||||
{
|
||||
try {
|
||||
// Verify the keyword belongs to this route
|
||||
if ($keyword->feed_id !== $feed->id || $keyword->platform_channel_id !== $channel->id) {
|
||||
return $this->sendNotFound('Keyword not found for this route.');
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'is_active' => 'boolean',
|
||||
]);
|
||||
|
||||
$keyword->update($validated);
|
||||
|
||||
return $this->sendResponse(
|
||||
$keyword->fresh()->toArray(),
|
||||
'Keyword updated successfully!'
|
||||
);
|
||||
} catch (ValidationException $e) {
|
||||
return $this->sendValidationError($e->errors());
|
||||
} catch (\Exception $e) {
|
||||
return $this->sendError('Failed to update keyword: ' . $e->getMessage(), [], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a keyword from a route
|
||||
*/
|
||||
public function destroy(Feed $feed, PlatformChannel $channel, Keyword $keyword): JsonResponse
|
||||
{
|
||||
try {
|
||||
// Verify the keyword belongs to this route
|
||||
if ($keyword->feed_id !== $feed->id || $keyword->platform_channel_id !== $channel->id) {
|
||||
return $this->sendNotFound('Keyword not found for this route.');
|
||||
}
|
||||
|
||||
$keyword->delete();
|
||||
|
||||
return $this->sendResponse(
|
||||
null,
|
||||
'Keyword deleted successfully!'
|
||||
);
|
||||
} catch (\Exception $e) {
|
||||
return $this->sendError('Failed to delete keyword: ' . $e->getMessage(), [], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle keyword active status
|
||||
*/
|
||||
public function toggle(Feed $feed, PlatformChannel $channel, Keyword $keyword): JsonResponse
|
||||
{
|
||||
try {
|
||||
// Verify the keyword belongs to this route
|
||||
if ($keyword->feed_id !== $feed->id || $keyword->platform_channel_id !== $channel->id) {
|
||||
return $this->sendNotFound('Keyword not found for this route.');
|
||||
}
|
||||
|
||||
$newStatus = !$keyword->is_active;
|
||||
$keyword->update(['is_active' => $newStatus]);
|
||||
|
||||
$status = $newStatus ? 'activated' : 'deactivated';
|
||||
|
||||
return $this->sendResponse(
|
||||
$keyword->fresh()->toArray(),
|
||||
"Keyword {$status} successfully!"
|
||||
);
|
||||
} catch (\Exception $e) {
|
||||
return $this->sendError('Failed to toggle keyword status: ' . $e->getMessage(), [], 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Auth\LoginRequest;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class AuthenticatedSessionController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display the login view.
|
||||
*/
|
||||
public function create(): View
|
||||
{
|
||||
return view('auth.login');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming authentication request.
|
||||
*/
|
||||
public function store(LoginRequest $request): RedirectResponse
|
||||
{
|
||||
$request->authenticate();
|
||||
|
||||
$request->session()->regenerate();
|
||||
|
||||
return redirect()->intended(route('dashboard', absolute: false));
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy an authenticated session.
|
||||
*/
|
||||
public function destroy(Request $request): RedirectResponse
|
||||
{
|
||||
Auth::guard('web')->logout();
|
||||
|
||||
$request->session()->invalidate();
|
||||
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return redirect('/');
|
||||
}
|
||||
}
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class ConfirmablePasswordController extends Controller
|
||||
{
|
||||
/**
|
||||
* Show the confirm password view.
|
||||
*/
|
||||
public function show(): View
|
||||
{
|
||||
return view('auth.confirm-password');
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm the user's password.
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
if (! Auth::guard('web')->validate([
|
||||
'email' => $request->user()->email,
|
||||
'password' => $request->password,
|
||||
])) {
|
||||
throw ValidationException::withMessages([
|
||||
'password' => __('auth.password'),
|
||||
]);
|
||||
}
|
||||
|
||||
$request->session()->put('auth.password_confirmed_at', time());
|
||||
|
||||
return redirect()->intended(route('dashboard', absolute: false));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class EmailVerificationNotificationController extends Controller
|
||||
{
|
||||
/**
|
||||
* Send a new email verification notification.
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
if ($request->user()->hasVerifiedEmail()) {
|
||||
return redirect()->intended(route('dashboard', absolute: false));
|
||||
}
|
||||
|
||||
$request->user()->sendEmailVerificationNotification();
|
||||
|
||||
return back()->with('status', 'verification-link-sent');
|
||||
}
|
||||
}
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class EmailVerificationPromptController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display the email verification prompt.
|
||||
*/
|
||||
public function __invoke(Request $request): RedirectResponse|View
|
||||
{
|
||||
return $request->user()->hasVerifiedEmail()
|
||||
? redirect()->intended(route('dashboard', absolute: false))
|
||||
: view('auth.verify-email');
|
||||
}
|
||||
}
|
||||
|
|
@ -1,62 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Events\PasswordReset;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Password;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rules;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class NewPasswordController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display the password reset view.
|
||||
*/
|
||||
public function create(Request $request): View
|
||||
{
|
||||
return view('auth.reset-password', ['request' => $request]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming new password request.
|
||||
*
|
||||
* @throws \Illuminate\Validation\ValidationException
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'token' => ['required'],
|
||||
'email' => ['required', 'email'],
|
||||
'password' => ['required', 'confirmed', Rules\Password::defaults()],
|
||||
]);
|
||||
|
||||
// Here we will attempt to reset the user's password. If it is successful we
|
||||
// will update the password on an actual user model and persist it to the
|
||||
// database. Otherwise we will parse the error and return the response.
|
||||
$status = Password::reset(
|
||||
$request->only('email', 'password', 'password_confirmation', 'token'),
|
||||
function (User $user) use ($request) {
|
||||
$user->forceFill([
|
||||
'password' => Hash::make($request->password),
|
||||
'remember_token' => Str::random(60),
|
||||
])->save();
|
||||
|
||||
event(new PasswordReset($user));
|
||||
}
|
||||
);
|
||||
|
||||
// If the password was successfully reset, we will redirect the user back to
|
||||
// the application's home authenticated view. If there is an error we can
|
||||
// redirect them back to where they came from with their error message.
|
||||
return $status == Password::PASSWORD_RESET
|
||||
? redirect()->route('login')->with('status', __($status))
|
||||
: back()->withInput($request->only('email'))
|
||||
->withErrors(['email' => __($status)]);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
|
||||
class PasswordController extends Controller
|
||||
{
|
||||
/**
|
||||
* Update the user's password.
|
||||
*/
|
||||
public function update(Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validateWithBag('updatePassword', [
|
||||
'current_password' => ['required', 'current_password'],
|
||||
'password' => ['required', Password::defaults(), 'confirmed'],
|
||||
]);
|
||||
|
||||
$request->user()->update([
|
||||
'password' => Hash::make($validated['password']),
|
||||
]);
|
||||
|
||||
return back()->with('status', 'password-updated');
|
||||
}
|
||||
}
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Password;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class PasswordResetLinkController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display the password reset link request view.
|
||||
*/
|
||||
public function create(): View
|
||||
{
|
||||
return view('auth.forgot-password');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming password reset link request.
|
||||
*
|
||||
* @throws \Illuminate\Validation\ValidationException
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'email' => ['required', 'email'],
|
||||
]);
|
||||
|
||||
// We will send the password reset link to this user. Once we have attempted
|
||||
// to send the link, we will examine the response then see the message we
|
||||
// need to show to the user. Finally, we'll send out a proper response.
|
||||
$status = Password::sendResetLink(
|
||||
$request->only('email')
|
||||
);
|
||||
|
||||
return $status == Password::RESET_LINK_SENT
|
||||
? back()->with('status', __($status))
|
||||
: back()->withInput($request->only('email'))
|
||||
->withErrors(['email' => __($status)]);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Events\Registered;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Validation\Rules;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class RegisteredUserController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display the registration view.
|
||||
*/
|
||||
public function create(): View
|
||||
{
|
||||
return view('auth.register');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming registration request.
|
||||
*
|
||||
* @throws \Illuminate\Validation\ValidationException
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:'.User::class],
|
||||
'password' => ['required', 'confirmed', Rules\Password::defaults()],
|
||||
]);
|
||||
|
||||
$user = User::create([
|
||||
'name' => $request->name,
|
||||
'email' => $request->email,
|
||||
'password' => Hash::make($request->password),
|
||||
]);
|
||||
|
||||
event(new Registered($user));
|
||||
|
||||
Auth::login($user);
|
||||
|
||||
return redirect(route('dashboard', absolute: false));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Auth\Events\Verified;
|
||||
use Illuminate\Foundation\Auth\EmailVerificationRequest;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
|
||||
class VerifyEmailController extends Controller
|
||||
{
|
||||
/**
|
||||
* Mark the authenticated user's email address as verified.
|
||||
*/
|
||||
public function __invoke(EmailVerificationRequest $request): RedirectResponse
|
||||
{
|
||||
if ($request->user()->hasVerifiedEmail()) {
|
||||
return redirect()->intended(route('dashboard', absolute: false).'?verified=1');
|
||||
}
|
||||
|
||||
if ($request->user()->markEmailAsVerified()) {
|
||||
event(new Verified($request->user()));
|
||||
}
|
||||
|
||||
return redirect()->intended(route('dashboard', absolute: false).'?verified=1');
|
||||
}
|
||||
}
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\ProfileUpdateRequest;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Redirect;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class ProfileController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display the user's profile form.
|
||||
*/
|
||||
public function edit(Request $request): View
|
||||
{
|
||||
return view('profile.edit', [
|
||||
'user' => $request->user(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the user's profile information.
|
||||
*/
|
||||
public function update(ProfileUpdateRequest $request): RedirectResponse
|
||||
{
|
||||
$request->user()->fill($request->validated());
|
||||
|
||||
if ($request->user()->isDirty('email')) {
|
||||
$request->user()->email_verified_at = null;
|
||||
}
|
||||
|
||||
$request->user()->save();
|
||||
|
||||
return Redirect::route('profile.edit')->with('status', 'profile-updated');
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the user's account.
|
||||
*/
|
||||
public function destroy(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validateWithBag('userDeletion', [
|
||||
'password' => ['required', 'current_password'],
|
||||
]);
|
||||
|
||||
$user = $request->user();
|
||||
|
||||
Auth::logout();
|
||||
|
||||
$user->delete();
|
||||
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return Redirect::to('/');
|
||||
}
|
||||
}
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Services\OnboardingService;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EnsureOnboardingComplete
|
||||
{
|
||||
public function __construct(
|
||||
private OnboardingService $onboardingService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* Redirect to onboarding if the user hasn't completed setup.
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if ($this->onboardingService->needsOnboarding()) {
|
||||
return redirect()->route('onboarding');
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Services\OnboardingService;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class RedirectIfOnboardingComplete
|
||||
{
|
||||
public function __construct(
|
||||
private OnboardingService $onboardingService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* Redirect to dashboard if onboarding is already complete.
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (!$this->onboardingService->needsOnboarding()) {
|
||||
return redirect()->route('dashboard');
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,85 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests\Auth;
|
||||
|
||||
use Illuminate\Auth\Events\Lockout;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class LoginRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'email' => ['required', 'string', 'email'],
|
||||
'password' => ['required', 'string'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to authenticate the request's credentials.
|
||||
*
|
||||
* @throws \Illuminate\Validation\ValidationException
|
||||
*/
|
||||
public function authenticate(): void
|
||||
{
|
||||
$this->ensureIsNotRateLimited();
|
||||
|
||||
if (! Auth::attempt($this->only('email', 'password'), $this->boolean('remember'))) {
|
||||
RateLimiter::hit($this->throttleKey());
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'email' => trans('auth.failed'),
|
||||
]);
|
||||
}
|
||||
|
||||
RateLimiter::clear($this->throttleKey());
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the login request is not rate limited.
|
||||
*
|
||||
* @throws \Illuminate\Validation\ValidationException
|
||||
*/
|
||||
public function ensureIsNotRateLimited(): void
|
||||
{
|
||||
if (! RateLimiter::tooManyAttempts($this->throttleKey(), 5)) {
|
||||
return;
|
||||
}
|
||||
|
||||
event(new Lockout($this));
|
||||
|
||||
$seconds = RateLimiter::availableIn($this->throttleKey());
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'email' => trans('auth.throttle', [
|
||||
'seconds' => $seconds,
|
||||
'minutes' => ceil($seconds / 60),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the rate limiting throttle key for the request.
|
||||
*/
|
||||
public function throttleKey(): string
|
||||
{
|
||||
return Str::transliterate(Str::lower($this->string('email')).'|'.$this->ip());
|
||||
}
|
||||
}
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class ProfileUpdateRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => [
|
||||
'required',
|
||||
'string',
|
||||
'lowercase',
|
||||
'email',
|
||||
'max:255',
|
||||
Rule::unique(User::class)->ignore($this->user()->id),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -1,69 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Exceptions\PublishException;
|
||||
use App\Models\Article;
|
||||
use App\Services\Article\ArticleFetcher;
|
||||
use App\Services\Publishing\ArticlePublishingService;
|
||||
use Illuminate\Contracts\Queue\ShouldBeUnique;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
|
||||
class PublishNextArticleJob implements ShouldQueue, ShouldBeUnique
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
/**
|
||||
* The number of seconds after which the job's unique lock will be released.
|
||||
*/
|
||||
public int $uniqueFor = 300;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->onQueue('publishing');
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the job.
|
||||
* @throws PublishException
|
||||
*/
|
||||
public function handle(ArticleFetcher $articleFetcher, ArticlePublishingService $publishingService): void
|
||||
{
|
||||
// Get the oldest approved article that hasn't been published yet
|
||||
$article = Article::where('approval_status', 'approved')
|
||||
->whereDoesntHave('articlePublication')
|
||||
->oldest('created_at')
|
||||
->first();
|
||||
|
||||
if (! $article) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger()->info('Publishing next article from scheduled job', [
|
||||
'article_id' => $article->id,
|
||||
'title' => $article->title,
|
||||
'url' => $article->url,
|
||||
'created_at' => $article->created_at
|
||||
]);
|
||||
|
||||
// Fetch article data
|
||||
$extractedData = $articleFetcher->fetchArticleData($article);
|
||||
|
||||
try {
|
||||
$publishingService->publishToRoutedChannels($article, $extractedData);
|
||||
|
||||
logger()->info('Successfully published article', [
|
||||
'article_id' => $article->id,
|
||||
'title' => $article->title
|
||||
]);
|
||||
} catch (PublishException $e) {
|
||||
logger()->error('Failed to publish article', [
|
||||
'article_id' => $article->id,
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Listeners;
|
||||
|
||||
use App\Events\ExceptionLogged;
|
||||
use App\Events\ExceptionOccurred;
|
||||
use App\Models\Log;
|
||||
class LogExceptionToDatabase
|
||||
{
|
||||
|
||||
public function handle(ExceptionOccurred $event): void
|
||||
{
|
||||
// Truncate the message to prevent database errors
|
||||
$message = strlen($event->message) > 255
|
||||
? substr($event->message, 0, 252) . '...'
|
||||
: $event->message;
|
||||
|
||||
try {
|
||||
$log = Log::create([
|
||||
'level' => $event->level,
|
||||
'message' => $message,
|
||||
'context' => [
|
||||
'exception_class' => get_class($event->exception),
|
||||
'file' => $event->exception->getFile(),
|
||||
'line' => $event->exception->getLine(),
|
||||
'trace' => $event->exception->getTraceAsString(),
|
||||
...$event->context
|
||||
]
|
||||
]);
|
||||
|
||||
ExceptionLogged::dispatch($log);
|
||||
} catch (\Exception $e) {
|
||||
// Prevent infinite recursion by not logging this exception
|
||||
// Optionally log to file or other non-database destination
|
||||
error_log("Failed to log exception to database: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,61 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use App\Models\Article;
|
||||
use App\Models\Setting;
|
||||
use App\Jobs\ArticleDiscoveryJob;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithPagination;
|
||||
|
||||
class Articles extends Component
|
||||
{
|
||||
use WithPagination;
|
||||
|
||||
public bool $isRefreshing = false;
|
||||
|
||||
public function approve(int $articleId): void
|
||||
{
|
||||
$article = Article::findOrFail($articleId);
|
||||
$article->approve();
|
||||
|
||||
$this->dispatch('article-updated');
|
||||
}
|
||||
|
||||
public function reject(int $articleId): void
|
||||
{
|
||||
$article = Article::findOrFail($articleId);
|
||||
$article->reject();
|
||||
|
||||
$this->dispatch('article-updated');
|
||||
}
|
||||
|
||||
public function refresh(): void
|
||||
{
|
||||
$this->isRefreshing = true;
|
||||
|
||||
ArticleDiscoveryJob::dispatch();
|
||||
|
||||
// Reset after 10 seconds
|
||||
$this->dispatch('refresh-complete')->self();
|
||||
}
|
||||
|
||||
public function refreshComplete(): void
|
||||
{
|
||||
$this->isRefreshing = false;
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
$articles = Article::with(['feed', 'articlePublication'])
|
||||
->orderBy('created_at', 'desc')
|
||||
->paginate(15);
|
||||
|
||||
$approvalsEnabled = Setting::isPublishingApprovalsEnabled();
|
||||
|
||||
return view('livewire.articles', [
|
||||
'articles' => $articles,
|
||||
'approvalsEnabled' => $approvalsEnabled,
|
||||
])->layout('layouts.app');
|
||||
}
|
||||
}
|
||||
|
|
@ -1,73 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use App\Models\PlatformAccount;
|
||||
use App\Models\PlatformChannel;
|
||||
use Livewire\Component;
|
||||
|
||||
class Channels extends Component
|
||||
{
|
||||
public ?int $managingChannelId = null;
|
||||
|
||||
public function toggle(int $channelId): void
|
||||
{
|
||||
$channel = PlatformChannel::findOrFail($channelId);
|
||||
$channel->is_active = !$channel->is_active;
|
||||
$channel->save();
|
||||
}
|
||||
|
||||
public function openAccountModal(int $channelId): void
|
||||
{
|
||||
$this->managingChannelId = $channelId;
|
||||
}
|
||||
|
||||
public function closeAccountModal(): void
|
||||
{
|
||||
$this->managingChannelId = null;
|
||||
}
|
||||
|
||||
public function attachAccount(int $accountId): void
|
||||
{
|
||||
if (!$this->managingChannelId) {
|
||||
return;
|
||||
}
|
||||
|
||||
$channel = PlatformChannel::findOrFail($this->managingChannelId);
|
||||
|
||||
if (!$channel->platformAccounts()->where('platform_account_id', $accountId)->exists()) {
|
||||
$channel->platformAccounts()->attach($accountId, [
|
||||
'is_active' => true,
|
||||
'priority' => 1,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function detachAccount(int $channelId, int $accountId): void
|
||||
{
|
||||
$channel = PlatformChannel::findOrFail($channelId);
|
||||
$channel->platformAccounts()->detach($accountId);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
$channels = PlatformChannel::with(['platformInstance', 'platformAccounts'])->orderBy('name')->get();
|
||||
$allAccounts = PlatformAccount::where('is_active', true)->get();
|
||||
|
||||
$managingChannel = $this->managingChannelId
|
||||
? PlatformChannel::with('platformAccounts')->find($this->managingChannelId)
|
||||
: null;
|
||||
|
||||
$availableAccounts = $managingChannel
|
||||
? $allAccounts->filter(fn($account) => !$managingChannel->platformAccounts->contains('id', $account->id))
|
||||
: collect();
|
||||
|
||||
return view('livewire.channels', [
|
||||
'channels' => $channels,
|
||||
'managingChannel' => $managingChannel,
|
||||
'availableAccounts' => $availableAccounts,
|
||||
])->layout('layouts.app');
|
||||
}
|
||||
}
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use App\Services\DashboardStatsService;
|
||||
use Livewire\Component;
|
||||
|
||||
class Dashboard extends Component
|
||||
{
|
||||
public string $period = 'today';
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
// Default period
|
||||
}
|
||||
|
||||
public function setPeriod(string $period): void
|
||||
{
|
||||
$this->period = $period;
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
$service = app(DashboardStatsService::class);
|
||||
|
||||
$articleStats = $service->getStats($this->period);
|
||||
$systemStats = $service->getSystemStats();
|
||||
$availablePeriods = $service->getAvailablePeriods();
|
||||
|
||||
return view('livewire.dashboard', [
|
||||
'articleStats' => $articleStats,
|
||||
'systemStats' => $systemStats,
|
||||
'availablePeriods' => $availablePeriods,
|
||||
])->layout('layouts.app');
|
||||
}
|
||||
}
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use App\Models\Feed;
|
||||
use Livewire\Component;
|
||||
|
||||
class Feeds extends Component
|
||||
{
|
||||
public function toggle(int $feedId): void
|
||||
{
|
||||
$feed = Feed::findOrFail($feedId);
|
||||
$feed->is_active = !$feed->is_active;
|
||||
$feed->save();
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
$feeds = Feed::orderBy('name')->get();
|
||||
|
||||
return view('livewire.feeds', [
|
||||
'feeds' => $feeds,
|
||||
])->layout('layouts.app');
|
||||
}
|
||||
}
|
||||
|
|
@ -1,358 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use App\Jobs\ArticleDiscoveryJob;
|
||||
use App\Models\Feed;
|
||||
use App\Models\Language;
|
||||
use App\Models\PlatformAccount;
|
||||
use App\Models\PlatformChannel;
|
||||
use App\Models\PlatformInstance;
|
||||
use App\Models\Route;
|
||||
use App\Models\Setting;
|
||||
use App\Services\Auth\LemmyAuthService;
|
||||
use App\Services\OnboardingService;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Livewire\Component;
|
||||
|
||||
class Onboarding extends Component
|
||||
{
|
||||
// Step tracking (1-6: welcome, platform, feed, channel, route, complete)
|
||||
public int $step = 1;
|
||||
|
||||
// Platform form
|
||||
public string $instanceUrl = '';
|
||||
public string $username = '';
|
||||
public string $password = '';
|
||||
public ?array $existingAccount = null;
|
||||
|
||||
// Feed form
|
||||
public string $feedName = '';
|
||||
public string $feedProvider = 'vrt';
|
||||
public ?int $feedLanguageId = null;
|
||||
public string $feedDescription = '';
|
||||
|
||||
// Channel form
|
||||
public string $channelName = '';
|
||||
public ?int $platformInstanceId = null;
|
||||
public ?int $channelLanguageId = null;
|
||||
public string $channelDescription = '';
|
||||
|
||||
// Route form
|
||||
public ?int $routeFeedId = null;
|
||||
public ?int $routeChannelId = null;
|
||||
public int $routePriority = 50;
|
||||
|
||||
// State
|
||||
public array $formErrors = [];
|
||||
public bool $isLoading = false;
|
||||
|
||||
protected LemmyAuthService $lemmyAuthService;
|
||||
|
||||
public function boot(LemmyAuthService $lemmyAuthService): void
|
||||
{
|
||||
$this->lemmyAuthService = $lemmyAuthService;
|
||||
}
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
// Check for existing platform account
|
||||
$account = PlatformAccount::where('is_active', true)->first();
|
||||
if ($account) {
|
||||
$this->existingAccount = [
|
||||
'id' => $account->id,
|
||||
'username' => $account->username,
|
||||
'instance_url' => $account->instance_url,
|
||||
];
|
||||
}
|
||||
|
||||
// Pre-fill feed form if exists
|
||||
$feed = Feed::where('is_active', true)->first();
|
||||
if ($feed) {
|
||||
$this->feedName = $feed->name;
|
||||
$this->feedProvider = $feed->provider ?? 'vrt';
|
||||
$this->feedLanguageId = $feed->language_id;
|
||||
$this->feedDescription = $feed->description ?? '';
|
||||
}
|
||||
|
||||
// Pre-fill channel form if exists
|
||||
$channel = PlatformChannel::where('is_active', true)->first();
|
||||
if ($channel) {
|
||||
$this->channelName = $channel->name;
|
||||
$this->platformInstanceId = $channel->platform_instance_id;
|
||||
$this->channelLanguageId = $channel->language_id;
|
||||
$this->channelDescription = $channel->description ?? '';
|
||||
}
|
||||
|
||||
// Pre-fill route form if exists
|
||||
$route = Route::where('is_active', true)->first();
|
||||
if ($route) {
|
||||
$this->routeFeedId = $route->feed_id;
|
||||
$this->routeChannelId = $route->platform_channel_id;
|
||||
$this->routePriority = $route->priority;
|
||||
}
|
||||
}
|
||||
|
||||
public function goToStep(int $step): void
|
||||
{
|
||||
$this->step = $step;
|
||||
$this->formErrors = [];
|
||||
}
|
||||
|
||||
public function nextStep(): void
|
||||
{
|
||||
$this->step++;
|
||||
$this->formErrors = [];
|
||||
}
|
||||
|
||||
public function previousStep(): void
|
||||
{
|
||||
if ($this->step > 1) {
|
||||
$this->step--;
|
||||
$this->formErrors = [];
|
||||
}
|
||||
}
|
||||
|
||||
public function continueWithExistingAccount(): void
|
||||
{
|
||||
$this->nextStep();
|
||||
}
|
||||
|
||||
public function deleteAccount(): void
|
||||
{
|
||||
if ($this->existingAccount) {
|
||||
PlatformAccount::destroy($this->existingAccount['id']);
|
||||
$this->existingAccount = null;
|
||||
}
|
||||
}
|
||||
|
||||
public function createPlatformAccount(): void
|
||||
{
|
||||
$this->formErrors = [];
|
||||
$this->isLoading = true;
|
||||
|
||||
$this->validate([
|
||||
'instanceUrl' => 'required|string|max:255|regex:/^[a-zA-Z0-9]([a-zA-Z0-9\-\.]*[a-zA-Z0-9])?$/',
|
||||
'username' => 'required|string|max:255',
|
||||
'password' => 'required|string|min:6',
|
||||
], [
|
||||
'instanceUrl.regex' => 'Please enter a valid domain name (e.g., lemmy.world, belgae.social)',
|
||||
]);
|
||||
|
||||
$fullInstanceUrl = 'https://' . $this->instanceUrl;
|
||||
|
||||
try {
|
||||
// Create or get platform instance
|
||||
$platformInstance = PlatformInstance::firstOrCreate([
|
||||
'url' => $fullInstanceUrl,
|
||||
'platform' => 'lemmy',
|
||||
], [
|
||||
'name' => ucfirst($this->instanceUrl),
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
// Authenticate with Lemmy API
|
||||
$authResponse = $this->lemmyAuthService->authenticate(
|
||||
$fullInstanceUrl,
|
||||
$this->username,
|
||||
$this->password
|
||||
);
|
||||
|
||||
// Create platform account
|
||||
$platformAccount = PlatformAccount::create([
|
||||
'platform' => 'lemmy',
|
||||
'instance_url' => $fullInstanceUrl,
|
||||
'username' => $this->username,
|
||||
'password' => Crypt::encryptString($this->password),
|
||||
'settings' => [
|
||||
'display_name' => $authResponse['person_view']['person']['display_name'] ?? null,
|
||||
'description' => $authResponse['person_view']['person']['bio'] ?? null,
|
||||
'person_id' => $authResponse['person_view']['person']['id'] ?? null,
|
||||
'platform_instance_id' => $platformInstance->id,
|
||||
'api_token' => $authResponse['jwt'] ?? null,
|
||||
],
|
||||
'is_active' => true,
|
||||
'status' => 'active',
|
||||
]);
|
||||
|
||||
$this->existingAccount = [
|
||||
'id' => $platformAccount->id,
|
||||
'username' => $platformAccount->username,
|
||||
'instance_url' => $platformAccount->instance_url,
|
||||
];
|
||||
|
||||
$this->nextStep();
|
||||
} catch (\App\Exceptions\PlatformAuthException $e) {
|
||||
$message = $e->getMessage();
|
||||
if (str_contains($message, 'Rate limited by')) {
|
||||
$this->formErrors['general'] = $message;
|
||||
} elseif (str_contains($message, 'Connection failed')) {
|
||||
$this->formErrors['general'] = 'Unable to connect to the Lemmy instance. Please check the URL and try again.';
|
||||
} else {
|
||||
$this->formErrors['general'] = 'Invalid username or password. Please check your credentials and try again.';
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
logger()->error('Lemmy platform account creation failed', [
|
||||
'instance_url' => $fullInstanceUrl,
|
||||
'username' => $this->username,
|
||||
'error' => $e->getMessage(),
|
||||
'class' => get_class($e),
|
||||
]);
|
||||
$this->formErrors['general'] = 'An error occurred while setting up your account. Please try again.';
|
||||
} finally {
|
||||
$this->isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
public function createFeed(): void
|
||||
{
|
||||
$this->formErrors = [];
|
||||
$this->isLoading = true;
|
||||
|
||||
$this->validate([
|
||||
'feedName' => 'required|string|max:255',
|
||||
'feedProvider' => 'required|in:belga,vrt',
|
||||
'feedLanguageId' => 'required|exists:languages,id',
|
||||
'feedDescription' => 'nullable|string|max:1000',
|
||||
]);
|
||||
|
||||
try {
|
||||
// Map provider to URL
|
||||
$url = $this->feedProvider === 'vrt'
|
||||
? 'https://www.vrt.be/vrtnws/en/'
|
||||
: 'https://www.belganewsagency.eu/';
|
||||
|
||||
Feed::firstOrCreate(
|
||||
['url' => $url],
|
||||
[
|
||||
'name' => $this->feedName,
|
||||
'type' => 'website',
|
||||
'provider' => $this->feedProvider,
|
||||
'language_id' => $this->feedLanguageId,
|
||||
'description' => $this->feedDescription ?: null,
|
||||
'is_active' => true,
|
||||
]
|
||||
);
|
||||
|
||||
$this->nextStep();
|
||||
} catch (\Exception $e) {
|
||||
$this->formErrors['general'] = 'Failed to create feed. Please try again.';
|
||||
} finally {
|
||||
$this->isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
public function createChannel(): void
|
||||
{
|
||||
$this->formErrors = [];
|
||||
$this->isLoading = true;
|
||||
|
||||
$this->validate([
|
||||
'channelName' => 'required|string|max:255',
|
||||
'platformInstanceId' => 'required|exists:platform_instances,id',
|
||||
'channelLanguageId' => 'required|exists:languages,id',
|
||||
'channelDescription' => 'nullable|string|max:1000',
|
||||
]);
|
||||
|
||||
try {
|
||||
$platformInstance = PlatformInstance::findOrFail($this->platformInstanceId);
|
||||
|
||||
// Check for active platform accounts
|
||||
$activeAccounts = PlatformAccount::where('instance_url', $platformInstance->url)
|
||||
->where('is_active', true)
|
||||
->get();
|
||||
|
||||
if ($activeAccounts->isEmpty()) {
|
||||
$this->formErrors['general'] = 'No active platform accounts found for this instance. Please create a platform account first.';
|
||||
$this->isLoading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
$channel = PlatformChannel::create([
|
||||
'platform_instance_id' => $this->platformInstanceId,
|
||||
'channel_id' => $this->channelName,
|
||||
'name' => $this->channelName,
|
||||
'display_name' => ucfirst($this->channelName),
|
||||
'description' => $this->channelDescription ?: null,
|
||||
'language_id' => $this->channelLanguageId,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
// Attach first active account
|
||||
$channel->platformAccounts()->attach($activeAccounts->first()->id, [
|
||||
'is_active' => true,
|
||||
'priority' => 1,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$this->nextStep();
|
||||
} catch (\Exception $e) {
|
||||
$this->formErrors['general'] = 'Failed to create channel. Please try again.';
|
||||
} finally {
|
||||
$this->isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
public function createRoute(): void
|
||||
{
|
||||
$this->formErrors = [];
|
||||
$this->isLoading = true;
|
||||
|
||||
$this->validate([
|
||||
'routeFeedId' => 'required|exists:feeds,id',
|
||||
'routeChannelId' => 'required|exists:platform_channels,id',
|
||||
'routePriority' => 'nullable|integer|min:1|max:100',
|
||||
]);
|
||||
|
||||
try {
|
||||
Route::create([
|
||||
'feed_id' => $this->routeFeedId,
|
||||
'platform_channel_id' => $this->routeChannelId,
|
||||
'priority' => $this->routePriority,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
// Trigger article discovery
|
||||
ArticleDiscoveryJob::dispatch();
|
||||
|
||||
$this->nextStep();
|
||||
} catch (\Exception $e) {
|
||||
$this->formErrors['general'] = 'Failed to create route. Please try again.';
|
||||
} finally {
|
||||
$this->isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
public function completeOnboarding(): void
|
||||
{
|
||||
Setting::updateOrCreate(
|
||||
['key' => 'onboarding_completed'],
|
||||
['value' => now()->toIso8601String()]
|
||||
);
|
||||
|
||||
app(OnboardingService::class)->clearCache();
|
||||
|
||||
$this->redirect(route('dashboard'));
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
$languages = Language::where('is_active', true)->orderBy('name')->get();
|
||||
$platformInstances = PlatformInstance::where('is_active', true)->orderBy('name')->get();
|
||||
$feeds = Feed::where('is_active', true)->orderBy('name')->get();
|
||||
$channels = PlatformChannel::where('is_active', true)->orderBy('name')->get();
|
||||
|
||||
$feedProviders = collect(config('feed.providers', []))
|
||||
->filter(fn($provider) => $provider['is_active'] ?? false)
|
||||
->values();
|
||||
|
||||
return view('livewire.onboarding', [
|
||||
'languages' => $languages,
|
||||
'platformInstances' => $platformInstances,
|
||||
'feeds' => $feeds,
|
||||
'channels' => $channels,
|
||||
'feedProviders' => $feedProviders,
|
||||
])->layout('layouts.onboarding');
|
||||
}
|
||||
}
|
||||
|
|
@ -1,200 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use App\Models\Feed;
|
||||
use App\Models\Keyword;
|
||||
use App\Models\PlatformChannel;
|
||||
use App\Models\Route;
|
||||
use Livewire\Component;
|
||||
|
||||
class Routes extends Component
|
||||
{
|
||||
public bool $showCreateModal = false;
|
||||
public ?int $editingFeedId = null;
|
||||
public ?int $editingChannelId = null;
|
||||
|
||||
// Create form
|
||||
public ?int $newFeedId = null;
|
||||
public ?int $newChannelId = null;
|
||||
public int $newPriority = 50;
|
||||
|
||||
// Edit form
|
||||
public int $editPriority = 50;
|
||||
|
||||
// Keyword management
|
||||
public string $newKeyword = '';
|
||||
public bool $showKeywordInput = false;
|
||||
|
||||
public function openCreateModal(): void
|
||||
{
|
||||
$this->showCreateModal = true;
|
||||
$this->newFeedId = null;
|
||||
$this->newChannelId = null;
|
||||
$this->newPriority = 50;
|
||||
}
|
||||
|
||||
public function closeCreateModal(): void
|
||||
{
|
||||
$this->showCreateModal = false;
|
||||
}
|
||||
|
||||
public function createRoute(): void
|
||||
{
|
||||
$this->validate([
|
||||
'newFeedId' => 'required|exists:feeds,id',
|
||||
'newChannelId' => 'required|exists:platform_channels,id',
|
||||
'newPriority' => 'required|integer|min:0',
|
||||
]);
|
||||
|
||||
$exists = Route::where('feed_id', $this->newFeedId)
|
||||
->where('platform_channel_id', $this->newChannelId)
|
||||
->exists();
|
||||
|
||||
if ($exists) {
|
||||
$this->addError('newFeedId', 'This route already exists.');
|
||||
return;
|
||||
}
|
||||
|
||||
Route::create([
|
||||
'feed_id' => $this->newFeedId,
|
||||
'platform_channel_id' => $this->newChannelId,
|
||||
'priority' => $this->newPriority,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$this->closeCreateModal();
|
||||
}
|
||||
|
||||
public function openEditModal(int $feedId, int $channelId): void
|
||||
{
|
||||
$route = Route::where('feed_id', $feedId)
|
||||
->where('platform_channel_id', $channelId)
|
||||
->firstOrFail();
|
||||
|
||||
$this->editingFeedId = $feedId;
|
||||
$this->editingChannelId = $channelId;
|
||||
$this->editPriority = $route->priority;
|
||||
$this->newKeyword = '';
|
||||
$this->showKeywordInput = false;
|
||||
}
|
||||
|
||||
public function closeEditModal(): void
|
||||
{
|
||||
$this->editingFeedId = null;
|
||||
$this->editingChannelId = null;
|
||||
}
|
||||
|
||||
public function updateRoute(): void
|
||||
{
|
||||
if (!$this->editingFeedId || !$this->editingChannelId) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->validate([
|
||||
'editPriority' => 'required|integer|min:0',
|
||||
]);
|
||||
|
||||
Route::where('feed_id', $this->editingFeedId)
|
||||
->where('platform_channel_id', $this->editingChannelId)
|
||||
->update(['priority' => $this->editPriority]);
|
||||
|
||||
$this->closeEditModal();
|
||||
}
|
||||
|
||||
public function toggle(int $feedId, int $channelId): void
|
||||
{
|
||||
$route = Route::where('feed_id', $feedId)
|
||||
->where('platform_channel_id', $channelId)
|
||||
->firstOrFail();
|
||||
|
||||
$route->is_active = !$route->is_active;
|
||||
$route->save();
|
||||
}
|
||||
|
||||
public function delete(int $feedId, int $channelId): void
|
||||
{
|
||||
// Delete associated keywords first
|
||||
Keyword::where('feed_id', $feedId)
|
||||
->where('platform_channel_id', $channelId)
|
||||
->delete();
|
||||
|
||||
Route::where('feed_id', $feedId)
|
||||
->where('platform_channel_id', $channelId)
|
||||
->delete();
|
||||
}
|
||||
|
||||
public function addKeyword(): void
|
||||
{
|
||||
if (!$this->editingFeedId || !$this->editingChannelId || empty(trim($this->newKeyword))) {
|
||||
return;
|
||||
}
|
||||
|
||||
Keyword::create([
|
||||
'feed_id' => $this->editingFeedId,
|
||||
'platform_channel_id' => $this->editingChannelId,
|
||||
'keyword' => trim($this->newKeyword),
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$this->newKeyword = '';
|
||||
$this->showKeywordInput = false;
|
||||
}
|
||||
|
||||
public function toggleKeyword(int $keywordId): void
|
||||
{
|
||||
$keyword = Keyword::findOrFail($keywordId);
|
||||
$keyword->is_active = !$keyword->is_active;
|
||||
$keyword->save();
|
||||
}
|
||||
|
||||
public function deleteKeyword(int $keywordId): void
|
||||
{
|
||||
Keyword::destroy($keywordId);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
$routes = Route::with(['feed', 'platformChannel'])
|
||||
->orderBy('priority', 'desc')
|
||||
->get();
|
||||
|
||||
// Batch load keywords for all routes to avoid N+1 queries
|
||||
$routeKeys = $routes->map(fn($r) => $r->feed_id . '-' . $r->platform_channel_id);
|
||||
$allKeywords = Keyword::whereIn('feed_id', $routes->pluck('feed_id'))
|
||||
->whereIn('platform_channel_id', $routes->pluck('platform_channel_id'))
|
||||
->get()
|
||||
->groupBy(fn($k) => $k->feed_id . '-' . $k->platform_channel_id);
|
||||
|
||||
$routes = $routes->map(function ($route) use ($allKeywords) {
|
||||
$key = $route->feed_id . '-' . $route->platform_channel_id;
|
||||
$route->keywords = $allKeywords->get($key, collect());
|
||||
return $route;
|
||||
});
|
||||
|
||||
$feeds = Feed::where('is_active', true)->orderBy('name')->get();
|
||||
$channels = PlatformChannel::where('is_active', true)->orderBy('name')->get();
|
||||
|
||||
$editingRoute = null;
|
||||
$editingKeywords = collect();
|
||||
|
||||
if ($this->editingFeedId && $this->editingChannelId) {
|
||||
$editingRoute = Route::with(['feed', 'platformChannel'])
|
||||
->where('feed_id', $this->editingFeedId)
|
||||
->where('platform_channel_id', $this->editingChannelId)
|
||||
->first();
|
||||
|
||||
$editingKeywords = Keyword::where('feed_id', $this->editingFeedId)
|
||||
->where('platform_channel_id', $this->editingChannelId)
|
||||
->get();
|
||||
}
|
||||
|
||||
return view('livewire.routes', [
|
||||
'routes' => $routes,
|
||||
'feeds' => $feeds,
|
||||
'channels' => $channels,
|
||||
'editingRoute' => $editingRoute,
|
||||
'editingKeywords' => $editingKeywords,
|
||||
])->layout('layouts.app');
|
||||
}
|
||||
}
|
||||
|
|
@ -1,55 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use App\Models\Setting;
|
||||
use Livewire\Component;
|
||||
|
||||
class Settings extends Component
|
||||
{
|
||||
public bool $articleProcessingEnabled = true;
|
||||
public bool $publishingApprovalsEnabled = false;
|
||||
|
||||
public ?string $successMessage = null;
|
||||
public ?string $errorMessage = null;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->articleProcessingEnabled = Setting::isArticleProcessingEnabled();
|
||||
$this->publishingApprovalsEnabled = Setting::isPublishingApprovalsEnabled();
|
||||
}
|
||||
|
||||
public function toggleArticleProcessing(): void
|
||||
{
|
||||
$this->articleProcessingEnabled = !$this->articleProcessingEnabled;
|
||||
Setting::setArticleProcessingEnabled($this->articleProcessingEnabled);
|
||||
$this->showSuccess();
|
||||
}
|
||||
|
||||
public function togglePublishingApprovals(): void
|
||||
{
|
||||
$this->publishingApprovalsEnabled = !$this->publishingApprovalsEnabled;
|
||||
Setting::setPublishingApprovalsEnabled($this->publishingApprovalsEnabled);
|
||||
$this->showSuccess();
|
||||
}
|
||||
|
||||
protected function showSuccess(): void
|
||||
{
|
||||
$this->successMessage = 'Settings updated successfully!';
|
||||
$this->errorMessage = null;
|
||||
|
||||
// Clear success message after 3 seconds
|
||||
$this->dispatch('clear-message');
|
||||
}
|
||||
|
||||
public function clearMessages(): void
|
||||
{
|
||||
$this->successMessage = null;
|
||||
$this->errorMessage = null;
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.settings')->layout('layouts.app');
|
||||
}
|
||||
}
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Feed;
|
||||
use App\Models\PlatformAccount;
|
||||
use App\Models\PlatformChannel;
|
||||
use App\Models\Route;
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class OnboardingService
|
||||
{
|
||||
public function needsOnboarding(): bool
|
||||
{
|
||||
return Cache::remember('onboarding_needed', 300, function () {
|
||||
return $this->checkOnboardingStatus();
|
||||
});
|
||||
}
|
||||
|
||||
public function clearCache(): void
|
||||
{
|
||||
Cache::forget('onboarding_needed');
|
||||
}
|
||||
|
||||
private function checkOnboardingStatus(): bool
|
||||
{
|
||||
$onboardingSkipped = Setting::where('key', 'onboarding_skipped')->value('value') === 'true';
|
||||
$onboardingCompleted = Setting::where('key', 'onboarding_completed')->exists();
|
||||
|
||||
// If skipped or completed, no onboarding needed
|
||||
if ($onboardingCompleted || $onboardingSkipped) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if all components exist
|
||||
$hasPlatformAccount = PlatformAccount::where('is_active', true)->exists();
|
||||
$hasFeed = Feed::where('is_active', true)->exists();
|
||||
$hasChannel = PlatformChannel::where('is_active', true)->exists();
|
||||
$hasRoute = Route::where('is_active', true)->exists();
|
||||
|
||||
$hasAllComponents = $hasPlatformAccount && $hasFeed && $hasChannel && $hasRoute;
|
||||
|
||||
return !$hasAllComponents;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,143 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Publishing;
|
||||
|
||||
use App\Enums\PlatformEnum;
|
||||
use App\Exceptions\PublishException;
|
||||
use App\Models\Article;
|
||||
use App\Models\ArticlePublication;
|
||||
use App\Models\PlatformChannel;
|
||||
use App\Models\Route;
|
||||
use App\Modules\Lemmy\Services\LemmyPublisher;
|
||||
use App\Services\Log\LogSaver;
|
||||
use Exception;
|
||||
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
||||
use Illuminate\Support\Collection;
|
||||
use RuntimeException;
|
||||
|
||||
class ArticlePublishingService
|
||||
{
|
||||
public function __construct(private LogSaver $logSaver)
|
||||
{
|
||||
}
|
||||
/**
|
||||
* Factory seam to create publisher instances (helps testing without network calls)
|
||||
*/
|
||||
protected function makePublisher(mixed $account): LemmyPublisher
|
||||
{
|
||||
return new LemmyPublisher($account);
|
||||
}
|
||||
/**
|
||||
* @param array<string, mixed> $extractedData
|
||||
* @return Collection<int, ArticlePublication>
|
||||
* @throws PublishException
|
||||
*/
|
||||
public function publishToRoutedChannels(Article $article, array $extractedData): Collection
|
||||
{
|
||||
if (! $article->isValid()) {
|
||||
throw new PublishException($article, PlatformEnum::LEMMY, new RuntimeException('CANNOT_PUBLISH_INVALID_ARTICLE'));
|
||||
}
|
||||
|
||||
$feed = $article->feed;
|
||||
|
||||
// Get active routes with keywords instead of just channels
|
||||
$activeRoutes = Route::where('feed_id', $feed->id)
|
||||
->where('is_active', true)
|
||||
->with(['platformChannel.platformInstance', 'platformChannel.activePlatformAccounts', 'keywords'])
|
||||
->orderBy('priority', 'desc')
|
||||
->get();
|
||||
|
||||
// Filter routes based on keyword matches
|
||||
$matchingRoutes = $activeRoutes->filter(function (Route $route) use ($extractedData) {
|
||||
return $this->routeMatchesArticle($route, $extractedData);
|
||||
});
|
||||
|
||||
return $matchingRoutes->map(function (Route $route) use ($article, $extractedData) {
|
||||
$channel = $route->platformChannel;
|
||||
$account = $channel->activePlatformAccounts()->first();
|
||||
|
||||
if (! $account) {
|
||||
$this->logSaver->warning('No active account for channel', $channel, [
|
||||
'article_id' => $article->id,
|
||||
'route_priority' => $route->priority
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->publishToChannel($article, $extractedData, $channel, $account);
|
||||
})
|
||||
->filter();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a route matches an article based on keywords
|
||||
* @param array<string, mixed> $extractedData
|
||||
*/
|
||||
private function routeMatchesArticle(Route $route, array $extractedData): bool
|
||||
{
|
||||
// Get active keywords for this route
|
||||
$activeKeywords = $route->keywords->where('is_active', true);
|
||||
|
||||
// If no keywords are defined for this route, the route matches any article
|
||||
if ($activeKeywords->isEmpty()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Get article content for keyword matching
|
||||
$articleContent = '';
|
||||
if (isset($extractedData['full_article'])) {
|
||||
$articleContent = $extractedData['full_article'];
|
||||
}
|
||||
if (isset($extractedData['title'])) {
|
||||
$articleContent .= ' ' . $extractedData['title'];
|
||||
}
|
||||
if (isset($extractedData['description'])) {
|
||||
$articleContent .= ' ' . $extractedData['description'];
|
||||
}
|
||||
|
||||
// Check if any of the route's keywords match the article content
|
||||
foreach ($activeKeywords as $keywordModel) {
|
||||
$keyword = $keywordModel->keyword;
|
||||
if (stripos($articleContent, $keyword) !== false) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $extractedData
|
||||
*/
|
||||
private function publishToChannel(Article $article, array $extractedData, PlatformChannel $channel, mixed $account): ?ArticlePublication
|
||||
{
|
||||
try {
|
||||
$publisher = $this->makePublisher($account);
|
||||
$postData = $publisher->publishToChannel($article, $extractedData, $channel);
|
||||
|
||||
$publication = ArticlePublication::create([
|
||||
'article_id' => $article->id,
|
||||
'post_id' => $postData['post_view']['post']['id'],
|
||||
'platform_channel_id' => $channel->id,
|
||||
'published_by' => $account->username,
|
||||
'published_at' => now(),
|
||||
'platform' => $channel->platformInstance->platform->value,
|
||||
'publication_data' => $postData,
|
||||
]);
|
||||
|
||||
$this->logSaver->info('Published to channel via keyword-filtered routing', $channel, [
|
||||
'article_id' => $article->id
|
||||
]);
|
||||
|
||||
return $publication;
|
||||
} catch (Exception $e) {
|
||||
$this->logSaver->warning('Failed to publish to channel', $channel, [
|
||||
'article_id' => $article->id,
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\View\Components;
|
||||
|
||||
use Illuminate\View\Component;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class AppLayout extends Component
|
||||
{
|
||||
/**
|
||||
* Get the view / contents that represents the component.
|
||||
*/
|
||||
public function render(): View
|
||||
{
|
||||
return view('layouts.app');
|
||||
}
|
||||
}
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\View\Components;
|
||||
|
||||
use Illuminate\View\Component;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class GuestLayout extends Component
|
||||
{
|
||||
/**
|
||||
* Get the view / contents that represents the component.
|
||||
*/
|
||||
public function render(): View
|
||||
{
|
||||
return view('layouts.guest');
|
||||
}
|
||||
}
|
||||
62
backend/.env.broken
Normal file
62
backend/.env.broken
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
APP_NAME="FFR Development"
|
||||
APP_ENV=local
|
||||
APP_KEY=
|
||||
APP_DEBUG=true
|
||||
APP_TIMEZONE=UTC
|
||||
APP_URL=http://localhost:8000
|
||||
|
||||
APP_LOCALE=en
|
||||
APP_FALLBACK_LOCALE=en
|
||||
APP_FAKER_LOCALE=en_US
|
||||
|
||||
APP_MAINTENANCE_DRIVER=file
|
||||
APP_MAINTENANCE_STORE=database
|
||||
|
||||
BCRYPT_ROUNDS=12
|
||||
|
||||
LOG_CHANNEL=stack
|
||||
LOG_STACK=single
|
||||
LOG_DEPRECATIONS_CHANNEL=null
|
||||
LOG_LEVEL=debug
|
||||
|
||||
DB_CONNECTION=mysql
|
||||
DB_HOST=db
|
||||
DB_PORT=3306
|
||||
DB_DATABASE=ffr_dev
|
||||
DB_USERNAME=ffr_user
|
||||
DB_PASSWORD=ffr_password
|
||||
|
||||
SESSION_DRIVER=redis
|
||||
SESSION_LIFETIME=120
|
||||
SESSION_ENCRYPT=false
|
||||
SESSION_PATH=/
|
||||
SESSION_DOMAIN=null
|
||||
|
||||
BROADCAST_CONNECTION=log
|
||||
FILESYSTEM_DISK=local
|
||||
QUEUE_CONNECTION=redis
|
||||
|
||||
CACHE_STORE=redis
|
||||
CACHE_PREFIX=
|
||||
|
||||
REDIS_CLIENT=phpredis
|
||||
REDIS_HOST=redis
|
||||
REDIS_PASSWORD=null
|
||||
REDIS_PORT=6379
|
||||
|
||||
MAIL_MAILER=log
|
||||
MAIL_HOST=127.0.0.1
|
||||
MAIL_PORT=2525
|
||||
MAIL_USERNAME=null
|
||||
MAIL_PASSWORD=null
|
||||
MAIL_ENCRYPTION=null
|
||||
MAIL_FROM_ADDRESS="hello@example.com"
|
||||
MAIL_FROM_NAME="${APP_NAME}"
|
||||
|
||||
AWS_ACCESS_KEY_ID=
|
||||
AWS_SECRET_ACCESS_KEY=
|
||||
AWS_DEFAULT_REGION=us-east-1
|
||||
AWS_BUCKET=
|
||||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||
|
||||
VITE_APP_NAME="${APP_NAME}"
|
||||
2
backend/.gitignore
vendored
Normal file
2
backend/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
vendor
|
||||
/coverage-report-*
|
||||
18
backend/app/Events/ArticleReadyToPublish.php
Normal file
18
backend/app/Events/ArticleReadyToPublish.php
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\Article;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class ArticleReadyToPublish
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
public function __construct(public Article $article)
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
|
|
@ -6,7 +6,6 @@
|
|||
use App\Models\Article;
|
||||
use App\Models\Setting;
|
||||
use App\Jobs\ArticleDiscoveryJob;
|
||||
use Exception;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
|
|
@ -48,12 +47,12 @@ public function approve(Article $article): JsonResponse
|
|||
{
|
||||
try {
|
||||
$article->approve('manual');
|
||||
|
||||
|
||||
return $this->sendResponse(
|
||||
new ArticleResource($article->fresh(['feed', 'articlePublication'])),
|
||||
'Article approved and queued for publishing.'
|
||||
);
|
||||
} catch (Exception $e) {
|
||||
} catch (\Exception $e) {
|
||||
return $this->sendError('Failed to approve article: ' . $e->getMessage(), [], 500);
|
||||
}
|
||||
}
|
||||
|
|
@ -65,12 +64,12 @@ public function reject(Article $article): JsonResponse
|
|||
{
|
||||
try {
|
||||
$article->reject('manual');
|
||||
|
||||
|
||||
return $this->sendResponse(
|
||||
new ArticleResource($article->fresh(['feed', 'articlePublication'])),
|
||||
'Article rejected.'
|
||||
);
|
||||
} catch (Exception $e) {
|
||||
} catch (\Exception $e) {
|
||||
return $this->sendError('Failed to reject article: ' . $e->getMessage(), [], 500);
|
||||
}
|
||||
}
|
||||
|
|
@ -81,14 +80,15 @@ public function reject(Article $article): JsonResponse
|
|||
public function refresh(): JsonResponse
|
||||
{
|
||||
try {
|
||||
// Dispatch the article discovery job
|
||||
ArticleDiscoveryJob::dispatch();
|
||||
|
||||
|
||||
return $this->sendResponse(
|
||||
null,
|
||||
'Article refresh started. New articles will appear shortly.'
|
||||
);
|
||||
} catch (Exception $e) {
|
||||
} catch (\Exception $e) {
|
||||
return $this->sendError('Failed to start article refresh: ' . $e->getMessage(), [], 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -57,6 +57,9 @@ public function store(StoreFeedRequest $request): JsonResponse
|
|||
$validated['url'] = $adapter->getHomepageUrl();
|
||||
$validated['type'] = 'website';
|
||||
|
||||
// Remove provider from validated data as it's not a database column
|
||||
unset($validated['provider']);
|
||||
|
||||
$feed = Feed::create($validated);
|
||||
|
||||
return $this->sendResponse(
|
||||
|
|
@ -7,7 +7,6 @@
|
|||
use App\Http\Resources\PlatformAccountResource;
|
||||
use App\Http\Resources\PlatformChannelResource;
|
||||
use App\Http\Resources\RouteResource;
|
||||
use App\Jobs\ArticleDiscoveryJob;
|
||||
use App\Models\Feed;
|
||||
use App\Models\Language;
|
||||
use App\Models\PlatformAccount;
|
||||
|
|
@ -37,15 +36,11 @@ public function status(): JsonResponse
|
|||
$hasChannel = PlatformChannel::where('is_active', true)->exists();
|
||||
$hasRoute = Route::where('is_active', true)->exists();
|
||||
|
||||
// Check if onboarding was explicitly skipped or completed
|
||||
// Check if onboarding was explicitly skipped
|
||||
$onboardingSkipped = Setting::where('key', 'onboarding_skipped')->value('value') === 'true';
|
||||
$onboardingCompleted = Setting::where('key', 'onboarding_completed')->exists();
|
||||
|
||||
// User needs onboarding if:
|
||||
// 1. They haven't completed or skipped onboarding AND
|
||||
// 2. They don't have all required components
|
||||
$hasAllComponents = $hasPlatformAccount && $hasFeed && $hasChannel && $hasRoute;
|
||||
$needsOnboarding = !$onboardingCompleted && !$onboardingSkipped && !$hasAllComponents;
|
||||
// User needs onboarding if they don't have the required components AND haven't skipped it
|
||||
$needsOnboarding = (!$hasPlatformAccount || !$hasFeed || !$hasChannel || !$hasRoute) && !$onboardingSkipped;
|
||||
|
||||
// Determine current step
|
||||
$currentStep = null;
|
||||
|
|
@ -69,8 +64,6 @@ public function status(): JsonResponse
|
|||
'has_channel' => $hasChannel,
|
||||
'has_route' => $hasRoute,
|
||||
'onboarding_skipped' => $onboardingSkipped,
|
||||
'onboarding_completed' => $onboardingCompleted,
|
||||
'missing_components' => !$hasAllComponents && $onboardingCompleted,
|
||||
], 'Onboarding status retrieved successfully.');
|
||||
}
|
||||
|
||||
|
|
@ -97,17 +90,11 @@ public function options(): JsonResponse
|
|||
->orderBy('name')
|
||||
->get(['id', 'platform_instance_id', 'name', 'display_name', 'description']);
|
||||
|
||||
// Get feed providers from config
|
||||
$feedProviders = collect(config('feed.providers', []))
|
||||
->filter(fn($provider) => $provider['is_active'])
|
||||
->values();
|
||||
|
||||
return $this->sendResponse([
|
||||
'languages' => $languages,
|
||||
'platform_instances' => $platformInstances,
|
||||
'feeds' => $feeds,
|
||||
'platform_channels' => $platformChannels,
|
||||
'feed_providers' => $feedProviders,
|
||||
], 'Onboarding options retrieved successfully.');
|
||||
}
|
||||
|
||||
|
|
@ -224,17 +211,14 @@ public function createFeed(Request $request): JsonResponse
|
|||
$url = 'https://www.belganewsagency.eu/';
|
||||
}
|
||||
|
||||
$feed = Feed::firstOrCreate(
|
||||
['url' => $url],
|
||||
[
|
||||
'name' => $validated['name'],
|
||||
'type' => $type,
|
||||
'provider' => $provider,
|
||||
'language_id' => $validated['language_id'],
|
||||
'description' => $validated['description'] ?? null,
|
||||
'is_active' => true,
|
||||
]
|
||||
);
|
||||
$feed = Feed::create([
|
||||
'name' => $validated['name'],
|
||||
'url' => $url,
|
||||
'type' => $type,
|
||||
'language_id' => $validated['language_id'],
|
||||
'description' => $validated['description'] ?? null,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
return $this->sendResponse(
|
||||
new FeedResource($feed->load('language')),
|
||||
|
|
@ -244,7 +228,6 @@ public function createFeed(Request $request): JsonResponse
|
|||
|
||||
/**
|
||||
* Create channel for onboarding
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function createChannel(Request $request): JsonResponse
|
||||
{
|
||||
|
|
@ -261,22 +244,6 @@ public function createChannel(Request $request): JsonResponse
|
|||
|
||||
$validated = $validator->validated();
|
||||
|
||||
// Get the platform instance to check for active accounts
|
||||
$platformInstance = PlatformInstance::findOrFail($validated['platform_instance_id']);
|
||||
|
||||
// Check if there are active platform accounts for this instance
|
||||
$activeAccounts = PlatformAccount::where('instance_url', $platformInstance->url)
|
||||
->where('is_active', true)
|
||||
->get();
|
||||
|
||||
if ($activeAccounts->isEmpty()) {
|
||||
return $this->sendError(
|
||||
'Cannot create channel: No active platform accounts found for this instance. Please create a platform account first.',
|
||||
[],
|
||||
422
|
||||
);
|
||||
}
|
||||
|
||||
$channel = PlatformChannel::create([
|
||||
'platform_instance_id' => $validated['platform_instance_id'],
|
||||
'channel_id' => $validated['name'], // For Lemmy, this is the community name
|
||||
|
|
@ -287,25 +254,14 @@ public function createChannel(Request $request): JsonResponse
|
|||
'is_active' => true,
|
||||
]);
|
||||
|
||||
// Automatically attach the first active account to the channel
|
||||
$firstAccount = $activeAccounts->first();
|
||||
$channel->platformAccounts()->attach($firstAccount->id, [
|
||||
'is_active' => true,
|
||||
'priority' => 1,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
return $this->sendResponse(
|
||||
new PlatformChannelResource($channel->load(['platformInstance', 'language', 'platformAccounts'])),
|
||||
'Channel created successfully and linked to platform account.'
|
||||
new PlatformChannelResource($channel->load(['platformInstance', 'language'])),
|
||||
'Channel created successfully.'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create route for onboarding
|
||||
*
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function createRoute(Request $request): JsonResponse
|
||||
{
|
||||
|
|
@ -313,6 +269,7 @@ public function createRoute(Request $request): JsonResponse
|
|||
'feed_id' => 'required|exists:feeds,id',
|
||||
'platform_channel_id' => 'required|exists:platform_channels,id',
|
||||
'priority' => 'nullable|integer|min:1|max:100',
|
||||
'filters' => 'nullable|array',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
|
|
@ -325,13 +282,10 @@ public function createRoute(Request $request): JsonResponse
|
|||
'feed_id' => $validated['feed_id'],
|
||||
'platform_channel_id' => $validated['platform_channel_id'],
|
||||
'priority' => $validated['priority'] ?? 50,
|
||||
'filters' => $validated['filters'] ?? [],
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
// Trigger article discovery when the first route is created during onboarding
|
||||
// This ensures articles start being fetched immediately after setup
|
||||
ArticleDiscoveryJob::dispatch();
|
||||
|
||||
return $this->sendResponse(
|
||||
new RouteResource($route->load(['feed', 'platformChannel'])),
|
||||
'Route created successfully.'
|
||||
|
|
@ -343,11 +297,10 @@ public function createRoute(Request $request): JsonResponse
|
|||
*/
|
||||
public function complete(): JsonResponse
|
||||
{
|
||||
// Track that onboarding has been completed with a timestamp
|
||||
Setting::updateOrCreate(
|
||||
['key' => 'onboarding_completed'],
|
||||
['value' => now()->toIso8601String()]
|
||||
);
|
||||
// In a real implementation, you might want to update a user preference
|
||||
// or create a setting that tracks onboarding completion
|
||||
// For now, we'll just return success since the onboarding status
|
||||
// is determined by the existence of platform accounts, feeds, and channels
|
||||
|
||||
return $this->sendResponse(
|
||||
['completed' => true],
|
||||
|
|
@ -377,12 +330,10 @@ public function skip(): JsonResponse
|
|||
public function resetSkip(): JsonResponse
|
||||
{
|
||||
Setting::where('key', 'onboarding_skipped')->delete();
|
||||
// Also reset completion status to allow re-onboarding
|
||||
Setting::where('key', 'onboarding_completed')->delete();
|
||||
|
||||
return $this->sendResponse(
|
||||
['reset' => true],
|
||||
'Onboarding status reset successfully.'
|
||||
'Onboarding skip status reset successfully.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -44,36 +44,11 @@ public function store(Request $request): JsonResponse
|
|||
|
||||
$validated['is_active'] = $validated['is_active'] ?? true;
|
||||
|
||||
// Get the platform instance to check for active accounts
|
||||
$platformInstance = \App\Models\PlatformInstance::findOrFail($validated['platform_instance_id']);
|
||||
|
||||
// Check if there are active platform accounts for this instance
|
||||
$activeAccounts = PlatformAccount::where('instance_url', $platformInstance->url)
|
||||
->where('is_active', true)
|
||||
->get();
|
||||
|
||||
if ($activeAccounts->isEmpty()) {
|
||||
return $this->sendError(
|
||||
'Cannot create channel: No active platform accounts found for this instance. Please create a platform account first.',
|
||||
[],
|
||||
422
|
||||
);
|
||||
}
|
||||
|
||||
$channel = PlatformChannel::create($validated);
|
||||
|
||||
// Automatically attach the first active account to the channel
|
||||
$firstAccount = $activeAccounts->first();
|
||||
$channel->platformAccounts()->attach($firstAccount->id, [
|
||||
'is_active' => true,
|
||||
'priority' => 1,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
return $this->sendResponse(
|
||||
new PlatformChannelResource($channel->load(['platformInstance', 'platformAccounts'])),
|
||||
'Platform channel created successfully and linked to platform account!',
|
||||
new PlatformChannelResource($channel->load('platformInstance')),
|
||||
'Platform channel created successfully!',
|
||||
201
|
||||
);
|
||||
} catch (ValidationException $e) {
|
||||
|
|
@ -17,7 +17,7 @@ class RoutingController extends BaseController
|
|||
*/
|
||||
public function index(): JsonResponse
|
||||
{
|
||||
$routes = Route::with(['feed', 'platformChannel', 'keywords'])
|
||||
$routes = Route::with(['feed', 'platformChannel'])
|
||||
->orderBy('is_active', 'desc')
|
||||
->orderBy('priority', 'asc')
|
||||
->get();
|
||||
|
|
@ -47,7 +47,7 @@ public function store(Request $request): JsonResponse
|
|||
$route = Route::create($validated);
|
||||
|
||||
return $this->sendResponse(
|
||||
new RouteResource($route->load(['feed', 'platformChannel', 'keywords'])),
|
||||
new RouteResource($route->load(['feed', 'platformChannel'])),
|
||||
'Routing configuration created successfully!',
|
||||
201
|
||||
);
|
||||
|
|
@ -69,7 +69,7 @@ public function show(Feed $feed, PlatformChannel $channel): JsonResponse
|
|||
return $this->sendNotFound('Routing configuration not found.');
|
||||
}
|
||||
|
||||
$route->load(['feed', 'platformChannel', 'keywords']);
|
||||
$route->load(['feed', 'platformChannel']);
|
||||
|
||||
return $this->sendResponse(
|
||||
new RouteResource($route),
|
||||
|
|
@ -99,7 +99,7 @@ public function update(Request $request, Feed $feed, PlatformChannel $channel):
|
|||
->update($validated);
|
||||
|
||||
return $this->sendResponse(
|
||||
new RouteResource($route->fresh(['feed', 'platformChannel', 'keywords'])),
|
||||
new RouteResource($route->fresh(['feed', 'platformChannel'])),
|
||||
'Routing configuration updated successfully!'
|
||||
);
|
||||
} catch (ValidationException $e) {
|
||||
|
|
@ -154,7 +154,7 @@ public function toggle(Feed $feed, PlatformChannel $channel): JsonResponse
|
|||
$status = $newStatus ? 'activated' : 'deactivated';
|
||||
|
||||
return $this->sendResponse(
|
||||
new RouteResource($route->fresh(['feed', 'platformChannel', 'keywords'])),
|
||||
new RouteResource($route->fresh(['feed', 'platformChannel'])),
|
||||
"Routing configuration {$status} successfully!"
|
||||
);
|
||||
} catch (\Exception $e) {
|
||||
|
|
@ -5,11 +5,13 @@
|
|||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
*/
|
||||
class ArticleResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* Transform the resource into an array.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
|
|
@ -25,11 +27,10 @@ public function toArray(Request $request): array
|
|||
'approved_by' => $this->approved_by,
|
||||
'fetched_at' => $this->fetched_at?->toISOString(),
|
||||
'validated_at' => $this->validated_at?->toISOString(),
|
||||
'is_published' => $this->relationLoaded('articlePublication') && $this->articlePublication !== null,
|
||||
'created_at' => $this->created_at->toISOString(),
|
||||
'updated_at' => $this->updated_at->toISOString(),
|
||||
'feed' => new FeedResource($this->whenLoaded('feed')),
|
||||
'article_publication' => new ArticlePublicationResource($this->whenLoaded('articlePublication')),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -19,8 +19,6 @@ public function toArray(Request $request): array
|
|||
'name' => $this->name,
|
||||
'url' => $this->url,
|
||||
'type' => $this->type,
|
||||
'provider' => $this->provider,
|
||||
'language_id' => $this->language_id,
|
||||
'is_active' => $this->is_active,
|
||||
'description' => $this->description,
|
||||
'created_at' => $this->created_at->toISOString(),
|
||||
|
|
@ -21,7 +21,6 @@ public function toArray(Request $request): array
|
|||
'name' => $this->name,
|
||||
'display_name' => $this->display_name,
|
||||
'description' => $this->description,
|
||||
'language_id' => $this->language_id,
|
||||
'is_active' => $this->is_active,
|
||||
'created_at' => $this->created_at->toISOString(),
|
||||
'updated_at' => $this->updated_at->toISOString(),
|
||||
|
|
@ -24,15 +24,6 @@ public function toArray(Request $request): array
|
|||
'updated_at' => $this->updated_at->toISOString(),
|
||||
'feed' => new FeedResource($this->whenLoaded('feed')),
|
||||
'platform_channel' => new PlatformChannelResource($this->whenLoaded('platformChannel')),
|
||||
'keywords' => $this->whenLoaded('keywords', function () {
|
||||
return $this->keywords->map(function ($keyword) {
|
||||
return [
|
||||
'id' => $keyword->id,
|
||||
'keyword' => $keyword->keyword,
|
||||
'is_active' => $keyword->is_active,
|
||||
];
|
||||
});
|
||||
}),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -20,17 +20,17 @@ public function __construct(
|
|||
$this->onQueue('feed-discovery');
|
||||
}
|
||||
|
||||
public function handle(LogSaver $logSaver, ArticleFetcher $articleFetcher): void
|
||||
public function handle(): void
|
||||
{
|
||||
$logSaver->info('Starting feed article fetch', null, [
|
||||
LogSaver::info('Starting feed article fetch', null, [
|
||||
'feed_id' => $this->feed->id,
|
||||
'feed_name' => $this->feed->name,
|
||||
'feed_url' => $this->feed->url
|
||||
]);
|
||||
|
||||
$articles = $articleFetcher->getArticlesFromFeed($this->feed);
|
||||
$articles = ArticleFetcher::getArticlesFromFeed($this->feed);
|
||||
|
||||
$logSaver->info('Feed article fetch completed', null, [
|
||||
LogSaver::info('Feed article fetch completed', null, [
|
||||
'feed_id' => $this->feed->id,
|
||||
'feed_name' => $this->feed->name,
|
||||
'articles_count' => $articles->count()
|
||||
|
|
@ -41,11 +41,9 @@ public function handle(LogSaver $logSaver, ArticleFetcher $articleFetcher): void
|
|||
|
||||
public static function dispatchForAllActiveFeeds(): void
|
||||
{
|
||||
$logSaver = app(LogSaver::class);
|
||||
|
||||
Feed::where('is_active', true)
|
||||
->get()
|
||||
->each(function (Feed $feed, $index) use ($logSaver) {
|
||||
->each(function (Feed $feed, $index) {
|
||||
// Space jobs apart to avoid overwhelming feeds
|
||||
$delayMinutes = $index * self::FEED_DISCOVERY_DELAY_MINUTES;
|
||||
|
||||
|
|
@ -53,7 +51,7 @@ public static function dispatchForAllActiveFeeds(): void
|
|||
->delay(now()->addMinutes($delayMinutes))
|
||||
->onQueue('feed-discovery');
|
||||
|
||||
$logSaver->info('Dispatched feed discovery job', null, [
|
||||
LogSaver::info('Dispatched feed discovery job', null, [
|
||||
'feed_id' => $feed->id,
|
||||
'feed_name' => $feed->name,
|
||||
'delay_minutes' => $delayMinutes
|
||||
|
|
@ -16,18 +16,18 @@ public function __construct()
|
|||
$this->onQueue('feed-discovery');
|
||||
}
|
||||
|
||||
public function handle(LogSaver $logSaver): void
|
||||
public function handle(): void
|
||||
{
|
||||
if (!Setting::isArticleProcessingEnabled()) {
|
||||
$logSaver->info('Article processing is disabled. Article discovery skipped.');
|
||||
LogSaver::info('Article processing is disabled. Article discovery skipped.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$logSaver->info('Starting article discovery for all active feeds');
|
||||
LogSaver::info('Starting article discovery for all active feeds');
|
||||
|
||||
ArticleDiscoveryForFeedJob::dispatchForAllActiveFeeds();
|
||||
|
||||
$logSaver->info('Article discovery jobs dispatched for all active feeds');
|
||||
LogSaver::info('Article discovery jobs dispatched for all active feeds');
|
||||
}
|
||||
}
|
||||
38
backend/app/Jobs/PublishToLemmyJob.php
Normal file
38
backend/app/Jobs/PublishToLemmyJob.php
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Exceptions\PublishException;
|
||||
use App\Models\Article;
|
||||
use App\Services\Article\ArticleFetcher;
|
||||
use App\Services\Publishing\ArticlePublishingService;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
|
||||
class PublishToLemmyJob implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public int $tries = 3;
|
||||
public array $backoff = [60, 120, 300];
|
||||
|
||||
public function __construct(
|
||||
private readonly Article $article
|
||||
) {
|
||||
$this->onQueue('lemmy-posts');
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$extractedData = ArticleFetcher::fetchArticleData($this->article);
|
||||
|
||||
/** @var ArticlePublishingService $publishingService */
|
||||
$publishingService = resolve(ArticlePublishingService::class);
|
||||
|
||||
try {
|
||||
$publishingService->publishToRoutedChannels($this->article, $extractedData);
|
||||
} catch (PublishException $e) {
|
||||
$this->fail($e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -27,34 +27,32 @@ public function __construct(
|
|||
|
||||
public static function dispatchForAllActiveChannels(): void
|
||||
{
|
||||
$logSaver = app(LogSaver::class);
|
||||
|
||||
PlatformChannel::with(['platformInstance', 'platformAccounts'])
|
||||
->whereHas('platformInstance', fn ($query) => $query->where('platform', PlatformEnum::LEMMY))
|
||||
->whereHas('platformAccounts', fn ($query) => $query->where('platform_accounts.is_active', true))
|
||||
->where('platform_channels.is_active', true)
|
||||
->whereHas('platformAccounts', fn ($query) => $query->where('is_active', true))
|
||||
->where('is_active', true)
|
||||
->get()
|
||||
->each(function (PlatformChannel $channel) use ($logSaver) {
|
||||
->each(function (PlatformChannel $channel) {
|
||||
self::dispatch($channel);
|
||||
$logSaver->info('Dispatched sync job for channel', $channel);
|
||||
LogSaver::info('Dispatched sync job for channel', $channel);
|
||||
});
|
||||
}
|
||||
|
||||
public function handle(LogSaver $logSaver): void
|
||||
public function handle(): void
|
||||
{
|
||||
$logSaver->info('Starting channel posts sync job', $this->channel);
|
||||
LogSaver::info('Starting channel posts sync job', $this->channel);
|
||||
|
||||
match ($this->channel->platformInstance->platform) {
|
||||
PlatformEnum::LEMMY => $this->syncLemmyChannelPosts($logSaver),
|
||||
PlatformEnum::LEMMY => $this->syncLemmyChannelPosts(),
|
||||
};
|
||||
|
||||
$logSaver->info('Channel posts sync job completed', $this->channel);
|
||||
LogSaver::info('Channel posts sync job completed', $this->channel);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws PlatformAuthException
|
||||
*/
|
||||
private function syncLemmyChannelPosts(LogSaver $logSaver): void
|
||||
private function syncLemmyChannelPosts(): void
|
||||
{
|
||||
try {
|
||||
/** @var Collection<int, PlatformAccount> $accounts */
|
||||
|
|
@ -74,10 +72,10 @@ private function syncLemmyChannelPosts(LogSaver $logSaver): void
|
|||
|
||||
$api->syncChannelPosts($token, $platformChannelId, $this->channel->name);
|
||||
|
||||
$logSaver->info('Channel posts synced successfully', $this->channel);
|
||||
LogSaver::info('Channel posts synced successfully', $this->channel);
|
||||
|
||||
} catch (Exception $e) {
|
||||
$logSaver->error('Failed to sync channel posts', $this->channel, [
|
||||
LogSaver::error('Failed to sync channel posts', $this->channel, [
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
|
||||
27
backend/app/Listeners/LogExceptionToDatabase.php
Normal file
27
backend/app/Listeners/LogExceptionToDatabase.php
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<?php
|
||||
|
||||
namespace App\Listeners;
|
||||
|
||||
use App\Events\ExceptionLogged;
|
||||
use App\Events\ExceptionOccurred;
|
||||
use App\Models\Log;
|
||||
class LogExceptionToDatabase
|
||||
{
|
||||
|
||||
public function handle(ExceptionOccurred $event): void
|
||||
{
|
||||
$log = Log::create([
|
||||
'level' => $event->level,
|
||||
'message' => $event->message,
|
||||
'context' => [
|
||||
'exception_class' => get_class($event->exception),
|
||||
'file' => $event->exception->getFile(),
|
||||
'line' => $event->exception->getLine(),
|
||||
'trace' => $event->exception->getTraceAsString(),
|
||||
...$event->context
|
||||
]
|
||||
]);
|
||||
|
||||
ExceptionLogged::dispatch($log);
|
||||
}
|
||||
}
|
||||
27
backend/app/Listeners/PublishApprovedArticle.php
Normal file
27
backend/app/Listeners/PublishApprovedArticle.php
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<?php
|
||||
|
||||
namespace App\Listeners;
|
||||
|
||||
use App\Events\ArticleApproved;
|
||||
use App\Events\ArticleReadyToPublish;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
|
||||
class PublishApprovedArticle implements ShouldQueue
|
||||
{
|
||||
public string $queue = 'default';
|
||||
|
||||
public function handle(ArticleApproved $event): void
|
||||
{
|
||||
$article = $event->article;
|
||||
|
||||
// Skip if already has publication (prevents duplicate processing)
|
||||
if ($article->articlePublication()->exists()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only publish if the article is valid and approved
|
||||
if ($article->isValid() && $article->isApproved()) {
|
||||
event(new ArticleReadyToPublish($article));
|
||||
}
|
||||
}
|
||||
}
|
||||
39
backend/app/Listeners/PublishArticle.php
Normal file
39
backend/app/Listeners/PublishArticle.php
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
<?php
|
||||
|
||||
namespace App\Listeners;
|
||||
|
||||
use App\Events\ArticleReadyToPublish;
|
||||
use App\Jobs\PublishToLemmyJob;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
|
||||
class PublishArticle implements ShouldQueue
|
||||
{
|
||||
public string|null $queue = 'lemmy-publish';
|
||||
public int $delay = 300;
|
||||
public int $tries = 3;
|
||||
public int $backoff = 300;
|
||||
|
||||
public function __construct()
|
||||
{}
|
||||
|
||||
public function handle(ArticleReadyToPublish $event): void
|
||||
{
|
||||
$article = $event->article;
|
||||
|
||||
if ($article->articlePublication()->exists()) {
|
||||
logger()->info('Article already published, skipping job dispatch', [
|
||||
'article_id' => $article->id,
|
||||
'url' => $article->url
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
logger()->info('Article queued for publishing to Lemmy', [
|
||||
'article_id' => $article->id,
|
||||
'url' => $article->url
|
||||
]);
|
||||
|
||||
PublishToLemmyJob::dispatch($article);
|
||||
}
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@
|
|||
namespace App\Listeners;
|
||||
|
||||
use App\Events\NewArticleFetched;
|
||||
use App\Events\ArticleApproved;
|
||||
use App\Events\ArticleReadyToPublish;
|
||||
use App\Models\Setting;
|
||||
use App\Services\Article\ValidationService;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
|
|
@ -12,7 +12,7 @@ class ValidateArticleListener implements ShouldQueue
|
|||
{
|
||||
public string $queue = 'default';
|
||||
|
||||
public function handle(NewArticleFetched $event, ValidationService $validationService): void
|
||||
public function handle(NewArticleFetched $event): void
|
||||
{
|
||||
$article = $event->article;
|
||||
|
||||
|
|
@ -25,7 +25,7 @@ public function handle(NewArticleFetched $event, ValidationService $validationSe
|
|||
return;
|
||||
}
|
||||
|
||||
$article = $validationService->validate($article);
|
||||
$article = ValidationService::validate($article);
|
||||
|
||||
if ($article->isValid()) {
|
||||
// Double-check publication doesn't exist (race condition protection)
|
||||
|
|
@ -37,12 +37,12 @@ public function handle(NewArticleFetched $event, ValidationService $validationSe
|
|||
if (Setting::isPublishingApprovalsEnabled()) {
|
||||
// If approvals are enabled, only proceed if article is approved
|
||||
if ($article->isApproved()) {
|
||||
event(new ArticleApproved($article));
|
||||
event(new ArticleReadyToPublish($article));
|
||||
}
|
||||
// If not approved, article will wait for manual approval
|
||||
} else {
|
||||
// If approvals are disabled, proceed with publishing
|
||||
event(new ArticleApproved($article));
|
||||
event(new ArticleReadyToPublish($article));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -35,11 +35,13 @@ class Article extends Model
|
|||
'url',
|
||||
'title',
|
||||
'description',
|
||||
'content',
|
||||
'image_url',
|
||||
'published_at',
|
||||
'author',
|
||||
'is_valid',
|
||||
'is_duplicate',
|
||||
'approval_status',
|
||||
'approved_at',
|
||||
'approved_by',
|
||||
'fetched_at',
|
||||
'validated_at',
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
@ -48,8 +50,12 @@ class Article extends Model
|
|||
public function casts(): array
|
||||
{
|
||||
return [
|
||||
'is_valid' => 'boolean',
|
||||
'is_duplicate' => 'boolean',
|
||||
'approval_status' => 'string',
|
||||
'published_at' => 'datetime',
|
||||
'approved_at' => 'datetime',
|
||||
'fetched_at' => 'datetime',
|
||||
'validated_at' => 'datetime',
|
||||
'created_at' => 'datetime',
|
||||
'updated_at' => 'datetime',
|
||||
];
|
||||
|
|
@ -57,9 +63,15 @@ public function casts(): array
|
|||
|
||||
public function isValid(): bool
|
||||
{
|
||||
// In the consolidated schema, we only have approval_status
|
||||
// Consider 'approved' status as valid
|
||||
return $this->approval_status === 'approved';
|
||||
if (is_null($this->validated_at)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (is_null($this->is_valid)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->is_valid;
|
||||
}
|
||||
|
||||
public function isApproved(): bool
|
||||
|
|
@ -81,6 +93,8 @@ public function approve(string $approvedBy = null): void
|
|||
{
|
||||
$this->update([
|
||||
'approval_status' => 'approved',
|
||||
'approved_at' => now(),
|
||||
'approved_by' => $approvedBy,
|
||||
]);
|
||||
|
||||
// Fire event to trigger publishing
|
||||
|
|
@ -91,6 +105,8 @@ public function reject(string $rejectedBy = null): void
|
|||
{
|
||||
$this->update([
|
||||
'approval_status' => 'rejected',
|
||||
'approved_at' => now(),
|
||||
'approved_by' => $rejectedBy,
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -109,11 +125,6 @@ public function canBePublished(): bool
|
|||
return $this->isApproved();
|
||||
}
|
||||
|
||||
public function getIsPublishedAttribute(): bool
|
||||
{
|
||||
return $this->articlePublication()->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasOne<ArticlePublication, $this>
|
||||
*/
|
||||
|
|
@ -15,7 +15,6 @@
|
|||
* @property string $name
|
||||
* @property string $url
|
||||
* @property string $type
|
||||
* @property string $provider
|
||||
* @property int $language_id
|
||||
* @property Language|null $language
|
||||
* @property string $description
|
||||
|
|
@ -39,7 +38,6 @@ class Feed extends Model
|
|||
'name',
|
||||
'url',
|
||||
'type',
|
||||
'provider',
|
||||
'language_id',
|
||||
'description',
|
||||
'settings',
|
||||
|
|
@ -89,7 +87,7 @@ public function getStatusAttribute(): string
|
|||
public function channels(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(PlatformChannel::class, 'routes')
|
||||
->withPivot(['is_active', 'priority'])
|
||||
->withPivot(['is_active', 'priority', 'filters'])
|
||||
->withTimestamps();
|
||||
}
|
||||
|
||||
|
|
@ -39,6 +39,7 @@ class PlatformAccount extends Model
|
|||
'instance_url',
|
||||
'username',
|
||||
'password',
|
||||
'api_token',
|
||||
'settings',
|
||||
'is_active',
|
||||
'last_tested_at',
|
||||
|
|
@ -59,40 +60,22 @@ class PlatformAccount extends Model
|
|||
protected function password(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: function ($value, array $attributes) {
|
||||
// Return null if the raw value is null
|
||||
if (is_null($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Return empty string if value is empty
|
||||
if (empty($value)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
return Crypt::decryptString($value);
|
||||
} catch (\Exception $e) {
|
||||
// If decryption fails, return null to be safe
|
||||
return null;
|
||||
}
|
||||
},
|
||||
set: function ($value) {
|
||||
// Store null if null is passed
|
||||
if (is_null($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Store empty string as null
|
||||
if (empty($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Crypt::encryptString($value);
|
||||
},
|
||||
)->withoutObjectCaching();
|
||||
get: fn ($value) => $value ? Crypt::decryptString($value) : null,
|
||||
set: fn ($value) => $value ? Crypt::encryptString($value) : null,
|
||||
);
|
||||
}
|
||||
|
||||
// Encrypt API token when storing
|
||||
/**
|
||||
* @return Attribute<string|null, string|null>
|
||||
*/
|
||||
protected function apiToken(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn ($value) => $value ? Crypt::decryptString($value) : null,
|
||||
set: fn ($value) => $value ? Crypt::encryptString($value) : null,
|
||||
);
|
||||
}
|
||||
|
||||
// Get the active accounts for a platform (returns collection)
|
||||
/**
|
||||
|
|
@ -10,7 +10,6 @@
|
|||
|
||||
/**
|
||||
* @method static findMany(mixed $channel_ids)
|
||||
* @method static create(array $array)
|
||||
* @property integer $id
|
||||
* @property integer $platform_instance_id
|
||||
* @property PlatformInstance $platformInstance
|
||||
|
|
@ -79,7 +78,7 @@ public function getFullNameAttribute(): string
|
|||
public function feeds(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Feed::class, 'routes')
|
||||
->withPivot(['is_active', 'priority'])
|
||||
->withPivot(['is_active', 'priority', 'filters'])
|
||||
->withTimestamps();
|
||||
}
|
||||
|
||||
|
|
@ -3,7 +3,6 @@
|
|||
namespace App\Models;
|
||||
|
||||
use App\Enums\PlatformEnum;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
|
|
@ -12,7 +11,6 @@
|
|||
*/
|
||||
class PlatformChannelPost extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
protected $fillable = [
|
||||
'platform',
|
||||
'channel_id',
|
||||
|
|
@ -14,6 +14,7 @@
|
|||
* @property int $platform_channel_id
|
||||
* @property bool $is_active
|
||||
* @property int $priority
|
||||
* @property array<string, mixed> $filters
|
||||
* @property Carbon $created_at
|
||||
* @property Carbon $updated_at
|
||||
*/
|
||||
|
|
@ -32,11 +33,13 @@ class Route extends Model
|
|||
'feed_id',
|
||||
'platform_channel_id',
|
||||
'is_active',
|
||||
'priority'
|
||||
'priority',
|
||||
'filters'
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_active' => 'boolean'
|
||||
'is_active' => 'boolean',
|
||||
'filters' => 'array'
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
@ -63,11 +63,6 @@ public function login(string $username, string $password): ?string
|
|||
$data = $response->json();
|
||||
return $data['jwt'] ?? null;
|
||||
} catch (Exception $e) {
|
||||
// Re-throw rate limit exceptions immediately
|
||||
if (str_contains($e->getMessage(), 'Rate limited')) {
|
||||
throw $e;
|
||||
}
|
||||
|
||||
logger()->error('Lemmy login exception', ['error' => $e->getMessage(), 'scheme' => $scheme]);
|
||||
// If this was the first attempt and HTTPS, try HTTP next
|
||||
if ($idx === 0 && in_array('http', $schemesToTry, true)) {
|
||||
|
|
@ -28,21 +28,16 @@ public function __construct(PlatformAccount $account)
|
|||
*/
|
||||
public function publishToChannel(Article $article, array $extractedData, PlatformChannel $channel): array
|
||||
{
|
||||
$token = resolve(LemmyAuthService::class)->getToken($this->account);
|
||||
$token = LemmyAuthService::getToken($this->account);
|
||||
|
||||
// Use the language ID from extracted data (should be set during validation)
|
||||
$languageId = $extractedData['language_id'] ?? null;
|
||||
|
||||
// Resolve community name to numeric ID if needed
|
||||
$communityId = is_numeric($channel->channel_id)
|
||||
? (int) $channel->channel_id
|
||||
: $this->api->getCommunityId($channel->channel_id, $token);
|
||||
|
||||
return $this->api->createPost(
|
||||
$token,
|
||||
$extractedData['title'] ?? 'Untitled',
|
||||
$extractedData['description'] ?? '',
|
||||
$communityId,
|
||||
(int) $channel->channel_id,
|
||||
$article->url,
|
||||
$extractedData['thumbnail'] ?? null,
|
||||
$languageId
|
||||
|
|
@ -30,6 +30,16 @@ public function boot(): void
|
|||
\App\Listeners\ValidateArticleListener::class,
|
||||
);
|
||||
|
||||
Event::listen(
|
||||
\App\Events\ArticleApproved::class,
|
||||
\App\Listeners\PublishApprovedArticle::class,
|
||||
);
|
||||
|
||||
Event::listen(
|
||||
\App\Events\ArticleReadyToPublish::class,
|
||||
\App\Listeners\PublishArticle::class,
|
||||
);
|
||||
|
||||
|
||||
app()->make(ExceptionHandler::class)
|
||||
->reportable(function (Throwable $e) {
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue