Compare commits

..

2 commits

Author SHA1 Message Date
fd05ffd06d Remove docker compose files 2025-11-16 15:54:34 +01:00
ed27f4bb49 2 - add production build and compose files 2025-11-15 10:19:53 +01:00
567 changed files with 7609 additions and 12084 deletions

View file

@ -0,0 +1,109 @@
# Development Docker Setup
This directory contains Docker Compose files for local development using Laravel Sail.
## Files
- **docker-compose.yml** - Base Sail configuration for backend (Laravel) and MySQL
- **docker-compose.override.yml** - Development overrides:
- Podman/SELinux compatibility (`:Z` volume flags)
- Standard MySQL image (instead of mysql-server)
- Frontend container (Node.js development server)
## Quick Start
Use the automated setup script from the project root:
```bash
./bin/start-dev
```
This script will:
1. Create `.env` if it doesn't exist
2. Install backend dependencies (composer)
3. Start all containers (backend, MySQL, frontend)
4. Run database migrations
5. Optionally seed the database
## Manual Usage
### Start all services
```bash
docker compose -f .docker/development/docker-compose.yml \
-f .docker/development/docker-compose.override.yml \
up -d
```
### View logs
```bash
# All services
docker compose -f .docker/development/docker-compose.yml \
-f .docker/development/docker-compose.override.yml \
logs -f
# Specific service
docker compose -f .docker/development/docker-compose.yml \
-f .docker/development/docker-compose.override.yml \
logs -f frontend
```
### Run backend commands
```bash
# Run migrations
docker compose -f .docker/development/docker-compose.yml \
-f .docker/development/docker-compose.override.yml \
exec backend php artisan migrate
# Run tinker
docker compose -f .docker/development/docker-compose.yml \
-f .docker/development/docker-compose.override.yml \
exec backend php artisan tinker
# Run tests
docker compose -f .docker/development/docker-compose.yml \
-f .docker/development/docker-compose.override.yml \
exec backend php artisan test
```
### Stop all services
```bash
docker compose -f .docker/development/docker-compose.yml \
-f .docker/development/docker-compose.override.yml \
down
```
## Services
| Service | Port | Description |
|---------|------|-------------|
| **backend** | 8000 | Laravel API (PHP 8.4 + Sail) |
| **mysql** | 3306 | MySQL 8.0 database |
| **frontend** | 5173 | Vite dev server (React Router) |
## Environment Variables
The backend reads from `backend/.env`. Key variables:
- `APP_PORT` - Backend port (default: 80, set to 8000 for Podman)
- `DB_*` - Database connection settings
- `FORWARD_DB_PORT` - Exposed MySQL port (default: 3306)
## Volume Mounts
- `backend/``/var/www/html` (backend container)
- `frontend/``/app` (frontend container)
- MySQL data persists in named volume `sail-mysql`
## Podman Compatibility
The `:Z` flag on volumes enables SELinux compatibility for Podman users. This is automatically included in the override file.
## Notes
- The frontend container automatically runs `npm install` and `npm run dev` on startup
- Laravel Sail handles PHP-FPM, supervisor, and other backend services
- Hot reload is enabled for both frontend (Vite HMR) and backend (file watchers)

View file

@ -0,0 +1,25 @@
services:
backend:
volumes:
- '../../backend:/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

View file

@ -0,0 +1,54 @@
services:
backend:
build:
context: '../../backend/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:
- '../../backend:/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

View file

@ -0,0 +1,29 @@
# Ignore .env files - they should be created at runtime
backend/.env
backend/.env.production
# Ignore node_modules - we install dependencies during build
frontend/node_modules
backend/vendor
# Ignore git
.git
.gitignore
# Ignore build artifacts
frontend/build
backend/bootstrap/cache/*
backend/storage/logs/*
backend/storage/framework/cache/*
backend/storage/framework/sessions/*
backend/storage/framework/views/*
# Ignore test files
backend/tests
frontend/tests
# Ignore development files
.vscode
.idea
*.log
.DS_Store

View file

@ -0,0 +1,151 @@
# ============================================
# Dish Planner - All-in-One Production Image
# ============================================
# This Dockerfile creates a single container with:
# - MySQL 8.0 database
# - PHP 8.2-FPM (Laravel backend)
# - Node.js 20 (React Router frontend)
# - Nginx (reverse proxy)
# - Supervisor (process manager)
#
# Build: docker build -f .docker/production/Dockerfile -t codeberg.org/lvl0/dish-planner:latest .
# Deploy: See .docker/production/README.md for deployment instructions
# ============================================
FROM ubuntu:22.04
# Prevent interactive prompts during build
ENV DEBIAN_FRONTEND=noninteractive
ENV TZ=UTC
# ============================================
# Install System Dependencies
# ============================================
RUN apt-get update && apt-get install -y \
# Basic utilities
curl \
wget \
git \
unzip \
ca-certificates \
gnupg \
supervisor \
software-properties-common \
# Nginx
nginx \
# MySQL Server
mysql-server \
&& rm -rf /var/lib/apt/lists/*
# ============================================
# Install PHP 8.2 from ondrej/php PPA
# ============================================
RUN add-apt-repository ppa:ondrej/php -y \
&& apt-get update \
&& apt-get install -y \
php8.2-fpm \
php8.2-cli \
php8.2-mysql \
php8.2-mbstring \
php8.2-xml \
php8.2-bcmath \
php8.2-curl \
php8.2-zip \
php8.2-gd \
php8.2-intl \
&& rm -rf /var/lib/apt/lists/*
# ============================================
# Install Node.js 20
# ============================================
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get install -y nodejs \
&& rm -rf /var/lib/apt/lists/*
# ============================================
# Install Composer
# ============================================
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
# ============================================
# Set up working directories
# ============================================
WORKDIR /var/www
# Create necessary directories
RUN mkdir -p /var/www/backend \
/var/www/frontend \
/var/www/storage/logs \
/var/lib/mysql \
/run/php
# ============================================
# Copy and Build Backend (Laravel)
# ============================================
COPY backend /var/www/backend
WORKDIR /var/www/backend
# Remove any existing .env files - they will be created at runtime
RUN rm -f .env .env.production
# Install PHP dependencies (production)
RUN composer install --no-dev --optimize-autoloader --no-interaction
# Set permissions for Laravel
RUN chown -R www-data:www-data /var/www/backend/storage /var/www/backend/bootstrap/cache \
&& chmod -R 775 /var/www/backend/storage /var/www/backend/bootstrap/cache
# Add www-data to mysql group so it can access MySQL socket
RUN usermod -a -G mysql www-data
# ============================================
# Copy and Build Frontend (React Router)
# ============================================
COPY frontend /var/www/frontend
WORKDIR /var/www/frontend
# Install all dependencies (including dev for build), build, then remove dev deps
RUN npm ci \
&& npm run build \
&& npm prune --production
# ============================================
# Configure Nginx
# ============================================
COPY .docker/production/nginx.conf /etc/nginx/sites-available/default
RUN ln -sf /etc/nginx/sites-available/default /etc/nginx/sites-enabled/default \
&& rm -f /etc/nginx/sites-enabled/default.old
# ============================================
# Configure PHP-FPM
# ============================================
RUN sed -i 's/listen = \/run\/php\/php8.2-fpm.sock/listen = 127.0.0.1:9000/' /etc/php/8.2/fpm/pool.d/www.conf
# ============================================
# Configure MySQL
# ============================================
# Allow MySQL to bind to all interfaces (for easier debugging if needed)
RUN sed -i 's/bind-address.*/bind-address = 127.0.0.1/' /etc/mysql/mysql.conf.d/mysqld.cnf || true
# ============================================
# Copy Configuration Files
# ============================================
COPY .docker/production/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
COPY .docker/production/entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
# ============================================
# Create volume mount points
# ============================================
VOLUME ["/var/lib/mysql", "/var/www/backend/storage"]
# ============================================
# Expose HTTP port
# ============================================
EXPOSE 80
# ============================================
# Set entrypoint and default command
# ============================================
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]

View file

@ -0,0 +1,345 @@
# Dish Planner - Self-Hosted Deployment Guide
This directory contains everything you need to deploy Dish Planner as a self-hosted all-in-one container.
> **Note**: This guide uses an embedded docker-compose configuration (no separate file to download). Simply copy the YAML configuration shown below and save it as `docker-compose.yml`.
## Quick Start
### For End Users (Using Pre-Built Image)
> **Image Versioning**: This guide uses the `:latest` tag for simplicity. For production deployments, consider using a specific version tag (e.g., `:v0.3`) for stability and predictable updates.
1. **Create a docker-compose.yml file:**
Copy the following configuration and save it as `docker-compose.yml`:
```yaml
# ============================================
# Dish Planner - Self-Hosted Deployment
# ============================================
#
# QUICK START:
# 1. Save this file as docker-compose.yml
# 2. (Optional) Edit the environment variables below
# 3. Run: docker compose up -d
# 4. Access your app at http://localhost:3000
#
# IMPORTANT SECURITY NOTES:
# - Database credentials are auto-generated on first run
# - Check the container logs to see generated credentials:
# docker logs dishplanner
# - For production, set a custom APP_URL environment variable
#
# CUSTOMIZATION:
# You can override any environment variable by uncommenting
# and editing the values below, or by creating a .env file.
#
# ============================================
services:
dishplanner:
# Pre-built all-in-one image from Codeberg Container Registry
image: codeberg.org/lvl0/dish-planner:latest
container_name: dishplanner
restart: unless-stopped
# ----------------------------------------
# Port Configuration
# ----------------------------------------
# The application will be accessible on port 3000
# Change the left number to use a different host port
# Example: "8080:80" to access on port 8080
ports:
- "3000:80"
# ----------------------------------------
# Environment Variables
# ----------------------------------------
# Uncomment and customize as needed
environment:
# Application URL (set this to your domain)
- APP_URL=http://localhost:3000
# Application environment (production recommended)
- APP_ENV=production
# Debug mode (set to false in production for security)
- APP_DEBUG=false
# Database configuration
# Note: Credentials are auto-generated on first run if not set
# Check logs for generated credentials: docker logs dishplanner
# - DB_DATABASE=dishplanner
# - DB_USERNAME=dishuser
# - DB_PASSWORD=change-this-secure-password
# Timezone (optional)
# - APP_TIMEZONE=UTC
# ----------------------------------------
# Persistent Data Volumes
# ----------------------------------------
# These volumes ensure data persists across container restarts
volumes:
# MySQL database data
- dishplanner_mysql_data:/var/lib/mysql
# Laravel storage (uploaded files, logs, cache)
- dishplanner_storage:/var/www/backend/storage
# Supervisor logs
- dishplanner_logs:/var/log/supervisor
# ----------------------------------------
# Health Check (optional)
# ----------------------------------------
# Uncomment to enable container health monitoring
# healthcheck:
# test: ["CMD", "curl", "-f", "http://localhost/api/health"]
# interval: 30s
# timeout: 10s
# retries: 3
# start_period: 60s
# ----------------------------------------
# Named Volumes
# ----------------------------------------
# Docker manages these volumes - data persists even if container is removed
volumes:
dishplanner_mysql_data:
driver: local
dishplanner_storage:
driver: local
dishplanner_logs:
driver: local
# ----------------------------------------
# Network Configuration
# ----------------------------------------
# Uses default bridge network (suitable for single-host deployment)
# For advanced setups, you can define custom networks here
```
2. **Start the application:**
```bash
docker compose up -d
```
3. **View the initialization logs to get your database credentials:**
```bash
docker logs dishplanner
```
Save the auto-generated database credentials shown in the logs!
4. **Access the application:**
Open your browser to `http://localhost:3000`
That's it! The application is now running with:
- MySQL database
- Laravel backend API
- React Router frontend
- Nginx reverse proxy
- Background queue worker
### For DockGE Users
1. In the DockGE web UI, click "Add Stack"
2. Copy the entire YAML configuration from the code block above (starting from `services:` and including all volumes)
3. Paste it into the DockGE compose editor
4. Optionally edit the environment variables
5. Click "Start"
6. View logs to get auto-generated database credentials
## Configuration
### Environment Variables
You can customize the deployment by setting these environment variables in the docker-compose file:
| Variable | Default | Description |
|----------|---------|-------------|
| `APP_URL` | `http://localhost:3000` | The URL where your app is accessible |
| `APP_ENV` | `production` | Environment mode (production/local) |
| `APP_DEBUG` | `false` | Enable debug mode (never use in production!) |
| `DB_DATABASE` | `dishplanner` | Database name |
| `DB_USERNAME` | `dishuser` | Database username |
| `DB_PASSWORD` | Auto-generated | Database password (check logs for generated value) |
### Changing the Port
By default, the app runs on port 3000. To use a different port, edit the `ports` section:
```yaml
ports:
- "8080:80" # This makes the app available on port 8080
```
### Persistent Data
The following data is persisted in Docker volumes:
- **MySQL database** - All your dishes, schedules, and users
- **Laravel storage** - Uploaded files, logs, and cache
- **Supervisor logs** - Application and service logs
Even if you remove the container, this data remains intact.
## Building the Image Yourself
If you prefer to build the image yourself instead of using the pre-built one:
1. **Clone the repository:**
```bash
git clone https://codeberg.org/lvl0/dish-planner.git
cd dish-planner
```
2. **Build the image:**
```bash
docker build -f .docker/production/Dockerfile -t codeberg.org/lvl0/dish-planner:latest .
```
3. **Create a docker-compose.yml file** using the configuration shown in the Quick Start section above (or save the embedded compose config to a file)
4. **Run it:**
```bash
docker compose up -d
```
## Management
### Viewing Logs
```bash
# All logs
docker logs dishplanner
# Follow logs in real-time
docker logs -f dishplanner
# Last 100 lines
docker logs --tail 100 dishplanner
```
### Stopping the Application
```bash
docker compose down
```
### Restarting the Application
```bash
docker compose restart
```
### Updating to a New Version
1. Pull the latest image:
```bash
docker compose pull
```
2. Recreate the container:
```bash
docker compose up -d
```
3. Your data persists in volumes automatically!
## Troubleshooting
### "Cannot connect to database"
Check that MySQL started successfully:
```bash
docker logs dishplanner | grep mysql
```
### "502 Bad Gateway"
One of the services may not have started. Check supervisor logs:
```bash
docker exec dishplanner supervisorctl status
```
### Reset Everything (CAUTION: Deletes all data!)
```bash
docker compose down -v # The -v flag removes volumes
docker compose up -d
```
### Access the Container Shell
```bash
docker exec -it dishplanner bash
```
### Run Laravel Commands
```bash
# Run migrations
docker exec dishplanner php /var/www/backend/artisan migrate
# Create a new user (if needed)
docker exec -it dishplanner php /var/www/backend/artisan tinker
```
## Architecture
This all-in-one container includes:
- **MySQL 8.0** - Database server
- **PHP 8.2-FPM** - Runs the Laravel backend
- **Node.js 20** - Runs the React Router frontend
- **Nginx** - Web server and reverse proxy
- **Supervisord** - Process manager that keeps everything running
All services start automatically and are monitored by supervisord. If any service crashes, it will be automatically restarted.
## Security Recommendations
For production deployments:
1. **Set a strong database password:**
```yaml
environment:
- DB_PASSWORD=your-very-secure-password-here
```
2. **Use HTTPS:** Put the container behind a reverse proxy (Nginx, Caddy, Traefik) with SSL/TLS
3. **Set APP_DEBUG to false:**
```yaml
environment:
- APP_DEBUG=false
```
4. **Keep the image updated:** Regularly pull and deploy new versions
5. **Backup your data:**
```bash
# Backup volumes
docker run --rm -v dishplanner_mysql_data:/data -v $(pwd):/backup ubuntu tar czf /backup/mysql-backup.tar.gz /data
```
## Support
For issues and questions:
- Codeberg Issues: https://codeberg.org/lvl0/dish-planner/issues
- Documentation: https://codeberg.org/lvl0/dish-planner
## What's Inside
The container runs these processes (managed by supervisord):
1. **MySQL** - Database (port 3306, internal only)
2. **PHP-FPM** - Laravel application (port 9000, internal only)
3. **Node.js** - React frontend (port 3000, internal only)
4. **Nginx** - Reverse proxy (port 80, exposed)
5. **Queue Worker** - Background job processor
Only Nginx's port 80 is exposed to the host (mapped to 3000 by default).

View file

@ -0,0 +1,105 @@
# ============================================
# Dish Planner - Self-Hosted Deployment
# ============================================
#
# QUICK START:
# 1. Copy this file to your server
# 2. (Optional) Edit the environment variables below
# 3. Run: docker compose up -d
# 4. Access your app at http://localhost:3000
#
# IMPORTANT SECURITY NOTES:
# - Database credentials are auto-generated on first run
# - Check the container logs to see generated credentials:
# docker logs dishplanner
# - For production, set a custom APP_URL environment variable
#
# CUSTOMIZATION:
# You can override any environment variable by uncommenting
# and editing the values below, or by creating a .env file.
#
# ============================================
services:
dishplanner:
# Pre-built all-in-one image from Docker Hub
image: localhost/dishplanner-allinone:test
container_name: dishplanner
restart: unless-stopped
# ----------------------------------------
# Port Configuration
# ----------------------------------------
# The application will be accessible on port 3000
# Change the left number to use a different host port
# Example: "8080:80" to access on port 8080
ports:
- "3000:80"
# ----------------------------------------
# Environment Variables
# ----------------------------------------
# Uncomment and customize as needed
environment:
# Application URL (set this to your domain)
- APP_URL=http://localhost:3000
# Application environment (production recommended)
- APP_ENV=production
# Debug mode (set to false in production for security)
- APP_DEBUG=false
# Database configuration
# Note: Credentials are auto-generated on first run if not set
# Check logs for generated credentials: docker logs dishplanner
# - DB_DATABASE=dishplanner
# - DB_USERNAME=dishuser
# - DB_PASSWORD=change-this-secure-password
# Timezone (optional)
# - APP_TIMEZONE=UTC
# ----------------------------------------
# Persistent Data Volumes
# ----------------------------------------
# These volumes ensure data persists across container restarts
volumes:
# MySQL database data
- dishplanner_mysql_data:/var/lib/mysql
# Laravel storage (uploaded files, logs, cache)
- dishplanner_storage:/var/www/backend/storage
# Supervisor logs
- dishplanner_logs:/var/log/supervisor
# ----------------------------------------
# Health Check (optional)
# ----------------------------------------
# Uncomment to enable container health monitoring
# healthcheck:
# test: ["CMD", "curl", "-f", "http://localhost/api/health"]
# interval: 30s
# timeout: 10s
# retries: 3
# start_period: 60s
# ----------------------------------------
# Named Volumes
# ----------------------------------------
# Docker manages these volumes - data persists even if container is removed
volumes:
dishplanner_mysql_data:
driver: local
dishplanner_storage:
driver: local
dishplanner_logs:
driver: local
# ----------------------------------------
# Network Configuration
# ----------------------------------------
# Uses default bridge network (suitable for single-host deployment)
# For advanced setups, you can define custom networks here

View file

@ -0,0 +1,153 @@
#!/bin/bash
set -e
echo "=========================================="
echo "Dish Planner - Starting Initialization"
echo "=========================================="
# ============================================
# MySQL Initialization
# ============================================
echo "[1/6] Initializing MySQL..."
# Check if MySQL data directory is empty (first run)
if [ ! -d "/var/lib/mysql/mysql" ]; then
echo " → First run detected, initializing MySQL data directory..."
mysqld --initialize-insecure --user=mysql --datadir=/var/lib/mysql
fi
# Start MySQL temporarily in background for setup
echo " → Starting MySQL..."
mysqld --user=mysql --datadir=/var/lib/mysql &
MYSQL_PID=$!
# Wait for MySQL to be ready
echo " → Waiting for MySQL to be ready..."
for i in {1..30}; do
if mysqladmin ping -h localhost --silent; then
echo " → MySQL is ready!"
break
fi
echo " → Waiting... ($i/30)"
sleep 2
done
# ============================================
# Database Setup
# ============================================
echo "[2/6] Setting up database..."
# Set default values for database credentials
DB_DATABASE=${DB_DATABASE:-dishplanner}
DB_USERNAME=${DB_USERNAME:-dishuser}
# Check if this is first run by looking for credentials file
CREDS_FILE="/var/www/backend/storage/.db_credentials"
if [ -f "$CREDS_FILE" ]; then
# Not first run - load existing credentials
echo " → Loading existing database credentials..."
source "$CREDS_FILE"
else
# First run - generate new credentials and save them
echo " → First run detected, generating credentials..."
DB_PASSWORD=${DB_PASSWORD:-$(openssl rand -base64 32)}
MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD:-$(openssl rand -base64 32)}
# Save credentials for future restarts
cat > "$CREDS_FILE" <<EOF
DB_PASSWORD='${DB_PASSWORD}'
MYSQL_ROOT_PASSWORD='${MYSQL_ROOT_PASSWORD}'
EOF
chmod 600 "$CREDS_FILE"
# Set root password
mysql -u root <<-EOSQL
ALTER USER 'root'@'localhost' IDENTIFIED BY '${MYSQL_ROOT_PASSWORD}';
FLUSH PRIVILEGES;
EOSQL
# Create database and user
mysql -u root -p"${MYSQL_ROOT_PASSWORD}" <<-EOSQL
CREATE DATABASE IF NOT EXISTS ${DB_DATABASE};
CREATE USER IF NOT EXISTS '${DB_USERNAME}'@'localhost' IDENTIFIED BY '${DB_PASSWORD}';
CREATE USER IF NOT EXISTS '${DB_USERNAME}'@'%' IDENTIFIED BY '${DB_PASSWORD}';
GRANT ALL PRIVILEGES ON ${DB_DATABASE}.* TO '${DB_USERNAME}'@'localhost';
GRANT ALL PRIVILEGES ON ${DB_DATABASE}.* TO '${DB_USERNAME}'@'%';
FLUSH PRIVILEGES;
EOSQL
echo " → Database '${DB_DATABASE}' created"
echo " → User '${DB_USERNAME}' created"
fi
# ============================================
# Laravel Environment Setup
# ============================================
echo "[3/6] Configuring Laravel environment..."
cd /var/www/backend
# Create .env if it doesn't exist
if [ ! -f .env ]; then
echo " → Creating .env file..."
cp .env.example .env
fi
# Update database credentials in .env using a more robust method
# Use @ as delimiter to avoid conflicts with special chars in passwords
sed -i "s@DB_DATABASE=.*@DB_DATABASE=${DB_DATABASE}@" .env
sed -i "s@DB_USERNAME=.*@DB_USERNAME=${DB_USERNAME}@" .env
sed -i "s@DB_PASSWORD=.*@DB_PASSWORD=${DB_PASSWORD}@" .env
sed -i "s@DB_HOST=.*@DB_HOST=127.0.0.1@" .env
sed -i "s@DB_PORT=.*@DB_PORT=3306@" .env
# Generate APP_KEY if not set
if ! grep -q "APP_KEY=base64:" .env; then
echo " → Generating application key..."
php artisan key:generate --force
else
echo " → Application key already set"
fi
# Set APP_URL if provided
if [ -n "${APP_URL}" ]; then
sed -i "s@APP_URL=.*@APP_URL=${APP_URL}@" .env
fi
# ============================================
# Run Database Migrations
# ============================================
echo "[4/6] Running database migrations..."
php artisan migrate --force
# ============================================
# Laravel Optimizations
# ============================================
echo "[5/6] Optimizing Laravel..."
php artisan config:cache
php artisan route:cache
php artisan view:cache
# ============================================
# Stop temporary MySQL instance
# ============================================
echo "[6/6] Stopping temporary MySQL instance..."
mysqladmin -u root -p"${MYSQL_ROOT_PASSWORD}" shutdown
wait $MYSQL_PID
echo "=========================================="
echo "Initialization complete!"
echo "=========================================="
echo ""
echo "Database Credentials (save these!):"
echo " Database: ${DB_DATABASE}"
echo " Username: ${DB_USERNAME}"
echo " Password: ${DB_PASSWORD}"
echo " Root Password: ${MYSQL_ROOT_PASSWORD}"
echo ""
echo "Starting all services with supervisord..."
echo "=========================================="
# Execute the command passed to the container (supervisord)
exec "$@"

View file

@ -0,0 +1,109 @@
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name _;
# Logging
access_log /var/log/nginx/access.log;
error_log /var/log/nginx/error.log;
# =========================================
# Frontend Static Assets (served directly by nginx) - MUST BE FIRST
# =========================================
# Use ^~ to prevent regex locations from matching
location ^~ /assets/ {
alias /var/www/frontend/build/client/assets/;
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
# =========================================
# Backend API Routes (Laravel)
# =========================================
root /var/www/backend/public;
index index.php index.html;
location ~ ^/(api|sanctum)/ {
try_files $uri $uri/ /index.php?$query_string;
location ~ \.php$ {
fastcgi_pass 127.0.0.1:9000;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
# Laravel-specific settings
fastcgi_param HTTP_PROXY "";
fastcgi_read_timeout 300;
fastcgi_buffer_size 128k;
fastcgi_buffers 256 16k;
fastcgi_busy_buffers_size 256k;
fastcgi_temp_file_write_size 256k;
}
}
# Handle API routes that don't map to files
location ~ ^/(api|sanctum) {
try_files $uri /index.php?$query_string;
}
# PHP file handler for backend
location ~ \.php$ {
fastcgi_pass 127.0.0.1:9000;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
}
# =========================================
# Frontend Routes (React Router via Node.js)
# =========================================
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
# Standard proxy headers
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket support (for React Router HMR if needed)
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# Timeouts
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
# =========================================
# Security & Performance
# =========================================
# Deny access to hidden files
location ~ /\. {
deny all;
access_log off;
log_not_found off;
}
# Disable logging for favicon and robots.txt
location = /favicon.ico {
access_log off;
log_not_found off;
}
location = /robots.txt {
access_log off;
log_not_found off;
}
# Cache static assets
location ~* \.(jpg|jpeg|png|gif|ico|css|js|svg|woff|woff2|ttf|eot)$ {
expires 30d;
add_header Cache-Control "public, immutable";
}
}

View file

@ -0,0 +1,52 @@
[supervisord]
nodaemon=true
user=root
logfile=/var/log/supervisor/supervisord.log
pidfile=/var/run/supervisord.pid
loglevel=info
[program:mysql]
command=/usr/sbin/mysqld --user=mysql --console
autostart=true
autorestart=true
stdout_logfile=/var/log/supervisor/mysql.log
stderr_logfile=/var/log/supervisor/mysql_error.log
priority=1
[program:php-fpm]
command=/usr/sbin/php-fpm8.2 -F
autostart=true
autorestart=true
stdout_logfile=/var/log/supervisor/php-fpm.log
stderr_logfile=/var/log/supervisor/php-fpm_error.log
priority=10
[program:nginx]
command=/usr/sbin/nginx -g "daemon off;"
autostart=true
autorestart=true
stdout_logfile=/var/log/supervisor/nginx.log
stderr_logfile=/var/log/supervisor/nginx_error.log
priority=20
[program:frontend]
command=/usr/bin/node /var/www/frontend/node_modules/@react-router/serve/dist/cli.js /var/www/frontend/build/server/index.js
directory=/var/www/frontend
autostart=true
autorestart=true
stdout_logfile=/var/log/supervisor/frontend.log
stderr_logfile=/var/log/supervisor/frontend_error.log
environment=PORT=3000,NODE_ENV=production
priority=30
user=www-data
[program:queue-worker]
command=/usr/bin/php /var/www/backend/artisan queue:work --sleep=3 --tries=3 --max-time=3600
directory=/var/www/backend
autostart=true
autorestart=true
stdout_logfile=/var/log/supervisor/queue-worker.log
stderr_logfile=/var/log/supervisor/queue-worker_error.log
priority=40
user=www-data
numprocs=1

View file

@ -1,62 +0,0 @@
# 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

View file

@ -1,24 +0,0 @@
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

View file

@ -1,66 +0,0 @@
# 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}"

25
.gitignore vendored
View file

@ -1,26 +1 @@
/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

View file

@ -1,8 +0,0 @@
{
"hash": "9e76e24f",
"configHash": "16a1459d",
"lockfileHash": "e3b0c442",
"browserHash": "8e8bb46e",
"optimized": {},
"chunks": {}
}

View file

@ -1,3 +0,0 @@
{
"type": "module"
}

View file

@ -1,123 +0,0 @@
# Production Dockerfile with FrankenPHP
FROM dunglas/frankenphp:latest-php8.3-alpine
# Install system dependencies
RUN apk add --no-cache \
nodejs \
npm \
git \
mysql-client
# Install PHP extensions
RUN install-php-extensions \
pdo_mysql \
opcache \
zip \
gd \
intl \
bcmath
# 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"]

View file

@ -1,128 +0,0 @@
# Development Dockerfile with FrankenPHP
FROM dunglas/frankenphp:latest-php8.3-alpine
# Install system dependencies + development tools
RUN apk add --no-cache \
nodejs \
npm \
git \
mysql-client \
vim \
bash \
nano
# Install PHP extensions including xdebug for development
RUN install-php-extensions \
pdo_mysql \
opcache \
zip \
gd \
intl \
bcmath \
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
View file

@ -1,123 +0,0 @@
# 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
View file

@ -1,295 +0,0 @@
# 🍽️ 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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

71
backend/.env.example Normal file
View file

@ -0,0 +1,71 @@
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 Normal file
View file

@ -0,0 +1,25 @@
/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

35
backend/Dockerfile Normal file
View file

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

14
backend/README.md Normal file
View file

@ -0,0 +1,14 @@
# 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
```

View file

@ -6,13 +6,11 @@
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
class RequireSaasMode class ForceJsonResponse
{ {
public function handle(Request $request, Closure $next): Response public function handle(Request $request, Closure $next): Response
{ {
if (! is_mode_saas()) { $request->headers->set('Accept', 'application/json');
abort(404);
}
return $next($request); return $next($request);
} }

View file

@ -6,17 +6,15 @@
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 Billable, HasApiTokens, HasFactory, Notifiable; use HasApiTokens, HasFactory, Notifiable;
protected $fillable = [ protected $fillable = [
'name', 'email', 'password', 'name', 'email', 'password',
@ -26,10 +24,6 @@ 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);

View file

@ -27,7 +27,6 @@
* @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
{ {
@ -57,6 +56,6 @@ public function scheduledUserDishes(): HasMany
public function hasAllUsersScheduled(): bool public function hasAllUsersScheduled(): bool
{ {
return $this->scheduledUserDishes->count() === User::where('planner_id', $this->planner_id)->count(); return $this->scheduledUserDishes->count() === User::all()->count();
} }
} }

View file

@ -12,25 +12,17 @@
* @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 = [ protected $fillable = ['schedule_id', 'user_id', 'user_dish_id', 'is_skipped'];
'schedule_id',
'user_id',
'user_dish_id',
'is_skipped'
];
protected $casts = [ protected $casts = [
'is_skipped' => 'boolean', 'is_skipped' => 'boolean',

View file

@ -9,7 +9,8 @@
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\Database\Eloquent\Model; use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
/** /**
* @property int $id * @property int $id
@ -19,17 +20,22 @@
* @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 Model class User extends Authenticatable
{ {
/** @use HasFactory<UserFactory> */ /** @use HasFactory<UserFactory> */
use HasFactory; use HasFactory, Notifiable;
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
@ -37,6 +43,19 @@ 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');

View file

@ -4,12 +4,10 @@
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;
@ -47,8 +45,6 @@ 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);

61
backend/bootstrap/app.php Normal file
View file

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

0
bootstrap/cache/.gitignore → backend/bootstrap/cache/.gitignore vendored Executable file → Normal file
View file

View file

@ -10,15 +10,12 @@
"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",
@ -27,9 +24,6 @@
"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/",
@ -61,17 +55,6 @@
"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": {

View file

@ -13,7 +13,7 @@
| |
*/ */
'name' => env('APP_NAME', 'Dish Planner'), 'name' => env('APP_NAME', 'Laravel'),
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
@ -28,18 +28,6 @@
'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

View file

@ -35,14 +35,4 @@
], ],
], ],
'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