Compare commits
108 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3e23dad5c5 | |||
| 16ce3b6324 | |||
| 03fa4b803f | |||
| b6290c0f8d | |||
| 4e0f0bb072 | |||
| 638983d42a | |||
| 0823cb796c | |||
|
|
f03a5c7603 | ||
| cd830dfbc1 | |||
| 3c3116ae55 | |||
| 366ff11904 | |||
| 0de5ea795d | |||
| 65cb836b51 | |||
| 7d4aa3da83 | |||
| 98fc361ab9 | |||
| da4fb6ac72 | |||
| be851be039 | |||
| f11d12dab3 | |||
| 5c00149e66 | |||
| 8c28f09921 | |||
| 1e70525f73 | |||
| 9d6e20b4f1 | |||
| 1b29c3fc13 | |||
| 65fefb9534 | |||
| c77667d263 | |||
| 3f41090174 | |||
| 8c335c9967 | |||
| 431f8a6d57 | |||
| 536dc3fcb8 | |||
| 997646be35 | |||
| 4af7b8a38c | |||
| d59128871e | |||
| 1c772e63cb | |||
| f2947b57c0 | |||
| cc4fd998ea | |||
| a4b5aee790 | |||
| 54abf52e20 | |||
| 84d402a91d | |||
| e7e29a978f | |||
| f3f16cebe4 | |||
| 3d0d8b3c89 | |||
| 5c666e62af | |||
| 3b810c0ffd | |||
| 11d4262457 | |||
| e495f49481 | |||
| 696e2b5235 | |||
| 2a68895ba9 | |||
| 387920e82b | |||
| 73ba089e46 | |||
| 17320ad05a | |||
| c17a858e63 | |||
| 3a9ab87a6d | |||
|
|
6ccff513f4 | ||
| 4dea85e2c8 | |||
| 4a45ef691e | |||
| 4412974cfb | |||
| 5a142d2a3c | |||
| d2416a3ae2 | |||
| 43ab722fdc | |||
| bac0ae12d9 | |||
| ae07aa80e0 | |||
| 40a0c9d4e6 | |||
| 727a562b0d | |||
| 8dc4f6066d | |||
| a7108ce17c | |||
| da857b7951 | |||
| 714be5e7f3 | |||
| 01493ac0bf | |||
| bb771d5e14 | |||
| ca428250fe | |||
| ace0db0446 | |||
| 0f041983dd | |||
| 3e7f8ebe1a | |||
| 3ee9e342e6 | |||
| 8c5bccae9e | |||
| ad56d7a51b | |||
| 3d899034f9 | |||
| c551b12360 | |||
| 761d50706a | |||
| 76d642b8ee | |||
| dd032b3a80 | |||
| 2d981081f2 | |||
| 8f7dbf993d | |||
| fc155d7962 | |||
| 12beec83c8 | |||
| 07745b170e | |||
| 52ddf8c055 | |||
| c851c2292c | |||
| 26187e2dd9 | |||
| 195471f8a9 | |||
| 188a64094a | |||
| 8de4368e50 | |||
| 833cf7a313 | |||
| 5e79e82452 | |||
| a034875216 | |||
| ef01015492 | |||
| ad572148f0 | |||
| e6f9c2eea8 | |||
| e79e2fdea4 | |||
| a1e2f4639b | |||
| 626085ed67 | |||
| bea1e67b19 | |||
| c7302092bb | |||
| d4e9e27c41 | |||
| 214b240423 | |||
| 774ef59e28 | |||
| 085fa85fcd | |||
| 62c0938018 |
352 changed files with 21476 additions and 15338 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -17,8 +17,12 @@ npm-debug.log
|
||||||
yarn-error.log
|
yarn-error.log
|
||||||
/package-lock.json
|
/package-lock.json
|
||||||
/auth.json
|
/auth.json
|
||||||
|
/composer.lock
|
||||||
/.fleet
|
/.fleet
|
||||||
/.idea
|
/.idea
|
||||||
/.nova
|
/.nova
|
||||||
/.vscode
|
/.vscode
|
||||||
/.zed
|
/.zed
|
||||||
|
/coverage-report*
|
||||||
|
/coverage.xml
|
||||||
|
/.claude
|
||||||
|
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
resources/js/components/ui/*
|
|
||||||
resources/js/ziggy.js
|
|
||||||
resources/views/mail/*
|
|
||||||
19
.prettierrc
19
.prettierrc
|
|
@ -1,19 +0,0 @@
|
||||||
{
|
|
||||||
"semi": true,
|
|
||||||
"singleQuote": true,
|
|
||||||
"singleAttributePerLine": false,
|
|
||||||
"htmlWhitespaceSensitivity": "css",
|
|
||||||
"printWidth": 150,
|
|
||||||
"plugins": ["prettier-plugin-organize-imports", "prettier-plugin-tailwindcss"],
|
|
||||||
"tailwindFunctions": ["clsx", "cn"],
|
|
||||||
"tailwindStylesheet": "resources/css/app.css",
|
|
||||||
"tabWidth": 4,
|
|
||||||
"overrides": [
|
|
||||||
{
|
|
||||||
"files": "**/*.yml",
|
|
||||||
"options": {
|
|
||||||
"tabWidth": 2
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
144
Dockerfile
144
Dockerfile
|
|
@ -1,57 +1,127 @@
|
||||||
# Multi-stage build for Laravel with React frontend
|
# Production Dockerfile with FrankenPHP
|
||||||
FROM node:22-alpine AS frontend-builder
|
FROM dunglas/frankenphp:latest-php8.3-alpine
|
||||||
|
|
||||||
WORKDIR /app
|
# Install system dependencies
|
||||||
COPY package*.json ./
|
|
||||||
RUN npm install --only=production
|
|
||||||
COPY . .
|
|
||||||
RUN npm run build
|
|
||||||
|
|
||||||
FROM php:8.4-fpm-alpine
|
|
||||||
|
|
||||||
# Install system dependencies and PHP extensions
|
|
||||||
RUN apk add --no-cache \
|
RUN apk add --no-cache \
|
||||||
|
nodejs \
|
||||||
|
npm \
|
||||||
git \
|
git \
|
||||||
curl \
|
mysql-client
|
||||||
libpng-dev \
|
|
||||||
oniguruma-dev \
|
# Install PHP extensions
|
||||||
libxml2-dev \
|
RUN install-php-extensions \
|
||||||
|
pdo_mysql \
|
||||||
|
opcache \
|
||||||
zip \
|
zip \
|
||||||
unzip \
|
gd \
|
||||||
&& docker-php-ext-install pdo_mysql mbstring exif pcntl bcmath gd
|
intl \
|
||||||
|
bcmath \
|
||||||
|
redis \
|
||||||
|
pcntl
|
||||||
|
|
||||||
# Install Composer
|
# Install Composer
|
||||||
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
|
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
||||||
|
|
||||||
# Set working directory
|
# Set working directory
|
||||||
WORKDIR /var/www/html
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy composer files
|
# Set fixed production environment variables
|
||||||
COPY composer*.json ./
|
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 files (needed for artisan in composer scripts)
|
# Copy application code first
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Install dependencies
|
# Install PHP dependencies (production only)
|
||||||
RUN composer install --no-dev --optimize-autoloader --no-interaction
|
RUN composer install --no-dev --no-interaction --optimize-autoloader
|
||||||
|
|
||||||
# Copy production environment file and generate APP_KEY
|
# Install ALL Node dependencies (including dev for building)
|
||||||
COPY docker/.env.production .env
|
RUN npm ci
|
||||||
RUN php artisan key:generate
|
|
||||||
|
|
||||||
# Copy built frontend assets
|
# Build frontend assets
|
||||||
COPY --from=frontend-builder /app/public/build /var/www/html/public/build
|
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
|
# Set permissions
|
||||||
RUN chown -R www-data:www-data /var/www/html \
|
RUN chown -R www-data:www-data /app/storage /app/bootstrap/cache
|
||||||
&& chmod -R 755 /var/www/html/storage \
|
|
||||||
&& chmod -R 755 /var/www/html/bootstrap/cache
|
|
||||||
|
|
||||||
# Create entrypoint script
|
# Configure Caddy
|
||||||
COPY docker/entrypoint.sh /entrypoint.sh
|
RUN cat > /etc/caddy/Caddyfile <<EOF
|
||||||
RUN chmod +x /entrypoint.sh
|
{
|
||||||
|
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
|
EXPOSE 8000
|
||||||
|
|
||||||
ENTRYPOINT ["/entrypoint.sh"]
|
# Health check
|
||||||
CMD ["web"]
|
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
Normal file
127
Dockerfile.dev
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
# Development Dockerfile with FrankenPHP
|
||||||
|
FROM dunglas/frankenphp:latest-php8.3-alpine
|
||||||
|
|
||||||
|
# Install system dependencies + development tools
|
||||||
|
RUN apk add --no-cache \
|
||||||
|
nodejs \
|
||||||
|
npm \
|
||||||
|
git \
|
||||||
|
mysql-client \
|
||||||
|
vim \
|
||||||
|
bash \
|
||||||
|
nano
|
||||||
|
|
||||||
|
# Install PHP extensions including xdebug for development
|
||||||
|
RUN install-php-extensions \
|
||||||
|
pdo_mysql \
|
||||||
|
opcache \
|
||||||
|
zip \
|
||||||
|
gd \
|
||||||
|
intl \
|
||||||
|
bcmath \
|
||||||
|
redis \
|
||||||
|
pcntl \
|
||||||
|
xdebug
|
||||||
|
|
||||||
|
# Install Composer
|
||||||
|
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
||||||
|
|
||||||
|
# Set working directory
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Configure PHP for development
|
||||||
|
RUN mv "$PHP_INI_DIR/php.ini-development" "$PHP_INI_DIR/php.ini"
|
||||||
|
|
||||||
|
# Configure Xdebug (disabled by default to reduce noise)
|
||||||
|
RUN echo "xdebug.mode=off" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini \
|
||||||
|
&& echo ";xdebug.mode=debug" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini \
|
||||||
|
&& echo ";xdebug.client_host=host.docker.internal" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini \
|
||||||
|
&& echo ";xdebug.start_with_request=yes" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini
|
||||||
|
|
||||||
|
# Configure Caddy for development (simpler, no worker mode)
|
||||||
|
RUN cat > /etc/caddy/Caddyfile <<EOF
|
||||||
|
{
|
||||||
|
frankenphp
|
||||||
|
order php_server before file_server
|
||||||
|
}
|
||||||
|
|
||||||
|
:8000 {
|
||||||
|
root * /app/public
|
||||||
|
|
||||||
|
php_server {
|
||||||
|
index index.php
|
||||||
|
}
|
||||||
|
|
||||||
|
encode gzip
|
||||||
|
|
||||||
|
file_server
|
||||||
|
|
||||||
|
# Less strict headers for development
|
||||||
|
header {
|
||||||
|
X-Frame-Options "SAMEORIGIN"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Install Node development dependencies globally
|
||||||
|
RUN npm install -g nodemon
|
||||||
|
|
||||||
|
# Create startup script for development
|
||||||
|
RUN cat > /start.sh <<'EOF'
|
||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Create .env file if it doesn't exist
|
||||||
|
if [ ! -f ".env" ]; then
|
||||||
|
echo "Creating .env file from .env.example..."
|
||||||
|
cp .env.example .env
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Install dependencies if volumes are empty
|
||||||
|
if [ ! -f "vendor/autoload.php" ]; then
|
||||||
|
echo "Installing composer dependencies..."
|
||||||
|
composer install
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Always reinstall node_modules in container to get correct native binaries for Alpine/musl
|
||||||
|
echo "Installing npm dependencies..."
|
||||||
|
rm -rf node_modules 2>/dev/null || true
|
||||||
|
rm -rf /app/.npm 2>/dev/null || true
|
||||||
|
npm install --cache /tmp/.npm
|
||||||
|
|
||||||
|
# Clear Laravel caches
|
||||||
|
php artisan config:clear || true
|
||||||
|
php artisan cache:clear || true
|
||||||
|
|
||||||
|
# Wait for database and run migrations
|
||||||
|
echo "Waiting for database..."
|
||||||
|
sleep 5
|
||||||
|
php artisan migrate --force || echo "Migration failed or not needed"
|
||||||
|
|
||||||
|
# Run seeders
|
||||||
|
echo "Running seeders..."
|
||||||
|
php artisan db:seed --force || echo "Seeding skipped or already done"
|
||||||
|
|
||||||
|
# Generate app key if not set
|
||||||
|
if [ -z "$APP_KEY" ] || [ "$APP_KEY" = "base64:YOUR_KEY_HERE" ]; then
|
||||||
|
echo "Generating application key..."
|
||||||
|
php artisan key:generate
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Start Vite dev server in background
|
||||||
|
npm run dev &
|
||||||
|
|
||||||
|
# Start Horizon (queue worker) in background
|
||||||
|
php artisan horizon &
|
||||||
|
|
||||||
|
# Start FrankenPHP
|
||||||
|
exec frankenphp run --config /etc/caddy/Caddyfile
|
||||||
|
EOF
|
||||||
|
|
||||||
|
RUN chmod +x /start.sh
|
||||||
|
|
||||||
|
# Expose ports
|
||||||
|
EXPOSE 8000 5173
|
||||||
|
|
||||||
|
# Use the startup script
|
||||||
|
CMD ["/start.sh"]
|
||||||
241
Jenkinsfile
vendored
Normal file
241
Jenkinsfile
vendored
Normal file
|
|
@ -0,0 +1,241 @@
|
||||||
|
pipeline {
|
||||||
|
agent any
|
||||||
|
|
||||||
|
environment {
|
||||||
|
APP_ENV = 'testing'
|
||||||
|
DB_CONNECTION = 'mysql'
|
||||||
|
DB_HOST = 'mysql'
|
||||||
|
DB_PORT = '3306'
|
||||||
|
DB_DATABASE = 'ffr_testing'
|
||||||
|
DB_USERNAME = 'ffr_user'
|
||||||
|
DB_PASSWORD = 'ffr_password'
|
||||||
|
CACHE_STORE = 'array'
|
||||||
|
SESSION_DRIVER = 'array'
|
||||||
|
QUEUE_CONNECTION = 'sync'
|
||||||
|
}
|
||||||
|
|
||||||
|
stages {
|
||||||
|
stage('Checkout') {
|
||||||
|
steps {
|
||||||
|
checkout scm
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('Setup Environment') {
|
||||||
|
steps {
|
||||||
|
script {
|
||||||
|
sh '''
|
||||||
|
echo "Setting up environment for testing..."
|
||||||
|
cp .env.example .env.testing
|
||||||
|
echo "APP_ENV=testing" >> .env.testing
|
||||||
|
echo "DB_CONNECTION=${DB_CONNECTION}" >> .env.testing
|
||||||
|
echo "DB_HOST=${DB_HOST}" >> .env.testing
|
||||||
|
echo "DB_PORT=${DB_PORT}" >> .env.testing
|
||||||
|
echo "DB_DATABASE=${DB_DATABASE}" >> .env.testing
|
||||||
|
echo "DB_USERNAME=${DB_USERNAME}" >> .env.testing
|
||||||
|
echo "DB_PASSWORD=${DB_PASSWORD}" >> .env.testing
|
||||||
|
echo "CACHE_STORE=${CACHE_STORE}" >> .env.testing
|
||||||
|
echo "SESSION_DRIVER=${SESSION_DRIVER}" >> .env.testing
|
||||||
|
echo "QUEUE_CONNECTION=${QUEUE_CONNECTION}" >> .env.testing
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('Install Dependencies') {
|
||||||
|
parallel {
|
||||||
|
stage('PHP Dependencies') {
|
||||||
|
steps {
|
||||||
|
sh '''
|
||||||
|
echo "Installing PHP dependencies..."
|
||||||
|
composer install --no-interaction --prefer-dist --optimize-autoloader --no-dev
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stage('Node Dependencies') {
|
||||||
|
steps {
|
||||||
|
sh '''
|
||||||
|
echo "Installing Node.js dependencies..."
|
||||||
|
npm ci
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('Generate Application Key') {
|
||||||
|
steps {
|
||||||
|
sh '''
|
||||||
|
php artisan key:generate --env=testing --force
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('Database Setup') {
|
||||||
|
steps {
|
||||||
|
sh '''
|
||||||
|
echo "Setting up test database..."
|
||||||
|
php artisan migrate:fresh --env=testing --force
|
||||||
|
php artisan config:clear --env=testing
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('Code Quality Checks') {
|
||||||
|
parallel {
|
||||||
|
stage('PHP Syntax Check') {
|
||||||
|
steps {
|
||||||
|
sh '''
|
||||||
|
echo "Checking PHP syntax..."
|
||||||
|
find . -name "*.php" -not -path "./vendor/*" -not -path "./node_modules/*" -exec php -l {} \\;
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stage('PHPStan Analysis') {
|
||||||
|
steps {
|
||||||
|
script {
|
||||||
|
try {
|
||||||
|
sh '''
|
||||||
|
if [ -f "phpstan.neon" ]; then
|
||||||
|
echo "Running PHPStan static analysis..."
|
||||||
|
./vendor/bin/phpstan analyse --no-progress --error-format=table
|
||||||
|
else
|
||||||
|
echo "PHPStan configuration not found, skipping static analysis"
|
||||||
|
fi
|
||||||
|
'''
|
||||||
|
} catch (Exception e) {
|
||||||
|
unstable(message: "PHPStan found issues")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stage('Security Audit') {
|
||||||
|
steps {
|
||||||
|
script {
|
||||||
|
try {
|
||||||
|
sh '''
|
||||||
|
echo "Running security audit..."
|
||||||
|
composer audit
|
||||||
|
'''
|
||||||
|
} catch (Exception e) {
|
||||||
|
unstable(message: "Security vulnerabilities found")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('Unit Tests') {
|
||||||
|
steps {
|
||||||
|
sh '''
|
||||||
|
echo "Running Unit Tests..."
|
||||||
|
php artisan test tests/Unit/ --env=testing --stop-on-failure
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
post {
|
||||||
|
always {
|
||||||
|
publishTestResults testResultsPattern: 'tests/Unit/results/*.xml'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('Feature Tests') {
|
||||||
|
steps {
|
||||||
|
sh '''
|
||||||
|
echo "Running Feature Tests..."
|
||||||
|
php artisan test tests/Feature/ --env=testing --stop-on-failure
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
post {
|
||||||
|
always {
|
||||||
|
publishTestResults testResultsPattern: 'tests/Feature/results/*.xml'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('Full Regression Test Suite') {
|
||||||
|
steps {
|
||||||
|
sh '''
|
||||||
|
echo "Running comprehensive regression test suite..."
|
||||||
|
chmod +x ./run-regression-tests.sh
|
||||||
|
./run-regression-tests.sh
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
post {
|
||||||
|
always {
|
||||||
|
// Archive test results
|
||||||
|
archiveArtifacts artifacts: 'tests/reports/**/*', allowEmptyArchive: true
|
||||||
|
|
||||||
|
// Publish coverage reports if available
|
||||||
|
publishHTML([
|
||||||
|
allowMissing: false,
|
||||||
|
alwaysLinkToLastBuild: true,
|
||||||
|
keepAll: true,
|
||||||
|
reportDir: 'coverage',
|
||||||
|
reportFiles: 'index.html',
|
||||||
|
reportName: 'Coverage Report'
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('Performance Tests') {
|
||||||
|
steps {
|
||||||
|
script {
|
||||||
|
try {
|
||||||
|
sh '''
|
||||||
|
echo "Running performance tests..."
|
||||||
|
|
||||||
|
# Test memory usage
|
||||||
|
php -d memory_limit=256M artisan test tests/Feature/DatabaseIntegrationTest.php --env=testing
|
||||||
|
|
||||||
|
# Test response times for API endpoints
|
||||||
|
time php artisan test tests/Feature/ApiEndpointRegressionTest.php --env=testing
|
||||||
|
'''
|
||||||
|
} catch (Exception e) {
|
||||||
|
unstable(message: "Performance tests indicated potential issues")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('Build Assets') {
|
||||||
|
when {
|
||||||
|
anyOf {
|
||||||
|
branch 'main'
|
||||||
|
branch 'develop'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
steps {
|
||||||
|
sh '''
|
||||||
|
echo "Building production assets..."
|
||||||
|
npm run build
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
post {
|
||||||
|
always {
|
||||||
|
// Clean up
|
||||||
|
sh '''
|
||||||
|
echo "Cleaning up..."
|
||||||
|
rm -f .env.testing
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
success {
|
||||||
|
echo '✅ All regression tests passed successfully!'
|
||||||
|
// Notify success (customize as needed)
|
||||||
|
// slackSend channel: '#dev-team', color: 'good', message: "Regression tests passed for ${env.JOB_NAME} - ${env.BUILD_NUMBER}"
|
||||||
|
}
|
||||||
|
failure {
|
||||||
|
echo '❌ Regression tests failed!'
|
||||||
|
// Notify failure (customize as needed)
|
||||||
|
// slackSend channel: '#dev-team', color: 'danger', message: "Regression tests failed for ${env.JOB_NAME} - ${env.BUILD_NUMBER}"
|
||||||
|
}
|
||||||
|
unstable {
|
||||||
|
echo '⚠️ Tests completed with warnings'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
186
README.md
186
README.md
|
|
@ -1,126 +1,128 @@
|
||||||
# Lemmy Poster
|
# FFR (Feed to Fediverse Router)
|
||||||
|
|
||||||
A Laravel application for posting articles to Lemmy platforms.
|
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.
|
||||||
|
|
||||||
## Docker Deployment
|
## Features
|
||||||
|
|
||||||
### Building the Image
|
- **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
|
||||||
|
|
||||||
```bash
|
## Self-hosting
|
||||||
docker build -t your-registry/lemmy-poster:latest .
|
|
||||||
docker push your-registry/lemmy-poster:latest
|
|
||||||
```
|
|
||||||
|
|
||||||
### Docker Compose
|
The production image is available at `codeberg.org/lvl0/ffr:latest`.
|
||||||
|
|
||||||
Create a `docker-compose.yml` file:
|
### docker-compose.yml
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
app-web:
|
app:
|
||||||
image: your-registry/lemmy-poster:latest
|
image: codeberg.org/lvl0/ffr:latest
|
||||||
command: ["web"]
|
container_name: ffr_app
|
||||||
|
restart: always
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
environment:
|
environment:
|
||||||
- DB_DATABASE=${DB_DATABASE}
|
APP_KEY: "${APP_KEY}"
|
||||||
- DB_USERNAME=${DB_USERNAME}
|
APP_URL: "${APP_URL}"
|
||||||
- DB_PASSWORD=${DB_PASSWORD}
|
DB_DATABASE: "${DB_DATABASE}"
|
||||||
- LEMMY_INSTANCE=${LEMMY_INSTANCE}
|
DB_USERNAME: "${DB_USERNAME}"
|
||||||
- LEMMY_USERNAME=${LEMMY_USERNAME}
|
DB_PASSWORD: "${DB_PASSWORD}"
|
||||||
- LEMMY_PASSWORD=${LEMMY_PASSWORD}
|
REDIS_HOST: redis
|
||||||
- LEMMY_COMMUNITY=${LEMMY_COMMUNITY}
|
REDIS_PORT: 6379
|
||||||
|
volumes:
|
||||||
|
- app_storage:/app/storage
|
||||||
depends_on:
|
depends_on:
|
||||||
- mysql
|
- db
|
||||||
volumes:
|
- redis
|
||||||
- storage_data:/var/www/html/storage/app
|
healthcheck:
|
||||||
restart: unless-stopped
|
test: ["CMD", "curl", "-f", "http://localhost:8000/up"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 40s
|
||||||
|
|
||||||
app-queue:
|
db:
|
||||||
image: your-registry/lemmy-poster:latest
|
image: mariadb:11
|
||||||
command: ["queue"]
|
container_name: ffr_db
|
||||||
|
restart: always
|
||||||
environment:
|
environment:
|
||||||
- DB_DATABASE=${DB_DATABASE}
|
MYSQL_DATABASE: "${DB_DATABASE}"
|
||||||
- DB_USERNAME=${DB_USERNAME}
|
MYSQL_USER: "${DB_USERNAME}"
|
||||||
- DB_PASSWORD=${DB_PASSWORD}
|
MYSQL_PASSWORD: "${DB_PASSWORD}"
|
||||||
- LEMMY_INSTANCE=${LEMMY_INSTANCE}
|
MYSQL_ROOT_PASSWORD: "${DB_ROOT_PASSWORD}"
|
||||||
- LEMMY_USERNAME=${LEMMY_USERNAME}
|
|
||||||
- LEMMY_PASSWORD=${LEMMY_PASSWORD}
|
|
||||||
- LEMMY_COMMUNITY=${LEMMY_COMMUNITY}
|
|
||||||
depends_on:
|
|
||||||
- mysql
|
|
||||||
volumes:
|
volumes:
|
||||||
- storage_data:/var/www/html/storage/app
|
- db_data:/var/lib/mysql
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
mysql:
|
redis:
|
||||||
image: mysql:8.0
|
image: redis:7-alpine
|
||||||
command: --host-cache-size=0 --innodb-use-native-aio=0 --sql-mode=STRICT_TRANS_TABLES,NO_ZERO_DATE,NO_ZERO_IN_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_AUTO_CREATE_USER,NO_ENGINE_SUBSTITUTION --log-error-verbosity=1
|
container_name: ffr_redis
|
||||||
environment:
|
restart: always
|
||||||
- MYSQL_DATABASE=${DB_DATABASE}
|
|
||||||
- MYSQL_USER=${DB_USERNAME}
|
|
||||||
- MYSQL_PASSWORD=${DB_PASSWORD}
|
|
||||||
- MYSQL_ROOT_PASSWORD=${DB_PASSWORD}
|
|
||||||
- TZ=UTC
|
|
||||||
volumes:
|
volumes:
|
||||||
- mysql_data:/var/lib/mysql
|
- redis_data:/data
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
mysql_data:
|
db_data:
|
||||||
storage_data:
|
redis_data:
|
||||||
|
app_storage:
|
||||||
```
|
```
|
||||||
|
|
||||||
### Environment Variables
|
### Environment Variables
|
||||||
|
|
||||||
Create a `.env` file with:
|
| Variable | Required | Description |
|
||||||
|
|----------|----------|-------------|
|
||||||
|
| `APP_KEY` | Yes | Encryption key. Generate with: `echo "base64:$(openssl rand -base64 32)"` |
|
||||||
|
| `APP_URL` | Yes | Your domain (e.g., `https://ffr.example.com`) |
|
||||||
|
| `DB_DATABASE` | Yes | Database name |
|
||||||
|
| `DB_USERNAME` | Yes | Database user |
|
||||||
|
| `DB_PASSWORD` | Yes | Database password |
|
||||||
|
| `DB_ROOT_PASSWORD` | Yes | MariaDB root password |
|
||||||
|
|
||||||
```env
|
## Development
|
||||||
# Database Settings
|
|
||||||
DB_DATABASE=lemmy_poster
|
|
||||||
DB_USERNAME=lemmy_user
|
|
||||||
DB_PASSWORD=your-password
|
|
||||||
|
|
||||||
# Lemmy Settings
|
### NixOS / Nix
|
||||||
LEMMY_INSTANCE=your-lemmy-instance.com
|
|
||||||
LEMMY_USERNAME=your-lemmy-username
|
|
||||||
LEMMY_PASSWORD=your-lemmy-password
|
|
||||||
LEMMY_COMMUNITY=your-target-community
|
|
||||||
```
|
|
||||||
|
|
||||||
### Deployment
|
|
||||||
|
|
||||||
1. Build and push the image to your registry
|
|
||||||
2. Copy the docker-compose.yml to your server
|
|
||||||
3. Create the .env file with your environment variables
|
|
||||||
4. Run: `docker compose up -d`
|
|
||||||
|
|
||||||
The application will automatically:
|
|
||||||
- Wait for the database to be ready
|
|
||||||
- Run database migrations on first startup
|
|
||||||
- Start the queue worker after migrations complete
|
|
||||||
- Handle race conditions between web and queue containers
|
|
||||||
|
|
||||||
### Initial Setup
|
|
||||||
|
|
||||||
After deployment, the article refresh will run every hour. To trigger the initial article fetch manually:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose exec app-web php artisan article:refresh
|
git clone https://codeberg.org/lvl0/ffr.git
|
||||||
|
cd ffr
|
||||||
|
nix-shell
|
||||||
```
|
```
|
||||||
|
|
||||||
The application will then automatically:
|
The shell will display available commands and optionally start the containers for you.
|
||||||
- Fetch new articles every hour
|
|
||||||
- Publish valid articles every 5 minutes
|
|
||||||
- Sync community posts every 10 minutes
|
|
||||||
|
|
||||||
The web interface will be available on port 8000.
|
#### Available Commands
|
||||||
|
|
||||||
### Architecture
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `dev-up` | Start development environment |
|
||||||
|
| `dev-down` | Stop development environment |
|
||||||
|
| `dev-restart` | Restart containers |
|
||||||
|
| `dev-logs` | Follow app logs |
|
||||||
|
| `dev-logs-db` | Follow database logs |
|
||||||
|
| `dev-shell` | Enter app container |
|
||||||
|
| `dev-artisan <cmd>` | Run artisan commands |
|
||||||
|
| `prod-build [tag]` | Build and push prod image (default: latest) |
|
||||||
|
|
||||||
The application uses a multi-container setup:
|
#### Services
|
||||||
- **app-web**: Serves the Laravel web interface and handles HTTP requests
|
|
||||||
- **app-queue**: Processes background jobs (article fetching, Lemmy posting)
|
|
||||||
- **mysql**: Database storage for articles, logs, and application data
|
|
||||||
|
|
||||||
Both app containers use the same Docker image but with different commands (`web` or `queue`). Environment variables are passed from your `.env` file to configure database access and Lemmy integration.
|
| 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
|
||||||
|
|
||||||
|
For issues and questions, please use [Codeberg Issues](https://codeberg.org/lvl0/ffr/issues).
|
||||||
|
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Console\Commands;
|
|
||||||
|
|
||||||
use App\Models\Article;
|
|
||||||
use App\Services\Article\ArticleFetcher;
|
|
||||||
use Illuminate\Console\Command;
|
|
||||||
|
|
||||||
class FetchArticleCommand extends Command
|
|
||||||
{
|
|
||||||
protected $signature = 'article:fetch {url}';
|
|
||||||
|
|
||||||
protected $description = 'Fetch article from url';
|
|
||||||
|
|
||||||
public function handle(): int
|
|
||||||
{
|
|
||||||
$article = Article::createQuietly([
|
|
||||||
'url' => $this->argument('url'),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$res = ArticleFetcher::fetchArticleData($article);
|
|
||||||
|
|
||||||
dump($res);
|
|
||||||
|
|
||||||
return self::SUCCESS;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -2,7 +2,9 @@
|
||||||
|
|
||||||
namespace App\Console\Commands;
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
use App\Services\Article\ArticleFetcher;
|
use App\Jobs\ArticleDiscoveryJob;
|
||||||
|
use App\Models\Feed;
|
||||||
|
use App\Models\Setting;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
class FetchNewArticlesCommand extends Command
|
class FetchNewArticlesCommand extends Command
|
||||||
|
|
@ -13,9 +15,19 @@ class FetchNewArticlesCommand extends Command
|
||||||
|
|
||||||
public function handle(): int
|
public function handle(): int
|
||||||
{
|
{
|
||||||
logger('Fetch new articles command');
|
if (!Setting::isArticleProcessingEnabled()) {
|
||||||
|
$this->info('Article processing is disabled. Article discovery skipped.');
|
||||||
|
|
||||||
ArticleFetcher::getNewArticles();
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Feed::where('is_active', true)->exists()) {
|
||||||
|
$this->info('No active feeds found. Article discovery skipped.');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
ArticleDiscoveryJob::dispatch();
|
||||||
|
|
||||||
return self::SUCCESS;
|
return self::SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Console\Commands;
|
|
||||||
|
|
||||||
use App\Jobs\PublishToLemmyJob;
|
|
||||||
use App\Models\Article;
|
|
||||||
use Illuminate\Console\Command;
|
|
||||||
|
|
||||||
class PublishToLemmyCommand extends Command
|
|
||||||
{
|
|
||||||
protected $signature = 'article:publish-to-lemmy';
|
|
||||||
|
|
||||||
protected $description = 'Queue an article for publishing to Lemmy';
|
|
||||||
|
|
||||||
public function handle(): int
|
|
||||||
{
|
|
||||||
$article = Article::all()
|
|
||||||
->filter(fn (Article $article) => $article->articlePublication === null)
|
|
||||||
->firstOrFail();
|
|
||||||
|
|
||||||
$this->info('Queuing article for publishing: ' . $article->url);
|
|
||||||
|
|
||||||
PublishToLemmyJob::dispatch($article);
|
|
||||||
|
|
||||||
$this->info('Article queued successfully');
|
|
||||||
|
|
||||||
return self::SUCCESS;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -2,9 +2,7 @@
|
||||||
|
|
||||||
namespace App\Console\Commands;
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
use App\Enums\PlatformEnum;
|
|
||||||
use App\Jobs\SyncChannelPostsJob;
|
use App\Jobs\SyncChannelPostsJob;
|
||||||
use App\Modules\Lemmy\Services\LemmyApiService;
|
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
class SyncChannelPostsCommand extends Command
|
class SyncChannelPostsCommand extends Command
|
||||||
|
|
@ -22,39 +20,15 @@ public function handle(): int
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->error("Unsupported platform: {$platform}");
|
$this->error("Unsupported platform: {$platform}");
|
||||||
|
|
||||||
return self::FAILURE;
|
return self::FAILURE;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function syncLemmy(): int
|
private function syncLemmy(): int
|
||||||
{
|
{
|
||||||
$communityName = config('lemmy.community');
|
SyncChannelPostsJob::dispatchForAllActiveChannels();
|
||||||
|
$this->info('Successfully dispatched sync jobs for all active Lemmy channels');
|
||||||
|
|
||||||
if (!$communityName) {
|
return self::SUCCESS;
|
||||||
$this->error('Missing Lemmy community configuration (lemmy.community)');
|
|
||||||
return self::FAILURE;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$this->info("Getting community ID for: {$communityName}");
|
|
||||||
|
|
||||||
$api = new LemmyApiService(config('lemmy.instance'));
|
|
||||||
$communityId = $api->getCommunityId($communityName);
|
|
||||||
|
|
||||||
$this->info("Running sync job for Lemmy community: {$communityName} (ID: {$communityId})");
|
|
||||||
|
|
||||||
SyncChannelPostsJob::dispatchSync(
|
|
||||||
PlatformEnum::LEMMY,
|
|
||||||
(string) $communityId,
|
|
||||||
$communityName
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->info('Channel posts synced successfully');
|
|
||||||
|
|
||||||
return self::SUCCESS;
|
|
||||||
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
$this->error("Failed to sync channel posts: {$e->getMessage()}");
|
|
||||||
return self::FAILURE;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ public function canParse(string $url): bool;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract article data from HTML
|
* Extract article data from HTML
|
||||||
|
* @return array<string, mixed>
|
||||||
*/
|
*/
|
||||||
public function extractData(string $html): array;
|
public function extractData(string $html): array;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ public function canParse(string $url): bool;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract article URLs from homepage HTML
|
* Extract article URLs from homepage HTML
|
||||||
|
* @return array<int, string>
|
||||||
*/
|
*/
|
||||||
public function extractArticleUrls(string $html): array;
|
public function extractArticleUrls(string $html): array;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App;
|
namespace App\Enums;
|
||||||
|
|
||||||
enum LogLevelEnum: string
|
enum LogLevelEnum: string
|
||||||
{
|
{
|
||||||
|
|
@ -10,6 +10,9 @@ enum LogLevelEnum: string
|
||||||
case ERROR = 'error';
|
case ERROR = 'error';
|
||||||
case CRITICAL = 'critical';
|
case CRITICAL = 'critical';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
public static function toArray(): array
|
public static function toArray(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
|
@ -3,16 +3,15 @@
|
||||||
namespace App\Events;
|
namespace App\Events;
|
||||||
|
|
||||||
use App\Models\Article;
|
use App\Models\Article;
|
||||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
|
||||||
use Illuminate\Foundation\Events\Dispatchable;
|
use Illuminate\Foundation\Events\Dispatchable;
|
||||||
use Illuminate\Queue\SerializesModels;
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
class ArticleReadyToPublish
|
class ArticleApproved
|
||||||
{
|
{
|
||||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
use Dispatchable, SerializesModels;
|
||||||
|
|
||||||
public function __construct(public Article $article)
|
public function __construct(public Article $article)
|
||||||
{
|
{
|
||||||
|
//
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
namespace App\Events;
|
namespace App\Events;
|
||||||
|
|
||||||
use App\LogLevelEnum;
|
use App\Enums\LogLevelEnum;
|
||||||
use Illuminate\Foundation\Events\Dispatchable;
|
use Illuminate\Foundation\Events\Dispatchable;
|
||||||
use Illuminate\Queue\SerializesModels;
|
use Illuminate\Queue\SerializesModels;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
@ -15,6 +15,7 @@ public function __construct(
|
||||||
public Throwable $exception,
|
public Throwable $exception,
|
||||||
public LogLevelEnum $level,
|
public LogLevelEnum $level,
|
||||||
public string $message,
|
public string $message,
|
||||||
|
/** @var array<string, mixed> */
|
||||||
public array $context = []
|
public array $context = []
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,12 +7,11 @@
|
||||||
use Illuminate\Foundation\Events\Dispatchable;
|
use Illuminate\Foundation\Events\Dispatchable;
|
||||||
use Illuminate\Queue\SerializesModels;
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
class ArticleFetched
|
class NewArticleFetched
|
||||||
{
|
{
|
||||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||||
|
|
||||||
public function __construct(public Article $article)
|
public function __construct(public Article $article)
|
||||||
{
|
{
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
9
app/Exceptions/ChannelException.php
Normal file
9
app/Exceptions/ChannelException.php
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Exceptions;
|
||||||
|
|
||||||
|
use Exception;
|
||||||
|
|
||||||
|
class ChannelException extends Exception
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
@ -11,10 +11,14 @@ class PublishException extends Exception
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly Article $article,
|
private readonly Article $article,
|
||||||
private readonly PlatformEnum $platform,
|
private readonly PlatformEnum|null $platform,
|
||||||
?Throwable $previous = null
|
?Throwable $previous = null
|
||||||
) {
|
) {
|
||||||
$message = "Failed to publish article #{$article->id} to {$platform->value}";
|
$message = "Failed to publish article #$article->id";
|
||||||
|
|
||||||
|
if ($this->platform) {
|
||||||
|
$message .= " to $platform->value";
|
||||||
|
}
|
||||||
|
|
||||||
if ($previous) {
|
if ($previous) {
|
||||||
$message .= ": {$previous->getMessage()}";
|
$message .= ": {$previous->getMessage()}";
|
||||||
|
|
@ -28,7 +32,7 @@ public function getArticle(): Article
|
||||||
return $this->article;
|
return $this->article;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getPlatform(): PlatformEnum
|
public function getPlatform(): ?PlatformEnum
|
||||||
{
|
{
|
||||||
return $this->platform;
|
return $this->platform;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
9
app/Exceptions/RoutingException.php
Normal file
9
app/Exceptions/RoutingException.php
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Exceptions;
|
||||||
|
|
||||||
|
use Exception;
|
||||||
|
|
||||||
|
class RoutingException extends Exception
|
||||||
|
{
|
||||||
|
}
|
||||||
22
app/Exceptions/RoutingMismatchException.php
Normal file
22
app/Exceptions/RoutingMismatchException.php
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Exceptions;
|
||||||
|
|
||||||
|
use App\Models\Feed;
|
||||||
|
use App\Models\PlatformChannel;
|
||||||
|
|
||||||
|
class RoutingMismatchException extends RoutingException
|
||||||
|
{
|
||||||
|
public function __construct(Feed $feed, PlatformChannel $channel)
|
||||||
|
{
|
||||||
|
$message = sprintf(
|
||||||
|
"Language mismatch: Feed '%s' is in '%s' but channel '%s' is set to '%s'. Feed and channel languages must match for proper content routing.",
|
||||||
|
$feed->name,
|
||||||
|
$feed->language,
|
||||||
|
$channel->name,
|
||||||
|
$channel->language
|
||||||
|
);
|
||||||
|
|
||||||
|
parent::__construct($message);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
app/Facades/LogSaver.php
Normal file
13
app/Facades/LogSaver.php
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Facades;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Facade;
|
||||||
|
|
||||||
|
class LogSaver extends Facade
|
||||||
|
{
|
||||||
|
protected static function getFacadeAccessor()
|
||||||
|
{
|
||||||
|
return \App\Services\Log\LogSaver::class;
|
||||||
|
}
|
||||||
|
}
|
||||||
94
app/Http/Controllers/Api/V1/ArticlesController.php
Normal file
94
app/Http/Controllers/Api/V1/ArticlesController.php
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Http\Resources\ArticleResource;
|
||||||
|
use App\Models\Article;
|
||||||
|
use App\Models\Setting;
|
||||||
|
use App\Jobs\ArticleDiscoveryJob;
|
||||||
|
use Exception;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Artisan;
|
||||||
|
|
||||||
|
class ArticlesController extends BaseController
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Display a listing of articles
|
||||||
|
*/
|
||||||
|
public function index(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$perPage = min($request->get('per_page', 15), 100); // Max 100 items per page
|
||||||
|
$articles = Article::with(['feed', 'articlePublication'])
|
||||||
|
->orderBy('created_at', 'desc')
|
||||||
|
->paginate($perPage);
|
||||||
|
|
||||||
|
$publishingApprovalsEnabled = Setting::isPublishingApprovalsEnabled();
|
||||||
|
|
||||||
|
return $this->sendResponse([
|
||||||
|
'articles' => ArticleResource::collection($articles->items()),
|
||||||
|
'pagination' => [
|
||||||
|
'current_page' => $articles->currentPage(),
|
||||||
|
'last_page' => $articles->lastPage(),
|
||||||
|
'per_page' => $articles->perPage(),
|
||||||
|
'total' => $articles->total(),
|
||||||
|
'from' => $articles->firstItem(),
|
||||||
|
'to' => $articles->lastItem(),
|
||||||
|
],
|
||||||
|
'settings' => [
|
||||||
|
'publishing_approvals_enabled' => $publishingApprovalsEnabled,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Approve an article
|
||||||
|
*/
|
||||||
|
public function approve(Article $article): JsonResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$article->approve('manual');
|
||||||
|
|
||||||
|
return $this->sendResponse(
|
||||||
|
new ArticleResource($article->fresh(['feed', 'articlePublication'])),
|
||||||
|
'Article approved and queued for publishing.'
|
||||||
|
);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
return $this->sendError('Failed to approve article: ' . $e->getMessage(), [], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reject an article
|
||||||
|
*/
|
||||||
|
public function reject(Article $article): JsonResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$article->reject('manual');
|
||||||
|
|
||||||
|
return $this->sendResponse(
|
||||||
|
new ArticleResource($article->fresh(['feed', 'articlePublication'])),
|
||||||
|
'Article rejected.'
|
||||||
|
);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
return $this->sendError('Failed to reject article: ' . $e->getMessage(), [], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manually refresh articles from all active feeds
|
||||||
|
*/
|
||||||
|
public function refresh(): JsonResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
ArticleDiscoveryJob::dispatch();
|
||||||
|
|
||||||
|
return $this->sendResponse(
|
||||||
|
null,
|
||||||
|
'Article refresh started. New articles will appear shortly.'
|
||||||
|
);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
return $this->sendError('Failed to start article refresh: ' . $e->getMessage(), [], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
112
app/Http/Controllers/Api/V1/AuthController.php
Normal file
112
app/Http/Controllers/Api/V1/AuthController.php
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
|
class AuthController extends BaseController
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Login user and create token
|
||||||
|
*/
|
||||||
|
public function login(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$request->validate([
|
||||||
|
'email' => 'required|email',
|
||||||
|
'password' => 'required',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user = User::where('email', $request->email)->first();
|
||||||
|
|
||||||
|
if (!$user || !Hash::check($request->password, $user->password)) {
|
||||||
|
return $this->sendError('Invalid credentials', [], 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
$token = $user->createToken('api-token')->plainTextToken;
|
||||||
|
|
||||||
|
return $this->sendResponse([
|
||||||
|
'user' => [
|
||||||
|
'id' => $user->id,
|
||||||
|
'name' => $user->name,
|
||||||
|
'email' => $user->email,
|
||||||
|
],
|
||||||
|
'token' => $token,
|
||||||
|
'token_type' => 'Bearer',
|
||||||
|
], 'Login successful');
|
||||||
|
} catch (ValidationException $e) {
|
||||||
|
return $this->sendValidationError($e->errors());
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return $this->sendError('Login failed: ' . $e->getMessage(), [], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a new user
|
||||||
|
*/
|
||||||
|
public function register(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$validated = $request->validate([
|
||||||
|
'name' => 'required|string|max:255',
|
||||||
|
'email' => 'required|string|email|max:255|unique:users',
|
||||||
|
'password' => 'required|string|min:8|confirmed',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user = User::create([
|
||||||
|
'name' => $validated['name'],
|
||||||
|
'email' => $validated['email'],
|
||||||
|
'password' => Hash::make($validated['password']),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$token = $user->createToken('api-token')->plainTextToken;
|
||||||
|
|
||||||
|
return $this->sendResponse([
|
||||||
|
'user' => [
|
||||||
|
'id' => $user->id,
|
||||||
|
'name' => $user->name,
|
||||||
|
'email' => $user->email,
|
||||||
|
],
|
||||||
|
'token' => $token,
|
||||||
|
'token_type' => 'Bearer',
|
||||||
|
], 'Registration successful', 201);
|
||||||
|
} catch (ValidationException $e) {
|
||||||
|
return $this->sendValidationError($e->errors());
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return $this->sendError('Registration failed: ' . $e->getMessage(), [], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logout user (revoke token)
|
||||||
|
*/
|
||||||
|
public function logout(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$request->user()->currentAccessToken()->delete();
|
||||||
|
|
||||||
|
return $this->sendResponse(null, 'Logged out successfully');
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return $this->sendError('Logout failed: ' . $e->getMessage(), [], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current authenticated user
|
||||||
|
*/
|
||||||
|
public function me(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
return $this->sendResponse([
|
||||||
|
'user' => [
|
||||||
|
'id' => $request->user()->id,
|
||||||
|
'name' => $request->user()->name,
|
||||||
|
'email' => $request->user()->email,
|
||||||
|
],
|
||||||
|
], 'User retrieved successfully');
|
||||||
|
}
|
||||||
|
}
|
||||||
64
app/Http/Controllers/Api/V1/BaseController.php
Normal file
64
app/Http/Controllers/Api/V1/BaseController.php
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
|
class BaseController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Success response method
|
||||||
|
*/
|
||||||
|
public function sendResponse(mixed $result, string $message = 'Success', int $code = 200): JsonResponse
|
||||||
|
{
|
||||||
|
$response = [
|
||||||
|
'success' => true,
|
||||||
|
'data' => $result,
|
||||||
|
'message' => $message,
|
||||||
|
];
|
||||||
|
|
||||||
|
return response()->json($response, $code);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error response method
|
||||||
|
*/
|
||||||
|
public function sendError(string $error, array $errorMessages = [], int $code = 400): JsonResponse
|
||||||
|
{
|
||||||
|
$response = [
|
||||||
|
'success' => false,
|
||||||
|
'message' => $error,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!empty($errorMessages)) {
|
||||||
|
$response['errors'] = $errorMessages;
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json($response, $code);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validation error response method
|
||||||
|
*/
|
||||||
|
public function sendValidationError(array $errors): JsonResponse
|
||||||
|
{
|
||||||
|
return $this->sendError('Validation failed', $errors, 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Not found response method
|
||||||
|
*/
|
||||||
|
public function sendNotFound(string $message = 'Resource not found'): JsonResponse
|
||||||
|
{
|
||||||
|
return $this->sendError($message, [], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unauthorized response method
|
||||||
|
*/
|
||||||
|
public function sendUnauthorized(string $message = 'Unauthorized'): JsonResponse
|
||||||
|
{
|
||||||
|
return $this->sendError($message, [], 401);
|
||||||
|
}
|
||||||
|
}
|
||||||
47
app/Http/Controllers/Api/V1/DashboardController.php
Normal file
47
app/Http/Controllers/Api/V1/DashboardController.php
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Models\Article;
|
||||||
|
use App\Models\Feed;
|
||||||
|
use App\Models\PlatformAccount;
|
||||||
|
use App\Models\PlatformChannel;
|
||||||
|
use App\Services\DashboardStatsService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class DashboardController extends BaseController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private DashboardStatsService $dashboardStatsService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get dashboard statistics
|
||||||
|
*/
|
||||||
|
public function stats(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$period = $request->get('period', 'today');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get article stats from service
|
||||||
|
$articleStats = $this->dashboardStatsService->getStats($period);
|
||||||
|
|
||||||
|
// Get system stats
|
||||||
|
$systemStats = $this->dashboardStatsService->getSystemStats();
|
||||||
|
|
||||||
|
// Get available periods
|
||||||
|
$availablePeriods = $this->dashboardStatsService->getAvailablePeriods();
|
||||||
|
|
||||||
|
return $this->sendResponse([
|
||||||
|
'article_stats' => $articleStats,
|
||||||
|
'system_stats' => $systemStats,
|
||||||
|
'available_periods' => $availablePeriods,
|
||||||
|
'current_period' => $period,
|
||||||
|
]);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
throw $e;
|
||||||
|
return $this->sendError('Failed to fetch dashboard stats: ' . $e->getMessage(), [], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
143
app/Http/Controllers/Api/V1/FeedsController.php
Normal file
143
app/Http/Controllers/Api/V1/FeedsController.php
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Http\Requests\StoreFeedRequest;
|
||||||
|
use App\Http\Requests\UpdateFeedRequest;
|
||||||
|
use App\Http\Resources\FeedResource;
|
||||||
|
use App\Models\Feed;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
|
class FeedsController extends BaseController
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Display a listing of feeds
|
||||||
|
*/
|
||||||
|
public function index(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$perPage = min($request->get('per_page', 15), 100);
|
||||||
|
|
||||||
|
$feeds = Feed::with(['language'])
|
||||||
|
->withCount('articles')
|
||||||
|
->orderBy('is_active', 'desc')
|
||||||
|
->orderBy('name')
|
||||||
|
->paginate($perPage);
|
||||||
|
|
||||||
|
return $this->sendResponse([
|
||||||
|
'feeds' => FeedResource::collection($feeds->items()),
|
||||||
|
'pagination' => [
|
||||||
|
'current_page' => $feeds->currentPage(),
|
||||||
|
'last_page' => $feeds->lastPage(),
|
||||||
|
'per_page' => $feeds->perPage(),
|
||||||
|
'total' => $feeds->total(),
|
||||||
|
'from' => $feeds->firstItem(),
|
||||||
|
'to' => $feeds->lastItem(),
|
||||||
|
]
|
||||||
|
], 'Feeds retrieved successfully.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store a newly created feed
|
||||||
|
*/
|
||||||
|
public function store(StoreFeedRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$validated = $request->validated();
|
||||||
|
$validated['is_active'] = $validated['is_active'] ?? true;
|
||||||
|
|
||||||
|
// Map provider to URL and set type
|
||||||
|
$providers = [
|
||||||
|
'vrt' => new \App\Services\Parsers\VrtHomepageParserAdapter(),
|
||||||
|
'belga' => new \App\Services\Parsers\BelgaHomepageParserAdapter(),
|
||||||
|
];
|
||||||
|
|
||||||
|
$adapter = $providers[$validated['provider']];
|
||||||
|
$validated['url'] = $adapter->getHomepageUrl();
|
||||||
|
$validated['type'] = 'website';
|
||||||
|
|
||||||
|
$feed = Feed::create($validated);
|
||||||
|
|
||||||
|
return $this->sendResponse(
|
||||||
|
new FeedResource($feed),
|
||||||
|
'Feed created successfully!',
|
||||||
|
201
|
||||||
|
);
|
||||||
|
} catch (ValidationException $e) {
|
||||||
|
return $this->sendValidationError($e->errors());
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return $this->sendError('Failed to create feed: ' . $e->getMessage(), [], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display the specified feed
|
||||||
|
*/
|
||||||
|
public function show(Feed $feed): JsonResponse
|
||||||
|
{
|
||||||
|
return $this->sendResponse(
|
||||||
|
new FeedResource($feed),
|
||||||
|
'Feed retrieved successfully.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the specified feed
|
||||||
|
*/
|
||||||
|
public function update(UpdateFeedRequest $request, Feed $feed): JsonResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$validated = $request->validated();
|
||||||
|
$validated['is_active'] = $validated['is_active'] ?? $feed->is_active;
|
||||||
|
|
||||||
|
$feed->update($validated);
|
||||||
|
|
||||||
|
return $this->sendResponse(
|
||||||
|
new FeedResource($feed->fresh()),
|
||||||
|
'Feed updated successfully!'
|
||||||
|
);
|
||||||
|
} catch (ValidationException $e) {
|
||||||
|
return $this->sendValidationError($e->errors());
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return $this->sendError('Failed to update feed: ' . $e->getMessage(), [], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the specified feed
|
||||||
|
*/
|
||||||
|
public function destroy(Feed $feed): JsonResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$feed->delete();
|
||||||
|
|
||||||
|
return $this->sendResponse(
|
||||||
|
null,
|
||||||
|
'Feed deleted successfully!'
|
||||||
|
);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return $this->sendError('Failed to delete feed: ' . $e->getMessage(), [], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle feed active status
|
||||||
|
*/
|
||||||
|
public function toggle(Feed $feed): JsonResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$newStatus = !$feed->is_active;
|
||||||
|
$feed->update(['is_active' => $newStatus]);
|
||||||
|
|
||||||
|
$status = $newStatus ? 'activated' : 'deactivated';
|
||||||
|
|
||||||
|
return $this->sendResponse(
|
||||||
|
new FeedResource($feed->fresh()),
|
||||||
|
"Feed {$status} successfully!"
|
||||||
|
);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return $this->sendError('Failed to toggle feed status: ' . $e->getMessage(), [], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
143
app/Http/Controllers/Api/V1/KeywordsController.php
Normal file
143
app/Http/Controllers/Api/V1/KeywordsController.php
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Models\Feed;
|
||||||
|
use App\Models\Keyword;
|
||||||
|
use App\Models\PlatformChannel;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
|
class KeywordsController extends BaseController
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Display keywords for a specific route
|
||||||
|
*/
|
||||||
|
public function index(Feed $feed, PlatformChannel $channel): JsonResponse
|
||||||
|
{
|
||||||
|
$keywords = Keyword::where('feed_id', $feed->id)
|
||||||
|
->where('platform_channel_id', $channel->id)
|
||||||
|
->orderBy('keyword')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return $this->sendResponse(
|
||||||
|
$keywords->toArray(),
|
||||||
|
'Keywords retrieved successfully.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store a new keyword for a route
|
||||||
|
*/
|
||||||
|
public function store(Request $request, Feed $feed, PlatformChannel $channel): JsonResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$validated = $request->validate([
|
||||||
|
'keyword' => 'required|string|max:255',
|
||||||
|
'is_active' => 'boolean',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$validated['feed_id'] = $feed->id;
|
||||||
|
$validated['platform_channel_id'] = $channel->id;
|
||||||
|
$validated['is_active'] = $validated['is_active'] ?? true;
|
||||||
|
|
||||||
|
// Check if keyword already exists for this route
|
||||||
|
$existingKeyword = Keyword::where('feed_id', $feed->id)
|
||||||
|
->where('platform_channel_id', $channel->id)
|
||||||
|
->where('keyword', $validated['keyword'])
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($existingKeyword) {
|
||||||
|
return $this->sendError('Keyword already exists for this route.', [], 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
$keyword = Keyword::create($validated);
|
||||||
|
|
||||||
|
return $this->sendResponse(
|
||||||
|
$keyword->toArray(),
|
||||||
|
'Keyword created successfully!',
|
||||||
|
201
|
||||||
|
);
|
||||||
|
} catch (ValidationException $e) {
|
||||||
|
return $this->sendValidationError($e->errors());
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return $this->sendError('Failed to create keyword: ' . $e->getMessage(), [], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a keyword's status
|
||||||
|
*/
|
||||||
|
public function update(Request $request, Feed $feed, PlatformChannel $channel, Keyword $keyword): JsonResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
// Verify the keyword belongs to this route
|
||||||
|
if ($keyword->feed_id !== $feed->id || $keyword->platform_channel_id !== $channel->id) {
|
||||||
|
return $this->sendNotFound('Keyword not found for this route.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'is_active' => 'boolean',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$keyword->update($validated);
|
||||||
|
|
||||||
|
return $this->sendResponse(
|
||||||
|
$keyword->fresh()->toArray(),
|
||||||
|
'Keyword updated successfully!'
|
||||||
|
);
|
||||||
|
} catch (ValidationException $e) {
|
||||||
|
return $this->sendValidationError($e->errors());
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return $this->sendError('Failed to update keyword: ' . $e->getMessage(), [], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a keyword from a route
|
||||||
|
*/
|
||||||
|
public function destroy(Feed $feed, PlatformChannel $channel, Keyword $keyword): JsonResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
// Verify the keyword belongs to this route
|
||||||
|
if ($keyword->feed_id !== $feed->id || $keyword->platform_channel_id !== $channel->id) {
|
||||||
|
return $this->sendNotFound('Keyword not found for this route.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$keyword->delete();
|
||||||
|
|
||||||
|
return $this->sendResponse(
|
||||||
|
null,
|
||||||
|
'Keyword deleted successfully!'
|
||||||
|
);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return $this->sendError('Failed to delete keyword: ' . $e->getMessage(), [], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle keyword active status
|
||||||
|
*/
|
||||||
|
public function toggle(Feed $feed, PlatformChannel $channel, Keyword $keyword): JsonResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
// Verify the keyword belongs to this route
|
||||||
|
if ($keyword->feed_id !== $feed->id || $keyword->platform_channel_id !== $channel->id) {
|
||||||
|
return $this->sendNotFound('Keyword not found for this route.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$newStatus = !$keyword->is_active;
|
||||||
|
$keyword->update(['is_active' => $newStatus]);
|
||||||
|
|
||||||
|
$status = $newStatus ? 'activated' : 'deactivated';
|
||||||
|
|
||||||
|
return $this->sendResponse(
|
||||||
|
$keyword->fresh()->toArray(),
|
||||||
|
"Keyword {$status} successfully!"
|
||||||
|
);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return $this->sendError('Failed to toggle keyword status: ' . $e->getMessage(), [], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
55
app/Http/Controllers/Api/V1/LogsController.php
Normal file
55
app/Http/Controllers/Api/V1/LogsController.php
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Models\Log;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class LogsController extends BaseController
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Display a listing of logs
|
||||||
|
*/
|
||||||
|
public function index(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
// Clamp per_page between 1 and 100 and ensure integer
|
||||||
|
$perPage = (int) $request->query('per_page', 20);
|
||||||
|
if ($perPage < 1) {
|
||||||
|
$perPage = 20;
|
||||||
|
}
|
||||||
|
$perPage = min($perPage, 100);
|
||||||
|
|
||||||
|
$level = $request->query('level');
|
||||||
|
|
||||||
|
// Stable ordering: created_at desc, then id desc for deterministic results
|
||||||
|
$query = Log::orderBy('created_at', 'desc')
|
||||||
|
->orderBy('id', 'desc');
|
||||||
|
|
||||||
|
// Exclude known system/console noise that may appear during test bootstrap
|
||||||
|
$query->where('message', '!=', 'No active feeds found. Article discovery skipped.');
|
||||||
|
|
||||||
|
if ($level) {
|
||||||
|
$query->where('level', $level);
|
||||||
|
}
|
||||||
|
|
||||||
|
$logs = $query->paginate($perPage);
|
||||||
|
|
||||||
|
return $this->sendResponse([
|
||||||
|
'logs' => $logs->items(),
|
||||||
|
'pagination' => [
|
||||||
|
'current_page' => $logs->currentPage(),
|
||||||
|
// Ensure last_page is at least 1 to satisfy empty dataset expectation
|
||||||
|
'last_page' => max(1, $logs->lastPage()),
|
||||||
|
'per_page' => $logs->perPage(),
|
||||||
|
'total' => $logs->total(),
|
||||||
|
'from' => $logs->firstItem(),
|
||||||
|
'to' => $logs->lastItem(),
|
||||||
|
],
|
||||||
|
], 'Logs retrieved successfully.');
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return $this->sendError('Failed to retrieve logs: ' . $e->getMessage(), [], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
388
app/Http/Controllers/Api/V1/OnboardingController.php
Normal file
388
app/Http/Controllers/Api/V1/OnboardingController.php
Normal file
|
|
@ -0,0 +1,388 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Http\Requests\StoreFeedRequest;
|
||||||
|
use App\Http\Resources\FeedResource;
|
||||||
|
use App\Http\Resources\PlatformAccountResource;
|
||||||
|
use App\Http\Resources\PlatformChannelResource;
|
||||||
|
use App\Http\Resources\RouteResource;
|
||||||
|
use App\Jobs\ArticleDiscoveryJob;
|
||||||
|
use App\Models\Feed;
|
||||||
|
use App\Models\Language;
|
||||||
|
use App\Models\PlatformAccount;
|
||||||
|
use App\Models\PlatformChannel;
|
||||||
|
use App\Models\PlatformInstance;
|
||||||
|
use App\Models\Route;
|
||||||
|
use App\Models\Setting;
|
||||||
|
use App\Services\Auth\LemmyAuthService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Validator;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
|
class OnboardingController extends BaseController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly LemmyAuthService $lemmyAuthService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get onboarding status - whether user needs onboarding
|
||||||
|
*/
|
||||||
|
public function status(): JsonResponse
|
||||||
|
{
|
||||||
|
$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();
|
||||||
|
|
||||||
|
// Check if onboarding was explicitly skipped or completed
|
||||||
|
$onboardingSkipped = Setting::where('key', 'onboarding_skipped')->value('value') === 'true';
|
||||||
|
$onboardingCompleted = Setting::where('key', 'onboarding_completed')->exists();
|
||||||
|
|
||||||
|
// User needs onboarding if:
|
||||||
|
// 1. They haven't completed or skipped onboarding AND
|
||||||
|
// 2. They don't have all required components
|
||||||
|
$hasAllComponents = $hasPlatformAccount && $hasFeed && $hasChannel && $hasRoute;
|
||||||
|
$needsOnboarding = !$onboardingCompleted && !$onboardingSkipped && !$hasAllComponents;
|
||||||
|
|
||||||
|
// Determine current step
|
||||||
|
$currentStep = null;
|
||||||
|
if ($needsOnboarding) {
|
||||||
|
if (!$hasPlatformAccount) {
|
||||||
|
$currentStep = 'platform';
|
||||||
|
} elseif (!$hasFeed) {
|
||||||
|
$currentStep = 'feed';
|
||||||
|
} elseif (!$hasChannel) {
|
||||||
|
$currentStep = 'channel';
|
||||||
|
} elseif (!$hasRoute) {
|
||||||
|
$currentStep = 'route';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->sendResponse([
|
||||||
|
'needs_onboarding' => $needsOnboarding,
|
||||||
|
'current_step' => $currentStep,
|
||||||
|
'has_platform_account' => $hasPlatformAccount,
|
||||||
|
'has_feed' => $hasFeed,
|
||||||
|
'has_channel' => $hasChannel,
|
||||||
|
'has_route' => $hasRoute,
|
||||||
|
'onboarding_skipped' => $onboardingSkipped,
|
||||||
|
'onboarding_completed' => $onboardingCompleted,
|
||||||
|
'missing_components' => !$hasAllComponents && $onboardingCompleted,
|
||||||
|
], 'Onboarding status retrieved successfully.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get onboarding options (languages, platform instances)
|
||||||
|
*/
|
||||||
|
public function options(): JsonResponse
|
||||||
|
{
|
||||||
|
$languages = Language::where('is_active', true)
|
||||||
|
->orderBy('name')
|
||||||
|
->get(['id', 'short_code', 'name', 'native_name', 'is_active']);
|
||||||
|
|
||||||
|
$platformInstances = PlatformInstance::where('is_active', true)
|
||||||
|
->orderBy('name')
|
||||||
|
->get(['id', 'platform', 'url', 'name', 'description', 'is_active']);
|
||||||
|
|
||||||
|
// Get existing feeds and channels for route creation
|
||||||
|
$feeds = Feed::where('is_active', true)
|
||||||
|
->orderBy('name')
|
||||||
|
->get(['id', 'name', 'url', 'type']);
|
||||||
|
|
||||||
|
$platformChannels = PlatformChannel::where('is_active', true)
|
||||||
|
->with(['platformInstance:id,name,url'])
|
||||||
|
->orderBy('name')
|
||||||
|
->get(['id', 'platform_instance_id', 'name', 'display_name', 'description']);
|
||||||
|
|
||||||
|
// Get feed providers from config
|
||||||
|
$feedProviders = collect(config('feed.providers', []))
|
||||||
|
->filter(fn($provider) => $provider['is_active'])
|
||||||
|
->values();
|
||||||
|
|
||||||
|
return $this->sendResponse([
|
||||||
|
'languages' => $languages,
|
||||||
|
'platform_instances' => $platformInstances,
|
||||||
|
'feeds' => $feeds,
|
||||||
|
'platform_channels' => $platformChannels,
|
||||||
|
'feed_providers' => $feedProviders,
|
||||||
|
], 'Onboarding options retrieved successfully.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create platform account for onboarding
|
||||||
|
*/
|
||||||
|
public function createPlatform(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$validator = Validator::make($request->all(), [
|
||||||
|
'instance_url' => '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',
|
||||||
|
'platform' => 'required|in:lemmy',
|
||||||
|
], [
|
||||||
|
'instance_url.regex' => 'Please enter a valid domain name (e.g., lemmy.world, belgae.social)'
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($validator->fails()) {
|
||||||
|
throw new ValidationException($validator);
|
||||||
|
}
|
||||||
|
|
||||||
|
$validated = $validator->validated();
|
||||||
|
|
||||||
|
// Normalize the instance URL - prepend https:// if needed
|
||||||
|
$instanceDomain = $validated['instance_url'];
|
||||||
|
$fullInstanceUrl = 'https://' . $instanceDomain;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create or get platform instance
|
||||||
|
$platformInstance = PlatformInstance::firstOrCreate([
|
||||||
|
'url' => $fullInstanceUrl,
|
||||||
|
'platform' => $validated['platform'],
|
||||||
|
], [
|
||||||
|
'name' => ucfirst($instanceDomain),
|
||||||
|
'is_active' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Authenticate with Lemmy API using the full URL
|
||||||
|
$authResponse = $this->lemmyAuthService->authenticate(
|
||||||
|
$fullInstanceUrl,
|
||||||
|
$validated['username'],
|
||||||
|
$validated['password']
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create platform account with the current schema
|
||||||
|
$platformAccount = PlatformAccount::create([
|
||||||
|
'platform' => $validated['platform'],
|
||||||
|
'instance_url' => $fullInstanceUrl,
|
||||||
|
'username' => $validated['username'],
|
||||||
|
'password' => $validated['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, // Store JWT in settings for now
|
||||||
|
],
|
||||||
|
'is_active' => true,
|
||||||
|
'status' => 'active',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $this->sendResponse(
|
||||||
|
new PlatformAccountResource($platformAccount),
|
||||||
|
'Platform account created successfully.'
|
||||||
|
);
|
||||||
|
|
||||||
|
} catch (\App\Exceptions\PlatformAuthException $e) {
|
||||||
|
// Check if it's a rate limit error
|
||||||
|
if (str_contains($e->getMessage(), 'Rate limited by')) {
|
||||||
|
return $this->sendError($e->getMessage(), [], 429);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->sendError('Invalid username or password. Please check your credentials and try again.', [], 422);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
|
||||||
|
$message = 'Unable to connect to the Lemmy instance. Please check the URL and try again.';
|
||||||
|
|
||||||
|
// If it's a network/connection issue, provide a more specific message
|
||||||
|
if (str_contains(strtolower($e->getMessage()), 'connection') ||
|
||||||
|
str_contains(strtolower($e->getMessage()), 'network') ||
|
||||||
|
str_contains(strtolower($e->getMessage()), 'timeout')) {
|
||||||
|
$message = 'Connection failed. Please check the instance URL and your internet connection.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->sendError($message, [], 422);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create feed for onboarding
|
||||||
|
*/
|
||||||
|
public function createFeed(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$validator = Validator::make($request->all(), [
|
||||||
|
'name' => 'required|string|max:255',
|
||||||
|
'provider' => 'required|in:belga,vrt',
|
||||||
|
'language_id' => 'required|exists:languages,id',
|
||||||
|
'description' => 'nullable|string|max:1000',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($validator->fails()) {
|
||||||
|
throw new ValidationException($validator);
|
||||||
|
}
|
||||||
|
|
||||||
|
$validated = $validator->validated();
|
||||||
|
|
||||||
|
// Map provider to preset URL and type as required by onboarding tests
|
||||||
|
$provider = $validated['provider'];
|
||||||
|
$url = null;
|
||||||
|
$type = 'website';
|
||||||
|
if ($provider === 'vrt') {
|
||||||
|
$url = 'https://www.vrt.be/vrtnws/en/';
|
||||||
|
} elseif ($provider === 'belga') {
|
||||||
|
$url = 'https://www.belganewsagency.eu/';
|
||||||
|
}
|
||||||
|
|
||||||
|
$feed = Feed::firstOrCreate(
|
||||||
|
['url' => $url],
|
||||||
|
[
|
||||||
|
'name' => $validated['name'],
|
||||||
|
'type' => $type,
|
||||||
|
'provider' => $provider,
|
||||||
|
'language_id' => $validated['language_id'],
|
||||||
|
'description' => $validated['description'] ?? null,
|
||||||
|
'is_active' => true,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return $this->sendResponse(
|
||||||
|
new FeedResource($feed->load('language')),
|
||||||
|
'Feed created successfully.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create channel for onboarding
|
||||||
|
* @throws ValidationException
|
||||||
|
*/
|
||||||
|
public function createChannel(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$validator = Validator::make($request->all(), [
|
||||||
|
'name' => 'required|string|max:255',
|
||||||
|
'platform_instance_id' => 'required|exists:platform_instances,id',
|
||||||
|
'language_id' => 'required|exists:languages,id',
|
||||||
|
'description' => 'nullable|string|max:1000',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($validator->fails()) {
|
||||||
|
throw new ValidationException($validator);
|
||||||
|
}
|
||||||
|
|
||||||
|
$validated = $validator->validated();
|
||||||
|
|
||||||
|
// Get the platform instance to check for active accounts
|
||||||
|
$platformInstance = PlatformInstance::findOrFail($validated['platform_instance_id']);
|
||||||
|
|
||||||
|
// Check if there are active platform accounts for this instance
|
||||||
|
$activeAccounts = PlatformAccount::where('instance_url', $platformInstance->url)
|
||||||
|
->where('is_active', true)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
if ($activeAccounts->isEmpty()) {
|
||||||
|
return $this->sendError(
|
||||||
|
'Cannot create channel: No active platform accounts found for this instance. Please create a platform account first.',
|
||||||
|
[],
|
||||||
|
422
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$channel = PlatformChannel::create([
|
||||||
|
'platform_instance_id' => $validated['platform_instance_id'],
|
||||||
|
'channel_id' => $validated['name'], // For Lemmy, this is the community name
|
||||||
|
'name' => $validated['name'],
|
||||||
|
'display_name' => ucfirst($validated['name']),
|
||||||
|
'description' => $validated['description'] ?? null,
|
||||||
|
'language_id' => $validated['language_id'],
|
||||||
|
'is_active' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Automatically attach the first active account to the channel
|
||||||
|
$firstAccount = $activeAccounts->first();
|
||||||
|
$channel->platformAccounts()->attach($firstAccount->id, [
|
||||||
|
'is_active' => true,
|
||||||
|
'priority' => 1,
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $this->sendResponse(
|
||||||
|
new PlatformChannelResource($channel->load(['platformInstance', 'language', 'platformAccounts'])),
|
||||||
|
'Channel created successfully and linked to platform account.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create route for onboarding
|
||||||
|
*
|
||||||
|
* @throws ValidationException
|
||||||
|
*/
|
||||||
|
public function createRoute(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$validator = Validator::make($request->all(), [
|
||||||
|
'feed_id' => 'required|exists:feeds,id',
|
||||||
|
'platform_channel_id' => 'required|exists:platform_channels,id',
|
||||||
|
'priority' => 'nullable|integer|min:1|max:100',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($validator->fails()) {
|
||||||
|
throw new ValidationException($validator);
|
||||||
|
}
|
||||||
|
|
||||||
|
$validated = $validator->validated();
|
||||||
|
|
||||||
|
$route = Route::create([
|
||||||
|
'feed_id' => $validated['feed_id'],
|
||||||
|
'platform_channel_id' => $validated['platform_channel_id'],
|
||||||
|
'priority' => $validated['priority'] ?? 50,
|
||||||
|
'is_active' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Trigger article discovery when the first route is created during onboarding
|
||||||
|
// This ensures articles start being fetched immediately after setup
|
||||||
|
ArticleDiscoveryJob::dispatch();
|
||||||
|
|
||||||
|
return $this->sendResponse(
|
||||||
|
new RouteResource($route->load(['feed', 'platformChannel'])),
|
||||||
|
'Route created successfully.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark onboarding as complete
|
||||||
|
*/
|
||||||
|
public function complete(): JsonResponse
|
||||||
|
{
|
||||||
|
// Track that onboarding has been completed with a timestamp
|
||||||
|
Setting::updateOrCreate(
|
||||||
|
['key' => 'onboarding_completed'],
|
||||||
|
['value' => now()->toIso8601String()]
|
||||||
|
);
|
||||||
|
|
||||||
|
return $this->sendResponse(
|
||||||
|
['completed' => true],
|
||||||
|
'Onboarding completed successfully.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skip onboarding - user can access the app without completing setup
|
||||||
|
*/
|
||||||
|
public function skip(): JsonResponse
|
||||||
|
{
|
||||||
|
Setting::updateOrCreate(
|
||||||
|
['key' => 'onboarding_skipped'],
|
||||||
|
['value' => 'true']
|
||||||
|
);
|
||||||
|
|
||||||
|
return $this->sendResponse(
|
||||||
|
['skipped' => true],
|
||||||
|
'Onboarding skipped successfully.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset onboarding skip status - force user back to onboarding
|
||||||
|
*/
|
||||||
|
public function resetSkip(): JsonResponse
|
||||||
|
{
|
||||||
|
Setting::where('key', 'onboarding_skipped')->delete();
|
||||||
|
// Also reset completion status to allow re-onboarding
|
||||||
|
Setting::where('key', 'onboarding_completed')->delete();
|
||||||
|
|
||||||
|
return $this->sendResponse(
|
||||||
|
['reset' => true],
|
||||||
|
'Onboarding status reset successfully.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
151
app/Http/Controllers/Api/V1/PlatformAccountsController.php
Normal file
151
app/Http/Controllers/Api/V1/PlatformAccountsController.php
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Enums\PlatformEnum;
|
||||||
|
use App\Http\Resources\PlatformAccountResource;
|
||||||
|
use App\Models\PlatformAccount;
|
||||||
|
use App\Models\PlatformInstance;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
|
class PlatformAccountsController extends BaseController
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Display a listing of platform accounts
|
||||||
|
*/
|
||||||
|
public function index(): JsonResponse
|
||||||
|
{
|
||||||
|
$accounts = PlatformAccount::orderBy('platform')
|
||||||
|
->orderBy('created_at', 'desc')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return $this->sendResponse(
|
||||||
|
PlatformAccountResource::collection($accounts),
|
||||||
|
'Platform accounts retrieved successfully.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store a newly created platform account
|
||||||
|
*/
|
||||||
|
public function store(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$validated = $request->validate([
|
||||||
|
'platform' => 'required|in:lemmy,mastodon,reddit',
|
||||||
|
'instance_url' => 'required|url',
|
||||||
|
'username' => 'required|string|max:255',
|
||||||
|
'password' => 'required|string',
|
||||||
|
'settings' => 'nullable|array',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Create or find platform instance
|
||||||
|
$platformEnum = PlatformEnum::from($validated['platform']);
|
||||||
|
$instance = PlatformInstance::firstOrCreate([
|
||||||
|
'platform' => $platformEnum,
|
||||||
|
'url' => $validated['instance_url'],
|
||||||
|
], [
|
||||||
|
'name' => parse_url($validated['instance_url'], PHP_URL_HOST),
|
||||||
|
'description' => ucfirst($validated['platform']) . ' instance',
|
||||||
|
'is_active' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$account = PlatformAccount::create($validated);
|
||||||
|
|
||||||
|
// If this is the first account for this platform, make it active
|
||||||
|
if (!PlatformAccount::where('platform', $validated['platform'])
|
||||||
|
->where('is_active', true)
|
||||||
|
->exists()) {
|
||||||
|
$account->setAsActive();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->sendResponse(
|
||||||
|
new PlatformAccountResource($account),
|
||||||
|
'Platform account created successfully!',
|
||||||
|
201
|
||||||
|
);
|
||||||
|
} catch (ValidationException $e) {
|
||||||
|
return $this->sendValidationError($e->errors());
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return $this->sendError('Failed to create platform account: ' . $e->getMessage(), [], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display the specified platform account
|
||||||
|
*/
|
||||||
|
public function show(PlatformAccount $platformAccount): JsonResponse
|
||||||
|
{
|
||||||
|
return $this->sendResponse(
|
||||||
|
new PlatformAccountResource($platformAccount),
|
||||||
|
'Platform account retrieved successfully.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the specified platform account
|
||||||
|
*/
|
||||||
|
public function update(Request $request, PlatformAccount $platformAccount): JsonResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$validated = $request->validate([
|
||||||
|
'instance_url' => 'required|url',
|
||||||
|
'username' => 'required|string|max:255',
|
||||||
|
'password' => 'nullable|string',
|
||||||
|
'settings' => 'nullable|array',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Don't update password if not provided
|
||||||
|
if (empty($validated['password'])) {
|
||||||
|
unset($validated['password']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$platformAccount->update($validated);
|
||||||
|
|
||||||
|
return $this->sendResponse(
|
||||||
|
new PlatformAccountResource($platformAccount->fresh()),
|
||||||
|
'Platform account updated successfully!'
|
||||||
|
);
|
||||||
|
} catch (ValidationException $e) {
|
||||||
|
return $this->sendValidationError($e->errors());
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return $this->sendError('Failed to update platform account: ' . $e->getMessage(), [], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the specified platform account
|
||||||
|
*/
|
||||||
|
public function destroy(PlatformAccount $platformAccount): JsonResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$platformAccount->delete();
|
||||||
|
|
||||||
|
return $this->sendResponse(
|
||||||
|
null,
|
||||||
|
'Platform account deleted successfully!'
|
||||||
|
);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return $this->sendError('Failed to delete platform account: ' . $e->getMessage(), [], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set platform account as active
|
||||||
|
*/
|
||||||
|
public function setActive(PlatformAccount $platformAccount): JsonResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$platformAccount->setAsActive();
|
||||||
|
|
||||||
|
return $this->sendResponse(
|
||||||
|
new PlatformAccountResource($platformAccount->fresh()),
|
||||||
|
"Set {$platformAccount->username}@{$platformAccount->instance_url} as active for {$platformAccount->platform->value}!"
|
||||||
|
);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return $this->sendError('Failed to set platform account as active: ' . $e->getMessage(), [], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
249
app/Http/Controllers/Api/V1/PlatformChannelsController.php
Normal file
249
app/Http/Controllers/Api/V1/PlatformChannelsController.php
Normal file
|
|
@ -0,0 +1,249 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Http\Resources\PlatformChannelResource;
|
||||||
|
use App\Models\PlatformChannel;
|
||||||
|
use App\Models\PlatformAccount;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
|
class PlatformChannelsController extends BaseController
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Display a listing of platform channels
|
||||||
|
*/
|
||||||
|
public function index(): JsonResponse
|
||||||
|
{
|
||||||
|
$channels = PlatformChannel::with(['platformInstance', 'platformAccounts'])
|
||||||
|
->orderBy('is_active', 'desc')
|
||||||
|
->orderBy('name')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return $this->sendResponse(
|
||||||
|
PlatformChannelResource::collection($channels),
|
||||||
|
'Platform channels retrieved successfully.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store a newly created platform channel
|
||||||
|
*/
|
||||||
|
public function store(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$validated = $request->validate([
|
||||||
|
'platform_instance_id' => 'required|exists:platform_instances,id',
|
||||||
|
'channel_id' => 'required|string|max:255',
|
||||||
|
'name' => 'required|string|max:255',
|
||||||
|
'display_name' => 'nullable|string|max:255',
|
||||||
|
'description' => 'nullable|string',
|
||||||
|
'is_active' => 'boolean',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$validated['is_active'] = $validated['is_active'] ?? true;
|
||||||
|
|
||||||
|
// Get the platform instance to check for active accounts
|
||||||
|
$platformInstance = \App\Models\PlatformInstance::findOrFail($validated['platform_instance_id']);
|
||||||
|
|
||||||
|
// Check if there are active platform accounts for this instance
|
||||||
|
$activeAccounts = PlatformAccount::where('instance_url', $platformInstance->url)
|
||||||
|
->where('is_active', true)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
if ($activeAccounts->isEmpty()) {
|
||||||
|
return $this->sendError(
|
||||||
|
'Cannot create channel: No active platform accounts found for this instance. Please create a platform account first.',
|
||||||
|
[],
|
||||||
|
422
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$channel = PlatformChannel::create($validated);
|
||||||
|
|
||||||
|
// Automatically attach the first active account to the channel
|
||||||
|
$firstAccount = $activeAccounts->first();
|
||||||
|
$channel->platformAccounts()->attach($firstAccount->id, [
|
||||||
|
'is_active' => true,
|
||||||
|
'priority' => 1,
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $this->sendResponse(
|
||||||
|
new PlatformChannelResource($channel->load(['platformInstance', 'platformAccounts'])),
|
||||||
|
'Platform channel created successfully and linked to platform account!',
|
||||||
|
201
|
||||||
|
);
|
||||||
|
} catch (ValidationException $e) {
|
||||||
|
return $this->sendValidationError($e->errors());
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return $this->sendError('Failed to create platform channel: ' . $e->getMessage(), [], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display the specified platform channel
|
||||||
|
*/
|
||||||
|
public function show(PlatformChannel $platformChannel): JsonResponse
|
||||||
|
{
|
||||||
|
return $this->sendResponse(
|
||||||
|
new PlatformChannelResource($platformChannel->load('platformInstance')),
|
||||||
|
'Platform channel retrieved successfully.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the specified platform channel
|
||||||
|
*/
|
||||||
|
public function update(Request $request, PlatformChannel $platformChannel): JsonResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$validated = $request->validate([
|
||||||
|
'name' => 'required|string|max:255',
|
||||||
|
'display_name' => 'nullable|string|max:255',
|
||||||
|
'description' => 'nullable|string',
|
||||||
|
'is_active' => 'boolean',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$platformChannel->update($validated);
|
||||||
|
|
||||||
|
return $this->sendResponse(
|
||||||
|
new PlatformChannelResource($platformChannel->fresh(['platformInstance'])),
|
||||||
|
'Platform channel updated successfully!'
|
||||||
|
);
|
||||||
|
} catch (ValidationException $e) {
|
||||||
|
return $this->sendValidationError($e->errors());
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return $this->sendError('Failed to update platform channel: ' . $e->getMessage(), [], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the specified platform channel
|
||||||
|
*/
|
||||||
|
public function destroy(PlatformChannel $platformChannel): JsonResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$platformChannel->delete();
|
||||||
|
|
||||||
|
return $this->sendResponse(
|
||||||
|
null,
|
||||||
|
'Platform channel deleted successfully!'
|
||||||
|
);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return $this->sendError('Failed to delete platform channel: ' . $e->getMessage(), [], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle platform channel active status
|
||||||
|
*/
|
||||||
|
public function toggle(PlatformChannel $channel): JsonResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$newStatus = !$channel->is_active;
|
||||||
|
$channel->update(['is_active' => $newStatus]);
|
||||||
|
|
||||||
|
$status = $newStatus ? 'activated' : 'deactivated';
|
||||||
|
|
||||||
|
return $this->sendResponse(
|
||||||
|
new PlatformChannelResource($channel->fresh(['platformInstance', 'platformAccounts'])),
|
||||||
|
"Platform channel {$status} successfully!"
|
||||||
|
);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return $this->sendError('Failed to toggle platform channel status: ' . $e->getMessage(), [], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attach a platform account to a channel
|
||||||
|
*/
|
||||||
|
public function attachAccount(PlatformChannel $channel, Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$validated = $request->validate([
|
||||||
|
'platform_account_id' => 'required|exists:platform_accounts,id',
|
||||||
|
'is_active' => 'boolean',
|
||||||
|
'priority' => 'nullable|integer|min:1|max:100',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$platformAccount = PlatformAccount::findOrFail($validated['platform_account_id']);
|
||||||
|
|
||||||
|
// Check if account is already attached
|
||||||
|
if ($channel->platformAccounts()->where('platform_account_id', $platformAccount->id)->exists()) {
|
||||||
|
return $this->sendError('Platform account is already attached to this channel.', [], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$channel->platformAccounts()->attach($platformAccount->id, [
|
||||||
|
'is_active' => $validated['is_active'] ?? true,
|
||||||
|
'priority' => $validated['priority'] ?? 1,
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $this->sendResponse(
|
||||||
|
new PlatformChannelResource($channel->fresh(['platformInstance', 'platformAccounts'])),
|
||||||
|
'Platform account attached to channel successfully!'
|
||||||
|
);
|
||||||
|
} catch (ValidationException $e) {
|
||||||
|
return $this->sendValidationError($e->errors());
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return $this->sendError('Failed to attach platform account: ' . $e->getMessage(), [], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detach a platform account from a channel
|
||||||
|
*/
|
||||||
|
public function detachAccount(PlatformChannel $channel, PlatformAccount $account): JsonResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
if (!$channel->platformAccounts()->where('platform_account_id', $account->id)->exists()) {
|
||||||
|
return $this->sendError('Platform account is not attached to this channel.', [], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$channel->platformAccounts()->detach($account->id);
|
||||||
|
|
||||||
|
return $this->sendResponse(
|
||||||
|
new PlatformChannelResource($channel->fresh(['platformInstance', 'platformAccounts'])),
|
||||||
|
'Platform account detached from channel successfully!'
|
||||||
|
);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return $this->sendError('Failed to detach platform account: ' . $e->getMessage(), [], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update platform account-channel relationship settings
|
||||||
|
*/
|
||||||
|
public function updateAccountRelation(PlatformChannel $channel, PlatformAccount $account, Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$validated = $request->validate([
|
||||||
|
'is_active' => 'boolean',
|
||||||
|
'priority' => 'nullable|integer|min:1|max:100',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!$channel->platformAccounts()->where('platform_account_id', $account->id)->exists()) {
|
||||||
|
return $this->sendError('Platform account is not attached to this channel.', [], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$channel->platformAccounts()->updateExistingPivot($account->id, [
|
||||||
|
'is_active' => $validated['is_active'] ?? true,
|
||||||
|
'priority' => $validated['priority'] ?? 1,
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $this->sendResponse(
|
||||||
|
new PlatformChannelResource($channel->fresh(['platformInstance', 'platformAccounts'])),
|
||||||
|
'Platform account-channel relationship updated successfully!'
|
||||||
|
);
|
||||||
|
} catch (ValidationException $e) {
|
||||||
|
return $this->sendValidationError($e->errors());
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return $this->sendError('Failed to update platform account relationship: ' . $e->getMessage(), [], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
174
app/Http/Controllers/Api/V1/RoutingController.php
Normal file
174
app/Http/Controllers/Api/V1/RoutingController.php
Normal file
|
|
@ -0,0 +1,174 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Http\Resources\RouteResource;
|
||||||
|
use App\Models\Feed;
|
||||||
|
use App\Models\PlatformChannel;
|
||||||
|
use App\Models\Route;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
|
class RoutingController extends BaseController
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Display a listing of routing configurations
|
||||||
|
*/
|
||||||
|
public function index(): JsonResponse
|
||||||
|
{
|
||||||
|
$routes = Route::with(['feed', 'platformChannel', 'keywords'])
|
||||||
|
->orderBy('is_active', 'desc')
|
||||||
|
->orderBy('priority', 'asc')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return $this->sendResponse(
|
||||||
|
RouteResource::collection($routes),
|
||||||
|
'Routing configurations retrieved successfully.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store a newly created routing configuration
|
||||||
|
*/
|
||||||
|
public function store(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$validated = $request->validate([
|
||||||
|
'feed_id' => 'required|exists:feeds,id',
|
||||||
|
'platform_channel_id' => 'required|exists:platform_channels,id',
|
||||||
|
'is_active' => 'boolean',
|
||||||
|
'priority' => 'nullable|integer|min:0',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$validated['is_active'] = $validated['is_active'] ?? true;
|
||||||
|
$validated['priority'] = $validated['priority'] ?? 0;
|
||||||
|
|
||||||
|
$route = Route::create($validated);
|
||||||
|
|
||||||
|
return $this->sendResponse(
|
||||||
|
new RouteResource($route->load(['feed', 'platformChannel', 'keywords'])),
|
||||||
|
'Routing configuration created successfully!',
|
||||||
|
201
|
||||||
|
);
|
||||||
|
} catch (ValidationException $e) {
|
||||||
|
return $this->sendValidationError($e->errors());
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return $this->sendError('Failed to create routing configuration: ' . $e->getMessage(), [], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display the specified routing configuration
|
||||||
|
*/
|
||||||
|
public function show(Feed $feed, PlatformChannel $channel): JsonResponse
|
||||||
|
{
|
||||||
|
$route = $this->findRoute($feed, $channel);
|
||||||
|
|
||||||
|
if (!$route) {
|
||||||
|
return $this->sendNotFound('Routing configuration not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$route->load(['feed', 'platformChannel', 'keywords']);
|
||||||
|
|
||||||
|
return $this->sendResponse(
|
||||||
|
new RouteResource($route),
|
||||||
|
'Routing configuration retrieved successfully.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the specified routing configuration
|
||||||
|
*/
|
||||||
|
public function update(Request $request, Feed $feed, PlatformChannel $channel): JsonResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$route = $this->findRoute($feed, $channel);
|
||||||
|
|
||||||
|
if (!$route) {
|
||||||
|
return $this->sendNotFound('Routing configuration not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'is_active' => 'boolean',
|
||||||
|
'priority' => 'nullable|integer|min:0',
|
||||||
|
]);
|
||||||
|
|
||||||
|
Route::where('feed_id', $feed->id)
|
||||||
|
->where('platform_channel_id', $channel->id)
|
||||||
|
->update($validated);
|
||||||
|
|
||||||
|
return $this->sendResponse(
|
||||||
|
new RouteResource($route->fresh(['feed', 'platformChannel', 'keywords'])),
|
||||||
|
'Routing configuration updated successfully!'
|
||||||
|
);
|
||||||
|
} catch (ValidationException $e) {
|
||||||
|
return $this->sendValidationError($e->errors());
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return $this->sendError('Failed to update routing configuration: ' . $e->getMessage(), [], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the specified routing configuration
|
||||||
|
*/
|
||||||
|
public function destroy(Feed $feed, PlatformChannel $channel): JsonResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$route = $this->findRoute($feed, $channel);
|
||||||
|
|
||||||
|
if (!$route) {
|
||||||
|
return $this->sendNotFound('Routing configuration not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
Route::where('feed_id', $feed->id)
|
||||||
|
->where('platform_channel_id', $channel->id)
|
||||||
|
->delete();
|
||||||
|
|
||||||
|
return $this->sendResponse(
|
||||||
|
null,
|
||||||
|
'Routing configuration deleted successfully!'
|
||||||
|
);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return $this->sendError('Failed to delete routing configuration: ' . $e->getMessage(), [], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle routing configuration active status
|
||||||
|
*/
|
||||||
|
public function toggle(Feed $feed, PlatformChannel $channel): JsonResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$route = $this->findRoute($feed, $channel);
|
||||||
|
|
||||||
|
if (!$route) {
|
||||||
|
return $this->sendNotFound('Routing configuration not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$newStatus = !$route->is_active;
|
||||||
|
Route::where('feed_id', $feed->id)
|
||||||
|
->where('platform_channel_id', $channel->id)
|
||||||
|
->update(['is_active' => $newStatus]);
|
||||||
|
|
||||||
|
$status = $newStatus ? 'activated' : 'deactivated';
|
||||||
|
|
||||||
|
return $this->sendResponse(
|
||||||
|
new RouteResource($route->fresh(['feed', 'platformChannel', 'keywords'])),
|
||||||
|
"Routing configuration {$status} successfully!"
|
||||||
|
);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return $this->sendError('Failed to toggle routing configuration status: ' . $e->getMessage(), [], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a route by feed and channel
|
||||||
|
*/
|
||||||
|
private function findRoute(Feed $feed, PlatformChannel $channel): ?Route
|
||||||
|
{
|
||||||
|
return Route::where('feed_id', $feed->id)
|
||||||
|
->where('platform_channel_id', $channel->id)
|
||||||
|
->first();
|
||||||
|
}
|
||||||
|
}
|
||||||
63
app/Http/Controllers/Api/V1/SettingsController.php
Normal file
63
app/Http/Controllers/Api/V1/SettingsController.php
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Models\Setting;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
|
class SettingsController extends BaseController
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Display current settings
|
||||||
|
*/
|
||||||
|
public function index(): JsonResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$settings = [
|
||||||
|
'article_processing_enabled' => Setting::isArticleProcessingEnabled(),
|
||||||
|
'publishing_approvals_enabled' => Setting::isPublishingApprovalsEnabled(),
|
||||||
|
];
|
||||||
|
|
||||||
|
return $this->sendResponse($settings, 'Settings retrieved successfully.');
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return $this->sendError('Failed to retrieve settings: ' . $e->getMessage(), [], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update settings
|
||||||
|
*/
|
||||||
|
public function update(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$validated = $request->validate([
|
||||||
|
'article_processing_enabled' => 'boolean',
|
||||||
|
'enable_publishing_approvals' => 'boolean',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (isset($validated['article_processing_enabled'])) {
|
||||||
|
Setting::setArticleProcessingEnabled($validated['article_processing_enabled']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($validated['enable_publishing_approvals'])) {
|
||||||
|
Setting::setPublishingApprovalsEnabled($validated['enable_publishing_approvals']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$updatedSettings = [
|
||||||
|
'article_processing_enabled' => Setting::isArticleProcessingEnabled(),
|
||||||
|
'publishing_approvals_enabled' => Setting::isPublishingApprovalsEnabled(),
|
||||||
|
];
|
||||||
|
|
||||||
|
return $this->sendResponse(
|
||||||
|
$updatedSettings,
|
||||||
|
'Settings updated successfully.'
|
||||||
|
);
|
||||||
|
} catch (ValidationException $e) {
|
||||||
|
return $this->sendValidationError($e->errors());
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return $this->sendError('Failed to update settings: ' . $e->getMessage(), [], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Controllers;
|
|
||||||
|
|
||||||
use App\Models\Article;
|
|
||||||
use Illuminate\Contracts\View\View;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
|
|
||||||
class ArticlesController extends Controller
|
|
||||||
{
|
|
||||||
public function __invoke(Request $request): View
|
|
||||||
{
|
|
||||||
$articles = Article::with('articlePublication')
|
|
||||||
->orderBy('created_at', 'desc')
|
|
||||||
->paginate(15);
|
|
||||||
|
|
||||||
return view('pages.articles.index', compact('articles'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -7,21 +7,16 @@
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\View\View;
|
||||||
use Inertia\Inertia;
|
|
||||||
use Inertia\Response;
|
|
||||||
|
|
||||||
class AuthenticatedSessionController extends Controller
|
class AuthenticatedSessionController extends Controller
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Show the login page.
|
* Display the login view.
|
||||||
*/
|
*/
|
||||||
public function create(Request $request): Response
|
public function create(): View
|
||||||
{
|
{
|
||||||
return Inertia::render('auth/login', [
|
return view('auth.login');
|
||||||
'canResetPassword' => Route::has('password.request'),
|
|
||||||
'status' => $request->session()->get('status'),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -44,6 +39,7 @@ public function destroy(Request $request): RedirectResponse
|
||||||
Auth::guard('web')->logout();
|
Auth::guard('web')->logout();
|
||||||
|
|
||||||
$request->session()->invalidate();
|
$request->session()->invalidate();
|
||||||
|
|
||||||
$request->session()->regenerateToken();
|
$request->session()->regenerateToken();
|
||||||
|
|
||||||
return redirect('/');
|
return redirect('/');
|
||||||
|
|
|
||||||
|
|
@ -7,17 +7,16 @@
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
use Inertia\Inertia;
|
use Illuminate\View\View;
|
||||||
use Inertia\Response;
|
|
||||||
|
|
||||||
class ConfirmablePasswordController extends Controller
|
class ConfirmablePasswordController extends Controller
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Show the confirm password page.
|
* Show the confirm password view.
|
||||||
*/
|
*/
|
||||||
public function show(): Response
|
public function show(): View
|
||||||
{
|
{
|
||||||
return Inertia::render('auth/confirm-password');
|
return view('auth.confirm-password');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -5,18 +5,17 @@
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Inertia\Inertia;
|
use Illuminate\View\View;
|
||||||
use Inertia\Response;
|
|
||||||
|
|
||||||
class EmailVerificationPromptController extends Controller
|
class EmailVerificationPromptController extends Controller
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Show the email verification prompt page.
|
* Display the email verification prompt.
|
||||||
*/
|
*/
|
||||||
public function __invoke(Request $request): Response|RedirectResponse
|
public function __invoke(Request $request): RedirectResponse|View
|
||||||
{
|
{
|
||||||
return $request->user()->hasVerifiedEmail()
|
return $request->user()->hasVerifiedEmail()
|
||||||
? redirect()->intended(route('dashboard', absolute: false))
|
? redirect()->intended(route('dashboard', absolute: false))
|
||||||
: Inertia::render('auth/verify-email', ['status' => $request->session()->get('status')]);
|
: view('auth.verify-email');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
namespace App\Http\Controllers\Auth;
|
namespace App\Http\Controllers\Auth;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\User;
|
||||||
use Illuminate\Auth\Events\PasswordReset;
|
use Illuminate\Auth\Events\PasswordReset;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
|
@ -10,21 +11,16 @@
|
||||||
use Illuminate\Support\Facades\Password;
|
use Illuminate\Support\Facades\Password;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Illuminate\Validation\Rules;
|
use Illuminate\Validation\Rules;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\View\View;
|
||||||
use Inertia\Inertia;
|
|
||||||
use Inertia\Response;
|
|
||||||
|
|
||||||
class NewPasswordController extends Controller
|
class NewPasswordController extends Controller
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Show the password reset page.
|
* Display the password reset view.
|
||||||
*/
|
*/
|
||||||
public function create(Request $request): Response
|
public function create(Request $request): View
|
||||||
{
|
{
|
||||||
return Inertia::render('auth/reset-password', [
|
return view('auth.reset-password', ['request' => $request]);
|
||||||
'email' => $request->email,
|
|
||||||
'token' => $request->route('token'),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -35,8 +31,8 @@ public function create(Request $request): Response
|
||||||
public function store(Request $request): RedirectResponse
|
public function store(Request $request): RedirectResponse
|
||||||
{
|
{
|
||||||
$request->validate([
|
$request->validate([
|
||||||
'token' => 'required',
|
'token' => ['required'],
|
||||||
'email' => 'required|email',
|
'email' => ['required', 'email'],
|
||||||
'password' => ['required', 'confirmed', Rules\Password::defaults()],
|
'password' => ['required', 'confirmed', Rules\Password::defaults()],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
@ -45,7 +41,7 @@ public function store(Request $request): RedirectResponse
|
||||||
// database. Otherwise we will parse the error and return the response.
|
// database. Otherwise we will parse the error and return the response.
|
||||||
$status = Password::reset(
|
$status = Password::reset(
|
||||||
$request->only('email', 'password', 'password_confirmation', 'token'),
|
$request->only('email', 'password', 'password_confirmation', 'token'),
|
||||||
function ($user) use ($request) {
|
function (User $user) use ($request) {
|
||||||
$user->forceFill([
|
$user->forceFill([
|
||||||
'password' => Hash::make($request->password),
|
'password' => Hash::make($request->password),
|
||||||
'remember_token' => Str::random(60),
|
'remember_token' => Str::random(60),
|
||||||
|
|
@ -58,12 +54,9 @@ function ($user) use ($request) {
|
||||||
// If the password was successfully reset, we will redirect the user back to
|
// 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
|
// 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.
|
// redirect them back to where they came from with their error message.
|
||||||
if ($status == Password::PasswordReset) {
|
return $status == Password::PASSWORD_RESET
|
||||||
return to_route('login')->with('status', __($status));
|
? redirect()->route('login')->with('status', __($status))
|
||||||
}
|
: back()->withInput($request->only('email'))
|
||||||
|
->withErrors(['email' => __($status)]);
|
||||||
throw ValidationException::withMessages([
|
|
||||||
'email' => [__($status)],
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,31 +1,21 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Http\Controllers\Settings;
|
namespace App\Http\Controllers\Auth;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
use Illuminate\Validation\Rules\Password;
|
use Illuminate\Validation\Rules\Password;
|
||||||
use Inertia\Inertia;
|
|
||||||
use Inertia\Response;
|
|
||||||
|
|
||||||
class PasswordController extends Controller
|
class PasswordController extends Controller
|
||||||
{
|
{
|
||||||
/**
|
|
||||||
* Show the user's password settings page.
|
|
||||||
*/
|
|
||||||
public function edit(): Response
|
|
||||||
{
|
|
||||||
return Inertia::render('settings/password');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update the user's password.
|
* Update the user's password.
|
||||||
*/
|
*/
|
||||||
public function update(Request $request): RedirectResponse
|
public function update(Request $request): RedirectResponse
|
||||||
{
|
{
|
||||||
$validated = $request->validate([
|
$validated = $request->validateWithBag('updatePassword', [
|
||||||
'current_password' => ['required', 'current_password'],
|
'current_password' => ['required', 'current_password'],
|
||||||
'password' => ['required', Password::defaults(), 'confirmed'],
|
'password' => ['required', Password::defaults(), 'confirmed'],
|
||||||
]);
|
]);
|
||||||
|
|
@ -34,6 +24,6 @@ public function update(Request $request): RedirectResponse
|
||||||
'password' => Hash::make($validated['password']),
|
'password' => Hash::make($validated['password']),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return back();
|
return back()->with('status', 'password-updated');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -6,19 +6,16 @@
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Password;
|
use Illuminate\Support\Facades\Password;
|
||||||
use Inertia\Inertia;
|
use Illuminate\View\View;
|
||||||
use Inertia\Response;
|
|
||||||
|
|
||||||
class PasswordResetLinkController extends Controller
|
class PasswordResetLinkController extends Controller
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Show the password reset link request page.
|
* Display the password reset link request view.
|
||||||
*/
|
*/
|
||||||
public function create(Request $request): Response
|
public function create(): View
|
||||||
{
|
{
|
||||||
return Inertia::render('auth/forgot-password', [
|
return view('auth.forgot-password');
|
||||||
'status' => $request->session()->get('status'),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -29,13 +26,19 @@ public function create(Request $request): Response
|
||||||
public function store(Request $request): RedirectResponse
|
public function store(Request $request): RedirectResponse
|
||||||
{
|
{
|
||||||
$request->validate([
|
$request->validate([
|
||||||
'email' => 'required|email',
|
'email' => ['required', 'email'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
Password::sendResetLink(
|
// 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')
|
$request->only('email')
|
||||||
);
|
);
|
||||||
|
|
||||||
return back()->with('status', __('A reset link will be sent if the account exists.'));
|
return $status == Password::RESET_LINK_SENT
|
||||||
|
? back()->with('status', __($status))
|
||||||
|
: back()->withInput($request->only('email'))
|
||||||
|
->withErrors(['email' => __($status)]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,17 +10,16 @@
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
use Illuminate\Validation\Rules;
|
use Illuminate\Validation\Rules;
|
||||||
use Inertia\Inertia;
|
use Illuminate\View\View;
|
||||||
use Inertia\Response;
|
|
||||||
|
|
||||||
class RegisteredUserController extends Controller
|
class RegisteredUserController extends Controller
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Show the registration page.
|
* Display the registration view.
|
||||||
*/
|
*/
|
||||||
public function create(): Response
|
public function create(): View
|
||||||
{
|
{
|
||||||
return Inertia::render('auth/register');
|
return view('auth.register');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -31,8 +30,8 @@ public function create(): Response
|
||||||
public function store(Request $request): RedirectResponse
|
public function store(Request $request): RedirectResponse
|
||||||
{
|
{
|
||||||
$request->validate([
|
$request->validate([
|
||||||
'name' => 'required|string|max:255',
|
'name' => ['required', 'string', 'max:255'],
|
||||||
'email' => 'required|string|lowercase|email|max:255|unique:'.User::class,
|
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:'.User::class],
|
||||||
'password' => ['required', 'confirmed', Rules\Password::defaults()],
|
'password' => ['required', 'confirmed', Rules\Password::defaults()],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
@ -46,6 +45,6 @@ public function store(Request $request): RedirectResponse
|
||||||
|
|
||||||
Auth::login($user);
|
Auth::login($user);
|
||||||
|
|
||||||
return redirect()->intended(route('dashboard', absolute: false));
|
return redirect(route('dashboard', absolute: false));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,10 +19,7 @@ public function __invoke(EmailVerificationRequest $request): RedirectResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($request->user()->markEmailAsVerified()) {
|
if ($request->user()->markEmailAsVerified()) {
|
||||||
/** @var \Illuminate\Contracts\Auth\MustVerifyEmail $user */
|
event(new Verified($request->user()));
|
||||||
$user = $request->user();
|
|
||||||
|
|
||||||
event(new Verified($user));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return redirect()->intended(route('dashboard', absolute: false).'?verified=1');
|
return redirect()->intended(route('dashboard', absolute: false).'?verified=1');
|
||||||
|
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Controllers;
|
|
||||||
|
|
||||||
use App\Models\Log;
|
|
||||||
use Illuminate\Contracts\View\View;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
|
|
||||||
class LogsController extends Controller
|
|
||||||
{
|
|
||||||
public function __invoke(Request $request): View
|
|
||||||
{
|
|
||||||
$logs = Log::all()->sortByDesc('created_at');
|
|
||||||
|
|
||||||
return view('pages.logs.index', compact('logs'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,31 +1,28 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Http\Controllers\Settings;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Requests\ProfileUpdateRequest;
|
||||||
use App\Http\Requests\Settings\ProfileUpdateRequest;
|
|
||||||
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Inertia\Inertia;
|
use Illuminate\Support\Facades\Redirect;
|
||||||
use Inertia\Response;
|
use Illuminate\View\View;
|
||||||
|
|
||||||
class ProfileController extends Controller
|
class ProfileController extends Controller
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Show the user's profile settings page.
|
* Display the user's profile form.
|
||||||
*/
|
*/
|
||||||
public function edit(Request $request): Response
|
public function edit(Request $request): View
|
||||||
{
|
{
|
||||||
return Inertia::render('settings/profile', [
|
return view('profile.edit', [
|
||||||
'mustVerifyEmail' => $request->user() instanceof MustVerifyEmail,
|
'user' => $request->user(),
|
||||||
'status' => $request->session()->get('status'),
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update the user's profile settings.
|
* Update the user's profile information.
|
||||||
*/
|
*/
|
||||||
public function update(ProfileUpdateRequest $request): RedirectResponse
|
public function update(ProfileUpdateRequest $request): RedirectResponse
|
||||||
{
|
{
|
||||||
|
|
@ -37,7 +34,7 @@ public function update(ProfileUpdateRequest $request): RedirectResponse
|
||||||
|
|
||||||
$request->user()->save();
|
$request->user()->save();
|
||||||
|
|
||||||
return to_route('profile.edit');
|
return Redirect::route('profile.edit')->with('status', 'profile-updated');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -45,7 +42,7 @@ public function update(ProfileUpdateRequest $request): RedirectResponse
|
||||||
*/
|
*/
|
||||||
public function destroy(Request $request): RedirectResponse
|
public function destroy(Request $request): RedirectResponse
|
||||||
{
|
{
|
||||||
$request->validate([
|
$request->validateWithBag('userDeletion', [
|
||||||
'password' => ['required', 'current_password'],
|
'password' => ['required', 'current_password'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
@ -58,6 +55,6 @@ public function destroy(Request $request): RedirectResponse
|
||||||
$request->session()->invalidate();
|
$request->session()->invalidate();
|
||||||
$request->session()->regenerateToken();
|
$request->session()->regenerateToken();
|
||||||
|
|
||||||
return redirect('/');
|
return Redirect::to('/');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
29
app/Http/Middleware/EnsureOnboardingComplete.php
Normal file
29
app/Http/Middleware/EnsureOnboardingComplete.php
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use App\Services\OnboardingService;
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class EnsureOnboardingComplete
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private OnboardingService $onboardingService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle an incoming request.
|
||||||
|
*
|
||||||
|
* Redirect to onboarding if the user hasn't completed setup.
|
||||||
|
*/
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
|
if ($this->onboardingService->needsOnboarding()) {
|
||||||
|
return redirect()->route('onboarding');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,10 +2,8 @@
|
||||||
|
|
||||||
namespace App\Http\Middleware;
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
use Illuminate\Foundation\Inspiring;
|
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Inertia\Middleware;
|
use Inertia\Middleware;
|
||||||
use Tighten\Ziggy\Ziggy;
|
|
||||||
|
|
||||||
class HandleInertiaRequests extends Middleware
|
class HandleInertiaRequests extends Middleware
|
||||||
{
|
{
|
||||||
|
|
@ -37,20 +35,8 @@ public function version(Request $request): ?string
|
||||||
*/
|
*/
|
||||||
public function share(Request $request): array
|
public function share(Request $request): array
|
||||||
{
|
{
|
||||||
[$message, $author] = str(Inspiring::quotes()->random())->explode('-');
|
return array_merge(parent::share($request), [
|
||||||
|
//
|
||||||
return [
|
]);
|
||||||
...parent::share($request),
|
|
||||||
'name' => config('app.name'),
|
|
||||||
'quote' => ['message' => trim($message), 'author' => trim($author)],
|
|
||||||
'auth' => [
|
|
||||||
'user' => $request->user(),
|
|
||||||
],
|
|
||||||
'ziggy' => fn (): array => [
|
|
||||||
...(new Ziggy)->toArray(),
|
|
||||||
'location' => $request->url(),
|
|
||||||
],
|
|
||||||
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
29
app/Http/Middleware/RedirectIfOnboardingComplete.php
Normal file
29
app/Http/Middleware/RedirectIfOnboardingComplete.php
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use App\Services\OnboardingService;
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class RedirectIfOnboardingComplete
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private OnboardingService $onboardingService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle an incoming request.
|
||||||
|
*
|
||||||
|
* Redirect to dashboard if onboarding is already complete.
|
||||||
|
*/
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
|
if (!$this->onboardingService->needsOnboarding()) {
|
||||||
|
return redirect()->route('dashboard');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -45,7 +45,7 @@ public function authenticate(): void
|
||||||
RateLimiter::hit($this->throttleKey());
|
RateLimiter::hit($this->throttleKey());
|
||||||
|
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
'email' => __('auth.failed'),
|
'email' => trans('auth.failed'),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -68,7 +68,7 @@ public function ensureIsNotRateLimited(): void
|
||||||
$seconds = RateLimiter::availableIn($this->throttleKey());
|
$seconds = RateLimiter::availableIn($this->throttleKey());
|
||||||
|
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
'email' => __('auth.throttle', [
|
'email' => trans('auth.throttle', [
|
||||||
'seconds' => $seconds,
|
'seconds' => $seconds,
|
||||||
'minutes' => ceil($seconds / 60),
|
'minutes' => ceil($seconds / 60),
|
||||||
]),
|
]),
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Http\Requests\Settings;
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Contracts\Validation\ValidationRule;
|
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
|
@ -12,13 +11,12 @@ class ProfileUpdateRequest extends FormRequest
|
||||||
/**
|
/**
|
||||||
* Get the validation rules that apply to the request.
|
* Get the validation rules that apply to the request.
|
||||||
*
|
*
|
||||||
* @return array<string, ValidationRule|array<mixed>|string>
|
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||||
*/
|
*/
|
||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'name' => ['required', 'string', 'max:255'],
|
'name' => ['required', 'string', 'max:255'],
|
||||||
|
|
||||||
'email' => [
|
'email' => [
|
||||||
'required',
|
'required',
|
||||||
'string',
|
'string',
|
||||||
27
app/Http/Requests/StoreFeedRequest.php
Normal file
27
app/Http/Requests/StoreFeedRequest.php
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class StoreFeedRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => 'required|string|max:255',
|
||||||
|
'provider' => 'required|in:vrt,belga',
|
||||||
|
'language_id' => 'required|exists:languages,id',
|
||||||
|
'description' => 'nullable|string',
|
||||||
|
'is_active' => 'boolean'
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
29
app/Http/Requests/UpdateFeedRequest.php
Normal file
29
app/Http/Requests/UpdateFeedRequest.php
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use App\Models\Feed;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class UpdateFeedRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => 'required|string|max:255',
|
||||||
|
'url' => 'required|url|unique:feeds,url,' . ($this->route('feed') instanceof Feed ? (string)$this->route('feed')->id : (string)$this->route('feed')),
|
||||||
|
'type' => 'required|in:website,rss',
|
||||||
|
'language_id' => 'required|exists:languages,id',
|
||||||
|
'description' => 'nullable|string',
|
||||||
|
'is_active' => 'boolean'
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
26
app/Http/Resources/ArticlePublicationResource.php
Normal file
26
app/Http/Resources/ArticlePublicationResource.php
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
class ArticlePublicationResource extends JsonResource
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Transform the resource into an array.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'article_id' => $this->article_id,
|
||||||
|
'status' => $this->status,
|
||||||
|
'published_at' => $this->published_at?->toISOString(),
|
||||||
|
'created_at' => $this->created_at->toISOString(),
|
||||||
|
'updated_at' => $this->updated_at->toISOString(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
35
app/Http/Resources/ArticleResource.php
Normal file
35
app/Http/Resources/ArticleResource.php
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @property int $id
|
||||||
|
*/
|
||||||
|
class ArticleResource extends JsonResource
|
||||||
|
{
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'feed_id' => $this->feed_id,
|
||||||
|
'url' => $this->url,
|
||||||
|
'title' => $this->title,
|
||||||
|
'description' => $this->description,
|
||||||
|
'is_valid' => $this->is_valid,
|
||||||
|
'is_duplicate' => $this->is_duplicate,
|
||||||
|
'approval_status' => $this->approval_status,
|
||||||
|
'approved_at' => $this->approved_at?->toISOString(),
|
||||||
|
'approved_by' => $this->approved_by,
|
||||||
|
'fetched_at' => $this->fetched_at?->toISOString(),
|
||||||
|
'validated_at' => $this->validated_at?->toISOString(),
|
||||||
|
'is_published' => $this->relationLoaded('articlePublication') && $this->articlePublication !== null,
|
||||||
|
'created_at' => $this->created_at->toISOString(),
|
||||||
|
'updated_at' => $this->updated_at->toISOString(),
|
||||||
|
'feed' => new FeedResource($this->whenLoaded('feed')),
|
||||||
|
'article_publication' => new ArticlePublicationResource($this->whenLoaded('articlePublication')),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
34
app/Http/Resources/FeedResource.php
Normal file
34
app/Http/Resources/FeedResource.php
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
class FeedResource extends JsonResource
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Transform the resource into an array.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'name' => $this->name,
|
||||||
|
'url' => $this->url,
|
||||||
|
'type' => $this->type,
|
||||||
|
'provider' => $this->provider,
|
||||||
|
'language_id' => $this->language_id,
|
||||||
|
'is_active' => $this->is_active,
|
||||||
|
'description' => $this->description,
|
||||||
|
'created_at' => $this->created_at->toISOString(),
|
||||||
|
'updated_at' => $this->updated_at->toISOString(),
|
||||||
|
'articles_count' => $this->when(
|
||||||
|
$request->routeIs('api.feeds.*') && isset($this->articles_count),
|
||||||
|
$this->articles_count
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
31
app/Http/Resources/PlatformAccountResource.php
Normal file
31
app/Http/Resources/PlatformAccountResource.php
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
class PlatformAccountResource extends JsonResource
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Transform the resource into an array.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'platform' => $this->platform->value,
|
||||||
|
'instance_url' => $this->instance_url,
|
||||||
|
'username' => $this->username,
|
||||||
|
'settings' => $this->settings,
|
||||||
|
'is_active' => $this->is_active,
|
||||||
|
'last_tested_at' => $this->last_tested_at?->toISOString(),
|
||||||
|
'status' => $this->status,
|
||||||
|
'created_at' => $this->created_at->toISOString(),
|
||||||
|
'updated_at' => $this->updated_at->toISOString(),
|
||||||
|
'channels' => PlatformChannelResource::collection($this->whenLoaded('channels')),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
33
app/Http/Resources/PlatformChannelResource.php
Normal file
33
app/Http/Resources/PlatformChannelResource.php
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
class PlatformChannelResource extends JsonResource
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Transform the resource into an array.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'platform_instance_id' => $this->platform_instance_id,
|
||||||
|
'channel_id' => $this->channel_id,
|
||||||
|
'name' => $this->name,
|
||||||
|
'display_name' => $this->display_name,
|
||||||
|
'description' => $this->description,
|
||||||
|
'language_id' => $this->language_id,
|
||||||
|
'is_active' => $this->is_active,
|
||||||
|
'created_at' => $this->created_at->toISOString(),
|
||||||
|
'updated_at' => $this->updated_at->toISOString(),
|
||||||
|
'platform_instance' => new PlatformInstanceResource($this->whenLoaded('platformInstance')),
|
||||||
|
'platform_accounts' => PlatformAccountResource::collection($this->whenLoaded('platformAccounts')),
|
||||||
|
'routes' => RouteResource::collection($this->whenLoaded('routes')),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
27
app/Http/Resources/PlatformInstanceResource.php
Normal file
27
app/Http/Resources/PlatformInstanceResource.php
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
class PlatformInstanceResource 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,
|
||||||
|
'description' => $this->description,
|
||||||
|
'is_active' => $this->is_active,
|
||||||
|
'created_at' => $this->created_at->toISOString(),
|
||||||
|
'updated_at' => $this->updated_at->toISOString(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
38
app/Http/Resources/RouteResource.php
Normal file
38
app/Http/Resources/RouteResource.php
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
class RouteResource extends JsonResource
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Transform the resource into an array.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'feed_id' => $this->feed_id,
|
||||||
|
'platform_channel_id' => $this->platform_channel_id,
|
||||||
|
'is_active' => $this->is_active,
|
||||||
|
'priority' => $this->priority,
|
||||||
|
'created_at' => $this->created_at->toISOString(),
|
||||||
|
'updated_at' => $this->updated_at->toISOString(),
|
||||||
|
'feed' => new FeedResource($this->whenLoaded('feed')),
|
||||||
|
'platform_channel' => new PlatformChannelResource($this->whenLoaded('platformChannel')),
|
||||||
|
'keywords' => $this->whenLoaded('keywords', function () {
|
||||||
|
return $this->keywords->map(function ($keyword) {
|
||||||
|
return [
|
||||||
|
'id' => $keyword->id,
|
||||||
|
'keyword' => $keyword->keyword,
|
||||||
|
'is_active' => $keyword->is_active,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
63
app/Jobs/ArticleDiscoveryForFeedJob.php
Normal file
63
app/Jobs/ArticleDiscoveryForFeedJob.php
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Models\Feed;
|
||||||
|
use App\Services\Article\ArticleFetcher;
|
||||||
|
use App\Services\Log\LogSaver;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Queue\Queueable;
|
||||||
|
|
||||||
|
class ArticleDiscoveryForFeedJob implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Queueable;
|
||||||
|
|
||||||
|
private const FEED_DISCOVERY_DELAY_MINUTES = 5;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly Feed $feed
|
||||||
|
) {
|
||||||
|
$this->onQueue('feed-discovery');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(LogSaver $logSaver, ArticleFetcher $articleFetcher): void
|
||||||
|
{
|
||||||
|
$logSaver->info('Starting feed article fetch', null, [
|
||||||
|
'feed_id' => $this->feed->id,
|
||||||
|
'feed_name' => $this->feed->name,
|
||||||
|
'feed_url' => $this->feed->url
|
||||||
|
]);
|
||||||
|
|
||||||
|
$articles = $articleFetcher->getArticlesFromFeed($this->feed);
|
||||||
|
|
||||||
|
$logSaver->info('Feed article fetch completed', null, [
|
||||||
|
'feed_id' => $this->feed->id,
|
||||||
|
'feed_name' => $this->feed->name,
|
||||||
|
'articles_count' => $articles->count()
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->feed->update(['last_fetched_at' => now()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function dispatchForAllActiveFeeds(): void
|
||||||
|
{
|
||||||
|
$logSaver = app(LogSaver::class);
|
||||||
|
|
||||||
|
Feed::where('is_active', true)
|
||||||
|
->get()
|
||||||
|
->each(function (Feed $feed, $index) use ($logSaver) {
|
||||||
|
// Space jobs apart to avoid overwhelming feeds
|
||||||
|
$delayMinutes = $index * self::FEED_DISCOVERY_DELAY_MINUTES;
|
||||||
|
|
||||||
|
self::dispatch($feed)
|
||||||
|
->delay(now()->addMinutes($delayMinutes))
|
||||||
|
->onQueue('feed-discovery');
|
||||||
|
|
||||||
|
$logSaver->info('Dispatched feed discovery job', null, [
|
||||||
|
'feed_id' => $feed->id,
|
||||||
|
'feed_name' => $feed->name,
|
||||||
|
'delay_minutes' => $delayMinutes
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
33
app/Jobs/ArticleDiscoveryJob.php
Normal file
33
app/Jobs/ArticleDiscoveryJob.php
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Models\Setting;
|
||||||
|
use App\Services\Log\LogSaver;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Queue\Queueable;
|
||||||
|
|
||||||
|
class ArticleDiscoveryJob implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Queueable;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->onQueue('feed-discovery');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(LogSaver $logSaver): void
|
||||||
|
{
|
||||||
|
if (!Setting::isArticleProcessingEnabled()) {
|
||||||
|
$logSaver->info('Article processing is disabled. Article discovery skipped.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$logSaver->info('Starting article discovery for all active feeds');
|
||||||
|
|
||||||
|
ArticleDiscoveryForFeedJob::dispatchForAllActiveFeeds();
|
||||||
|
|
||||||
|
$logSaver->info('Article discovery jobs dispatched for all active feeds');
|
||||||
|
}
|
||||||
|
}
|
||||||
69
app/Jobs/PublishNextArticleJob.php
Normal file
69
app/Jobs/PublishNextArticleJob.php
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Exceptions\PublishException;
|
||||||
|
use App\Models\Article;
|
||||||
|
use App\Services\Article\ArticleFetcher;
|
||||||
|
use App\Services\Publishing\ArticlePublishingService;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldBeUnique;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Queue\Queueable;
|
||||||
|
|
||||||
|
class PublishNextArticleJob implements ShouldQueue, ShouldBeUnique
|
||||||
|
{
|
||||||
|
use Queueable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The number of seconds after which the job's unique lock will be released.
|
||||||
|
*/
|
||||||
|
public int $uniqueFor = 300;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->onQueue('publishing');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute the job.
|
||||||
|
* @throws PublishException
|
||||||
|
*/
|
||||||
|
public function handle(ArticleFetcher $articleFetcher, ArticlePublishingService $publishingService): void
|
||||||
|
{
|
||||||
|
// Get the oldest approved article that hasn't been published yet
|
||||||
|
$article = Article::where('approval_status', 'approved')
|
||||||
|
->whereDoesntHave('articlePublication')
|
||||||
|
->oldest('created_at')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $article) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger()->info('Publishing next article from scheduled job', [
|
||||||
|
'article_id' => $article->id,
|
||||||
|
'title' => $article->title,
|
||||||
|
'url' => $article->url,
|
||||||
|
'created_at' => $article->created_at
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Fetch article data
|
||||||
|
$extractedData = $articleFetcher->fetchArticleData($article);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$publishingService->publishToRoutedChannels($article, $extractedData);
|
||||||
|
|
||||||
|
logger()->info('Successfully published article', [
|
||||||
|
'article_id' => $article->id,
|
||||||
|
'title' => $article->title
|
||||||
|
]);
|
||||||
|
} catch (PublishException $e) {
|
||||||
|
logger()->error('Failed to publish article', [
|
||||||
|
'article_id' => $article->id,
|
||||||
|
'error' => $e->getMessage()
|
||||||
|
]);
|
||||||
|
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Jobs;
|
|
||||||
|
|
||||||
use App\Exceptions\PublishException;
|
|
||||||
use App\Models\Article;
|
|
||||||
use App\Modules\Lemmy\Services\LemmyPublisher;
|
|
||||||
use App\Services\Article\ArticleFetcher;
|
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
|
||||||
use Illuminate\Foundation\Queue\Queueable;
|
|
||||||
|
|
||||||
class PublishToLemmyJob implements ShouldQueue
|
|
||||||
{
|
|
||||||
use Queueable;
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
private readonly Article $article
|
|
||||||
) {
|
|
||||||
$this->onQueue('lemmy-posts');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function handle(): void
|
|
||||||
{
|
|
||||||
if ($this->article->articlePublication !== null) {
|
|
||||||
logger()->info('Article already published, skipping', [
|
|
||||||
'article_id' => $this->article->id
|
|
||||||
]);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$extractedData = ArticleFetcher::fetchArticleData($this->article);
|
|
||||||
|
|
||||||
logger()->info('Publishing article to Lemmy', [
|
|
||||||
'article_id' => $this->article->id,
|
|
||||||
'url' => $this->article->url
|
|
||||||
]);
|
|
||||||
|
|
||||||
try {
|
|
||||||
LemmyPublisher::fromConfig()->publish($this->article, $extractedData);
|
|
||||||
|
|
||||||
logger()->info('Article published successfully', [
|
|
||||||
'article_id' => $this->article->id
|
|
||||||
]);
|
|
||||||
|
|
||||||
} catch (PublishException $e) {
|
|
||||||
$this->fail($e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Jobs;
|
|
||||||
|
|
||||||
use App\Services\Article\ArticleFetcher;
|
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
|
||||||
use Illuminate\Foundation\Queue\Queueable;
|
|
||||||
|
|
||||||
class RefreshArticlesJob implements ShouldQueue
|
|
||||||
{
|
|
||||||
use Queueable;
|
|
||||||
|
|
||||||
public function __construct()
|
|
||||||
{
|
|
||||||
$this->onQueue('lemmy-posts');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function handle(): void
|
|
||||||
{
|
|
||||||
ArticleFetcher::getNewArticles();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -4,72 +4,80 @@
|
||||||
|
|
||||||
use App\Enums\PlatformEnum;
|
use App\Enums\PlatformEnum;
|
||||||
use App\Exceptions\PlatformAuthException;
|
use App\Exceptions\PlatformAuthException;
|
||||||
|
use App\Models\PlatformAccount;
|
||||||
|
use App\Models\PlatformChannel;
|
||||||
use App\Modules\Lemmy\Services\LemmyApiService;
|
use App\Modules\Lemmy\Services\LemmyApiService;
|
||||||
|
use App\Services\Log\LogSaver;
|
||||||
use Exception;
|
use Exception;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldBeUnique;
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
use Illuminate\Foundation\Queue\Queueable;
|
use Illuminate\Foundation\Queue\Queueable;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
|
||||||
class SyncChannelPostsJob implements ShouldQueue
|
class SyncChannelPostsJob implements ShouldQueue, ShouldBeUnique
|
||||||
{
|
{
|
||||||
use Queueable;
|
use Queueable;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly PlatformEnum $platform,
|
private readonly PlatformChannel $channel
|
||||||
private readonly string $channelId,
|
|
||||||
private readonly string $channelName
|
|
||||||
) {
|
) {
|
||||||
$this->onQueue('lemmy-posts');
|
$this->onQueue('sync');
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function dispatchForLemmy(): void
|
public static function dispatchForAllActiveChannels(): void
|
||||||
{
|
{
|
||||||
$communityId = config('lemmy.community_id');
|
$logSaver = app(LogSaver::class);
|
||||||
$communityName = config('lemmy.community');
|
|
||||||
|
|
||||||
if ($communityName) {
|
PlatformChannel::with(['platformInstance', 'platformAccounts'])
|
||||||
// Use a placeholder ID if community_id is not set - we'll resolve it in the job
|
->whereHas('platformInstance', fn ($query) => $query->where('platform', PlatformEnum::LEMMY))
|
||||||
$communityId = $communityId ?: 'resolve_from_name';
|
->whereHas('platformAccounts', fn ($query) => $query->where('platform_accounts.is_active', true))
|
||||||
self::dispatch(PlatformEnum::LEMMY, (string) $communityId, $communityName);
|
->where('platform_channels.is_active', true)
|
||||||
} else {
|
->get()
|
||||||
logger()->warning('Cannot dispatch Lemmy sync job: missing community configuration');
|
->each(function (PlatformChannel $channel) use ($logSaver) {
|
||||||
}
|
self::dispatch($channel);
|
||||||
|
$logSaver->info('Dispatched sync job for channel', $channel);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public function handle(): void
|
public function handle(LogSaver $logSaver): void
|
||||||
{
|
{
|
||||||
echo "Starting channel posts sync job...\n";
|
$logSaver->info('Starting channel posts sync job', $this->channel);
|
||||||
|
|
||||||
if ($this->platform === PlatformEnum::LEMMY) {
|
match ($this->channel->platformInstance->platform) {
|
||||||
$this->syncLemmyChannelPosts();
|
PlatformEnum::LEMMY => $this->syncLemmyChannelPosts($logSaver),
|
||||||
}
|
};
|
||||||
|
|
||||||
echo "Channel posts sync job completed!\n";
|
$logSaver->info('Channel posts sync job completed', $this->channel);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function syncLemmyChannelPosts(): void
|
/**
|
||||||
|
* @throws PlatformAuthException
|
||||||
|
*/
|
||||||
|
private function syncLemmyChannelPosts(LogSaver $logSaver): void
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$api = new LemmyApiService(config('lemmy.instance'));
|
/** @var Collection<int, PlatformAccount> $accounts */
|
||||||
$token = $this->getAuthToken($api);
|
$accounts = $this->channel->activePlatformAccounts()->get();
|
||||||
|
$account = $accounts->first();
|
||||||
|
|
||||||
// Resolve community ID if it's a placeholder
|
if (! $account) {
|
||||||
$communityId = $this->channelId === 'resolve_from_name'
|
throw new PlatformAuthException(PlatformEnum::LEMMY, 'No active account found for channel');
|
||||||
? $api->getCommunityId($this->channelName)
|
}
|
||||||
: (int) $this->channelId;
|
|
||||||
|
|
||||||
$api->syncChannelPosts($token, $communityId, $this->channelName);
|
$api = new LemmyApiService($this->channel->platformInstance->url);
|
||||||
|
$token = $this->getAuthToken($api, $account);
|
||||||
|
|
||||||
logger()->info('Channel posts synced successfully', [
|
$platformChannelId = $this->channel->channel_id
|
||||||
'platform' => $this->platform->value,
|
? $this->channel->channel_id
|
||||||
'channel_id' => $this->channelId,
|
: $api->getCommunityId($this->channel->name, $token);
|
||||||
'channel_name' => $this->channelName
|
|
||||||
]);
|
$api->syncChannelPosts($token, $platformChannelId, $this->channel->name);
|
||||||
|
|
||||||
|
$logSaver->info('Channel posts synced successfully', $this->channel);
|
||||||
|
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
logger()->error('Failed to sync channel posts', [
|
$logSaver->error('Failed to sync channel posts', $this->channel, [
|
||||||
'platform' => $this->platform->value,
|
|
||||||
'channel_id' => $this->channelId,
|
|
||||||
'error' => $e->getMessage()
|
'error' => $e->getMessage()
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
@ -77,28 +85,29 @@ private function syncLemmyChannelPosts(): void
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private function getAuthToken(LemmyApiService $api): string
|
/**
|
||||||
|
* @throws PlatformAuthException
|
||||||
|
*/
|
||||||
|
private function getAuthToken(LemmyApiService $api, PlatformAccount $account): string
|
||||||
{
|
{
|
||||||
$cachedToken = Cache::get('lemmy_jwt_token');
|
$cacheKey = "lemmy_jwt_token_{$account->id}";
|
||||||
|
$cachedToken = Cache::get($cacheKey);
|
||||||
|
|
||||||
if ($cachedToken) {
|
if ($cachedToken) {
|
||||||
return $cachedToken;
|
return $cachedToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
$username = config('lemmy.username');
|
if (!$account->username || !$account->password) {
|
||||||
$password = config('lemmy.password');
|
throw new PlatformAuthException(PlatformEnum::LEMMY, 'Missing credentials for account');
|
||||||
|
|
||||||
if (!$username || !$password) {
|
|
||||||
throw new PlatformAuthException(PlatformEnum::LEMMY, 'Missing credentials');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$token = $api->login($username, $password);
|
$token = $api->login($account->username, $account->password);
|
||||||
|
|
||||||
if (!$token) {
|
if (!$token) {
|
||||||
throw new PlatformAuthException(PlatformEnum::LEMMY, 'Login failed');
|
throw new PlatformAuthException(PlatformEnum::LEMMY, 'Login failed for account');
|
||||||
}
|
}
|
||||||
|
|
||||||
Cache::put('lemmy_jwt_token', $token, 3600);
|
Cache::put($cacheKey, $token, 3600);
|
||||||
|
|
||||||
return $token;
|
return $token;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,18 +10,29 @@ class LogExceptionToDatabase
|
||||||
|
|
||||||
public function handle(ExceptionOccurred $event): void
|
public function handle(ExceptionOccurred $event): void
|
||||||
{
|
{
|
||||||
$log = Log::create([
|
// Truncate the message to prevent database errors
|
||||||
'level' => $event->level,
|
$message = strlen($event->message) > 255
|
||||||
'message' => $event->message,
|
? substr($event->message, 0, 252) . '...'
|
||||||
'context' => [
|
: $event->message;
|
||||||
'exception_class' => get_class($event->exception),
|
|
||||||
'file' => $event->exception->getFile(),
|
|
||||||
'line' => $event->exception->getLine(),
|
|
||||||
'trace' => $event->exception->getTraceAsString(),
|
|
||||||
...$event->context
|
|
||||||
]
|
|
||||||
]);
|
|
||||||
|
|
||||||
ExceptionLogged::dispatch($log);
|
try {
|
||||||
|
$log = Log::create([
|
||||||
|
'level' => $event->level,
|
||||||
|
'message' => $message,
|
||||||
|
'context' => [
|
||||||
|
'exception_class' => get_class($event->exception),
|
||||||
|
'file' => $event->exception->getFile(),
|
||||||
|
'line' => $event->exception->getLine(),
|
||||||
|
'trace' => $event->exception->getTraceAsString(),
|
||||||
|
...$event->context
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
|
||||||
|
ExceptionLogged::dispatch($log);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
// Prevent infinite recursion by not logging this exception
|
||||||
|
// Optionally log to file or other non-database destination
|
||||||
|
error_log("Failed to log exception to database: " . $e->getMessage());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Listeners;
|
|
||||||
|
|
||||||
use App\Events\ArticleReadyToPublish;
|
|
||||||
use App\Jobs\PublishToLemmyJob;
|
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
|
||||||
|
|
||||||
class PublishArticle implements ShouldQueue
|
|
||||||
{
|
|
||||||
public string|null $queue = 'default';
|
|
||||||
public int $delay = 300;
|
|
||||||
public int $tries = 3;
|
|
||||||
public int $backoff = 300;
|
|
||||||
|
|
||||||
public function __construct()
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public function handle(ArticleReadyToPublish $event): void
|
|
||||||
{
|
|
||||||
$article = $event->article;
|
|
||||||
|
|
||||||
// Check if already published to avoid duplicate jobs
|
|
||||||
if ($article->articlePublication()->exists()) {
|
|
||||||
logger()->info('Article already published, skipping job dispatch', [
|
|
||||||
'article_id' => $article->id,
|
|
||||||
'url' => $article->url
|
|
||||||
]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
logger()->info('Article queued for publishing to Lemmy', [
|
|
||||||
'article_id' => $article->id,
|
|
||||||
'url' => $article->url
|
|
||||||
]);
|
|
||||||
|
|
||||||
PublishToLemmyJob::dispatch($article);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Listeners;
|
|
||||||
|
|
||||||
use App\Events\ArticleFetched;
|
|
||||||
use App\Events\ArticleReadyToPublish;
|
|
||||||
use App\Services\Article\ValidationService;
|
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
|
||||||
|
|
||||||
class ValidateArticle implements ShouldQueue
|
|
||||||
{
|
|
||||||
public string|null $queue = 'default';
|
|
||||||
|
|
||||||
public function __construct()
|
|
||||||
{}
|
|
||||||
|
|
||||||
public function handle(ArticleFetched $event): void
|
|
||||||
{
|
|
||||||
$article = $event->article;
|
|
||||||
|
|
||||||
// Skip if already validated
|
|
||||||
if (! is_null($article->validated_at)) {
|
|
||||||
// Even if validated, don't fire ready-to-publish if already has publication
|
|
||||||
if ($article->isValid() && !$article->articlePublication()->exists()) {
|
|
||||||
event(new ArticleReadyToPublish($article));
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip if already has publication (prevents duplicate processing)
|
|
||||||
if ($article->articlePublication()->exists()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$article = ValidationService::validate($article);
|
|
||||||
|
|
||||||
if ($article->isValid()) {
|
|
||||||
// Double-check publication doesn't exist (race condition protection)
|
|
||||||
if ($article->articlePublication()->exists()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
event(new ArticleReadyToPublish($article));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
49
app/Listeners/ValidateArticleListener.php
Normal file
49
app/Listeners/ValidateArticleListener.php
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Listeners;
|
||||||
|
|
||||||
|
use App\Events\NewArticleFetched;
|
||||||
|
use App\Events\ArticleApproved;
|
||||||
|
use App\Models\Setting;
|
||||||
|
use App\Services\Article\ValidationService;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
|
||||||
|
class ValidateArticleListener implements ShouldQueue
|
||||||
|
{
|
||||||
|
public string $queue = 'default';
|
||||||
|
|
||||||
|
public function handle(NewArticleFetched $event, ValidationService $validationService): void
|
||||||
|
{
|
||||||
|
$article = $event->article;
|
||||||
|
|
||||||
|
if (! is_null($article->validated_at)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip if already has publication (prevents duplicate processing)
|
||||||
|
if ($article->articlePublication()->exists()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$article = $validationService->validate($article);
|
||||||
|
|
||||||
|
if ($article->isValid()) {
|
||||||
|
// Double-check publication doesn't exist (race condition protection)
|
||||||
|
if ($article->articlePublication()->exists()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if approval system is enabled
|
||||||
|
if (Setting::isPublishingApprovalsEnabled()) {
|
||||||
|
// If approvals are enabled, only proceed if article is approved
|
||||||
|
if ($article->isApproved()) {
|
||||||
|
event(new ArticleApproved($article));
|
||||||
|
}
|
||||||
|
// If not approved, article will wait for manual approval
|
||||||
|
} else {
|
||||||
|
// If approvals are disabled, proceed with publishing
|
||||||
|
event(new ArticleApproved($article));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
61
app/Livewire/Articles.php
Normal file
61
app/Livewire/Articles.php
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire;
|
||||||
|
|
||||||
|
use App\Models\Article;
|
||||||
|
use App\Models\Setting;
|
||||||
|
use App\Jobs\ArticleDiscoveryJob;
|
||||||
|
use Livewire\Component;
|
||||||
|
use Livewire\WithPagination;
|
||||||
|
|
||||||
|
class Articles extends Component
|
||||||
|
{
|
||||||
|
use WithPagination;
|
||||||
|
|
||||||
|
public bool $isRefreshing = false;
|
||||||
|
|
||||||
|
public function approve(int $articleId): void
|
||||||
|
{
|
||||||
|
$article = Article::findOrFail($articleId);
|
||||||
|
$article->approve();
|
||||||
|
|
||||||
|
$this->dispatch('article-updated');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function reject(int $articleId): void
|
||||||
|
{
|
||||||
|
$article = Article::findOrFail($articleId);
|
||||||
|
$article->reject();
|
||||||
|
|
||||||
|
$this->dispatch('article-updated');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function refresh(): void
|
||||||
|
{
|
||||||
|
$this->isRefreshing = true;
|
||||||
|
|
||||||
|
ArticleDiscoveryJob::dispatch();
|
||||||
|
|
||||||
|
// Reset after 10 seconds
|
||||||
|
$this->dispatch('refresh-complete')->self();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function refreshComplete(): void
|
||||||
|
{
|
||||||
|
$this->isRefreshing = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render()
|
||||||
|
{
|
||||||
|
$articles = Article::with(['feed', 'articlePublication'])
|
||||||
|
->orderBy('created_at', 'desc')
|
||||||
|
->paginate(15);
|
||||||
|
|
||||||
|
$approvalsEnabled = Setting::isPublishingApprovalsEnabled();
|
||||||
|
|
||||||
|
return view('livewire.articles', [
|
||||||
|
'articles' => $articles,
|
||||||
|
'approvalsEnabled' => $approvalsEnabled,
|
||||||
|
])->layout('layouts.app');
|
||||||
|
}
|
||||||
|
}
|
||||||
73
app/Livewire/Channels.php
Normal file
73
app/Livewire/Channels.php
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire;
|
||||||
|
|
||||||
|
use App\Models\PlatformAccount;
|
||||||
|
use App\Models\PlatformChannel;
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
|
class Channels extends Component
|
||||||
|
{
|
||||||
|
public ?int $managingChannelId = null;
|
||||||
|
|
||||||
|
public function toggle(int $channelId): void
|
||||||
|
{
|
||||||
|
$channel = PlatformChannel::findOrFail($channelId);
|
||||||
|
$channel->is_active = !$channel->is_active;
|
||||||
|
$channel->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function openAccountModal(int $channelId): void
|
||||||
|
{
|
||||||
|
$this->managingChannelId = $channelId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function closeAccountModal(): void
|
||||||
|
{
|
||||||
|
$this->managingChannelId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function attachAccount(int $accountId): void
|
||||||
|
{
|
||||||
|
if (!$this->managingChannelId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$channel = PlatformChannel::findOrFail($this->managingChannelId);
|
||||||
|
|
||||||
|
if (!$channel->platformAccounts()->where('platform_account_id', $accountId)->exists()) {
|
||||||
|
$channel->platformAccounts()->attach($accountId, [
|
||||||
|
'is_active' => true,
|
||||||
|
'priority' => 1,
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function detachAccount(int $channelId, int $accountId): void
|
||||||
|
{
|
||||||
|
$channel = PlatformChannel::findOrFail($channelId);
|
||||||
|
$channel->platformAccounts()->detach($accountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render()
|
||||||
|
{
|
||||||
|
$channels = PlatformChannel::with(['platformInstance', 'platformAccounts'])->orderBy('name')->get();
|
||||||
|
$allAccounts = PlatformAccount::where('is_active', true)->get();
|
||||||
|
|
||||||
|
$managingChannel = $this->managingChannelId
|
||||||
|
? PlatformChannel::with('platformAccounts')->find($this->managingChannelId)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
$availableAccounts = $managingChannel
|
||||||
|
? $allAccounts->filter(fn($account) => !$managingChannel->platformAccounts->contains('id', $account->id))
|
||||||
|
: collect();
|
||||||
|
|
||||||
|
return view('livewire.channels', [
|
||||||
|
'channels' => $channels,
|
||||||
|
'managingChannel' => $managingChannel,
|
||||||
|
'availableAccounts' => $availableAccounts,
|
||||||
|
])->layout('layouts.app');
|
||||||
|
}
|
||||||
|
}
|
||||||
36
app/Livewire/Dashboard.php
Normal file
36
app/Livewire/Dashboard.php
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire;
|
||||||
|
|
||||||
|
use App\Services\DashboardStatsService;
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
|
class Dashboard extends Component
|
||||||
|
{
|
||||||
|
public string $period = 'today';
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
// Default period
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setPeriod(string $period): void
|
||||||
|
{
|
||||||
|
$this->period = $period;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render()
|
||||||
|
{
|
||||||
|
$service = app(DashboardStatsService::class);
|
||||||
|
|
||||||
|
$articleStats = $service->getStats($this->period);
|
||||||
|
$systemStats = $service->getSystemStats();
|
||||||
|
$availablePeriods = $service->getAvailablePeriods();
|
||||||
|
|
||||||
|
return view('livewire.dashboard', [
|
||||||
|
'articleStats' => $articleStats,
|
||||||
|
'systemStats' => $systemStats,
|
||||||
|
'availablePeriods' => $availablePeriods,
|
||||||
|
])->layout('layouts.app');
|
||||||
|
}
|
||||||
|
}
|
||||||
25
app/Livewire/Feeds.php
Normal file
25
app/Livewire/Feeds.php
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire;
|
||||||
|
|
||||||
|
use App\Models\Feed;
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
|
class Feeds extends Component
|
||||||
|
{
|
||||||
|
public function toggle(int $feedId): void
|
||||||
|
{
|
||||||
|
$feed = Feed::findOrFail($feedId);
|
||||||
|
$feed->is_active = !$feed->is_active;
|
||||||
|
$feed->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render()
|
||||||
|
{
|
||||||
|
$feeds = Feed::orderBy('name')->get();
|
||||||
|
|
||||||
|
return view('livewire.feeds', [
|
||||||
|
'feeds' => $feeds,
|
||||||
|
])->layout('layouts.app');
|
||||||
|
}
|
||||||
|
}
|
||||||
358
app/Livewire/Onboarding.php
Normal file
358
app/Livewire/Onboarding.php
Normal file
|
|
@ -0,0 +1,358 @@
|
||||||
|
<?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');
|
||||||
|
}
|
||||||
|
}
|
||||||
200
app/Livewire/Routes.php
Normal file
200
app/Livewire/Routes.php
Normal file
|
|
@ -0,0 +1,200 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire;
|
||||||
|
|
||||||
|
use App\Models\Feed;
|
||||||
|
use App\Models\Keyword;
|
||||||
|
use App\Models\PlatformChannel;
|
||||||
|
use App\Models\Route;
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
|
class Routes extends Component
|
||||||
|
{
|
||||||
|
public bool $showCreateModal = false;
|
||||||
|
public ?int $editingFeedId = null;
|
||||||
|
public ?int $editingChannelId = null;
|
||||||
|
|
||||||
|
// Create form
|
||||||
|
public ?int $newFeedId = null;
|
||||||
|
public ?int $newChannelId = null;
|
||||||
|
public int $newPriority = 50;
|
||||||
|
|
||||||
|
// Edit form
|
||||||
|
public int $editPriority = 50;
|
||||||
|
|
||||||
|
// Keyword management
|
||||||
|
public string $newKeyword = '';
|
||||||
|
public bool $showKeywordInput = false;
|
||||||
|
|
||||||
|
public function openCreateModal(): void
|
||||||
|
{
|
||||||
|
$this->showCreateModal = true;
|
||||||
|
$this->newFeedId = null;
|
||||||
|
$this->newChannelId = null;
|
||||||
|
$this->newPriority = 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function closeCreateModal(): void
|
||||||
|
{
|
||||||
|
$this->showCreateModal = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createRoute(): void
|
||||||
|
{
|
||||||
|
$this->validate([
|
||||||
|
'newFeedId' => 'required|exists:feeds,id',
|
||||||
|
'newChannelId' => 'required|exists:platform_channels,id',
|
||||||
|
'newPriority' => 'required|integer|min:0',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$exists = Route::where('feed_id', $this->newFeedId)
|
||||||
|
->where('platform_channel_id', $this->newChannelId)
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
if ($exists) {
|
||||||
|
$this->addError('newFeedId', 'This route already exists.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Route::create([
|
||||||
|
'feed_id' => $this->newFeedId,
|
||||||
|
'platform_channel_id' => $this->newChannelId,
|
||||||
|
'priority' => $this->newPriority,
|
||||||
|
'is_active' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->closeCreateModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function openEditModal(int $feedId, int $channelId): void
|
||||||
|
{
|
||||||
|
$route = Route::where('feed_id', $feedId)
|
||||||
|
->where('platform_channel_id', $channelId)
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
$this->editingFeedId = $feedId;
|
||||||
|
$this->editingChannelId = $channelId;
|
||||||
|
$this->editPriority = $route->priority;
|
||||||
|
$this->newKeyword = '';
|
||||||
|
$this->showKeywordInput = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function closeEditModal(): void
|
||||||
|
{
|
||||||
|
$this->editingFeedId = null;
|
||||||
|
$this->editingChannelId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateRoute(): void
|
||||||
|
{
|
||||||
|
if (!$this->editingFeedId || !$this->editingChannelId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->validate([
|
||||||
|
'editPriority' => 'required|integer|min:0',
|
||||||
|
]);
|
||||||
|
|
||||||
|
Route::where('feed_id', $this->editingFeedId)
|
||||||
|
->where('platform_channel_id', $this->editingChannelId)
|
||||||
|
->update(['priority' => $this->editPriority]);
|
||||||
|
|
||||||
|
$this->closeEditModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toggle(int $feedId, int $channelId): void
|
||||||
|
{
|
||||||
|
$route = Route::where('feed_id', $feedId)
|
||||||
|
->where('platform_channel_id', $channelId)
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
$route->is_active = !$route->is_active;
|
||||||
|
$route->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(int $feedId, int $channelId): void
|
||||||
|
{
|
||||||
|
// Delete associated keywords first
|
||||||
|
Keyword::where('feed_id', $feedId)
|
||||||
|
->where('platform_channel_id', $channelId)
|
||||||
|
->delete();
|
||||||
|
|
||||||
|
Route::where('feed_id', $feedId)
|
||||||
|
->where('platform_channel_id', $channelId)
|
||||||
|
->delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function addKeyword(): void
|
||||||
|
{
|
||||||
|
if (!$this->editingFeedId || !$this->editingChannelId || empty(trim($this->newKeyword))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Keyword::create([
|
||||||
|
'feed_id' => $this->editingFeedId,
|
||||||
|
'platform_channel_id' => $this->editingChannelId,
|
||||||
|
'keyword' => trim($this->newKeyword),
|
||||||
|
'is_active' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->newKeyword = '';
|
||||||
|
$this->showKeywordInput = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toggleKeyword(int $keywordId): void
|
||||||
|
{
|
||||||
|
$keyword = Keyword::findOrFail($keywordId);
|
||||||
|
$keyword->is_active = !$keyword->is_active;
|
||||||
|
$keyword->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleteKeyword(int $keywordId): void
|
||||||
|
{
|
||||||
|
Keyword::destroy($keywordId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render()
|
||||||
|
{
|
||||||
|
$routes = Route::with(['feed', 'platformChannel'])
|
||||||
|
->orderBy('priority', 'desc')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
// Batch load keywords for all routes to avoid N+1 queries
|
||||||
|
$routeKeys = $routes->map(fn($r) => $r->feed_id . '-' . $r->platform_channel_id);
|
||||||
|
$allKeywords = Keyword::whereIn('feed_id', $routes->pluck('feed_id'))
|
||||||
|
->whereIn('platform_channel_id', $routes->pluck('platform_channel_id'))
|
||||||
|
->get()
|
||||||
|
->groupBy(fn($k) => $k->feed_id . '-' . $k->platform_channel_id);
|
||||||
|
|
||||||
|
$routes = $routes->map(function ($route) use ($allKeywords) {
|
||||||
|
$key = $route->feed_id . '-' . $route->platform_channel_id;
|
||||||
|
$route->keywords = $allKeywords->get($key, collect());
|
||||||
|
return $route;
|
||||||
|
});
|
||||||
|
|
||||||
|
$feeds = Feed::where('is_active', true)->orderBy('name')->get();
|
||||||
|
$channels = PlatformChannel::where('is_active', true)->orderBy('name')->get();
|
||||||
|
|
||||||
|
$editingRoute = null;
|
||||||
|
$editingKeywords = collect();
|
||||||
|
|
||||||
|
if ($this->editingFeedId && $this->editingChannelId) {
|
||||||
|
$editingRoute = Route::with(['feed', 'platformChannel'])
|
||||||
|
->where('feed_id', $this->editingFeedId)
|
||||||
|
->where('platform_channel_id', $this->editingChannelId)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
$editingKeywords = Keyword::where('feed_id', $this->editingFeedId)
|
||||||
|
->where('platform_channel_id', $this->editingChannelId)
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
return view('livewire.routes', [
|
||||||
|
'routes' => $routes,
|
||||||
|
'feeds' => $feeds,
|
||||||
|
'channels' => $channels,
|
||||||
|
'editingRoute' => $editingRoute,
|
||||||
|
'editingKeywords' => $editingKeywords,
|
||||||
|
])->layout('layouts.app');
|
||||||
|
}
|
||||||
|
}
|
||||||
55
app/Livewire/Settings.php
Normal file
55
app/Livewire/Settings.php
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire;
|
||||||
|
|
||||||
|
use App\Models\Setting;
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
|
class Settings extends Component
|
||||||
|
{
|
||||||
|
public bool $articleProcessingEnabled = true;
|
||||||
|
public bool $publishingApprovalsEnabled = false;
|
||||||
|
|
||||||
|
public ?string $successMessage = null;
|
||||||
|
public ?string $errorMessage = null;
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
$this->articleProcessingEnabled = Setting::isArticleProcessingEnabled();
|
||||||
|
$this->publishingApprovalsEnabled = Setting::isPublishingApprovalsEnabled();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toggleArticleProcessing(): void
|
||||||
|
{
|
||||||
|
$this->articleProcessingEnabled = !$this->articleProcessingEnabled;
|
||||||
|
Setting::setArticleProcessingEnabled($this->articleProcessingEnabled);
|
||||||
|
$this->showSuccess();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function togglePublishingApprovals(): void
|
||||||
|
{
|
||||||
|
$this->publishingApprovalsEnabled = !$this->publishingApprovalsEnabled;
|
||||||
|
Setting::setPublishingApprovalsEnabled($this->publishingApprovalsEnabled);
|
||||||
|
$this->showSuccess();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function showSuccess(): void
|
||||||
|
{
|
||||||
|
$this->successMessage = 'Settings updated successfully!';
|
||||||
|
$this->errorMessage = null;
|
||||||
|
|
||||||
|
// Clear success message after 3 seconds
|
||||||
|
$this->dispatch('clear-message');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function clearMessages(): void
|
||||||
|
{
|
||||||
|
$this->successMessage = null;
|
||||||
|
$this->errorMessage = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render()
|
||||||
|
{
|
||||||
|
return view('livewire.settings')->layout('layouts.app');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,19 +2,22 @@
|
||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use App\Events\ArticleFetched;
|
use App\Events\ArticleApproved;
|
||||||
|
use App\Events\NewArticleFetched;
|
||||||
use Database\Factories\ArticleFactory;
|
use Database\Factories\ArticleFactory;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @method static firstOrCreate(string[] $array)
|
* @method static firstOrCreate(array<string, mixed> $array)
|
||||||
* @method static where(string $string, string $url)
|
* @method static where(string $string, string $url)
|
||||||
* @method static create(string[] $array)
|
* @method static create(array<string, mixed> $array)
|
||||||
* @property integer $id
|
* @property integer $id
|
||||||
|
* @property int $feed_id
|
||||||
|
* @property Feed $feed
|
||||||
* @property string $url
|
* @property string $url
|
||||||
* @property bool|null $is_valid
|
* @property bool|null $is_valid
|
||||||
* @property Carbon|null $validated_at
|
* @property Carbon|null $validated_at
|
||||||
|
|
@ -28,22 +31,25 @@ class Article extends Model
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
|
'feed_id',
|
||||||
'url',
|
'url',
|
||||||
'title',
|
'title',
|
||||||
'description',
|
'description',
|
||||||
'is_valid',
|
'content',
|
||||||
'is_duplicate',
|
'image_url',
|
||||||
'fetched_at',
|
'published_at',
|
||||||
'validated_at',
|
'author',
|
||||||
|
'approval_status',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
public function casts(): array
|
public function casts(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'is_valid' => 'boolean',
|
'approval_status' => 'string',
|
||||||
'is_duplicate' => 'boolean',
|
'published_at' => 'datetime',
|
||||||
'fetched_at' => 'datetime',
|
|
||||||
'validated_at' => 'datetime',
|
|
||||||
'created_at' => 'datetime',
|
'created_at' => 'datetime',
|
||||||
'updated_at' => 'datetime',
|
'updated_at' => 'datetime',
|
||||||
];
|
];
|
||||||
|
|
@ -51,31 +57,83 @@ public function casts(): array
|
||||||
|
|
||||||
public function isValid(): bool
|
public function isValid(): bool
|
||||||
{
|
{
|
||||||
if (is_null($this->validated_at)) {
|
// In the consolidated schema, we only have approval_status
|
||||||
return false;
|
// Consider 'approved' status as valid
|
||||||
}
|
return $this->approval_status === 'approved';
|
||||||
|
|
||||||
if (is_null($this->is_valid)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->is_valid;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function isApproved(): bool
|
||||||
|
{
|
||||||
|
return $this->approval_status === 'approved';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isPending(): bool
|
||||||
|
{
|
||||||
|
return $this->approval_status === 'pending';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isRejected(): bool
|
||||||
|
{
|
||||||
|
return $this->approval_status === 'rejected';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function approve(string $approvedBy = null): void
|
||||||
|
{
|
||||||
|
$this->update([
|
||||||
|
'approval_status' => 'approved',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Fire event to trigger publishing
|
||||||
|
event(new ArticleApproved($this));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function reject(string $rejectedBy = null): void
|
||||||
|
{
|
||||||
|
$this->update([
|
||||||
|
'approval_status' => 'rejected',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function canBePublished(): bool
|
||||||
|
{
|
||||||
|
if (!$this->isValid()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If approval system is disabled, auto-approve valid articles
|
||||||
|
if (!\App\Models\Setting::isPublishingApprovalsEnabled()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If approval system is enabled, only approved articles can be published
|
||||||
|
return $this->isApproved();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getIsPublishedAttribute(): bool
|
||||||
|
{
|
||||||
|
return $this->articlePublication()->exists();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return HasOne<ArticlePublication, $this>
|
||||||
|
*/
|
||||||
public function articlePublication(): HasOne
|
public function articlePublication(): HasOne
|
||||||
{
|
{
|
||||||
return $this->hasOne(ArticlePublication::class);
|
return $this->hasOne(ArticlePublication::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function articlePublications(): HasMany
|
/**
|
||||||
|
* @return BelongsTo<Feed, $this>
|
||||||
|
*/
|
||||||
|
public function feed(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->hasMany(ArticlePublication::class);
|
return $this->belongsTo(Feed::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected static function booted(): void
|
protected static function booted(): void
|
||||||
{
|
{
|
||||||
static::created(function ($article) {
|
static::created(function ($article) {
|
||||||
event(new ArticleFetched($article));
|
event(new NewArticleFetched($article));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,21 +2,26 @@
|
||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Database\Factories\ArticlePublicationFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @property integer $article_id
|
* @property integer $article_id
|
||||||
* @property integer $community_id
|
* @property integer $platform_channel_id
|
||||||
* @property integer $post_id
|
* @property integer $post_id
|
||||||
*
|
*
|
||||||
* @method static create(array $array)
|
* @method static create(array<string, mixed> $array)
|
||||||
*/
|
*/
|
||||||
class ArticlePublication extends Model
|
class ArticlePublication extends Model
|
||||||
{
|
{
|
||||||
|
/** @use HasFactory<ArticlePublicationFactory> */
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'article_id',
|
'article_id',
|
||||||
'community_id',
|
'platform_channel_id',
|
||||||
'post_id',
|
'post_id',
|
||||||
'published_at',
|
'published_at',
|
||||||
'published_by',
|
'published_by',
|
||||||
|
|
@ -29,6 +34,9 @@ class ArticlePublication extends Model
|
||||||
'publication_data' => 'array',
|
'publication_data' => 'array',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return BelongsTo<Article, $this>
|
||||||
|
*/
|
||||||
public function article(): BelongsTo
|
public function article(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Article::class);
|
return $this->belongsTo(Article::class);
|
||||||
|
|
|
||||||
121
app/Models/Feed.php
Normal file
121
app/Models/Feed.php
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Database\Factories\FeedFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @property int $id
|
||||||
|
* @property string $name
|
||||||
|
* @property string $url
|
||||||
|
* @property string $type
|
||||||
|
* @property string $provider
|
||||||
|
* @property int $language_id
|
||||||
|
* @property Language|null $language
|
||||||
|
* @property string $description
|
||||||
|
* @property array<string, mixed> $settings
|
||||||
|
* @property bool $is_active
|
||||||
|
* @property Carbon|null $last_fetched_at
|
||||||
|
* @property Carbon $created_at
|
||||||
|
* @property Carbon $updated_at
|
||||||
|
* @method static orderBy(string $string, string $string1)
|
||||||
|
* @method static where(string $string, true $true)
|
||||||
|
* @method static findOrFail(mixed $feed_id)
|
||||||
|
*/
|
||||||
|
class Feed extends Model
|
||||||
|
{
|
||||||
|
/** @use HasFactory<FeedFactory> */
|
||||||
|
use HasFactory;
|
||||||
|
private const RECENT_FETCH_THRESHOLD_HOURS = 2;
|
||||||
|
private const DAILY_FETCH_THRESHOLD_HOURS = 24;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'name',
|
||||||
|
'url',
|
||||||
|
'type',
|
||||||
|
'provider',
|
||||||
|
'language_id',
|
||||||
|
'description',
|
||||||
|
'settings',
|
||||||
|
'is_active',
|
||||||
|
'last_fetched_at'
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'settings' => 'array',
|
||||||
|
'is_active' => 'boolean',
|
||||||
|
'last_fetched_at' => 'datetime'
|
||||||
|
];
|
||||||
|
|
||||||
|
public function getTypeDisplayAttribute(): string
|
||||||
|
{
|
||||||
|
return match ($this->type) {
|
||||||
|
'website' => 'Website',
|
||||||
|
'rss' => 'RSS Feed',
|
||||||
|
default => 'Unknown'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getStatusAttribute(): string
|
||||||
|
{
|
||||||
|
if (!$this->is_active) {
|
||||||
|
return 'Inactive';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->last_fetched_at) {
|
||||||
|
return 'Never fetched';
|
||||||
|
}
|
||||||
|
|
||||||
|
$hoursAgo = $this->last_fetched_at->diffInHours(now());
|
||||||
|
|
||||||
|
if ($hoursAgo < self::RECENT_FETCH_THRESHOLD_HOURS) {
|
||||||
|
return 'Recently fetched';
|
||||||
|
} elseif ($hoursAgo < self::DAILY_FETCH_THRESHOLD_HOURS) {
|
||||||
|
return "Fetched {$hoursAgo}h ago";
|
||||||
|
} else {
|
||||||
|
return "Fetched " . $this->last_fetched_at->diffForHumans();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return BelongsToMany<PlatformChannel, $this, Route>
|
||||||
|
*/
|
||||||
|
public function channels(): BelongsToMany
|
||||||
|
{
|
||||||
|
return $this->belongsToMany(PlatformChannel::class, 'routes')
|
||||||
|
->withPivot(['is_active', 'priority'])
|
||||||
|
->withTimestamps();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return BelongsToMany<PlatformChannel, $this, Route>
|
||||||
|
*/
|
||||||
|
public function activeChannels(): BelongsToMany
|
||||||
|
{
|
||||||
|
return $this->channels()
|
||||||
|
->wherePivot('is_active', true)
|
||||||
|
->orderByPivot('priority', 'desc');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return HasMany<Article, $this>
|
||||||
|
*/
|
||||||
|
public function articles(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Article::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return BelongsTo<Language, $this>
|
||||||
|
*/
|
||||||
|
public function language(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Language::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
52
app/Models/Keyword.php
Normal file
52
app/Models/Keyword.php
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @property int $id
|
||||||
|
* @property int $feed_id
|
||||||
|
* @property Feed $feed
|
||||||
|
* @property int $platform_channel_id
|
||||||
|
* @property PlatformChannel $platformChannel
|
||||||
|
* @property string $keyword
|
||||||
|
* @property bool $is_active
|
||||||
|
* @property Carbon $created_at
|
||||||
|
* @property Carbon $updated_at
|
||||||
|
*/
|
||||||
|
class Keyword extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'feed_id',
|
||||||
|
'platform_channel_id',
|
||||||
|
'keyword',
|
||||||
|
'is_active'
|
||||||
|
];
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
52
app/Models/Language.php
Normal file
52
app/Models/Language.php
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Database\Factories\LanguageFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
|
class Language extends Model
|
||||||
|
{
|
||||||
|
/** @use HasFactory<LanguageFactory> */
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'short_code',
|
||||||
|
'name',
|
||||||
|
'native_name',
|
||||||
|
'is_active'
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'is_active' => 'boolean'
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return BelongsToMany<PlatformInstance, $this>
|
||||||
|
*/
|
||||||
|
public function platformInstances(): BelongsToMany
|
||||||
|
{
|
||||||
|
return $this->belongsToMany(PlatformInstance::class)
|
||||||
|
->withPivot(['platform_language_id', 'is_default'])
|
||||||
|
->withTimestamps();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return HasMany<PlatformChannel, $this>
|
||||||
|
*/
|
||||||
|
public function platformChannels(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(PlatformChannel::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return HasMany<Feed, $this>
|
||||||
|
*/
|
||||||
|
public function feeds(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Feed::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,11 +2,23 @@
|
||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use App\LogLevelEnum;
|
use App\Enums\LogLevelEnum;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @method static create(array $array)
|
||||||
|
* @property LogLevelEnum $level
|
||||||
|
* @property string $message
|
||||||
|
* @property array<string, mixed> $context
|
||||||
|
* @property Carbon $created_at
|
||||||
|
* @property Carbon $updated_at
|
||||||
|
*/
|
||||||
class Log extends Model
|
class Log extends Model
|
||||||
{
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
protected $table = 'logs';
|
protected $table = 'logs';
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
|
|
|
||||||
139
app/Models/PlatformAccount.php
Normal file
139
app/Models/PlatformAccount.php
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Database\Factories\PlatformAccountFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Facades\Crypt;
|
||||||
|
use App\Enums\PlatformEnum;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @property int $id
|
||||||
|
* @property PlatformEnum $platform
|
||||||
|
* @property string $instance_url
|
||||||
|
* @property string $username
|
||||||
|
* @property string $password
|
||||||
|
* @property string $settings
|
||||||
|
* @property bool $is_active
|
||||||
|
* @property Carbon $last_tested_at
|
||||||
|
* @property string $status
|
||||||
|
* @property Carbon $created_at
|
||||||
|
* @property Carbon $updated_at
|
||||||
|
* @property Collection<int, PlatformChannel> $activeChannels
|
||||||
|
* @method static where(string $string, PlatformEnum $platform)
|
||||||
|
* @method static orderBy(string $string)
|
||||||
|
* @method static create(array<string, mixed> $validated)
|
||||||
|
*/
|
||||||
|
class PlatformAccount extends Model
|
||||||
|
{
|
||||||
|
/** @use HasFactory<PlatformAccountFactory> */
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'platform',
|
||||||
|
'instance_url',
|
||||||
|
'username',
|
||||||
|
'password',
|
||||||
|
'settings',
|
||||||
|
'is_active',
|
||||||
|
'last_tested_at',
|
||||||
|
'status'
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'platform' => PlatformEnum::class,
|
||||||
|
'settings' => 'array',
|
||||||
|
'is_active' => 'boolean',
|
||||||
|
'last_tested_at' => 'datetime'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Encrypt password when storing
|
||||||
|
/**
|
||||||
|
* @return Attribute<string|null, string|null>
|
||||||
|
*/
|
||||||
|
protected function password(): Attribute
|
||||||
|
{
|
||||||
|
return Attribute::make(
|
||||||
|
get: function ($value, array $attributes) {
|
||||||
|
// Return null if the raw value is null
|
||||||
|
if (is_null($value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return empty string if value is empty
|
||||||
|
if (empty($value)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return Crypt::decryptString($value);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
// If decryption fails, return null to be safe
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
set: function ($value) {
|
||||||
|
// Store null if null is passed
|
||||||
|
if (is_null($value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store empty string as null
|
||||||
|
if (empty($value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Crypt::encryptString($value);
|
||||||
|
},
|
||||||
|
)->withoutObjectCaching();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Get the active accounts for a platform (returns collection)
|
||||||
|
/**
|
||||||
|
* @return Collection<int, PlatformAccount>
|
||||||
|
*/
|
||||||
|
public static function getActive(PlatformEnum $platform): Collection
|
||||||
|
{
|
||||||
|
return static::where('platform', $platform)
|
||||||
|
->where('is_active', true)
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set this account as active (deactivates others for same platform)
|
||||||
|
public function setAsActive(): void
|
||||||
|
{
|
||||||
|
// Deactivate other accounts for this platform
|
||||||
|
static::where('platform', $this->platform)
|
||||||
|
->where('id', '!=', $this->id)
|
||||||
|
->update(['is_active' => false]);
|
||||||
|
|
||||||
|
// Activate this account
|
||||||
|
$this->update(['is_active' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return BelongsToMany<PlatformChannel, $this>
|
||||||
|
*/
|
||||||
|
public function channels(): BelongsToMany
|
||||||
|
{
|
||||||
|
return $this->belongsToMany(PlatformChannel::class, 'platform_account_channels')
|
||||||
|
->withPivot(['is_active', 'priority'])
|
||||||
|
->withTimestamps();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return BelongsToMany<PlatformChannel, $this>
|
||||||
|
*/
|
||||||
|
public function activeChannels(): BelongsToMany
|
||||||
|
{
|
||||||
|
return $this->channels()
|
||||||
|
->wherePivot('is_active', true)
|
||||||
|
->orderByPivot('priority', 'desc');
|
||||||
|
}
|
||||||
|
}
|
||||||
103
app/Models/PlatformChannel.php
Normal file
103
app/Models/PlatformChannel.php
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Database\Factories\PlatformChannelFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @method static findMany(mixed $channel_ids)
|
||||||
|
* @method static create(array $array)
|
||||||
|
* @property integer $id
|
||||||
|
* @property integer $platform_instance_id
|
||||||
|
* @property PlatformInstance $platformInstance
|
||||||
|
* @property integer $channel_id
|
||||||
|
* @property string $name
|
||||||
|
* @property int $language_id
|
||||||
|
* @property Language|null $language
|
||||||
|
* @property boolean $is_active
|
||||||
|
*/
|
||||||
|
class PlatformChannel extends Model
|
||||||
|
{
|
||||||
|
/** @use HasFactory<PlatformChannelFactory> */
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $table = 'platform_channels';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'platform_instance_id',
|
||||||
|
'name',
|
||||||
|
'display_name',
|
||||||
|
'channel_id',
|
||||||
|
'description',
|
||||||
|
'language_id',
|
||||||
|
'is_active'
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'is_active' => 'boolean'
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return BelongsTo<PlatformInstance, $this>
|
||||||
|
*/
|
||||||
|
public function platformInstance(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(PlatformInstance::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return BelongsToMany<PlatformAccount, $this>
|
||||||
|
*/
|
||||||
|
public function platformAccounts(): BelongsToMany
|
||||||
|
{
|
||||||
|
return $this->belongsToMany(PlatformAccount::class, 'platform_account_channels')
|
||||||
|
->withPivot(['is_active', 'priority'])
|
||||||
|
->withTimestamps();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return BelongsToMany<PlatformAccount, $this>
|
||||||
|
*/
|
||||||
|
public function activePlatformAccounts(): BelongsToMany
|
||||||
|
{
|
||||||
|
return $this->platformAccounts()->wherePivot('is_active', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getFullNameAttribute(): string
|
||||||
|
{
|
||||||
|
// For Lemmy, use /c/ prefix
|
||||||
|
return $this->platformInstance->url . '/c/' . $this->name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return BelongsToMany<Feed, $this, Route>
|
||||||
|
*/
|
||||||
|
public function feeds(): BelongsToMany
|
||||||
|
{
|
||||||
|
return $this->belongsToMany(Feed::class, 'routes')
|
||||||
|
->withPivot(['is_active', 'priority'])
|
||||||
|
->withTimestamps();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return BelongsToMany<Feed, $this, Route>
|
||||||
|
*/
|
||||||
|
public function activeFeeds(): BelongsToMany
|
||||||
|
{
|
||||||
|
return $this->feeds()
|
||||||
|
->wherePivot('is_active', true)
|
||||||
|
->orderByPivot('priority', 'desc');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return BelongsTo<Language, $this>
|
||||||
|
*/
|
||||||
|
public function language(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Language::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,14 +3,16 @@
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use App\Enums\PlatformEnum;
|
use App\Enums\PlatformEnum;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @method static where(string $string, PlatformEnum $platform)
|
* @method static where(string $string, PlatformEnum $platform)
|
||||||
* @method static updateOrCreate(array $array, array $array1)
|
* @method static updateOrCreate(array<string, mixed> $array, array<string, mixed> $array1)
|
||||||
*/
|
*/
|
||||||
class PlatformChannelPost extends Model
|
class PlatformChannelPost extends Model
|
||||||
{
|
{
|
||||||
|
use HasFactory;
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'platform',
|
'platform',
|
||||||
'channel_id',
|
'channel_id',
|
||||||
|
|
@ -21,6 +23,9 @@ class PlatformChannelPost extends Model
|
||||||
'posted_at',
|
'posted_at',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
protected function casts(): array
|
protected function casts(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
|
|
||||||
63
app/Models/PlatformInstance.php
Normal file
63
app/Models/PlatformInstance.php
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Enums\PlatformEnum;
|
||||||
|
use Database\Factories\PlatformInstanceFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @method static updateOrCreate(array<string, mixed> $array, $instanceData)
|
||||||
|
* @method static where(string $string, mixed $operator)
|
||||||
|
* @property PlatformEnum $platform
|
||||||
|
* @property string $url
|
||||||
|
* @property string $name
|
||||||
|
* @property string $description
|
||||||
|
* @property boolean $is_active
|
||||||
|
*/
|
||||||
|
class PlatformInstance extends Model
|
||||||
|
{
|
||||||
|
/** @use HasFactory<PlatformInstanceFactory> */
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'platform',
|
||||||
|
'url',
|
||||||
|
'name',
|
||||||
|
'description',
|
||||||
|
'is_active'
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'platform' => PlatformEnum::class,
|
||||||
|
'is_active' => 'boolean'
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return HasMany<PlatformChannel, $this>
|
||||||
|
*/
|
||||||
|
public function channels(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(PlatformChannel::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return BelongsToMany<Language, $this>
|
||||||
|
*/
|
||||||
|
public function languages(): BelongsToMany
|
||||||
|
{
|
||||||
|
return $this->belongsToMany(Language::class)
|
||||||
|
->withPivot(['platform_language_id', 'is_default'])
|
||||||
|
->withTimestamps();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function findByUrl(PlatformEnum $platform, string $url): ?self
|
||||||
|
{
|
||||||
|
return static::where('platform', $platform)
|
||||||
|
->where('url', $url)
|
||||||
|
->first();
|
||||||
|
}
|
||||||
|
}
|
||||||
66
app/Models/Route.php
Normal file
66
app/Models/Route.php
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Database\Factories\RouteFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @property int $feed_id
|
||||||
|
* @property int $platform_channel_id
|
||||||
|
* @property bool $is_active
|
||||||
|
* @property int $priority
|
||||||
|
* @property Carbon $created_at
|
||||||
|
* @property Carbon $updated_at
|
||||||
|
*/
|
||||||
|
class Route extends Model
|
||||||
|
{
|
||||||
|
/** @use HasFactory<RouteFactory> */
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $table = 'routes';
|
||||||
|
|
||||||
|
// Laravel doesn't handle composite primary keys well, so we'll use regular queries
|
||||||
|
protected $primaryKey = null;
|
||||||
|
public $incrementing = false;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'feed_id',
|
||||||
|
'platform_channel_id',
|
||||||
|
'is_active',
|
||||||
|
'priority'
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'is_active' => 'boolean'
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return BelongsTo<Feed, $this>
|
||||||
|
*/
|
||||||
|
public function feed(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Feed::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return BelongsTo<PlatformChannel, $this>
|
||||||
|
*/
|
||||||
|
public function platformChannel(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(PlatformChannel::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return HasMany<Keyword, $this>
|
||||||
|
*/
|
||||||
|
public function keywords(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Keyword::class, 'feed_id', 'feed_id')
|
||||||
|
->where('platform_channel_id', $this->platform_channel_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
62
app/Models/Setting.php
Normal file
62
app/Models/Setting.php
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @method static updateOrCreate(string[] $array, array $array1)
|
||||||
|
* @method static create(string[] $array)
|
||||||
|
* @method static where(string $string, string $key)
|
||||||
|
*/
|
||||||
|
class Setting extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = ['key', 'value'];
|
||||||
|
|
||||||
|
public static function get(string $key, mixed $default = null): mixed
|
||||||
|
{
|
||||||
|
$setting = static::where('key', $key)->first();
|
||||||
|
|
||||||
|
return $setting ? $setting->value : $default;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function set(string $key, mixed $value): void
|
||||||
|
{
|
||||||
|
static::updateOrCreate(['key' => $key], ['value' => $value]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getBool(string $key, bool $default = false): bool
|
||||||
|
{
|
||||||
|
$value = static::get($key, $default);
|
||||||
|
|
||||||
|
return filter_var($value, FILTER_VALIDATE_BOOLEAN);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function setBool(string $key, bool $value): void
|
||||||
|
{
|
||||||
|
static::set($key, $value ? '1' : '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function isArticleProcessingEnabled(): bool
|
||||||
|
{
|
||||||
|
return static::getBool('article_processing_enabled', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function setArticleProcessingEnabled(bool $enabled): void
|
||||||
|
{
|
||||||
|
static::setBool('article_processing_enabled', $enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function isPublishingApprovalsEnabled(): bool
|
||||||
|
{
|
||||||
|
return static::getBool('enable_publishing_approvals', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function setPublishingApprovalsEnabled(bool $enabled): void
|
||||||
|
{
|
||||||
|
static::setBool('enable_publishing_approvals', $enabled);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,11 +6,12 @@
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||||
use Illuminate\Notifications\Notifiable;
|
use Illuminate\Notifications\Notifiable;
|
||||||
|
use Laravel\Sanctum\HasApiTokens;
|
||||||
|
|
||||||
class User extends Authenticatable
|
class User extends Authenticatable
|
||||||
{
|
{
|
||||||
/** @use HasFactory<\Database\Factories\UserFactory> */
|
/** @use HasFactory<\Database\Factories\UserFactory> */
|
||||||
use HasFactory, Notifiable;
|
use HasFactory, Notifiable, HasApiTokens;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The attributes that are mass assignable.
|
* The attributes that are mass assignable.
|
||||||
|
|
|
||||||
|
|
@ -9,16 +9,51 @@ class LemmyRequest
|
||||||
{
|
{
|
||||||
private string $instance;
|
private string $instance;
|
||||||
private ?string $token;
|
private ?string $token;
|
||||||
|
private string $scheme = 'https';
|
||||||
|
|
||||||
public function __construct(string $instance, ?string $token = null)
|
public function __construct(string $instance, ?string $token = null)
|
||||||
{
|
{
|
||||||
$this->instance = $instance;
|
// Detect scheme if provided in the instance string
|
||||||
|
if (preg_match('/^(https?):\/\//i', $instance, $m)) {
|
||||||
|
$this->scheme = strtolower($m[1]);
|
||||||
|
}
|
||||||
|
// Handle both full URLs and just domain names
|
||||||
|
$this->instance = $this->normalizeInstance($instance);
|
||||||
$this->token = $token;
|
$this->token = $token;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize instance URL to just the domain name
|
||||||
|
*/
|
||||||
|
private function normalizeInstance(string $instance): string
|
||||||
|
{
|
||||||
|
// Remove protocol if present
|
||||||
|
$instance = preg_replace('/^https?:\/\//i', '', $instance);
|
||||||
|
|
||||||
|
// Remove trailing slash if present
|
||||||
|
$instance = rtrim($instance, '/');
|
||||||
|
|
||||||
|
return $instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Explicitly set the scheme (http or https) for subsequent requests.
|
||||||
|
*/
|
||||||
|
public function withScheme(string $scheme): self
|
||||||
|
{
|
||||||
|
$scheme = strtolower($scheme);
|
||||||
|
if (in_array($scheme, ['http', 'https'], true)) {
|
||||||
|
$this->scheme = $scheme;
|
||||||
|
}
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $params
|
||||||
|
*/
|
||||||
public function get(string $endpoint, array $params = []): Response
|
public function get(string $endpoint, array $params = []): Response
|
||||||
{
|
{
|
||||||
$url = "https://{$this->instance}/api/v3/{$endpoint}";
|
$url = sprintf('%s://%s/api/v3/%s', $this->scheme, $this->instance, $endpoint);
|
||||||
|
|
||||||
$request = Http::timeout(30);
|
$request = Http::timeout(30);
|
||||||
|
|
||||||
|
|
@ -29,9 +64,12 @@ public function get(string $endpoint, array $params = []): Response
|
||||||
return $request->get($url, $params);
|
return $request->get($url, $params);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
*/
|
||||||
public function post(string $endpoint, array $data = []): Response
|
public function post(string $endpoint, array $data = []): Response
|
||||||
{
|
{
|
||||||
$url = "https://{$this->instance}/api/v3/{$endpoint}";
|
$url = sprintf('%s://%s/api/v3/%s', $this->scheme, $this->instance, $endpoint);
|
||||||
|
|
||||||
$request = Http::timeout(30);
|
$request = Http::timeout(30);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@
|
||||||
use App\Enums\PlatformEnum;
|
use App\Enums\PlatformEnum;
|
||||||
use App\Models\PlatformChannelPost;
|
use App\Models\PlatformChannelPost;
|
||||||
use App\Modules\Lemmy\LemmyRequest;
|
use App\Modules\Lemmy\LemmyRequest;
|
||||||
use App\Services\Auth\LemmyAuthService;
|
|
||||||
use Exception;
|
use Exception;
|
||||||
|
|
||||||
class LemmyApiService
|
class LemmyApiService
|
||||||
|
|
@ -19,33 +18,71 @@ public function __construct(string $instance)
|
||||||
|
|
||||||
public function login(string $username, string $password): ?string
|
public function login(string $username, string $password): ?string
|
||||||
{
|
{
|
||||||
try {
|
// Try HTTPS first; on failure, optionally retry with HTTP to support dev instances
|
||||||
$request = new LemmyRequest($this->instance);
|
$schemesToTry = [];
|
||||||
$response = $request->post('user/login', [
|
if (preg_match('/^https?:\/\//i', $this->instance)) {
|
||||||
'username_or_email' => $username,
|
// Preserve user-provided scheme as first try
|
||||||
'password' => $password,
|
$schemesToTry[] = strtolower(str_starts_with($this->instance, 'http://') ? 'http' : 'https');
|
||||||
]);
|
} else {
|
||||||
|
// Default order: https then http
|
||||||
|
$schemesToTry = ['https', 'http'];
|
||||||
|
}
|
||||||
|
|
||||||
if (!$response->successful()) {
|
foreach ($schemesToTry as $idx => $scheme) {
|
||||||
logger()->error('Lemmy login failed', [
|
try {
|
||||||
'status' => $response->status(),
|
$request = new LemmyRequest($this->instance);
|
||||||
'body' => $response->body()
|
// ensure scheme used matches current attempt
|
||||||
|
$request = $request->withScheme($scheme);
|
||||||
|
|
||||||
|
$response = $request->post('user/login', [
|
||||||
|
'username_or_email' => $username,
|
||||||
|
'password' => $password,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
if (!$response->successful()) {
|
||||||
|
$responseBody = $response->body();
|
||||||
|
logger()->error('Lemmy login failed', [
|
||||||
|
'status' => $response->status(),
|
||||||
|
'body' => $responseBody,
|
||||||
|
'scheme' => $scheme,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Check if it's a rate limit error
|
||||||
|
if (str_contains($responseBody, 'rate_limit_error')) {
|
||||||
|
throw new Exception('Rate limited by Lemmy instance. Please wait a moment and try again.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// If first attempt failed and there is another scheme to try, continue loop
|
||||||
|
if ($idx === 0 && count($schemesToTry) > 1) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $response->json();
|
||||||
|
return $data['jwt'] ?? null;
|
||||||
|
} catch (Exception $e) {
|
||||||
|
// Re-throw rate limit exceptions immediately
|
||||||
|
if (str_contains($e->getMessage(), 'Rate limited')) {
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger()->error('Lemmy login exception', ['error' => $e->getMessage(), 'scheme' => $scheme]);
|
||||||
|
// If this was the first attempt and HTTPS, try HTTP next
|
||||||
|
if ($idx === 0 && in_array('http', $schemesToTry, true)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$data = $response->json();
|
|
||||||
return $data['jwt'] ?? null;
|
|
||||||
} catch (Exception $e) {
|
|
||||||
logger()->error('Lemmy login exception', ['error' => $e->getMessage()]);
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getCommunityId(string $communityName): int
|
public function getCommunityId(string $communityName, string $token): int
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$token = LemmyAuthService::getToken();
|
|
||||||
$request = new LemmyRequest($this->instance, $token);
|
$request = new LemmyRequest($this->instance, $token);
|
||||||
$response = $request->get('community', ['name' => $communityName]);
|
$response = $request->get('community', ['name' => $communityName]);
|
||||||
|
|
||||||
|
|
@ -61,12 +98,12 @@ public function getCommunityId(string $communityName): int
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function syncChannelPosts(string $token, int $communityId, string $communityName): void
|
public function syncChannelPosts(string $token, int $platformChannelId, string $communityName): void
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$request = new LemmyRequest($this->instance, $token);
|
$request = new LemmyRequest($this->instance, $token);
|
||||||
$response = $request->get('post/list', [
|
$response = $request->get('post/list', [
|
||||||
'community_id' => $communityId,
|
'community_id' => $platformChannelId,
|
||||||
'limit' => 50,
|
'limit' => 50,
|
||||||
'sort' => 'New'
|
'sort' => 'New'
|
||||||
]);
|
]);
|
||||||
|
|
@ -74,7 +111,7 @@ public function syncChannelPosts(string $token, int $communityId, string $commun
|
||||||
if (!$response->successful()) {
|
if (!$response->successful()) {
|
||||||
logger()->warning('Failed to sync channel posts', [
|
logger()->warning('Failed to sync channel posts', [
|
||||||
'status' => $response->status(),
|
'status' => $response->status(),
|
||||||
'community_id' => $communityId
|
'platform_channel_id' => $platformChannelId
|
||||||
]);
|
]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -87,7 +124,7 @@ public function syncChannelPosts(string $token, int $communityId, string $commun
|
||||||
|
|
||||||
PlatformChannelPost::storePost(
|
PlatformChannelPost::storePost(
|
||||||
PlatformEnum::LEMMY,
|
PlatformEnum::LEMMY,
|
||||||
(string) $communityId,
|
(string) $platformChannelId,
|
||||||
$communityName,
|
$communityName,
|
||||||
(string) $post['id'],
|
(string) $post['id'],
|
||||||
$post['url'] ?? null,
|
$post['url'] ?? null,
|
||||||
|
|
@ -97,19 +134,22 @@ public function syncChannelPosts(string $token, int $communityId, string $commun
|
||||||
}
|
}
|
||||||
|
|
||||||
logger()->info('Synced channel posts', [
|
logger()->info('Synced channel posts', [
|
||||||
'community_id' => $communityId,
|
'platform_channel_id' => $platformChannelId,
|
||||||
'posts_count' => count($posts)
|
'posts_count' => count($posts)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
logger()->error('Exception while syncing channel posts', [
|
logger()->error('Exception while syncing channel posts', [
|
||||||
'error' => $e->getMessage(),
|
'error' => $e->getMessage(),
|
||||||
'community_id' => $communityId
|
'platform_channel_id' => $platformChannelId
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function createPost(string $token, string $title, string $body, int $communityId, ?string $url = null, ?string $thumbnail = null, ?int $languageId = null): array
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function createPost(string $token, string $title, string $body, int $platformChannelId, ?string $url = null, ?string $thumbnail = null, ?int $languageId = null): array
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$request = new LemmyRequest($this->instance, $token);
|
$request = new LemmyRequest($this->instance, $token);
|
||||||
|
|
@ -117,7 +157,7 @@ public function createPost(string $token, string $title, string $body, int $comm
|
||||||
$postData = [
|
$postData = [
|
||||||
'name' => $title,
|
'name' => $title,
|
||||||
'body' => $body,
|
'body' => $body,
|
||||||
'community_id' => $communityId,
|
'community_id' => $platformChannelId,
|
||||||
];
|
];
|
||||||
|
|
||||||
if ($url) {
|
if ($url) {
|
||||||
|
|
@ -145,6 +185,9 @@ public function createPost(string $token, string $title, string $body, int $comm
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, mixed>
|
||||||
|
*/
|
||||||
public function getLanguages(): array
|
public function getLanguages(): array
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -2,137 +2,51 @@
|
||||||
|
|
||||||
namespace App\Modules\Lemmy\Services;
|
namespace App\Modules\Lemmy\Services;
|
||||||
|
|
||||||
use App\Enums\PlatformEnum;
|
use App\Exceptions\PlatformAuthException;
|
||||||
use App\Exceptions\PublishException;
|
|
||||||
use App\Models\Article;
|
use App\Models\Article;
|
||||||
use App\Models\ArticlePublication;
|
use App\Models\PlatformAccount;
|
||||||
|
use App\Models\PlatformChannel;
|
||||||
use App\Services\Auth\LemmyAuthService;
|
use App\Services\Auth\LemmyAuthService;
|
||||||
use Exception;
|
use Exception;
|
||||||
use Illuminate\Support\Facades\Cache;
|
|
||||||
|
|
||||||
class LemmyPublisher
|
class LemmyPublisher
|
||||||
{
|
{
|
||||||
private LemmyApiService $api;
|
private LemmyApiService $api;
|
||||||
private string $username;
|
private PlatformAccount $account;
|
||||||
private string $community;
|
|
||||||
|
|
||||||
public function __construct(string $instance, string $username, string $community)
|
public function __construct(PlatformAccount $account)
|
||||||
{
|
{
|
||||||
$this->api = new LemmyApiService($instance);
|
$this->api = new LemmyApiService($account->instance_url);
|
||||||
$this->username = $username;
|
$this->account = $account;
|
||||||
$this->community = $community;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function fromConfig(): self
|
|
||||||
{
|
|
||||||
return new self(
|
|
||||||
config('lemmy.instance'),
|
|
||||||
config('lemmy.username'),
|
|
||||||
config('lemmy.community')
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @throws PublishException
|
* @param array<string, mixed> $extractedData
|
||||||
|
* @return array<string, mixed>
|
||||||
|
* @throws PlatformAuthException
|
||||||
|
* @throws Exception
|
||||||
*/
|
*/
|
||||||
public function publish(Article $article, array $extractedData): ArticlePublication
|
public function publishToChannel(Article $article, array $extractedData, PlatformChannel $channel): array
|
||||||
{
|
{
|
||||||
try {
|
$token = resolve(LemmyAuthService::class)->getToken($this->account);
|
||||||
$token = LemmyAuthService::getToken();
|
|
||||||
$communityId = $this->getCommunityId();
|
|
||||||
|
|
||||||
$languageId = $this->getLanguageIdForSource($article->url);
|
// Use the language ID from extracted data (should be set during validation)
|
||||||
|
$languageId = $extractedData['language_id'] ?? null;
|
||||||
|
|
||||||
$postData = $this->api->createPost(
|
// Resolve community name to numeric ID if needed
|
||||||
$token,
|
$communityId = is_numeric($channel->channel_id)
|
||||||
$extractedData['title'] ?? 'Untitled',
|
? (int) $channel->channel_id
|
||||||
$extractedData['description'] ?? '',
|
: $this->api->getCommunityId($channel->channel_id, $token);
|
||||||
$communityId,
|
|
||||||
$article->url,
|
|
||||||
$extractedData['thumbnail'] ?? null,
|
|
||||||
$languageId
|
|
||||||
);
|
|
||||||
|
|
||||||
return $this->createPublicationRecord($article, $postData, $communityId);
|
return $this->api->createPost(
|
||||||
} catch (Exception $e) {
|
$token,
|
||||||
throw new PublishException($article, PlatformEnum::LEMMY, $e);
|
$extractedData['title'] ?? 'Untitled',
|
||||||
}
|
$extractedData['description'] ?? '',
|
||||||
|
$communityId,
|
||||||
|
$article->url,
|
||||||
|
$extractedData['thumbnail'] ?? null,
|
||||||
|
$languageId
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function getCommunityId(): int
|
|
||||||
{
|
|
||||||
$cacheKey = "lemmy_community_id_{$this->community}";
|
|
||||||
$cachedId = Cache::get($cacheKey);
|
|
||||||
|
|
||||||
if ($cachedId) {
|
|
||||||
return $cachedId;
|
|
||||||
}
|
|
||||||
|
|
||||||
$communityId = $this->api->getCommunityId($this->community);
|
|
||||||
Cache::put($cacheKey, $communityId, 3600);
|
|
||||||
|
|
||||||
return $communityId;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function createPublicationRecord(Article $article, array $postData, int $communityId): ArticlePublication
|
|
||||||
{
|
|
||||||
return ArticlePublication::create([
|
|
||||||
'article_id' => $article->id,
|
|
||||||
'post_id' => $postData['post_view']['post']['id'],
|
|
||||||
'community_id' => $communityId,
|
|
||||||
'published_by' => $this->username,
|
|
||||||
'published_at' => now(),
|
|
||||||
'platform' => 'lemmy',
|
|
||||||
'publication_data' => $postData,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function getLanguageIdForSource(string $url): ?int
|
|
||||||
{
|
|
||||||
// TODO this will be obsolete when sources can be created from the UI, so we can remove these hard-coded sources
|
|
||||||
|
|
||||||
// VRT articles are in Dutch
|
|
||||||
if (str_contains($url, 'vrt.be')) {
|
|
||||||
return $this->getLanguageId('nl'); // Dutch
|
|
||||||
}
|
|
||||||
|
|
||||||
// Belga articles are in English (based on URL structure)
|
|
||||||
if (str_contains($url, 'belganewsagency.eu')) {
|
|
||||||
return $this->getLanguageId('en'); // English
|
|
||||||
}
|
|
||||||
|
|
||||||
return null; // Default to no language specified
|
|
||||||
}
|
|
||||||
|
|
||||||
private function getLanguageId(string $languageCode): ?int
|
|
||||||
{
|
|
||||||
$cacheKey = "lemmy_language_id_{$languageCode}";
|
|
||||||
$cachedId = Cache::get($cacheKey);
|
|
||||||
|
|
||||||
if ($cachedId !== null) {
|
|
||||||
return $cachedId;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$languages = $this->api->getLanguages();
|
|
||||||
|
|
||||||
foreach ($languages as $language) {
|
|
||||||
if (isset($language['code']) && $language['code'] === $languageCode) {
|
|
||||||
$languageId = $language['id'];
|
|
||||||
Cache::put($cacheKey, $languageId, 3600);
|
|
||||||
return $languageId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cache null result to avoid repeated API calls
|
|
||||||
Cache::put($cacheKey, null, 3600);
|
|
||||||
return null;
|
|
||||||
} catch (Exception $e) {
|
|
||||||
logger()->warning('Failed to get language ID', [
|
|
||||||
'language_code' => $languageCode,
|
|
||||||
'error' => $e->getMessage()
|
|
||||||
]);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,33 +2,35 @@
|
||||||
|
|
||||||
namespace App\Providers;
|
namespace App\Providers;
|
||||||
|
|
||||||
use App\Events\ArticleFetched;
|
use App\Enums\LogLevelEnum;
|
||||||
use App\Events\ArticleReadyToPublish;
|
|
||||||
use App\Events\ExceptionOccurred;
|
use App\Events\ExceptionOccurred;
|
||||||
use App\Listeners\ValidateArticle;
|
|
||||||
use App\Listeners\LogExceptionToDatabase;
|
use App\Listeners\LogExceptionToDatabase;
|
||||||
use App\Listeners\PublishArticle;
|
use Error;
|
||||||
use App\LogLevelEnum;
|
|
||||||
use Illuminate\Contracts\Debug\ExceptionHandler;
|
use Illuminate\Contracts\Debug\ExceptionHandler;
|
||||||
use Illuminate\Support\Facades\Event;
|
use Illuminate\Support\Facades\Event;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
use InvalidArgumentException;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
class AppServiceProvider extends ServiceProvider
|
class AppServiceProvider extends ServiceProvider
|
||||||
{
|
{
|
||||||
public function register(): void
|
public function register(): void
|
||||||
{
|
{
|
||||||
//
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function boot(): void
|
public function boot(): void
|
||||||
{
|
{
|
||||||
|
|
||||||
Event::listen(
|
Event::listen(
|
||||||
ExceptionOccurred::class,
|
ExceptionOccurred::class,
|
||||||
LogExceptionToDatabase::class,
|
LogExceptionToDatabase::class,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
Event::listen(
|
||||||
|
\App\Events\NewArticleFetched::class,
|
||||||
|
\App\Listeners\ValidateArticleListener::class,
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
app()->make(ExceptionHandler::class)
|
app()->make(ExceptionHandler::class)
|
||||||
->reportable(function (Throwable $e) {
|
->reportable(function (Throwable $e) {
|
||||||
$level = $this->mapExceptionToLogLevel($e);
|
$level = $this->mapExceptionToLogLevel($e);
|
||||||
|
|
@ -40,9 +42,8 @@ public function boot(): void
|
||||||
private function mapExceptionToLogLevel(Throwable $exception): LogLevelEnum
|
private function mapExceptionToLogLevel(Throwable $exception): LogLevelEnum
|
||||||
{
|
{
|
||||||
return match (true) {
|
return match (true) {
|
||||||
$exception instanceof \Error => LogLevelEnum::CRITICAL,
|
$exception instanceof Error => LogLevelEnum::CRITICAL,
|
||||||
$exception instanceof \RuntimeException => LogLevelEnum::ERROR,
|
$exception instanceof InvalidArgumentException => LogLevelEnum::WARNING,
|
||||||
$exception instanceof \InvalidArgumentException => LogLevelEnum::WARNING,
|
|
||||||
default => LogLevelEnum::ERROR,
|
default => LogLevelEnum::ERROR,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
34
app/Providers/HorizonServiceProvider.php
Normal file
34
app/Providers/HorizonServiceProvider.php
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Providers;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
use Laravel\Horizon\Horizon;
|
||||||
|
use Laravel\Horizon\HorizonApplicationServiceProvider;
|
||||||
|
|
||||||
|
class HorizonServiceProvider extends HorizonApplicationServiceProvider
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Bootstrap any application services.
|
||||||
|
*/
|
||||||
|
public function boot(): void
|
||||||
|
{
|
||||||
|
parent::boot();
|
||||||
|
|
||||||
|
// Horizon::routeSmsNotificationsTo('15556667777');
|
||||||
|
// Horizon::routeMailNotificationsTo('example@example.com');
|
||||||
|
// Horizon::routeSlackNotificationsTo('slack-webhook-url', '#channel');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register the Horizon gate.
|
||||||
|
*
|
||||||
|
* This gate determines who can access Horizon in non-local environments.
|
||||||
|
*/
|
||||||
|
protected function gate(): void
|
||||||
|
{
|
||||||
|
Gate::define('viewHorizon', function ($user = null) {
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,38 +3,88 @@
|
||||||
namespace App\Services\Article;
|
namespace App\Services\Article;
|
||||||
|
|
||||||
use App\Models\Article;
|
use App\Models\Article;
|
||||||
|
use App\Models\Feed;
|
||||||
use App\Services\Http\HttpFetcher;
|
use App\Services\Http\HttpFetcher;
|
||||||
use App\Services\Factories\ArticleParserFactory;
|
use App\Services\Factories\ArticleParserFactory;
|
||||||
use App\Services\Factories\HomepageParserFactory;
|
use App\Services\Factories\HomepageParserFactory;
|
||||||
|
use App\Services\Log\LogSaver;
|
||||||
use Exception;
|
use Exception;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
class ArticleFetcher
|
class ArticleFetcher
|
||||||
{
|
{
|
||||||
public static function getNewArticles(): Collection
|
public function __construct(
|
||||||
|
private LogSaver $logSaver
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Collection<int, Article>
|
||||||
|
*/
|
||||||
|
public function getArticlesFromFeed(Feed $feed): Collection
|
||||||
|
{
|
||||||
|
if ($feed->type === 'rss') {
|
||||||
|
return $this->getArticlesFromRssFeed($feed);
|
||||||
|
} elseif ($feed->type === 'website') {
|
||||||
|
return $this->getArticlesFromWebsiteFeed($feed);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->logSaver->warning("Unsupported feed type", null, [
|
||||||
|
'feed_id' => $feed->id,
|
||||||
|
'feed_type' => $feed->type
|
||||||
|
]);
|
||||||
|
|
||||||
|
return collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Collection<int, Article>
|
||||||
|
*/
|
||||||
|
private function getArticlesFromRssFeed(Feed $feed): Collection
|
||||||
|
{
|
||||||
|
// TODO: Implement RSS feed parsing
|
||||||
|
// For now, return empty collection
|
||||||
|
return collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Collection<int, Article>
|
||||||
|
*/
|
||||||
|
private function getArticlesFromWebsiteFeed(Feed $feed): Collection
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$allArticles = collect();
|
// Try to get parser for this feed
|
||||||
|
$parser = HomepageParserFactory::getParserForFeed($feed);
|
||||||
|
|
||||||
foreach (HomepageParserFactory::getAllParsers() as $parser) {
|
if (! $parser) {
|
||||||
$html = HttpFetcher::fetchHtml($parser->getHomepageUrl());
|
$this->logSaver->warning("No parser available for feed URL", null, [
|
||||||
$urls = $parser->extractArticleUrls($html);
|
'feed_id' => $feed->id,
|
||||||
|
'feed_url' => $feed->url
|
||||||
|
]);
|
||||||
|
|
||||||
$articles = collect($urls)
|
return collect();
|
||||||
->map(fn (string $url) => self::saveArticle($url));
|
|
||||||
|
|
||||||
$allArticles = $allArticles->merge($articles);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return $allArticles->filter();
|
$html = HttpFetcher::fetchHtml($feed->url);
|
||||||
} catch (Exception $e) {
|
$urls = $parser->extractArticleUrls($html);
|
||||||
logger()->error("Failed to get new articles", ['error' => $e->getMessage()]);
|
|
||||||
|
|
||||||
return new Collection([]);
|
return collect($urls)
|
||||||
|
->map(fn (string $url) => $this->saveArticle($url, $feed->id));
|
||||||
|
|
||||||
|
} catch (Exception $e) {
|
||||||
|
$this->logSaver->error("Failed to fetch articles from website feed", null, [
|
||||||
|
'feed_id' => $feed->id,
|
||||||
|
'feed_url' => $feed->url,
|
||||||
|
'error' => $e->getMessage()
|
||||||
|
]);
|
||||||
|
|
||||||
|
return collect();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function fetchArticleData(Article $article): array
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function fetchArticleData(Article $article): array
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$html = HttpFetcher::fetchHtml($article->url);
|
$html = HttpFetcher::fetchHtml($article->url);
|
||||||
|
|
@ -42,7 +92,7 @@ public static function fetchArticleData(Article $article): array
|
||||||
|
|
||||||
return $parser->extractData($html);
|
return $parser->extractData($html);
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
logger()->error('Exception while fetching article data', [
|
$this->logSaver->error('Exception while fetching article data', null, [
|
||||||
'url' => $article->url,
|
'url' => $article->url,
|
||||||
'error' => $e->getMessage()
|
'error' => $e->getMessage()
|
||||||
]);
|
]);
|
||||||
|
|
@ -51,7 +101,7 @@ public static function fetchArticleData(Article $article): array
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function saveArticle(string $url): Article
|
private function saveArticle(string $url, ?int $feedId = null): Article
|
||||||
{
|
{
|
||||||
$existingArticle = Article::where('url', $url)->first();
|
$existingArticle = Article::where('url', $url)->first();
|
||||||
|
|
||||||
|
|
@ -59,6 +109,37 @@ private static function saveArticle(string $url): Article
|
||||||
return $existingArticle;
|
return $existingArticle;
|
||||||
}
|
}
|
||||||
|
|
||||||
return Article::create(['url' => $url]);
|
// Extract a basic title from URL as fallback
|
||||||
|
$fallbackTitle = $this->generateFallbackTitle($url);
|
||||||
|
|
||||||
|
try {
|
||||||
|
return Article::create([
|
||||||
|
'url' => $url,
|
||||||
|
'feed_id' => $feedId,
|
||||||
|
'title' => $fallbackTitle,
|
||||||
|
]);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->logSaver->error("Failed to create article - title validation failed", null, [
|
||||||
|
'url' => $url,
|
||||||
|
'feed_id' => $feedId,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
'suggestion' => 'Check regex parsing patterns for title extraction'
|
||||||
|
]);
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function generateFallbackTitle(string $url): string
|
||||||
|
{
|
||||||
|
// Extract filename from URL as a basic fallback title
|
||||||
|
$path = parse_url($url, PHP_URL_PATH);
|
||||||
|
$filename = basename($path ?: $url);
|
||||||
|
|
||||||
|
// Remove file extension and convert to readable format
|
||||||
|
$title = preg_replace('/\.[^.]*$/', '', $filename);
|
||||||
|
$title = str_replace(['-', '_'], ' ', $title);
|
||||||
|
$title = ucwords($title);
|
||||||
|
|
||||||
|
return $title ?: 'Untitled Article';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,40 +6,62 @@
|
||||||
|
|
||||||
class ValidationService
|
class ValidationService
|
||||||
{
|
{
|
||||||
public static function validate(Article $article): Article
|
public function __construct(
|
||||||
|
private ArticleFetcher $articleFetcher
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function validate(Article $article): Article
|
||||||
{
|
{
|
||||||
logger('Checking keywords for article: ' . $article->id);
|
logger('Checking keywords for article: ' . $article->id);
|
||||||
|
|
||||||
$articleData = ArticleFetcher::fetchArticleData($article);
|
$articleData = $this->articleFetcher->fetchArticleData($article);
|
||||||
|
|
||||||
if (!isset($articleData['full_article'])) {
|
// Update article with fetched metadata (title, description)
|
||||||
logger()->warning('Article data missing full_article key', [
|
$updateData = [];
|
||||||
|
|
||||||
|
if (!empty($articleData)) {
|
||||||
|
$updateData['title'] = $articleData['title'] ?? $article->title;
|
||||||
|
$updateData['description'] = $articleData['description'] ?? $article->description;
|
||||||
|
$updateData['content'] = $articleData['full_article'] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isset($articleData['full_article']) || empty($articleData['full_article'])) {
|
||||||
|
logger()->warning('Article data missing full_article content', [
|
||||||
'article_id' => $article->id,
|
'article_id' => $article->id,
|
||||||
'url' => $article->url
|
'url' => $article->url
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$article->update([
|
$updateData['approval_status'] = 'rejected';
|
||||||
'is_valid' => false,
|
$article->update($updateData);
|
||||||
'validated_at' => now(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return $article->refresh();
|
return $article->refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
$validationResult = self::validateByKeywords($articleData['full_article']);
|
// Validate using extracted content (not stored)
|
||||||
|
$validationResult = $this->validateByKeywords($articleData['full_article']);
|
||||||
|
$updateData['approval_status'] = $validationResult ? 'approved' : 'pending';
|
||||||
|
|
||||||
$article->update([
|
$article->update($updateData);
|
||||||
'is_valid' => $validationResult,
|
|
||||||
'validated_at' => now(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return $article->refresh();
|
return $article->refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function validateByKeywords(string $full_article): bool
|
private function validateByKeywords(string $full_article): bool
|
||||||
{
|
{
|
||||||
|
// Belgian news content keywords - broader set for Belgian news relevance
|
||||||
$keywords = [
|
$keywords = [
|
||||||
'N-VA', 'Bart De Wever', 'Frank Vandenbroucke',
|
// Political parties and leaders
|
||||||
|
'N-VA', 'Bart De Wever', 'Frank Vandenbroucke', 'Alexander De Croo',
|
||||||
|
'Vooruit', 'Open Vld', 'CD&V', 'Vlaams Belang', 'PTB', 'PVDA',
|
||||||
|
|
||||||
|
// Belgian locations and institutions
|
||||||
|
'Belgium', 'Belgian', 'Flanders', 'Flemish', 'Wallonia', 'Brussels',
|
||||||
|
'Antwerp', 'Ghent', 'Bruges', 'Leuven', 'Mechelen', 'Namur', 'Liège', 'Charleroi',
|
||||||
|
'parliament', 'government', 'minister', 'policy', 'law', 'legislation',
|
||||||
|
|
||||||
|
// Common Belgian news topics
|
||||||
|
'economy', 'economic', 'education', 'healthcare', 'transport', 'climate', 'energy',
|
||||||
|
'European', 'EU', 'migration', 'security', 'justice', 'culture', 'police'
|
||||||
];
|
];
|
||||||
|
|
||||||
foreach ($keywords as $keyword) {
|
foreach ($keywords as $keyword) {
|
||||||
|
|
|
||||||
|
|
@ -4,37 +4,69 @@
|
||||||
|
|
||||||
use App\Enums\PlatformEnum;
|
use App\Enums\PlatformEnum;
|
||||||
use App\Exceptions\PlatformAuthException;
|
use App\Exceptions\PlatformAuthException;
|
||||||
|
use App\Models\PlatformAccount;
|
||||||
use App\Modules\Lemmy\Services\LemmyApiService;
|
use App\Modules\Lemmy\Services\LemmyApiService;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Exception;
|
||||||
|
|
||||||
class LemmyAuthService
|
class LemmyAuthService
|
||||||
{
|
{
|
||||||
public static function getToken(): string
|
/**
|
||||||
|
* @throws PlatformAuthException
|
||||||
|
*/
|
||||||
|
public function getToken(PlatformAccount $account): string
|
||||||
{
|
{
|
||||||
$cachedToken = Cache::get('lemmy_jwt_token');
|
if (! $account->username || ! $account->password || ! $account->instance_url) {
|
||||||
|
throw new PlatformAuthException(PlatformEnum::LEMMY, 'Missing credentials for account: ' . $account->username);
|
||||||
if ($cachedToken) {
|
|
||||||
return $cachedToken;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$username = config('lemmy.username');
|
$api = new LemmyApiService($account->instance_url);
|
||||||
$password = config('lemmy.password');
|
$token = $api->login($account->username, $account->password);
|
||||||
$instance = config('lemmy.instance');
|
|
||||||
|
|
||||||
if (!$username || !$password || !$instance) {
|
|
||||||
throw new PlatformAuthException(PlatformEnum::LEMMY, 'Missing credentials or instance');
|
|
||||||
}
|
|
||||||
|
|
||||||
$api = new LemmyApiService($instance);
|
|
||||||
$token = $api->login($username, $password);
|
|
||||||
|
|
||||||
if (!$token) {
|
if (!$token) {
|
||||||
throw new PlatformAuthException(PlatformEnum::LEMMY, 'Login failed');
|
throw new PlatformAuthException(PlatformEnum::LEMMY, 'Login failed for account: ' . $account->username);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache for 50 minutes (3000 seconds) to allow buffer before token expires
|
|
||||||
Cache::put('lemmy_jwt_token', $token, config('lemmy.token_ttl', 3000));
|
|
||||||
|
|
||||||
return $token;
|
return $token;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authenticate with Lemmy API and return user data with JWT
|
||||||
|
* @throws PlatformAuthException
|
||||||
|
*/
|
||||||
|
public function authenticate(string $instanceUrl, string $username, string $password): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$api = new LemmyApiService($instanceUrl);
|
||||||
|
$token = $api->login($username, $password);
|
||||||
|
|
||||||
|
if (!$token) {
|
||||||
|
// Throw a clean exception that will be caught and handled by the controller
|
||||||
|
throw new PlatformAuthException(PlatformEnum::LEMMY, 'Invalid credentials');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user info with the token
|
||||||
|
// For now, we'll return a basic response structure
|
||||||
|
// In a real implementation, you might want to fetch user details
|
||||||
|
return [
|
||||||
|
'jwt' => $token,
|
||||||
|
'person_view' => [
|
||||||
|
'person' => [
|
||||||
|
'id' => 0, // Would need API call to get actual user info
|
||||||
|
'display_name' => null,
|
||||||
|
'bio' => null,
|
||||||
|
]
|
||||||
|
]
|
||||||
|
];
|
||||||
|
} catch (PlatformAuthException $e) {
|
||||||
|
// Re-throw PlatformAuthExceptions as-is to avoid nesting
|
||||||
|
throw $e;
|
||||||
|
} catch (Exception $e) {
|
||||||
|
// Check if it's a rate limit error
|
||||||
|
if (str_contains($e->getMessage(), 'Rate limited by')) {
|
||||||
|
throw new PlatformAuthException(PlatformEnum::LEMMY, $e->getMessage());
|
||||||
|
}
|
||||||
|
// For other exceptions, throw a clean PlatformAuthException
|
||||||
|
throw new PlatformAuthException(PlatformEnum::LEMMY, 'Connection failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
98
app/Services/DashboardStatsService.php
Normal file
98
app/Services/DashboardStatsService.php
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\Article;
|
||||||
|
use App\Models\ArticlePublication;
|
||||||
|
use App\Models\Feed;
|
||||||
|
use App\Models\PlatformAccount;
|
||||||
|
use App\Models\PlatformChannel;
|
||||||
|
use App\Models\Route;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
class DashboardStatsService
|
||||||
|
{
|
||||||
|
public function getStats(string $period = 'today'): array
|
||||||
|
{
|
||||||
|
$dateRange = $this->getDateRange($period);
|
||||||
|
|
||||||
|
// Get articles fetched for the period
|
||||||
|
$articlesFetchedQuery = Article::query();
|
||||||
|
if ($dateRange) {
|
||||||
|
$articlesFetchedQuery->whereBetween('created_at', $dateRange);
|
||||||
|
}
|
||||||
|
$articlesFetched = $articlesFetchedQuery->count();
|
||||||
|
|
||||||
|
// Get articles published for the period
|
||||||
|
$articlesPublishedQuery = ArticlePublication::query()
|
||||||
|
->whereNotNull('published_at');
|
||||||
|
if ($dateRange) {
|
||||||
|
$articlesPublishedQuery->whereBetween('published_at', $dateRange);
|
||||||
|
}
|
||||||
|
$articlesPublished = $articlesPublishedQuery->count();
|
||||||
|
|
||||||
|
// Calculate published percentage
|
||||||
|
$publishedPercentage = $articlesFetched > 0 ? round(($articlesPublished / $articlesFetched) * 100, 1) : 0.0;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'articles_fetched' => $articlesFetched,
|
||||||
|
'articles_published' => $articlesPublished,
|
||||||
|
'published_percentage' => $publishedPercentage,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public function getAvailablePeriods(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'today' => 'Today',
|
||||||
|
'week' => 'This Week',
|
||||||
|
'month' => 'This Month',
|
||||||
|
'year' => 'This Year',
|
||||||
|
'all' => 'All Time',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{0: Carbon, 1: Carbon}|null
|
||||||
|
*/
|
||||||
|
private function getDateRange(string $period): ?array
|
||||||
|
{
|
||||||
|
$now = Carbon::now();
|
||||||
|
|
||||||
|
return match ($period) {
|
||||||
|
'today' => [$now->copy()->startOfDay(), $now->copy()->endOfDay()],
|
||||||
|
'week' => [$now->copy()->startOfWeek(), $now->copy()->endOfWeek()],
|
||||||
|
'month' => [$now->copy()->startOfMonth(), $now->copy()->endOfMonth()],
|
||||||
|
'year' => [$now->copy()->startOfYear(), $now->copy()->endOfYear()],
|
||||||
|
'all' => null, // No date filtering for all-time stats
|
||||||
|
default => [$now->copy()->startOfDay(), $now->copy()->endOfDay()],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSystemStats(): array
|
||||||
|
{
|
||||||
|
$totalFeeds = Feed::query()->count();
|
||||||
|
$activeFeeds = Feed::query()->where('is_active', 1)->count();
|
||||||
|
$totalPlatformAccounts = PlatformAccount::query()->count();
|
||||||
|
$activePlatformAccounts = PlatformAccount::query()->where('is_active', 1)->count();
|
||||||
|
$totalPlatformChannels = PlatformChannel::query()->count();
|
||||||
|
$activePlatformChannels = PlatformChannel::query()->where('is_active', 1)->count();
|
||||||
|
$totalRoutes = Route::query()->count();
|
||||||
|
$activeRoutes = Route::query()->where('is_active', 1)->count();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'total_feeds' => $totalFeeds,
|
||||||
|
'active_feeds' => $activeFeeds,
|
||||||
|
'total_platform_accounts' => $totalPlatformAccounts,
|
||||||
|
'active_platform_accounts' => $activePlatformAccounts,
|
||||||
|
'total_platform_channels' => $totalPlatformChannels,
|
||||||
|
'active_platform_channels' => $activePlatformChannels,
|
||||||
|
'total_routes' => $totalRoutes,
|
||||||
|
'active_routes' => $activeRoutes,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,17 +3,24 @@
|
||||||
namespace App\Services\Factories;
|
namespace App\Services\Factories;
|
||||||
|
|
||||||
use App\Contracts\ArticleParserInterface;
|
use App\Contracts\ArticleParserInterface;
|
||||||
|
use App\Models\Feed;
|
||||||
use App\Services\Parsers\VrtArticleParser;
|
use App\Services\Parsers\VrtArticleParser;
|
||||||
use App\Services\Parsers\BelgaArticleParser;
|
use App\Services\Parsers\BelgaArticleParser;
|
||||||
use Exception;
|
use Exception;
|
||||||
|
|
||||||
class ArticleParserFactory
|
class ArticleParserFactory
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* @var array<int, class-string<ArticleParserInterface>>
|
||||||
|
*/
|
||||||
private static array $parsers = [
|
private static array $parsers = [
|
||||||
VrtArticleParser::class,
|
VrtArticleParser::class,
|
||||||
BelgaArticleParser::class,
|
BelgaArticleParser::class,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
public static function getParser(string $url): ArticleParserInterface
|
public static function getParser(string $url): ArticleParserInterface
|
||||||
{
|
{
|
||||||
foreach (self::$parsers as $parserClass) {
|
foreach (self::$parsers as $parserClass) {
|
||||||
|
|
@ -27,6 +34,28 @@ public static function getParser(string $url): ArticleParserInterface
|
||||||
throw new Exception("No parser found for URL: {$url}");
|
throw new Exception("No parser found for URL: {$url}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function getParserForFeed(Feed $feed, string $parserType = 'article'): ?ArticleParserInterface
|
||||||
|
{
|
||||||
|
if (!$feed->provider) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$providerConfig = config("feed.providers.{$feed->provider}");
|
||||||
|
if (!$providerConfig || !isset($providerConfig['parsers'][$parserType])) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$parserClass = $providerConfig['parsers'][$parserType];
|
||||||
|
if (!class_exists($parserClass)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new $parserClass();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
public static function getSupportedSources(): array
|
public static function getSupportedSources(): array
|
||||||
{
|
{
|
||||||
return array_map(function($parserClass) {
|
return array_map(function($parserClass) {
|
||||||
|
|
@ -35,6 +64,9 @@ public static function getSupportedSources(): array
|
||||||
}, self::$parsers);
|
}, self::$parsers);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param class-string<ArticleParserInterface> $parserClass
|
||||||
|
*/
|
||||||
public static function registerParser(string $parserClass): void
|
public static function registerParser(string $parserClass): void
|
||||||
{
|
{
|
||||||
if (!in_array($parserClass, self::$parsers)) {
|
if (!in_array($parserClass, self::$parsers)) {
|
||||||
|
|
|
||||||
|
|
@ -3,17 +3,24 @@
|
||||||
namespace App\Services\Factories;
|
namespace App\Services\Factories;
|
||||||
|
|
||||||
use App\Contracts\HomepageParserInterface;
|
use App\Contracts\HomepageParserInterface;
|
||||||
|
use App\Models\Feed;
|
||||||
use App\Services\Parsers\VrtHomepageParserAdapter;
|
use App\Services\Parsers\VrtHomepageParserAdapter;
|
||||||
use App\Services\Parsers\BelgaHomepageParserAdapter;
|
use App\Services\Parsers\BelgaHomepageParserAdapter;
|
||||||
use Exception;
|
use Exception;
|
||||||
|
|
||||||
class HomepageParserFactory
|
class HomepageParserFactory
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* @var array<int, class-string<HomepageParserInterface>>
|
||||||
|
*/
|
||||||
private static array $parsers = [
|
private static array $parsers = [
|
||||||
VrtHomepageParserAdapter::class,
|
VrtHomepageParserAdapter::class,
|
||||||
BelgaHomepageParserAdapter::class,
|
BelgaHomepageParserAdapter::class,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
public static function getParser(string $url): HomepageParserInterface
|
public static function getParser(string $url): HomepageParserInterface
|
||||||
{
|
{
|
||||||
foreach (self::$parsers as $parserClass) {
|
foreach (self::$parsers as $parserClass) {
|
||||||
|
|
@ -27,23 +34,22 @@ public static function getParser(string $url): HomepageParserInterface
|
||||||
throw new Exception("No homepage parser found for URL: {$url}");
|
throw new Exception("No homepage parser found for URL: {$url}");
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function getAllParsers(): array
|
public static function getParserForFeed(Feed $feed): ?HomepageParserInterface
|
||||||
{
|
{
|
||||||
return array_map(fn($parserClass) => new $parserClass(), self::$parsers);
|
if (!$feed->provider) {
|
||||||
}
|
return null;
|
||||||
|
|
||||||
public static function getSupportedSources(): array
|
|
||||||
{
|
|
||||||
return array_map(function($parserClass) {
|
|
||||||
$parser = new $parserClass();
|
|
||||||
return $parser->getSourceName();
|
|
||||||
}, self::$parsers);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function registerParser(string $parserClass): void
|
|
||||||
{
|
|
||||||
if (!in_array($parserClass, self::$parsers)) {
|
|
||||||
self::$parsers[] = $parserClass;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$providerConfig = config("feed.providers.{$feed->provider}");
|
||||||
|
if (!$providerConfig || !isset($providerConfig['parsers']['homepage'])) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$parserClass = $providerConfig['parsers']['homepage'];
|
||||||
|
if (!class_exists($parserClass)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new $parserClass();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue