Compare commits
39 commits
feature/2-
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 4b31f3d315 | |||
| 592a402d23 | |||
| fc6fd87c4b | |||
| bcbc1ce8e7 | |||
| 71668ea5bd | |||
| b1cf8b5f22 | |||
| 0baa87e373 | |||
| 7412316746 | |||
| d57af05974 | |||
| 1b7c04e29f | |||
| 7bb1bb4161 | |||
| 0de6c30dab | |||
| 6723c9813b | |||
| 01648359b5 | |||
| efa3f62146 | |||
| 89184b79a5 | |||
| 98230a5ef2 | |||
| 197a74ee9b | |||
| faed07395e | |||
| 2ed9dfbdaa | |||
| b93e6cb832 | |||
| 09236f6f10 | |||
| 1174f2fbda | |||
| 53840d5ced | |||
| 8336df4551 | |||
| f226295d72 | |||
| 28da206043 | |||
| 77817bca14 | |||
| dc00300f44 | |||
| ecfadbc2ad | |||
| 01ee82cbac | |||
| 9e22c23208 | |||
| 3c32d49977 | |||
| 94abe140e1 | |||
| 6e5fb9cb36 | |||
| 71212ef9da | |||
| 630a8cc73f | |||
| 5a26120141 | |||
| 71ed93fda0 |
558 changed files with 12078 additions and 6546 deletions
62
.dockerignore
Normal file
62
.dockerignore
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
# Git
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.gitattributes
|
||||||
|
|
||||||
|
# Documentation
|
||||||
|
*.md
|
||||||
|
LICENSE
|
||||||
|
docs/
|
||||||
|
|
||||||
|
# Environment files
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# Dependencies (will be installed fresh)
|
||||||
|
vendor/
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Storage (will be mounted as volumes)
|
||||||
|
storage/app/*
|
||||||
|
storage/framework/cache/*
|
||||||
|
storage/framework/sessions/*
|
||||||
|
storage/framework/views/*
|
||||||
|
storage/logs/*
|
||||||
|
bootstrap/cache/*
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
phpunit.xml
|
||||||
|
.phpunit.result.cache
|
||||||
|
tests/
|
||||||
|
coverage/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
Dockerfile*
|
||||||
|
docker-compose*.yml
|
||||||
|
.dockerignore
|
||||||
|
|
||||||
|
# Frontend old
|
||||||
|
frontend-old/
|
||||||
|
|
||||||
|
# Build artifacts
|
||||||
|
public/build/
|
||||||
|
public/hot
|
||||||
|
public/mix-manifest.json
|
||||||
|
|
||||||
|
# Development
|
||||||
|
.php_cs.cache
|
||||||
|
.php-cs-fixer.cache
|
||||||
|
phpstan.neon
|
||||||
|
.editorconfig
|
||||||
24
.env.dusk.local
Normal file
24
.env.dusk.local
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
APP_NAME=DishPlanner
|
||||||
|
APP_ENV=testing
|
||||||
|
APP_KEY=base64:KSKZNT+cJuaBRBv4Y2HQqav6hzREKoLkNIKN8yszU1Q=
|
||||||
|
APP_DEBUG=true
|
||||||
|
APP_URL=http://dishplanner_app:8000
|
||||||
|
|
||||||
|
LOG_CHANNEL=single
|
||||||
|
|
||||||
|
# Test database
|
||||||
|
DB_CONNECTION=mysql
|
||||||
|
DB_HOST=db
|
||||||
|
DB_PORT=3306
|
||||||
|
DB_DATABASE=dishplanner_test
|
||||||
|
DB_USERNAME=dishplanner
|
||||||
|
DB_PASSWORD=dishplanner
|
||||||
|
|
||||||
|
BROADCAST_DRIVER=log
|
||||||
|
CACHE_DRIVER=array
|
||||||
|
FILESYSTEM_DISK=local
|
||||||
|
QUEUE_CONNECTION=sync
|
||||||
|
SESSION_DRIVER=file
|
||||||
|
SESSION_LIFETIME=120
|
||||||
|
|
||||||
|
MAIL_MAILER=array
|
||||||
66
.env.example
Normal file
66
.env.example
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
# Application Settings
|
||||||
|
APP_NAME=DishPlanner
|
||||||
|
APP_ENV=local
|
||||||
|
APP_KEY=
|
||||||
|
APP_DEBUG=true
|
||||||
|
APP_TIMEZONE=UTC
|
||||||
|
APP_URL=http://localhost:8000
|
||||||
|
|
||||||
|
APP_LOCALE=en
|
||||||
|
APP_FALLBACK_LOCALE=en
|
||||||
|
APP_FAKER_LOCALE=en_US
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
LOG_CHANNEL=stack
|
||||||
|
LOG_STACK=single
|
||||||
|
LOG_DEPRECATIONS_CHANNEL=null
|
||||||
|
LOG_LEVEL=debug
|
||||||
|
|
||||||
|
# Database Connection (Docker)
|
||||||
|
DB_CONNECTION=mysql
|
||||||
|
DB_HOST=db # Use 'db' for Docker, 'localhost' for local
|
||||||
|
DB_PORT=3306
|
||||||
|
DB_DATABASE=dishplanner
|
||||||
|
DB_USERNAME=dishplanner
|
||||||
|
DB_PASSWORD=dishplanner
|
||||||
|
DB_ROOT_PASSWORD=root # For Docker MariaDB root user
|
||||||
|
|
||||||
|
# Session & Cache
|
||||||
|
SESSION_DRIVER=file # Use 'database' for production
|
||||||
|
SESSION_LIFETIME=120
|
||||||
|
SESSION_ENCRYPT=false
|
||||||
|
SESSION_PATH=/
|
||||||
|
SESSION_DOMAIN=null
|
||||||
|
|
||||||
|
CACHE_STORE=file
|
||||||
|
CACHE_PREFIX=dishplanner
|
||||||
|
|
||||||
|
# Queue
|
||||||
|
QUEUE_CONNECTION=sync # Use 'database' for production
|
||||||
|
|
||||||
|
BROADCAST_CONNECTION=log
|
||||||
|
FILESYSTEM_DISK=local
|
||||||
|
|
||||||
|
# Redis (Optional - uncomment if using Redis)
|
||||||
|
# REDIS_CLIENT=phpredis
|
||||||
|
# REDIS_HOST=redis # Use 'redis' for Docker
|
||||||
|
# REDIS_PASSWORD=null
|
||||||
|
# REDIS_PORT=6379
|
||||||
|
|
||||||
|
# Mail Settings
|
||||||
|
MAIL_MAILER=log # Use 'smtp' for production
|
||||||
|
MAIL_HOST=mailhog # Use 'mailhog' for Docker dev
|
||||||
|
MAIL_PORT=1025
|
||||||
|
MAIL_USERNAME=null
|
||||||
|
MAIL_PASSWORD=null
|
||||||
|
MAIL_ENCRYPTION=null
|
||||||
|
MAIL_FROM_ADDRESS="noreply@dishplanner.local"
|
||||||
|
MAIL_FROM_NAME="${APP_NAME}"
|
||||||
|
|
||||||
|
AWS_ACCESS_KEY_ID=
|
||||||
|
AWS_SECRET_ACCESS_KEY=
|
||||||
|
AWS_DEFAULT_REGION=us-east-1
|
||||||
|
AWS_BUCKET=
|
||||||
|
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||||
|
|
||||||
|
VITE_APP_NAME="${APP_NAME}"
|
||||||
0
backend/.gitattributes → .gitattributes
vendored
0
backend/.gitattributes → .gitattributes
vendored
25
.gitignore
vendored
25
.gitignore
vendored
|
|
@ -1 +1,26 @@
|
||||||
|
/composer.lock
|
||||||
|
/.phpunit.cache
|
||||||
|
/coverage
|
||||||
|
/node_modules
|
||||||
|
/public/build
|
||||||
|
/public/hot
|
||||||
|
/public/storage
|
||||||
|
/storage/*.key
|
||||||
|
/storage/pail
|
||||||
|
/vendor
|
||||||
|
.env
|
||||||
|
.env.backup
|
||||||
|
.env.production
|
||||||
|
.phpactor.json
|
||||||
|
.phpunit.result.cache
|
||||||
|
Homestead.json
|
||||||
|
Homestead.yaml
|
||||||
|
npm-debug.log
|
||||||
|
yarn-error.log
|
||||||
|
/auth.json
|
||||||
|
/.fleet
|
||||||
/.idea
|
/.idea
|
||||||
|
/.nova
|
||||||
|
/.vscode
|
||||||
|
/.zed
|
||||||
|
/.vite
|
||||||
|
|
|
||||||
8
.vite/deps/_metadata.json
Normal file
8
.vite/deps/_metadata.json
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"hash": "9e76e24f",
|
||||||
|
"configHash": "16a1459d",
|
||||||
|
"lockfileHash": "e3b0c442",
|
||||||
|
"browserHash": "8e8bb46e",
|
||||||
|
"optimized": {},
|
||||||
|
"chunks": {}
|
||||||
|
}
|
||||||
3
.vite/deps/package.json
Normal file
3
.vite/deps/package.json
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"type": "module"
|
||||||
|
}
|
||||||
123
Dockerfile
Normal file
123
Dockerfile
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
# Production Dockerfile with FrankenPHP
|
||||||
|
FROM dunglas/frankenphp:latest-php8.3-alpine
|
||||||
|
|
||||||
|
# Install system dependencies
|
||||||
|
RUN apk add --no-cache \
|
||||||
|
nodejs \
|
||||||
|
npm \
|
||||||
|
git \
|
||||||
|
mysql-client
|
||||||
|
|
||||||
|
# Install PHP extensions
|
||||||
|
RUN install-php-extensions \
|
||||||
|
pdo_mysql \
|
||||||
|
opcache \
|
||||||
|
zip \
|
||||||
|
gd \
|
||||||
|
intl \
|
||||||
|
bcmath
|
||||||
|
|
||||||
|
# Install Composer
|
||||||
|
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
||||||
|
|
||||||
|
# Set working directory
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Set fixed production environment variables
|
||||||
|
ENV APP_ENV=production \
|
||||||
|
APP_DEBUG=false \
|
||||||
|
DB_CONNECTION=mysql \
|
||||||
|
DB_HOST=db \
|
||||||
|
DB_PORT=3306 \
|
||||||
|
SESSION_DRIVER=database \
|
||||||
|
CACHE_DRIVER=file \
|
||||||
|
QUEUE_CONNECTION=database \
|
||||||
|
LOG_CHANNEL=stack \
|
||||||
|
LOG_LEVEL=error \
|
||||||
|
MAIL_MAILER=smtp \
|
||||||
|
MAIL_ENCRYPTION=tls
|
||||||
|
|
||||||
|
# Copy application code first
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Install PHP dependencies (production only)
|
||||||
|
RUN composer install --no-dev --no-interaction --optimize-autoloader
|
||||||
|
|
||||||
|
# Install ALL Node dependencies (including dev for building)
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# Build frontend assets
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Remove node_modules after build to save space
|
||||||
|
RUN rm -rf node_modules
|
||||||
|
|
||||||
|
# Laravel optimizations
|
||||||
|
RUN php artisan config:cache \
|
||||||
|
&& php artisan route:cache \
|
||||||
|
&& composer dump-autoload --optimize
|
||||||
|
|
||||||
|
# Set permissions
|
||||||
|
RUN chown -R www-data:www-data /app/storage /app/bootstrap/cache
|
||||||
|
|
||||||
|
# Configure Caddy
|
||||||
|
RUN cat > /etc/caddy/Caddyfile <<EOF
|
||||||
|
{
|
||||||
|
frankenphp
|
||||||
|
order php_server before file_server
|
||||||
|
}
|
||||||
|
|
||||||
|
:8000 {
|
||||||
|
root * /app/public
|
||||||
|
|
||||||
|
php_server {
|
||||||
|
index index.php
|
||||||
|
}
|
||||||
|
|
||||||
|
encode gzip
|
||||||
|
|
||||||
|
file_server
|
||||||
|
|
||||||
|
header {
|
||||||
|
X-Frame-Options "SAMEORIGIN"
|
||||||
|
X-Content-Type-Options "nosniff"
|
||||||
|
X-XSS-Protection "1; mode=block"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||||
|
CMD curl -f http://localhost:8000/up || exit 1
|
||||||
|
|
||||||
|
# Create startup script for production
|
||||||
|
RUN cat > /start-prod.sh <<'EOF'
|
||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Wait for database to be ready
|
||||||
|
echo "Waiting for database..."
|
||||||
|
for i in $(seq 1 30); do
|
||||||
|
if mysqladmin ping -h "$DB_HOST" -u "$DB_USERNAME" -p"$DB_PASSWORD" --silent 2>/dev/null; then
|
||||||
|
echo "Database is ready!"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
echo "Waiting for database... ($i/30)"
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
# Run migrations
|
||||||
|
echo "Running migrations..."
|
||||||
|
php artisan migrate --force || echo "Migrations failed or already up-to-date"
|
||||||
|
|
||||||
|
# Start FrankenPHP
|
||||||
|
exec frankenphp run --config /etc/caddy/Caddyfile
|
||||||
|
EOF
|
||||||
|
|
||||||
|
RUN chmod +x /start-prod.sh
|
||||||
|
|
||||||
|
# Start with our script
|
||||||
|
CMD ["/start-prod.sh"]
|
||||||
128
Dockerfile.dev
Normal file
128
Dockerfile.dev
Normal file
|
|
@ -0,0 +1,128 @@
|
||||||
|
# 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 \
|
||||||
|
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 (runs as host user)
|
||||||
|
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
|
||||||
|
|
||||||
|
# Handle node_modules with care - clean install if having issues
|
||||||
|
if [ ! -f "node_modules/.bin/vite" ]; then
|
||||||
|
echo "Installing npm dependencies..."
|
||||||
|
# Clean any remnants first
|
||||||
|
rm -rf node_modules/.* 2>/dev/null || true
|
||||||
|
rm -rf /app/.npm 2>/dev/null || true
|
||||||
|
# Fresh install with cache in tmp to avoid permission issues
|
||||||
|
npm install --cache /tmp/.npm
|
||||||
|
else
|
||||||
|
echo "Node modules already installed, skipping npm install"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 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 development seeder (only in dev environment)
|
||||||
|
echo "Running development seeder..."
|
||||||
|
php artisan db:seed --class=DevelopmentSeeder --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 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"]
|
||||||
123
Makefile
Normal file
123
Makefile
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
# Dish Planner - Docker Commands
|
||||||
|
|
||||||
|
.PHONY: help
|
||||||
|
help: ## Show this help message
|
||||||
|
@echo "Dish Planner - Docker Management"
|
||||||
|
@echo ""
|
||||||
|
@echo "Usage: make [command]"
|
||||||
|
@echo ""
|
||||||
|
@echo "Available commands:"
|
||||||
|
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " %-20s %s\n", $$1, $$2}'
|
||||||
|
|
||||||
|
# Development Commands
|
||||||
|
.PHONY: dev
|
||||||
|
dev: ## Start development environment
|
||||||
|
docker compose up -d
|
||||||
|
@echo "Development server running at http://localhost:8000"
|
||||||
|
@echo "Mailhog available at http://localhost:8025"
|
||||||
|
|
||||||
|
.PHONY: dev-build
|
||||||
|
dev-build: ## Build and start development environment
|
||||||
|
docker compose build
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
.PHONY: dev-stop
|
||||||
|
dev-stop: ## Stop development environment
|
||||||
|
docker compose down
|
||||||
|
|
||||||
|
.PHONY: dev-clean
|
||||||
|
dev-clean: ## Stop and remove volumes (CAUTION: removes database)
|
||||||
|
docker compose down -v
|
||||||
|
|
||||||
|
.PHONY: logs
|
||||||
|
logs: ## Show application logs
|
||||||
|
docker compose logs -f app
|
||||||
|
|
||||||
|
.PHONY: logs-db
|
||||||
|
logs-db: ## Show database logs
|
||||||
|
docker compose logs -f db
|
||||||
|
|
||||||
|
# Production Commands
|
||||||
|
.PHONY: prod-build
|
||||||
|
prod-build: ## Build production image for Codeberg
|
||||||
|
./bin/build-push.sh
|
||||||
|
|
||||||
|
.PHONY: prod-build-tag
|
||||||
|
prod-build-tag: ## Build with specific tag (usage: make prod-build-tag TAG=v1.0.0)
|
||||||
|
./bin/build-push.sh $(TAG)
|
||||||
|
|
||||||
|
.PHONY: prod-login
|
||||||
|
prod-login: ## Login to Codeberg registry
|
||||||
|
podman login codeberg.org
|
||||||
|
|
||||||
|
# Laravel Commands
|
||||||
|
.PHONY: artisan
|
||||||
|
artisan: ## Run artisan command (usage: make artisan cmd="migrate")
|
||||||
|
docker compose exec app php artisan $(cmd)
|
||||||
|
|
||||||
|
.PHONY: composer
|
||||||
|
composer: ## Run composer command (usage: make composer cmd="require package")
|
||||||
|
docker compose exec app composer $(cmd)
|
||||||
|
|
||||||
|
.PHONY: npm
|
||||||
|
npm: ## Run npm command (usage: make npm cmd="install package")
|
||||||
|
docker compose exec app npm $(cmd)
|
||||||
|
|
||||||
|
.PHONY: migrate
|
||||||
|
migrate: ## Run database migrations
|
||||||
|
docker compose exec app php artisan migrate
|
||||||
|
|
||||||
|
.PHONY: seed
|
||||||
|
seed: ## Seed the database
|
||||||
|
docker compose exec app php artisan db:seed
|
||||||
|
|
||||||
|
.PHONY: fresh
|
||||||
|
fresh: ## Fresh migrate and seed
|
||||||
|
docker compose exec app php artisan migrate:fresh --seed
|
||||||
|
|
||||||
|
.PHONY: tinker
|
||||||
|
tinker: ## Start Laravel tinker
|
||||||
|
docker compose exec app php artisan tinker
|
||||||
|
|
||||||
|
.PHONY: test
|
||||||
|
test: ## Run tests
|
||||||
|
docker compose exec app php artisan test
|
||||||
|
|
||||||
|
# Utility Commands
|
||||||
|
.PHONY: shell
|
||||||
|
shell: ## Enter app container shell
|
||||||
|
docker compose exec app sh
|
||||||
|
|
||||||
|
.PHONY: db-shell
|
||||||
|
db-shell: ## Enter database shell
|
||||||
|
docker compose exec db mariadb -u dishplanner -pdishplanner dishplanner
|
||||||
|
|
||||||
|
.PHONY: clear
|
||||||
|
clear: ## Clear all Laravel caches
|
||||||
|
docker compose exec app php artisan cache:clear
|
||||||
|
docker compose exec app php artisan config:clear
|
||||||
|
docker compose exec app php artisan route:clear
|
||||||
|
docker compose exec app php artisan view:clear
|
||||||
|
|
||||||
|
.PHONY: optimize
|
||||||
|
optimize: ## Optimize Laravel for production
|
||||||
|
docker compose exec app php artisan config:cache
|
||||||
|
docker compose exec app php artisan route:cache
|
||||||
|
docker compose exec app php artisan view:cache
|
||||||
|
docker compose exec app php artisan livewire:discover
|
||||||
|
|
||||||
|
# Installation
|
||||||
|
.PHONY: install
|
||||||
|
install: ## First time setup
|
||||||
|
@echo "Setting up Dish Planner..."
|
||||||
|
@cp -n .env.example .env || true
|
||||||
|
@echo "Generating application key..."
|
||||||
|
@docker compose build
|
||||||
|
@docker compose up -d
|
||||||
|
@sleep 5
|
||||||
|
@docker compose exec app php artisan key:generate
|
||||||
|
@docker compose exec app php artisan migrate
|
||||||
|
@echo ""
|
||||||
|
@echo "✅ Installation complete!"
|
||||||
|
@echo "Access the app at: http://localhost:8000"
|
||||||
|
@echo "Create your first planner and user to get started."
|
||||||
295
README.md
Normal file
295
README.md
Normal file
|
|
@ -0,0 +1,295 @@
|
||||||
|
# 🍽️ Dish Planner
|
||||||
|
|
||||||
|
A Laravel-based meal planning application that helps households organize and schedule their dishes among multiple users. Built with Laravel, Livewire, and FrankenPHP for a modern, single-container deployment.
|
||||||
|
|
||||||
|
## ✨ Features
|
||||||
|
|
||||||
|
- **Multi-user dish management** - Assign dishes to specific household members
|
||||||
|
- **Smart scheduling** - Automatic schedule generation with recurrence patterns
|
||||||
|
- **Calendar view** - Visual 31-day schedule overview
|
||||||
|
- **Real-time updates** - Livewire-powered reactive interface
|
||||||
|
- **Dark theme UI** - Modern interface with purple/pink accents
|
||||||
|
- **Single container deployment** - Simplified hosting with FrankenPHP
|
||||||
|
|
||||||
|
## 🚀 Quick Start
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
- Docker and Docker Compose
|
||||||
|
- Make (optional, for convenience commands)
|
||||||
|
|
||||||
|
### First Time Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone the repository
|
||||||
|
git clone https://github.com/yourusername/dish-planner.git
|
||||||
|
cd dish-planner
|
||||||
|
|
||||||
|
# Quick install with Make
|
||||||
|
make install
|
||||||
|
|
||||||
|
# Or manually:
|
||||||
|
cp .env.example .env
|
||||||
|
docker compose build
|
||||||
|
docker compose up -d
|
||||||
|
docker compose exec app php artisan key:generate
|
||||||
|
docker compose exec app php artisan migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
The application will be available at **http://localhost:8000**
|
||||||
|
|
||||||
|
## 🔧 Development
|
||||||
|
|
||||||
|
### Starting the Development Environment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start all services
|
||||||
|
make dev
|
||||||
|
|
||||||
|
# Or with Docker Compose directly
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
**Available services:**
|
||||||
|
- **App**: http://localhost:8000 (Laravel + FrankenPHP)
|
||||||
|
- **Vite**: http://localhost:5173 (Asset hot-reload)
|
||||||
|
- **Mailhog**: http://localhost:8025 (Email testing)
|
||||||
|
- **Database**: localhost:3306 (MariaDB)
|
||||||
|
|
||||||
|
### Common Development Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# View logs
|
||||||
|
make logs # App logs
|
||||||
|
make logs-db # Database logs
|
||||||
|
|
||||||
|
# Laravel commands
|
||||||
|
make artisan cmd="migrate" # Run artisan commands
|
||||||
|
make tinker # Start Laravel tinker
|
||||||
|
make test # Run tests
|
||||||
|
|
||||||
|
# Database
|
||||||
|
make migrate # Run migrations
|
||||||
|
make seed # Seed database
|
||||||
|
make fresh # Fresh migrate with seeds
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
make test # Run tests
|
||||||
|
composer test:coverage-html # Run tests with coverage report (generates coverage/index.html)
|
||||||
|
|
||||||
|
# Utilities
|
||||||
|
make shell # Enter app container
|
||||||
|
make db-shell # Enter database shell
|
||||||
|
make clear # Clear all caches
|
||||||
|
```
|
||||||
|
|
||||||
|
### Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
dish-planner/
|
||||||
|
├── app/
|
||||||
|
│ ├── Livewire/ # Livewire components
|
||||||
|
│ │ ├── Auth/ # Authentication
|
||||||
|
│ │ ├── Dishes/ # Dish management
|
||||||
|
│ │ ├── Schedule/ # Schedule calendar
|
||||||
|
│ │ └── Users/ # User management
|
||||||
|
│ └── Models/ # Eloquent models
|
||||||
|
├── resources/
|
||||||
|
│ └── views/
|
||||||
|
│ └── livewire/ # Livewire component views
|
||||||
|
├── docker-compose.yml # Development environment
|
||||||
|
├── docker-compose.prod.yml # Production environment
|
||||||
|
├── Dockerfile # Production image
|
||||||
|
├── Dockerfile.dev # Development image
|
||||||
|
└── Makefile # Convenience commands
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🚢 Production Deployment
|
||||||
|
|
||||||
|
### Building for Production
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build production image
|
||||||
|
make prod-build
|
||||||
|
|
||||||
|
# Start production environment
|
||||||
|
make prod
|
||||||
|
|
||||||
|
# Or with Docker Compose
|
||||||
|
docker compose -f docker-compose.prod.yml build
|
||||||
|
docker compose -f docker-compose.prod.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### Production Environment Variables
|
||||||
|
|
||||||
|
Required environment variables for production:
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Required - Generate APP_KEY (see instructions below)
|
||||||
|
APP_KEY=base64:your-generated-key-here
|
||||||
|
APP_URL=https://your-domain.com
|
||||||
|
|
||||||
|
# Database Configuration
|
||||||
|
DB_DATABASE=dishplanner
|
||||||
|
DB_USERNAME=dishplanner
|
||||||
|
DB_PASSWORD=strong-password-here
|
||||||
|
DB_ROOT_PASSWORD=strong-root-password
|
||||||
|
|
||||||
|
# Optional Email Configuration
|
||||||
|
MAIL_HOST=your-smtp-host
|
||||||
|
MAIL_PORT=587
|
||||||
|
MAIL_USERNAME=your-username
|
||||||
|
MAIL_PASSWORD=your-password
|
||||||
|
MAIL_FROM_ADDRESS=noreply@your-domain.com
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Generating APP_KEY
|
||||||
|
|
||||||
|
The APP_KEY is critical for encryption and must be kept consistent across deployments. Generate one using any of these methods:
|
||||||
|
|
||||||
|
**Option 1: Using OpenSSL (Linux/Mac/Windows with Git Bash)**
|
||||||
|
```bash
|
||||||
|
echo "base64:$(openssl rand -base64 32)"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Option 2: Using Node.js (Cross-platform)**
|
||||||
|
```bash
|
||||||
|
node -e "console.log('base64:' + require('crypto').randomBytes(32).toString('base64'))"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Option 3: Using Python (Cross-platform)**
|
||||||
|
```bash
|
||||||
|
python -c "import base64, os; print('base64:' + base64.b64encode(os.urandom(32)).decode())"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Option 4: Online Generator**
|
||||||
|
Generate a random 32-character string at https://randomkeygen.com/ and prepend with `base64:`
|
||||||
|
|
||||||
|
⚠️ **Important**: Save this key securely! If lost, you won't be able to decrypt existing data.
|
||||||
|
|
||||||
|
### Deployment with DockGE
|
||||||
|
|
||||||
|
The production setup is optimized for DockGE deployment with just 2 containers:
|
||||||
|
|
||||||
|
1. **app** - Laravel application with FrankenPHP
|
||||||
|
2. **db** - MariaDB database
|
||||||
|
|
||||||
|
Simply import the `docker-compose.prod.yml` into DockGE and configure your environment variables.
|
||||||
|
|
||||||
|
## 🛠️ Technology Stack
|
||||||
|
|
||||||
|
- **Backend**: Laravel 12 with Livewire 3
|
||||||
|
- **Web Server**: FrankenPHP (PHP 8.3 + Caddy)
|
||||||
|
- **Database**: MariaDB 11
|
||||||
|
- **Frontend**: Blade + Livewire + Alpine.js
|
||||||
|
- **Styling**: TailwindCSS with custom dark theme
|
||||||
|
- **Assets**: Vite for bundling
|
||||||
|
|
||||||
|
## 📦 Docker Architecture
|
||||||
|
|
||||||
|
### Development (`docker-compose.yml`)
|
||||||
|
- **Hot reload** - Volume mounts for live code editing
|
||||||
|
- **Debug tools** - Xdebug configured for debugging
|
||||||
|
- **Email testing** - Mailhog for capturing emails
|
||||||
|
- **Asset watching** - Vite dev server for instant updates
|
||||||
|
|
||||||
|
### Production (`docker-compose.prod.yml`)
|
||||||
|
- **Optimized** - Multi-stage builds with caching
|
||||||
|
- **Secure** - No debug tools, proper permissions
|
||||||
|
- **Health checks** - Automatic container monitoring
|
||||||
|
- **Single container** - FrankenPHP serves everything
|
||||||
|
|
||||||
|
## 🔨 Make Commands Reference
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Development
|
||||||
|
make dev # Start development environment
|
||||||
|
make dev-build # Build and start development
|
||||||
|
make dev-stop # Stop development environment
|
||||||
|
make dev-clean # Stop and remove volumes (CAUTION)
|
||||||
|
|
||||||
|
# Production
|
||||||
|
make prod # Start production environment
|
||||||
|
make prod-build # Build production image
|
||||||
|
make prod-stop # Stop production environment
|
||||||
|
make prod-logs # Show production logs
|
||||||
|
|
||||||
|
# Laravel
|
||||||
|
make artisan cmd="..." # Run artisan command
|
||||||
|
make composer cmd="..." # Run composer command
|
||||||
|
make npm cmd="..." # Run npm command
|
||||||
|
make migrate # Run migrations
|
||||||
|
make seed # Seed database
|
||||||
|
make fresh # Fresh migrate and seed
|
||||||
|
make tinker # Start tinker session
|
||||||
|
make test # Run tests
|
||||||
|
|
||||||
|
# Utilities
|
||||||
|
make shell # Enter app container
|
||||||
|
make db-shell # Enter database shell
|
||||||
|
make logs # Show app logs
|
||||||
|
make logs-db # Show database logs
|
||||||
|
make clear # Clear all caches
|
||||||
|
make optimize # Optimize for production
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🐛 Troubleshooting
|
||||||
|
|
||||||
|
### Container won't start
|
||||||
|
```bash
|
||||||
|
# Check logs
|
||||||
|
docker compose logs app
|
||||||
|
|
||||||
|
# Rebuild containers
|
||||||
|
docker compose build --no-cache
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database connection issues
|
||||||
|
```bash
|
||||||
|
# Verify database is running
|
||||||
|
docker compose ps
|
||||||
|
|
||||||
|
# Check database logs
|
||||||
|
docker compose logs db
|
||||||
|
|
||||||
|
# Try manual connection
|
||||||
|
make db-shell
|
||||||
|
```
|
||||||
|
|
||||||
|
### Permission issues
|
||||||
|
```bash
|
||||||
|
# Fix storage permissions
|
||||||
|
docker compose exec app chmod -R 777 storage bootstrap/cache
|
||||||
|
```
|
||||||
|
|
||||||
|
### Clear all caches
|
||||||
|
```bash
|
||||||
|
make clear
|
||||||
|
# Or manually
|
||||||
|
docker compose exec app php artisan cache:clear
|
||||||
|
docker compose exec app php artisan config:clear
|
||||||
|
docker compose exec app php artisan route:clear
|
||||||
|
docker compose exec app php artisan view:clear
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📄 License
|
||||||
|
|
||||||
|
This project is open-source software licensed under the [MIT license](LICENSE.md).
|
||||||
|
|
||||||
|
## 🤝 Contributing
|
||||||
|
|
||||||
|
Contributions are welcome! Please feel free to submit a Pull Request.
|
||||||
|
|
||||||
|
1. Fork the repository
|
||||||
|
2. Create your feature branch (`git checkout -b feature/AmazingFeature`)
|
||||||
|
3. Commit your changes (`git commit -m 'Add some AmazingFeature'`)
|
||||||
|
4. Push to the branch (`git push origin feature/AmazingFeature`)
|
||||||
|
5. Open a Pull Request
|
||||||
|
|
||||||
|
## 📞 Support
|
||||||
|
|
||||||
|
For issues and questions, please use the [GitHub Issues](https://github.com/yourusername/dish-planner/issues) page.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Built with ❤️ using Laravel and Livewire
|
||||||
84
app/Actions/User/CreateUserAction.php
Normal file
84
app/Actions/User/CreateUserAction.php
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Actions\User;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Exception;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
class CreateUserAction
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
public function execute(array $data): User
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
// Validate required fields first
|
||||||
|
if (!isset($data['name']) || empty($data['name'])) {
|
||||||
|
throw new InvalidArgumentException('Name is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isset($data['planner_id']) || empty($data['planner_id'])) {
|
||||||
|
throw new InvalidArgumentException('Planner ID is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::beginTransaction();
|
||||||
|
|
||||||
|
Log::info('CreateUserAction: Starting user creation', [
|
||||||
|
'name' => $data['name'],
|
||||||
|
'planner_id' => $data['planner_id'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Create the user
|
||||||
|
$user = User::create([
|
||||||
|
'name' => $data['name'],
|
||||||
|
'planner_id' => $data['planner_id'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!$user) {
|
||||||
|
throw new Exception('User creation returned null');
|
||||||
|
}
|
||||||
|
|
||||||
|
Log::info('CreateUserAction: User creation result', [
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'name' => $user->name,
|
||||||
|
'planner_id' => $user->planner_id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Verify the user was actually created
|
||||||
|
$createdUser = User::find($user->id);
|
||||||
|
if (!$createdUser) {
|
||||||
|
throw new Exception('User creation did not persist to database');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($createdUser->name !== $data['name']) {
|
||||||
|
throw new Exception('User creation data mismatch');
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::commit();
|
||||||
|
|
||||||
|
Log::info('CreateUserAction: User successfully created', [
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'name' => $user->name,
|
||||||
|
'planner_id' => $user->planner_id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $user;
|
||||||
|
|
||||||
|
} catch (Exception $e) {
|
||||||
|
DB::rollBack();
|
||||||
|
|
||||||
|
Log::error('CreateUserAction: User creation failed', [
|
||||||
|
'name' => $data['name'] ?? 'N/A',
|
||||||
|
'planner_id' => $data['planner_id'] ?? 'N/A',
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
'trace' => $e->getTraceAsString(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
79
app/Actions/User/DeleteUserAction.php
Normal file
79
app/Actions/User/DeleteUserAction.php
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Actions\User;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Exception;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
class DeleteUserAction
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
public function execute(User $user): bool
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
DB::beginTransaction();
|
||||||
|
|
||||||
|
Log::info('DeleteUserAction: Starting user deletion', [
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'user_name' => $user->name,
|
||||||
|
'planner_id' => $user->planner_id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Check for related data
|
||||||
|
$userDishCount = $user->userDishes()->count();
|
||||||
|
$dishCount = $user->dishes()->count();
|
||||||
|
|
||||||
|
Log::info('DeleteUserAction: User relationship counts', [
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'user_dishes_count' => $userDishCount,
|
||||||
|
'dishes_count' => $dishCount,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Store user info before deletion for verification
|
||||||
|
$userId = $user->id;
|
||||||
|
$userName = $user->name;
|
||||||
|
|
||||||
|
// Delete the user (cascading deletes should handle related records)
|
||||||
|
$result = $user->delete();
|
||||||
|
|
||||||
|
Log::info('DeleteUserAction: Delete result', [
|
||||||
|
'result' => $result,
|
||||||
|
'user_id' => $userId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (! $result) {
|
||||||
|
throw new Exception('User deletion returned false');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the deletion actually happened
|
||||||
|
$stillExists = User::find($userId);
|
||||||
|
if ($stillExists) {
|
||||||
|
throw new Exception('User deletion did not persist to database');
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::commit();
|
||||||
|
|
||||||
|
Log::info('DeleteUserAction: User successfully deleted', [
|
||||||
|
'user_id' => $userId,
|
||||||
|
'user_name' => $userName,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
|
||||||
|
} catch (Exception $e) {
|
||||||
|
DB::rollBack();
|
||||||
|
|
||||||
|
Log::error('DeleteUserAction: User deletion failed', [
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
'trace' => $e->getTraceAsString(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
63
app/Actions/User/EditUserAction.php
Normal file
63
app/Actions/User/EditUserAction.php
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Actions\User;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
class EditUserAction
|
||||||
|
{
|
||||||
|
public function execute(User $user, array $data): bool
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
DB::beginTransaction();
|
||||||
|
|
||||||
|
Log::info('EditUserAction: Starting user update', [
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'old_name' => $user->name,
|
||||||
|
'new_name' => $data['name'],
|
||||||
|
'planner_id' => $user->planner_id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$result = $user->update([
|
||||||
|
'name' => $data['name'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
Log::info('EditUserAction: Update result', [
|
||||||
|
'result' => $result,
|
||||||
|
'user_id' => $user->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!$result) {
|
||||||
|
throw new \Exception('User update returned false');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the update actually happened
|
||||||
|
$user->refresh();
|
||||||
|
if ($user->name !== $data['name']) {
|
||||||
|
throw new \Exception('User update did not persist to database');
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::commit();
|
||||||
|
|
||||||
|
Log::info('EditUserAction: User successfully updated', [
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'updated_name' => $user->name,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
DB::rollBack();
|
||||||
|
|
||||||
|
Log::error('EditUserAction: User update failed', [
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
'trace' => $e->getTraceAsString(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
24
app/Enums/AppModeEnum.php
Normal file
24
app/Enums/AppModeEnum.php
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Enums;
|
||||||
|
|
||||||
|
enum AppModeEnum: string
|
||||||
|
{
|
||||||
|
case APP = 'app';
|
||||||
|
case SAAS = 'saas';
|
||||||
|
|
||||||
|
public static function current(): self
|
||||||
|
{
|
||||||
|
return self::from(config('app.mode', 'app'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isApp(): bool
|
||||||
|
{
|
||||||
|
return $this === self::APP;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isSaas(): bool
|
||||||
|
{
|
||||||
|
return $this === self::SAAS;
|
||||||
|
}
|
||||||
|
}
|
||||||
44
app/Http/Controllers/Auth/LoginController.php
Normal file
44
app/Http/Controllers/Auth/LoginController.php
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Auth;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
|
class LoginController extends Controller
|
||||||
|
{
|
||||||
|
public function showLoginForm()
|
||||||
|
{
|
||||||
|
return view('auth.login');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function login(Request $request)
|
||||||
|
{
|
||||||
|
$credentials = $request->validate([
|
||||||
|
'email' => ['required', 'email'],
|
||||||
|
'password' => ['required'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (Auth::attempt($credentials, $request->boolean('remember'))) {
|
||||||
|
$request->session()->regenerate();
|
||||||
|
|
||||||
|
return redirect()->intended(route('dashboard'));
|
||||||
|
}
|
||||||
|
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'email' => 'These credentials do not match our records.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function logout(Request $request)
|
||||||
|
{
|
||||||
|
Auth::logout();
|
||||||
|
|
||||||
|
$request->session()->invalidate();
|
||||||
|
$request->session()->regenerateToken();
|
||||||
|
|
||||||
|
return redirect('/');
|
||||||
|
}
|
||||||
|
}
|
||||||
37
app/Http/Controllers/Auth/RegisterController.php
Normal file
37
app/Http/Controllers/Auth/RegisterController.php
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Auth;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Planner;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Validation\Rules;
|
||||||
|
|
||||||
|
class RegisterController extends Controller
|
||||||
|
{
|
||||||
|
public function showRegistrationForm()
|
||||||
|
{
|
||||||
|
return view('auth.register');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function register(Request $request)
|
||||||
|
{
|
||||||
|
$request->validate([
|
||||||
|
'name' => ['required', 'string', 'max:255'],
|
||||||
|
'email' => ['required', 'string', 'email', 'max:255', 'unique:planners'],
|
||||||
|
'password' => ['required', 'confirmed', Rules\Password::defaults()],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user = Planner::create([
|
||||||
|
'name' => $request->name,
|
||||||
|
'email' => $request->email,
|
||||||
|
'password' => Hash::make($request->password),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Auth::login($user);
|
||||||
|
|
||||||
|
return redirect(route('dashboard'));
|
||||||
|
}
|
||||||
|
}
|
||||||
112
app/Http/Controllers/SubscriptionController.php
Normal file
112
app/Http/Controllers/SubscriptionController.php
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Laravel\Cashier\Cashier;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class SubscriptionController extends Controller
|
||||||
|
{
|
||||||
|
public function checkout(Request $request)
|
||||||
|
{
|
||||||
|
$planner = $request->user();
|
||||||
|
|
||||||
|
if ($planner->subscribed()) {
|
||||||
|
return redirect()->route('dashboard');
|
||||||
|
}
|
||||||
|
|
||||||
|
$plan = $request->input('plan', 'monthly');
|
||||||
|
$priceId = $plan === 'yearly'
|
||||||
|
? config('services.stripe.price_yearly')
|
||||||
|
: config('services.stripe.price_monthly');
|
||||||
|
|
||||||
|
return $planner->newSubscription('default', $priceId)
|
||||||
|
->checkout([
|
||||||
|
'success_url' => route('subscription.success') . '?session_id={CHECKOUT_SESSION_ID}',
|
||||||
|
'cancel_url' => route('subscription.index'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function success(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$sessionId = $request->query('session_id');
|
||||||
|
|
||||||
|
if ($sessionId) {
|
||||||
|
$planner = $request->user();
|
||||||
|
$session = Cashier::stripe()->checkout->sessions->retrieve($sessionId, [
|
||||||
|
'expand' => ['subscription'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($session->subscription && ! $planner->subscribed()) {
|
||||||
|
$subscription = $session->subscription;
|
||||||
|
|
||||||
|
$planner->subscriptions()->create([
|
||||||
|
'type' => 'default',
|
||||||
|
'stripe_id' => $subscription->id,
|
||||||
|
'stripe_status' => $subscription->status,
|
||||||
|
'stripe_price' => $subscription->items->data[0]->price->id ?? null,
|
||||||
|
'quantity' => $subscription->items->data[0]->quantity ?? 1,
|
||||||
|
'trial_ends_at' => $subscription->trial_end ? now()->setTimestamp($subscription->trial_end) : null,
|
||||||
|
'ends_at' => null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()->route('dashboard')->with('success', 'Subscription activated!');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function billing(Request $request)
|
||||||
|
{
|
||||||
|
$planner = $request->user();
|
||||||
|
$subscription = $planner->subscription();
|
||||||
|
|
||||||
|
if (! $subscription) {
|
||||||
|
return redirect()->route('subscription.index');
|
||||||
|
}
|
||||||
|
|
||||||
|
$planType = match ($subscription->stripe_price) {
|
||||||
|
config('services.stripe.price_yearly') => 'Yearly',
|
||||||
|
config('services.stripe.price_monthly') => 'Monthly',
|
||||||
|
default => 'Unknown',
|
||||||
|
};
|
||||||
|
|
||||||
|
$nextBillingDate = null;
|
||||||
|
if ($subscription->stripe_status === 'active') {
|
||||||
|
try {
|
||||||
|
$stripeSubscription = Cashier::stripe()->subscriptions->retrieve($subscription->stripe_id);
|
||||||
|
$nextBillingDate = $stripeSubscription->current_period_end
|
||||||
|
? now()->setTimestamp($stripeSubscription->current_period_end)
|
||||||
|
: null;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
// Stripe API error - continue without next billing date
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return view('billing.index', [
|
||||||
|
'subscription' => $subscription,
|
||||||
|
'planner' => $planner,
|
||||||
|
'planType' => $planType,
|
||||||
|
'nextBillingDate' => $nextBillingDate,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function cancel(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$planner = $request->user();
|
||||||
|
|
||||||
|
if (! $planner->subscribed()) {
|
||||||
|
return back()->with('error', 'No active subscription found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$planner->subscription()->cancel();
|
||||||
|
|
||||||
|
return back()->with('success', 'Subscription canceled. Access will continue until the end of your billing period.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function billingPortal(Request $request)
|
||||||
|
{
|
||||||
|
return $request->user()->redirectToBillingPortal(route('billing'));
|
||||||
|
}
|
||||||
|
}
|
||||||
20
app/Http/Middleware/ForceJsonResponse.php
Normal file
20
app/Http/Middleware/ForceJsonResponse.php
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class ForceJsonResponse
|
||||||
|
{
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
|
// Only force JSON for API routes
|
||||||
|
if ($request->is('api/*')) {
|
||||||
|
$request->headers->set('Accept', 'application/json');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,11 +6,13 @@
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
class ForceJsonResponse
|
class RequireSaasMode
|
||||||
{
|
{
|
||||||
public function handle(Request $request, Closure $next): Response
|
public function handle(Request $request, Closure $next): Response
|
||||||
{
|
{
|
||||||
$request->headers->set('Accept', 'application/json');
|
if (! is_mode_saas()) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
return $next($request);
|
return $next($request);
|
||||||
}
|
}
|
||||||
25
app/Http/Middleware/RequireSubscription.php
Normal file
25
app/Http/Middleware/RequireSubscription.php
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class RequireSubscription
|
||||||
|
{
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
|
if (is_mode_app()) {
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
$planner = $request->user();
|
||||||
|
|
||||||
|
if (! $planner?->subscribed()) {
|
||||||
|
return redirect()->route('subscription.index');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
132
app/Livewire/Dishes/DishesList.php
Normal file
132
app/Livewire/Dishes/DishesList.php
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire\Dishes;
|
||||||
|
|
||||||
|
use App\Models\Dish;
|
||||||
|
use App\Models\User;
|
||||||
|
use Livewire\Component;
|
||||||
|
use Livewire\WithPagination;
|
||||||
|
|
||||||
|
class DishesList extends Component
|
||||||
|
{
|
||||||
|
use WithPagination;
|
||||||
|
|
||||||
|
public $showCreateModal = false;
|
||||||
|
public $showEditModal = false;
|
||||||
|
public $showDeleteModal = false;
|
||||||
|
|
||||||
|
public $editingDish = null;
|
||||||
|
public $deletingDish = null;
|
||||||
|
|
||||||
|
// Form fields
|
||||||
|
public $name = '';
|
||||||
|
public $selectedUsers = [];
|
||||||
|
|
||||||
|
protected $rules = [
|
||||||
|
'name' => 'required|string|max:255',
|
||||||
|
'selectedUsers' => 'array',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function render()
|
||||||
|
{
|
||||||
|
$dishes = Dish::with('users')
|
||||||
|
->orderBy('name')
|
||||||
|
->paginate(10);
|
||||||
|
|
||||||
|
$users = User::where('planner_id', auth()->id())
|
||||||
|
->orderBy('name')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return view('livewire.dishes.dishes-list', [
|
||||||
|
'dishes' => $dishes,
|
||||||
|
'users' => $users
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create()
|
||||||
|
{
|
||||||
|
$this->reset(['name', 'selectedUsers']);
|
||||||
|
$this->resetValidation();
|
||||||
|
$this->showCreateModal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store()
|
||||||
|
{
|
||||||
|
$this->validate();
|
||||||
|
|
||||||
|
$dish = Dish::create([
|
||||||
|
'name' => $this->name,
|
||||||
|
'planner_id' => auth()->id(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Attach selected users
|
||||||
|
if (!empty($this->selectedUsers)) {
|
||||||
|
$dish->users()->attach($this->selectedUsers);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->showCreateModal = false;
|
||||||
|
$this->reset(['name', 'selectedUsers']);
|
||||||
|
|
||||||
|
session()->flash('success', 'Dish created successfully.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function edit(Dish $dish)
|
||||||
|
{
|
||||||
|
$this->editingDish = $dish;
|
||||||
|
$this->name = $dish->name;
|
||||||
|
$this->selectedUsers = $dish->users->pluck('id')->toArray();
|
||||||
|
$this->resetValidation();
|
||||||
|
$this->showEditModal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update()
|
||||||
|
{
|
||||||
|
$this->validate();
|
||||||
|
|
||||||
|
$this->editingDish->update([
|
||||||
|
'name' => $this->name,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Sync users
|
||||||
|
$this->editingDish->users()->sync($this->selectedUsers);
|
||||||
|
|
||||||
|
$this->showEditModal = false;
|
||||||
|
$this->reset(['name', 'selectedUsers', 'editingDish']);
|
||||||
|
|
||||||
|
session()->flash('success', 'Dish updated successfully.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function confirmDelete(Dish $dish)
|
||||||
|
{
|
||||||
|
$this->deletingDish = $dish;
|
||||||
|
$this->showDeleteModal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete()
|
||||||
|
{
|
||||||
|
$this->deletingDish->users()->detach();
|
||||||
|
$this->deletingDish->delete();
|
||||||
|
$this->showDeleteModal = false;
|
||||||
|
$this->deletingDish = null;
|
||||||
|
|
||||||
|
session()->flash('success', 'Dish deleted successfully.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function cancel()
|
||||||
|
{
|
||||||
|
$this->showCreateModal = false;
|
||||||
|
$this->showEditModal = false;
|
||||||
|
$this->showDeleteModal = false;
|
||||||
|
$this->reset(['name', 'selectedUsers', 'editingDish', 'deletingDish']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toggleAllUsers(): void
|
||||||
|
{
|
||||||
|
$users = User::where('planner_id', auth()->id())->get();
|
||||||
|
if (count($this->selectedUsers) === $users->count()) {
|
||||||
|
$this->selectedUsers = [];
|
||||||
|
} else {
|
||||||
|
$this->selectedUsers = $users->pluck('id')->map(fn($id) => (string) $id)->toArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
433
app/Livewire/Schedule/ScheduleCalendar.php
Normal file
433
app/Livewire/Schedule/ScheduleCalendar.php
Normal file
|
|
@ -0,0 +1,433 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire\Schedule;
|
||||||
|
|
||||||
|
use App\Models\Dish;
|
||||||
|
use App\Models\Schedule;
|
||||||
|
use App\Models\ScheduledUserDish;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\UserDish;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use DishPlanner\Schedule\Services\ScheduleCalendarService;
|
||||||
|
use DishPlanner\ScheduledUserDish\Actions\DeleteScheduledUserDishForDateAction;
|
||||||
|
use DishPlanner\ScheduledUserDish\Actions\SkipScheduledUserDishForDateAction;
|
||||||
|
use Exception;
|
||||||
|
use Illuminate\Contracts\View\View;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
|
class ScheduleCalendar extends Component
|
||||||
|
{
|
||||||
|
public $currentMonth;
|
||||||
|
public $currentYear;
|
||||||
|
public $calendarDays = [];
|
||||||
|
public $showRegenerateModal = false;
|
||||||
|
public $regenerateDate = null;
|
||||||
|
public $regenerateUserId = null;
|
||||||
|
|
||||||
|
// Edit dish modal
|
||||||
|
public $showEditDishModal = false;
|
||||||
|
public $editDate = null;
|
||||||
|
public $editUserId = null;
|
||||||
|
public $selectedDishId = null;
|
||||||
|
public $availableDishes = [];
|
||||||
|
|
||||||
|
// Add dish modal
|
||||||
|
public $showAddDishModal = false;
|
||||||
|
public $addDate = null;
|
||||||
|
public $addUserIds = [];
|
||||||
|
public $addSelectedDishId = null;
|
||||||
|
public $addAvailableUsers = [];
|
||||||
|
public $addAvailableDishes = [];
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
$this->currentMonth = now()->month;
|
||||||
|
$this->currentYear = now()->year;
|
||||||
|
$this->loadCalendar();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected $listeners = ['schedule-generated' => 'refreshCalendar'];
|
||||||
|
|
||||||
|
public function render(): View
|
||||||
|
{
|
||||||
|
return view('livewire.schedule.schedule-calendar');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function refreshCalendar(): void
|
||||||
|
{
|
||||||
|
$this->loadCalendar();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function loadCalendar(): void
|
||||||
|
{
|
||||||
|
$service = new ScheduleCalendarService();
|
||||||
|
$this->calendarDays = $service->getCalendarDays(
|
||||||
|
auth()->user(),
|
||||||
|
$this->currentMonth,
|
||||||
|
$this->currentYear
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function previousMonth(): void
|
||||||
|
{
|
||||||
|
if ($this->currentMonth === 1) {
|
||||||
|
$this->currentMonth = 12;
|
||||||
|
$this->currentYear--;
|
||||||
|
} else {
|
||||||
|
$this->currentMonth--;
|
||||||
|
}
|
||||||
|
$this->loadCalendar();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function nextMonth(): void
|
||||||
|
{
|
||||||
|
if ($this->currentMonth === 12) {
|
||||||
|
$this->currentMonth = 1;
|
||||||
|
$this->currentYear++;
|
||||||
|
} else {
|
||||||
|
$this->currentMonth++;
|
||||||
|
}
|
||||||
|
$this->loadCalendar();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function regenerateForUserDate($date, $userId): void
|
||||||
|
{
|
||||||
|
if (!$this->authorizeUser($userId)) {
|
||||||
|
session()->flash('error', 'Unauthorized action.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->regenerateDate = $date;
|
||||||
|
$this->regenerateUserId = $userId;
|
||||||
|
$this->showRegenerateModal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function confirmRegenerate(): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
if (!$this->authorizeUser($this->regenerateUserId)) {
|
||||||
|
session()->flash('error', 'Unauthorized action.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$action = new DeleteScheduledUserDishForDateAction();
|
||||||
|
$action->execute(
|
||||||
|
auth()->user(),
|
||||||
|
Carbon::parse($this->regenerateDate),
|
||||||
|
$this->regenerateUserId
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->showRegenerateModal = false;
|
||||||
|
$this->loadCalendar();
|
||||||
|
|
||||||
|
session()->flash('success', 'Schedule regenerated for the selected date!');
|
||||||
|
} catch (Exception $e) {
|
||||||
|
Log::error('Schedule regeneration failed', ['exception' => $e, 'date' => $this->regenerateDate]);
|
||||||
|
session()->flash('error', 'Unable to regenerate schedule. Please try again.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function skipDay($date, $userId): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
if (!$this->authorizeUser($userId)) {
|
||||||
|
session()->flash('error', 'Unauthorized action.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$action = new SkipScheduledUserDishForDateAction();
|
||||||
|
$action->execute(
|
||||||
|
auth()->user(),
|
||||||
|
Carbon::parse($date),
|
||||||
|
$userId
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->loadCalendar();
|
||||||
|
|
||||||
|
session()->flash('success', 'Day skipped successfully!');
|
||||||
|
} catch (Exception $e) {
|
||||||
|
Log::error('Skip day failed', ['exception' => $e, 'date' => $date, 'userId' => $userId]);
|
||||||
|
session()->flash('error', 'Unable to skip day. Please try again.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function authorizeUser(int $userId): bool
|
||||||
|
{
|
||||||
|
$user = User::find($userId);
|
||||||
|
return $user && $user->planner_id === auth()->id();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function cancel(): void
|
||||||
|
{
|
||||||
|
$this->showRegenerateModal = false;
|
||||||
|
$this->regenerateDate = null;
|
||||||
|
$this->regenerateUserId = null;
|
||||||
|
$this->showEditDishModal = false;
|
||||||
|
$this->editDate = null;
|
||||||
|
$this->editUserId = null;
|
||||||
|
$this->selectedDishId = null;
|
||||||
|
$this->availableDishes = [];
|
||||||
|
$this->showAddDishModal = false;
|
||||||
|
$this->addDate = null;
|
||||||
|
$this->addUserIds = [];
|
||||||
|
$this->addSelectedDishId = null;
|
||||||
|
$this->addAvailableUsers = [];
|
||||||
|
$this->addAvailableDishes = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function removeDish($date, $userId): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
if (!$this->authorizeUser($userId)) {
|
||||||
|
session()->flash('error', 'Unauthorized action.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$schedule = Schedule::where('planner_id', auth()->id())
|
||||||
|
->where('date', $date)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($schedule) {
|
||||||
|
ScheduledUserDish::where('schedule_id', $schedule->id)
|
||||||
|
->where('user_id', $userId)
|
||||||
|
->delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->loadCalendar();
|
||||||
|
session()->flash('success', 'Dish removed successfully!');
|
||||||
|
} catch (Exception $e) {
|
||||||
|
Log::error('Remove dish failed', ['exception' => $e, 'date' => $date, 'userId' => $userId]);
|
||||||
|
session()->flash('error', 'Unable to remove dish. Please try again.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function openAddDishModal($date): void
|
||||||
|
{
|
||||||
|
$this->addDate = $date;
|
||||||
|
|
||||||
|
// Load all users for this planner
|
||||||
|
$this->addAvailableUsers = User::where('planner_id', auth()->id())
|
||||||
|
->orderBy('name')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$this->addAvailableDishes = [];
|
||||||
|
$this->addUserIds = [];
|
||||||
|
$this->addSelectedDishId = null;
|
||||||
|
|
||||||
|
$this->showAddDishModal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toggleAllUsers(): void
|
||||||
|
{
|
||||||
|
if (count($this->addUserIds) === count($this->addAvailableUsers)) {
|
||||||
|
$this->addUserIds = [];
|
||||||
|
} else {
|
||||||
|
$this->addUserIds = $this->addAvailableUsers->pluck('id')->map(fn($id) => (string) $id)->toArray();
|
||||||
|
}
|
||||||
|
$this->updateAvailableDishes();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updatedAddUserIds(): void
|
||||||
|
{
|
||||||
|
$this->updateAvailableDishes();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function updateAvailableDishes(): void
|
||||||
|
{
|
||||||
|
if (empty($this->addUserIds)) {
|
||||||
|
$this->addAvailableDishes = [];
|
||||||
|
} else {
|
||||||
|
// Load dishes that ALL selected users have in common
|
||||||
|
$selectedCount = count($this->addUserIds);
|
||||||
|
$this->addAvailableDishes = Dish::whereHas('users', function ($query) {
|
||||||
|
$query->whereIn('users.id', $this->addUserIds);
|
||||||
|
}, '=', $selectedCount)->orderBy('name')->get();
|
||||||
|
}
|
||||||
|
$this->addSelectedDishId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function saveAddDish(): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
if (empty($this->addUserIds)) {
|
||||||
|
session()->flash('error', 'Please select at least one user.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->addSelectedDishId) {
|
||||||
|
session()->flash('error', 'Please select a dish.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find or create the schedule for this date
|
||||||
|
$schedule = Schedule::firstOrCreate(
|
||||||
|
[
|
||||||
|
'planner_id' => auth()->id(),
|
||||||
|
'date' => $this->addDate,
|
||||||
|
],
|
||||||
|
['is_skipped' => false]
|
||||||
|
);
|
||||||
|
|
||||||
|
$addedCount = 0;
|
||||||
|
$skippedCount = 0;
|
||||||
|
|
||||||
|
foreach ($this->addUserIds as $userId) {
|
||||||
|
if (!$this->authorizeUser((int) $userId)) {
|
||||||
|
$skippedCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user already has a dish scheduled for this date
|
||||||
|
$existing = ScheduledUserDish::where('schedule_id', $schedule->id)
|
||||||
|
->where('user_id', $userId)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($existing) {
|
||||||
|
$skippedCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the UserDish for this user and dish
|
||||||
|
$userDish = UserDish::where('user_id', $userId)
|
||||||
|
->where('dish_id', $this->addSelectedDishId)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (!$userDish) {
|
||||||
|
$skippedCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the scheduled user dish
|
||||||
|
ScheduledUserDish::create([
|
||||||
|
'schedule_id' => $schedule->id,
|
||||||
|
'user_id' => $userId,
|
||||||
|
'user_dish_id' => $userDish->id,
|
||||||
|
'is_skipped' => false,
|
||||||
|
]);
|
||||||
|
$addedCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->closeAddDishModal();
|
||||||
|
$this->loadCalendar();
|
||||||
|
|
||||||
|
if ($addedCount > 0 && $skippedCount > 0) {
|
||||||
|
session()->flash('success', "Dish added for {$addedCount} user(s). {$skippedCount} user(s) skipped (already scheduled).");
|
||||||
|
} elseif ($addedCount > 0) {
|
||||||
|
session()->flash('success', "Dish added for {$addedCount} user(s)!");
|
||||||
|
} else {
|
||||||
|
session()->flash('error', 'No users could be scheduled. They may already have dishes for this date.');
|
||||||
|
}
|
||||||
|
} catch (Exception $e) {
|
||||||
|
Log::error('Add dish failed', ['exception' => $e]);
|
||||||
|
session()->flash('error', 'Unable to add dish. Please try again.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function closeAddDishModal(): void
|
||||||
|
{
|
||||||
|
$this->showAddDishModal = false;
|
||||||
|
$this->addDate = null;
|
||||||
|
$this->addUserIds = [];
|
||||||
|
$this->addSelectedDishId = null;
|
||||||
|
$this->addAvailableUsers = [];
|
||||||
|
$this->addAvailableDishes = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function editDish($date, $userId): void
|
||||||
|
{
|
||||||
|
if (!$this->authorizeUser($userId)) {
|
||||||
|
session()->flash('error', 'Unauthorized action.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->editDate = $date;
|
||||||
|
$this->editUserId = $userId;
|
||||||
|
|
||||||
|
// Load dishes available for this user (via UserDish pivot)
|
||||||
|
$this->availableDishes = Dish::whereHas('users', function ($query) use ($userId) {
|
||||||
|
$query->where('users.id', $userId);
|
||||||
|
})->orderBy('name')->get();
|
||||||
|
|
||||||
|
// Get currently selected dish for this date/user if exists
|
||||||
|
$schedule = Schedule::where('planner_id', auth()->id())
|
||||||
|
->where('date', $date)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($schedule) {
|
||||||
|
$scheduledUserDish = ScheduledUserDish::where('schedule_id', $schedule->id)
|
||||||
|
->where('user_id', $userId)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($scheduledUserDish && $scheduledUserDish->userDish) {
|
||||||
|
$this->selectedDishId = $scheduledUserDish->userDish->dish_id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->showEditDishModal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function saveDish(): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
if (!$this->authorizeUser($this->editUserId)) {
|
||||||
|
session()->flash('error', 'Unauthorized action.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->selectedDishId) {
|
||||||
|
session()->flash('error', 'Please select a dish.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find or create the schedule for this date
|
||||||
|
$schedule = Schedule::firstOrCreate(
|
||||||
|
[
|
||||||
|
'planner_id' => auth()->id(),
|
||||||
|
'date' => $this->editDate,
|
||||||
|
],
|
||||||
|
['is_skipped' => false]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Find the UserDish for this user and dish
|
||||||
|
$userDish = UserDish::where('user_id', $this->editUserId)
|
||||||
|
->where('dish_id', $this->selectedDishId)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (!$userDish) {
|
||||||
|
session()->flash('error', 'This dish is not assigned to this user.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update or create the scheduled user dish
|
||||||
|
ScheduledUserDish::updateOrCreate(
|
||||||
|
[
|
||||||
|
'schedule_id' => $schedule->id,
|
||||||
|
'user_id' => $this->editUserId,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'user_dish_id' => $userDish->id,
|
||||||
|
'is_skipped' => false,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->showEditDishModal = false;
|
||||||
|
$this->editDate = null;
|
||||||
|
$this->editUserId = null;
|
||||||
|
$this->selectedDishId = null;
|
||||||
|
$this->availableDishes = [];
|
||||||
|
|
||||||
|
$this->loadCalendar();
|
||||||
|
session()->flash('success', 'Dish updated successfully!');
|
||||||
|
} catch (Exception $e) {
|
||||||
|
Log::error('Save dish failed', ['exception' => $e]);
|
||||||
|
session()->flash('error', 'Unable to save dish. Please try again.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getMonthNameProperty(): string
|
||||||
|
{
|
||||||
|
$service = new ScheduleCalendarService();
|
||||||
|
return $service->getMonthName($this->currentMonth, $this->currentYear);
|
||||||
|
}
|
||||||
|
}
|
||||||
141
app/Livewire/Schedule/ScheduleGenerator.php
Normal file
141
app/Livewire/Schedule/ScheduleGenerator.php
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire\Schedule;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use DishPlanner\Schedule\Actions\ClearScheduleForMonthAction;
|
||||||
|
use DishPlanner\Schedule\Actions\GenerateScheduleForMonthAction;
|
||||||
|
use DishPlanner\Schedule\Actions\RegenerateScheduleForDateForUsersAction;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
|
class ScheduleGenerator extends Component
|
||||||
|
{
|
||||||
|
private const YEARS_IN_PAST = 1;
|
||||||
|
private const YEARS_IN_FUTURE = 5;
|
||||||
|
|
||||||
|
public $selectedMonth;
|
||||||
|
public $selectedYear;
|
||||||
|
public $selectedUsers = [];
|
||||||
|
public $clearExisting = true;
|
||||||
|
public $showAdvancedOptions = false;
|
||||||
|
public $isGenerating = false;
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
$this->selectedMonth = now()->month;
|
||||||
|
$this->selectedYear = now()->year;
|
||||||
|
|
||||||
|
$this->selectedUsers = User::where('planner_id', auth()->id())
|
||||||
|
->pluck('id')
|
||||||
|
->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render(): \Illuminate\Contracts\View\Factory|\Illuminate\Contracts\View\View
|
||||||
|
{
|
||||||
|
$users = User::where('planner_id', auth()->id())
|
||||||
|
->orderBy('name')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$years = range(now()->year - self::YEARS_IN_PAST, now()->year + self::YEARS_IN_FUTURE);
|
||||||
|
|
||||||
|
return view('livewire.schedule.schedule-generator', [
|
||||||
|
'users' => $users,
|
||||||
|
'months' => $this->getMonthNames(),
|
||||||
|
'years' => $years
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function generate(): void
|
||||||
|
{
|
||||||
|
$this->validate([
|
||||||
|
'selectedUsers' => 'required|array|min:1',
|
||||||
|
'selectedMonth' => 'required|integer|min:1|max:12',
|
||||||
|
'selectedYear' => 'required|integer|min:' . (now()->year - self::YEARS_IN_PAST) . '|max:' . (now()->year + self::YEARS_IN_FUTURE),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->isGenerating = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$action = new GenerateScheduleForMonthAction();
|
||||||
|
$action->execute(
|
||||||
|
auth()->user(),
|
||||||
|
$this->selectedMonth,
|
||||||
|
$this->selectedYear,
|
||||||
|
$this->selectedUsers,
|
||||||
|
$this->clearExisting
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->isGenerating = false;
|
||||||
|
$this->dispatch('schedule-generated');
|
||||||
|
|
||||||
|
session()->flash('success', 'Schedule generated successfully for ' .
|
||||||
|
$this->getSelectedMonthName() . ' ' . $this->selectedYear);
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->isGenerating = false;
|
||||||
|
Log::error('Schedule generation failed', ['exception' => $e]);
|
||||||
|
session()->flash('error', 'Unable to generate schedule. Please try again.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function regenerateForDate($date): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$action = new RegenerateScheduleForDateForUsersAction();
|
||||||
|
$action->execute(
|
||||||
|
auth()->user(),
|
||||||
|
Carbon::parse($date),
|
||||||
|
$this->selectedUsers
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->dispatch('schedule-generated');
|
||||||
|
session()->flash('success', 'Schedule regenerated for ' . Carbon::parse($date)->format('M d, Y'));
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('Schedule regeneration failed', ['exception' => $e, 'date' => $date]);
|
||||||
|
session()->flash('error', 'Unable to regenerate schedule. Please try again.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function clearMonth(): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$action = new ClearScheduleForMonthAction();
|
||||||
|
$action->execute(
|
||||||
|
auth()->user(),
|
||||||
|
$this->selectedMonth,
|
||||||
|
$this->selectedYear,
|
||||||
|
$this->selectedUsers
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->dispatch('schedule-generated');
|
||||||
|
session()->flash('success', 'Schedule cleared for ' .
|
||||||
|
$this->getSelectedMonthName() . ' ' . $this->selectedYear);
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('Clear month failed', ['exception' => $e]);
|
||||||
|
session()->flash('error', 'Unable to clear schedule. Please try again.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toggleAdvancedOptions()
|
||||||
|
{
|
||||||
|
$this->showAdvancedOptions = !$this->showAdvancedOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getMonthNames(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
1 => 'January', 2 => 'February', 3 => 'March', 4 => 'April',
|
||||||
|
5 => 'May', 6 => 'June', 7 => 'July', 8 => 'August',
|
||||||
|
9 => 'September', 10 => 'October', 11 => 'November', 12 => 'December'
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getSelectedMonthName(): string
|
||||||
|
{
|
||||||
|
return $this->getMonthNames()[$this->selectedMonth];
|
||||||
|
}
|
||||||
|
}
|
||||||
126
app/Livewire/Users/UsersList.php
Normal file
126
app/Livewire/Users/UsersList.php
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire\Users;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Actions\User\CreateUserAction;
|
||||||
|
use App\Actions\User\DeleteUserAction;
|
||||||
|
use App\Actions\User\EditUserAction;
|
||||||
|
use Exception;
|
||||||
|
use Illuminate\Contracts\View\View;
|
||||||
|
use Livewire\Component;
|
||||||
|
use Livewire\WithPagination;
|
||||||
|
|
||||||
|
class UsersList extends Component
|
||||||
|
{
|
||||||
|
use WithPagination;
|
||||||
|
|
||||||
|
public bool $showCreateModal = false;
|
||||||
|
public bool $showEditModal = false;
|
||||||
|
public bool $showDeleteModal = false;
|
||||||
|
|
||||||
|
public ?User $editingUser = null;
|
||||||
|
public ?User $deletingUser = null;
|
||||||
|
|
||||||
|
// Form fields
|
||||||
|
public string $name = '';
|
||||||
|
|
||||||
|
protected array $rules = [
|
||||||
|
'name' => 'required|string|max:255',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function render(): View
|
||||||
|
{
|
||||||
|
$users = User::where('planner_id', auth()->id())
|
||||||
|
->orderBy('name')
|
||||||
|
->paginate(10);
|
||||||
|
|
||||||
|
return view('livewire.users.users-list', [
|
||||||
|
'users' => $users
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(): void
|
||||||
|
{
|
||||||
|
$this->reset(['name']);
|
||||||
|
$this->resetValidation();
|
||||||
|
$this->showCreateModal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(): void
|
||||||
|
{
|
||||||
|
$this->validate();
|
||||||
|
|
||||||
|
try {
|
||||||
|
(new CreateUserAction())->execute([
|
||||||
|
'name' => $this->name,
|
||||||
|
'planner_id' => auth()->id(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->showCreateModal = false;
|
||||||
|
$this->reset(['name']);
|
||||||
|
|
||||||
|
session()->flash('success', 'User created successfully.');
|
||||||
|
} catch (Exception $e) {
|
||||||
|
session()->flash('error', 'Failed to create user: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function edit(User $user): void
|
||||||
|
{
|
||||||
|
$this->editingUser = $user;
|
||||||
|
$this->name = $user->name;
|
||||||
|
$this->resetValidation();
|
||||||
|
$this->showEditModal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(): void
|
||||||
|
{
|
||||||
|
$this->validate();
|
||||||
|
|
||||||
|
try {
|
||||||
|
(new EditUserAction())->execute($this->editingUser, ['name' => $this->name]);
|
||||||
|
|
||||||
|
$this->showEditModal = false;
|
||||||
|
$this->reset(['name', 'editingUser']);
|
||||||
|
|
||||||
|
session()->flash('success', 'User updated successfully.');
|
||||||
|
|
||||||
|
// Force component to re-render with fresh data
|
||||||
|
$this->resetPage();
|
||||||
|
} catch (Exception $e) {
|
||||||
|
session()->flash('error', 'Failed to update user: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function confirmDelete(User $user): void
|
||||||
|
{
|
||||||
|
$this->deletingUser = $user;
|
||||||
|
$this->showDeleteModal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
(new DeleteUserAction())->execute($this->deletingUser);
|
||||||
|
|
||||||
|
$this->showDeleteModal = false;
|
||||||
|
$this->deletingUser = null;
|
||||||
|
|
||||||
|
session()->flash('success', 'User deleted successfully.');
|
||||||
|
|
||||||
|
// Force component to re-render with fresh data
|
||||||
|
$this->resetPage();
|
||||||
|
} catch (Exception $e) {
|
||||||
|
session()->flash('error', 'Failed to delete user: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function cancel(): void
|
||||||
|
{
|
||||||
|
$this->showCreateModal = false;
|
||||||
|
$this->showEditModal = false;
|
||||||
|
$this->showDeleteModal = false;
|
||||||
|
$this->reset(['name', 'editingUser', 'deletingUser']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,15 +6,17 @@
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||||
use Illuminate\Notifications\Notifiable;
|
use Illuminate\Notifications\Notifiable;
|
||||||
|
use Laravel\Cashier\Billable;
|
||||||
use Laravel\Sanctum\HasApiTokens;
|
use Laravel\Sanctum\HasApiTokens;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @property int $id
|
* @property int $id
|
||||||
* @property static PlannerFactory factory($count = null, $state = [])
|
* @property static PlannerFactory factory($count = null, $state = [])
|
||||||
|
* @method static first()
|
||||||
*/
|
*/
|
||||||
class Planner extends Authenticatable
|
class Planner extends Authenticatable
|
||||||
{
|
{
|
||||||
use HasApiTokens, HasFactory, Notifiable;
|
use Billable, HasApiTokens, HasFactory, Notifiable;
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'name', 'email', 'password',
|
'name', 'email', 'password',
|
||||||
|
|
@ -24,6 +26,10 @@ class Planner extends Authenticatable
|
||||||
'password', 'remember_token',
|
'password', 'remember_token',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'password' => 'hashed',
|
||||||
|
];
|
||||||
|
|
||||||
public function schedules(): HasMany
|
public function schedules(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(Schedule::class);
|
return $this->hasMany(Schedule::class);
|
||||||
|
|
@ -27,6 +27,7 @@
|
||||||
* @method static create(array $array)
|
* @method static create(array $array)
|
||||||
* @method static Builder where(array|Closure|Expression|string $column, mixed $operator = null, mixed $value = null, string $boolean = 'and')
|
* @method static Builder where(array|Closure|Expression|string $column, mixed $operator = null, mixed $value = null, string $boolean = 'and')
|
||||||
* @method static ScheduleFactory factory($count = null, $state = [])
|
* @method static ScheduleFactory factory($count = null, $state = [])
|
||||||
|
* @method static firstOrCreate(array $array, false[] $array1)
|
||||||
*/
|
*/
|
||||||
class Schedule extends Model
|
class Schedule extends Model
|
||||||
{
|
{
|
||||||
|
|
@ -56,6 +57,6 @@ public function scheduledUserDishes(): HasMany
|
||||||
|
|
||||||
public function hasAllUsersScheduled(): bool
|
public function hasAllUsersScheduled(): bool
|
||||||
{
|
{
|
||||||
return $this->scheduledUserDishes->count() === User::all()->count();
|
return $this->scheduledUserDishes->count() === User::where('planner_id', $this->planner_id)->count();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -12,17 +12,25 @@
|
||||||
* @property int $id
|
* @property int $id
|
||||||
* @property int $schedule_id
|
* @property int $schedule_id
|
||||||
* @property Schedule $schedule
|
* @property Schedule $schedule
|
||||||
|
* @property int $user_id
|
||||||
|
* @property User $user
|
||||||
* @property int $user_dish_id
|
* @property int $user_dish_id
|
||||||
* @property UserDish $userDish
|
* @property UserDish $userDish
|
||||||
* @property bool $is_skipped
|
* @property bool $is_skipped
|
||||||
* @method static create(array $array)
|
* @method static create(array $array)
|
||||||
* @method static ScheduledUserDishFactory factory($count = null, $state = [])
|
* @method static ScheduledUserDishFactory factory($count = null, $state = [])
|
||||||
|
* @method static firstOrCreate(array $array, array $array1)
|
||||||
*/
|
*/
|
||||||
class ScheduledUserDish extends Model
|
class ScheduledUserDish extends Model
|
||||||
{
|
{
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
|
||||||
protected $fillable = ['schedule_id', 'user_id', 'user_dish_id', 'is_skipped'];
|
protected $fillable = [
|
||||||
|
'schedule_id',
|
||||||
|
'user_id',
|
||||||
|
'user_dish_id',
|
||||||
|
'is_skipped'
|
||||||
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'is_skipped' => 'boolean',
|
'is_skipped' => 'boolean',
|
||||||
|
|
@ -9,8 +9,7 @@
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
|
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
|
||||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Notifications\Notifiable;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @property int $id
|
* @property int $id
|
||||||
|
|
@ -20,22 +19,17 @@
|
||||||
* @property Collection<UserDish> $userDishes
|
* @property Collection<UserDish> $userDishes
|
||||||
* @method static User findOrFail(int $user_id)
|
* @method static User findOrFail(int $user_id)
|
||||||
* @method static UserFactory factory($count = null, $state = [])
|
* @method static UserFactory factory($count = null, $state = [])
|
||||||
|
* @method static create(array $array)
|
||||||
|
* @method static where(string $string, int|string|null $id)
|
||||||
*/
|
*/
|
||||||
class User extends Authenticatable
|
class User extends Model
|
||||||
{
|
{
|
||||||
/** @use HasFactory<UserFactory> */
|
/** @use HasFactory<UserFactory> */
|
||||||
use HasFactory, Notifiable;
|
use HasFactory;
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'planner_id',
|
'planner_id',
|
||||||
'name',
|
'name',
|
||||||
'email',
|
|
||||||
'password',
|
|
||||||
];
|
|
||||||
|
|
||||||
protected $hidden = [
|
|
||||||
'password',
|
|
||||||
'remember_token',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
protected static function booted(): void
|
protected static function booted(): void
|
||||||
|
|
@ -43,19 +37,6 @@ protected static function booted(): void
|
||||||
static::addGlobalScope(new BelongsToPlanner);
|
static::addGlobalScope(new BelongsToPlanner);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the attributes that should be cast.
|
|
||||||
*
|
|
||||||
* @return array<string, string>
|
|
||||||
*/
|
|
||||||
protected function casts(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'email_verified_at' => 'datetime',
|
|
||||||
'password' => 'hashed',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function dishes(): BelongsToMany
|
public function dishes(): BelongsToMany
|
||||||
{
|
{
|
||||||
return $this->belongsToMany(Dish::class, 'user_dishes', 'user_id', 'dish_id');
|
return $this->belongsToMany(Dish::class, 'user_dishes', 'user_id', 'dish_id');
|
||||||
|
|
@ -4,10 +4,12 @@
|
||||||
|
|
||||||
use App\Exceptions\CustomException;
|
use App\Exceptions\CustomException;
|
||||||
use App\Models\Dish;
|
use App\Models\Dish;
|
||||||
|
use App\Models\Planner;
|
||||||
use App\Models\Schedule;
|
use App\Models\Schedule;
|
||||||
use App\Models\ScheduledUserDish;
|
use App\Models\ScheduledUserDish;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\UserDish;
|
use App\Models\UserDish;
|
||||||
|
use Laravel\Cashier\Cashier;
|
||||||
use DishPlanner\Dish\Policies\DishPolicy;
|
use DishPlanner\Dish\Policies\DishPolicy;
|
||||||
use DishPlanner\Schedule\Policies\SchedulePolicy;
|
use DishPlanner\Schedule\Policies\SchedulePolicy;
|
||||||
use DishPlanner\ScheduledUserDish\Policies\ScheduledUserDishPolicy;
|
use DishPlanner\ScheduledUserDish\Policies\ScheduledUserDishPolicy;
|
||||||
|
|
@ -45,6 +47,8 @@ public function render($request, Throwable $e)
|
||||||
|
|
||||||
public function boot(): void
|
public function boot(): void
|
||||||
{
|
{
|
||||||
|
Cashier::useCustomerModel(Planner::class);
|
||||||
|
|
||||||
Gate::policy(Dish::class, DishPolicy::class);
|
Gate::policy(Dish::class, DishPolicy::class);
|
||||||
Gate::policy(Schedule::class, SchedulePolicy::class);
|
Gate::policy(Schedule::class, SchedulePolicy::class);
|
||||||
Gate::policy(ScheduledUserDish::class, ScheduledUserDishPolicy::class);
|
Gate::policy(ScheduledUserDish::class, ScheduledUserDishPolicy::class);
|
||||||
17
app/helpers.php
Normal file
17
app/helpers.php
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Enums\AppModeEnum;
|
||||||
|
|
||||||
|
if (! function_exists('is_mode_app')) {
|
||||||
|
function is_mode_app(): bool
|
||||||
|
{
|
||||||
|
return AppModeEnum::current()->isApp();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! function_exists('is_mode_saas')) {
|
||||||
|
function is_mode_saas(): bool
|
||||||
|
{
|
||||||
|
return AppModeEnum::current()->isSaas();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,71 +0,0 @@
|
||||||
APP_NAME=DishPlanner
|
|
||||||
APP_ENV=local
|
|
||||||
APP_KEY=base64:Z3WnYIG9I6xxft15P1EO31WHinj1R36eM/iN3ouyFBM=
|
|
||||||
APP_DEBUG=true
|
|
||||||
APP_TIMEZONE=UTC
|
|
||||||
APP_URL=http://localhost:8000
|
|
||||||
|
|
||||||
SANCTUM_STATEFUL_DOMAINS=localhost:3000
|
|
||||||
SESSION_DOMAIN=localhost
|
|
||||||
|
|
||||||
APP_LOCALE=en
|
|
||||||
APP_FALLBACK_LOCALE=en
|
|
||||||
APP_FAKER_LOCALE=en_US
|
|
||||||
|
|
||||||
APP_MAINTENANCE_DRIVER=file
|
|
||||||
# APP_MAINTENANCE_STORE=database
|
|
||||||
|
|
||||||
PHP_CLI_SERVER_WORKERS=4
|
|
||||||
|
|
||||||
BCRYPT_ROUNDS=12
|
|
||||||
|
|
||||||
LOG_CHANNEL=stack
|
|
||||||
LOG_STACK=single
|
|
||||||
LOG_DEPRECATIONS_CHANNEL=null
|
|
||||||
LOG_LEVEL=debug
|
|
||||||
|
|
||||||
WWWGROUP=1000
|
|
||||||
|
|
||||||
DB_CONNECTION=mysql
|
|
||||||
DB_HOST=mysql
|
|
||||||
DB_PORT=3306
|
|
||||||
DB_DATABASE=dishplanner
|
|
||||||
DB_USERNAME=dpuser
|
|
||||||
DB_PASSWORD=dppass
|
|
||||||
|
|
||||||
SESSION_DRIVER=database
|
|
||||||
SESSION_LIFETIME=120
|
|
||||||
SESSION_ENCRYPT=false
|
|
||||||
SESSION_PATH=/
|
|
||||||
SESSION_DOMAIN=null
|
|
||||||
|
|
||||||
BROADCAST_CONNECTION=log
|
|
||||||
FILESYSTEM_DISK=local
|
|
||||||
QUEUE_CONNECTION=database
|
|
||||||
|
|
||||||
CACHE_STORE=file
|
|
||||||
CACHE_PREFIX=dishplanner
|
|
||||||
|
|
||||||
MEMCACHED_HOST=127.0.0.1
|
|
||||||
|
|
||||||
REDIS_CLIENT=phpredis
|
|
||||||
REDIS_HOST=127.0.0.1
|
|
||||||
REDIS_PASSWORD=null
|
|
||||||
REDIS_PORT=6379
|
|
||||||
|
|
||||||
MAIL_MAILER=log
|
|
||||||
MAIL_SCHEME=null
|
|
||||||
MAIL_HOST=127.0.0.1
|
|
||||||
MAIL_PORT=2525
|
|
||||||
MAIL_USERNAME=null
|
|
||||||
MAIL_PASSWORD=null
|
|
||||||
MAIL_FROM_ADDRESS="hello@example.com"
|
|
||||||
MAIL_FROM_NAME="${APP_NAME}"
|
|
||||||
|
|
||||||
AWS_ACCESS_KEY_ID=
|
|
||||||
AWS_SECRET_ACCESS_KEY=
|
|
||||||
AWS_DEFAULT_REGION=us-east-1
|
|
||||||
AWS_BUCKET=
|
|
||||||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
|
||||||
|
|
||||||
VITE_APP_NAME="${APP_NAME}"
|
|
||||||
25
backend/.gitignore
vendored
25
backend/.gitignore
vendored
|
|
@ -1,25 +0,0 @@
|
||||||
/composer.lock
|
|
||||||
/.composer
|
|
||||||
/.phpunit.cache
|
|
||||||
/node_modules
|
|
||||||
/public/build
|
|
||||||
/public/hot
|
|
||||||
/public/storage
|
|
||||||
/storage/*.key
|
|
||||||
/storage/pail
|
|
||||||
/vendor
|
|
||||||
.env
|
|
||||||
.env.backup
|
|
||||||
.env.production
|
|
||||||
.phpactor.json
|
|
||||||
.phpunit.result.cache
|
|
||||||
Homestead.json
|
|
||||||
Homestead.yaml
|
|
||||||
npm-debug.log
|
|
||||||
yarn-error.log
|
|
||||||
/auth.json
|
|
||||||
/.fleet
|
|
||||||
/.idea
|
|
||||||
/.nova
|
|
||||||
/.vscode
|
|
||||||
/.zed
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
# Use official PHP base with required extensions
|
|
||||||
FROM php:8.2-fpm
|
|
||||||
|
|
||||||
# Install system dependencies & PHP extensions
|
|
||||||
RUN apt-get update && apt-get install -y \
|
|
||||||
git unzip curl libzip-dev libpng-dev libonig-dev libxml2-dev zip \
|
|
||||||
&& docker-php-ext-install pdo pdo_mysql zip mbstring exif pcntl bcmath
|
|
||||||
|
|
||||||
# Install Composer globally
|
|
||||||
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
|
||||||
|
|
||||||
# Set working dir
|
|
||||||
WORKDIR /var/www
|
|
||||||
|
|
||||||
# Copy app files
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
# Install PHP dependencies
|
|
||||||
RUN composer install --no-dev --optimize-autoloader
|
|
||||||
|
|
||||||
# Laravel optimizations
|
|
||||||
RUN php artisan config:cache \
|
|
||||||
&& php artisan route:cache \
|
|
||||||
&& php artisan view:cache
|
|
||||||
|
|
||||||
# Set correct permissions
|
|
||||||
RUN chown -R www-data:www-data /var/www \
|
|
||||||
&& chmod -R 755 /var/www/storage
|
|
||||||
|
|
||||||
USER www-data
|
|
||||||
|
|
||||||
# Expose port 9000 (default for php-fpm)
|
|
||||||
EXPOSE 9000
|
|
||||||
|
|
||||||
CMD ["php-fpm"]
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
# Dish Planner
|
|
||||||
|
|
||||||
Plan your future dishes
|
|
||||||
|
|
||||||
## Development
|
|
||||||
|
|
||||||
### Installation
|
|
||||||
|
|
||||||
This project uses Laravel Sail, so install all composer packages, get your docker up, and run
|
|
||||||
|
|
||||||
```shell
|
|
||||||
./vendor/bin/sail up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
@ -1,61 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
use App\Http\Middleware\ForceJsonResponse;
|
|
||||||
use App\Http\Middleware\HandleResourceNotFound;
|
|
||||||
use App\Services\OutputService;
|
|
||||||
use DishPlanner\Dish\Controllers\DishController;
|
|
||||||
use DishPlanner\Schedule\Console\Commands\GenerateScheduleCommand;
|
|
||||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
|
||||||
use Illuminate\Foundation\Application;
|
|
||||||
use Illuminate\Foundation\Configuration\Exceptions;
|
|
||||||
use Illuminate\Foundation\Configuration\Middleware;
|
|
||||||
use Illuminate\Http\Middleware\HandleCors;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Session\Middleware\StartSession;
|
|
||||||
use Illuminate\Validation\ValidationException;
|
|
||||||
use Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful;
|
|
||||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
|
||||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
|
||||||
|
|
||||||
return Application::configure(basePath: dirname(__DIR__))
|
|
||||||
->withRouting(
|
|
||||||
web: __DIR__.'/../routes/web.php',
|
|
||||||
api: __DIR__.'/../routes/api.php',
|
|
||||||
commands: __DIR__.'/../routes/console.php',
|
|
||||||
health: '/up',
|
|
||||||
)
|
|
||||||
->withMiddleware(function (Middleware $middleware) {
|
|
||||||
$middleware->append(ForceJsonResponse::class);
|
|
||||||
$middleware->append(StartSession::class);
|
|
||||||
$middleware->append(HandleCors::class);
|
|
||||||
})
|
|
||||||
->withExceptions(function (Exceptions $exceptions) {
|
|
||||||
$exceptions->shouldRenderJsonWhen(function (Request $request, Throwable $e) {
|
|
||||||
if ($request->is('api/*')) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $request->expectsJson();
|
|
||||||
});
|
|
||||||
|
|
||||||
/** @var OutputService $outputService */
|
|
||||||
$outputService = resolve(OutputService::class);
|
|
||||||
|
|
||||||
$exceptions->render(fn (ValidationException $e, Request $request) => $outputService
|
|
||||||
->response(false, null, [$e->getMessage()], 404)
|
|
||||||
);
|
|
||||||
|
|
||||||
$exceptions->render(fn (NotFoundHttpException $e, Request $request) => response()->json(
|
|
||||||
$outputService->response(false, null, ['MODEL_NOT_FOUND']),
|
|
||||||
404
|
|
||||||
));
|
|
||||||
|
|
||||||
$exceptions->render(fn (AccessDeniedHttpException $e, Request $request) => response()->json(
|
|
||||||
$outputService->response(false, null, [$e->getMessage()]),
|
|
||||||
403
|
|
||||||
));
|
|
||||||
})
|
|
||||||
->withCommands([
|
|
||||||
GenerateScheduleCommand::class,
|
|
||||||
])
|
|
||||||
->create();
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace Database\Seeders;
|
|
||||||
|
|
||||||
use App\Models\Dish;
|
|
||||||
use App\Models\Planner;
|
|
||||||
use App\Models\User;
|
|
||||||
use Illuminate\Database\Seeder;
|
|
||||||
|
|
||||||
class DishesSeeder extends Seeder
|
|
||||||
{
|
|
||||||
public function run(): void
|
|
||||||
{
|
|
||||||
$users = User::all();
|
|
||||||
$userOptions = collect([
|
|
||||||
[$users->first()],
|
|
||||||
[$users->last()],
|
|
||||||
[$users->first(), $users->last()],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$planner = Planner::all()->first() ?? Planner::factory()->create();
|
|
||||||
|
|
||||||
collect([
|
|
||||||
'lasagne', 'pizza', 'burger', 'fries', 'salad', 'sushi', 'pancakes', 'ice cream', 'spaghetti', 'mac and cheese',
|
|
||||||
'steak', 'chicken', 'beef', 'pork', 'fish', 'chips', 'cake',
|
|
||||||
])->map(fn (string $name) => Dish::factory()
|
|
||||||
->create([
|
|
||||||
'planner_id' => $planner->id,
|
|
||||||
'name' => $name,
|
|
||||||
])
|
|
||||||
)->each(fn (Dish $dish) => $dish->users()->attach($userOptions->random()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
services:
|
|
||||||
backend:
|
|
||||||
volumes:
|
|
||||||
- '.:/var/www/html:Z'
|
|
||||||
|
|
||||||
mysql:
|
|
||||||
image: 'docker.io/library/mysql:8.0'
|
|
||||||
environment:
|
|
||||||
MYSQL_USER: '${DB_USERNAME}'
|
|
||||||
MYSQL_PASSWORD: '${DB_PASSWORD}'
|
|
||||||
volumes:
|
|
||||||
- 'sail-mysql:/var/lib/mysql:Z'
|
|
||||||
|
|
||||||
frontend:
|
|
||||||
image: 'docker.io/library/node:22-alpine'
|
|
||||||
working_dir: /app
|
|
||||||
command: sh -c "npm install && npm run dev -- --host"
|
|
||||||
volumes:
|
|
||||||
- '../frontend:/app:Z'
|
|
||||||
ports:
|
|
||||||
- '5173:5173'
|
|
||||||
networks:
|
|
||||||
- sail
|
|
||||||
depends_on:
|
|
||||||
- backend
|
|
||||||
|
|
@ -1,54 +0,0 @@
|
||||||
services:
|
|
||||||
backend:
|
|
||||||
build:
|
|
||||||
context: './vendor/laravel/sail/runtimes/8.4'
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
args:
|
|
||||||
WWWGROUP: '${WWWGROUP}'
|
|
||||||
image: 'sail-8.4/app'
|
|
||||||
extra_hosts:
|
|
||||||
- 'host.docker.internal:host-gateway'
|
|
||||||
ports:
|
|
||||||
- '${APP_PORT:-80}:80'
|
|
||||||
- '${VITE_PORT:-5173}:${VITE_PORT:-5173}'
|
|
||||||
environment:
|
|
||||||
WWWUSER: '${WWWUSER}'
|
|
||||||
LARAVEL_SAIL: 1
|
|
||||||
XDEBUG_MODE: '${SAIL_XDEBUG_MODE:-off}'
|
|
||||||
XDEBUG_CONFIG: '${SAIL_XDEBUG_CONFIG:-client_host=host.docker.internal}'
|
|
||||||
IGNITION_LOCAL_SITES_PATH: '${PWD}'
|
|
||||||
volumes:
|
|
||||||
- '.:/var/www/html:Z'
|
|
||||||
networks:
|
|
||||||
- sail
|
|
||||||
depends_on:
|
|
||||||
- mysql
|
|
||||||
mysql:
|
|
||||||
image: 'docker.io/mysql/mysql-server:8.0'
|
|
||||||
ports:
|
|
||||||
- '${FORWARD_DB_PORT:-3306}:3306'
|
|
||||||
environment:
|
|
||||||
MYSQL_ROOT_PASSWORD: '${DB_PASSWORD}'
|
|
||||||
MYSQL_ROOT_HOST: '%'
|
|
||||||
MYSQL_DATABASE: '${DB_DATABASE}'
|
|
||||||
MYSQL_USER: '${DB_USERNAME}'
|
|
||||||
MYSQL_PASSWORD: '${DB_PASSWORD}'
|
|
||||||
MYSQL_ALLOW_EMPTY_PASSWORD: 1
|
|
||||||
volumes:
|
|
||||||
- 'sail-mysql:/var/lib/mysql:Z'
|
|
||||||
networks:
|
|
||||||
- sail
|
|
||||||
healthcheck:
|
|
||||||
test:
|
|
||||||
- CMD
|
|
||||||
- mysqladmin
|
|
||||||
- ping
|
|
||||||
- '-p${DB_PASSWORD}'
|
|
||||||
retries: 3
|
|
||||||
timeout: 5s
|
|
||||||
networks:
|
|
||||||
sail:
|
|
||||||
driver: bridge
|
|
||||||
volumes:
|
|
||||||
sail-mysql:
|
|
||||||
driver: local
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
{
|
|
||||||
"private": true,
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"build": "vite build",
|
|
||||||
"dev": "vite"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"autoprefixer": "^10.4.20",
|
|
||||||
"axios": "^1.7.4",
|
|
||||||
"concurrently": "^9.0.1",
|
|
||||||
"laravel-vite-plugin": "^1.0",
|
|
||||||
"postcss": "^8.4.47",
|
|
||||||
"tailwindcss": "^3.4.13",
|
|
||||||
"vite": "^6.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
@tailwind base;
|
|
||||||
@tailwind components;
|
|
||||||
@tailwind utilities;
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
use Illuminate\Support\Facades\Route;
|
|
||||||
|
|
||||||
Route::get('/', function () {
|
|
||||||
return view('welcome');
|
|
||||||
});
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
import defaultTheme from 'tailwindcss/defaultTheme';
|
|
||||||
|
|
||||||
/** @type {import('tailwindcss').Config} */
|
|
||||||
export default {
|
|
||||||
content: [
|
|
||||||
'./vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php',
|
|
||||||
'./storage/framework/views/*.php',
|
|
||||||
'./resources/**/*.blade.php',
|
|
||||||
'./resources/**/*.js',
|
|
||||||
'./resources/**/*.vue',
|
|
||||||
],
|
|
||||||
theme: {
|
|
||||||
extend: {
|
|
||||||
fontFamily: {
|
|
||||||
sans: ['Figtree', ...defaultTheme.fontFamily.sans],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
plugins: [],
|
|
||||||
};
|
|
||||||
|
|
@ -1,98 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace Tests\Feature;
|
|
||||||
|
|
||||||
use App\Models\Planner;
|
|
||||||
use App\Models\Schedule;
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
||||||
use Illuminate\Support\Facades\Hash;
|
|
||||||
use Tests\TestCase;
|
|
||||||
|
|
||||||
class PlannerLoginTest extends TestCase
|
|
||||||
{
|
|
||||||
use RefreshDatabase;
|
|
||||||
|
|
||||||
public function test_a_planner_can_log_in_with_correct_credentials(): void
|
|
||||||
{
|
|
||||||
$planner = Planner::factory()->create([
|
|
||||||
'email' => 'planner@example.com',
|
|
||||||
'password' => Hash::make('secret123'),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response = $this
|
|
||||||
->actingAs($planner)
|
|
||||||
->post(route('api.auth.login'), [
|
|
||||||
'email' => 'planner@example.com',
|
|
||||||
'password' => 'secret123',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response->assertOk();
|
|
||||||
$this->assertAuthenticatedAs($planner);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_login_fails_with_invalid_credentials(): void
|
|
||||||
{
|
|
||||||
Planner::factory()->create([
|
|
||||||
'email' => 'planner@example.com',
|
|
||||||
'password' => Hash::make('secret123'),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response = $this->postJson(route('api.auth.login'), [
|
|
||||||
'email' => 'planner@example.com',
|
|
||||||
'password' => 'wrongpassword',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response->assertUnauthorized();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_a_logged_in_planner_can_log_out(): void
|
|
||||||
{
|
|
||||||
$planner = Planner::factory()->create([
|
|
||||||
'password' => Hash::make('secret123'),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->post(route('api.auth.login'), [
|
|
||||||
'email' => $planner->email,
|
|
||||||
'password' => 'secret123',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response = $this->post(route('api.auth.logout'));
|
|
||||||
|
|
||||||
$response->assertOk();
|
|
||||||
$this->assertGuest(); // nobody should be logged in after logout
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_planner_can_register(): void
|
|
||||||
{
|
|
||||||
$schedulesCount = Schedule::all()->count();
|
|
||||||
|
|
||||||
$response = $this->post(route('api.auth.register'), [
|
|
||||||
'name' => 'High Functioning Planner',
|
|
||||||
'email' => 'planner@example.com',
|
|
||||||
'password' => 'secret123',
|
|
||||||
'password_confirmation' => 'secret123',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response->assertCreated();
|
|
||||||
|
|
||||||
$this->assertDatabaseHas('planners', [
|
|
||||||
'email' => 'planner@example.com',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->assertGreaterThan($schedulesCount, Schedule::all()->count());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_it_returns_the_authenticated_planner(): void
|
|
||||||
{
|
|
||||||
$planner = Planner::factory()->create();
|
|
||||||
|
|
||||||
$this
|
|
||||||
->actingAs($planner)
|
|
||||||
->get(route('api.auth.me'))
|
|
||||||
->assertOk()
|
|
||||||
->assertJsonFragment([
|
|
||||||
'email' => $planner->email,
|
|
||||||
'name' => $planner->name,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace Tests\Traits;
|
|
||||||
|
|
||||||
use App\Models\Planner;
|
|
||||||
|
|
||||||
trait HasPlanner
|
|
||||||
{
|
|
||||||
protected Planner $planner;
|
|
||||||
|
|
||||||
public function setUp(): void
|
|
||||||
{
|
|
||||||
parent::setUp();
|
|
||||||
|
|
||||||
$planner = Planner::factory()->create();
|
|
||||||
|
|
||||||
$this->planner = $planner;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
29
bin/build-push.sh
Executable file
29
bin/build-push.sh
Executable file
|
|
@ -0,0 +1,29 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# Build and push production image to Codeberg
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
REGISTRY="codeberg.org"
|
||||||
|
NAMESPACE="lvl0"
|
||||||
|
IMAGE_NAME="dish-planner"
|
||||||
|
TAG="${1:-latest}"
|
||||||
|
|
||||||
|
echo "🔨 Building production image..."
|
||||||
|
podman build -f Dockerfile -t ${REGISTRY}/${NAMESPACE}/${IMAGE_NAME}:${TAG} .
|
||||||
|
|
||||||
|
echo "📤 Pushing to Codeberg registry..."
|
||||||
|
echo "Please ensure you're logged in to Codeberg:"
|
||||||
|
echo " podman login codeberg.org"
|
||||||
|
|
||||||
|
podman push ${REGISTRY}/${NAMESPACE}/${IMAGE_NAME}:${TAG}
|
||||||
|
|
||||||
|
echo "✅ Done! Image pushed to ${REGISTRY}/${NAMESPACE}/${IMAGE_NAME}:${TAG}"
|
||||||
|
echo ""
|
||||||
|
echo "To deploy in production:"
|
||||||
|
echo "1. Copy docker-compose.prod.yml to your server"
|
||||||
|
echo "2. Set required environment variables:"
|
||||||
|
echo " - APP_KEY (generate with: openssl rand -base64 32)"
|
||||||
|
echo " - APP_URL"
|
||||||
|
echo " - DB_DATABASE, DB_USERNAME, DB_PASSWORD, DB_ROOT_PASSWORD"
|
||||||
|
echo "3. Run: docker-compose -f docker-compose.prod.yml up -d"
|
||||||
80
bootstrap/app.php
Normal file
80
bootstrap/app.php
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Middleware\ForceJsonResponse;
|
||||||
|
use App\Http\Middleware\RequireSaasMode;
|
||||||
|
use App\Http\Middleware\RequireSubscription;
|
||||||
|
use App\Services\OutputService;
|
||||||
|
use Illuminate\Foundation\Application;
|
||||||
|
use Illuminate\Foundation\Configuration\Exceptions;
|
||||||
|
use Illuminate\Foundation\Configuration\Middleware;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
|
|
||||||
|
return Application::configure(basePath: dirname(__DIR__))
|
||||||
|
->withRouting(
|
||||||
|
web: __DIR__.'/../routes/web.php',
|
||||||
|
api: __DIR__.'/../routes/api.php',
|
||||||
|
commands: __DIR__.'/../routes/console.php',
|
||||||
|
health: '/up',
|
||||||
|
then: function () {
|
||||||
|
Route::middleware('web')
|
||||||
|
->group(base_path('routes/web/subscription.php'));
|
||||||
|
},
|
||||||
|
)
|
||||||
|
->withMiddleware(function (Middleware $middleware) {
|
||||||
|
// Apply ForceJsonResponse only to API routes
|
||||||
|
$middleware->api(ForceJsonResponse::class);
|
||||||
|
|
||||||
|
$middleware->alias([
|
||||||
|
'subscription' => RequireSubscription::class,
|
||||||
|
'saas' => RequireSaasMode::class,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Exclude Stripe webhook from CSRF verification
|
||||||
|
$middleware->validateCsrfTokens(except: [
|
||||||
|
'stripe/webhook',
|
||||||
|
]);
|
||||||
|
})
|
||||||
|
->withExceptions(function (Exceptions $exceptions) {
|
||||||
|
$exceptions->shouldRenderJsonWhen(function (Request $request, Throwable $e) {
|
||||||
|
if ($request->is('api/*')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $request->expectsJson();
|
||||||
|
});
|
||||||
|
|
||||||
|
/** @var OutputService $outputService */
|
||||||
|
$outputService = resolve(OutputService::class);
|
||||||
|
|
||||||
|
$exceptions->render(function (ValidationException $e, Request $request) use ($outputService) {
|
||||||
|
if ($request->is('api/*') || $request->expectsJson()) {
|
||||||
|
return response()->json(
|
||||||
|
$outputService->response(false, null, [$e->getMessage()]),
|
||||||
|
404
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$exceptions->render(function (NotFoundHttpException $e, Request $request) use ($outputService) {
|
||||||
|
if ($request->is('api/*') || $request->expectsJson()) {
|
||||||
|
return response()->json(
|
||||||
|
$outputService->response(false, null, ['MODEL_NOT_FOUND']),
|
||||||
|
404
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$exceptions->render(function (AccessDeniedHttpException $e, Request $request) use ($outputService) {
|
||||||
|
if ($request->is('api/*') || $request->expectsJson()) {
|
||||||
|
return response()->json(
|
||||||
|
$outputService->response(false, null, [$e->getMessage()]),
|
||||||
|
403
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
->create();
|
||||||
0
backend/bootstrap/cache/.gitignore → bootstrap/cache/.gitignore
vendored
Normal file → Executable file
0
backend/bootstrap/cache/.gitignore → bootstrap/cache/.gitignore
vendored
Normal file → Executable file
29
build-push.sh
Executable file
29
build-push.sh
Executable file
|
|
@ -0,0 +1,29 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# Build and push production image to Codeberg
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
REGISTRY="codeberg.org"
|
||||||
|
NAMESPACE="lvl0"
|
||||||
|
IMAGE_NAME="dish-planner"
|
||||||
|
TAG="${1:-latest}"
|
||||||
|
|
||||||
|
echo "🔨 Building production image..."
|
||||||
|
podman build -f Dockerfile -t ${REGISTRY}/${NAMESPACE}/${IMAGE_NAME}:${TAG} .
|
||||||
|
|
||||||
|
echo "📤 Pushing to Codeberg registry..."
|
||||||
|
echo "Please ensure you're logged in to Codeberg:"
|
||||||
|
echo " podman login codeberg.org"
|
||||||
|
|
||||||
|
podman push ${REGISTRY}/${NAMESPACE}/${IMAGE_NAME}:${TAG}
|
||||||
|
|
||||||
|
echo "✅ Done! Image pushed to ${REGISTRY}/${NAMESPACE}/${IMAGE_NAME}:${TAG}"
|
||||||
|
echo ""
|
||||||
|
echo "To deploy in production:"
|
||||||
|
echo "1. Copy docker-compose.prod.yml to your server"
|
||||||
|
echo "2. Set required environment variables:"
|
||||||
|
echo " - APP_KEY (generate with: openssl rand -base64 32)"
|
||||||
|
echo " - APP_URL"
|
||||||
|
echo " - DB_DATABASE, DB_USERNAME, DB_PASSWORD, DB_ROOT_PASSWORD"
|
||||||
|
echo "3. Run: docker-compose -f docker-compose.prod.yml up -d"
|
||||||
3
build_and_push.sh
Executable file
3
build_and_push.sh
Executable file
|
|
@ -0,0 +1,3 @@
|
||||||
|
#!/bin/bash
|
||||||
|
docker build -t 192.168.178.152:50114/dishplanner-backend .
|
||||||
|
docker push 192.168.178.152:50114/dishplanner-backend
|
||||||
|
|
@ -10,12 +10,15 @@
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"require": {
|
"require": {
|
||||||
"php": "^8.2",
|
"php": "^8.2",
|
||||||
|
"laravel/cashier": "^16.1",
|
||||||
"laravel/framework": "^12.9.2",
|
"laravel/framework": "^12.9.2",
|
||||||
"laravel/sanctum": "^4.0",
|
"laravel/sanctum": "^4.0",
|
||||||
"laravel/tinker": "^2.9"
|
"laravel/tinker": "^2.9",
|
||||||
|
"livewire/livewire": "^3.7"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"fakerphp/faker": "^1.23",
|
"fakerphp/faker": "^1.23",
|
||||||
|
"laravel/dusk": "^8.3",
|
||||||
"laravel/pail": "^1.1",
|
"laravel/pail": "^1.1",
|
||||||
"laravel/pint": "^1.13",
|
"laravel/pint": "^1.13",
|
||||||
"laravel/sail": "^1.26",
|
"laravel/sail": "^1.26",
|
||||||
|
|
@ -24,6 +27,9 @@
|
||||||
"phpunit/phpunit": "^11.0.1"
|
"phpunit/phpunit": "^11.0.1"
|
||||||
},
|
},
|
||||||
"autoload": {
|
"autoload": {
|
||||||
|
"files": [
|
||||||
|
"app/helpers.php"
|
||||||
|
],
|
||||||
"psr-4": {
|
"psr-4": {
|
||||||
"App\\": "app/",
|
"App\\": "app/",
|
||||||
"DishPlanner\\": "src/DishPlanner/",
|
"DishPlanner\\": "src/DishPlanner/",
|
||||||
|
|
@ -55,6 +61,17 @@
|
||||||
"dev": [
|
"dev": [
|
||||||
"Composer\\Config::disableProcessTimeout",
|
"Composer\\Config::disableProcessTimeout",
|
||||||
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite"
|
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite"
|
||||||
|
],
|
||||||
|
"test": [
|
||||||
|
"@php artisan test"
|
||||||
|
],
|
||||||
|
"test:coverage": [
|
||||||
|
"Composer\\Config::disableProcessTimeout",
|
||||||
|
"@php -d xdebug.mode=coverage artisan test --coverage"
|
||||||
|
],
|
||||||
|
"test:coverage-html": [
|
||||||
|
"Composer\\Config::disableProcessTimeout",
|
||||||
|
"@php -d xdebug.mode=coverage vendor/bin/phpunit --coverage-html coverage --coverage-text"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"extra": {
|
"extra": {
|
||||||
|
|
@ -13,7 +13,7 @@
|
||||||
|
|
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'name' => env('APP_NAME', 'Laravel'),
|
'name' => env('APP_NAME', 'Dish Planner'),
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
|
|
@ -28,6 +28,18 @@
|
||||||
|
|
||||||
'env' => env('APP_ENV', 'production'),
|
'env' => env('APP_ENV', 'production'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Application Mode
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Determines the application deployment mode: 'app' for self-hosted,
|
||||||
|
| 'saas' for multi-tenant SaaS, 'demo' for demonstration instances.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'mode' => env('APP_MODE', 'app'),
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
| Application Debug Mode
|
| Application Debug Mode
|
||||||
186
config/livewire.php
Normal file
186
config/livewire.php
Normal file
|
|
@ -0,0 +1,186 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|---------------------------------------------------------------------------
|
||||||
|
| Class Namespace
|
||||||
|
|---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This value sets the root class namespace for Livewire component classes in
|
||||||
|
| your application. This value will change where component auto-discovery
|
||||||
|
| finds components. It's also referenced by the file creation commands.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'class_namespace' => 'App\\Livewire',
|
||||||
|
|
||||||
|
/*
|
||||||
|
|---------------------------------------------------------------------------
|
||||||
|
| View Path
|
||||||
|
|---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This value is used to specify where Livewire component Blade templates are
|
||||||
|
| stored when running file creation commands like `artisan make:livewire`.
|
||||||
|
| It is also used if you choose to omit a component's render() method.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'view_path' => resource_path('views/livewire'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|---------------------------------------------------------------------------
|
||||||
|
| Layout
|
||||||
|
|---------------------------------------------------------------------------
|
||||||
|
| The view that will be used as the layout when rendering a single component
|
||||||
|
| as an entire page via `Route::get('/post/create', CreatePost::class);`.
|
||||||
|
| In this case, the view returned by CreatePost will render into $slot.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'layout' => 'components.layouts.app',
|
||||||
|
|
||||||
|
/*
|
||||||
|
|---------------------------------------------------------------------------
|
||||||
|
| Lazy Loading Placeholder
|
||||||
|
|---------------------------------------------------------------------------
|
||||||
|
| Livewire allows you to lazy load components that would otherwise slow down
|
||||||
|
| the initial page load. Every component can have a custom placeholder or
|
||||||
|
| you can define the default placeholder view for all components below.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'lazy_placeholder' => null,
|
||||||
|
|
||||||
|
/*
|
||||||
|
|---------------------------------------------------------------------------
|
||||||
|
| Temporary File Uploads
|
||||||
|
|---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Livewire handles file uploads by storing uploads in a temporary directory
|
||||||
|
| before the file is stored permanently. All file uploads are directed to
|
||||||
|
| a global endpoint for temporary storage. You may configure this below:
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'temporary_file_upload' => [
|
||||||
|
'disk' => null, // Example: 'local', 's3' | Default: 'default'
|
||||||
|
'rules' => null, // Example: ['file', 'mimes:png,jpg'] | Default: ['required', 'file', 'max:12288'] (12MB)
|
||||||
|
'directory' => null, // Example: 'tmp' | Default: 'livewire-tmp'
|
||||||
|
'middleware' => null, // Example: 'throttle:5,1' | Default: 'throttle:60,1'
|
||||||
|
'preview_mimes' => [ // Supported file types for temporary pre-signed file URLs...
|
||||||
|
'png', 'gif', 'bmp', 'svg', 'wav', 'mp4',
|
||||||
|
'mov', 'avi', 'wmv', 'mp3', 'm4a',
|
||||||
|
'jpg', 'jpeg', 'mpga', 'webp', 'wma',
|
||||||
|
],
|
||||||
|
'max_upload_time' => 5, // Max duration (in minutes) before an upload is invalidated...
|
||||||
|
'cleanup' => true, // Should cleanup temporary uploads older than 24 hrs...
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|---------------------------------------------------------------------------
|
||||||
|
| Render On Redirect
|
||||||
|
|---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This value determines if Livewire will run a component's `render()` method
|
||||||
|
| after a redirect has been triggered using something like `redirect(...)`
|
||||||
|
| Setting this to true will render the view once more before redirecting
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'render_on_redirect' => false,
|
||||||
|
|
||||||
|
/*
|
||||||
|
|---------------------------------------------------------------------------
|
||||||
|
| Eloquent Model Binding
|
||||||
|
|---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Previous versions of Livewire supported binding directly to eloquent model
|
||||||
|
| properties using wire:model by default. However, this behavior has been
|
||||||
|
| deemed too "magical" and has therefore been put under a feature flag.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'legacy_model_binding' => false,
|
||||||
|
|
||||||
|
/*
|
||||||
|
|---------------------------------------------------------------------------
|
||||||
|
| Auto-inject Frontend Assets
|
||||||
|
|---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| By default, Livewire automatically injects its JavaScript and CSS into the
|
||||||
|
| <head> and <body> of pages containing Livewire components. By disabling
|
||||||
|
| this behavior, you need to use @livewireStyles and @livewireScripts.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'inject_assets' => true,
|
||||||
|
|
||||||
|
/*
|
||||||
|
|---------------------------------------------------------------------------
|
||||||
|
| Navigate (SPA mode)
|
||||||
|
|---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| By adding `wire:navigate` to links in your Livewire application, Livewire
|
||||||
|
| will prevent the default link handling and instead request those pages
|
||||||
|
| via AJAX, creating an SPA-like effect. Configure this behavior here.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'navigate' => [
|
||||||
|
'show_progress_bar' => true,
|
||||||
|
'progress_bar_color' => '#2299dd',
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|---------------------------------------------------------------------------
|
||||||
|
| HTML Morph Markers
|
||||||
|
|---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Livewire intelligently "morphs" existing HTML into the newly rendered HTML
|
||||||
|
| after each update. To make this process more reliable, Livewire injects
|
||||||
|
| "markers" into the rendered Blade surrounding @if, @class & @foreach.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'inject_morph_markers' => true,
|
||||||
|
|
||||||
|
/*
|
||||||
|
|---------------------------------------------------------------------------
|
||||||
|
| Smart Wire Keys
|
||||||
|
|---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Livewire uses loops and keys used within loops to generate smart keys that
|
||||||
|
| are applied to nested components that don't have them. This makes using
|
||||||
|
| nested components more reliable by ensuring that they all have keys.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'smart_wire_keys' => false,
|
||||||
|
|
||||||
|
/*
|
||||||
|
|---------------------------------------------------------------------------
|
||||||
|
| Pagination Theme
|
||||||
|
|---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| When enabling Livewire's pagination feature by using the `WithPagination`
|
||||||
|
| trait, Livewire will use Tailwind templates to render pagination views
|
||||||
|
| on the page. If you want Bootstrap CSS, you can specify: "bootstrap"
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'pagination_theme' => 'tailwind',
|
||||||
|
|
||||||
|
/*
|
||||||
|
|---------------------------------------------------------------------------
|
||||||
|
| Release Token
|
||||||
|
|---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This token is stored client-side and sent along with each request to check
|
||||||
|
| a users session to see if a new release has invalidated it. If there is
|
||||||
|
| a mismatch it will throw an error and prompt for a browser refresh.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'release_token' => 'a',
|
||||||
|
];
|
||||||
|
|
@ -35,4 +35,14 @@
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'stripe' => [
|
||||||
|
'key' => env('STRIPE_KEY'),
|
||||||
|
'secret' => env('STRIPE_SECRET'),
|
||||||
|
'webhook' => [
|
||||||
|
'secret' => env('STRIPE_WEBHOOK_SECRET'),
|
||||||
|
],
|
||||||
|
'price_monthly' => env('STRIPE_PRICE_MONTHLY'),
|
||||||
|
'price_yearly' => env('STRIPE_PRICE_YEARLY'),
|
||||||
|
],
|
||||||
|
|
||||||
];
|
];
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue