Compare commits
7 commits
main
...
release/v1
| Author | SHA1 | Date | |
|---|---|---|---|
| 25cae3c0e9 | |||
| 58848c934e | |||
| 2087ca389e | |||
| f765d04d06 | |||
| cb276cf81d | |||
| e986f7871b | |||
| ed93dc3630 |
378 changed files with 27918 additions and 6778 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -23,6 +23,5 @@ yarn-error.log
|
||||||
/.nova
|
/.nova
|
||||||
/.vscode
|
/.vscode
|
||||||
/.zed
|
/.zed
|
||||||
/coverage-report*
|
/backend/coverage-report*
|
||||||
/coverage.xml
|
|
||||||
/.claude
|
/.claude
|
||||||
|
|
|
||||||
127
Dockerfile
127
Dockerfile
|
|
@ -1,127 +0,0 @@
|
||||||
# Production Dockerfile with FrankenPHP
|
|
||||||
FROM dunglas/frankenphp:latest-php8.3-alpine
|
|
||||||
|
|
||||||
# Install system dependencies
|
|
||||||
RUN apk add --no-cache \
|
|
||||||
nodejs \
|
|
||||||
npm \
|
|
||||||
git \
|
|
||||||
mysql-client
|
|
||||||
|
|
||||||
# Install PHP extensions
|
|
||||||
RUN install-php-extensions \
|
|
||||||
pdo_mysql \
|
|
||||||
opcache \
|
|
||||||
zip \
|
|
||||||
gd \
|
|
||||||
intl \
|
|
||||||
bcmath \
|
|
||||||
redis \
|
|
||||||
pcntl
|
|
||||||
|
|
||||||
# Install Composer
|
|
||||||
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
|
||||||
|
|
||||||
# Set working directory
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Set fixed production environment variables
|
|
||||||
ENV APP_ENV=production \
|
|
||||||
APP_DEBUG=false \
|
|
||||||
DB_CONNECTION=mysql \
|
|
||||||
DB_HOST=db \
|
|
||||||
DB_PORT=3306 \
|
|
||||||
SESSION_DRIVER=redis \
|
|
||||||
CACHE_STORE=redis \
|
|
||||||
QUEUE_CONNECTION=redis \
|
|
||||||
LOG_CHANNEL=stack \
|
|
||||||
LOG_LEVEL=error
|
|
||||||
|
|
||||||
# Copy application code first
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
# Install PHP dependencies (production only)
|
|
||||||
RUN composer install --no-dev --no-interaction --optimize-autoloader
|
|
||||||
|
|
||||||
# Install ALL Node dependencies (including dev for building)
|
|
||||||
RUN npm ci
|
|
||||||
|
|
||||||
# Build frontend assets
|
|
||||||
RUN npm run build
|
|
||||||
|
|
||||||
# Remove node_modules after build to save space
|
|
||||||
RUN rm -rf node_modules
|
|
||||||
|
|
||||||
# Laravel optimizations
|
|
||||||
RUN php artisan config:cache \
|
|
||||||
&& php artisan route:cache \
|
|
||||||
&& php artisan view:cache \
|
|
||||||
&& composer dump-autoload --optimize
|
|
||||||
|
|
||||||
# Set permissions
|
|
||||||
RUN chown -R www-data:www-data /app/storage /app/bootstrap/cache
|
|
||||||
|
|
||||||
# Configure Caddy
|
|
||||||
RUN cat > /etc/caddy/Caddyfile <<EOF
|
|
||||||
{
|
|
||||||
frankenphp
|
|
||||||
order php_server before file_server
|
|
||||||
}
|
|
||||||
|
|
||||||
:8000 {
|
|
||||||
root * /app/public
|
|
||||||
|
|
||||||
php_server {
|
|
||||||
index index.php
|
|
||||||
}
|
|
||||||
|
|
||||||
encode gzip
|
|
||||||
|
|
||||||
file_server
|
|
||||||
|
|
||||||
header {
|
|
||||||
X-Frame-Options "SAMEORIGIN"
|
|
||||||
X-Content-Type-Options "nosniff"
|
|
||||||
X-XSS-Protection "1; mode=block"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
EOF
|
|
||||||
|
|
||||||
# Expose port
|
|
||||||
EXPOSE 8000
|
|
||||||
|
|
||||||
# Health check
|
|
||||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
|
||||||
CMD curl -f http://localhost:8000/up || exit 1
|
|
||||||
|
|
||||||
# Create startup script for production
|
|
||||||
RUN cat > /start-prod.sh <<'EOF'
|
|
||||||
#!/bin/sh
|
|
||||||
set -e
|
|
||||||
|
|
||||||
# Wait for database to be ready
|
|
||||||
echo "Waiting for database..."
|
|
||||||
for i in $(seq 1 30); do
|
|
||||||
if mysqladmin ping -h "$DB_HOST" -u "$DB_USERNAME" -p"$DB_PASSWORD" --silent 2>/dev/null; then
|
|
||||||
echo "Database is ready!"
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
echo "Waiting for database... ($i/30)"
|
|
||||||
sleep 2
|
|
||||||
done
|
|
||||||
|
|
||||||
# Run migrations
|
|
||||||
echo "Running migrations..."
|
|
||||||
php artisan migrate --force || echo "Migrations failed or already up-to-date"
|
|
||||||
|
|
||||||
# Start Horizon in the background
|
|
||||||
php artisan horizon &
|
|
||||||
|
|
||||||
# Start FrankenPHP
|
|
||||||
exec frankenphp run --config /etc/caddy/Caddyfile
|
|
||||||
EOF
|
|
||||||
|
|
||||||
RUN chmod +x /start-prod.sh
|
|
||||||
|
|
||||||
# Start with our script
|
|
||||||
CMD ["/start-prod.sh"]
|
|
||||||
127
Dockerfile.dev
127
Dockerfile.dev
|
|
@ -1,127 +0,0 @@
|
||||||
# Development Dockerfile with FrankenPHP
|
|
||||||
FROM dunglas/frankenphp:latest-php8.3-alpine
|
|
||||||
|
|
||||||
# Install system dependencies + development tools
|
|
||||||
RUN apk add --no-cache \
|
|
||||||
nodejs \
|
|
||||||
npm \
|
|
||||||
git \
|
|
||||||
mysql-client \
|
|
||||||
vim \
|
|
||||||
bash \
|
|
||||||
nano
|
|
||||||
|
|
||||||
# Install PHP extensions including xdebug for development
|
|
||||||
RUN install-php-extensions \
|
|
||||||
pdo_mysql \
|
|
||||||
opcache \
|
|
||||||
zip \
|
|
||||||
gd \
|
|
||||||
intl \
|
|
||||||
bcmath \
|
|
||||||
redis \
|
|
||||||
pcntl \
|
|
||||||
xdebug
|
|
||||||
|
|
||||||
# Install Composer
|
|
||||||
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
|
||||||
|
|
||||||
# Set working directory
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Configure PHP for development
|
|
||||||
RUN mv "$PHP_INI_DIR/php.ini-development" "$PHP_INI_DIR/php.ini"
|
|
||||||
|
|
||||||
# Configure Xdebug (disabled by default to reduce noise)
|
|
||||||
RUN echo "xdebug.mode=off" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini \
|
|
||||||
&& echo ";xdebug.mode=debug" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini \
|
|
||||||
&& echo ";xdebug.client_host=host.docker.internal" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini \
|
|
||||||
&& echo ";xdebug.start_with_request=yes" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini
|
|
||||||
|
|
||||||
# Configure Caddy for development (simpler, no worker mode)
|
|
||||||
RUN cat > /etc/caddy/Caddyfile <<EOF
|
|
||||||
{
|
|
||||||
frankenphp
|
|
||||||
order php_server before file_server
|
|
||||||
}
|
|
||||||
|
|
||||||
:8000 {
|
|
||||||
root * /app/public
|
|
||||||
|
|
||||||
php_server {
|
|
||||||
index index.php
|
|
||||||
}
|
|
||||||
|
|
||||||
encode gzip
|
|
||||||
|
|
||||||
file_server
|
|
||||||
|
|
||||||
# Less strict headers for development
|
|
||||||
header {
|
|
||||||
X-Frame-Options "SAMEORIGIN"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
EOF
|
|
||||||
|
|
||||||
# Install Node development dependencies globally
|
|
||||||
RUN npm install -g nodemon
|
|
||||||
|
|
||||||
# Create startup script for development
|
|
||||||
RUN cat > /start.sh <<'EOF'
|
|
||||||
#!/bin/sh
|
|
||||||
set -e
|
|
||||||
|
|
||||||
# Create .env file if it doesn't exist
|
|
||||||
if [ ! -f ".env" ]; then
|
|
||||||
echo "Creating .env file from .env.example..."
|
|
||||||
cp .env.example .env
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Install dependencies if volumes are empty
|
|
||||||
if [ ! -f "vendor/autoload.php" ]; then
|
|
||||||
echo "Installing composer dependencies..."
|
|
||||||
composer install
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Always reinstall node_modules in container to get correct native binaries for Alpine/musl
|
|
||||||
echo "Installing npm dependencies..."
|
|
||||||
rm -rf node_modules 2>/dev/null || true
|
|
||||||
rm -rf /app/.npm 2>/dev/null || true
|
|
||||||
npm install --cache /tmp/.npm
|
|
||||||
|
|
||||||
# Clear Laravel caches
|
|
||||||
php artisan config:clear || true
|
|
||||||
php artisan cache:clear || true
|
|
||||||
|
|
||||||
# Wait for database and run migrations
|
|
||||||
echo "Waiting for database..."
|
|
||||||
sleep 5
|
|
||||||
php artisan migrate --force || echo "Migration failed or not needed"
|
|
||||||
|
|
||||||
# Run seeders
|
|
||||||
echo "Running seeders..."
|
|
||||||
php artisan db:seed --force || echo "Seeding skipped or already done"
|
|
||||||
|
|
||||||
# Generate app key if not set
|
|
||||||
if [ -z "$APP_KEY" ] || [ "$APP_KEY" = "base64:YOUR_KEY_HERE" ]; then
|
|
||||||
echo "Generating application key..."
|
|
||||||
php artisan key:generate
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Start Vite dev server in background
|
|
||||||
npm run dev &
|
|
||||||
|
|
||||||
# Start Horizon (queue worker) in background
|
|
||||||
php artisan horizon &
|
|
||||||
|
|
||||||
# Start FrankenPHP
|
|
||||||
exec frankenphp run --config /etc/caddy/Caddyfile
|
|
||||||
EOF
|
|
||||||
|
|
||||||
RUN chmod +x /start.sh
|
|
||||||
|
|
||||||
# Expose ports
|
|
||||||
EXPOSE 8000 5173
|
|
||||||
|
|
||||||
# Use the startup script
|
|
||||||
CMD ["/start.sh"]
|
|
||||||
299
README.md
299
README.md
|
|
@ -1,128 +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 `codeberg.org/lvl0/ffr: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: codeberg.org/lvl0/ffr: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://codeberg.org/lvl0/ffr.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 |
|
|
||||||
| `prod-build [tag]` | Build and push prod image (default: latest) |
|
|
||||||
|
|
||||||
#### 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 [Codeberg Issues](https://codeberg.org/lvl0/ffr/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,61 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Livewire;
|
|
||||||
|
|
||||||
use App\Models\Article;
|
|
||||||
use App\Models\Setting;
|
|
||||||
use App\Jobs\ArticleDiscoveryJob;
|
|
||||||
use Livewire\Component;
|
|
||||||
use Livewire\WithPagination;
|
|
||||||
|
|
||||||
class Articles extends Component
|
|
||||||
{
|
|
||||||
use WithPagination;
|
|
||||||
|
|
||||||
public bool $isRefreshing = false;
|
|
||||||
|
|
||||||
public function approve(int $articleId): void
|
|
||||||
{
|
|
||||||
$article = Article::findOrFail($articleId);
|
|
||||||
$article->approve();
|
|
||||||
|
|
||||||
$this->dispatch('article-updated');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function reject(int $articleId): void
|
|
||||||
{
|
|
||||||
$article = Article::findOrFail($articleId);
|
|
||||||
$article->reject();
|
|
||||||
|
|
||||||
$this->dispatch('article-updated');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function refresh(): void
|
|
||||||
{
|
|
||||||
$this->isRefreshing = true;
|
|
||||||
|
|
||||||
ArticleDiscoveryJob::dispatch();
|
|
||||||
|
|
||||||
// Reset after 10 seconds
|
|
||||||
$this->dispatch('refresh-complete')->self();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function refreshComplete(): void
|
|
||||||
{
|
|
||||||
$this->isRefreshing = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function render()
|
|
||||||
{
|
|
||||||
$articles = Article::with(['feed', 'articlePublication'])
|
|
||||||
->orderBy('created_at', 'desc')
|
|
||||||
->paginate(15);
|
|
||||||
|
|
||||||
$approvalsEnabled = Setting::isPublishingApprovalsEnabled();
|
|
||||||
|
|
||||||
return view('livewire.articles', [
|
|
||||||
'articles' => $articles,
|
|
||||||
'approvalsEnabled' => $approvalsEnabled,
|
|
||||||
])->layout('layouts.app');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,73 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Livewire;
|
|
||||||
|
|
||||||
use App\Models\PlatformAccount;
|
|
||||||
use App\Models\PlatformChannel;
|
|
||||||
use Livewire\Component;
|
|
||||||
|
|
||||||
class Channels extends Component
|
|
||||||
{
|
|
||||||
public ?int $managingChannelId = null;
|
|
||||||
|
|
||||||
public function toggle(int $channelId): void
|
|
||||||
{
|
|
||||||
$channel = PlatformChannel::findOrFail($channelId);
|
|
||||||
$channel->is_active = !$channel->is_active;
|
|
||||||
$channel->save();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function openAccountModal(int $channelId): void
|
|
||||||
{
|
|
||||||
$this->managingChannelId = $channelId;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function closeAccountModal(): void
|
|
||||||
{
|
|
||||||
$this->managingChannelId = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function attachAccount(int $accountId): void
|
|
||||||
{
|
|
||||||
if (!$this->managingChannelId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$channel = PlatformChannel::findOrFail($this->managingChannelId);
|
|
||||||
|
|
||||||
if (!$channel->platformAccounts()->where('platform_account_id', $accountId)->exists()) {
|
|
||||||
$channel->platformAccounts()->attach($accountId, [
|
|
||||||
'is_active' => true,
|
|
||||||
'priority' => 1,
|
|
||||||
'created_at' => now(),
|
|
||||||
'updated_at' => now(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function detachAccount(int $channelId, int $accountId): void
|
|
||||||
{
|
|
||||||
$channel = PlatformChannel::findOrFail($channelId);
|
|
||||||
$channel->platformAccounts()->detach($accountId);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function render()
|
|
||||||
{
|
|
||||||
$channels = PlatformChannel::with(['platformInstance', 'platformAccounts'])->orderBy('name')->get();
|
|
||||||
$allAccounts = PlatformAccount::where('is_active', true)->get();
|
|
||||||
|
|
||||||
$managingChannel = $this->managingChannelId
|
|
||||||
? PlatformChannel::with('platformAccounts')->find($this->managingChannelId)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
$availableAccounts = $managingChannel
|
|
||||||
? $allAccounts->filter(fn($account) => !$managingChannel->platformAccounts->contains('id', $account->id))
|
|
||||||
: collect();
|
|
||||||
|
|
||||||
return view('livewire.channels', [
|
|
||||||
'channels' => $channels,
|
|
||||||
'managingChannel' => $managingChannel,
|
|
||||||
'availableAccounts' => $availableAccounts,
|
|
||||||
])->layout('layouts.app');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Livewire;
|
|
||||||
|
|
||||||
use App\Services\DashboardStatsService;
|
|
||||||
use Livewire\Component;
|
|
||||||
|
|
||||||
class Dashboard extends Component
|
|
||||||
{
|
|
||||||
public string $period = 'today';
|
|
||||||
|
|
||||||
public function mount(): void
|
|
||||||
{
|
|
||||||
// Default period
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setPeriod(string $period): void
|
|
||||||
{
|
|
||||||
$this->period = $period;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function render()
|
|
||||||
{
|
|
||||||
$service = app(DashboardStatsService::class);
|
|
||||||
|
|
||||||
$articleStats = $service->getStats($this->period);
|
|
||||||
$systemStats = $service->getSystemStats();
|
|
||||||
$availablePeriods = $service->getAvailablePeriods();
|
|
||||||
|
|
||||||
return view('livewire.dashboard', [
|
|
||||||
'articleStats' => $articleStats,
|
|
||||||
'systemStats' => $systemStats,
|
|
||||||
'availablePeriods' => $availablePeriods,
|
|
||||||
])->layout('layouts.app');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Livewire;
|
|
||||||
|
|
||||||
use App\Models\Feed;
|
|
||||||
use Livewire\Component;
|
|
||||||
|
|
||||||
class Feeds extends Component
|
|
||||||
{
|
|
||||||
public function toggle(int $feedId): void
|
|
||||||
{
|
|
||||||
$feed = Feed::findOrFail($feedId);
|
|
||||||
$feed->is_active = !$feed->is_active;
|
|
||||||
$feed->save();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function render()
|
|
||||||
{
|
|
||||||
$feeds = Feed::orderBy('name')->get();
|
|
||||||
|
|
||||||
return view('livewire.feeds', [
|
|
||||||
'feeds' => $feeds,
|
|
||||||
])->layout('layouts.app');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,358 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Livewire;
|
|
||||||
|
|
||||||
use App\Jobs\ArticleDiscoveryJob;
|
|
||||||
use App\Models\Feed;
|
|
||||||
use App\Models\Language;
|
|
||||||
use App\Models\PlatformAccount;
|
|
||||||
use App\Models\PlatformChannel;
|
|
||||||
use App\Models\PlatformInstance;
|
|
||||||
use App\Models\Route;
|
|
||||||
use App\Models\Setting;
|
|
||||||
use App\Services\Auth\LemmyAuthService;
|
|
||||||
use App\Services\OnboardingService;
|
|
||||||
use Illuminate\Support\Facades\Crypt;
|
|
||||||
use Livewire\Component;
|
|
||||||
|
|
||||||
class Onboarding extends Component
|
|
||||||
{
|
|
||||||
// Step tracking (1-6: welcome, platform, feed, channel, route, complete)
|
|
||||||
public int $step = 1;
|
|
||||||
|
|
||||||
// Platform form
|
|
||||||
public string $instanceUrl = '';
|
|
||||||
public string $username = '';
|
|
||||||
public string $password = '';
|
|
||||||
public ?array $existingAccount = null;
|
|
||||||
|
|
||||||
// Feed form
|
|
||||||
public string $feedName = '';
|
|
||||||
public string $feedProvider = 'vrt';
|
|
||||||
public ?int $feedLanguageId = null;
|
|
||||||
public string $feedDescription = '';
|
|
||||||
|
|
||||||
// Channel form
|
|
||||||
public string $channelName = '';
|
|
||||||
public ?int $platformInstanceId = null;
|
|
||||||
public ?int $channelLanguageId = null;
|
|
||||||
public string $channelDescription = '';
|
|
||||||
|
|
||||||
// Route form
|
|
||||||
public ?int $routeFeedId = null;
|
|
||||||
public ?int $routeChannelId = null;
|
|
||||||
public int $routePriority = 50;
|
|
||||||
|
|
||||||
// State
|
|
||||||
public array $formErrors = [];
|
|
||||||
public bool $isLoading = false;
|
|
||||||
|
|
||||||
protected LemmyAuthService $lemmyAuthService;
|
|
||||||
|
|
||||||
public function boot(LemmyAuthService $lemmyAuthService): void
|
|
||||||
{
|
|
||||||
$this->lemmyAuthService = $lemmyAuthService;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function mount(): void
|
|
||||||
{
|
|
||||||
// Check for existing platform account
|
|
||||||
$account = PlatformAccount::where('is_active', true)->first();
|
|
||||||
if ($account) {
|
|
||||||
$this->existingAccount = [
|
|
||||||
'id' => $account->id,
|
|
||||||
'username' => $account->username,
|
|
||||||
'instance_url' => $account->instance_url,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pre-fill feed form if exists
|
|
||||||
$feed = Feed::where('is_active', true)->first();
|
|
||||||
if ($feed) {
|
|
||||||
$this->feedName = $feed->name;
|
|
||||||
$this->feedProvider = $feed->provider ?? 'vrt';
|
|
||||||
$this->feedLanguageId = $feed->language_id;
|
|
||||||
$this->feedDescription = $feed->description ?? '';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pre-fill channel form if exists
|
|
||||||
$channel = PlatformChannel::where('is_active', true)->first();
|
|
||||||
if ($channel) {
|
|
||||||
$this->channelName = $channel->name;
|
|
||||||
$this->platformInstanceId = $channel->platform_instance_id;
|
|
||||||
$this->channelLanguageId = $channel->language_id;
|
|
||||||
$this->channelDescription = $channel->description ?? '';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pre-fill route form if exists
|
|
||||||
$route = Route::where('is_active', true)->first();
|
|
||||||
if ($route) {
|
|
||||||
$this->routeFeedId = $route->feed_id;
|
|
||||||
$this->routeChannelId = $route->platform_channel_id;
|
|
||||||
$this->routePriority = $route->priority;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function goToStep(int $step): void
|
|
||||||
{
|
|
||||||
$this->step = $step;
|
|
||||||
$this->formErrors = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function nextStep(): void
|
|
||||||
{
|
|
||||||
$this->step++;
|
|
||||||
$this->formErrors = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function previousStep(): void
|
|
||||||
{
|
|
||||||
if ($this->step > 1) {
|
|
||||||
$this->step--;
|
|
||||||
$this->formErrors = [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function continueWithExistingAccount(): void
|
|
||||||
{
|
|
||||||
$this->nextStep();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function deleteAccount(): void
|
|
||||||
{
|
|
||||||
if ($this->existingAccount) {
|
|
||||||
PlatformAccount::destroy($this->existingAccount['id']);
|
|
||||||
$this->existingAccount = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function createPlatformAccount(): void
|
|
||||||
{
|
|
||||||
$this->formErrors = [];
|
|
||||||
$this->isLoading = true;
|
|
||||||
|
|
||||||
$this->validate([
|
|
||||||
'instanceUrl' => 'required|string|max:255|regex:/^[a-zA-Z0-9]([a-zA-Z0-9\-\.]*[a-zA-Z0-9])?$/',
|
|
||||||
'username' => 'required|string|max:255',
|
|
||||||
'password' => 'required|string|min:6',
|
|
||||||
], [
|
|
||||||
'instanceUrl.regex' => 'Please enter a valid domain name (e.g., lemmy.world, belgae.social)',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$fullInstanceUrl = 'https://' . $this->instanceUrl;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Create or get platform instance
|
|
||||||
$platformInstance = PlatformInstance::firstOrCreate([
|
|
||||||
'url' => $fullInstanceUrl,
|
|
||||||
'platform' => 'lemmy',
|
|
||||||
], [
|
|
||||||
'name' => ucfirst($this->instanceUrl),
|
|
||||||
'is_active' => true,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Authenticate with Lemmy API
|
|
||||||
$authResponse = $this->lemmyAuthService->authenticate(
|
|
||||||
$fullInstanceUrl,
|
|
||||||
$this->username,
|
|
||||||
$this->password
|
|
||||||
);
|
|
||||||
|
|
||||||
// Create platform account
|
|
||||||
$platformAccount = PlatformAccount::create([
|
|
||||||
'platform' => 'lemmy',
|
|
||||||
'instance_url' => $fullInstanceUrl,
|
|
||||||
'username' => $this->username,
|
|
||||||
'password' => Crypt::encryptString($this->password),
|
|
||||||
'settings' => [
|
|
||||||
'display_name' => $authResponse['person_view']['person']['display_name'] ?? null,
|
|
||||||
'description' => $authResponse['person_view']['person']['bio'] ?? null,
|
|
||||||
'person_id' => $authResponse['person_view']['person']['id'] ?? null,
|
|
||||||
'platform_instance_id' => $platformInstance->id,
|
|
||||||
'api_token' => $authResponse['jwt'] ?? null,
|
|
||||||
],
|
|
||||||
'is_active' => true,
|
|
||||||
'status' => 'active',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->existingAccount = [
|
|
||||||
'id' => $platformAccount->id,
|
|
||||||
'username' => $platformAccount->username,
|
|
||||||
'instance_url' => $platformAccount->instance_url,
|
|
||||||
];
|
|
||||||
|
|
||||||
$this->nextStep();
|
|
||||||
} catch (\App\Exceptions\PlatformAuthException $e) {
|
|
||||||
$message = $e->getMessage();
|
|
||||||
if (str_contains($message, 'Rate limited by')) {
|
|
||||||
$this->formErrors['general'] = $message;
|
|
||||||
} elseif (str_contains($message, 'Connection failed')) {
|
|
||||||
$this->formErrors['general'] = 'Unable to connect to the Lemmy instance. Please check the URL and try again.';
|
|
||||||
} else {
|
|
||||||
$this->formErrors['general'] = 'Invalid username or password. Please check your credentials and try again.';
|
|
||||||
}
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
logger()->error('Lemmy platform account creation failed', [
|
|
||||||
'instance_url' => $fullInstanceUrl,
|
|
||||||
'username' => $this->username,
|
|
||||||
'error' => $e->getMessage(),
|
|
||||||
'class' => get_class($e),
|
|
||||||
]);
|
|
||||||
$this->formErrors['general'] = 'An error occurred while setting up your account. Please try again.';
|
|
||||||
} finally {
|
|
||||||
$this->isLoading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function createFeed(): void
|
|
||||||
{
|
|
||||||
$this->formErrors = [];
|
|
||||||
$this->isLoading = true;
|
|
||||||
|
|
||||||
$this->validate([
|
|
||||||
'feedName' => 'required|string|max:255',
|
|
||||||
'feedProvider' => 'required|in:belga,vrt',
|
|
||||||
'feedLanguageId' => 'required|exists:languages,id',
|
|
||||||
'feedDescription' => 'nullable|string|max:1000',
|
|
||||||
]);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Map provider to URL
|
|
||||||
$url = $this->feedProvider === 'vrt'
|
|
||||||
? 'https://www.vrt.be/vrtnws/en/'
|
|
||||||
: 'https://www.belganewsagency.eu/';
|
|
||||||
|
|
||||||
Feed::firstOrCreate(
|
|
||||||
['url' => $url],
|
|
||||||
[
|
|
||||||
'name' => $this->feedName,
|
|
||||||
'type' => 'website',
|
|
||||||
'provider' => $this->feedProvider,
|
|
||||||
'language_id' => $this->feedLanguageId,
|
|
||||||
'description' => $this->feedDescription ?: null,
|
|
||||||
'is_active' => true,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->nextStep();
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
$this->formErrors['general'] = 'Failed to create feed. Please try again.';
|
|
||||||
} finally {
|
|
||||||
$this->isLoading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function createChannel(): void
|
|
||||||
{
|
|
||||||
$this->formErrors = [];
|
|
||||||
$this->isLoading = true;
|
|
||||||
|
|
||||||
$this->validate([
|
|
||||||
'channelName' => 'required|string|max:255',
|
|
||||||
'platformInstanceId' => 'required|exists:platform_instances,id',
|
|
||||||
'channelLanguageId' => 'required|exists:languages,id',
|
|
||||||
'channelDescription' => 'nullable|string|max:1000',
|
|
||||||
]);
|
|
||||||
|
|
||||||
try {
|
|
||||||
$platformInstance = PlatformInstance::findOrFail($this->platformInstanceId);
|
|
||||||
|
|
||||||
// Check for active platform accounts
|
|
||||||
$activeAccounts = PlatformAccount::where('instance_url', $platformInstance->url)
|
|
||||||
->where('is_active', true)
|
|
||||||
->get();
|
|
||||||
|
|
||||||
if ($activeAccounts->isEmpty()) {
|
|
||||||
$this->formErrors['general'] = 'No active platform accounts found for this instance. Please create a platform account first.';
|
|
||||||
$this->isLoading = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$channel = PlatformChannel::create([
|
|
||||||
'platform_instance_id' => $this->platformInstanceId,
|
|
||||||
'channel_id' => $this->channelName,
|
|
||||||
'name' => $this->channelName,
|
|
||||||
'display_name' => ucfirst($this->channelName),
|
|
||||||
'description' => $this->channelDescription ?: null,
|
|
||||||
'language_id' => $this->channelLanguageId,
|
|
||||||
'is_active' => true,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Attach first active account
|
|
||||||
$channel->platformAccounts()->attach($activeAccounts->first()->id, [
|
|
||||||
'is_active' => true,
|
|
||||||
'priority' => 1,
|
|
||||||
'created_at' => now(),
|
|
||||||
'updated_at' => now(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->nextStep();
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
$this->formErrors['general'] = 'Failed to create channel. Please try again.';
|
|
||||||
} finally {
|
|
||||||
$this->isLoading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function createRoute(): void
|
|
||||||
{
|
|
||||||
$this->formErrors = [];
|
|
||||||
$this->isLoading = true;
|
|
||||||
|
|
||||||
$this->validate([
|
|
||||||
'routeFeedId' => 'required|exists:feeds,id',
|
|
||||||
'routeChannelId' => 'required|exists:platform_channels,id',
|
|
||||||
'routePriority' => 'nullable|integer|min:1|max:100',
|
|
||||||
]);
|
|
||||||
|
|
||||||
try {
|
|
||||||
Route::create([
|
|
||||||
'feed_id' => $this->routeFeedId,
|
|
||||||
'platform_channel_id' => $this->routeChannelId,
|
|
||||||
'priority' => $this->routePriority,
|
|
||||||
'is_active' => true,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Trigger article discovery
|
|
||||||
ArticleDiscoveryJob::dispatch();
|
|
||||||
|
|
||||||
$this->nextStep();
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
$this->formErrors['general'] = 'Failed to create route. Please try again.';
|
|
||||||
} finally {
|
|
||||||
$this->isLoading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function completeOnboarding(): void
|
|
||||||
{
|
|
||||||
Setting::updateOrCreate(
|
|
||||||
['key' => 'onboarding_completed'],
|
|
||||||
['value' => now()->toIso8601String()]
|
|
||||||
);
|
|
||||||
|
|
||||||
app(OnboardingService::class)->clearCache();
|
|
||||||
|
|
||||||
$this->redirect(route('dashboard'));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function render()
|
|
||||||
{
|
|
||||||
$languages = Language::where('is_active', true)->orderBy('name')->get();
|
|
||||||
$platformInstances = PlatformInstance::where('is_active', true)->orderBy('name')->get();
|
|
||||||
$feeds = Feed::where('is_active', true)->orderBy('name')->get();
|
|
||||||
$channels = PlatformChannel::where('is_active', true)->orderBy('name')->get();
|
|
||||||
|
|
||||||
$feedProviders = collect(config('feed.providers', []))
|
|
||||||
->filter(fn($provider) => $provider['is_active'] ?? false)
|
|
||||||
->values();
|
|
||||||
|
|
||||||
return view('livewire.onboarding', [
|
|
||||||
'languages' => $languages,
|
|
||||||
'platformInstances' => $platformInstances,
|
|
||||||
'feeds' => $feeds,
|
|
||||||
'channels' => $channels,
|
|
||||||
'feedProviders' => $feedProviders,
|
|
||||||
])->layout('layouts.onboarding');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,200 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Livewire;
|
|
||||||
|
|
||||||
use App\Models\Feed;
|
|
||||||
use App\Models\Keyword;
|
|
||||||
use App\Models\PlatformChannel;
|
|
||||||
use App\Models\Route;
|
|
||||||
use Livewire\Component;
|
|
||||||
|
|
||||||
class Routes extends Component
|
|
||||||
{
|
|
||||||
public bool $showCreateModal = false;
|
|
||||||
public ?int $editingFeedId = null;
|
|
||||||
public ?int $editingChannelId = null;
|
|
||||||
|
|
||||||
// Create form
|
|
||||||
public ?int $newFeedId = null;
|
|
||||||
public ?int $newChannelId = null;
|
|
||||||
public int $newPriority = 50;
|
|
||||||
|
|
||||||
// Edit form
|
|
||||||
public int $editPriority = 50;
|
|
||||||
|
|
||||||
// Keyword management
|
|
||||||
public string $newKeyword = '';
|
|
||||||
public bool $showKeywordInput = false;
|
|
||||||
|
|
||||||
public function openCreateModal(): void
|
|
||||||
{
|
|
||||||
$this->showCreateModal = true;
|
|
||||||
$this->newFeedId = null;
|
|
||||||
$this->newChannelId = null;
|
|
||||||
$this->newPriority = 50;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function closeCreateModal(): void
|
|
||||||
{
|
|
||||||
$this->showCreateModal = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function createRoute(): void
|
|
||||||
{
|
|
||||||
$this->validate([
|
|
||||||
'newFeedId' => 'required|exists:feeds,id',
|
|
||||||
'newChannelId' => 'required|exists:platform_channels,id',
|
|
||||||
'newPriority' => 'required|integer|min:0',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$exists = Route::where('feed_id', $this->newFeedId)
|
|
||||||
->where('platform_channel_id', $this->newChannelId)
|
|
||||||
->exists();
|
|
||||||
|
|
||||||
if ($exists) {
|
|
||||||
$this->addError('newFeedId', 'This route already exists.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Route::create([
|
|
||||||
'feed_id' => $this->newFeedId,
|
|
||||||
'platform_channel_id' => $this->newChannelId,
|
|
||||||
'priority' => $this->newPriority,
|
|
||||||
'is_active' => true,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->closeCreateModal();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function openEditModal(int $feedId, int $channelId): void
|
|
||||||
{
|
|
||||||
$route = Route::where('feed_id', $feedId)
|
|
||||||
->where('platform_channel_id', $channelId)
|
|
||||||
->firstOrFail();
|
|
||||||
|
|
||||||
$this->editingFeedId = $feedId;
|
|
||||||
$this->editingChannelId = $channelId;
|
|
||||||
$this->editPriority = $route->priority;
|
|
||||||
$this->newKeyword = '';
|
|
||||||
$this->showKeywordInput = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function closeEditModal(): void
|
|
||||||
{
|
|
||||||
$this->editingFeedId = null;
|
|
||||||
$this->editingChannelId = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function updateRoute(): void
|
|
||||||
{
|
|
||||||
if (!$this->editingFeedId || !$this->editingChannelId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->validate([
|
|
||||||
'editPriority' => 'required|integer|min:0',
|
|
||||||
]);
|
|
||||||
|
|
||||||
Route::where('feed_id', $this->editingFeedId)
|
|
||||||
->where('platform_channel_id', $this->editingChannelId)
|
|
||||||
->update(['priority' => $this->editPriority]);
|
|
||||||
|
|
||||||
$this->closeEditModal();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function toggle(int $feedId, int $channelId): void
|
|
||||||
{
|
|
||||||
$route = Route::where('feed_id', $feedId)
|
|
||||||
->where('platform_channel_id', $channelId)
|
|
||||||
->firstOrFail();
|
|
||||||
|
|
||||||
$route->is_active = !$route->is_active;
|
|
||||||
$route->save();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function delete(int $feedId, int $channelId): void
|
|
||||||
{
|
|
||||||
// Delete associated keywords first
|
|
||||||
Keyword::where('feed_id', $feedId)
|
|
||||||
->where('platform_channel_id', $channelId)
|
|
||||||
->delete();
|
|
||||||
|
|
||||||
Route::where('feed_id', $feedId)
|
|
||||||
->where('platform_channel_id', $channelId)
|
|
||||||
->delete();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function addKeyword(): void
|
|
||||||
{
|
|
||||||
if (!$this->editingFeedId || !$this->editingChannelId || empty(trim($this->newKeyword))) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Keyword::create([
|
|
||||||
'feed_id' => $this->editingFeedId,
|
|
||||||
'platform_channel_id' => $this->editingChannelId,
|
|
||||||
'keyword' => trim($this->newKeyword),
|
|
||||||
'is_active' => true,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->newKeyword = '';
|
|
||||||
$this->showKeywordInput = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function toggleKeyword(int $keywordId): void
|
|
||||||
{
|
|
||||||
$keyword = Keyword::findOrFail($keywordId);
|
|
||||||
$keyword->is_active = !$keyword->is_active;
|
|
||||||
$keyword->save();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function deleteKeyword(int $keywordId): void
|
|
||||||
{
|
|
||||||
Keyword::destroy($keywordId);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function render()
|
|
||||||
{
|
|
||||||
$routes = Route::with(['feed', 'platformChannel'])
|
|
||||||
->orderBy('priority', 'desc')
|
|
||||||
->get();
|
|
||||||
|
|
||||||
// Batch load keywords for all routes to avoid N+1 queries
|
|
||||||
$routeKeys = $routes->map(fn($r) => $r->feed_id . '-' . $r->platform_channel_id);
|
|
||||||
$allKeywords = Keyword::whereIn('feed_id', $routes->pluck('feed_id'))
|
|
||||||
->whereIn('platform_channel_id', $routes->pluck('platform_channel_id'))
|
|
||||||
->get()
|
|
||||||
->groupBy(fn($k) => $k->feed_id . '-' . $k->platform_channel_id);
|
|
||||||
|
|
||||||
$routes = $routes->map(function ($route) use ($allKeywords) {
|
|
||||||
$key = $route->feed_id . '-' . $route->platform_channel_id;
|
|
||||||
$route->keywords = $allKeywords->get($key, collect());
|
|
||||||
return $route;
|
|
||||||
});
|
|
||||||
|
|
||||||
$feeds = Feed::where('is_active', true)->orderBy('name')->get();
|
|
||||||
$channels = PlatformChannel::where('is_active', true)->orderBy('name')->get();
|
|
||||||
|
|
||||||
$editingRoute = null;
|
|
||||||
$editingKeywords = collect();
|
|
||||||
|
|
||||||
if ($this->editingFeedId && $this->editingChannelId) {
|
|
||||||
$editingRoute = Route::with(['feed', 'platformChannel'])
|
|
||||||
->where('feed_id', $this->editingFeedId)
|
|
||||||
->where('platform_channel_id', $this->editingChannelId)
|
|
||||||
->first();
|
|
||||||
|
|
||||||
$editingKeywords = Keyword::where('feed_id', $this->editingFeedId)
|
|
||||||
->where('platform_channel_id', $this->editingChannelId)
|
|
||||||
->get();
|
|
||||||
}
|
|
||||||
|
|
||||||
return view('livewire.routes', [
|
|
||||||
'routes' => $routes,
|
|
||||||
'feeds' => $feeds,
|
|
||||||
'channels' => $channels,
|
|
||||||
'editingRoute' => $editingRoute,
|
|
||||||
'editingKeywords' => $editingKeywords,
|
|
||||||
])->layout('layouts.app');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Livewire;
|
|
||||||
|
|
||||||
use App\Models\Setting;
|
|
||||||
use Livewire\Component;
|
|
||||||
|
|
||||||
class Settings extends Component
|
|
||||||
{
|
|
||||||
public bool $articleProcessingEnabled = true;
|
|
||||||
public bool $publishingApprovalsEnabled = false;
|
|
||||||
|
|
||||||
public ?string $successMessage = null;
|
|
||||||
public ?string $errorMessage = null;
|
|
||||||
|
|
||||||
public function mount(): void
|
|
||||||
{
|
|
||||||
$this->articleProcessingEnabled = Setting::isArticleProcessingEnabled();
|
|
||||||
$this->publishingApprovalsEnabled = Setting::isPublishingApprovalsEnabled();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function toggleArticleProcessing(): void
|
|
||||||
{
|
|
||||||
$this->articleProcessingEnabled = !$this->articleProcessingEnabled;
|
|
||||||
Setting::setArticleProcessingEnabled($this->articleProcessingEnabled);
|
|
||||||
$this->showSuccess();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function togglePublishingApprovals(): void
|
|
||||||
{
|
|
||||||
$this->publishingApprovalsEnabled = !$this->publishingApprovalsEnabled;
|
|
||||||
Setting::setPublishingApprovalsEnabled($this->publishingApprovalsEnabled);
|
|
||||||
$this->showSuccess();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function showSuccess(): void
|
|
||||||
{
|
|
||||||
$this->successMessage = 'Settings updated successfully!';
|
|
||||||
$this->errorMessage = null;
|
|
||||||
|
|
||||||
// Clear success message after 3 seconds
|
|
||||||
$this->dispatch('clear-message');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function clearMessages(): void
|
|
||||||
{
|
|
||||||
$this->successMessage = null;
|
|
||||||
$this->errorMessage = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function render()
|
|
||||||
{
|
|
||||||
return view('livewire.settings')->layout('layouts.app');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,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,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}"
|
||||||
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,15 +49,35 @@ 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),
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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,8 +26,8 @@ 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,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
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,10 +19,20 @@
|
||||||
'description' => 'Belgian public broadcaster news',
|
'description' => 'Belgian public broadcaster news',
|
||||||
'type' => 'website',
|
'type' => 'website',
|
||||||
'is_active' => true,
|
'is_active' => true,
|
||||||
|
'supported_languages' => [
|
||||||
|
'en' => [
|
||||||
|
'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' => [
|
||||||
|
|
@ -31,10 +41,16 @@
|
||||||
'description' => 'Belgian national news agency',
|
'description' => 'Belgian national news agency',
|
||||||
'type' => 'rss',
|
'type' => 'rss',
|
||||||
'is_active' => true,
|
'is_active' => true,
|
||||||
|
'supported_languages' => [
|
||||||
|
'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,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
2519
backend/coverage.xml
Normal file
2519
backend/coverage.xml
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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();
|
||||||
|
|
@ -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
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
namespace Database\Seeders;
|
namespace Database\Seeders;
|
||||||
|
|
||||||
use App\Models\Setting;
|
use Domains\Settings\Models\Setting;
|
||||||
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
|
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
|
||||||
use Illuminate\Database\Seeder;
|
use Illuminate\Database\Seeder;
|
||||||
|
|
||||||
|
|
@ -15,6 +15,7 @@
|
||||||
<source>
|
<source>
|
||||||
<include>
|
<include>
|
||||||
<directory>app</directory>
|
<directory>app</directory>
|
||||||
|
<directory>src</directory>
|
||||||
</include>
|
</include>
|
||||||
</source>
|
</source>
|
||||||
<php>
|
<php>
|
||||||
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.6 KiB |
65
backend/public/build/assets/app-BR3co0lu.js
Normal file
65
backend/public/build/assets/app-BR3co0lu.js
Normal file
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue