Compare commits

..

18 commits

Author SHA1 Message Date
35e4260c87 76 - Lock feed language to channel language in onboarding wizard 2026-03-07 10:43:48 +01:00
2a0653981c 40 - Run seeders on build
All checks were successful
Build and Push Docker Image / build (push) Successful in 4m14s
2026-03-07 09:22:00 +01:00
7b3a59da10 40 - Fix javascript/livewire issue
All checks were successful
Build and Push Docker Image / build (push) Successful in 4m16s
2026-03-07 00:45:51 +01:00
86b1fe7263 40 - Add base image for faster CI build
All checks were successful
Build and Push Docker Image / build (push) Successful in 5m10s
2026-03-07 00:27:16 +01:00
005abb1877 40 - Fix npm install in Dockerfile and remove .github workflows
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m44s
2026-03-07 00:03:11 +01:00
8ea0365b8b 40 - Add Forgejo CI workflow for Docker builds
Some checks failed
Build and Push Docker Image / build (push) Failing after 5m58s
2026-03-06 23:48:46 +01:00
71e7611e65 68 - Sync channel posts on setup for duplicate detection 2026-02-26 01:38:38 +01:00
c75a1c8cf0 68 - Fix duplicate posting and publishing pipeline 2026-02-26 01:19:38 +01:00
8bc6e99f96 68 - Fix duplicate posting + test suite fixes 2026-02-25 23:22:05 +01:00
d3c44a4952 63 - Swap feed and channel steps in onboarding wizard 2026-02-25 21:13:42 +01:00
b574785d1e 75 - Fix error handling for invalid instance URLs 2026-02-25 20:36:34 +01:00
3e23dad5c5 Minor bug fixes
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
2026-02-25 20:22:02 +01:00
16ce3b6324 74 - Minor fixes
- Add logout button to onboarding menu
- remove Forgot Password
2026-01-23 01:00:37 +01:00
03fa4b803f 74 - Fix auth + onboarding layout 2026-01-23 00:56:01 +01:00
b6290c0f8d 73 - Fix prod environment 2026-01-23 00:32:42 +01:00
4e0f0bb072 73 - Fix dev environment 2026-01-23 00:08:32 +01:00
638983d42a 73 - Port react frontend to blade+livewire 2026-01-22 23:38:00 +01:00
0823cb796c 73 - Move backend to root 2026-01-22 21:55:57 +01:00
386 changed files with 7563 additions and 28347 deletions

View file

@ -23,14 +23,14 @@ LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug LOG_LEVEL=debug
DB_CONNECTION=sqlite DB_CONNECTION=mysql
# DB_HOST=127.0.0.1 DB_HOST=db
# DB_PORT=3306 DB_PORT=3306
# DB_DATABASE=laravel DB_DATABASE=ffr_dev
# DB_USERNAME=root DB_USERNAME=ffr
# DB_PASSWORD= DB_PASSWORD=ffr
SESSION_DRIVER=database SESSION_DRIVER=redis
SESSION_LIFETIME=120 SESSION_LIFETIME=120
SESSION_ENCRYPT=false SESSION_ENCRYPT=false
SESSION_PATH=/ SESSION_PATH=/
@ -38,15 +38,15 @@ SESSION_DOMAIN=null
BROADCAST_CONNECTION=log BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local FILESYSTEM_DISK=local
QUEUE_CONNECTION=database QUEUE_CONNECTION=redis
CACHE_STORE=database CACHE_STORE=redis
# CACHE_PREFIX= # CACHE_PREFIX=
MEMCACHED_HOST=127.0.0.1 MEMCACHED_HOST=127.0.0.1
REDIS_CLIENT=phpredis REDIS_CLIENT=phpredis
REDIS_HOST=127.0.0.1 REDIS_HOST=redis
REDIS_PASSWORD=null REDIS_PASSWORD=null
REDIS_PORT=6379 REDIS_PORT=6379

View file

@ -0,0 +1,41 @@
name: Build and Push Docker Image
on:
push:
branches: [main]
tags: ['v*']
jobs:
build:
runs-on: docker
container:
image: catthehacker/ubuntu:act-latest
steps:
- uses: https://data.forgejo.org/actions/checkout@v4
- name: Set up Docker Buildx
uses: https://data.forgejo.org/docker/setup-buildx-action@v3
- name: Login to Forgejo Registry
uses: https://data.forgejo.org/docker/login-action@v3
with:
registry: forge.lvl0.xyz
username: ${{ github.actor }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Determine tags
id: meta
run: |
if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
TAG="${{ github.ref_name }}"
echo "tags=forge.lvl0.xyz/lvl0/fedi-feed-router:${TAG},forge.lvl0.xyz/lvl0/fedi-feed-router:latest" >> $GITHUB_OUTPUT
else
echo "tags=forge.lvl0.xyz/lvl0/fedi-feed-router:latest" >> $GITHUB_OUTPUT
fi
- name: Build and push
uses: https://data.forgejo.org/docker/build-push-action@v5
with:
context: .
file: Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}

View file

@ -1,45 +0,0 @@
name: linter
on:
push:
branches:
- develop
- main
pull_request:
branches:
- develop
- main
permissions:
contents: write
jobs:
quality:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.4'
- name: Install Dependencies
run: |
composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist
npm install
- name: Run Pint
run: vendor/bin/pint
- name: Format Frontend
run: npm run format
- name: Lint Frontend
run: npm run lint
# - name: Commit Changes
# uses: stefanzweifel/git-auto-commit-action@v5
# with:
# commit_message: fix code style
# commit_options: '--no-verify'

View file

@ -1,50 +0,0 @@
name: tests
on:
push:
branches:
- develop
- main
pull_request:
branches:
- develop
- main
jobs:
ci:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: 8.4
tools: composer:v2
coverage: xdebug
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- name: Install Node Dependencies
run: npm ci
- name: Build Assets
run: npm run build
- name: Install Dependencies
run: composer install --no-interaction --prefer-dist --optimize-autoloader
- name: Copy Environment File
run: cp .env.example .env
- name: Generate Application Key
run: php artisan key:generate
- name: Tests
run: ./vendor/bin/phpunit

4
.gitignore vendored
View file

@ -4,6 +4,7 @@
/public/build /public/build
/public/hot /public/hot
/public/storage /public/storage
/public/vendor
/storage/*.key /storage/*.key
/storage/pail /storage/pail
/vendor /vendor
@ -23,5 +24,6 @@ yarn-error.log
/.nova /.nova
/.vscode /.vscode
/.zed /.zed
/backend/coverage-report* /coverage-report*
/coverage.xml
/.claude /.claude

111
Dockerfile Normal file
View file

@ -0,0 +1,111 @@
# Production Dockerfile - uses pre-built base image
FROM forge.lvl0.xyz/lvl0/fedi-feed-router-base:latest
# 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=${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 install
# Build frontend assets
RUN npm run build
# Remove node_modules after build to save space
RUN rm -rf node_modules
# Publish Livewire assets
RUN php artisan livewire:publish --assets
# Laravel optimizations (skip config and route cache - they need runtime env vars)
RUN 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
encode gzip
php_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
# Cache config with runtime env vars
echo "Caching configuration..."
php artisan config:cache
# Run migrations
echo "Running migrations..."
php artisan migrate --force || echo "Migrations failed or already up-to-date"
# Run seeders (idempotent - uses updateOrInsert)
echo "Running seeders..."
php artisan db:seed --force || echo "Seeders failed or already run"
# 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 Normal file
View file

@ -0,0 +1,127 @@
# 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"]

298
README.md
View file

@ -1,219 +1,127 @@
# Fedi Feed Router (FFR) v1.0.0 # FFR (Feed to Fediverse Router)
<div align="center"> 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.
<img src="backend/public/images/ffr-logo-600.png" alt="FFR Logo" width="200">
**A minimal working version — limited to two hardcoded sources, designed for self-hosters.** ## Features
*Future versions will expand configurability and support.*
</div>
--- - **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
## 🔰 Project Overview ## Self-hosting
**One-liner:** FFR routes content from RSS/Atom feeds to the fediverse based on keyword matching. The production image is available at `forge.lvl0.xyz/lvl0/fedi-feed-router:latest`.
FFR is a self-hosted tool that monitors RSS/Atom feeds, filters articles based on keywords, and automatically publishes matching content to fediverse platforms like Lemmy. This v1.0.0 release provides a working foundation with two hardcoded news sources (CBC and BBC), designed specifically for self-hosters who want a simple, privacy-first solution without SaaS dependencies. ### docker-compose.yml
## ⚙️ Features ```yaml
services:
app:
image: forge.lvl0.xyz/lvl0/fedi-feed-router:latest
container_name: ffr_app
restart: always
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
depends_on:
- db
- redis
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/up"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
Current v1.0.0 features: db:
- ✅ Fetches articles from two hardcoded RSS feeds (CBC News, BBC News) image: mariadb:11
- ✅ Keyword-based content filtering and matching container_name: ffr_db
- ✅ Automatic posting to Lemmy communities restart: always
- ✅ Web dashboard for monitoring and management environment:
- ✅ Docker-based deployment for easy self-hosting MYSQL_DATABASE: "${DB_DATABASE}"
- ✅ Privacy-first design with no external dependencies MYSQL_USER: "${DB_USERNAME}"
MYSQL_PASSWORD: "${DB_PASSWORD}"
MYSQL_ROOT_PASSWORD: "${DB_ROOT_PASSWORD}"
volumes:
- db_data:/var/lib/mysql
Limitations (to be addressed in future versions): redis:
- Feed sources are currently hardcoded (not user-configurable) image: redis:7-alpine
- Only supports Lemmy as target platform container_name: ffr_redis
- Basic keyword matching (no regex or complex rules yet) restart: always
volumes:
- redis_data:/data
## 🚀 Installation volumes:
db_data:
### Quick Start with Docker redis_data:
app_storage:
1. **Clone the repository:**
```bash
git clone https://codeberg.org/lvl0/ffr.git
cd ffr
```
2. **Create environment file:**
```bash
cp docker/production/.env.example .env
```
3. **Configure your environment variables:**
```env
# Required variables only
APP_URL=http://your-domain.com:8000
DB_PASSWORD=your-secure-db-password
DB_ROOT_PASSWORD=your-secure-root-password
```
4. **Start the application:**
```bash
docker-compose -f docker/production/docker-compose.yml up -d
```
The application will be available at `http://localhost:8000`
### System Requirements
- Docker and Docker Compose (or Podman)
- 2GB RAM minimum
- 10GB disk space
- Linux/macOS/Windows with WSL2
## 🕹️ Usage
### Web Interface
Access the dashboard at `http://localhost:8000` to:
- View fetched articles
- Monitor posting queue
- Check system logs
- Manage keywords (coming in v2.0)
### Manual Commands
Trigger article refresh manually:
```bash
docker compose exec app php artisan article:refresh
``` ```
View application logs: ### Environment Variables
```bash
docker compose logs -f app
```
### Scheduled Tasks | 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 |
The application automatically: ## Development
- Fetches new articles every hour
- Publishes matching articles every 5 minutes
- Syncs with Lemmy communities every 10 minutes
## 📜 Logging & Debugging ### NixOS / Nix
**Log locations:**
- Application logs: Available in web dashboard under "Logs" section
- Docker logs: `docker compose logs -f app`
- Laravel logs: Inside container at `/var/www/html/backend/storage/logs/`
**Debug mode:**
To enable debug mode for troubleshooting, add to your `.env`:
```env
APP_DEBUG=true
```
⚠️ Remember to disable debug mode in production!
## 🤝 Contributing
We welcome contributions! Here's how you can help:
1. **Report bugs:** Open an issue describing the problem
2. **Suggest features:** Create an issue with your idea
3. **Submit PRs:** Fork, create a feature branch, and submit a pull request
4. **Improve docs:** Documentation improvements are always appreciated
For development setup, see the [Development Setup](#development-setup) section below.
## 📘 License
This project is licensed under the GNU Affero General Public License v3.0 (AGPLv3).
See [LICENSE](LICENSE) file for details.
## 🧭 Roadmap
### v1.0.0 (Current Release)
- ✅ Basic feed fetching from hardcoded sources
- ✅ Keyword filtering
- ✅ Lemmy posting
- ✅ Web dashboard
- ✅ Docker deployment
### v2.0.0 (Planned)
- [ ] User-configurable feed sources
- [ ] Advanced filtering rules (regex, boolean logic)
- [ ] Support for Mastodon and other ActivityPub platforms
- [ ] API for external integrations
- [ ] Multi-user support with permissions
### v3.0.0 (Future)
- [ ] Machine learning-based content categorization
- [ ] Feed discovery and recommendations
- [ ] Scheduled posting with optimal timing
- [ ] Analytics and insights dashboard
---
## Development Setup
For contributors and developers who want to work on FFR:
### Prerequisites
- Podman and podman-compose (or Docker)
- Git
- PHP 8.2+ (for local development)
- Node.js 18+ (for frontend development)
### Quick Start
1. **Clone and start the development environment:**
```bash
git clone https://codeberg.org/lvl0/ffr.git
cd ffr
./docker/dev/podman/start-dev.sh
```
2. **Access the development environment:**
- Web interface: http://localhost:8000
- Vite dev server: http://localhost:5173
- Database: localhost:3307
- Redis: localhost:6380
### Development Commands
```bash ```bash
# Run tests with coverage git clone https://forge.lvl0.xyz/lvl0/fedi-feed-router.git
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" cd ffr
nix-shell
# Execute artisan commands
podman-compose -f docker/dev/podman/docker-compose.yml exec app php artisan migrate
podman-compose -f docker/dev/podman/docker-compose.yml exec app php artisan tinker
# View logs
podman-compose -f docker/dev/podman/docker-compose.yml logs -f
# Access container shell
podman-compose -f docker/dev/podman/docker-compose.yml exec app bash
# Stop environment
podman-compose -f docker/dev/podman/docker-compose.yml down
``` ```
### Development Features The shell will display available commands and optionally start the containers for you.
- **Hot reload:** Vite automatically reloads frontend changes #### Available Commands
- **Pre-seeded database:** Sample data for immediate testing
- **Laravel Horizon:** Queue monitoring dashboard
- **Xdebug:** Configured for debugging and code coverage
- **Redis:** For caching, sessions, and queues
--- | 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 |
#### Services
| Service | URL |
|---------|-----|
| App | http://localhost:8000 |
| Vite | http://localhost:5173 |
| MariaDB | localhost:3307 |
| Redis | localhost:6380 |
### Other Platforms
Contributions welcome for development setup instructions on other platforms.
## License
This project is open-source software licensed under the [AGPL-3.0 license](LICENSE).
## Support ## Support
For help and support: For issues and questions, please use [Issues](https://forge.lvl0.xyz/lvl0/fedi-feed-router/issues).
- 💬 Open a [Discussion](https://codeberg.org/lvl0/ffr/discussions)
- 🐛 Report [Issues](https://codeberg.org/lvl0/ffr/issues)
---
<div align="center">
Built with ❤️ for the self-hosting community
</div>

View file

@ -2,9 +2,9 @@
namespace App\Console\Commands; namespace App\Console\Commands;
use Domains\Article\Jobs\ArticleDiscoveryJob; use App\Jobs\ArticleDiscoveryJob;
use Domains\Feed\Models\Feed; use App\Models\Feed;
use Domains\Settings\Models\Setting; use App\Models\Setting;
use Illuminate\Console\Command; use Illuminate\Console\Command;
class FetchNewArticlesCommand extends Command class FetchNewArticlesCommand extends Command

View file

@ -2,7 +2,7 @@
namespace App\Console\Commands; namespace App\Console\Commands;
use Domains\Platform\Jobs\SyncChannelPostsJob; use App\Jobs\SyncChannelPostsJob;
use Illuminate\Console\Command; use Illuminate\Console\Command;
class SyncChannelPostsCommand extends Command class SyncChannelPostsCommand extends Command

View file

@ -1,6 +1,6 @@
<?php <?php
namespace Domains\Article\Contracts; namespace App\Contracts;
interface ArticleParserInterface interface ArticleParserInterface
{ {

View file

@ -1,6 +1,6 @@
<?php <?php
namespace Domains\Article\Contracts; namespace App\Contracts;
interface HomepageParserInterface interface HomepageParserInterface
{ {

View file

@ -1,6 +1,6 @@
<?php <?php
namespace Domains\Logging\Enums; namespace App\Enums;
enum LogLevelEnum: string enum LogLevelEnum: string
{ {

View file

@ -1,6 +1,6 @@
<?php <?php
namespace Domains\Platform\Enums; namespace App\Enums;
enum PlatformEnum: string enum PlatformEnum: string
{ {

View file

@ -1,8 +1,8 @@
<?php <?php
namespace Domains\Article\Events; namespace App\Events;
use Domains\Article\Models\Article; use App\Models\Article;
use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;

View file

@ -1,8 +1,8 @@
<?php <?php
namespace Domains\Logging\Events; namespace App\Events;
use Domains\Logging\Models\Log; use App\Models\Log;
use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;

View file

@ -1,8 +1,8 @@
<?php <?php
namespace Domains\Logging\Events; namespace App\Events;
use Domains\Logging\Enums\LogLevelEnum; use App\Enums\LogLevelEnum;
use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Throwable; use Throwable;

View file

@ -1,8 +1,8 @@
<?php <?php
namespace Domains\Article\Events; namespace App\Events;
use Domains\Article\Models\Article; use App\Models\Article;
use Illuminate\Broadcasting\InteractsWithSockets; use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;

View file

@ -0,0 +1,9 @@
<?php
namespace App\Exceptions;
use Exception;
class ChannelException extends Exception
{
}

View file

@ -1,8 +1,8 @@
<?php <?php
namespace Domains\Platform\Exceptions; namespace App\Exceptions;
use Domains\Platform\Enums\PlatformEnum; use App\Enums\PlatformEnum;
use Exception; use Exception;
class PlatformAuthException extends Exception class PlatformAuthException extends Exception

View file

@ -1,9 +1,9 @@
<?php <?php
namespace Domains\Platform\Exceptions; namespace App\Exceptions;
use Domains\Platform\Enums\PlatformEnum; use App\Enums\PlatformEnum;
use Domains\Article\Models\Article; use App\Models\Article;
use Exception; use Exception;
use Throwable; use Throwable;

View file

@ -1,6 +1,6 @@
<?php <?php
namespace Domains\Feed\Exceptions; namespace App\Exceptions;
use Exception; use Exception;

View file

@ -1,9 +1,9 @@
<?php <?php
namespace Domains\Feed\Exceptions; namespace App\Exceptions;
use Domains\Feed\Models\Feed; use App\Models\Feed;
use Domains\Platform\Models\PlatformChannel; use App\Models\PlatformChannel;
class RoutingMismatchException extends RoutingException class RoutingMismatchException extends RoutingException
{ {

View file

@ -1,6 +1,6 @@
<?php <?php
namespace Domains\Logging\Facades; namespace App\Facades;
use Illuminate\Support\Facades\Facade; use Illuminate\Support\Facades\Facade;
@ -8,6 +8,6 @@ class LogSaver extends Facade
{ {
protected static function getFacadeAccessor() protected static function getFacadeAccessor()
{ {
return \Domains\Logging\Services\LogSaver::class; return \App\Services\Log\LogSaver::class;
} }
} }

View file

@ -2,10 +2,10 @@
namespace App\Http\Controllers\Api\V1; namespace App\Http\Controllers\Api\V1;
use Domains\Article\Resources\ArticleResource; use App\Http\Resources\ArticleResource;
use Domains\Article\Models\Article; use App\Models\Article;
use Domains\Settings\Models\Setting; use App\Models\Setting;
use Domains\Article\Jobs\ArticleDiscoveryJob; use App\Jobs\ArticleDiscoveryJob;
use Exception; use Exception;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;

View file

@ -0,0 +1,112 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\ValidationException;
class AuthController extends BaseController
{
/**
* Login user and create token
*/
public function login(Request $request): JsonResponse
{
try {
$request->validate([
'email' => 'required|email',
'password' => 'required',
]);
$user = User::where('email', $request->email)->first();
if (!$user || !Hash::check($request->password, $user->password)) {
return $this->sendError('Invalid credentials', [], 401);
}
$token = $user->createToken('api-token')->plainTextToken;
return $this->sendResponse([
'user' => [
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
],
'token' => $token,
'token_type' => 'Bearer',
], 'Login successful');
} catch (ValidationException $e) {
return $this->sendValidationError($e->errors());
} catch (\Exception $e) {
return $this->sendError('Login failed: ' . $e->getMessage(), [], 500);
}
}
/**
* Register a new user
*/
public function register(Request $request): JsonResponse
{
try {
$validated = $request->validate([
'name' => 'required|string|max:255',
'email' => 'required|string|email|max:255|unique:users',
'password' => 'required|string|min:8|confirmed',
]);
$user = User::create([
'name' => $validated['name'],
'email' => $validated['email'],
'password' => Hash::make($validated['password']),
]);
$token = $user->createToken('api-token')->plainTextToken;
return $this->sendResponse([
'user' => [
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
],
'token' => $token,
'token_type' => 'Bearer',
], 'Registration successful', 201);
} catch (ValidationException $e) {
return $this->sendValidationError($e->errors());
} catch (\Exception $e) {
return $this->sendError('Registration failed: ' . $e->getMessage(), [], 500);
}
}
/**
* Logout user (revoke token)
*/
public function logout(Request $request): JsonResponse
{
try {
$request->user()->currentAccessToken()->delete();
return $this->sendResponse(null, 'Logged out successfully');
} catch (\Exception $e) {
return $this->sendError('Logout failed: ' . $e->getMessage(), [], 500);
}
}
/**
* Get current authenticated user
*/
public function me(Request $request): JsonResponse
{
return $this->sendResponse([
'user' => [
'id' => $request->user()->id,
'name' => $request->user()->name,
'email' => $request->user()->email,
],
], 'User retrieved successfully');
}
}

View file

@ -2,11 +2,11 @@
namespace App\Http\Controllers\Api\V1; namespace App\Http\Controllers\Api\V1;
use Domains\Article\Models\Article; use App\Models\Article;
use Domains\Feed\Models\Feed; use App\Models\Feed;
use Domains\Platform\Models\PlatformAccount; use App\Models\PlatformAccount;
use Domains\Platform\Models\PlatformChannel; use App\Models\PlatformChannel;
use Domains\Article\Services\DashboardStatsService; use App\Services\DashboardStatsService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@ -40,6 +40,7 @@ public function stats(Request $request): JsonResponse
'current_period' => $period, 'current_period' => $period,
]); ]);
} catch (\Exception $e) { } catch (\Exception $e) {
throw $e;
return $this->sendError('Failed to fetch dashboard stats: ' . $e->getMessage(), [], 500); return $this->sendError('Failed to fetch dashboard stats: ' . $e->getMessage(), [], 500);
} }
} }

View file

@ -2,10 +2,10 @@
namespace App\Http\Controllers\Api\V1; namespace App\Http\Controllers\Api\V1;
use Domains\Feed\Requests\StoreFeedRequest; use App\Http\Requests\StoreFeedRequest;
use Domains\Feed\Requests\UpdateFeedRequest; use App\Http\Requests\UpdateFeedRequest;
use Domains\Feed\Resources\FeedResource; use App\Http\Resources\FeedResource;
use Domains\Feed\Models\Feed; use App\Models\Feed;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
@ -19,7 +19,7 @@ public function index(Request $request): JsonResponse
{ {
$perPage = min($request->get('per_page', 15), 100); $perPage = min($request->get('per_page', 15), 100);
$feeds = Feed::with(['languages']) $feeds = Feed::with(['language'])
->withCount('articles') ->withCount('articles')
->orderBy('is_active', 'desc') ->orderBy('is_active', 'desc')
->orderBy('name') ->orderBy('name')
@ -49,35 +49,15 @@ public function store(StoreFeedRequest $request): JsonResponse
// Map provider to URL and set type // Map provider to URL and set type
$providers = [ $providers = [
'vrt' => new \Domains\Article\Parsers\Vrt\VrtHomepageParserAdapter(), 'vrt' => new \App\Services\Parsers\VrtHomepageParserAdapter(),
'belga' => new \Domains\Article\Parsers\Belga\BelgaHomepageParserAdapter(), 'belga' => new \App\Services\Parsers\BelgaHomepageParserAdapter(),
]; ];
$adapter = $providers[$validated['provider']]; $adapter = $providers[$validated['provider']];
$validated['url'] = $adapter->getHomepageUrl(); $validated['url'] = $adapter->getHomepageUrl();
$validated['type'] = 'website'; $validated['type'] = 'website';
// Extract language-related data
$languageIds = $validated['language_ids'] ?? [];
$primaryLanguageId = $validated['primary_language_id'] ?? $languageIds[0] ?? null;
$languageUrls = $validated['language_urls'] ?? [];
// Remove language fields from feed data
unset($validated['language_ids'], $validated['primary_language_id'], $validated['language_urls']);
$feed = Feed::create($validated); $feed = Feed::create($validated);
// Attach languages to the feed
foreach ($languageIds as $index => $languageId) {
$pivotData = [
'url' => $languageUrls[$languageId] ?? $validated['url'],
'is_active' => true,
'is_primary' => $languageId == $primaryLanguageId,
];
$feed->languages()->attach($languageId, $pivotData);
}
$feed->load('languages');
return $this->sendResponse( return $this->sendResponse(
new FeedResource($feed), new FeedResource($feed),
@ -96,8 +76,6 @@ public function store(StoreFeedRequest $request): JsonResponse
*/ */
public function show(Feed $feed): JsonResponse public function show(Feed $feed): JsonResponse
{ {
$feed->load('languages');
return $this->sendResponse( return $this->sendResponse(
new FeedResource($feed), new FeedResource($feed),
'Feed retrieved successfully.' 'Feed retrieved successfully.'
@ -113,34 +91,10 @@ public function update(UpdateFeedRequest $request, Feed $feed): JsonResponse
$validated = $request->validated(); $validated = $request->validated();
$validated['is_active'] = $validated['is_active'] ?? $feed->is_active; $validated['is_active'] = $validated['is_active'] ?? $feed->is_active;
// Extract language-related data
$languageIds = $validated['language_ids'] ?? null;
$primaryLanguageId = $validated['primary_language_id'] ?? null;
$languageUrls = $validated['language_urls'] ?? [];
// Remove language fields from feed data
unset($validated['language_ids'], $validated['primary_language_id'], $validated['language_urls']);
$feed->update($validated); $feed->update($validated);
// Update languages if provided
if ($languageIds !== null) {
// Sync languages with the feed
$syncData = [];
foreach ($languageIds as $index => $languageId) {
$syncData[$languageId] = [
'url' => $languageUrls[$languageId] ?? $feed->url,
'is_active' => true,
'is_primary' => $primaryLanguageId ? $languageId == $primaryLanguageId : $index === 0,
];
}
$feed->languages()->sync($syncData);
}
$feed->load('languages');
return $this->sendResponse( return $this->sendResponse(
new FeedResource($feed), new FeedResource($feed->fresh()),
'Feed updated successfully!' 'Feed updated successfully!'
); );
} catch (ValidationException $e) { } catch (ValidationException $e) {

View file

@ -2,9 +2,9 @@
namespace App\Http\Controllers\Api\V1; namespace App\Http\Controllers\Api\V1;
use Domains\Feed\Models\Feed; use App\Models\Feed;
use Domains\Article\Models\Keyword; use App\Models\Keyword;
use Domains\Platform\Models\PlatformChannel; use App\Models\PlatformChannel;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;

View file

@ -2,7 +2,7 @@
namespace App\Http\Controllers\Api\V1; namespace App\Http\Controllers\Api\V1;
use Domains\Logging\Models\Log; use App\Models\Log;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;

View file

@ -2,22 +2,20 @@
namespace App\Http\Controllers\Api\V1; namespace App\Http\Controllers\Api\V1;
use Domains\Feed\Requests\StoreFeedRequest; use App\Http\Requests\StoreFeedRequest;
use Domains\Feed\Resources\FeedResource; use App\Http\Resources\FeedResource;
use Domains\Platform\Resources\PlatformAccountResource; use App\Http\Resources\PlatformAccountResource;
use Domains\Platform\Resources\PlatformChannelResource; use App\Http\Resources\PlatformChannelResource;
use Domains\Feed\Resources\RouteResource; use App\Http\Resources\RouteResource;
use Domains\Article\Jobs\ArticleDiscoveryJob; use App\Jobs\ArticleDiscoveryJob;
use Domains\Feed\Models\Feed; use App\Models\Feed;
use Domains\Settings\Models\Language; use App\Models\Language;
use Domains\Platform\Models\PlatformAccount; use App\Models\PlatformAccount;
use Domains\Platform\Models\PlatformChannel; use App\Models\PlatformChannel;
use Domains\Platform\Models\PlatformInstance; use App\Models\PlatformInstance;
use Domains\Feed\Models\Route; use App\Models\Route;
use Domains\Settings\Models\Setting; use App\Models\Setting;
use Domains\Platform\Services\Auth\Authenticators\LemmyAuthService; use App\Services\Auth\LemmyAuthService;
use Domains\Platform\Services\ChannelLanguageDetectionService;
use Domains\Platform\Exceptions\ChannelException;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Validator;
@ -91,19 +89,13 @@ public function options(): JsonResponse
// Get existing feeds and channels for route creation // Get existing feeds and channels for route creation
$feeds = Feed::where('is_active', true) $feeds = Feed::where('is_active', true)
->with('languages')
->orderBy('name') ->orderBy('name')
->get(['id', 'name', 'url', 'type']); ->get(['id', 'name', 'url', 'type']);
$platformChannels = PlatformChannel::where('is_active', true) $platformChannels = PlatformChannel::where('is_active', true)
->with(['platformInstance:id,name,url', 'language:id,name,short_code']) ->with(['platformInstance:id,name,url'])
->orderBy('name') ->orderBy('name')
->get(['id', 'platform_instance_id', 'name', 'display_name', 'description', 'language_id']); ->get(['id', 'platform_instance_id', 'name', 'display_name', 'description']);
// Get languages available for routes (have both feeds and channels)
$availableLanguages = Language::availableForRoutes()
->orderBy('name')
->get(['id', 'short_code', 'name', 'native_name']);
// Get feed providers from config // Get feed providers from config
$feedProviders = collect(config('feed.providers', [])) $feedProviders = collect(config('feed.providers', []))
@ -115,7 +107,6 @@ public function options(): JsonResponse
'platform_instances' => $platformInstances, 'platform_instances' => $platformInstances,
'feeds' => $feeds, 'feeds' => $feeds,
'platform_channels' => $platformChannels, 'platform_channels' => $platformChannels,
'available_languages' => $availableLanguages,
'feed_providers' => $feedProviders, 'feed_providers' => $feedProviders,
], 'Onboarding options retrieved successfully.'); ], 'Onboarding options retrieved successfully.');
} }
@ -183,7 +174,7 @@ public function createPlatform(Request $request): JsonResponse
'Platform account created successfully.' 'Platform account created successfully.'
); );
} catch (\Domains\Platform\Exceptions\PlatformAuthException $e) { } catch (\App\Exceptions\PlatformAuthException $e) {
// Check if it's a rate limit error // Check if it's a rate limit error
if (str_contains($e->getMessage(), 'Rate limited by')) { if (str_contains($e->getMessage(), 'Rate limited by')) {
return $this->sendError($e->getMessage(), [], 429); return $this->sendError($e->getMessage(), [], 429);
@ -213,9 +204,7 @@ public function createFeed(Request $request): JsonResponse
$validator = Validator::make($request->all(), [ $validator = Validator::make($request->all(), [
'name' => 'required|string|max:255', 'name' => 'required|string|max:255',
'provider' => 'required|in:belga,vrt', 'provider' => 'required|in:belga,vrt',
'language_ids' => 'required|array|min:1', 'language_id' => 'required|exists:languages,id',
'language_ids.*' => 'exists:languages,id',
'primary_language_id' => 'nullable|exists:languages,id',
'description' => 'nullable|string|max:1000', 'description' => 'nullable|string|max:1000',
]); ]);
@ -235,34 +224,20 @@ public function createFeed(Request $request): JsonResponse
$url = 'https://www.belganewsagency.eu/'; $url = 'https://www.belganewsagency.eu/';
} }
// Extract language-related data
$languageIds = $validated['language_ids'];
$primaryLanguageId = $validated['primary_language_id'] ?? $languageIds[0];
$feed = Feed::firstOrCreate( $feed = Feed::firstOrCreate(
['url' => $url], ['url' => $url],
[ [
'name' => $validated['name'], 'name' => $validated['name'],
'type' => $type, 'type' => $type,
'provider' => $provider, 'provider' => $provider,
'language_id' => $validated['language_id'],
'description' => $validated['description'] ?? null, 'description' => $validated['description'] ?? null,
'is_active' => true, 'is_active' => true,
] ]
); );
// Sync languages with the feed
$syncData = [];
foreach ($languageIds as $languageId) {
$syncData[$languageId] = [
'url' => $url,
'is_active' => true,
'is_primary' => $languageId == $primaryLanguageId,
];
}
$feed->languages()->sync($syncData);
return $this->sendResponse( return $this->sendResponse(
new FeedResource($feed->load('languages')), new FeedResource($feed->load('language')),
'Feed created successfully.' 'Feed created successfully.'
); );
} }
@ -276,6 +251,7 @@ public function createChannel(Request $request): JsonResponse
$validator = Validator::make($request->all(), [ $validator = Validator::make($request->all(), [
'name' => 'required|string|max:255', 'name' => 'required|string|max:255',
'platform_instance_id' => 'required|exists:platform_instances,id', 'platform_instance_id' => 'required|exists:platform_instances,id',
'language_id' => 'required|exists:languages,id',
'description' => 'nullable|string|max:1000', 'description' => 'nullable|string|max:1000',
]); ]);
@ -301,22 +277,6 @@ public function createChannel(Request $request): JsonResponse
); );
} }
// Detect and validate channel languages
$languageDetectionService = app(ChannelLanguageDetectionService::class);
try {
$languageInfo = $languageDetectionService->detectChannelLanguages(
$validated['name'],
$validated['platform_instance_id']
);
// Add detected language to validated data
$validated['language_id'] = $languageInfo['language_id'];
} catch (ChannelException $e) {
return $this->sendError($e->getMessage(), [], 422);
}
$channel = PlatformChannel::create([ $channel = PlatformChannel::create([
'platform_instance_id' => $validated['platform_instance_id'], 'platform_instance_id' => $validated['platform_instance_id'],
'channel_id' => $validated['name'], // For Lemmy, this is the community name 'channel_id' => $validated['name'], // For Lemmy, this is the community name
@ -336,16 +296,9 @@ public function createChannel(Request $request): JsonResponse
'updated_at' => now(), 'updated_at' => now(),
]); ]);
$responseMessage = 'Channel created successfully and linked to platform account.';
// Add information about language detection if fallback was used
if (isset($languageInfo['fallback_used']) && $languageInfo['fallback_used']) {
$responseMessage .= ' Note: Used default language due to detection issue.';
}
return $this->sendResponse( return $this->sendResponse(
new PlatformChannelResource($channel->load(['platformInstance', 'language', 'platformAccounts'])), new PlatformChannelResource($channel->load(['platformInstance', 'language', 'platformAccounts'])),
$responseMessage 'Channel created successfully and linked to platform account.'
); );
} }
@ -359,7 +312,6 @@ public function createRoute(Request $request): JsonResponse
$validator = Validator::make($request->all(), [ $validator = Validator::make($request->all(), [
'feed_id' => 'required|exists:feeds,id', 'feed_id' => 'required|exists:feeds,id',
'platform_channel_id' => 'required|exists:platform_channels,id', 'platform_channel_id' => 'required|exists:platform_channels,id',
'language_id' => 'required|exists:languages,id',
'priority' => 'nullable|integer|min:1|max:100', 'priority' => 'nullable|integer|min:1|max:100',
]); ]);
@ -369,22 +321,9 @@ public function createRoute(Request $request): JsonResponse
$validated = $validator->validated(); $validated = $validator->validated();
// Validate language consistency
$feed = Feed::find($validated['feed_id']);
$channel = PlatformChannel::find($validated['platform_channel_id']);
if (!$feed || !$feed->languages()->where('languages.id', $validated['language_id'])->exists()) {
return $this->sendError('The selected feed does not support the chosen language.', [], 422);
}
if (!$channel || $channel->language_id !== (int)$validated['language_id']) {
return $this->sendError('The selected channel does not support the chosen language.', [], 422);
}
$route = Route::create([ $route = Route::create([
'feed_id' => $validated['feed_id'], 'feed_id' => $validated['feed_id'],
'platform_channel_id' => $validated['platform_channel_id'], 'platform_channel_id' => $validated['platform_channel_id'],
'language_id' => $validated['language_id'],
'priority' => $validated['priority'] ?? 50, 'priority' => $validated['priority'] ?? 50,
'is_active' => true, 'is_active' => true,
]); ]);
@ -394,7 +333,7 @@ public function createRoute(Request $request): JsonResponse
ArticleDiscoveryJob::dispatch(); ArticleDiscoveryJob::dispatch();
return $this->sendResponse( return $this->sendResponse(
new RouteResource($route->load(['feed', 'platformChannel', 'language'])), new RouteResource($route->load(['feed', 'platformChannel'])),
'Route created successfully.' 'Route created successfully.'
); );
} }

View file

@ -2,10 +2,10 @@
namespace App\Http\Controllers\Api\V1; namespace App\Http\Controllers\Api\V1;
use Domains\Platform\Enums\PlatformEnum; use App\Enums\PlatformEnum;
use Domains\Platform\Resources\PlatformAccountResource; use App\Http\Resources\PlatformAccountResource;
use Domains\Platform\Models\PlatformAccount; use App\Models\PlatformAccount;
use Domains\Platform\Models\PlatformInstance; use App\Models\PlatformInstance;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;

View file

@ -2,17 +2,12 @@
namespace App\Http\Controllers\Api\V1; namespace App\Http\Controllers\Api\V1;
use Domains\Platform\Resources\PlatformChannelResource; use App\Http\Resources\PlatformChannelResource;
use Domains\Platform\Models\PlatformChannel; use App\Models\PlatformChannel;
use Domains\Platform\Models\PlatformAccount; use App\Models\PlatformAccount;
use Domains\Platform\Models\PlatformInstance;
use Domains\Platform\Services\ChannelLanguageDetectionService;
use Domains\Platform\Api\Lemmy\LemmyApiService;
use Domains\Platform\Exceptions\ChannelException;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
use Illuminate\Support\Facades\Cache;
class PlatformChannelsController extends BaseController class PlatformChannelsController extends BaseController
{ {
@ -50,7 +45,7 @@ public function store(Request $request): JsonResponse
$validated['is_active'] = $validated['is_active'] ?? true; $validated['is_active'] = $validated['is_active'] ?? true;
// Get the platform instance to check for active accounts // Get the platform instance to check for active accounts
$platformInstance = \Domains\Platform\Models\PlatformInstance::findOrFail($validated['platform_instance_id']); $platformInstance = \App\Models\PlatformInstance::findOrFail($validated['platform_instance_id']);
// Check if there are active platform accounts for this instance // Check if there are active platform accounts for this instance
$activeAccounts = PlatformAccount::where('instance_url', $platformInstance->url) $activeAccounts = PlatformAccount::where('instance_url', $platformInstance->url)
@ -65,22 +60,6 @@ public function store(Request $request): JsonResponse
); );
} }
// Detect and validate channel languages
$languageDetectionService = app(ChannelLanguageDetectionService::class);
try {
$languageInfo = $languageDetectionService->detectChannelLanguages(
$validated['name'],
$validated['platform_instance_id']
);
// Add detected language to validated data
$validated['language_id'] = $languageInfo['language_id'];
} catch (ChannelException $e) {
return $this->sendError($e->getMessage(), [], 422);
}
$channel = PlatformChannel::create($validated); $channel = PlatformChannel::create($validated);
// Automatically attach the first active account to the channel // Automatically attach the first active account to the channel
@ -92,16 +71,9 @@ public function store(Request $request): JsonResponse
'updated_at' => now(), 'updated_at' => now(),
]); ]);
$responseMessage = 'Platform channel created successfully and linked to platform account!';
// Add information about language detection if fallback was used
if (isset($languageInfo['fallback_used']) && $languageInfo['fallback_used']) {
$responseMessage .= ' Note: Used default language due to detection issue.';
}
return $this->sendResponse( return $this->sendResponse(
new PlatformChannelResource($channel->load(['platformInstance', 'platformAccounts', 'language'])), new PlatformChannelResource($channel->load(['platformInstance', 'platformAccounts'])),
$responseMessage, 'Platform channel created successfully and linked to platform account!',
201 201
); );
} catch (ValidationException $e) { } catch (ValidationException $e) {
@ -274,108 +246,4 @@ public function updateAccountRelation(PlatformChannel $channel, PlatformAccount
return $this->sendError('Failed to update platform account relationship: ' . $e->getMessage(), [], 500); return $this->sendError('Failed to update platform account relationship: ' . $e->getMessage(), [], 500);
} }
} }
/**
* Get available communities for a platform instance
*/
public function getCommunities(Request $request): JsonResponse
{
try {
$validated = $request->validate([
'platform_instance_id' => 'required|exists:platform_instances,id',
'type' => 'sometimes|string|in:Local,All,Subscribed',
'sort' => 'sometimes|string|in:Hot,Active,New,TopDay,TopWeek,TopMonth,TopYear,TopAll',
'limit' => 'sometimes|integer|min:1|max:100',
'page' => 'sometimes|integer|min:1',
'show_nsfw' => 'sometimes|boolean',
]);
$platformInstance = PlatformInstance::findOrFail($validated['platform_instance_id']);
// Check if there are active platform accounts for this instance to get auth token
$activeAccount = PlatformAccount::where('instance_url', $platformInstance->url)
->where('is_active', true)
->first();
if (!$activeAccount) {
return $this->sendError(
'Cannot fetch communities: No active platform accounts found for this instance. Please create a platform account first.',
[],
422
);
}
// Create cache key based on instance and parameters
$cacheKey = sprintf(
'communities:%s:%s:%s:%d:%d:%s',
$platformInstance->id,
$validated['type'] ?? 'Local',
$validated['sort'] ?? 'Active',
$validated['limit'] ?? 50,
$validated['page'] ?? 1,
$validated['show_nsfw'] ?? false ? '1' : '0'
);
// Try to get communities from cache first (cache for 10 minutes)
$communities = Cache::remember($cacheKey, 600, function () use ($platformInstance, $activeAccount, $validated) {
$apiService = app(LemmyApiService::class, ['instance' => $platformInstance->url]);
return $apiService->listCommunities(
$activeAccount->settings['api_token'] ?? null,
$validated['type'] ?? 'Local',
$validated['sort'] ?? 'Active',
$validated['limit'] ?? 50,
$validated['page'] ?? 1,
$validated['show_nsfw'] ?? false
);
});
// Transform the response to include only relevant data and add helpful fields
$transformedCommunities = collect($communities['communities'] ?? [])->map(function ($item) {
$community = $item['community'] ?? [];
return [
'id' => $community['id'] ?? null,
'name' => $community['name'] ?? null,
'title' => $community['title'] ?? null,
'description' => $community['description'] ?? null,
'nsfw' => $community['nsfw'] ?? false,
'local' => $community['local'] ?? false,
'subscribers' => $item['counts']['subscribers'] ?? 0,
'posts' => $item['counts']['posts'] ?? 0,
'display_text' => sprintf(
'%s (%s subscribers)',
$community['title'] ?? $community['name'] ?? 'Unknown',
number_format($item['counts']['subscribers'] ?? 0)
),
];
});
return $this->sendResponse([
'communities' => $transformedCommunities,
'total' => $transformedCommunities->count(),
'platform_instance' => [
'id' => $platformInstance->id,
'name' => $platformInstance->name,
'url' => $platformInstance->url,
],
'parameters' => [
'type' => $validated['type'] ?? 'Local',
'sort' => $validated['sort'] ?? 'Active',
'limit' => $validated['limit'] ?? 50,
'page' => $validated['page'] ?? 1,
'show_nsfw' => $validated['show_nsfw'] ?? false,
]
], 'Communities retrieved successfully.');
} catch (ValidationException $e) {
return $this->sendValidationError($e->errors());
} catch (\Exception $e) {
// Clear cache on error to prevent serving stale data
if (isset($cacheKey)) {
Cache::forget($cacheKey);
}
return $this->sendError('Failed to fetch communities: ' . $e->getMessage(), [], 500);
}
}
} }

View file

@ -2,28 +2,22 @@
namespace App\Http\Controllers\Api\V1; namespace App\Http\Controllers\Api\V1;
use Domains\Feed\Resources\RouteResource; use App\Http\Resources\RouteResource;
use Domains\Feed\Models\Feed; use App\Models\Feed;
use Domains\Platform\Models\PlatformChannel; use App\Models\PlatformChannel;
use Domains\Feed\Models\Route; use App\Models\Route;
use Domains\Feed\Requests\StoreRouteRequest;
use Domains\Feed\Requests\UpdateRouteRequest;
use Domains\Settings\Services\LanguageService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
class RoutingController extends BaseController class RoutingController extends BaseController
{ {
public function __construct(
private LanguageService $languageService
) {}
/** /**
* Display a listing of routing configurations * Display a listing of routing configurations
*/ */
public function index(): JsonResponse public function index(): JsonResponse
{ {
$routes = Route::withAllRelationships() $routes = Route::with(['feed', 'platformChannel', 'keywords'])
->orderBy('is_active', 'desc') ->orderBy('is_active', 'desc')
->orderBy('priority', 'asc') ->orderBy('priority', 'asc')
->get(); ->get();
@ -37,27 +31,23 @@ public function index(): JsonResponse
/** /**
* Store a newly created routing configuration * Store a newly created routing configuration
*/ */
public function store(StoreRouteRequest $request): JsonResponse public function store(Request $request): JsonResponse
{ {
try { try {
$validated = $request->validated(); $validated = $request->validate([
'feed_id' => 'required|exists:feeds,id',
'platform_channel_id' => 'required|exists:platform_channels,id',
'is_active' => 'boolean',
'priority' => 'nullable|integer|min:0',
]);
$validated['is_active'] = $validated['is_active'] ?? true; $validated['is_active'] = $validated['is_active'] ?? true;
$validated['priority'] = $validated['priority'] ?? 0; $validated['priority'] = $validated['priority'] ?? 0;
$route = Route::create($validated); $route = Route::create($validated);
// Load relationships efficiently
$route->load([
'feed:id,name,url,type,provider,is_active',
'platformChannel:id,platform_instance_id,name,display_name,description,language_id,is_active',
'platformChannel.platformInstance:id,name,url',
'language:id,short_code,name,native_name,is_active',
'keywords:id,feed_id,platform_channel_id,keyword,is_active'
]);
return $this->sendResponse( return $this->sendResponse(
new RouteResource($route), new RouteResource($route->load(['feed', 'platformChannel', 'keywords'])),
'Routing configuration created successfully!', 'Routing configuration created successfully!',
201 201
); );
@ -79,13 +69,7 @@ public function show(Feed $feed, PlatformChannel $channel): JsonResponse
return $this->sendNotFound('Routing configuration not found.'); return $this->sendNotFound('Routing configuration not found.');
} }
$route->load([ $route->load(['feed', 'platformChannel', 'keywords']);
'feed:id,name,url,type,provider,is_active',
'platformChannel:id,platform_instance_id,name,display_name,description,language_id,is_active',
'platformChannel.platformInstance:id,name,url',
'language:id,short_code,name,native_name,is_active',
'keywords:id,feed_id,platform_channel_id,keyword,is_active'
]);
return $this->sendResponse( return $this->sendResponse(
new RouteResource($route), new RouteResource($route),
@ -96,7 +80,7 @@ public function show(Feed $feed, PlatformChannel $channel): JsonResponse
/** /**
* Update the specified routing configuration * Update the specified routing configuration
*/ */
public function update(UpdateRouteRequest $request, Feed $feed, PlatformChannel $channel): JsonResponse public function update(Request $request, Feed $feed, PlatformChannel $channel): JsonResponse
{ {
try { try {
$route = $this->findRoute($feed, $channel); $route = $this->findRoute($feed, $channel);
@ -105,14 +89,17 @@ public function update(UpdateRouteRequest $request, Feed $feed, PlatformChannel
return $this->sendNotFound('Routing configuration not found.'); return $this->sendNotFound('Routing configuration not found.');
} }
$validated = $request->validated(); $validated = $request->validate([
'is_active' => 'boolean',
'priority' => 'nullable|integer|min:0',
]);
Route::where('feed_id', $feed->id) Route::where('feed_id', $feed->id)
->where('platform_channel_id', $channel->id) ->where('platform_channel_id', $channel->id)
->update($validated); ->update($validated);
return $this->sendResponse( return $this->sendResponse(
new RouteResource($route->fresh(['feed', 'platformChannel', 'language', 'keywords'])), new RouteResource($route->fresh(['feed', 'platformChannel', 'keywords'])),
'Routing configuration updated successfully!' 'Routing configuration updated successfully!'
); );
} catch (ValidationException $e) { } catch (ValidationException $e) {
@ -167,7 +154,7 @@ public function toggle(Feed $feed, PlatformChannel $channel): JsonResponse
$status = $newStatus ? 'activated' : 'deactivated'; $status = $newStatus ? 'activated' : 'deactivated';
return $this->sendResponse( return $this->sendResponse(
new RouteResource($route->fresh(['feed', 'platformChannel', 'language', 'keywords'])), new RouteResource($route->fresh(['feed', 'platformChannel', 'keywords'])),
"Routing configuration {$status} successfully!" "Routing configuration {$status} successfully!"
); );
} catch (\Exception $e) { } catch (\Exception $e) {
@ -175,19 +162,6 @@ public function toggle(Feed $feed, PlatformChannel $channel): JsonResponse
} }
} }
/**
* Get common languages for feed and channel
*/
public function commonLanguages(Feed $feed, PlatformChannel $channel): JsonResponse
{
$commonLanguages = $this->languageService->getCommonLanguages($feed->id, $channel->id);
return $this->sendResponse(
$commonLanguages,
'Common languages for feed and channel retrieved successfully.'
);
}
/** /**
* Find a route by feed and channel * Find a route by feed and channel
*/ */

View file

@ -2,7 +2,7 @@
namespace App\Http\Controllers\Api\V1; namespace App\Http\Controllers\Api\V1;
use Domains\Settings\Models\Setting; use App\Models\Setting;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
@ -34,15 +34,15 @@ public function update(Request $request): JsonResponse
try { try {
$validated = $request->validate([ $validated = $request->validate([
'article_processing_enabled' => 'boolean', 'article_processing_enabled' => 'boolean',
'enable_publishing_approvals' => 'boolean', 'publishing_approvals_enabled' => 'boolean',
]); ]);
if (isset($validated['article_processing_enabled'])) { if (isset($validated['article_processing_enabled'])) {
Setting::setArticleProcessingEnabled($validated['article_processing_enabled']); Setting::setArticleProcessingEnabled($validated['article_processing_enabled']);
} }
if (isset($validated['enable_publishing_approvals'])) { if (isset($validated['publishing_approvals_enabled'])) {
Setting::setPublishingApprovalsEnabled($validated['enable_publishing_approvals']); Setting::setPublishingApprovalsEnabled($validated['publishing_approvals_enabled']);
} }
$updatedSettings = [ $updatedSettings = [
@ -60,4 +60,4 @@ public function update(Request $request): JsonResponse
return $this->sendError('Failed to update settings: ' . $e->getMessage(), [], 500); return $this->sendError('Failed to update settings: ' . $e->getMessage(), [], 500);
} }
} }
} }

View file

@ -0,0 +1,47 @@
<?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('/');
}
}

View file

@ -0,0 +1,40 @@
<?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));
}
}

View file

@ -0,0 +1,24 @@
<?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');
}
}

View file

@ -0,0 +1,21 @@
<?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');
}
}

View file

@ -0,0 +1,62 @@
<?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)]);
}
}

View file

@ -0,0 +1,29 @@
<?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');
}
}

View file

@ -0,0 +1,44 @@
<?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)]);
}
}

View file

@ -0,0 +1,50 @@
<?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));
}
}

View file

@ -0,0 +1,27 @@
<?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');
}
}

View file

@ -0,0 +1,60 @@
<?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('/');
}
}

View file

@ -0,0 +1,29 @@
<?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);
}
}

View file

@ -0,0 +1,29 @@
<?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);
}
}

View file

@ -0,0 +1,85 @@
<?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());
}
}

View file

@ -0,0 +1,30 @@
<?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),
],
];
}
}

View file

@ -0,0 +1,27 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreFeedRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, string>
*/
public function rules(): array
{
return [
'name' => 'required|string|max:255',
'provider' => 'required|in:vrt,belga',
'language_id' => 'required|exists:languages,id',
'description' => 'nullable|string',
'is_active' => 'boolean'
];
}
}

View file

@ -0,0 +1,29 @@
<?php
namespace App\Http\Requests;
use App\Models\Feed;
use Illuminate\Foundation\Http\FormRequest;
class UpdateFeedRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, string>
*/
public function rules(): array
{
return [
'name' => 'required|string|max:255',
'url' => 'required|url|unique:feeds,url,' . ($this->route('feed') instanceof Feed ? (string)$this->route('feed')->id : (string)$this->route('feed')),
'type' => 'required|in:website,rss',
'language_id' => 'required|exists:languages,id',
'description' => 'nullable|string',
'is_active' => 'boolean'
];
}
}

View file

@ -1,6 +1,6 @@
<?php <?php
namespace Domains\Article\Resources; namespace App\Http\Resources;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\Json\JsonResource;

View file

@ -1,9 +1,7 @@
<?php <?php
namespace Domains\Article\Resources; namespace App\Http\Resources;
use Domains\Feed\Resources\FeedResource;
use Domains\Article\Resources\ArticlePublicationResource;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\Json\JsonResource;
@ -23,6 +21,7 @@ public function toArray(Request $request): array
'is_valid' => $this->is_valid, 'is_valid' => $this->is_valid,
'is_duplicate' => $this->is_duplicate, 'is_duplicate' => $this->is_duplicate,
'approval_status' => $this->approval_status, 'approval_status' => $this->approval_status,
'publish_status' => $this->publish_status,
'approved_at' => $this->approved_at?->toISOString(), 'approved_at' => $this->approved_at?->toISOString(),
'approved_by' => $this->approved_by, 'approved_by' => $this->approved_by,
'fetched_at' => $this->fetched_at?->toISOString(), 'fetched_at' => $this->fetched_at?->toISOString(),

View file

@ -0,0 +1,34 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class FeedResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'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(),
'updated_at' => $this->updated_at->toISOString(),
'articles_count' => $this->when(
$request->routeIs('api.feeds.*') && isset($this->articles_count),
$this->articles_count
),
];
}
}

View file

@ -1,6 +1,6 @@
<?php <?php
namespace Domains\Platform\Resources; namespace App\Http\Resources;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\Json\JsonResource;

View file

@ -1,10 +1,7 @@
<?php <?php
namespace Domains\Platform\Resources; namespace App\Http\Resources;
use Domains\Feed\Resources\RouteResource;
use Domains\Platform\Resources\PlatformInstanceResource;
use Domains\Platform\Resources\PlatformAccountResource;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\Json\JsonResource;

View file

@ -1,6 +1,6 @@
<?php <?php
namespace Domains\Platform\Resources; namespace App\Http\Resources;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\Json\JsonResource;

View file

@ -1,10 +1,7 @@
<?php <?php
namespace Domains\Feed\Resources; namespace App\Http\Resources;
use Domains\Feed\Resources\FeedResource;
use Domains\Platform\Resources\PlatformChannelResource;
use Domains\Settings\Resources\LanguageResource;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\Json\JsonResource;
@ -21,14 +18,12 @@ public function toArray(Request $request): array
'id' => $this->id, 'id' => $this->id,
'feed_id' => $this->feed_id, 'feed_id' => $this->feed_id,
'platform_channel_id' => $this->platform_channel_id, 'platform_channel_id' => $this->platform_channel_id,
'language_id' => $this->language_id,
'is_active' => $this->is_active, 'is_active' => $this->is_active,
'priority' => $this->priority, 'priority' => $this->priority,
'created_at' => $this->created_at->toISOString(), 'created_at' => $this->created_at->toISOString(),
'updated_at' => $this->updated_at->toISOString(), 'updated_at' => $this->updated_at->toISOString(),
'feed' => new FeedResource($this->whenLoaded('feed')), 'feed' => new FeedResource($this->whenLoaded('feed')),
'platform_channel' => new PlatformChannelResource($this->whenLoaded('platformChannel')), 'platform_channel' => new PlatformChannelResource($this->whenLoaded('platformChannel')),
'language' => new LanguageResource($this->whenLoaded('language')),
'keywords' => $this->whenLoaded('keywords', function () { 'keywords' => $this->whenLoaded('keywords', function () {
return $this->keywords->map(function ($keyword) { return $this->keywords->map(function ($keyword) {
return [ return [

View file

@ -1,10 +1,10 @@
<?php <?php
namespace Domains\Article\Jobs; namespace App\Jobs;
use Domains\Feed\Models\Feed; use App\Models\Feed;
use Domains\Article\Services\ArticleFetcher; use App\Services\Article\ArticleFetcher;
use Domains\Logging\Services\LogSaver; use App\Services\Log\LogSaver;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable; use Illuminate\Foundation\Queue\Queueable;

View file

@ -1,9 +1,9 @@
<?php <?php
namespace Domains\Article\Jobs; namespace App\Jobs;
use Domains\Settings\Models\Setting; use App\Models\Setting;
use Domains\Logging\Services\LogSaver; use App\Services\Log\LogSaver;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable; use Illuminate\Foundation\Queue\Queueable;

View file

@ -1,11 +1,11 @@
<?php <?php
namespace Domains\Platform\Jobs; namespace App\Jobs;
use Domains\Platform\Exceptions\PublishException; use App\Exceptions\PublishException;
use Domains\Article\Models\Article; use App\Models\Article;
use Domains\Article\Services\ArticleFetcher; use App\Services\Article\ArticleFetcher;
use Domains\Platform\Services\Publishing\ArticlePublishingService; use App\Services\Publishing\ArticlePublishingService;
use Illuminate\Contracts\Queue\ShouldBeUnique; use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable; use Illuminate\Foundation\Queue\Queueable;

View file

@ -1,13 +1,13 @@
<?php <?php
namespace Domains\Platform\Jobs; namespace App\Jobs;
use Domains\Platform\Enums\PlatformEnum; use App\Enums\PlatformEnum;
use Domains\Platform\Exceptions\PlatformAuthException; use App\Exceptions\PlatformAuthException;
use Domains\Platform\Models\PlatformAccount; use App\Models\PlatformAccount;
use Domains\Platform\Models\PlatformChannel; use App\Models\PlatformChannel;
use Domains\Platform\Api\Lemmy\LemmyApiService; use App\Modules\Lemmy\Services\LemmyApiService;
use Domains\Logging\Services\LogSaver; use App\Services\Log\LogSaver;
use Exception; use Exception;
use Illuminate\Contracts\Queue\ShouldBeUnique; use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;

View file

@ -1,10 +1,10 @@
<?php <?php
namespace Domains\Logging\Listeners; namespace App\Listeners;
use Domains\Logging\Events\ExceptionLogged; use App\Events\ExceptionLogged;
use Domains\Logging\Events\ExceptionOccurred; use App\Events\ExceptionOccurred;
use Domains\Logging\Models\Log; use App\Models\Log;
class LogExceptionToDatabase class LogExceptionToDatabase
{ {

View file

@ -0,0 +1,64 @@
<?php
namespace App\Listeners;
use App\Events\ArticleApproved;
use App\Services\Article\ArticleFetcher;
use App\Services\Publishing\ArticlePublishingService;
use Exception;
use Illuminate\Contracts\Queue\ShouldQueue;
class PublishApprovedArticleListener implements ShouldQueue
{
public string $queue = 'publishing';
public function __construct(
private ArticleFetcher $articleFetcher,
private ArticlePublishingService $publishingService
) {}
public function handle(ArticleApproved $event): void
{
$article = $event->article->fresh();
// Skip if already published
if ($article->articlePublication()->exists()) {
return;
}
// Skip if not approved (safety check)
if (! $article->isApproved()) {
return;
}
$article->update(['publish_status' => 'publishing']);
try {
$extractedData = $this->articleFetcher->fetchArticleData($article);
$publications = $this->publishingService->publishToRoutedChannels($article, $extractedData);
if ($publications->isNotEmpty()) {
$article->update(['publish_status' => 'published']);
logger()->info('Published approved article', [
'article_id' => $article->id,
'title' => $article->title,
]);
} else {
$article->update(['publish_status' => 'error']);
logger()->warning('No publications created for approved article', [
'article_id' => $article->id,
'title' => $article->title,
]);
}
} catch (Exception $e) {
$article->update(['publish_status' => 'error']);
logger()->error('Failed to publish approved article', [
'article_id' => $article->id,
'error' => $e->getMessage(),
]);
}
}
}

View file

@ -0,0 +1,60 @@
<?php
namespace App\Listeners;
use App\Events\NewArticleFetched;
use App\Models\Setting;
use App\Services\Article\ValidationService;
use Exception;
use Illuminate\Contracts\Queue\ShouldQueue;
class ValidateArticleListener implements ShouldQueue
{
public string $queue = 'default';
public function __construct(
private ValidationService $validationService
) {}
public function handle(NewArticleFetched $event): void
{
$article = $event->article;
if (! is_null($article->validated_at)) {
return;
}
// Only validate articles that are still pending
if (! $article->isPending()) {
return;
}
// Skip if already has publication (prevents duplicate processing)
if ($article->articlePublication()->exists()) {
return;
}
try {
$article = $this->validationService->validate($article);
} catch (Exception $e) {
logger()->error('Article validation failed', [
'article_id' => $article->id,
'error' => $e->getMessage(),
]);
return;
}
if ($article->isValid()) {
// Double-check publication doesn't exist (race condition protection)
if ($article->articlePublication()->exists()) {
return;
}
// If approvals are enabled, article waits for manual approval.
// If approvals are disabled, auto-approve and publish.
if (! Setting::isPublishingApprovalsEnabled()) {
$article->approve();
}
}
}
}

61
app/Livewire/Articles.php Normal file
View file

@ -0,0 +1,61 @@
<?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');
}
}

73
app/Livewire/Channels.php Normal file
View file

@ -0,0 +1,73 @@
<?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');
}
}

View file

@ -0,0 +1,36 @@
<?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');
}
}

25
app/Livewire/Feeds.php Normal file
View file

@ -0,0 +1,25 @@
<?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');
}
}

465
app/Livewire/Onboarding.php Normal file
View file

@ -0,0 +1,465 @@
<?php
namespace App\Livewire;
use App\Jobs\ArticleDiscoveryJob;
use App\Jobs\SyncChannelPostsJob;
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, channel, feed, 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;
private ?int $previousChannelLanguageId = null;
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 = [];
// When entering feed step, inherit language from channel
if ($this->step === 4 && $this->channelLanguageId) {
$this->feedLanguageId = $this->channelLanguageId;
}
}
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 {
// Authenticate with Lemmy API first (before creating any records)
$authResponse = $this->lemmyAuthService->authenticate(
$fullInstanceUrl,
$this->username,
$this->password
);
// Only create platform instance after successful authentication
$platformInstance = PlatformInstance::firstOrCreate([
'url' => $fullInstanceUrl,
'platform' => 'lemmy',
], [
'name' => ucfirst($this->instanceUrl),
'is_active' => true,
]);
// 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;
// Get available provider codes for validation
$availableProviders = collect($this->getProvidersForLanguage())->pluck('code')->implode(',');
$this->validate([
'feedName' => 'required|string|max:255',
'feedProvider' => "required|in:{$availableProviders}",
'feedLanguageId' => 'required|exists:languages,id',
'feedDescription' => 'nullable|string|max:1000',
]);
try {
// Get language short code
$language = Language::find($this->feedLanguageId);
$langCode = $language->short_code;
// Look up URL from config
$url = config("feed.providers.{$this->feedProvider}.languages.{$langCode}.url");
if (!$url) {
$this->formErrors['general'] = 'Invalid provider and language combination.';
$this->isLoading = false;
return;
}
$providerConfig = config("feed.providers.{$this->feedProvider}");
Feed::firstOrCreate(
['url' => $url],
[
'name' => $this->feedName,
'type' => $providerConfig['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',
]);
// If language changed, reset feed form
if ($this->previousChannelLanguageId !== null && $this->previousChannelLanguageId !== $this->channelLanguageId) {
$this->feedName = '';
$this->feedProvider = '';
$this->feedDescription = '';
$this->routeFeedId = null;
$this->routeChannelId = null;
}
$this->previousChannelLanguageId = $this->channelLanguageId;
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(),
]);
// Sync existing posts from this channel for duplicate detection
SyncChannelPostsJob::dispatch($channel);
$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'));
}
/**
* Get language codes that have at least one active provider.
*/
public function getAvailableLanguageCodes(): array
{
$providers = config('feed.providers', []);
$languageCodes = [];
foreach ($providers as $provider) {
if (!($provider['is_active'] ?? false)) {
continue;
}
foreach (array_keys($provider['languages'] ?? []) as $code) {
$languageCodes[$code] = true;
}
}
return array_keys($languageCodes);
}
/**
* Get providers available for the current channel language.
*/
public function getProvidersForLanguage(): array
{
if (!$this->channelLanguageId) {
return [];
}
$language = Language::find($this->channelLanguageId);
if (!$language) {
return [];
}
$langCode = $language->short_code;
$providers = config('feed.providers', []);
$available = [];
foreach ($providers as $key => $provider) {
if (!($provider['is_active'] ?? false)) {
continue;
}
if (isset($provider['languages'][$langCode])) {
$available[] = [
'code' => $provider['code'],
'name' => $provider['name'],
'description' => $provider['description'] ?? '',
];
}
}
return $available;
}
/**
* Get the current channel language model.
*/
public function getChannelLanguage(): ?Language
{
if (!$this->channelLanguageId) {
return null;
}
return Language::find($this->channelLanguageId);
}
public function render()
{
// For channel step: only show languages that have providers
$availableCodes = $this->getAvailableLanguageCodes();
$wizardLanguages = Language::where('is_active', true)
->whereIn('short_code', $availableCodes)
->orderBy('name')
->get();
$platformInstances = PlatformInstance::where('is_active', true)->orderBy('name')->get();
$feeds = Feed::with('language')->where('is_active', true)->orderBy('name')->get();
$channels = PlatformChannel::with('language')->where('is_active', true)->orderBy('name')->get();
// For feed step: only show providers for the channel's language
$feedProviders = collect($this->getProvidersForLanguage());
// Get channel language for display
$channelLanguage = $this->getChannelLanguage();
return view('livewire.onboarding', [
'wizardLanguages' => $wizardLanguages,
'platformInstances' => $platformInstances,
'feeds' => $feeds,
'channels' => $channels,
'feedProviders' => $feedProviders,
'channelLanguage' => $channelLanguage,
])->layout('layouts.onboarding');
}
}

200
app/Livewire/Routes.php Normal file
View file

@ -0,0 +1,200 @@
<?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');
}
}

55
app/Livewire/Settings.php Normal file
View file

@ -0,0 +1,55 @@
<?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');
}
}

View file

@ -1,12 +1,10 @@
<?php <?php
namespace Domains\Article\Models; namespace App\Models;
use Domains\Article\Events\ArticleApproved; use App\Events\ArticleApproved;
use Domains\Article\Events\NewArticleFetched; use App\Events\NewArticleFetched;
use Domains\Article\Factories\ArticleFactory; use Database\Factories\ArticleFactory;
use Domains\Feed\Models\Feed;
use Domains\Settings\Models\Setting;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
@ -14,9 +12,9 @@
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
/** /**
* @method static firstOrCreate(array $array) * @method static firstOrCreate(array<string, mixed> $array)
* @method static where(string $string, string $url) * @method static where(string $string, string $url)
* @method static create(array $array) * @method static create(array<string, mixed> $array)
* @property integer $id * @property integer $id
* @property int $feed_id * @property int $feed_id
* @property Feed $feed * @property Feed $feed
@ -32,11 +30,6 @@ class Article extends Model
/** @use HasFactory<ArticleFactory> */ /** @use HasFactory<ArticleFactory> */
use HasFactory; use HasFactory;
protected static function newFactory()
{
return ArticleFactory::new();
}
protected $fillable = [ protected $fillable = [
'feed_id', 'feed_id',
'url', 'url',
@ -47,6 +40,8 @@ protected static function newFactory()
'published_at', 'published_at',
'author', 'author',
'approval_status', 'approval_status',
'validated_at',
'publish_status',
]; ];
/** /**
@ -56,7 +51,9 @@ public function casts(): array
{ {
return [ return [
'approval_status' => 'string', 'approval_status' => 'string',
'publish_status' => 'string',
'published_at' => 'datetime', 'published_at' => 'datetime',
'validated_at' => 'datetime',
'created_at' => 'datetime', 'created_at' => 'datetime',
'updated_at' => 'datetime', 'updated_at' => 'datetime',
]; ];
@ -64,9 +61,8 @@ public function casts(): array
public function isValid(): bool public function isValid(): bool
{ {
// In the consolidated schema, we only have approval_status // Article is valid if it passed validation and wasn't rejected
// Consider 'approved' status as valid return $this->validated_at !== null && ! $this->isRejected();
return $this->approval_status === 'approved';
} }
public function isApproved(): bool public function isApproved(): bool
@ -108,7 +104,7 @@ public function canBePublished(): bool
} }
// If approval system is disabled, auto-approve valid articles // If approval system is disabled, auto-approve valid articles
if (!Setting::isPublishingApprovalsEnabled()) { if (!\App\Models\Setting::isPublishingApprovalsEnabled()) {
return true; return true;
} }
@ -116,6 +112,11 @@ public function canBePublished(): bool
return $this->isApproved(); return $this->isApproved();
} }
public function getIsPublishedAttribute(): bool
{
return $this->articlePublication()->exists();
}
/** /**
* @return HasOne<ArticlePublication, $this> * @return HasOne<ArticlePublication, $this>
*/ */
@ -132,10 +133,8 @@ public function feed(): BelongsTo
return $this->belongsTo(Feed::class); return $this->belongsTo(Feed::class);
} }
protected static function booted(): void public function dispatchFetchedEvent(): void
{ {
static::created(function ($article) { event(new NewArticleFetched($this));
event(new NewArticleFetched($article));
});
} }
} }

View file

@ -1,8 +1,8 @@
<?php <?php
namespace Domains\Article\Models; namespace App\Models;
use Domains\Article\Factories\ArticlePublicationFactory; use Database\Factories\ArticlePublicationFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
@ -12,18 +12,13 @@
* @property integer $platform_channel_id * @property integer $platform_channel_id
* @property integer $post_id * @property integer $post_id
* *
* @method static create(array $array) * @method static create(array<string, mixed> $array)
*/ */
class ArticlePublication extends Model class ArticlePublication extends Model
{ {
/** @use HasFactory<ArticlePublicationFactory> */ /** @use HasFactory<ArticlePublicationFactory> */
use HasFactory; use HasFactory;
protected static function newFactory()
{
return \Domains\Article\Factories\ArticlePublicationFactory::new();
}
protected $fillable = [ protected $fillable = [
'article_id', 'article_id',
'platform_channel_id', 'platform_channel_id',

View file

@ -1,11 +1,8 @@
<?php <?php
namespace Domains\Feed\Models; namespace App\Models;
use Domains\Article\Models\Article; use Database\Factories\FeedFactory;
use Domains\Feed\Factories\FeedFactory;
use Domains\Platform\Models\PlatformChannel;
use Domains\Settings\Models\Language;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
@ -19,6 +16,8 @@
* @property string $url * @property string $url
* @property string $type * @property string $type
* @property string $provider * @property string $provider
* @property int $language_id
* @property Language|null $language
* @property string $description * @property string $description
* @property array<string, mixed> $settings * @property array<string, mixed> $settings
* @property bool $is_active * @property bool $is_active
@ -33,11 +32,6 @@ class Feed extends Model
{ {
/** @use HasFactory<FeedFactory> */ /** @use HasFactory<FeedFactory> */
use HasFactory; use HasFactory;
protected static function newFactory()
{
return \Domains\Feed\Factories\FeedFactory::new();
}
private const RECENT_FETCH_THRESHOLD_HOURS = 2; private const RECENT_FETCH_THRESHOLD_HOURS = 2;
private const DAILY_FETCH_THRESHOLD_HOURS = 24; private const DAILY_FETCH_THRESHOLD_HOURS = 24;
@ -46,6 +40,7 @@ protected static function newFactory()
'url', 'url',
'type', 'type',
'provider', 'provider',
'language_id',
'description', 'description',
'settings', 'settings',
'is_active', 'is_active',
@ -117,35 +112,10 @@ public function articles(): HasMany
} }
/** /**
* @return BelongsToMany<Language, $this> * @return BelongsTo<Language, $this>
*/ */
public function languages(): BelongsToMany public function language(): BelongsTo
{ {
return $this->belongsToMany(Language::class, 'feed_languages') return $this->belongsTo(Language::class);
->withPivot(['url', 'settings', 'is_active', 'is_primary'])
->withTimestamps()
->wherePivot('is_active', true)
->orderByPivot('is_primary', 'desc');
}
/**
* Get the primary language for this feed
* @return Language|null
*/
public function getPrimaryLanguageAttribute(): ?Language
{
return $this->languages()->wherePivot('is_primary', true)->first();
}
/**
* Get all languages including inactive ones
* @return BelongsToMany<Language, $this>
*/
public function allLanguages(): BelongsToMany
{
return $this->belongsToMany(Language::class, 'feed_languages')
->withPivot(['url', 'settings', 'is_active', 'is_primary'])
->withTimestamps()
->orderByPivot('is_primary', 'desc');
} }
} }

View file

@ -1,9 +1,7 @@
<?php <?php
namespace Domains\Article\Models; namespace App\Models;
use Domains\Feed\Models\Feed;
use Domains\Platform\Models\PlatformChannel;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
@ -24,11 +22,6 @@ class Keyword extends Model
{ {
use HasFactory; use HasFactory;
protected static function newFactory()
{
return \Domains\Article\Factories\KeywordFactory::new();
}
protected $fillable = [ protected $fillable = [
'feed_id', 'feed_id',
'platform_channel_id', 'platform_channel_id',

52
app/Models/Language.php Normal file
View file

@ -0,0 +1,52 @@
<?php
namespace App\Models;
use Database\Factories\LanguageFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Language extends Model
{
/** @use HasFactory<LanguageFactory> */
use HasFactory;
protected $fillable = [
'short_code',
'name',
'native_name',
'is_active'
];
protected $casts = [
'is_active' => 'boolean'
];
/**
* @return BelongsToMany<PlatformInstance, $this>
*/
public function platformInstances(): BelongsToMany
{
return $this->belongsToMany(PlatformInstance::class)
->withPivot(['platform_language_id', 'is_default'])
->withTimestamps();
}
/**
* @return HasMany<PlatformChannel, $this>
*/
public function platformChannels(): HasMany
{
return $this->hasMany(PlatformChannel::class);
}
/**
* @return HasMany<Feed, $this>
*/
public function feeds(): HasMany
{
return $this->hasMany(Feed::class);
}
}

View file

@ -1,8 +1,8 @@
<?php <?php
namespace Domains\Logging\Models; namespace App\Models;
use Domains\Logging\Enums\LogLevelEnum; use App\Enums\LogLevelEnum;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
@ -19,11 +19,6 @@ class Log extends Model
{ {
use HasFactory; use HasFactory;
protected static function newFactory()
{
return \Domains\Logging\Factories\LogFactory::new();
}
protected $table = 'logs'; protected $table = 'logs';
protected $fillable = [ protected $fillable = [

View file

@ -1,8 +1,8 @@
<?php <?php
namespace Domains\Platform\Models; namespace App\Models;
use Domains\Platform\Factories\PlatformAccountFactory; use Database\Factories\PlatformAccountFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Casts\Attribute;
@ -10,7 +10,7 @@
use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Crypt; use Illuminate\Support\Facades\Crypt;
use Domains\Platform\Enums\PlatformEnum; use App\Enums\PlatformEnum;
/** /**
* @property int $id * @property int $id
@ -27,18 +27,13 @@
* @property Collection<int, PlatformChannel> $activeChannels * @property Collection<int, PlatformChannel> $activeChannels
* @method static where(string $string, PlatformEnum $platform) * @method static where(string $string, PlatformEnum $platform)
* @method static orderBy(string $string) * @method static orderBy(string $string)
* @method static create(array $validated) * @method static create(array<string, mixed> $validated)
*/ */
class PlatformAccount extends Model class PlatformAccount extends Model
{ {
/** @use HasFactory<PlatformAccountFactory> */ /** @use HasFactory<PlatformAccountFactory> */
use HasFactory; use HasFactory;
protected static function newFactory()
{
return \Domains\Platform\Factories\PlatformAccountFactory::new();
}
protected $fillable = [ protected $fillable = [
'platform', 'platform',
'instance_url', 'instance_url',
@ -69,12 +64,12 @@ protected function password(): Attribute
if (is_null($value)) { if (is_null($value)) {
return null; return null;
} }
// Return empty string if value is empty // Return empty string if value is empty
if (empty($value)) { if (empty($value)) {
return ''; return '';
} }
try { try {
return Crypt::decryptString($value); return Crypt::decryptString($value);
} catch (\Exception $e) { } catch (\Exception $e) {
@ -87,12 +82,12 @@ protected function password(): Attribute
if (is_null($value)) { if (is_null($value)) {
return null; return null;
} }
// Store empty string as null // Store empty string as null
if (empty($value)) { if (empty($value)) {
return null; return null;
} }
return Crypt::encryptString($value); return Crypt::encryptString($value);
}, },
)->withoutObjectCaching(); )->withoutObjectCaching();

View file

@ -1,10 +1,8 @@
<?php <?php
namespace Domains\Platform\Models; namespace App\Models;
use Domains\Feed\Models\Feed; use Database\Factories\PlatformChannelFactory;
use Domains\Platform\Factories\PlatformChannelFactory;
use Domains\Settings\Models\Language;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
@ -27,11 +25,6 @@ class PlatformChannel extends Model
/** @use HasFactory<PlatformChannelFactory> */ /** @use HasFactory<PlatformChannelFactory> */
use HasFactory; use HasFactory;
protected static function newFactory()
{
return \Domains\Platform\Factories\PlatformChannelFactory::new();
}
protected $table = 'platform_channels'; protected $table = 'platform_channels';
protected $fillable = [ protected $fillable = [

View file

@ -1,24 +1,18 @@
<?php <?php
namespace Domains\Platform\Models; namespace App\Models;
use Domains\Platform\Enums\PlatformEnum; use App\Enums\PlatformEnum;
use Domains\Platform\Factories\PlatformChannelPostFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
/** /**
* @method static where(string $string, PlatformEnum $platform) * @method static where(string $string, PlatformEnum $platform)
* @method static updateOrCreate(array $array, array $array1) * @method static updateOrCreate(array<string, mixed> $array, array<string, mixed> $array1)
*/ */
class PlatformChannelPost extends Model class PlatformChannelPost extends Model
{ {
use HasFactory; use HasFactory;
protected static function newFactory()
{
return PlatformChannelPostFactory::new();
}
protected $fillable = [ protected $fillable = [
'platform', 'platform',
'channel_id', 'channel_id',
@ -48,6 +42,25 @@ public static function urlExists(PlatformEnum $platform, string $channelId, stri
->exists(); ->exists();
} }
public static function duplicateExists(PlatformEnum $platform, string $channelId, ?string $url, ?string $title): bool
{
if (!$url && !$title) {
return false;
}
return self::where('platform', $platform)
->where('channel_id', $channelId)
->where(function ($query) use ($url, $title) {
if ($url) {
$query->orWhere('url', $url);
}
if ($title) {
$query->orWhere('title', $title);
}
})
->exists();
}
public static function storePost(PlatformEnum $platform, string $channelId, ?string $channelName, string $postId, ?string $url, ?string $title, ?\DateTime $postedAt = null): self public static function storePost(PlatformEnum $platform, string $channelId, ?string $channelName, string $postId, ?string $url, ?string $title, ?\DateTime $postedAt = null): self
{ {
return self::updateOrCreate( return self::updateOrCreate(

View file

@ -1,17 +1,16 @@
<?php <?php
namespace Domains\Platform\Models; namespace App\Models;
use Domains\Platform\Enums\PlatformEnum; use App\Enums\PlatformEnum;
use Domains\Platform\Factories\PlatformInstanceFactory; use Database\Factories\PlatformInstanceFactory;
use Domains\Settings\Models\Language;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
/** /**
* @method static updateOrCreate(array $array, $instanceData) * @method static updateOrCreate(array<string, mixed> $array, $instanceData)
* @method static where(string $string, mixed $operator) * @method static where(string $string, mixed $operator)
* @property PlatformEnum $platform * @property PlatformEnum $platform
* @property string $url * @property string $url
@ -23,12 +22,7 @@ class PlatformInstance extends Model
{ {
/** @use HasFactory<PlatformInstanceFactory> */ /** @use HasFactory<PlatformInstanceFactory> */
use HasFactory; use HasFactory;
protected static function newFactory(): PlatformInstanceFactory
{
return PlatformInstanceFactory::new();
}
protected $fillable = [ protected $fillable = [
'platform', 'platform',
'url', 'url',

66
app/Models/Route.php Normal file
View file

@ -0,0 +1,66 @@
<?php
namespace App\Models;
use Database\Factories\RouteFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon;
/**
* @property int $feed_id
* @property int $platform_channel_id
* @property bool $is_active
* @property int $priority
* @property Carbon $created_at
* @property Carbon $updated_at
*/
class Route extends Model
{
/** @use HasFactory<RouteFactory> */
use HasFactory;
protected $table = 'routes';
// Laravel doesn't handle composite primary keys well, so we'll use regular queries
protected $primaryKey = null;
public $incrementing = false;
protected $fillable = [
'feed_id',
'platform_channel_id',
'is_active',
'priority'
];
protected $casts = [
'is_active' => 'boolean'
];
/**
* @return BelongsTo<Feed, $this>
*/
public function feed(): BelongsTo
{
return $this->belongsTo(Feed::class);
}
/**
* @return BelongsTo<PlatformChannel, $this>
*/
public function platformChannel(): BelongsTo
{
return $this->belongsTo(PlatformChannel::class);
}
/**
* @return HasMany<Keyword, $this>
*/
public function keywords(): HasMany
{
return $this->hasMany(Keyword::class, 'feed_id', 'feed_id')
->where('platform_channel_id', $this->platform_channel_id);
}
}

View file

@ -1,6 +1,6 @@
<?php <?php
namespace Domains\Settings\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
@ -14,11 +14,6 @@ class Setting extends Model
{ {
use HasFactory; use HasFactory;
protected static function newFactory()
{
return \Domains\Settings\Factories\SettingFactory::new();
}
protected $fillable = ['key', 'value']; protected $fillable = ['key', 'value'];
public static function get(string $key, mixed $default = null): mixed public static function get(string $key, mixed $default = null): mixed
@ -57,7 +52,7 @@ public static function setArticleProcessingEnabled(bool $enabled): void
public static function isPublishingApprovalsEnabled(): bool public static function isPublishingApprovalsEnabled(): bool
{ {
return static::getBool('enable_publishing_approvals', false); return static::getBool('enable_publishing_approvals', true);
} }
public static function setPublishingApprovalsEnabled(bool $enabled): void public static function setPublishingApprovalsEnabled(bool $enabled): void

View file

@ -1,6 +1,6 @@
<?php <?php
namespace Domains\User\Models; namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail; // use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
@ -10,14 +10,9 @@
class User extends Authenticatable class User extends Authenticatable
{ {
/** @use HasFactory<\Domains\User\Factories\UserFactory> */ /** @use HasFactory<\Database\Factories\UserFactory> */
use HasFactory, Notifiable, HasApiTokens; use HasFactory, Notifiable, HasApiTokens;
protected static function newFactory()
{
return \Domains\User\Factories\UserFactory::new();
}
/** /**
* The attributes that are mass assignable. * The attributes that are mass assignable.
* *

View file

@ -1,6 +1,6 @@
<?php <?php
namespace Domains\Platform\Api\Lemmy; namespace App\Modules\Lemmy;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Illuminate\Http\Client\Response; use Illuminate\Http\Client\Response;

View file

@ -1,10 +1,10 @@
<?php <?php
namespace Domains\Platform\Api\Lemmy; namespace App\Modules\Lemmy\Services;
use Domains\Platform\Enums\PlatformEnum; use App\Enums\PlatformEnum;
use Domains\Platform\Models\PlatformChannelPost; use App\Models\PlatformChannelPost;
use Domains\Platform\Api\Lemmy\LemmyRequest; use App\Modules\Lemmy\LemmyRequest;
use Exception; use Exception;
class LemmyApiService class LemmyApiService
@ -63,15 +63,22 @@ public function login(string $username, string $password): ?string
$data = $response->json(); $data = $response->json();
return $data['jwt'] ?? null; return $data['jwt'] ?? null;
} catch (Exception $e) { } 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]); logger()->error('Lemmy login exception', ['error' => $e->getMessage(), 'scheme' => $scheme]);
// If this was the first attempt and HTTPS, try HTTP next // If this was the first attempt and HTTPS, try HTTP next
if ($idx === 0 && in_array('http', $schemesToTry, true)) { if ($idx === 0 && in_array('http', $schemesToTry, true)) {
continue; continue;
} }
return null; // Connection failed - throw exception to distinguish from auth failure
throw new Exception('Connection failed: ' . $e->getMessage());
} }
} }
// If we get here without a token, it's an auth failure (not connection)
return null; return null;
} }
@ -93,47 +100,6 @@ public function getCommunityId(string $communityName, string $token): int
} }
} }
/**
* Get full community details including language information
*
* @param string $communityName
* @param string $token
* @return array<string, mixed>
* @throws Exception
*/
public function getCommunityDetails(string $communityName, string $token): array
{
try {
$request = new LemmyRequest($this->instance, $token);
$response = $request->get('community', ['name' => $communityName]);
if (!$response->successful()) {
$statusCode = $response->status();
$responseBody = $response->body();
if ($statusCode === 404) {
throw new Exception("Community '{$communityName}' not found on this instance");
}
throw new Exception("Failed to fetch community details: {$statusCode} - {$responseBody}");
}
$data = $response->json();
if (!isset($data['community_view']['community'])) {
throw new Exception('Invalid community response format');
}
return $data['community_view'];
} catch (Exception $e) {
logger()->error('Community details lookup failed', [
'community_name' => $communityName,
'error' => $e->getMessage()
]);
throw $e;
}
}
public function syncChannelPosts(string $token, int $platformChannelId, string $communityName): void public function syncChannelPosts(string $token, int $platformChannelId, string $communityName): void
{ {
try { try {
@ -246,70 +212,4 @@ public function getLanguages(): array
return []; return [];
} }
} }
/**
* List communities on the instance with optional filtering
*
* @param string|null $token
* @param string $type Local, All, or Subscribed
* @param string $sort Hot, Active, New, TopDay, TopWeek, TopMonth, TopYear, TopAll
* @param int $limit Maximum number of communities to return (default: 50)
* @param int $page Page number for pagination (default: 1)
* @param bool $showNsfw Whether to include NSFW communities (default: false)
* @return array<string, mixed>
* @throws Exception
*/
public function listCommunities(
?string $token = null,
string $type = 'Local',
string $sort = 'Active',
int $limit = 50,
int $page = 1,
bool $showNsfw = false
): array {
try {
$request = new LemmyRequest($this->instance, $token);
$params = [
'type_' => $type,
'sort' => $sort,
'limit' => $limit,
'page' => $page,
'show_nsfw' => $showNsfw,
];
$response = $request->get('community/list', $params);
if (!$response->successful()) {
$statusCode = $response->status();
$responseBody = $response->body();
logger()->warning('Failed to fetch communities list', [
'status' => $statusCode,
'response' => $responseBody,
'params' => $params
]);
throw new Exception("Failed to fetch communities list: {$statusCode} - {$responseBody}");
}
$data = $response->json();
if (!isset($data['communities'])) {
logger()->warning('Invalid communities list response format', ['response' => $data]);
return ['communities' => []];
}
return $data;
} catch (Exception $e) {
logger()->error('Exception while fetching communities list', [
'error' => $e->getMessage(),
'type' => $type,
'sort' => $sort,
'limit' => $limit,
'page' => $page
]);
throw $e;
}
}
} }

View file

@ -0,0 +1,69 @@
<?php
namespace App\Modules\Lemmy\Services;
use App\Exceptions\PlatformAuthException;
use App\Models\Article;
use App\Models\PlatformAccount;
use App\Models\PlatformChannel;
use App\Services\Auth\LemmyAuthService;
use Exception;
class LemmyPublisher
{
private LemmyApiService $api;
private PlatformAccount $account;
public function __construct(PlatformAccount $account)
{
$this->api = new LemmyApiService($account->instance_url);
$this->account = $account;
}
/**
* @param array<string, mixed> $extractedData
* @return array<string, mixed>
* @throws PlatformAuthException
* @throws Exception
*/
public function publishToChannel(Article $article, array $extractedData, PlatformChannel $channel): array
{
$authService = resolve(LemmyAuthService::class);
$token = $authService->getToken($this->account);
try {
return $this->createPost($token, $extractedData, $channel, $article);
} catch (Exception $e) {
// If the cached token was stale, refresh and retry once
if (str_contains($e->getMessage(), 'not_logged_in') || str_contains($e->getMessage(), 'Unauthorized')) {
$token = $authService->refreshToken($this->account);
return $this->createPost($token, $extractedData, $channel, $article);
}
throw $e;
}
}
/**
* @param array<string, mixed> $extractedData
* @return array<string, mixed>
*/
private function createPost(string $token, array $extractedData, PlatformChannel $channel, Article $article): array
{
$languageId = $extractedData['language_id'] ?? null;
$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,
$article->url,
$extractedData['thumbnail'] ?? null,
$languageId
);
}
}

View file

@ -2,9 +2,9 @@
namespace App\Providers; namespace App\Providers;
use Domains\Logging\Enums\LogLevelEnum; use App\Enums\LogLevelEnum;
use Domains\Logging\Events\ExceptionOccurred; use App\Events\ExceptionOccurred;
use Domains\Logging\Listeners\LogExceptionToDatabase; use App\Listeners\LogExceptionToDatabase;
use Error; use Error;
use Illuminate\Contracts\Debug\ExceptionHandler; use Illuminate\Contracts\Debug\ExceptionHandler;
use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Event;
@ -26,10 +26,14 @@ public function boot(): void
); );
Event::listen( Event::listen(
\Domains\Article\Events\NewArticleFetched::class, \App\Events\NewArticleFetched::class,
\Domains\Article\Listeners\ValidateArticleListener::class, \App\Listeners\ValidateArticleListener::class,
); );
Event::listen(
\App\Events\ArticleApproved::class,
\App\Listeners\PublishApprovedArticleListener::class,
);
app()->make(ExceptionHandler::class) app()->make(ExceptionHandler::class)
->reportable(function (Throwable $e) { ->reportable(function (Throwable $e) {

View file

@ -1,13 +1,13 @@
<?php <?php
namespace Domains\Article\Services; namespace App\Services\Article;
use Domains\Article\Models\Article; use App\Models\Article;
use Domains\Feed\Models\Feed; use App\Models\Feed;
use Domains\Shared\Services\HttpFetcher; use App\Services\Http\HttpFetcher;
use Domains\Article\Parsers\Factories\ArticleParserFactory; use App\Services\Factories\ArticleParserFactory;
use Domains\Article\Parsers\Factories\HomepageParserFactory; use App\Services\Factories\HomepageParserFactory;
use Domains\Logging\Services\LogSaver; use App\Services\Log\LogSaver;
use Exception; use Exception;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
@ -103,27 +103,27 @@ public function fetchArticleData(Article $article): array
private function saveArticle(string $url, ?int $feedId = null): Article private function saveArticle(string $url, ?int $feedId = null): Article
{ {
$existingArticle = Article::where('url', $url)->first();
if ($existingArticle) {
return $existingArticle;
}
// Extract a basic title from URL as fallback
$fallbackTitle = $this->generateFallbackTitle($url); $fallbackTitle = $this->generateFallbackTitle($url);
try { try {
return Article::create([ $article = Article::firstOrCreate(
'url' => $url, ['url' => $url],
'feed_id' => $feedId, [
'title' => $fallbackTitle, 'feed_id' => $feedId,
]); 'title' => $fallbackTitle,
]
);
if ($article->wasRecentlyCreated) {
$article->dispatchFetchedEvent();
}
return $article;
} catch (\Exception $e) { } catch (\Exception $e) {
$this->logSaver->error("Failed to create article - title validation failed", null, [ $this->logSaver->error("Failed to create article", null, [
'url' => $url, 'url' => $url,
'feed_id' => $feedId, 'feed_id' => $feedId,
'error' => $e->getMessage(), 'error' => $e->getMessage(),
'suggestion' => 'Check regex parsing patterns for title extraction'
]); ]);
throw $e; throw $e;
} }
@ -134,12 +134,12 @@ private function generateFallbackTitle(string $url): string
// Extract filename from URL as a basic fallback title // Extract filename from URL as a basic fallback title
$path = parse_url($url, PHP_URL_PATH); $path = parse_url($url, PHP_URL_PATH);
$filename = basename($path ?: $url); $filename = basename($path ?: $url);
// Remove file extension and convert to readable format // Remove file extension and convert to readable format
$title = preg_replace('/\.[^.]*$/', '', $filename); $title = preg_replace('/\.[^.]*$/', '', $filename);
$title = str_replace(['-', '_'], ' ', $title); $title = str_replace(['-', '_'], ' ', $title);
$title = ucwords($title); $title = ucwords($title);
return $title ?: 'Untitled Article'; return $title ?: 'Untitled Article';
} }
} }

View file

@ -1,8 +1,8 @@
<?php <?php
namespace Domains\Article\Services; namespace App\Services\Article;
use Domains\Article\Models\Article; use App\Models\Article;
class ValidationService class ValidationService
{ {
@ -15,7 +15,7 @@ public function validate(Article $article): Article
logger('Checking keywords for article: ' . $article->id); logger('Checking keywords for article: ' . $article->id);
$articleData = $this->articleFetcher->fetchArticleData($article); $articleData = $this->articleFetcher->fetchArticleData($article);
// Update article with fetched metadata (title, description) // Update article with fetched metadata (title, description)
$updateData = []; $updateData = [];
@ -24,23 +24,29 @@ public function validate(Article $article): Article
$updateData['description'] = $articleData['description'] ?? $article->description; $updateData['description'] = $articleData['description'] ?? $article->description;
$updateData['content'] = $articleData['full_article'] ?? null; $updateData['content'] = $articleData['full_article'] ?? null;
} }
if (!isset($articleData['full_article']) || empty($articleData['full_article'])) { if (!isset($articleData['full_article']) || empty($articleData['full_article'])) {
logger()->warning('Article data missing full_article content', [ logger()->warning('Article data missing full_article content', [
'article_id' => $article->id, 'article_id' => $article->id,
'url' => $article->url 'url' => $article->url
]); ]);
$updateData['approval_status'] = 'rejected'; $updateData['approval_status'] = 'rejected';
$article->update($updateData); $article->update($updateData);
return $article->refresh(); return $article->refresh();
} }
// Validate using extracted content (not stored)
$validationResult = $this->validateByKeywords($articleData['full_article']);
$updateData['approval_status'] = $validationResult ? 'approved' : 'pending';
// Validate content against keywords. If validation fails, reject.
// If validation passes, leave approval_status as-is (pending) —
// the listener decides whether to auto-approve based on settings.
$validationResult = $this->validateByKeywords($articleData['full_article']);
if (! $validationResult) {
$updateData['approval_status'] = 'rejected';
}
$updateData['validated_at'] = now();
$article->update($updateData); $article->update($updateData);
return $article->refresh(); return $article->refresh();
@ -53,12 +59,12 @@ private function validateByKeywords(string $full_article): bool
// Political parties and leaders // Political parties and leaders
'N-VA', 'Bart De Wever', 'Frank Vandenbroucke', 'Alexander De Croo', 'N-VA', 'Bart De Wever', 'Frank Vandenbroucke', 'Alexander De Croo',
'Vooruit', 'Open Vld', 'CD&V', 'Vlaams Belang', 'PTB', 'PVDA', 'Vooruit', 'Open Vld', 'CD&V', 'Vlaams Belang', 'PTB', 'PVDA',
// Belgian locations and institutions // Belgian locations and institutions
'Belgium', 'Belgian', 'Flanders', 'Flemish', 'Wallonia', 'Brussels', 'Belgium', 'Belgian', 'Flanders', 'Flemish', 'Wallonia', 'Brussels',
'Antwerp', 'Ghent', 'Bruges', 'Leuven', 'Mechelen', 'Namur', 'Liège', 'Charleroi', 'Antwerp', 'Ghent', 'Bruges', 'Leuven', 'Mechelen', 'Namur', 'Liège', 'Charleroi',
'parliament', 'government', 'minister', 'policy', 'law', 'legislation', 'parliament', 'government', 'minister', 'policy', 'law', 'legislation',
// Common Belgian news topics // Common Belgian news topics
'economy', 'economic', 'education', 'healthcare', 'transport', 'climate', 'energy', 'economy', 'economic', 'education', 'healthcare', 'transport', 'climate', 'energy',
'European', 'EU', 'migration', 'security', 'justice', 'culture', 'police' 'European', 'EU', 'migration', 'security', 'justice', 'culture', 'police'

View file

@ -1,11 +1,11 @@
<?php <?php
namespace Domains\Platform\Services\Auth\Authenticators; namespace App\Services\Auth;
use Domains\Platform\Enums\PlatformEnum; use App\Enums\PlatformEnum;
use Domains\Platform\Exceptions\PlatformAuthException; use App\Exceptions\PlatformAuthException;
use Domains\Platform\Models\PlatformAccount; use App\Models\PlatformAccount;
use Domains\Platform\Api\Lemmy\LemmyApiService; use App\Modules\Lemmy\Services\LemmyApiService;
use Exception; use Exception;
class LemmyAuthService class LemmyAuthService
@ -14,6 +14,22 @@ class LemmyAuthService
* @throws PlatformAuthException * @throws PlatformAuthException
*/ */
public function getToken(PlatformAccount $account): string public function getToken(PlatformAccount $account): string
{
// Use cached token if available
$cachedToken = $account->settings['api_token'] ?? null;
if ($cachedToken) {
return $cachedToken;
}
return $this->refreshToken($account);
}
/**
* Clear cached token and re-authenticate.
*
* @throws PlatformAuthException
*/
public function refreshToken(PlatformAccount $account): string
{ {
if (! $account->username || ! $account->password || ! $account->instance_url) { if (! $account->username || ! $account->password || ! $account->instance_url) {
throw new PlatformAuthException(PlatformEnum::LEMMY, 'Missing credentials for account: ' . $account->username); throw new PlatformAuthException(PlatformEnum::LEMMY, 'Missing credentials for account: ' . $account->username);
@ -26,6 +42,11 @@ public function getToken(PlatformAccount $account): string
throw new PlatformAuthException(PlatformEnum::LEMMY, 'Login failed for account: ' . $account->username); throw new PlatformAuthException(PlatformEnum::LEMMY, 'Login failed for account: ' . $account->username);
} }
// Cache the token for future use
$settings = $account->settings ?? [];
$settings['api_token'] = $token;
$account->update(['settings' => $settings]);
return $token; return $token;
} }
@ -65,6 +86,10 @@ public function authenticate(string $instanceUrl, string $username, string $pass
if (str_contains($e->getMessage(), 'Rate limited by')) { if (str_contains($e->getMessage(), 'Rate limited by')) {
throw new PlatformAuthException(PlatformEnum::LEMMY, $e->getMessage()); throw new PlatformAuthException(PlatformEnum::LEMMY, $e->getMessage());
} }
// Check if it's a connection failure
if (str_contains($e->getMessage(), 'Connection failed')) {
throw new PlatformAuthException(PlatformEnum::LEMMY, 'Connection failed');
}
// For other exceptions, throw a clean PlatformAuthException // For other exceptions, throw a clean PlatformAuthException
throw new PlatformAuthException(PlatformEnum::LEMMY, 'Connection failed'); throw new PlatformAuthException(PlatformEnum::LEMMY, 'Connection failed');
} }

View file

@ -1,13 +1,13 @@
<?php <?php
namespace Domains\Article\Services; namespace App\Services;
use Domains\Article\Models\Article; use App\Models\Article;
use Domains\Article\Models\ArticlePublication; use App\Models\ArticlePublication;
use Domains\Feed\Models\Feed; use App\Models\Feed;
use Domains\Platform\Models\PlatformAccount; use App\Models\PlatformAccount;
use Domains\Platform\Models\PlatformChannel; use App\Models\PlatformChannel;
use Domains\Feed\Models\Route; use App\Models\Route;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;

View file

@ -1,11 +1,11 @@
<?php <?php
namespace Domains\Article\Parsers\Factories; namespace App\Services\Factories;
use Domains\Article\Contracts\ArticleParserInterface; use App\Contracts\ArticleParserInterface;
use Domains\Feed\Models\Feed; use App\Models\Feed;
use Domains\Article\Parsers\Vrt\VrtArticleParser; use App\Services\Parsers\VrtArticleParser;
use Domains\Article\Parsers\Belga\BelgaArticleParser; use App\Services\Parsers\BelgaArticleParser;
use Exception; use Exception;
class ArticleParserFactory class ArticleParserFactory

View file

@ -1,11 +1,11 @@
<?php <?php
namespace Domains\Article\Parsers\Factories; namespace App\Services\Factories;
use Domains\Article\Contracts\HomepageParserInterface; use App\Contracts\HomepageParserInterface;
use Domains\Feed\Models\Feed; use App\Models\Feed;
use Domains\Article\Parsers\Vrt\VrtHomepageParserAdapter; use App\Services\Parsers\VrtHomepageParserAdapter;
use Domains\Article\Parsers\Belga\BelgaHomepageParserAdapter; use App\Services\Parsers\BelgaHomepageParserAdapter;
use Exception; use Exception;
class HomepageParserFactory class HomepageParserFactory

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