Compare commits
7 commits
35e4260c87
...
25cae3c0e9
| Author | SHA1 | Date | |
|---|---|---|---|
| 25cae3c0e9 | |||
| 58848c934e | |||
| 2087ca389e | |||
| f765d04d06 | |||
| cb276cf81d | |||
| e986f7871b | |||
| ed93dc3630 |
386 changed files with 28346 additions and 7562 deletions
|
|
@ -1,41 +0,0 @@
|
||||||
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 }}
|
|
||||||
45
.github/workflows/lint.yml
vendored
Normal file
45
.github/workflows/lint.yml
vendored
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
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'
|
||||||
50
.github/workflows/tests.yml
vendored
Normal file
50
.github/workflows/tests.yml
vendored
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
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
4
.gitignore
vendored
|
|
@ -4,7 +4,6 @@
|
||||||
/public/build
|
/public/build
|
||||||
/public/hot
|
/public/hot
|
||||||
/public/storage
|
/public/storage
|
||||||
/public/vendor
|
|
||||||
/storage/*.key
|
/storage/*.key
|
||||||
/storage/pail
|
/storage/pail
|
||||||
/vendor
|
/vendor
|
||||||
|
|
@ -24,6 +23,5 @@ yarn-error.log
|
||||||
/.nova
|
/.nova
|
||||||
/.vscode
|
/.vscode
|
||||||
/.zed
|
/.zed
|
||||||
/coverage-report*
|
/backend/coverage-report*
|
||||||
/coverage.xml
|
|
||||||
/.claude
|
/.claude
|
||||||
|
|
|
||||||
111
Dockerfile
111
Dockerfile
|
|
@ -1,111 +0,0 @@
|
||||||
# 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
127
Dockerfile.dev
|
|
@ -1,127 +0,0 @@
|
||||||
# Development Dockerfile with FrankenPHP
|
|
||||||
FROM dunglas/frankenphp:latest-php8.3-alpine
|
|
||||||
|
|
||||||
# Install system dependencies + development tools
|
|
||||||
RUN apk add --no-cache \
|
|
||||||
nodejs \
|
|
||||||
npm \
|
|
||||||
git \
|
|
||||||
mysql-client \
|
|
||||||
vim \
|
|
||||||
bash \
|
|
||||||
nano
|
|
||||||
|
|
||||||
# Install PHP extensions including xdebug for development
|
|
||||||
RUN install-php-extensions \
|
|
||||||
pdo_mysql \
|
|
||||||
opcache \
|
|
||||||
zip \
|
|
||||||
gd \
|
|
||||||
intl \
|
|
||||||
bcmath \
|
|
||||||
redis \
|
|
||||||
pcntl \
|
|
||||||
xdebug
|
|
||||||
|
|
||||||
# Install Composer
|
|
||||||
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
|
||||||
|
|
||||||
# Set working directory
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Configure PHP for development
|
|
||||||
RUN mv "$PHP_INI_DIR/php.ini-development" "$PHP_INI_DIR/php.ini"
|
|
||||||
|
|
||||||
# Configure Xdebug (disabled by default to reduce noise)
|
|
||||||
RUN echo "xdebug.mode=off" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini \
|
|
||||||
&& echo ";xdebug.mode=debug" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini \
|
|
||||||
&& echo ";xdebug.client_host=host.docker.internal" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini \
|
|
||||||
&& echo ";xdebug.start_with_request=yes" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini
|
|
||||||
|
|
||||||
# Configure Caddy for development (simpler, no worker mode)
|
|
||||||
RUN cat > /etc/caddy/Caddyfile <<EOF
|
|
||||||
{
|
|
||||||
frankenphp
|
|
||||||
order php_server before file_server
|
|
||||||
}
|
|
||||||
|
|
||||||
:8000 {
|
|
||||||
root * /app/public
|
|
||||||
|
|
||||||
php_server {
|
|
||||||
index index.php
|
|
||||||
}
|
|
||||||
|
|
||||||
encode gzip
|
|
||||||
|
|
||||||
file_server
|
|
||||||
|
|
||||||
# Less strict headers for development
|
|
||||||
header {
|
|
||||||
X-Frame-Options "SAMEORIGIN"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
EOF
|
|
||||||
|
|
||||||
# Install Node development dependencies globally
|
|
||||||
RUN npm install -g nodemon
|
|
||||||
|
|
||||||
# Create startup script for development
|
|
||||||
RUN cat > /start.sh <<'EOF'
|
|
||||||
#!/bin/sh
|
|
||||||
set -e
|
|
||||||
|
|
||||||
# Create .env file if it doesn't exist
|
|
||||||
if [ ! -f ".env" ]; then
|
|
||||||
echo "Creating .env file from .env.example..."
|
|
||||||
cp .env.example .env
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Install dependencies if volumes are empty
|
|
||||||
if [ ! -f "vendor/autoload.php" ]; then
|
|
||||||
echo "Installing composer dependencies..."
|
|
||||||
composer install
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Always reinstall node_modules in container to get correct native binaries for Alpine/musl
|
|
||||||
echo "Installing npm dependencies..."
|
|
||||||
rm -rf node_modules 2>/dev/null || true
|
|
||||||
rm -rf /app/.npm 2>/dev/null || true
|
|
||||||
npm install --cache /tmp/.npm
|
|
||||||
|
|
||||||
# Clear Laravel caches
|
|
||||||
php artisan config:clear || true
|
|
||||||
php artisan cache:clear || true
|
|
||||||
|
|
||||||
# Wait for database and run migrations
|
|
||||||
echo "Waiting for database..."
|
|
||||||
sleep 5
|
|
||||||
php artisan migrate --force || echo "Migration failed or not needed"
|
|
||||||
|
|
||||||
# Run seeders
|
|
||||||
echo "Running seeders..."
|
|
||||||
php artisan db:seed --force || echo "Seeding skipped or already done"
|
|
||||||
|
|
||||||
# Generate app key if not set
|
|
||||||
if [ -z "$APP_KEY" ] || [ "$APP_KEY" = "base64:YOUR_KEY_HERE" ]; then
|
|
||||||
echo "Generating application key..."
|
|
||||||
php artisan key:generate
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Start Vite dev server in background
|
|
||||||
npm run dev &
|
|
||||||
|
|
||||||
# Start Horizon (queue worker) in background
|
|
||||||
php artisan horizon &
|
|
||||||
|
|
||||||
# Start FrankenPHP
|
|
||||||
exec frankenphp run --config /etc/caddy/Caddyfile
|
|
||||||
EOF
|
|
||||||
|
|
||||||
RUN chmod +x /start.sh
|
|
||||||
|
|
||||||
# Expose ports
|
|
||||||
EXPOSE 8000 5173
|
|
||||||
|
|
||||||
# Use the startup script
|
|
||||||
CMD ["/start.sh"]
|
|
||||||
298
README.md
298
README.md
|
|
@ -1,127 +1,219 @@
|
||||||
# FFR (Feed to Fediverse Router)
|
# Fedi Feed Router (FFR) v1.0.0
|
||||||
|
|
||||||
A Laravel-based application for routing RSS/Atom feeds to Fediverse platforms like Lemmy. Built with Laravel, Livewire, and FrankenPHP for a modern, single-container deployment.
|
<div align="center">
|
||||||
|
<img src="backend/public/images/ffr-logo-600.png" alt="FFR Logo" width="200">
|
||||||
|
|
||||||
## Features
|
**A minimal working version — limited to two hardcoded sources, designed for self-hosters.**
|
||||||
|
*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
|
|
||||||
|
|
||||||
## Self-hosting
|
## 🔰 Project Overview
|
||||||
|
|
||||||
The production image is available at `forge.lvl0.xyz/lvl0/fedi-feed-router:latest`.
|
**One-liner:** FFR routes content from RSS/Atom feeds to the fediverse based on keyword matching.
|
||||||
|
|
||||||
### docker-compose.yml
|
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.
|
||||||
|
|
||||||
```yaml
|
## ⚙️ Features
|
||||||
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
|
|
||||||
|
|
||||||
db:
|
Current v1.0.0 features:
|
||||||
image: mariadb:11
|
- ✅ Fetches articles from two hardcoded RSS feeds (CBC News, BBC News)
|
||||||
container_name: ffr_db
|
- ✅ Keyword-based content filtering and matching
|
||||||
restart: always
|
- ✅ Automatic posting to Lemmy communities
|
||||||
environment:
|
- ✅ Web dashboard for monitoring and management
|
||||||
MYSQL_DATABASE: "${DB_DATABASE}"
|
- ✅ Docker-based deployment for easy self-hosting
|
||||||
MYSQL_USER: "${DB_USERNAME}"
|
- ✅ Privacy-first design with no external dependencies
|
||||||
MYSQL_PASSWORD: "${DB_PASSWORD}"
|
|
||||||
MYSQL_ROOT_PASSWORD: "${DB_ROOT_PASSWORD}"
|
|
||||||
volumes:
|
|
||||||
- db_data:/var/lib/mysql
|
|
||||||
|
|
||||||
redis:
|
Limitations (to be addressed in future versions):
|
||||||
image: redis:7-alpine
|
- Feed sources are currently hardcoded (not user-configurable)
|
||||||
container_name: ffr_redis
|
- Only supports Lemmy as target platform
|
||||||
restart: always
|
- Basic keyword matching (no regex or complex rules yet)
|
||||||
volumes:
|
|
||||||
- redis_data:/data
|
|
||||||
|
|
||||||
volumes:
|
## 🚀 Installation
|
||||||
db_data:
|
|
||||||
redis_data:
|
### Quick Start with Docker
|
||||||
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
|
||||||
```
|
```
|
||||||
|
|
||||||
### Environment Variables
|
View application logs:
|
||||||
|
```bash
|
||||||
|
docker compose logs -f app
|
||||||
|
```
|
||||||
|
|
||||||
| Variable | Required | Description |
|
### Scheduled Tasks
|
||||||
|----------|----------|-------------|
|
|
||||||
| `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 |
|
|
||||||
|
|
||||||
## Development
|
The application automatically:
|
||||||
|
- Fetches new articles every hour
|
||||||
|
- Publishes matching articles every 5 minutes
|
||||||
|
- Syncs with Lemmy communities every 10 minutes
|
||||||
|
|
||||||
### NixOS / Nix
|
## 📜 Logging & Debugging
|
||||||
|
|
||||||
|
**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
|
||||||
git clone https://forge.lvl0.xyz/lvl0/fedi-feed-router.git
|
# Run tests with coverage
|
||||||
cd ffr
|
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"
|
||||||
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
|
||||||
```
|
```
|
||||||
|
|
||||||
The shell will display available commands and optionally start the containers for you.
|
### Development Features
|
||||||
|
|
||||||
#### Available Commands
|
- **Hot reload:** Vite automatically reloads frontend changes
|
||||||
|
- **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 issues and questions, please use [Issues](https://forge.lvl0.xyz/lvl0/fedi-feed-router/issues).
|
For help and support:
|
||||||
|
- 💬 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>
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Exceptions;
|
|
||||||
|
|
||||||
use Exception;
|
|
||||||
|
|
||||||
class ChannelException extends Exception
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
@ -1,112 +0,0 @@
|
||||||
<?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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Controllers\Auth;
|
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
|
||||||
use App\Http\Requests\Auth\LoginRequest;
|
|
||||||
use Illuminate\Http\RedirectResponse;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Support\Facades\Auth;
|
|
||||||
use Illuminate\View\View;
|
|
||||||
|
|
||||||
class AuthenticatedSessionController extends Controller
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Display the login view.
|
|
||||||
*/
|
|
||||||
public function create(): View
|
|
||||||
{
|
|
||||||
return view('auth.login');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle an incoming authentication request.
|
|
||||||
*/
|
|
||||||
public function store(LoginRequest $request): RedirectResponse
|
|
||||||
{
|
|
||||||
$request->authenticate();
|
|
||||||
|
|
||||||
$request->session()->regenerate();
|
|
||||||
|
|
||||||
return redirect()->intended(route('dashboard', absolute: false));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Destroy an authenticated session.
|
|
||||||
*/
|
|
||||||
public function destroy(Request $request): RedirectResponse
|
|
||||||
{
|
|
||||||
Auth::guard('web')->logout();
|
|
||||||
|
|
||||||
$request->session()->invalidate();
|
|
||||||
|
|
||||||
$request->session()->regenerateToken();
|
|
||||||
|
|
||||||
return redirect('/');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Controllers\Auth;
|
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
|
||||||
use Illuminate\Http\RedirectResponse;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Support\Facades\Auth;
|
|
||||||
use Illuminate\Validation\ValidationException;
|
|
||||||
use Illuminate\View\View;
|
|
||||||
|
|
||||||
class ConfirmablePasswordController extends Controller
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Show the confirm password view.
|
|
||||||
*/
|
|
||||||
public function show(): View
|
|
||||||
{
|
|
||||||
return view('auth.confirm-password');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Confirm the user's password.
|
|
||||||
*/
|
|
||||||
public function store(Request $request): RedirectResponse
|
|
||||||
{
|
|
||||||
if (! Auth::guard('web')->validate([
|
|
||||||
'email' => $request->user()->email,
|
|
||||||
'password' => $request->password,
|
|
||||||
])) {
|
|
||||||
throw ValidationException::withMessages([
|
|
||||||
'password' => __('auth.password'),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$request->session()->put('auth.password_confirmed_at', time());
|
|
||||||
|
|
||||||
return redirect()->intended(route('dashboard', absolute: false));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Controllers\Auth;
|
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
|
||||||
use Illuminate\Http\RedirectResponse;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
|
|
||||||
class EmailVerificationNotificationController extends Controller
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Send a new email verification notification.
|
|
||||||
*/
|
|
||||||
public function store(Request $request): RedirectResponse
|
|
||||||
{
|
|
||||||
if ($request->user()->hasVerifiedEmail()) {
|
|
||||||
return redirect()->intended(route('dashboard', absolute: false));
|
|
||||||
}
|
|
||||||
|
|
||||||
$request->user()->sendEmailVerificationNotification();
|
|
||||||
|
|
||||||
return back()->with('status', 'verification-link-sent');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Controllers\Auth;
|
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
|
||||||
use Illuminate\Http\RedirectResponse;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\View\View;
|
|
||||||
|
|
||||||
class EmailVerificationPromptController extends Controller
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Display the email verification prompt.
|
|
||||||
*/
|
|
||||||
public function __invoke(Request $request): RedirectResponse|View
|
|
||||||
{
|
|
||||||
return $request->user()->hasVerifiedEmail()
|
|
||||||
? redirect()->intended(route('dashboard', absolute: false))
|
|
||||||
: view('auth.verify-email');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,62 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Controllers\Auth;
|
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
|
||||||
use App\Models\User;
|
|
||||||
use Illuminate\Auth\Events\PasswordReset;
|
|
||||||
use Illuminate\Http\RedirectResponse;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Support\Facades\Hash;
|
|
||||||
use Illuminate\Support\Facades\Password;
|
|
||||||
use Illuminate\Support\Str;
|
|
||||||
use Illuminate\Validation\Rules;
|
|
||||||
use Illuminate\View\View;
|
|
||||||
|
|
||||||
class NewPasswordController extends Controller
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Display the password reset view.
|
|
||||||
*/
|
|
||||||
public function create(Request $request): View
|
|
||||||
{
|
|
||||||
return view('auth.reset-password', ['request' => $request]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle an incoming new password request.
|
|
||||||
*
|
|
||||||
* @throws \Illuminate\Validation\ValidationException
|
|
||||||
*/
|
|
||||||
public function store(Request $request): RedirectResponse
|
|
||||||
{
|
|
||||||
$request->validate([
|
|
||||||
'token' => ['required'],
|
|
||||||
'email' => ['required', 'email'],
|
|
||||||
'password' => ['required', 'confirmed', Rules\Password::defaults()],
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Here we will attempt to reset the user's password. If it is successful we
|
|
||||||
// will update the password on an actual user model and persist it to the
|
|
||||||
// database. Otherwise we will parse the error and return the response.
|
|
||||||
$status = Password::reset(
|
|
||||||
$request->only('email', 'password', 'password_confirmation', 'token'),
|
|
||||||
function (User $user) use ($request) {
|
|
||||||
$user->forceFill([
|
|
||||||
'password' => Hash::make($request->password),
|
|
||||||
'remember_token' => Str::random(60),
|
|
||||||
])->save();
|
|
||||||
|
|
||||||
event(new PasswordReset($user));
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// If the password was successfully reset, we will redirect the user back to
|
|
||||||
// the application's home authenticated view. If there is an error we can
|
|
||||||
// redirect them back to where they came from with their error message.
|
|
||||||
return $status == Password::PASSWORD_RESET
|
|
||||||
? redirect()->route('login')->with('status', __($status))
|
|
||||||
: back()->withInput($request->only('email'))
|
|
||||||
->withErrors(['email' => __($status)]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Controllers\Auth;
|
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
|
||||||
use Illuminate\Http\RedirectResponse;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Support\Facades\Hash;
|
|
||||||
use Illuminate\Validation\Rules\Password;
|
|
||||||
|
|
||||||
class PasswordController extends Controller
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Update the user's password.
|
|
||||||
*/
|
|
||||||
public function update(Request $request): RedirectResponse
|
|
||||||
{
|
|
||||||
$validated = $request->validateWithBag('updatePassword', [
|
|
||||||
'current_password' => ['required', 'current_password'],
|
|
||||||
'password' => ['required', Password::defaults(), 'confirmed'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$request->user()->update([
|
|
||||||
'password' => Hash::make($validated['password']),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return back()->with('status', 'password-updated');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Controllers\Auth;
|
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
|
||||||
use Illuminate\Http\RedirectResponse;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Support\Facades\Password;
|
|
||||||
use Illuminate\View\View;
|
|
||||||
|
|
||||||
class PasswordResetLinkController extends Controller
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Display the password reset link request view.
|
|
||||||
*/
|
|
||||||
public function create(): View
|
|
||||||
{
|
|
||||||
return view('auth.forgot-password');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle an incoming password reset link request.
|
|
||||||
*
|
|
||||||
* @throws \Illuminate\Validation\ValidationException
|
|
||||||
*/
|
|
||||||
public function store(Request $request): RedirectResponse
|
|
||||||
{
|
|
||||||
$request->validate([
|
|
||||||
'email' => ['required', 'email'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
// We will send the password reset link to this user. Once we have attempted
|
|
||||||
// to send the link, we will examine the response then see the message we
|
|
||||||
// need to show to the user. Finally, we'll send out a proper response.
|
|
||||||
$status = Password::sendResetLink(
|
|
||||||
$request->only('email')
|
|
||||||
);
|
|
||||||
|
|
||||||
return $status == Password::RESET_LINK_SENT
|
|
||||||
? back()->with('status', __($status))
|
|
||||||
: back()->withInput($request->only('email'))
|
|
||||||
->withErrors(['email' => __($status)]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Controllers\Auth;
|
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
|
||||||
use App\Models\User;
|
|
||||||
use Illuminate\Auth\Events\Registered;
|
|
||||||
use Illuminate\Http\RedirectResponse;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Support\Facades\Auth;
|
|
||||||
use Illuminate\Support\Facades\Hash;
|
|
||||||
use Illuminate\Validation\Rules;
|
|
||||||
use Illuminate\View\View;
|
|
||||||
|
|
||||||
class RegisteredUserController extends Controller
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Display the registration view.
|
|
||||||
*/
|
|
||||||
public function create(): View
|
|
||||||
{
|
|
||||||
return view('auth.register');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle an incoming registration request.
|
|
||||||
*
|
|
||||||
* @throws \Illuminate\Validation\ValidationException
|
|
||||||
*/
|
|
||||||
public function store(Request $request): RedirectResponse
|
|
||||||
{
|
|
||||||
$request->validate([
|
|
||||||
'name' => ['required', 'string', 'max:255'],
|
|
||||||
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:'.User::class],
|
|
||||||
'password' => ['required', 'confirmed', Rules\Password::defaults()],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$user = User::create([
|
|
||||||
'name' => $request->name,
|
|
||||||
'email' => $request->email,
|
|
||||||
'password' => Hash::make($request->password),
|
|
||||||
]);
|
|
||||||
|
|
||||||
event(new Registered($user));
|
|
||||||
|
|
||||||
Auth::login($user);
|
|
||||||
|
|
||||||
return redirect(route('dashboard', absolute: false));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Controllers\Auth;
|
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
|
||||||
use Illuminate\Auth\Events\Verified;
|
|
||||||
use Illuminate\Foundation\Auth\EmailVerificationRequest;
|
|
||||||
use Illuminate\Http\RedirectResponse;
|
|
||||||
|
|
||||||
class VerifyEmailController extends Controller
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Mark the authenticated user's email address as verified.
|
|
||||||
*/
|
|
||||||
public function __invoke(EmailVerificationRequest $request): RedirectResponse
|
|
||||||
{
|
|
||||||
if ($request->user()->hasVerifiedEmail()) {
|
|
||||||
return redirect()->intended(route('dashboard', absolute: false).'?verified=1');
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($request->user()->markEmailAsVerified()) {
|
|
||||||
event(new Verified($request->user()));
|
|
||||||
}
|
|
||||||
|
|
||||||
return redirect()->intended(route('dashboard', absolute: false).'?verified=1');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Controllers;
|
|
||||||
|
|
||||||
use App\Http\Requests\ProfileUpdateRequest;
|
|
||||||
use Illuminate\Http\RedirectResponse;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Support\Facades\Auth;
|
|
||||||
use Illuminate\Support\Facades\Redirect;
|
|
||||||
use Illuminate\View\View;
|
|
||||||
|
|
||||||
class ProfileController extends Controller
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Display the user's profile form.
|
|
||||||
*/
|
|
||||||
public function edit(Request $request): View
|
|
||||||
{
|
|
||||||
return view('profile.edit', [
|
|
||||||
'user' => $request->user(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update the user's profile information.
|
|
||||||
*/
|
|
||||||
public function update(ProfileUpdateRequest $request): RedirectResponse
|
|
||||||
{
|
|
||||||
$request->user()->fill($request->validated());
|
|
||||||
|
|
||||||
if ($request->user()->isDirty('email')) {
|
|
||||||
$request->user()->email_verified_at = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$request->user()->save();
|
|
||||||
|
|
||||||
return Redirect::route('profile.edit')->with('status', 'profile-updated');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete the user's account.
|
|
||||||
*/
|
|
||||||
public function destroy(Request $request): RedirectResponse
|
|
||||||
{
|
|
||||||
$request->validateWithBag('userDeletion', [
|
|
||||||
'password' => ['required', 'current_password'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$user = $request->user();
|
|
||||||
|
|
||||||
Auth::logout();
|
|
||||||
|
|
||||||
$user->delete();
|
|
||||||
|
|
||||||
$request->session()->invalidate();
|
|
||||||
$request->session()->regenerateToken();
|
|
||||||
|
|
||||||
return Redirect::to('/');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Middleware;
|
|
||||||
|
|
||||||
use App\Services\OnboardingService;
|
|
||||||
use Closure;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
|
||||||
|
|
||||||
class EnsureOnboardingComplete
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private OnboardingService $onboardingService
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle an incoming request.
|
|
||||||
*
|
|
||||||
* Redirect to onboarding if the user hasn't completed setup.
|
|
||||||
*/
|
|
||||||
public function handle(Request $request, Closure $next): Response
|
|
||||||
{
|
|
||||||
if ($this->onboardingService->needsOnboarding()) {
|
|
||||||
return redirect()->route('onboarding');
|
|
||||||
}
|
|
||||||
|
|
||||||
return $next($request);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Middleware;
|
|
||||||
|
|
||||||
use App\Services\OnboardingService;
|
|
||||||
use Closure;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
|
||||||
|
|
||||||
class RedirectIfOnboardingComplete
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private OnboardingService $onboardingService
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle an incoming request.
|
|
||||||
*
|
|
||||||
* Redirect to dashboard if onboarding is already complete.
|
|
||||||
*/
|
|
||||||
public function handle(Request $request, Closure $next): Response
|
|
||||||
{
|
|
||||||
if (!$this->onboardingService->needsOnboarding()) {
|
|
||||||
return redirect()->route('dashboard');
|
|
||||||
}
|
|
||||||
|
|
||||||
return $next($request);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,85 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Requests\Auth;
|
|
||||||
|
|
||||||
use Illuminate\Auth\Events\Lockout;
|
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
|
||||||
use Illuminate\Support\Facades\Auth;
|
|
||||||
use Illuminate\Support\Facades\RateLimiter;
|
|
||||||
use Illuminate\Support\Str;
|
|
||||||
use Illuminate\Validation\ValidationException;
|
|
||||||
|
|
||||||
class LoginRequest extends FormRequest
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Determine if the user is authorized to make this request.
|
|
||||||
*/
|
|
||||||
public function authorize(): bool
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the validation rules that apply to the request.
|
|
||||||
*
|
|
||||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
|
||||||
*/
|
|
||||||
public function rules(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'email' => ['required', 'string', 'email'],
|
|
||||||
'password' => ['required', 'string'],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Attempt to authenticate the request's credentials.
|
|
||||||
*
|
|
||||||
* @throws \Illuminate\Validation\ValidationException
|
|
||||||
*/
|
|
||||||
public function authenticate(): void
|
|
||||||
{
|
|
||||||
$this->ensureIsNotRateLimited();
|
|
||||||
|
|
||||||
if (! Auth::attempt($this->only('email', 'password'), $this->boolean('remember'))) {
|
|
||||||
RateLimiter::hit($this->throttleKey());
|
|
||||||
|
|
||||||
throw ValidationException::withMessages([
|
|
||||||
'email' => trans('auth.failed'),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
RateLimiter::clear($this->throttleKey());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ensure the login request is not rate limited.
|
|
||||||
*
|
|
||||||
* @throws \Illuminate\Validation\ValidationException
|
|
||||||
*/
|
|
||||||
public function ensureIsNotRateLimited(): void
|
|
||||||
{
|
|
||||||
if (! RateLimiter::tooManyAttempts($this->throttleKey(), 5)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
event(new Lockout($this));
|
|
||||||
|
|
||||||
$seconds = RateLimiter::availableIn($this->throttleKey());
|
|
||||||
|
|
||||||
throw ValidationException::withMessages([
|
|
||||||
'email' => trans('auth.throttle', [
|
|
||||||
'seconds' => $seconds,
|
|
||||||
'minutes' => ceil($seconds / 60),
|
|
||||||
]),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the rate limiting throttle key for the request.
|
|
||||||
*/
|
|
||||||
public function throttleKey(): string
|
|
||||||
{
|
|
||||||
return Str::transliterate(Str::lower($this->string('email')).'|'.$this->ip());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Requests;
|
|
||||||
|
|
||||||
use App\Models\User;
|
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
|
||||||
use Illuminate\Validation\Rule;
|
|
||||||
|
|
||||||
class ProfileUpdateRequest extends FormRequest
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Get the validation rules that apply to the request.
|
|
||||||
*
|
|
||||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
|
||||||
*/
|
|
||||||
public function rules(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'name' => ['required', 'string', 'max:255'],
|
|
||||||
'email' => [
|
|
||||||
'required',
|
|
||||||
'string',
|
|
||||||
'lowercase',
|
|
||||||
'email',
|
|
||||||
'max:255',
|
|
||||||
Rule::unique(User::class)->ignore($this->user()->id),
|
|
||||||
],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
<?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'
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
<?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'
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
<?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
|
|
||||||
),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,64 +0,0 @@
|
||||||
<?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(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
<?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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,61 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Livewire;
|
|
||||||
|
|
||||||
use App\Models\Article;
|
|
||||||
use App\Models\Setting;
|
|
||||||
use App\Jobs\ArticleDiscoveryJob;
|
|
||||||
use Livewire\Component;
|
|
||||||
use Livewire\WithPagination;
|
|
||||||
|
|
||||||
class Articles extends Component
|
|
||||||
{
|
|
||||||
use WithPagination;
|
|
||||||
|
|
||||||
public bool $isRefreshing = false;
|
|
||||||
|
|
||||||
public function approve(int $articleId): void
|
|
||||||
{
|
|
||||||
$article = Article::findOrFail($articleId);
|
|
||||||
$article->approve();
|
|
||||||
|
|
||||||
$this->dispatch('article-updated');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function reject(int $articleId): void
|
|
||||||
{
|
|
||||||
$article = Article::findOrFail($articleId);
|
|
||||||
$article->reject();
|
|
||||||
|
|
||||||
$this->dispatch('article-updated');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function refresh(): void
|
|
||||||
{
|
|
||||||
$this->isRefreshing = true;
|
|
||||||
|
|
||||||
ArticleDiscoveryJob::dispatch();
|
|
||||||
|
|
||||||
// Reset after 10 seconds
|
|
||||||
$this->dispatch('refresh-complete')->self();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function refreshComplete(): void
|
|
||||||
{
|
|
||||||
$this->isRefreshing = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function render()
|
|
||||||
{
|
|
||||||
$articles = Article::with(['feed', 'articlePublication'])
|
|
||||||
->orderBy('created_at', 'desc')
|
|
||||||
->paginate(15);
|
|
||||||
|
|
||||||
$approvalsEnabled = Setting::isPublishingApprovalsEnabled();
|
|
||||||
|
|
||||||
return view('livewire.articles', [
|
|
||||||
'articles' => $articles,
|
|
||||||
'approvalsEnabled' => $approvalsEnabled,
|
|
||||||
])->layout('layouts.app');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,73 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Livewire;
|
|
||||||
|
|
||||||
use App\Models\PlatformAccount;
|
|
||||||
use App\Models\PlatformChannel;
|
|
||||||
use Livewire\Component;
|
|
||||||
|
|
||||||
class Channels extends Component
|
|
||||||
{
|
|
||||||
public ?int $managingChannelId = null;
|
|
||||||
|
|
||||||
public function toggle(int $channelId): void
|
|
||||||
{
|
|
||||||
$channel = PlatformChannel::findOrFail($channelId);
|
|
||||||
$channel->is_active = !$channel->is_active;
|
|
||||||
$channel->save();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function openAccountModal(int $channelId): void
|
|
||||||
{
|
|
||||||
$this->managingChannelId = $channelId;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function closeAccountModal(): void
|
|
||||||
{
|
|
||||||
$this->managingChannelId = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function attachAccount(int $accountId): void
|
|
||||||
{
|
|
||||||
if (!$this->managingChannelId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$channel = PlatformChannel::findOrFail($this->managingChannelId);
|
|
||||||
|
|
||||||
if (!$channel->platformAccounts()->where('platform_account_id', $accountId)->exists()) {
|
|
||||||
$channel->platformAccounts()->attach($accountId, [
|
|
||||||
'is_active' => true,
|
|
||||||
'priority' => 1,
|
|
||||||
'created_at' => now(),
|
|
||||||
'updated_at' => now(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function detachAccount(int $channelId, int $accountId): void
|
|
||||||
{
|
|
||||||
$channel = PlatformChannel::findOrFail($channelId);
|
|
||||||
$channel->platformAccounts()->detach($accountId);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function render()
|
|
||||||
{
|
|
||||||
$channels = PlatformChannel::with(['platformInstance', 'platformAccounts'])->orderBy('name')->get();
|
|
||||||
$allAccounts = PlatformAccount::where('is_active', true)->get();
|
|
||||||
|
|
||||||
$managingChannel = $this->managingChannelId
|
|
||||||
? PlatformChannel::with('platformAccounts')->find($this->managingChannelId)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
$availableAccounts = $managingChannel
|
|
||||||
? $allAccounts->filter(fn($account) => !$managingChannel->platformAccounts->contains('id', $account->id))
|
|
||||||
: collect();
|
|
||||||
|
|
||||||
return view('livewire.channels', [
|
|
||||||
'channels' => $channels,
|
|
||||||
'managingChannel' => $managingChannel,
|
|
||||||
'availableAccounts' => $availableAccounts,
|
|
||||||
])->layout('layouts.app');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Livewire;
|
|
||||||
|
|
||||||
use App\Services\DashboardStatsService;
|
|
||||||
use Livewire\Component;
|
|
||||||
|
|
||||||
class Dashboard extends Component
|
|
||||||
{
|
|
||||||
public string $period = 'today';
|
|
||||||
|
|
||||||
public function mount(): void
|
|
||||||
{
|
|
||||||
// Default period
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setPeriod(string $period): void
|
|
||||||
{
|
|
||||||
$this->period = $period;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function render()
|
|
||||||
{
|
|
||||||
$service = app(DashboardStatsService::class);
|
|
||||||
|
|
||||||
$articleStats = $service->getStats($this->period);
|
|
||||||
$systemStats = $service->getSystemStats();
|
|
||||||
$availablePeriods = $service->getAvailablePeriods();
|
|
||||||
|
|
||||||
return view('livewire.dashboard', [
|
|
||||||
'articleStats' => $articleStats,
|
|
||||||
'systemStats' => $systemStats,
|
|
||||||
'availablePeriods' => $availablePeriods,
|
|
||||||
])->layout('layouts.app');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Livewire;
|
|
||||||
|
|
||||||
use App\Models\Feed;
|
|
||||||
use Livewire\Component;
|
|
||||||
|
|
||||||
class Feeds extends Component
|
|
||||||
{
|
|
||||||
public function toggle(int $feedId): void
|
|
||||||
{
|
|
||||||
$feed = Feed::findOrFail($feedId);
|
|
||||||
$feed->is_active = !$feed->is_active;
|
|
||||||
$feed->save();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function render()
|
|
||||||
{
|
|
||||||
$feeds = Feed::orderBy('name')->get();
|
|
||||||
|
|
||||||
return view('livewire.feeds', [
|
|
||||||
'feeds' => $feeds,
|
|
||||||
])->layout('layouts.app');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,465 +0,0 @@
|
||||||
<?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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,200 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Livewire;
|
|
||||||
|
|
||||||
use App\Models\Feed;
|
|
||||||
use App\Models\Keyword;
|
|
||||||
use App\Models\PlatformChannel;
|
|
||||||
use App\Models\Route;
|
|
||||||
use Livewire\Component;
|
|
||||||
|
|
||||||
class Routes extends Component
|
|
||||||
{
|
|
||||||
public bool $showCreateModal = false;
|
|
||||||
public ?int $editingFeedId = null;
|
|
||||||
public ?int $editingChannelId = null;
|
|
||||||
|
|
||||||
// Create form
|
|
||||||
public ?int $newFeedId = null;
|
|
||||||
public ?int $newChannelId = null;
|
|
||||||
public int $newPriority = 50;
|
|
||||||
|
|
||||||
// Edit form
|
|
||||||
public int $editPriority = 50;
|
|
||||||
|
|
||||||
// Keyword management
|
|
||||||
public string $newKeyword = '';
|
|
||||||
public bool $showKeywordInput = false;
|
|
||||||
|
|
||||||
public function openCreateModal(): void
|
|
||||||
{
|
|
||||||
$this->showCreateModal = true;
|
|
||||||
$this->newFeedId = null;
|
|
||||||
$this->newChannelId = null;
|
|
||||||
$this->newPriority = 50;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function closeCreateModal(): void
|
|
||||||
{
|
|
||||||
$this->showCreateModal = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function createRoute(): void
|
|
||||||
{
|
|
||||||
$this->validate([
|
|
||||||
'newFeedId' => 'required|exists:feeds,id',
|
|
||||||
'newChannelId' => 'required|exists:platform_channels,id',
|
|
||||||
'newPriority' => 'required|integer|min:0',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$exists = Route::where('feed_id', $this->newFeedId)
|
|
||||||
->where('platform_channel_id', $this->newChannelId)
|
|
||||||
->exists();
|
|
||||||
|
|
||||||
if ($exists) {
|
|
||||||
$this->addError('newFeedId', 'This route already exists.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Route::create([
|
|
||||||
'feed_id' => $this->newFeedId,
|
|
||||||
'platform_channel_id' => $this->newChannelId,
|
|
||||||
'priority' => $this->newPriority,
|
|
||||||
'is_active' => true,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->closeCreateModal();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function openEditModal(int $feedId, int $channelId): void
|
|
||||||
{
|
|
||||||
$route = Route::where('feed_id', $feedId)
|
|
||||||
->where('platform_channel_id', $channelId)
|
|
||||||
->firstOrFail();
|
|
||||||
|
|
||||||
$this->editingFeedId = $feedId;
|
|
||||||
$this->editingChannelId = $channelId;
|
|
||||||
$this->editPriority = $route->priority;
|
|
||||||
$this->newKeyword = '';
|
|
||||||
$this->showKeywordInput = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function closeEditModal(): void
|
|
||||||
{
|
|
||||||
$this->editingFeedId = null;
|
|
||||||
$this->editingChannelId = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function updateRoute(): void
|
|
||||||
{
|
|
||||||
if (!$this->editingFeedId || !$this->editingChannelId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->validate([
|
|
||||||
'editPriority' => 'required|integer|min:0',
|
|
||||||
]);
|
|
||||||
|
|
||||||
Route::where('feed_id', $this->editingFeedId)
|
|
||||||
->where('platform_channel_id', $this->editingChannelId)
|
|
||||||
->update(['priority' => $this->editPriority]);
|
|
||||||
|
|
||||||
$this->closeEditModal();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function toggle(int $feedId, int $channelId): void
|
|
||||||
{
|
|
||||||
$route = Route::where('feed_id', $feedId)
|
|
||||||
->where('platform_channel_id', $channelId)
|
|
||||||
->firstOrFail();
|
|
||||||
|
|
||||||
$route->is_active = !$route->is_active;
|
|
||||||
$route->save();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function delete(int $feedId, int $channelId): void
|
|
||||||
{
|
|
||||||
// Delete associated keywords first
|
|
||||||
Keyword::where('feed_id', $feedId)
|
|
||||||
->where('platform_channel_id', $channelId)
|
|
||||||
->delete();
|
|
||||||
|
|
||||||
Route::where('feed_id', $feedId)
|
|
||||||
->where('platform_channel_id', $channelId)
|
|
||||||
->delete();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function addKeyword(): void
|
|
||||||
{
|
|
||||||
if (!$this->editingFeedId || !$this->editingChannelId || empty(trim($this->newKeyword))) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Keyword::create([
|
|
||||||
'feed_id' => $this->editingFeedId,
|
|
||||||
'platform_channel_id' => $this->editingChannelId,
|
|
||||||
'keyword' => trim($this->newKeyword),
|
|
||||||
'is_active' => true,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->newKeyword = '';
|
|
||||||
$this->showKeywordInput = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function toggleKeyword(int $keywordId): void
|
|
||||||
{
|
|
||||||
$keyword = Keyword::findOrFail($keywordId);
|
|
||||||
$keyword->is_active = !$keyword->is_active;
|
|
||||||
$keyword->save();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function deleteKeyword(int $keywordId): void
|
|
||||||
{
|
|
||||||
Keyword::destroy($keywordId);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function render()
|
|
||||||
{
|
|
||||||
$routes = Route::with(['feed', 'platformChannel'])
|
|
||||||
->orderBy('priority', 'desc')
|
|
||||||
->get();
|
|
||||||
|
|
||||||
// Batch load keywords for all routes to avoid N+1 queries
|
|
||||||
$routeKeys = $routes->map(fn($r) => $r->feed_id . '-' . $r->platform_channel_id);
|
|
||||||
$allKeywords = Keyword::whereIn('feed_id', $routes->pluck('feed_id'))
|
|
||||||
->whereIn('platform_channel_id', $routes->pluck('platform_channel_id'))
|
|
||||||
->get()
|
|
||||||
->groupBy(fn($k) => $k->feed_id . '-' . $k->platform_channel_id);
|
|
||||||
|
|
||||||
$routes = $routes->map(function ($route) use ($allKeywords) {
|
|
||||||
$key = $route->feed_id . '-' . $route->platform_channel_id;
|
|
||||||
$route->keywords = $allKeywords->get($key, collect());
|
|
||||||
return $route;
|
|
||||||
});
|
|
||||||
|
|
||||||
$feeds = Feed::where('is_active', true)->orderBy('name')->get();
|
|
||||||
$channels = PlatformChannel::where('is_active', true)->orderBy('name')->get();
|
|
||||||
|
|
||||||
$editingRoute = null;
|
|
||||||
$editingKeywords = collect();
|
|
||||||
|
|
||||||
if ($this->editingFeedId && $this->editingChannelId) {
|
|
||||||
$editingRoute = Route::with(['feed', 'platformChannel'])
|
|
||||||
->where('feed_id', $this->editingFeedId)
|
|
||||||
->where('platform_channel_id', $this->editingChannelId)
|
|
||||||
->first();
|
|
||||||
|
|
||||||
$editingKeywords = Keyword::where('feed_id', $this->editingFeedId)
|
|
||||||
->where('platform_channel_id', $this->editingChannelId)
|
|
||||||
->get();
|
|
||||||
}
|
|
||||||
|
|
||||||
return view('livewire.routes', [
|
|
||||||
'routes' => $routes,
|
|
||||||
'feeds' => $feeds,
|
|
||||||
'channels' => $channels,
|
|
||||||
'editingRoute' => $editingRoute,
|
|
||||||
'editingKeywords' => $editingKeywords,
|
|
||||||
])->layout('layouts.app');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Livewire;
|
|
||||||
|
|
||||||
use App\Models\Setting;
|
|
||||||
use Livewire\Component;
|
|
||||||
|
|
||||||
class Settings extends Component
|
|
||||||
{
|
|
||||||
public bool $articleProcessingEnabled = true;
|
|
||||||
public bool $publishingApprovalsEnabled = false;
|
|
||||||
|
|
||||||
public ?string $successMessage = null;
|
|
||||||
public ?string $errorMessage = null;
|
|
||||||
|
|
||||||
public function mount(): void
|
|
||||||
{
|
|
||||||
$this->articleProcessingEnabled = Setting::isArticleProcessingEnabled();
|
|
||||||
$this->publishingApprovalsEnabled = Setting::isPublishingApprovalsEnabled();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function toggleArticleProcessing(): void
|
|
||||||
{
|
|
||||||
$this->articleProcessingEnabled = !$this->articleProcessingEnabled;
|
|
||||||
Setting::setArticleProcessingEnabled($this->articleProcessingEnabled);
|
|
||||||
$this->showSuccess();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function togglePublishingApprovals(): void
|
|
||||||
{
|
|
||||||
$this->publishingApprovalsEnabled = !$this->publishingApprovalsEnabled;
|
|
||||||
Setting::setPublishingApprovalsEnabled($this->publishingApprovalsEnabled);
|
|
||||||
$this->showSuccess();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function showSuccess(): void
|
|
||||||
{
|
|
||||||
$this->successMessage = 'Settings updated successfully!';
|
|
||||||
$this->errorMessage = null;
|
|
||||||
|
|
||||||
// Clear success message after 3 seconds
|
|
||||||
$this->dispatch('clear-message');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function clearMessages(): void
|
|
||||||
{
|
|
||||||
$this->successMessage = null;
|
|
||||||
$this->errorMessage = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function render()
|
|
||||||
{
|
|
||||||
return view('livewire.settings')->layout('layouts.app');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,52 +0,0 @@
|
||||||
<?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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,66 +0,0 @@
|
||||||
<?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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,69 +0,0 @@
|
||||||
<?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
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Services;
|
|
||||||
|
|
||||||
use App\Models\Feed;
|
|
||||||
use App\Models\PlatformAccount;
|
|
||||||
use App\Models\PlatformChannel;
|
|
||||||
use App\Models\Route;
|
|
||||||
use App\Models\Setting;
|
|
||||||
use Illuminate\Support\Facades\Cache;
|
|
||||||
|
|
||||||
class OnboardingService
|
|
||||||
{
|
|
||||||
public function needsOnboarding(): bool
|
|
||||||
{
|
|
||||||
return Cache::remember('onboarding_needed', 300, function () {
|
|
||||||
return $this->checkOnboardingStatus();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public function clearCache(): void
|
|
||||||
{
|
|
||||||
Cache::forget('onboarding_needed');
|
|
||||||
}
|
|
||||||
|
|
||||||
private function checkOnboardingStatus(): bool
|
|
||||||
{
|
|
||||||
$onboardingSkipped = Setting::where('key', 'onboarding_skipped')->value('value') === 'true';
|
|
||||||
$onboardingCompleted = Setting::where('key', 'onboarding_completed')->exists();
|
|
||||||
|
|
||||||
// If skipped or completed, no onboarding needed
|
|
||||||
if ($onboardingCompleted || $onboardingSkipped) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if all components exist
|
|
||||||
$hasPlatformAccount = PlatformAccount::where('is_active', true)->exists();
|
|
||||||
$hasFeed = Feed::where('is_active', true)->exists();
|
|
||||||
$hasChannel = PlatformChannel::where('is_active', true)->exists();
|
|
||||||
$hasRoute = Route::where('is_active', true)->exists();
|
|
||||||
|
|
||||||
$hasAllComponents = $hasPlatformAccount && $hasFeed && $hasChannel && $hasRoute;
|
|
||||||
|
|
||||||
return !$hasAllComponents;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\View\Components;
|
|
||||||
|
|
||||||
use Illuminate\View\Component;
|
|
||||||
use Illuminate\View\View;
|
|
||||||
|
|
||||||
class AppLayout extends Component
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Get the view / contents that represents the component.
|
|
||||||
*/
|
|
||||||
public function render(): View
|
|
||||||
{
|
|
||||||
return view('layouts.app');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\View\Components;
|
|
||||||
|
|
||||||
use Illuminate\View\Component;
|
|
||||||
use Illuminate\View\View;
|
|
||||||
|
|
||||||
class GuestLayout extends Component
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Get the view / contents that represents the component.
|
|
||||||
*/
|
|
||||||
public function render(): View
|
|
||||||
{
|
|
||||||
return view('layouts.guest');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
62
backend/.env.broken
Normal file
62
backend/.env.broken
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
APP_NAME="FFR Development"
|
||||||
|
APP_ENV=local
|
||||||
|
APP_KEY=
|
||||||
|
APP_DEBUG=true
|
||||||
|
APP_TIMEZONE=UTC
|
||||||
|
APP_URL=http://localhost:8000
|
||||||
|
|
||||||
|
APP_LOCALE=en
|
||||||
|
APP_FALLBACK_LOCALE=en
|
||||||
|
APP_FAKER_LOCALE=en_US
|
||||||
|
|
||||||
|
APP_MAINTENANCE_DRIVER=file
|
||||||
|
APP_MAINTENANCE_STORE=database
|
||||||
|
|
||||||
|
BCRYPT_ROUNDS=12
|
||||||
|
|
||||||
|
LOG_CHANNEL=stack
|
||||||
|
LOG_STACK=single
|
||||||
|
LOG_DEPRECATIONS_CHANNEL=null
|
||||||
|
LOG_LEVEL=debug
|
||||||
|
|
||||||
|
DB_CONNECTION=mysql
|
||||||
|
DB_HOST=db
|
||||||
|
DB_PORT=3306
|
||||||
|
DB_DATABASE=ffr_dev
|
||||||
|
DB_USERNAME=ffr_user
|
||||||
|
DB_PASSWORD=ffr_password
|
||||||
|
|
||||||
|
SESSION_DRIVER=redis
|
||||||
|
SESSION_LIFETIME=120
|
||||||
|
SESSION_ENCRYPT=false
|
||||||
|
SESSION_PATH=/
|
||||||
|
SESSION_DOMAIN=null
|
||||||
|
|
||||||
|
BROADCAST_CONNECTION=log
|
||||||
|
FILESYSTEM_DISK=local
|
||||||
|
QUEUE_CONNECTION=redis
|
||||||
|
|
||||||
|
CACHE_STORE=redis
|
||||||
|
CACHE_PREFIX=
|
||||||
|
|
||||||
|
REDIS_CLIENT=phpredis
|
||||||
|
REDIS_HOST=redis
|
||||||
|
REDIS_PASSWORD=null
|
||||||
|
REDIS_PORT=6379
|
||||||
|
|
||||||
|
MAIL_MAILER=log
|
||||||
|
MAIL_HOST=127.0.0.1
|
||||||
|
MAIL_PORT=2525
|
||||||
|
MAIL_USERNAME=null
|
||||||
|
MAIL_PASSWORD=null
|
||||||
|
MAIL_ENCRYPTION=null
|
||||||
|
MAIL_FROM_ADDRESS="hello@example.com"
|
||||||
|
MAIL_FROM_NAME="${APP_NAME}"
|
||||||
|
|
||||||
|
AWS_ACCESS_KEY_ID=
|
||||||
|
AWS_SECRET_ACCESS_KEY=
|
||||||
|
AWS_DEFAULT_REGION=us-east-1
|
||||||
|
AWS_BUCKET=
|
||||||
|
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||||
|
|
||||||
|
VITE_APP_NAME="${APP_NAME}"
|
||||||
|
|
@ -23,14 +23,14 @@ LOG_STACK=single
|
||||||
LOG_DEPRECATIONS_CHANNEL=null
|
LOG_DEPRECATIONS_CHANNEL=null
|
||||||
LOG_LEVEL=debug
|
LOG_LEVEL=debug
|
||||||
|
|
||||||
DB_CONNECTION=mysql
|
DB_CONNECTION=sqlite
|
||||||
DB_HOST=db
|
# DB_HOST=127.0.0.1
|
||||||
DB_PORT=3306
|
# DB_PORT=3306
|
||||||
DB_DATABASE=ffr_dev
|
# DB_DATABASE=laravel
|
||||||
DB_USERNAME=ffr
|
# DB_USERNAME=root
|
||||||
DB_PASSWORD=ffr
|
# DB_PASSWORD=
|
||||||
|
|
||||||
SESSION_DRIVER=redis
|
SESSION_DRIVER=database
|
||||||
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=redis
|
QUEUE_CONNECTION=database
|
||||||
|
|
||||||
CACHE_STORE=redis
|
CACHE_STORE=database
|
||||||
# CACHE_PREFIX=
|
# CACHE_PREFIX=
|
||||||
|
|
||||||
MEMCACHED_HOST=127.0.0.1
|
MEMCACHED_HOST=127.0.0.1
|
||||||
|
|
||||||
REDIS_CLIENT=phpredis
|
REDIS_CLIENT=phpredis
|
||||||
REDIS_HOST=redis
|
REDIS_HOST=127.0.0.1
|
||||||
REDIS_PASSWORD=null
|
REDIS_PASSWORD=null
|
||||||
REDIS_PORT=6379
|
REDIS_PORT=6379
|
||||||
|
|
||||||
2
backend/.gitignore
vendored
Normal file
2
backend/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
vendor
|
||||||
|
/coverage-report-*
|
||||||
|
|
@ -2,9 +2,9 @@
|
||||||
|
|
||||||
namespace App\Console\Commands;
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
use App\Jobs\ArticleDiscoveryJob;
|
use Domains\Article\Jobs\ArticleDiscoveryJob;
|
||||||
use App\Models\Feed;
|
use Domains\Feed\Models\Feed;
|
||||||
use App\Models\Setting;
|
use Domains\Settings\Models\Setting;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
class FetchNewArticlesCommand extends Command
|
class FetchNewArticlesCommand extends Command
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
namespace App\Console\Commands;
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
use App\Jobs\SyncChannelPostsJob;
|
use Domains\Platform\Jobs\SyncChannelPostsJob;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
class SyncChannelPostsCommand extends Command
|
class SyncChannelPostsCommand extends Command
|
||||||
|
|
@ -2,10 +2,10 @@
|
||||||
|
|
||||||
namespace App\Http\Controllers\Api\V1;
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
use App\Http\Resources\ArticleResource;
|
use Domains\Article\Resources\ArticleResource;
|
||||||
use App\Models\Article;
|
use Domains\Article\Models\Article;
|
||||||
use App\Models\Setting;
|
use Domains\Settings\Models\Setting;
|
||||||
use App\Jobs\ArticleDiscoveryJob;
|
use Domains\Article\Jobs\ArticleDiscoveryJob;
|
||||||
use Exception;
|
use Exception;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
|
@ -2,11 +2,11 @@
|
||||||
|
|
||||||
namespace App\Http\Controllers\Api\V1;
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
use App\Models\Article;
|
use Domains\Article\Models\Article;
|
||||||
use App\Models\Feed;
|
use Domains\Feed\Models\Feed;
|
||||||
use App\Models\PlatformAccount;
|
use Domains\Platform\Models\PlatformAccount;
|
||||||
use App\Models\PlatformChannel;
|
use Domains\Platform\Models\PlatformChannel;
|
||||||
use App\Services\DashboardStatsService;
|
use Domains\Article\Services\DashboardStatsService;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
|
@ -40,7 +40,6 @@ 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -2,10 +2,10 @@
|
||||||
|
|
||||||
namespace App\Http\Controllers\Api\V1;
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
use App\Http\Requests\StoreFeedRequest;
|
use Domains\Feed\Requests\StoreFeedRequest;
|
||||||
use App\Http\Requests\UpdateFeedRequest;
|
use Domains\Feed\Requests\UpdateFeedRequest;
|
||||||
use App\Http\Resources\FeedResource;
|
use Domains\Feed\Resources\FeedResource;
|
||||||
use App\Models\Feed;
|
use Domains\Feed\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(['language'])
|
$feeds = Feed::with(['languages'])
|
||||||
->withCount('articles')
|
->withCount('articles')
|
||||||
->orderBy('is_active', 'desc')
|
->orderBy('is_active', 'desc')
|
||||||
->orderBy('name')
|
->orderBy('name')
|
||||||
|
|
@ -49,16 +49,36 @@ public function store(StoreFeedRequest $request): JsonResponse
|
||||||
|
|
||||||
// Map provider to URL and set type
|
// Map provider to URL and set type
|
||||||
$providers = [
|
$providers = [
|
||||||
'vrt' => new \App\Services\Parsers\VrtHomepageParserAdapter(),
|
'vrt' => new \Domains\Article\Parsers\Vrt\VrtHomepageParserAdapter(),
|
||||||
'belga' => new \App\Services\Parsers\BelgaHomepageParserAdapter(),
|
'belga' => new \Domains\Article\Parsers\Belga\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),
|
||||||
'Feed created successfully!',
|
'Feed created successfully!',
|
||||||
|
|
@ -76,6 +96,8 @@ 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.'
|
||||||
|
|
@ -91,10 +113,34 @@ 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->fresh()),
|
new FeedResource($feed),
|
||||||
'Feed updated successfully!'
|
'Feed updated successfully!'
|
||||||
);
|
);
|
||||||
} catch (ValidationException $e) {
|
} catch (ValidationException $e) {
|
||||||
|
|
@ -2,9 +2,9 @@
|
||||||
|
|
||||||
namespace App\Http\Controllers\Api\V1;
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
use App\Models\Feed;
|
use Domains\Feed\Models\Feed;
|
||||||
use App\Models\Keyword;
|
use Domains\Article\Models\Keyword;
|
||||||
use App\Models\PlatformChannel;
|
use Domains\Platform\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;
|
||||||
146
backend/app/Http/Controllers/Api/V1/LanguagesController.php
Normal file
146
backend/app/Http/Controllers/Api/V1/LanguagesController.php
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use Domains\Settings\Models\Language;
|
||||||
|
use Domains\Settings\Services\LanguageService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class LanguagesController extends BaseController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private LanguageService $languageService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get languages available for route creation
|
||||||
|
* Returns languages that have both active feeds and active channels
|
||||||
|
*/
|
||||||
|
public function availableForRoutes(): JsonResponse
|
||||||
|
{
|
||||||
|
$languages = $this->languageService->getAvailableForRoutes();
|
||||||
|
|
||||||
|
return $this->sendResponse(
|
||||||
|
$languages,
|
||||||
|
'Available languages for routes retrieved successfully.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get feeds filtered by language
|
||||||
|
*/
|
||||||
|
public function feedsByLanguage(Request $request, int $languageId): JsonResponse
|
||||||
|
{
|
||||||
|
$fields = $this->parseFields($request->get('fields'), [
|
||||||
|
'feeds.id', 'feeds.name', 'feeds.url', 'feeds.type', 'feeds.provider'
|
||||||
|
]);
|
||||||
|
|
||||||
|
$feeds = $this->languageService->getFeedsByLanguage(
|
||||||
|
$languageId,
|
||||||
|
$request->get('search'),
|
||||||
|
(int) $request->get('per_page', 15),
|
||||||
|
$fields
|
||||||
|
);
|
||||||
|
|
||||||
|
$language = Language::select(['name'])->findOrFail($languageId);
|
||||||
|
|
||||||
|
return $this->sendResponse(
|
||||||
|
$feeds,
|
||||||
|
"Feeds for language '{$language->name}' retrieved successfully."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get channels filtered by language
|
||||||
|
*/
|
||||||
|
public function channelsByLanguage(Request $request, int $languageId): JsonResponse
|
||||||
|
{
|
||||||
|
$fields = $this->parseFields($request->get('fields'), [
|
||||||
|
'id', 'platform_instance_id', 'name', 'display_name', 'description', 'language_id'
|
||||||
|
]);
|
||||||
|
|
||||||
|
$channels = $this->languageService->getChannelsByLanguage(
|
||||||
|
$languageId,
|
||||||
|
$request->get('search'),
|
||||||
|
(int) $request->get('per_page', 15),
|
||||||
|
$fields
|
||||||
|
);
|
||||||
|
|
||||||
|
$language = Language::select(['name'])->findOrFail($languageId);
|
||||||
|
|
||||||
|
return $this->sendResponse(
|
||||||
|
$channels,
|
||||||
|
"Channels for language '{$language->name}' retrieved successfully."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get language statistics
|
||||||
|
*/
|
||||||
|
public function statistics(int $languageId): JsonResponse
|
||||||
|
{
|
||||||
|
$stats = $this->languageService->getLanguageStatistics($languageId);
|
||||||
|
$language = Language::select(['name'])->findOrFail($languageId);
|
||||||
|
|
||||||
|
return $this->sendResponse(
|
||||||
|
$stats,
|
||||||
|
"Statistics for language '{$language->name}' retrieved successfully."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get usage summary for all languages
|
||||||
|
*/
|
||||||
|
public function usageSummary(): JsonResponse
|
||||||
|
{
|
||||||
|
$summary = $this->languageService->getLanguageUsageSummary();
|
||||||
|
|
||||||
|
return $this->sendResponse(
|
||||||
|
$summary,
|
||||||
|
'Language usage summary retrieved successfully.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get common languages between feed and channel
|
||||||
|
*/
|
||||||
|
public function commonLanguages(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$request->validate([
|
||||||
|
'feed_id' => 'required|exists:feeds,id',
|
||||||
|
'channel_id' => 'required|exists:platform_channels,id'
|
||||||
|
]);
|
||||||
|
|
||||||
|
$commonLanguages = $this->languageService->getCommonLanguages(
|
||||||
|
$request->get('feed_id'),
|
||||||
|
$request->get('channel_id')
|
||||||
|
);
|
||||||
|
|
||||||
|
return $this->sendResponse(
|
||||||
|
$commonLanguages,
|
||||||
|
'Common languages retrieved successfully.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse field selection from request
|
||||||
|
*/
|
||||||
|
private function parseFields(?string $fieldsParam, array $defaultFields): array
|
||||||
|
{
|
||||||
|
if (!$fieldsParam) {
|
||||||
|
return $defaultFields;
|
||||||
|
}
|
||||||
|
|
||||||
|
$requestedFields = array_map('trim', explode(',', $fieldsParam));
|
||||||
|
$validFields = [];
|
||||||
|
|
||||||
|
foreach ($requestedFields as $field) {
|
||||||
|
if (in_array($field, $defaultFields) || str_contains($field, '.')) {
|
||||||
|
$validFields[] = $field;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return empty($validFields) ? $defaultFields : $validFields;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
namespace App\Http\Controllers\Api\V1;
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
use App\Models\Log;
|
use Domains\Logging\Models\Log;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
|
@ -2,20 +2,22 @@
|
||||||
|
|
||||||
namespace App\Http\Controllers\Api\V1;
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
use App\Http\Requests\StoreFeedRequest;
|
use Domains\Feed\Requests\StoreFeedRequest;
|
||||||
use App\Http\Resources\FeedResource;
|
use Domains\Feed\Resources\FeedResource;
|
||||||
use App\Http\Resources\PlatformAccountResource;
|
use Domains\Platform\Resources\PlatformAccountResource;
|
||||||
use App\Http\Resources\PlatformChannelResource;
|
use Domains\Platform\Resources\PlatformChannelResource;
|
||||||
use App\Http\Resources\RouteResource;
|
use Domains\Feed\Resources\RouteResource;
|
||||||
use App\Jobs\ArticleDiscoveryJob;
|
use Domains\Article\Jobs\ArticleDiscoveryJob;
|
||||||
use App\Models\Feed;
|
use Domains\Feed\Models\Feed;
|
||||||
use App\Models\Language;
|
use Domains\Settings\Models\Language;
|
||||||
use App\Models\PlatformAccount;
|
use Domains\Platform\Models\PlatformAccount;
|
||||||
use App\Models\PlatformChannel;
|
use Domains\Platform\Models\PlatformChannel;
|
||||||
use App\Models\PlatformInstance;
|
use Domains\Platform\Models\PlatformInstance;
|
||||||
use App\Models\Route;
|
use Domains\Feed\Models\Route;
|
||||||
use App\Models\Setting;
|
use Domains\Settings\Models\Setting;
|
||||||
use App\Services\Auth\LemmyAuthService;
|
use Domains\Platform\Services\Auth\Authenticators\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;
|
||||||
|
|
@ -89,13 +91,19 @@ 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'])
|
->with(['platformInstance:id,name,url', 'language:id,name,short_code'])
|
||||||
->orderBy('name')
|
->orderBy('name')
|
||||||
->get(['id', 'platform_instance_id', 'name', 'display_name', 'description']);
|
->get(['id', 'platform_instance_id', 'name', 'display_name', 'description', 'language_id']);
|
||||||
|
|
||||||
|
// 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', []))
|
||||||
|
|
@ -107,6 +115,7 @@ 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.');
|
||||||
}
|
}
|
||||||
|
|
@ -174,7 +183,7 @@ public function createPlatform(Request $request): JsonResponse
|
||||||
'Platform account created successfully.'
|
'Platform account created successfully.'
|
||||||
);
|
);
|
||||||
|
|
||||||
} catch (\App\Exceptions\PlatformAuthException $e) {
|
} catch (\Domains\Platform\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);
|
||||||
|
|
@ -204,7 +213,9 @@ 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_id' => 'required|exists:languages,id',
|
'language_ids' => 'required|array|min:1',
|
||||||
|
'language_ids.*' => 'exists:languages,id',
|
||||||
|
'primary_language_id' => 'nullable|exists:languages,id',
|
||||||
'description' => 'nullable|string|max:1000',
|
'description' => 'nullable|string|max:1000',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
@ -224,20 +235,34 @@ 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('language')),
|
new FeedResource($feed->load('languages')),
|
||||||
'Feed created successfully.'
|
'Feed created successfully.'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -251,7 +276,6 @@ 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',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
@ -277,6 +301,22 @@ 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
|
||||||
|
|
@ -296,9 +336,16 @@ 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'])),
|
||||||
'Channel created successfully and linked to platform account.'
|
$responseMessage
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -312,6 +359,7 @@ 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',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
@ -321,9 +369,22 @@ 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,
|
||||||
]);
|
]);
|
||||||
|
|
@ -333,7 +394,7 @@ public function createRoute(Request $request): JsonResponse
|
||||||
ArticleDiscoveryJob::dispatch();
|
ArticleDiscoveryJob::dispatch();
|
||||||
|
|
||||||
return $this->sendResponse(
|
return $this->sendResponse(
|
||||||
new RouteResource($route->load(['feed', 'platformChannel'])),
|
new RouteResource($route->load(['feed', 'platformChannel', 'language'])),
|
||||||
'Route created successfully.'
|
'Route created successfully.'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -2,10 +2,10 @@
|
||||||
|
|
||||||
namespace App\Http\Controllers\Api\V1;
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
use App\Enums\PlatformEnum;
|
use Domains\Platform\Enums\PlatformEnum;
|
||||||
use App\Http\Resources\PlatformAccountResource;
|
use Domains\Platform\Resources\PlatformAccountResource;
|
||||||
use App\Models\PlatformAccount;
|
use Domains\Platform\Models\PlatformAccount;
|
||||||
use App\Models\PlatformInstance;
|
use Domains\Platform\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;
|
||||||
|
|
@ -2,12 +2,17 @@
|
||||||
|
|
||||||
namespace App\Http\Controllers\Api\V1;
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
use App\Http\Resources\PlatformChannelResource;
|
use Domains\Platform\Resources\PlatformChannelResource;
|
||||||
use App\Models\PlatformChannel;
|
use Domains\Platform\Models\PlatformChannel;
|
||||||
use App\Models\PlatformAccount;
|
use Domains\Platform\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
|
||||||
{
|
{
|
||||||
|
|
@ -45,7 +50,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 = \App\Models\PlatformInstance::findOrFail($validated['platform_instance_id']);
|
$platformInstance = \Domains\Platform\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)
|
||||||
|
|
@ -60,6 +65,22 @@ 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
|
||||||
|
|
@ -71,9 +92,16 @@ 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'])),
|
new PlatformChannelResource($channel->load(['platformInstance', 'platformAccounts', 'language'])),
|
||||||
'Platform channel created successfully and linked to platform account!',
|
$responseMessage,
|
||||||
201
|
201
|
||||||
);
|
);
|
||||||
} catch (ValidationException $e) {
|
} catch (ValidationException $e) {
|
||||||
|
|
@ -246,4 +274,108 @@ 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -2,22 +2,28 @@
|
||||||
|
|
||||||
namespace App\Http\Controllers\Api\V1;
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
use App\Http\Resources\RouteResource;
|
use Domains\Feed\Resources\RouteResource;
|
||||||
use App\Models\Feed;
|
use Domains\Feed\Models\Feed;
|
||||||
use App\Models\PlatformChannel;
|
use Domains\Platform\Models\PlatformChannel;
|
||||||
use App\Models\Route;
|
use Domains\Feed\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::with(['feed', 'platformChannel', 'keywords'])
|
$routes = Route::withAllRelationships()
|
||||||
->orderBy('is_active', 'desc')
|
->orderBy('is_active', 'desc')
|
||||||
->orderBy('priority', 'asc')
|
->orderBy('priority', 'asc')
|
||||||
->get();
|
->get();
|
||||||
|
|
@ -31,23 +37,27 @@ public function index(): JsonResponse
|
||||||
/**
|
/**
|
||||||
* Store a newly created routing configuration
|
* Store a newly created routing configuration
|
||||||
*/
|
*/
|
||||||
public function store(Request $request): JsonResponse
|
public function store(StoreRouteRequest $request): JsonResponse
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$validated = $request->validate([
|
$validated = $request->validated();
|
||||||
'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->load(['feed', 'platformChannel', 'keywords'])),
|
new RouteResource($route),
|
||||||
'Routing configuration created successfully!',
|
'Routing configuration created successfully!',
|
||||||
201
|
201
|
||||||
);
|
);
|
||||||
|
|
@ -69,7 +79,13 @@ public function show(Feed $feed, PlatformChannel $channel): JsonResponse
|
||||||
return $this->sendNotFound('Routing configuration not found.');
|
return $this->sendNotFound('Routing configuration not found.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$route->load(['feed', 'platformChannel', 'keywords']);
|
$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),
|
||||||
|
|
@ -80,7 +96,7 @@ public function show(Feed $feed, PlatformChannel $channel): JsonResponse
|
||||||
/**
|
/**
|
||||||
* Update the specified routing configuration
|
* Update the specified routing configuration
|
||||||
*/
|
*/
|
||||||
public function update(Request $request, Feed $feed, PlatformChannel $channel): JsonResponse
|
public function update(UpdateRouteRequest $request, Feed $feed, PlatformChannel $channel): JsonResponse
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$route = $this->findRoute($feed, $channel);
|
$route = $this->findRoute($feed, $channel);
|
||||||
|
|
@ -89,17 +105,14 @@ public function update(Request $request, Feed $feed, PlatformChannel $channel):
|
||||||
return $this->sendNotFound('Routing configuration not found.');
|
return $this->sendNotFound('Routing configuration not found.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$validated = $request->validate([
|
$validated = $request->validated();
|
||||||
'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', 'keywords'])),
|
new RouteResource($route->fresh(['feed', 'platformChannel', 'language', 'keywords'])),
|
||||||
'Routing configuration updated successfully!'
|
'Routing configuration updated successfully!'
|
||||||
);
|
);
|
||||||
} catch (ValidationException $e) {
|
} catch (ValidationException $e) {
|
||||||
|
|
@ -154,7 +167,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', 'keywords'])),
|
new RouteResource($route->fresh(['feed', 'platformChannel', 'language', 'keywords'])),
|
||||||
"Routing configuration {$status} successfully!"
|
"Routing configuration {$status} successfully!"
|
||||||
);
|
);
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
|
|
@ -162,6 +175,19 @@ 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
|
||||||
*/
|
*/
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
namespace App\Http\Controllers\Api\V1;
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
use App\Models\Setting;
|
use Domains\Settings\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',
|
||||||
'publishing_approvals_enabled' => 'boolean',
|
'enable_publishing_approvals' => '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['publishing_approvals_enabled'])) {
|
if (isset($validated['enable_publishing_approvals'])) {
|
||||||
Setting::setPublishingApprovalsEnabled($validated['publishing_approvals_enabled']);
|
Setting::setPublishingApprovalsEnabled($validated['enable_publishing_approvals']);
|
||||||
}
|
}
|
||||||
|
|
||||||
$updatedSettings = [
|
$updatedSettings = [
|
||||||
|
|
@ -2,9 +2,9 @@
|
||||||
|
|
||||||
namespace App\Providers;
|
namespace App\Providers;
|
||||||
|
|
||||||
use App\Enums\LogLevelEnum;
|
use Domains\Logging\Enums\LogLevelEnum;
|
||||||
use App\Events\ExceptionOccurred;
|
use Domains\Logging\Events\ExceptionOccurred;
|
||||||
use App\Listeners\LogExceptionToDatabase;
|
use Domains\Logging\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,14 +26,10 @@ public function boot(): void
|
||||||
);
|
);
|
||||||
|
|
||||||
Event::listen(
|
Event::listen(
|
||||||
\App\Events\NewArticleFetched::class,
|
\Domains\Article\Events\NewArticleFetched::class,
|
||||||
\App\Listeners\ValidateArticleListener::class,
|
\Domains\Article\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) {
|
||||||
42
backend/bootstrap/app.php
Normal file
42
backend/bootstrap/app.php
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Middleware\HandleAppearance;
|
||||||
|
use App\Http\Middleware\HandleInertiaRequests;
|
||||||
|
use Illuminate\Foundation\Application;
|
||||||
|
use Illuminate\Foundation\Configuration\Exceptions;
|
||||||
|
use Illuminate\Foundation\Configuration\Middleware;
|
||||||
|
use Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets;
|
||||||
|
|
||||||
|
return Application::configure(basePath: dirname(__DIR__))
|
||||||
|
->withRouting(
|
||||||
|
web: __DIR__.'/../routes/web.php',
|
||||||
|
api: __DIR__.'/../routes/api.php',
|
||||||
|
commands: __DIR__.'/../routes/console.php',
|
||||||
|
health: '/up',
|
||||||
|
)
|
||||||
|
->withMiddleware(function (Middleware $middleware) {
|
||||||
|
$middleware->encryptCookies(except: ['appearance', 'sidebar_state']);
|
||||||
|
|
||||||
|
$middleware->web(append: [
|
||||||
|
HandleAppearance::class,
|
||||||
|
HandleInertiaRequests::class,
|
||||||
|
AddLinkHeadersForPreloadedAssets::class,
|
||||||
|
]);
|
||||||
|
})
|
||||||
|
->withExceptions(function (Exceptions $exceptions) {
|
||||||
|
$exceptions->reportable(function (Throwable $e) {
|
||||||
|
$level = match (true) {
|
||||||
|
$e instanceof Error => \Domains\Logging\Enums\LogLevelEnum::CRITICAL,
|
||||||
|
$e instanceof RuntimeException => \Domains\Logging\Enums\LogLevelEnum::ERROR,
|
||||||
|
$e instanceof InvalidArgumentException => \Domains\Logging\Enums\LogLevelEnum::WARNING,
|
||||||
|
default => \Domains\Logging\Enums\LogLevelEnum::ERROR,
|
||||||
|
};
|
||||||
|
|
||||||
|
Domains\Logging\Events\ExceptionOccurred::dispatch(
|
||||||
|
$e,
|
||||||
|
$level,
|
||||||
|
$e->getMessage(),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
})->create();
|
||||||
|
|
@ -16,13 +16,11 @@
|
||||||
"laravel/horizon": "^5.29",
|
"laravel/horizon": "^5.29",
|
||||||
"laravel/sanctum": "^4.2",
|
"laravel/sanctum": "^4.2",
|
||||||
"laravel/tinker": "^2.10.1",
|
"laravel/tinker": "^2.10.1",
|
||||||
"livewire/livewire": "^4.0",
|
|
||||||
"tightenco/ziggy": "^2.4"
|
"tightenco/ziggy": "^2.4"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"fakerphp/faker": "^1.23",
|
"fakerphp/faker": "^1.23",
|
||||||
"larastan/larastan": "^3.5",
|
"larastan/larastan": "^3.5",
|
||||||
"laravel/breeze": "^2.3",
|
|
||||||
"laravel/pail": "^1.2.2",
|
"laravel/pail": "^1.2.2",
|
||||||
"laravel/pint": "^1.18",
|
"laravel/pint": "^1.18",
|
||||||
"laravel/sail": "^1.43",
|
"laravel/sail": "^1.43",
|
||||||
|
|
@ -34,7 +32,7 @@
|
||||||
"autoload": {
|
"autoload": {
|
||||||
"psr-4": {
|
"psr-4": {
|
||||||
"App\\": "app/",
|
"App\\": "app/",
|
||||||
"Database\\Factories\\": "database/factories/",
|
"Domains\\": "src/Domains/",
|
||||||
"Database\\Seeders\\": "database/seeders/"
|
"Database\\Seeders\\": "database/seeders/"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
8773
backend/composer.lock
generated
Normal file
8773
backend/composer.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -62,7 +62,7 @@
|
||||||
'providers' => [
|
'providers' => [
|
||||||
'users' => [
|
'users' => [
|
||||||
'driver' => 'eloquent',
|
'driver' => 'eloquent',
|
||||||
'model' => env('AUTH_MODEL', App\Models\User::class),
|
'model' => env('AUTH_MODEL', Domains\User\Models\User::class),
|
||||||
],
|
],
|
||||||
|
|
||||||
// 'users' => [
|
// 'users' => [
|
||||||
|
|
@ -19,14 +19,20 @@
|
||||||
'description' => 'Belgian public broadcaster news',
|
'description' => 'Belgian public broadcaster news',
|
||||||
'type' => 'website',
|
'type' => 'website',
|
||||||
'is_active' => true,
|
'is_active' => true,
|
||||||
'languages' => [
|
'supported_languages' => [
|
||||||
'en' => ['url' => 'https://www.vrt.be/vrtnws/en/'],
|
'en' => [
|
||||||
'nl' => ['url' => 'https://www.vrt.be/vrtnws/nl/'],
|
'url' => 'https://www.vrt.be/vrtnws/en/',
|
||||||
|
'name' => 'English',
|
||||||
|
],
|
||||||
|
'nl' => [
|
||||||
|
'url' => 'https://www.vrt.be/vrtnws/nl/',
|
||||||
|
'name' => 'Dutch',
|
||||||
|
],
|
||||||
],
|
],
|
||||||
'parsers' => [
|
'parsers' => [
|
||||||
'homepage' => \App\Services\Parsers\VrtHomepageParserAdapter::class,
|
'homepage' => \Domains\Article\Parsers\Vrt\VrtHomepageParserAdapter::class,
|
||||||
'article' => \App\Services\Parsers\VrtArticleParser::class,
|
'article' => \Domains\Article\Parsers\Vrt\VrtArticleParser::class,
|
||||||
'article_page' => \App\Services\Parsers\VrtArticlePageParser::class,
|
'article_page' => \Domains\Article\Parsers\Vrt\VrtArticlePageParser::class,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
'belga' => [
|
'belga' => [
|
||||||
|
|
@ -35,13 +41,16 @@
|
||||||
'description' => 'Belgian national news agency',
|
'description' => 'Belgian national news agency',
|
||||||
'type' => 'rss',
|
'type' => 'rss',
|
||||||
'is_active' => true,
|
'is_active' => true,
|
||||||
'languages' => [
|
'supported_languages' => [
|
||||||
'en' => ['url' => 'https://www.belganewsagency.eu/'],
|
'en' => [
|
||||||
|
'url' => 'https://www.belganewsagency.eu/',
|
||||||
|
'name' => 'English',
|
||||||
|
],
|
||||||
],
|
],
|
||||||
'parsers' => [
|
'parsers' => [
|
||||||
'homepage' => \App\Services\Parsers\BelgaHomepageParserAdapter::class,
|
'homepage' => \Domains\Article\Parsers\Belga\BelgaHomepageParserAdapter::class,
|
||||||
'article' => \App\Services\Parsers\BelgaArticleParser::class,
|
'article' => \Domains\Article\Parsers\Belga\BelgaArticleParser::class,
|
||||||
'article_page' => \App\Services\Parsers\BelgaArticlePageParser::class,
|
'article_page' => \Domains\Article\Parsers\Belga\BelgaArticlePageParser::class,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
@ -182,7 +182,7 @@
|
||||||
'defaults' => [
|
'defaults' => [
|
||||||
'supervisor-1' => [
|
'supervisor-1' => [
|
||||||
'connection' => 'redis',
|
'connection' => 'redis',
|
||||||
'queue' => ['default', 'publishing', 'feed-discovery', 'sync'],
|
'queue' => ['default', 'publishing', 'feed-discovery'],
|
||||||
'balance' => 'auto',
|
'balance' => 'auto',
|
||||||
'autoScalingStrategy' => 'time',
|
'autoScalingStrategy' => 'time',
|
||||||
'maxProcesses' => 1,
|
'maxProcesses' => 1,
|
||||||
51
backend/config/languages.php
Normal file
51
backend/config/languages.php
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Supported Languages
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This array contains all supported languages in the application.
|
||||||
|
| Each language has a short_code, display name, native name, and active status.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'supported' => [
|
||||||
|
'en' => [
|
||||||
|
'short_code' => 'en',
|
||||||
|
'name' => 'English',
|
||||||
|
'native_name' => 'English',
|
||||||
|
'is_active' => true,
|
||||||
|
],
|
||||||
|
'nl' => [
|
||||||
|
'short_code' => 'nl',
|
||||||
|
'name' => 'Dutch',
|
||||||
|
'native_name' => 'Nederlands',
|
||||||
|
'is_active' => true,
|
||||||
|
],
|
||||||
|
'fr' => [
|
||||||
|
'short_code' => 'fr',
|
||||||
|
'name' => 'French',
|
||||||
|
'native_name' => 'Français',
|
||||||
|
'is_active' => true,
|
||||||
|
],
|
||||||
|
'de' => [
|
||||||
|
'short_code' => 'de',
|
||||||
|
'name' => 'German',
|
||||||
|
'native_name' => 'Deutsch',
|
||||||
|
'is_active' => true,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Default Language
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| The default language code when no language is specified
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'default' => 'en',
|
||||||
|
];
|
||||||
2519
backend/coverage.xml
Normal file
2519
backend/coverage.xml
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -20,13 +20,10 @@ public function up(): void
|
||||||
$table->string('author')->nullable();
|
$table->string('author')->nullable();
|
||||||
$table->unsignedBigInteger('feed_id')->nullable();
|
$table->unsignedBigInteger('feed_id')->nullable();
|
||||||
$table->enum('approval_status', ['pending', 'approved', 'rejected'])->default('pending');
|
$table->enum('approval_status', ['pending', 'approved', 'rejected'])->default('pending');
|
||||||
$table->timestamp('validated_at')->nullable();
|
|
||||||
$table->enum('publish_status', ['unpublished', 'publishing', 'published', 'error'])->default('unpublished');
|
|
||||||
$table->timestamps();
|
$table->timestamps();
|
||||||
|
|
||||||
$table->index(['published_at', 'approval_status']);
|
$table->index(['published_at', 'approval_status']);
|
||||||
$table->index('feed_id');
|
$table->index('feed_id');
|
||||||
$table->unique('url');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Article publications table
|
// Article publications table
|
||||||
|
|
@ -27,7 +27,7 @@ public function up(): void
|
||||||
$table->enum('platform', ['lemmy']);
|
$table->enum('platform', ['lemmy']);
|
||||||
$table->string('instance_url');
|
$table->string('instance_url');
|
||||||
$table->string('username');
|
$table->string('username');
|
||||||
$table->text('password');
|
$table->string('password');
|
||||||
$table->json('settings')->nullable();
|
$table->json('settings')->nullable();
|
||||||
$table->boolean('is_active')->default(false);
|
$table->boolean('is_active')->default(false);
|
||||||
$table->timestamp('last_tested_at')->nullable();
|
$table->timestamp('last_tested_at')->nullable();
|
||||||
|
|
@ -64,21 +64,20 @@ public function up(): void
|
||||||
$table->unique(['platform_account_id', 'platform_channel_id'], 'account_channel_unique');
|
$table->unique(['platform_account_id', 'platform_channel_id'], 'account_channel_unique');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Platform channel posts table (synced from platform APIs for duplicate detection)
|
// Platform channel posts table
|
||||||
Schema::create('platform_channel_posts', function (Blueprint $table) {
|
Schema::create('platform_channel_posts', function (Blueprint $table) {
|
||||||
$table->id();
|
$table->id();
|
||||||
$table->string('platform');
|
$table->foreignId('platform_channel_id')->constrained()->onDelete('cascade');
|
||||||
$table->string('channel_id');
|
|
||||||
$table->string('channel_name')->nullable();
|
|
||||||
$table->string('post_id');
|
$table->string('post_id');
|
||||||
$table->string('title')->nullable();
|
$table->string('title');
|
||||||
|
$table->text('content')->nullable();
|
||||||
$table->string('url')->nullable();
|
$table->string('url')->nullable();
|
||||||
$table->timestamp('posted_at')->nullable();
|
$table->timestamp('posted_at');
|
||||||
|
$table->string('author');
|
||||||
|
$table->json('metadata')->nullable();
|
||||||
$table->timestamps();
|
$table->timestamps();
|
||||||
|
|
||||||
$table->unique(['platform', 'channel_id', 'post_id'], 'channel_post_unique');
|
$table->unique(['platform_channel_id', 'post_id'], 'channel_post_unique');
|
||||||
$table->index(['platform', 'channel_id', 'url']);
|
|
||||||
$table->index(['platform', 'channel_id', 'title']);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Language platform instance pivot table
|
// Language platform instance pivot table
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
// Create feed_languages pivot table for many-to-many relationship
|
||||||
|
Schema::create('feed_languages', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('feed_id')->constrained()->onDelete('cascade');
|
||||||
|
$table->foreignId('language_id')->constrained()->onDelete('cascade');
|
||||||
|
$table->string('url')->nullable(); // Optional: specific URL for this feed-language combination
|
||||||
|
$table->json('settings')->nullable(); // Optional: language-specific settings
|
||||||
|
$table->boolean('is_active')->default(true);
|
||||||
|
$table->boolean('is_primary')->default(false); // To indicate primary language for the feed
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
// Ensure unique combination of feed and language
|
||||||
|
$table->unique(['feed_id', 'language_id']);
|
||||||
|
|
||||||
|
// Index for performance
|
||||||
|
$table->index(['feed_id', 'is_active']);
|
||||||
|
$table->index(['language_id', 'is_active']);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Migrate existing data from feeds.language_id to feed_languages
|
||||||
|
// This will preserve existing language associations
|
||||||
|
DB::table('feeds')
|
||||||
|
->whereNotNull('language_id')
|
||||||
|
->orderBy('id')
|
||||||
|
->chunk(100, function ($feeds) {
|
||||||
|
foreach ($feeds as $feed) {
|
||||||
|
DB::table('feed_languages')->insert([
|
||||||
|
'feed_id' => $feed->id,
|
||||||
|
'language_id' => $feed->language_id,
|
||||||
|
'url' => $feed->url,
|
||||||
|
'is_active' => true,
|
||||||
|
'is_primary' => true, // Mark existing languages as primary
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('feed_languages');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('feeds', function (Blueprint $table) {
|
||||||
|
// Drop foreign key constraint first
|
||||||
|
$table->dropForeign(['language_id']);
|
||||||
|
// Then drop the column
|
||||||
|
$table->dropColumn('language_id');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('feeds', function (Blueprint $table) {
|
||||||
|
// Re-add the language_id column
|
||||||
|
$table->foreignId('language_id')->nullable()->after('provider');
|
||||||
|
$table->foreign('language_id')->references('id')->on('languages');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('routes', function (Blueprint $table) {
|
||||||
|
$table->foreignId('language_id')->nullable()->after('platform_channel_id')->constrained()->onDelete('cascade');
|
||||||
|
$table->index(['language_id', 'is_active']);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Migrate existing routes to set language based on feed's primary language and channel language (where they match)
|
||||||
|
DB::statement("
|
||||||
|
UPDATE routes r
|
||||||
|
INNER JOIN platform_channels pc ON r.platform_channel_id = pc.id
|
||||||
|
INNER JOIN feed_languages fl ON r.feed_id = fl.feed_id AND fl.is_primary = 1 AND fl.is_active = 1
|
||||||
|
SET r.language_id = pc.language_id
|
||||||
|
WHERE pc.language_id = fl.language_id
|
||||||
|
");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('routes', function (Blueprint $table) {
|
||||||
|
$table->dropForeign(['language_id']);
|
||||||
|
$table->dropIndex(['language_id', 'is_active']);
|
||||||
|
$table->dropColumn('language_id');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
// Add composite index on feed_languages for language filtering queries
|
||||||
|
Schema::table('feed_languages', function (Blueprint $table) {
|
||||||
|
$table->index(['language_id', 'is_active', 'is_primary'], 'idx_feed_languages_filtering');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add index on platform_channels for language-based channel lookups
|
||||||
|
Schema::table('platform_channels', function (Blueprint $table) {
|
||||||
|
$table->index(['language_id', 'is_active'], 'idx_platform_channels_language_active');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add composite index on routes for efficient language-based queries
|
||||||
|
Schema::table('routes', function (Blueprint $table) {
|
||||||
|
$table->index(['language_id', 'is_active', 'priority'], 'idx_routes_language_active_priority');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add index for language consistency validation queries
|
||||||
|
Schema::table('feed_languages', function (Blueprint $table) {
|
||||||
|
$table->index(['feed_id', 'language_id', 'is_active'], 'idx_feed_languages_validation');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('feed_languages', function (Blueprint $table) {
|
||||||
|
$table->dropIndex('idx_feed_languages_filtering');
|
||||||
|
$table->dropIndex('idx_feed_languages_validation');
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('platform_channels', function (Blueprint $table) {
|
||||||
|
$table->dropIndex('idx_platform_channels_language_active');
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('routes', function (Blueprint $table) {
|
||||||
|
$table->dropIndex('idx_routes_language_active_priority');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -2,8 +2,8 @@
|
||||||
|
|
||||||
namespace Database\Seeders;
|
namespace Database\Seeders;
|
||||||
|
|
||||||
use App\Enums\PlatformEnum;
|
use Domains\Platform\Enums\PlatformEnum;
|
||||||
use App\Models\PlatformInstance;
|
use Domains\Platform\Models\PlatformInstance;
|
||||||
use Illuminate\Database\Seeder;
|
use Illuminate\Database\Seeder;
|
||||||
|
|
||||||
class PlatformInstanceSeeder extends Seeder
|
class PlatformInstanceSeeder extends Seeder
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue