Compare commits

..

No commits in common. "main" and "v2.0" have entirely different histories.
main ... v2.0

41 changed files with 445 additions and 2791 deletions

View file

@ -3,9 +3,10 @@
.gitignore .gitignore
.gitattributes .gitattributes
# Environment files # Environment files (will be SCP'd separately)
.env.backup .env
.env.example .env.*
!.env.example
# Node modules (will be installed in container) # Node modules (will be installed in container)
node_modules/ node_modules/

46
.env
View file

@ -1,9 +1,8 @@
APP_NAME="MAD Lawsuit v2.0" APP_NAME=Laravel
APP_ENV=production APP_ENV=local
APP_KEY=base64:dUzoSQvI3yjuaq3Sj6XMMpHw/1v/zPFRzwO6WzeADaQ= APP_KEY=base64:FabRZZvfMqwKRSPj2KYehBzYNrYI+hX247oLw58LRVs=
APP_DEBUG=false APP_DEBUG=true
APP_TIMEZONE=UTC APP_URL=http://localhost
APP_URL=https://mad-lawsuit.org
APP_LOCALE=en APP_LOCALE=en
APP_FALLBACK_LOCALE=en APP_FALLBACK_LOCALE=en
@ -12,21 +11,21 @@ APP_FAKER_LOCALE=en_US
APP_MAINTENANCE_DRIVER=file APP_MAINTENANCE_DRIVER=file
# APP_MAINTENANCE_STORE=database # APP_MAINTENANCE_STORE=database
PHP_CLI_SERVER_WORKERS=4 # PHP_CLI_SERVER_WORKERS=4
BCRYPT_ROUNDS=12 BCRYPT_ROUNDS=12
LOG_CHANNEL=stack LOG_CHANNEL=stack
LOG_STACK=single LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=error LOG_LEVEL=debug
DB_CONNECTION=pgsql DB_CONNECTION=sqlite
DB_HOST=postgres # DB_HOST=127.0.0.1
DB_PORT=5432 # DB_PORT=3306
DB_DATABASE=mad_lawsuit_v2 # DB_DATABASE=laravel
DB_USERNAME=mad_user # DB_USERNAME=root
DB_PASSWORD=mK9#vL2$pQ7@wN4&xR8!zT6^yU3*bH5 # DB_PASSWORD=
SESSION_DRIVER=database SESSION_DRIVER=database
SESSION_LIFETIME=120 SESSION_LIFETIME=120
@ -39,7 +38,7 @@ FILESYSTEM_DISK=local
QUEUE_CONNECTION=database QUEUE_CONNECTION=database
CACHE_STORE=database CACHE_STORE=database
CACHE_PREFIX= # CACHE_PREFIX=
MEMCACHED_HOST=127.0.0.1 MEMCACHED_HOST=127.0.0.1
@ -48,14 +47,14 @@ REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null REDIS_PASSWORD=null
REDIS_PORT=6379 REDIS_PORT=6379
MAIL_MAILER=smtp MAIL_MAILER=log
MAIL_HOST=smtp.gmail.com MAIL_SCHEME=null
MAIL_PORT=587 MAIL_HOST=127.0.0.1
MAIL_USERNAME=system@deafgain.org MAIL_PORT=2525
MAIL_PASSWORD=idjcxxwzvloorfuk MAIL_USERNAME=null
MAIL_ENCRYPTION=tls MAIL_PASSWORD=null
MAIL_FROM_ADDRESS="system@deafgain.org" MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="MAD Lawsuit Court Docket" MAIL_FROM_NAME="${APP_NAME}"
AWS_ACCESS_KEY_ID= AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY= AWS_SECRET_ACCESS_KEY=
@ -64,4 +63,3 @@ AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}" VITE_APP_NAME="${APP_NAME}"
ASSET_URL=https://mad-lawsuit.org

2
.gitignore vendored
View file

@ -1,7 +1,9 @@
# Laravel & PHP # Laravel & PHP
*.log *.log
.DS_Store .DS_Store
.env
.env.backup .env.backup
.env.production
.phpactor.json .phpactor.json
.phpunit.result.cache .phpunit.result.cache
/.fleet /.fleet

View file

@ -1,141 +0,0 @@
{
admin 0.0.0.0:2019
email chris@sigd.net
}
# Internal Services
gitea.sigd.net {
reverse_proxy 10.4.0.156:3000
}
statistics.deafgain.org {
reverse_proxy matomo:80
}
archives.sigd.net {
reverse_proxy 10.4.0.159:8001
}
upload.sigd.net {
handle /api/* {
uri strip_prefix /api
reverse_proxy 10.4.0.159:3334
}
handle {
reverse_proxy 10.4.0.159:4174
}
}
preview.sigd.net {
reverse_proxy 10.4.0.83:3000
}
droppy.sigd.net {
reverse_proxy 10.4.0.159:6969
}
requests.sigd.net {
reverse_proxy 10.4.0.157:5055
}
documents.deafgain.org {
reverse_proxy 10.4.0.156:8001
}
documents.sigd.net {
reverse_proxy 10.4.0.159:8001
}
doculens.deafgain.org {
reverse_proxy 10.4.0.156:5015
}
firewall.sigd.net {
reverse_proxy 10.4.0.1:9080
}
# Public Websites (on this VM - 10.4.0.205)
# Deaf Summit Website
deafsummit.org {
# Route API requests to backend
handle /api/* {
reverse_proxy localhost:808
}
# Route everything else to frontend
handle {
reverse_proxy localhost:807
}
}
www.deafsummit.org {
redir https://deafsummit.org{uri} permanent
}
# DeafMissoula.org
www.deafmissoula.org {
redir https://deafmissoula.org{uri} permanent
}
deafmissoula.org {
reverse_proxy 172.18.0.7:801
}
# ChrisHaulmark.com
www.chrishaulmark.com {
redir https://chrishaulmark.com{uri} permanent
}
chrishaulmark.com {
reverse_proxy chrishaulmark-website-web-1:803
}
# DeafGain.org
www.deafgain.org {
redir https://deafgain.org{uri} permanent
}
deafgain.org {
reverse_proxy 172.18.0.4:804
}
# FinLion domains
finlion.com {
reverse_proxy 172.18.0.8:805
}
www.finlion.com {
redir https://finlion.com{uri} permanent
}
finlion.net, www.finlion.net {
redir https://finlion.com{uri} permanent
}
finlion.org, www.finlion.org {
redir https://finlion.com{uri} permanent
}
# MAD Lawsuit
www.mad-lawsuit.org {
redir https://mad-lawsuit.org{uri} permanent
}
mad-lawsuit.org {
reverse_proxy 172.18.0.6:806
}
files.mad-lawsuit.org {
reverse_proxy 172.18.0.5:901
}
# MAD Lawsuit V2 (Preview)
v2.mad-lawsuit.org {
reverse_proxy localhost:8080
}
www.v2.mad-lawsuit.org {
redir https://v2.mad-lawsuit.org{uri} permanent
}

View file

@ -1,220 +0,0 @@
# Email Notification System - Deployment & Testing Guide
## Overview
Email notification system has been implemented for the MAD Lawsuit v2.0 website. When a new docket entry is created via the admin dashboard, an email notification is automatically sent to subscribers.
## Testing Mode Configuration
**IMPORTANT**: The system is currently in TESTING MODE and will ONLY send emails to `chris@deafgain.org`.
### Testing Mode Details
- **Test Recipient**: chris@deafgain.org
- **Purpose**: Verify email functionality without spamming all 28 production subscribers
- **Location**: `app/Http/Controllers/Admin/DocketEntryController.php` (line ~155)
### Production Code (Currently Commented Out)
```php
// Production code (commented out for testing):
// $subscribers = Subscription::where('is_active', true)->get();
// foreach ($subscribers as $subscriber) {
// Mail::to($subscriber->email)->send(
// new NewDocketEntryNotification($entry, $subscriber->unsubscribe_token)
// );
// }
```
## Deployment Steps
### 1. SSH to Production Server
```bash
ssh chaulmark@10.4.0.205
```
### 2. Navigate to Project Directory
```bash
cd ~/websites/v2.mad-lawsuit.org
```
### 3. Pull Latest Code
```bash
git pull origin main
```
### 4. Rebuild Docker Container
```bash
docker compose down
docker compose up -d --build
```
### 5. Verify Container Status
```bash
docker compose ps
docker compose logs -f app
```
### 6. Clear Laravel Caches
```bash
docker exec mad-lawsuit-v2 php artisan config:clear
docker exec mad-lawsuit-v2 php artisan cache:clear
docker exec mad-lawsuit-v2 php artisan view:clear
```
## Testing the Email System
### Test Procedure
1. **Login to Admin Dashboard**
- URL: https://v2.mad-lawsuit.org/admin/login
- Username: eliza
- Password: AXEoZWk2AMAD0naw
2. **Create a Test Docket Entry**
- Navigate to "Docket Entries" → "Create New Entry"
- Fill in the form:
- Date: Today's date
- Title: "TEST - Email Notification System"
- Summary: "This is a test entry to verify email notifications are working correctly."
- Notes: (optional)
- Click "Create Docket Entry"
3. **Verify Email Delivery**
- Check chris@deafgain.org inbox
- Look for email with subject: "New Court Filing: TEST - Email Notification System"
- Verify email formatting and content
4. **Check Logs**
```bash
docker exec mad-lawsuit-v2 tail -f storage/logs/laravel.log
```
- Look for log entries:
- "Sending email notification for new docket entry"
- "Email notification sent successfully"
### Expected Email Content
- **Subject**: New Court Filing: [Entry Title]
- **From**: MAD Lawsuit Court Docket <system@deafgain.org>
- **To**: chris@deafgain.org
- **Content**:
- Professional header with case name
- Filing date, title, and summary
- "View Full Docket" button linking to https://v2.mad-lawsuit.org
- Unsubscribe link (placeholder during testing)
## SMTP Configuration
### Gmail SMTP Settings (Already Configured in .env)
```env
MAIL_MAILER=smtp
MAIL_HOST=smtp.gmail.com
MAIL_PORT=587
MAIL_USERNAME=system@deafgain.org
MAIL_PASSWORD=ojvlysraxwjriwzy
MAIL_ENCRYPTION=tls
MAIL_FROM_ADDRESS="system@deafgain.org"
MAIL_FROM_NAME="MAD Lawsuit Court Docket"
```
### Gmail App Password
- **Email**: system@deafgain.org
- **App Password**: ojvlysraxwjriwzy (16-character Google App Password)
- **Note**: This is NOT the regular Gmail password
## Switching to Production Mode
### When Ready to Send to All Subscribers
1. **Edit DocketEntryController.php**
```bash
# On production server
cd ~/websites/v2.mad-lawsuit.org
nano app/Http/Controllers/Admin/DocketEntryController.php
```
2. **Update sendEmailNotifications Method** (around line 155)
- Comment out the testing code:
```php
// TESTING MODE (comment out when ready for production)
// $testEmail = 'chris@deafgain.org';
// $testToken = 'test-unsubscribe-token';
// Mail::to($testEmail)->send(new NewDocketEntryNotification($entry, $testToken));
```
- Uncomment the production code:
```php
// Production code:
$subscribers = Subscription::where('is_active', true)->get();
foreach ($subscribers as $subscriber) {
Mail::to($subscriber->email)->send(
new NewDocketEntryNotification($entry, $subscriber->unsubscribe_token)
);
}
```
3. **Commit and Deploy**
```bash
git add app/Http/Controllers/Admin/DocketEntryController.php
git commit -m "Switch email notifications to production mode - send to all subscribers"
git push origin main
# On production server
git pull origin main
docker compose down
docker compose up -d --build
docker exec mad-lawsuit-v2 php artisan config:clear
```
## Troubleshooting
### Email Not Received
1. **Check Laravel Logs**
```bash
docker exec mad-lawsuit-v2 tail -100 storage/logs/laravel.log
```
2. **Check Gmail SMTP Connection**
```bash
docker exec mad-lawsuit-v2 php artisan tinker
# In tinker:
Mail::raw('Test email', function($msg) {
$msg->to('chris@deafgain.org')->subject('Test');
});
```
3. **Verify .env Configuration**
```bash
docker exec mad-lawsuit-v2 php artisan config:show mail
```
### Common Issues
- **Authentication Failed**: Verify Gmail App Password is correct
- **Connection Timeout**: Check firewall rules for port 587
- **SSL/TLS Errors**: Ensure MAIL_ENCRYPTION=tls (not ssl)
## Files Modified
### New Files Created
1. `app/Mail/NewDocketEntryNotification.php` - Mailable class
2. `resources/views/emails/new-docket-entry.blade.php` - HTML email template
### Modified Files
1. `app/Http/Controllers/Admin/DocketEntryController.php` - Added email sending logic
2. `.env` - Updated SMTP configuration
## Current Status
- ✅ Email notification system implemented
- ✅ Professional HTML email template created
- ✅ SMTP configured with Gmail
- ✅ Testing mode active (chris@deafgain.org only)
- ✅ Code committed and pushed to repository
- ⏳ Awaiting deployment to production server
- ⏳ Awaiting email functionality testing
## Next Steps
1. Deploy to production server (10.4.0.205)
2. Test email delivery to chris@deafgain.org
3. Verify email formatting and content
4. Once confirmed working, switch to production mode
5. Update documentation with test results
---
**Last Updated**: December 18, 2025 at 10:21 AM MST
**Status**: Ready for deployment and testing
**Git Commit**: 9687b9b9

View file

@ -1,224 +0,0 @@
# DNS Switchover Instructions - MAD Lawsuit v2.0
## Overview
This document provides step-by-step instructions to switch `mad-lawsuit.org` from v1.0 (Next.js) to v2.0 (Laravel).
## Pre-Switchover Checklist
- ✅ v2.0 fully tested at https://v2.mad-lawsuit.org
- ✅ All data migrated (63 entries, 63 documents, 28 subscribers)
- ✅ Admin dashboard functional
- ✅ PDF uploads working
- ✅ iOS/iPad PDF viewing fixed
- ✅ .env updated with mad-lawsuit.org URLs
- ✅ docker-compose.yml updated to use port 806
- ✅ Caddyfile updated on server
## Deployment Steps
### 1. SSH to Production Server
```bash
ssh chaulmark@10.4.0.205
```
### 2. Stop v1.0 Containers (Old mad-lawsuit.org)
```bash
cd ~/websites/mad-lawsuit.org
docker compose down
```
### 3. Pull Latest v2.0 Code
```bash
cd ~/websites/v2.mad-lawsuit.org
git pull origin main
```
### 4. Rebuild v2.0 Container with New Port
```bash
docker compose down
docker compose up -d --build
```
### 5. Verify Container is Running on Port 806
```bash
docker compose ps
# Should show mad-lawsuit-v2 running on 0.0.0.0:806->80/tcp
```
### 6. Clear Laravel Cache
```bash
docker exec mad-lawsuit-v2 php artisan config:clear
docker exec mad-lawsuit-v2 php artisan cache:clear
docker exec mad-lawsuit-v2 php artisan route:clear
```
### 7. Reload Caddy Configuration
```bash
docker exec caddy caddy reload --config /etc/caddy/Caddyfile
```
### 8. Test the Switchover
```bash
# Test from command line
curl -I https://mad-lawsuit.org
# Should return HTTP/2 200
# Should show Laravel headers
```
### 9. Browser Testing
Open https://mad-lawsuit.org in multiple browsers:
- ✅ Desktop Chrome/Firefox/Safari
- ✅ Mobile Chrome/Safari
- ✅ iPad Safari (test PDF viewing)
### 10. Verify All Functionality
- [ ] Home page loads correctly
- [ ] Docket entries display properly
- [ ] PDF documents open correctly
- [ ] iPad PDF viewing works (opens in new tab)
- [ ] Admin login works: https://mad-lawsuit.org/admin/login
- [ ] Admin dashboard accessible
- [ ] Can create/edit/delete docket entries
- [ ] Can upload PDFs
- [ ] Email subscription works
## Configuration Details
### Port Mapping
- **v1.0 (Old)**: Port 806 (now stopped)
- **v2.0 (New)**: Port 806 (same port, different container)
### Caddyfile Configuration
```caddyfile
# MAD Lawsuit V2 (Production)
www.mad-lawsuit.org {
redir https://mad-lawsuit.org{uri} permanent
}
mad-lawsuit.org {
reverse_proxy 172.18.0.6:806
}
# Old v2 subdomain redirects to main domain
v2.mad-lawsuit.org {
redir https://mad-lawsuit.org{uri} permanent
}
www.v2.mad-lawsuit.org {
redir https://mad-lawsuit.org{uri} permanent
}
```
### Docker Network
- **Network**: `caddy_network` (external)
- **Container IP**: 172.18.0.6
- **Container Port**: 80 (internal)
- **Host Port**: 806 (external)
## Rollback Plan (If Needed)
If issues arise, you can quickly rollback to v1.0:
### 1. Stop v2.0 Container
```bash
cd ~/websites/v2.mad-lawsuit.org
docker compose down
```
### 2. Restore Original Caddyfile
```bash
# Download backup Caddyfile (if you made one)
# Or manually edit to point back to v1.0
```
### 3. Start v1.0 Containers
```bash
cd ~/websites/mad-lawsuit.org
docker compose up -d
```
### 4. Reload Caddy
```bash
docker exec caddy caddy reload --config /etc/caddy/Caddyfile
```
## Post-Switchover Tasks
### Immediate (Within 24 hours)
- [ ] Monitor error logs: `docker logs mad-lawsuit-v2 -f`
- [ ] Check database connections
- [ ] Verify email notifications work
- [ ] Test all admin functions
### Within 1 Week
- [ ] Remove v1.0 containers permanently
- [ ] Clean up old v1.0 files (optional backup first)
- [ ] Update documentation
- [ ] Notify Eliza of successful switchover
### Optional Enhancements
- [ ] Set up automated backups
- [ ] Configure monitoring/alerts
- [ ] Implement email notifications for new entries
- [ ] Add Redis caching for performance
## Troubleshooting
### Issue: Site not loading
**Solution**: Check container status
```bash
docker compose ps
docker logs mad-lawsuit-v2 --tail 50
```
### Issue: 502 Bad Gateway
**Solution**: Verify Caddy can reach container
```bash
docker exec caddy caddy validate --config /etc/caddy/Caddyfile
docker network inspect caddy_network
```
### Issue: Database connection errors
**Solution**: Check PostgreSQL container
```bash
docker compose ps postgres
docker logs mad-lawsuit-v2-db --tail 50
```
### Issue: PDFs not loading
**Solution**: Check file permissions and storage
```bash
docker exec mad-lawsuit-v2 ls -la storage/app/public/documents
docker exec mad-lawsuit-v2 php artisan storage:link
```
## Important Notes
1. **SSL Certificates**: Caddy automatically handles SSL for mad-lawsuit.org (Let's Encrypt)
2. **Database**: PostgreSQL data persists in Docker volume `postgres_data`
3. **PDF Files**: Stored in `./storage/app/public/documents` (bind mount)
4. **No Downtime**: Switchover should be nearly instant (< 1 minute)
5. **DNS**: No DNS changes needed - same domain, different backend
## Success Criteria
✅ https://mad-lawsuit.org loads v2.0 application
✅ All 63 docket entries visible
✅ All 63 PDFs accessible
✅ Admin dashboard functional
✅ iPad PDF viewing works correctly
✅ No errors in logs
✅ SSL certificate valid
## Contact
If issues arise:
- Check logs: `docker logs mad-lawsuit-v2 -f`
- Review this document
- Rollback if necessary (see Rollback Plan above)
---
**Last Updated**: December 19, 2025
**Version**: 2.0
**Status**: Ready for deployment

View file

@ -18,13 +18,6 @@ RUN apk add --no-cache \
# Install PHP extensions # Install PHP extensions
RUN docker-php-ext-install pdo pdo_pgsql pgsql zip gd RUN docker-php-ext-install pdo pdo_pgsql pgsql zip gd
# Configure PHP for large file uploads
RUN echo "upload_max_filesize = 550M" >> /usr/local/etc/php/conf.d/uploads.ini && \
echo "post_max_size = 550M" >> /usr/local/etc/php/conf.d/uploads.ini && \
echo "max_execution_time = 300" >> /usr/local/etc/php/conf.d/uploads.ini && \
echo "max_input_time = 300" >> /usr/local/etc/php/conf.d/uploads.ini && \
echo "memory_limit = 512M" >> /usr/local/etc/php/conf.d/uploads.ini
# Install Composer # Install Composer
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
@ -32,16 +25,16 @@ COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
WORKDIR /var/www/html WORKDIR /var/www/html
# Copy composer files # Copy composer files
COPY composer.json ./ COPY composer.json composer.lock ./
# Install PHP dependencies (generate composer.lock if missing) # Install PHP dependencies
RUN composer install --no-dev --optimize-autoloader --no-scripts --no-interaction RUN composer install --no-dev --optimize-autoloader --no-scripts --no-interaction
# Copy package files # Copy package files
COPY package.json ./ COPY package.json package-lock.json* ./
# Install Node dependencies (generate package-lock.json if missing) # Install Node dependencies
RUN npm install --legacy-peer-deps RUN npm ci --legacy-peer-deps
# Copy application files # Copy application files
COPY . . COPY . .
@ -52,20 +45,9 @@ RUN npm run build
# Generate optimized autoload files # Generate optimized autoload files
RUN composer dump-autoload --optimize RUN composer dump-autoload --optimize
# Create log directory for supervisor # Set permissions
RUN mkdir -p /var/log/supervisor RUN chown -R www-data:www-data /var/www/html/storage /var/www/html/bootstrap/cache
RUN chmod -R 775 /var/www/html/storage /var/www/html/bootstrap/cache
# Create nginx temp directories and set permissions
RUN mkdir -p /var/lib/nginx/tmp/client_body \
/var/lib/nginx/tmp/proxy \
/var/lib/nginx/tmp/fastcgi \
/var/lib/nginx/tmp/uwsgi \
/var/lib/nginx/tmp/scgi
# Set permissions - fix parent directory first, then subdirectories
RUN chmod 755 /var/lib/nginx && \
chown -R www-data:www-data /var/www/html/storage /var/www/html/bootstrap/cache /var/lib/nginx/tmp && \
chmod -R 775 /var/www/html/storage /var/www/html/bootstrap/cache /var/lib/nginx/tmp
# Copy nginx configuration # Copy nginx configuration
COPY docker/nginx.conf /etc/nginx/nginx.conf COPY docker/nginx.conf /etc/nginx/nginx.conf

298
README.md
View file

@ -1,279 +1,59 @@
# MAD Lawsuit Court Docket Website <p align="center"><a href="https://laravel.com" target="_blank"><img src="https://raw.githubusercontent.com/laravel/art/master/logo-lockup/5%20SVG/2%20CMYK/1%20Full%20Color/laravel-logolockup-cmyk-red.svg" width="400" alt="Laravel Logo"></a></p>
A public-facing website for displaying court documents and filings for the lawsuit: **Elizabeth Kragh v. Montana Association of the Deaf**. <p align="center">
<a href="https://github.com/laravel/framework/actions"><img src="https://github.com/laravel/framework/workflows/tests/badge.svg" alt="Build Status"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/dt/laravel/framework" alt="Total Downloads"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/v/laravel/framework" alt="Latest Stable Version"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/l/laravel/framework" alt="License"></a>
</p>
**Live Site**: https://v2.mad-lawsuit.org ## About Laravel
**Admin Dashboard**: https://v2.mad-lawsuit.org/admin/login
## 📋 Project Overview Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as:
This website provides public access to court documents from an ongoing lawsuit in Montana state court. Since the Montana court system requires a licensed attorney electronic account for access, this platform ensures transparency by making all case filings, motions, exhibits, and court decisions publicly accessible. - [Simple, fast routing engine](https://laravel.com/docs/routing).
- [Powerful dependency injection container](https://laravel.com/docs/container).
- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage.
- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent).
- Database agnostic [schema migrations](https://laravel.com/docs/migrations).
- [Robust background job processing](https://laravel.com/docs/queues).
- [Real-time event broadcasting](https://laravel.com/docs/broadcasting).
### Purpose Laravel is accessible, powerful, and provides tools required for large, robust applications.
- **Public Transparency**: Makes court proceedings accessible to anyone interested ## Learning Laravel
- **Document Organization**: Centralized, chronological display of all case filings
- **Real-time Updates**: Email subscription system for case updates
- **Professional Presentation**: Clean interface with document summaries and metadata
### Target Audience Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework. You can also check out [Laravel Learn](https://laravel.com/learn), where you will be guided through building a modern Laravel application.
- General public following the case If you don't feel like reading, [Laracasts](https://laracasts.com) can help. Laracasts contains thousands of video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library.
- Legal community (attorneys, paralegals, law students)
- Disability rights advocacy groups
- Media and journalists
- Researchers studying the case
## 🛠️ Technology Stack ## Laravel Sponsors
### Backend We would like to extend our thanks to the following sponsors for funding Laravel development. If you are interested in becoming a sponsor, please visit the [Laravel Partners program](https://partners.laravel.com).
- **Framework**: Laravel 12.43.1
- **Language**: PHP 8.3.28
- **Database**: PostgreSQL 16
- **ORM**: Eloquent
- **Authentication**: Laravel Sanctum + Session
### Frontend ### Premium Partners
- **Framework**: Vue 3 with TypeScript
- **Routing**: Inertia.js (SSR-like experience)
- **Styling**: Tailwind CSS 3.x
- **Build Tool**: Vite 7.x
- **Components**: Vue Single File Components (SFC)
### Infrastructure - **[Vehikl](https://vehikl.com)**
- **Containerization**: Docker with Alpine Linux - **[Tighten Co.](https://tighten.co)**
- **Web Server**: nginx 1.28.0 - **[Kirschbaum Development Group](https://kirschbaumdevelopment.com)**
- **PHP**: PHP-FPM 8.3.28 - **[64 Robots](https://64robots.com)**
- **Reverse Proxy**: Caddy (with automatic SSL) - **[Curotec](https://www.curotec.com/services/technologies/laravel)**
- **Deployment**: Production server at 10.4.0.205 - **[DevSquad](https://devsquad.com/hire-laravel-developers)**
- **[Redberry](https://redberry.international/laravel-development)**
- **[Active Logic](https://activelogic.com)**
## ✨ Features ## Contributing
### Public Website Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions).
- **Court Docket Display**: Chronological list of all court filings
- **Document Viewer**:
- Desktop: Modal PDF viewer with download option
- Mobile: Opens PDFs in new tab (optimized for iOS Safari)
- **Email Subscriptions**: Users can subscribe for case updates
- **Responsive Design**: Optimized for desktop, tablet, and mobile
- **Case Information**: Status, last updated date, and case details
### Admin Dashboard ## Code of Conduct
- **Authentication**: Secure session-based login
- **Docket Entry Management**: Full CRUD operations
- Create new entries with date, title, and summary
- Edit existing entries
- Delete entries (cascades to associated documents)
- View entry details
- **Document Management**:
- Upload PDF documents (500MB max)
- UUID-based file naming for security
- Multiple documents per docket entry
- Delete documents
- **Subscriber Management**:
- View all subscribers (paginated, 50 per page)
- Deactivate subscribers
- Statistics dashboard (total, active, inactive)
- **Dashboard Statistics**: Quick overview of entries, documents, and subscribers
## 🚀 Getting Started In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct).
### Prerequisites ## Security Vulnerabilities
- PHP 8.3+
- Composer
- Node.js 18+ and npm
- PostgreSQL 16+
- Docker (for production deployment)
### Local Development Setup If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed.
1. **Clone the repository** ## License
```bash
git clone https://gitea.sigd.net/chaulmark/mad-lawsuit.git
cd mad-lawsuit
```
2. **Install PHP dependencies** The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).
```bash
composer install
```
3. **Install JavaScript dependencies**
```bash
npm install
```
4. **Configure environment**
```bash
cp .env.example .env
php artisan key:generate
```
5. **Configure database**
Edit `.env` with your PostgreSQL credentials:
```env
DB_CONNECTION=pgsql
DB_HOST=127.0.0.1
DB_PORT=5432
DB_DATABASE=mad_lawsuit
DB_USERNAME=your_username
DB_PASSWORD=your_password
```
6. **Run migrations**
```bash
php artisan migrate
```
7. **Seed admin user**
```bash
php artisan db:seed --class=AdminSeeder
```
8. **Create storage symlink**
```bash
php artisan storage:link
```
9. **Start development servers**
```bash
# Terminal 1: Laravel development server
php artisan serve
# Terminal 2: Vite development server
npm run dev
```
10. **Access the application**
- Public site: http://localhost:8000
- Admin dashboard: http://localhost:8000/admin/login
- Default credentials: See `database/seeders/AdminSeeder.php`
### Production Deployment
See [DEPLOYMENT.md](DEPLOYMENT.md) for detailed production deployment instructions using Docker.
## 📁 Project Structure
```
├── app/
│ ├── Http/
│ │ ├── Controllers/
│ │ │ ├── Admin/ # Admin dashboard controllers
│ │ │ ├── HomeController.php
│ │ │ └── SubscriptionController.php
│ │ └── Middleware/
│ │ └── AdminAuth.php # Admin authentication middleware
│ └── Models/
│ ├── AdminUser.php
│ ├── DocketEntry.php
│ ├── Document.php
│ └── Subscription.php
├── database/
│ ├── migrations/ # Database schema
│ └── seeders/
│ ├── AdminSeeder.php
│ └── V1DataMigrationSeeder.php
├── resources/
│ ├── js/
│ │ ├── Components/
│ │ │ └── PDFViewer.vue # PDF modal viewer
│ │ ├── Pages/
│ │ │ ├── Home.vue # Public homepage
│ │ │ └── Admin/ # Admin dashboard pages
│ │ └── app.ts
│ └── css/
│ └── app.css
├── routes/
│ └── web.php # Application routes
├── docker/ # Docker configuration
├── cline_docs/ # Project documentation
└── storage/
└── app/
└── public/
└── documents/ # Uploaded PDF files
```
## 🔐 Security
- **Admin Authentication**: Session-based authentication separate from Laravel Breeze
- **File Security**: UUID-based file naming prevents direct access guessing
- **CSRF Protection**: Laravel's built-in CSRF protection on all forms
- **SQL Injection**: Eloquent ORM prevents SQL injection
- **XSS Protection**: Vue.js automatic escaping
- **HTTPS**: Automatic SSL via Caddy in production
## 📊 Database Schema
### Tables
- **docket_entries**: Court filings with date, title, summary, notes
- **documents**: PDF files linked to docket entries
- **subscriptions**: Email subscribers with active/inactive status
- **admin_users**: Admin authentication
### Relationships
- One docket entry has many documents
- Documents cascade delete when entry is deleted
- Subscriptions are soft-deleted (is_active flag)
## 🧪 Testing
```bash
# Run PHP tests
php artisan test
# Run with coverage
php artisan test --coverage
```
## 📝 API Endpoints
### Public Routes
- `GET /` - Homepage with docket entries
- `POST /api/subscribe` - Email subscription
- `GET /api/documents/{id}/download` - Download PDF
### Admin Routes (requires authentication)
- `GET /admin/login` - Admin login page
- `POST /admin/login` - Process login
- `POST /admin/logout` - Logout
- `GET /admin/dashboard` - Admin dashboard
- Resource routes for:
- `/admin/docket-entries` - Docket entry management
- `/admin/documents` - Document management
- `/admin/subscribers` - Subscriber management
## 🐛 Known Issues & Solutions
### Mobile PDF Viewing
- **Issue**: iOS Safari renders PDFs in iframes as single-page images
- **Solution**: On screens < 768px, PDFs open in a new tab instead of modal viewer
### Admin Subscribers Page
- **Fixed**: Changed pagination from `->through()` to `->items()` to properly pass array to Vue
## 🤝 Contributing
This is a private project for a specific lawsuit. For questions or issues, contact the repository owner.
## 📄 License
Proprietary - All rights reserved.
## 👥 Credits
- **Development**: Built with Laravel, Vue.js, and Inertia.js
- **Design**: DeafGain LLC (http://deafgain.org)
- **Hosting**: Self-hosted on dedicated infrastructure
## 📞 Support
For technical issues or questions:
- Repository: https://gitea.sigd.net/chaulmark/mad-lawsuit
- Production URL: https://v2.mad-lawsuit.org
---
**Version**: 2.0
**Last Updated**: December 2025
**Status**: Production Ready ✅

View file

@ -1,118 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Models\Document;
use App\Models\DocumentAccess;
use Carbon\Carbon;
use Illuminate\Console\Command;
class ImportDocumentAccessLog extends Command
{
protected $signature = 'documents:import-access-log
{file : Path to an nginx access log in NCSA combined format}
{--source=nginx-backfill : Value stored in the source column}
{--dry-run : Parse and report without writing anything}';
protected $description = 'Backfill document_accesses from an archived nginx access log';
/**
* Matches e.g.
* 172.18.0.1 - - [23/May/2026:18:50:19 +0000] "GET /api/documents/83/download HTTP/1.1" 200 12345 "-" "Mozilla/5.0 ..."
*
* Only status 200 counts as an access: 499 means the client aborted the
* transfer, and 4xx/5xx never delivered the file.
*/
private const LINE = '#^\S+ \S+ \S+ \[([^\]]+)\] "GET /api/documents/(\d+)/download [^"]*" 200 \S+ "[^"]*" "([^"]*)"#';
public function handle(): int
{
$file = $this->argument('file');
if (! is_readable($file)) {
$this->error("Cannot read file: {$file}");
return self::FAILURE;
}
$source = $this->option('source');
$dryRun = $this->option('dry-run');
// Only import accesses for documents that still exist; the FK would
// reject the rest and a deleted document has no dashboard row anyway.
$knownIds = Document::pluck('id')->flip();
$handle = fopen($file, 'r');
$rows = [];
$parsed = $skippedUnknown = $unmatched = 0;
while (($line = fgets($handle)) !== false) {
if (! preg_match(self::LINE, $line, $m)) {
// Most lines are ordinary page views, not downloads.
if (str_contains($line, '/download')) {
$unmatched++;
}
continue;
}
[, $timestamp, $documentId, $userAgent] = $m;
if (! $knownIds->has((int) $documentId)) {
$skippedUnknown++;
continue;
}
$rows[] = [
'document_id' => (int) $documentId,
'accessed_at' => Carbon::createFromFormat('d/M/Y:H:i:s O', $timestamp)->utc(),
'ip_address' => null, // never recorded by the old log format
'user_agent' => $userAgent,
'source' => $source,
'created_at' => now(),
'updated_at' => now(),
];
$parsed++;
}
fclose($handle);
$this->info("Parsed {$parsed} successful downloads from {$file}");
if ($skippedUnknown > 0) {
$this->warn("Skipped {$skippedUnknown} referring to documents that no longer exist");
}
if ($unmatched > 0) {
$this->warn("Skipped {$unmatched} download lines that were not status 200 (aborted or failed)");
}
if ($dryRun) {
$this->comment('Dry run: nothing written.');
return self::SUCCESS;
}
// Idempotency: a re-run must not double-count. Every row from a given
// log carries the same source, so clearing that source first makes the
// import safely repeatable.
$existing = DocumentAccess::where('source', $source)->count();
if ($existing > 0) {
if (! $this->confirm("{$existing} rows with source '{$source}' already exist. Replace them?", true)) {
$this->comment('Aborted; nothing written.');
return self::SUCCESS;
}
DocumentAccess::where('source', $source)->delete();
}
foreach (array_chunk($rows, 500) as $chunk) {
DocumentAccess::insert($chunk);
}
$this->info('Imported '.count($rows)." rows with source '{$source}'.");
return self::SUCCESS;
}
}

View file

@ -5,10 +5,7 @@ namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\DocketEntry; use App\Models\DocketEntry;
use App\Models\Document; use App\Models\Document;
use App\Models\DocumentAccess;
use App\Models\Subscription; use App\Models\Subscription;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Inertia\Inertia; use Inertia\Inertia;
use Inertia\Response; use Inertia\Response;
@ -38,41 +35,6 @@ class DashboardController extends Controller
}), }),
]; ];
$stats += $this->documentAccessSummary();
return Inertia::render('Admin/Dashboard', $stats); return Inertia::render('Admin/Dashboard', $stats);
} }
/**
* Headline document-access numbers for the last 60 days, plus the most
* requested documents. Crawlers are excluded so the figures reflect people.
*/
private function documentAccessSummary(): array
{
$since = Carbon::now()->subDays(60)->startOfDay();
return [
'downloads_60d' => DocumentAccess::excludingBots()
->where('accessed_at', '>=', $since)
->count(),
'top_documents_60d' => DocumentAccess::query()
->excludingBots()
->join('documents', 'documents.id', '=', 'document_accesses.document_id')
->where('document_accesses.accessed_at', '>=', $since)
->groupBy('documents.id', 'documents.title')
->orderByDesc('downloads')
->take(5)
->get([
'documents.id',
'documents.title',
DB::raw('count(*) as downloads'),
])
->map(fn ($row) => [
'id' => $row->id,
'title' => $row->title,
'downloads' => (int) $row->downloads,
]),
];
}
} }

View file

@ -4,11 +4,7 @@ namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\DocketEntry; use App\Models\DocketEntry;
use App\Models\Subscription;
use App\Mail\NewDocketEntryNotification;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Log;
use Inertia\Inertia; use Inertia\Inertia;
use Inertia\Response; use Inertia\Response;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
@ -20,18 +16,24 @@ class DocketEntryController extends Controller
*/ */
public function index(): Response public function index(): Response
{ {
$entries = DocketEntry::with('documents') $paginated = DocketEntry::with('documents')
->orderBy('date', 'desc') ->orderBy('date', 'desc')
->get(); ->paginate(20);
return Inertia::render('Admin/DocketEntries/Index', [ return Inertia::render('Admin/DocketEntries/Index', [
'entries' => $entries->map(fn ($entry) => [ 'entries' => $paginated->map(fn ($entry) => [
'id' => $entry->id, 'id' => $entry->id,
'date' => $entry->date->format('m/d/Y'), 'date' => $entry->date->format('m/d/Y'),
'title' => $entry->title, 'title' => $entry->title,
'summary' => $entry->summary, 'summary' => $entry->summary,
'documents_count' => $entry->documents->count(), 'documents_count' => $entry->documents->count(),
])->toArray(), ])->toArray(),
'pagination' => [
'current_page' => $paginated->currentPage(),
'last_page' => $paginated->lastPage(),
'per_page' => $paginated->perPage(),
'total' => $paginated->total(),
],
]); ]);
} }
@ -131,76 +133,4 @@ class DocketEntryController extends Controller
return redirect()->route('admin.docket-entries.index') return redirect()->route('admin.docket-entries.index')
->with('success', 'Docket entry deleted successfully.'); ->with('success', 'Docket entry deleted successfully.');
} }
/**
* Send email notification to subscribers for this docket entry.
*/
public function sendNotification(DocketEntry $docketEntry): RedirectResponse
{
$this->sendEmailNotifications($docketEntry);
return redirect()->route('admin.docket-entries.show', $docketEntry->id)
->with('success', 'Email notifications sent successfully to subscribers.');
}
/**
* Send email notifications to all active subscribers.
*/
private function sendEmailNotifications(DocketEntry $entry): void
{
try {
// Get all active subscribers
$subscribers = Subscription::where('is_active', true)->get();
if ($subscribers->isEmpty()) {
Log::info('No active subscribers to notify', [
'entry_id' => $entry->id,
]);
return;
}
Log::info('Sending email notifications for new docket entry', [
'entry_id' => $entry->id,
'entry_title' => $entry->title,
'subscriber_count' => $subscribers->count(),
]);
$successCount = 0;
$failureCount = 0;
foreach ($subscribers as $subscriber) {
try {
Mail::to($subscriber->email)->send(
new NewDocketEntryNotification($entry, $subscriber->unsubscribe_token)
);
$successCount++;
Log::debug('Email sent to subscriber', [
'entry_id' => $entry->id,
'subscriber_email' => $subscriber->email,
]);
} catch (\Exception $e) {
$failureCount++;
Log::error('Failed to send email to subscriber', [
'entry_id' => $entry->id,
'subscriber_email' => $subscriber->email,
'error' => $e->getMessage(),
]);
}
}
Log::info('Email notification batch complete', [
'entry_id' => $entry->id,
'success_count' => $successCount,
'failure_count' => $failureCount,
'total_subscribers' => $subscribers->count(),
]);
} catch (\Exception $e) {
Log::error('Failed to send email notifications', [
'entry_id' => $entry->id,
'error' => $e->getMessage(),
]);
// Don't throw exception - we don't want email failures to break the entry creation
}
}
} }

View file

@ -1,199 +0,0 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\DocumentAccess;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Inertia\Inertia;
use Inertia\Response;
use Symfony\Component\HttpFoundation\StreamedResponse;
class DocumentAccessController extends Controller
{
/**
* The date real client IPs started being recorded. Before this, the nginx
* log stored only the docker gateway address, so unique-visitor counts are
* not available for earlier periods and must not be implied.
*/
private const IP_DATA_STARTS = '2026-07-30';
/**
* Per-document access counts over a selectable date range.
*/
public function index(Request $request): Response
{
$validated = $request->validate([
'from' => ['nullable', 'date'],
'to' => ['nullable', 'date'],
'include_bots' => ['nullable', 'boolean'],
]);
$from = isset($validated['from'])
? Carbon::parse($validated['from'])->startOfDay()
: Carbon::now()->subDays(60)->startOfDay();
$to = isset($validated['to'])
? Carbon::parse($validated['to'])->endOfDay()
: Carbon::now()->endOfDay();
$includeBots = (bool) ($validated['include_bots'] ?? false);
$documents = $this->documentCounts($from, $to, $includeBots);
return Inertia::render('Admin/DocumentAccess', [
'documents' => $documents,
'daily' => $this->dailyCounts($from, $to, $includeBots),
'filters' => [
'from' => $from->toDateString(),
'to' => $to->toDateString(),
'include_bots' => $includeBots,
],
'totals' => [
'downloads' => $documents->sum('downloads'),
'documents_touched' => $documents->count(),
// Distinct across the whole range, not the sum of the per-document
// column: one person pulling five documents is one visitor.
'unique_visitors' => $this->uniqueVisitors($from, $to, $includeBots),
],
// Drives the caveat shown in the UI.
'ip_data_starts' => self::IP_DATA_STARTS,
'range_predates_ip_data' => $from->lt(Carbon::parse(self::IP_DATA_STARTS)),
]);
}
/**
* Export the current view as CSV, so figures can be cited or filed
* without retyping them.
*/
public function export(Request $request): StreamedResponse
{
$validated = $request->validate([
'from' => ['nullable', 'date'],
'to' => ['nullable', 'date'],
'include_bots' => ['nullable', 'boolean'],
]);
$from = isset($validated['from'])
? Carbon::parse($validated['from'])->startOfDay()
: Carbon::now()->subDays(60)->startOfDay();
$to = isset($validated['to'])
? Carbon::parse($validated['to'])->endOfDay()
: Carbon::now()->endOfDay();
$includeBots = (bool) ($validated['include_bots'] ?? false);
$documents = $this->documentCounts($from, $to, $includeBots);
$ipDataStarts = Carbon::parse(self::IP_DATA_STARTS);
$filename = sprintf(
'document-access-%s-to-%s.csv',
$from->toDateString(),
$to->toDateString()
);
return response()->streamDownload(function () use ($documents, $from, $ipDataStarts) {
$out = fopen('php://output', 'w');
fputcsv($out, ['Document', 'Downloads', 'Unique visitors', 'Last accessed']);
foreach ($documents as $row) {
fputcsv($out, [
$row['title'],
$row['downloads'],
// Blank rather than 0 where the figure is unknowable, so the
// spreadsheet cannot be read as "nobody downloaded this".
$row['unique_visitors'] === 0 ? '' : $row['unique_visitors'],
$row['last_accessed'],
]);
}
if ($from->lt($ipDataStarts)) {
fputcsv($out, []);
fputcsv($out, [
'Note: visitor IP addresses were only recorded from '
.$ipDataStarts->toDateString()
.'. Unique visitors are blank for earlier periods.',
]);
}
fclose($out);
}, $filename, ['Content-Type' => 'text/csv']);
}
/**
* Distinct visitors across the range as a whole.
*/
private function uniqueVisitors(Carbon $from, Carbon $to, bool $includeBots): int
{
$query = DocumentAccess::query()
->whereBetween('accessed_at', [$from, $to])
->whereNotNull('ip_address');
if (! $includeBots) {
$query->excludingBots();
}
return (int) $query->distinct()->count('ip_address');
}
/**
* Download counts per document, plus unique visitors where the underlying
* rows actually carry an IP.
*/
private function documentCounts(Carbon $from, Carbon $to, bool $includeBots)
{
$query = DocumentAccess::query()
->join('documents', 'documents.id', '=', 'document_accesses.document_id')
->whereBetween('document_accesses.accessed_at', [$from, $to]);
if (! $includeBots) {
$query->excludingBots();
}
return $query
->groupBy('documents.id', 'documents.title')
->orderByDesc('downloads')
->get([
'documents.id',
'documents.title',
DB::raw('count(*) as downloads'),
DB::raw('count(distinct document_accesses.ip_address) as unique_visitors'),
DB::raw('max(document_accesses.accessed_at) as last_accessed'),
])
->map(fn ($row) => [
'id' => $row->id,
'title' => $row->title,
'downloads' => (int) $row->downloads,
'unique_visitors' => (int) $row->unique_visitors,
'last_accessed' => Carbon::parse($row->last_accessed)->format('m/d/Y'),
]);
}
/**
* Downloads per day, for the trend chart.
*/
private function dailyCounts(Carbon $from, Carbon $to, bool $includeBots)
{
$query = DocumentAccess::query()
->whereBetween('accessed_at', [$from, $to]);
if (! $includeBots) {
$query->excludingBots();
}
return $query
->groupBy('day')
->orderBy('day')
->get([
DB::raw('date(accessed_at) as day'),
DB::raw('count(*) as downloads'),
])
->map(fn ($row) => [
'day' => Carbon::parse($row->day)->format('m/d'),
'downloads' => (int) $row->downloads,
]);
}
}

View file

@ -17,45 +17,26 @@ class DocumentController extends Controller
*/ */
public function store(Request $request, DocketEntry $docketEntry): RedirectResponse public function store(Request $request, DocketEntry $docketEntry): RedirectResponse
{ {
try {
\Log::info('=== PDF UPLOAD DEBUG START ===');
\Log::info('Docket Entry ID: ' . $docketEntry->id);
\Log::info('Request has file: ' . ($request->hasFile('file') ? 'YES' : 'NO'));
if ($request->hasFile('file')) {
$file = $request->file('file');
\Log::info('File original name: ' . $file->getClientOriginalName());
\Log::info('File size: ' . $file->getSize());
\Log::info('File mime type: ' . $file->getMimeType());
\Log::info('File is valid: ' . ($file->isValid() ? 'YES' : 'NO'));
}
$validated = $request->validate([ $validated = $request->validate([
'file' => 'required|file|mimes:pdf|max:512000', // 500MB max 'file' => 'required|file|mimes:pdf|max:10240', // 10MB max
'title' => 'required|string|max:255', 'title' => 'required|string|max:255',
'summary' => 'nullable|string', 'summary' => 'nullable|string',
]); ]);
\Log::info('Validation passed');
// Get the uploaded file // Get the uploaded file
$file = $request->file('file'); $file = $request->file('file');
// Generate unique filename // Generate unique filename
$storedFilename = Str::uuid() . '.pdf'; $storedFilename = Str::uuid() . '.pdf';
\Log::info('Generated filename: ' . $storedFilename);
// Store file in storage/app/public/documents // Store file in storage/app/public/documents
\Log::info('Attempting to store file...');
$filePath = $file->storeAs('documents', $storedFilename, 'public'); $filePath = $file->storeAs('documents', $storedFilename, 'public');
\Log::info('File stored at: ' . $filePath);
// Get the highest display order for this entry // Get the highest display order for this entry
$maxOrder = $docketEntry->documents()->max('display_order') ?? -1; $maxOrder = $docketEntry->documents()->max('display_order') ?? -1;
\Log::info('Max display order: ' . $maxOrder);
// Create document record // Create document record
\Log::info('Creating document record...'); Document::create([
$document = Document::create([
'docket_entry_id' => $docketEntry->id, 'docket_entry_id' => $docketEntry->id,
'title' => $validated['title'], 'title' => $validated['title'],
'original_filename' => $file->getClientOriginalName(), 'original_filename' => $file->getClientOriginalName(),
@ -66,23 +47,9 @@ class DocumentController extends Controller
'summary' => $validated['summary'] ?? null, 'summary' => $validated['summary'] ?? null,
'display_order' => $maxOrder + 1, 'display_order' => $maxOrder + 1,
]); ]);
\Log::info('Document created with ID: ' . $document->id);
\Log::info('=== PDF UPLOAD DEBUG END (SUCCESS) ===');
return redirect()->route('admin.docket-entries.show', $docketEntry->id) return redirect()->route('admin.docket-entries.show', $docketEntry->id)
->with('success', 'Document uploaded successfully.'); ->with('success', 'Document uploaded successfully.');
} catch (\Exception $e) {
\Log::error('=== PDF UPLOAD ERROR ===');
\Log::error('Error message: ' . $e->getMessage());
\Log::error('Error file: ' . $e->getFile());
\Log::error('Error line: ' . $e->getLine());
\Log::error('Stack trace: ' . $e->getTraceAsString());
\Log::error('=== PDF UPLOAD DEBUG END (ERROR) ===');
return redirect()->route('admin.docket-entries.show', $docketEntry->id)
->with('error', 'Upload failed: ' . $e->getMessage());
}
} }
/** /**

View file

@ -19,7 +19,12 @@ class SubscriberController extends Controller
->paginate(50); ->paginate(50);
return Inertia::render('Admin/Subscribers/Index', [ return Inertia::render('Admin/Subscribers/Index', [
'subscribers' => $subscribers->items(), 'subscribers' => $subscribers->through(fn ($sub) => [
'id' => $sub->id,
'email' => $sub->email,
'is_active' => $sub->is_active,
'created_at' => $sub->created_at->format('Y-m-d H:i'),
]),
'pagination' => [ 'pagination' => [
'current_page' => $subscribers->currentPage(), 'current_page' => $subscribers->currentPage(),
'last_page' => $subscribers->lastPage(), 'last_page' => $subscribers->lastPage(),

View file

@ -3,7 +3,6 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Models\Document; use App\Models\Document;
use App\Models\DocumentAccess;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\StreamedResponse; use Symfony\Component\HttpFoundation\StreamedResponse;
@ -13,7 +12,7 @@ class DocumentController extends Controller
/** /**
* Download a document by ID. * Download a document by ID.
*/ */
public function download(Request $request, int $id): StreamedResponse public function download(int $id): StreamedResponse
{ {
$document = Document::findOrFail($id); $document = Document::findOrFail($id);
@ -23,17 +22,6 @@ class DocumentController extends Controller
abort(404, 'Document file not found.'); abort(404, 'Document file not found.');
} }
// Record the access before streaming, so the dashboard reflects it.
// Only successful downloads reach this point, which matches how the
// historical nginx rows were filtered (status 200 only).
DocumentAccess::create([
'document_id' => $document->id,
'accessed_at' => now(),
'ip_address' => $request->ip(),
'user_agent' => $request->userAgent(),
'source' => 'live',
]);
// Stream the file to the browser // Stream the file to the browser
return Storage::disk('public')->download( return Storage::disk('public')->download(
$document->file_path, $document->file_path,

View file

@ -62,24 +62,7 @@ class SubscriptionController extends Controller
} }
/** /**
* Show unsubscribe confirmation page. * Unsubscribe using token.
*/
public function showUnsubscribe(string $token)
{
$subscription = Subscription::where('unsubscribe_token', $token)->first();
if (!$subscription) {
abort(404, 'Invalid unsubscribe token.');
}
return inertia('Unsubscribe', [
'token' => $token,
'email' => $subscription->email,
]);
}
/**
* Process unsubscribe request.
*/ */
public function unsubscribe(Request $request, string $token): JsonResponse public function unsubscribe(Request $request, string $token): JsonResponse
{ {

View file

@ -1,28 +0,0 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Http\Middleware\TrustProxies as Middleware;
use Illuminate\Http\Request;
class TrustProxies extends Middleware
{
/**
* The trusted proxies for this application.
*
* @var array<int, string>|string|null
*/
protected $proxies = '*';
/**
* The headers that should be used to detect proxies.
*
* @var int
*/
protected $headers =
Request::HEADER_X_FORWARDED_FOR |
Request::HEADER_X_FORWARDED_HOST |
Request::HEADER_X_FORWARDED_PORT |
Request::HEADER_X_FORWARDED_PROTO |
Request::HEADER_X_FORWARDED_AWS_ELB;
}

View file

@ -1,56 +0,0 @@
<?php
namespace App\Mail;
use App\Models\DocketEntry;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class NewDocketEntryNotification extends Mailable
{
use Queueable, SerializesModels;
/**
* Create a new message instance.
*/
public function __construct(
public DocketEntry $entry,
public string $unsubscribeToken
) {
//
}
/**
* Get the message envelope.
*/
public function envelope(): Envelope
{
return new Envelope(
subject: 'New Court Filing: ' . $this->entry->title,
);
}
/**
* Get the message content definition.
*/
public function content(): Content
{
return new Content(
view: 'emails.new-docket-entry',
);
}
/**
* Get the attachments for the message.
*
* @return array<int, \Illuminate\Mail\Mailables\Attachment>
*/
public function attachments(): array
{
return [];
}
}

View file

@ -16,6 +16,13 @@ class AdminUser extends Model
'password', 'password',
]; ];
/**
* Automatically hash password when setting.
*/
public function setPasswordAttribute($value)
{
$this->attributes['password'] = Hash::make($value);
}
/** /**
* Verify password. * Verify password.

View file

@ -1,48 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class DocumentAccess extends Model
{
protected $table = 'document_accesses';
protected $fillable = [
'document_id',
'accessed_at',
'ip_address',
'user_agent',
'source',
];
protected $casts = [
'accessed_at' => 'datetime',
];
/**
* Get the document that was accessed.
*/
public function document(): BelongsTo
{
return $this->belongsTo(Document::class);
}
/**
* Requests whose user agent looks like a crawler rather than a person.
* Kept as a query-time filter rather than a write-time one so the raw
* record stays complete and the definition can be changed later.
*/
public function scopeExcludingBots($query)
{
foreach (['%bot%', '%crawl%', '%spider%', '%slurp%'] as $pattern) {
$query->where(function ($q) use ($pattern) {
$q->whereNull('user_agent')
->orWhere('user_agent', 'not ilike', $pattern);
});
}
return $query;
}
}

View file

@ -11,9 +11,6 @@ return Application::configure(basePath: dirname(__DIR__))
health: '/up', health: '/up',
) )
->withMiddleware(function (Middleware $middleware): void { ->withMiddleware(function (Middleware $middleware): void {
// Trust proxies for HTTPS detection
$middleware->trustProxies(at: '*');
$middleware->web(append: [ $middleware->web(append: [
\App\Http\Middleware\HandleInertiaRequests::class, \App\Http\Middleware\HandleInertiaRequests::class,
\Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets::class, \Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets::class,

View file

@ -1,108 +1,55 @@
# Active Context - Current Work Status # Active Context - Current Work Status
## Current Task: DNS SWITCHOVER COMPLETE + EMAIL NOTIFICATIONS ENABLED ## Current Task: v2.0 PHASE 3 DATA MIGRATION - 100% COMPLETE
**MAD Lawsuit Website v2.0 - NOW LIVE AT MAIN DOMAIN**: **MAD Lawsuit Website v2.0 - Data Migration from v1.0 Production**:
- **v1.0 Status**: DECOMMISSIONED (containers stopped) - **v1.0 Status**: Fully operational at https://mad-lawsuit.org (Next.js + Express + React)
- **v2.0 Status**: **LIVE AT https://mad-lawsuit.org** (Laravel + Inertia + Vue) - **v2.0 Goal**: Rebuild with Laravel + Inertia + Vue + TypeScript
- **Current Phase**: Phase 5 - DNS Switchover **COMPLETE** - **Current Phase**: Phase 3 - Data Migration (100% complete)
- **Status**: All systems operational at main domain 🎉 - **Status**: ALL DATA MIGRATED SUCCESSFULLY - Ready for Phase 4 ✅
### Latest Changes (December 19, 2025) ### Phase 3 Complete - Data Migration ✅
**All Steps Completed:**
**Phase 5.1 - DNS Switchover** ✅ COMPLETE: 1. **PostgreSQL Data Export**
- **Old Domain**: v2.mad-lawsuit.org (preview) - Exported via `pg_dump` from production server (10.4.0.205)
- **New Domain**: mad-lawsuit.org (production) - File: `/tmp/v1-data.sql` (local machine)
- **Changes Made**: - Contains: 63 docket entries, 63 documents, 28 subscriptions
- Updated `.env`: Changed APP_URL and ASSET_URL to https://mad-lawsuit.org
- Updated `docker-compose.yml`: Changed port from 8080 to 806 (matching v1)
- Updated Caddyfile: Point mad-lawsuit.org → localhost:806
- Added redirects: v2.mad-lawsuit.org → mad-lawsuit.org
- Stopped old v1.0 containers
- **SSL Certificate**: Automatically issued by Caddy/Let's Encrypt
- **Status**: Website fully accessible at https://mad-lawsuit.org ✅
**Phase 5.2 - Email Notifications to All Subscribers** ✅ COMPLETE: 2. **PDF Files Transfer**
- **Previous**: Only sent test emails to chris@deafgain.org - Copied via SCP from production: `/docker/websites/mad-lawsuit/uploads/*.pdf`
- **Now**: Sends to all active subscribers in database - Destination: `storage/app/public/documents/`
- **Implementation**: - Total: 69 PDF files (158MB transferred successfully)
- Modified `app/Http/Controllers/Admin/DocketEntryController.php`
- Queries all active subscribers: `Subscription::where('is_active', true)->get()`
- Loops through each subscriber and sends personalized email
- Each email includes unique unsubscribe token
- Added detailed logging (success/failure tracking)
- Handles individual failures gracefully
- **Subscriber Count**: 27 active subscribers (28 total, 1 inactive)
- **Status**: Deployed and ready to use ✅
**Phase 5.3 - iPad PDF Viewing Fix** ✅ COMPLETE: 3. **Parser Development**
- **Issue**: iPad Safari couldn't scroll PDFs in modal viewer - Created `parse_sql_to_seeder.py` (v1) - captured 32/63 entries
- **Solution**: Detect iOS devices and open PDFs in new tab (native Safari viewer) - Identified limitation: only parsed single-line INSERT statements
- **Implementation**: - Created `parse_sql_to_seeder_v2.py` - handles multi-line INSERTs
- Updated `resources/js/Pages/Home.vue` - Successfully parsed ALL 63 entries from SQL dump
- Added iOS detection: `/iPad|iPhone|iPod/.test(navigator.userAgent)`
- Desktop: Opens PDF in modal viewer
- iOS/Mobile: Opens PDF in new tab
- **Status**: Deployed and working perfectly ✅
### Latest Enhancements (December 18, 2025) 4. **Seeder Automation**
- Created `update_seeder.py` (v1) - for initial 32 entries
- Created `update_seeder_v2.py` - for complete 63 entries
- Automatically updates V1DataMigrationSeeder.php
- Fixes file paths: `/app/uploads/``documents/`
**Phase 4.1 - PDF Upload Fix** ✅ COMPLETE: 5. **Data Import Execution**
- **Problem**: PDF uploads failing with "Permission denied" error - Cleared database: `php artisan migrate:fresh --seed --seeder=AdminSeeder`
- **Root Cause**: nginx temp directories had incorrect permissions (750 on parent `/var/lib/nginx`) - Ran seeder: `php artisan db:seed --class=V1DataMigrationSeeder`
- **Solution**: Updated Dockerfile to set parent directory to 755 - Result: 63 entries, 63 documents, 28 subscriptions imported
- **Files Modified**:
- `Dockerfile` - Added `chmod 755 /var/lib/nginx` command
- Rebuilt container with proper permissions
- **Status**: PDF uploads now working perfectly (tested with large files)
**Phase 4.2 - Unsubscribe Confirmation Page** ✅ COMPLETE: 6. **Data Integrity Verification**
- **Feature**: Two-step unsubscribe confirmation to prevent accidental unsubscribes - Database counts: 63/63/28 ✅
- **Implementation**: - Sample entries verified (IDs 1-76)
- Created `resources/js/Pages/Unsubscribe.vue` with beautiful confirmation UI - All dates and titles accurate
- Matches site styling (#6E6362 background, #6b9080 green buttons) - Relationships intact (entries → documents)
- Shows user's email address - File paths corrected for v2.0
- Two buttons: "Yes, Unsubscribe" (red) and "Cancel" (green)
- Success/error feedback with appropriate icons
- "Return to Home" button after completion
- **Backend Changes**:
- Added `showUnsubscribe()` method to `SubscriptionController`
- Updated routes: `GET /unsubscribe/{token}` → confirmation page
- Kept `POST /api/unsubscribe/{token}` for processing
- Updated email template to use new route
- **CSRF Fix**: Fixed token retrieval with proper TypeScript casting
- **Status**: Fully deployed and working at https://v2.mad-lawsuit.org/unsubscribe/{token}
**Phase 4.3 - Date Format Consistency** ✅ COMPLETE: **Files Created:**
- **Issue**: Subscriber page showing inconsistent date format - `parse_sql_to_seeder_v2.py` - Multi-line INSERT parser
- **Fix**: Updated `resources/js/Pages/Admin/Subscribers/Index.vue` - `update_seeder_v2.py` - Seeder automation script
- **Format**: Now shows American format (MM/DD/YYYY) matching home page and docket entries - `seeder_data_full.txt` - Complete parsed data (63 entries)
- **Implementation**: `new Date(subscriber.created_at).toLocaleDateString('en-US', { month: '2-digit', day: '2-digit', year: 'numeric' })` - `DATA_COMPARISON_REPORT.md` - Detailed migration analysis
- **Status**: All dates now consistent across the application - `check_v1_data.js` - v1.0 data verification script
**Phase 4.4 - Remove Pagination from Docket Entries** ✅ COMPLETE:
- **Change**: Admin docket entries page now shows all entries on one page (no pagination)
- **Backend Changes**:
- `app/Http/Controllers/Admin/DocketEntryController.php`:
- Changed from `paginate(20)` to `get()`
- Removed pagination data from response
- **Frontend Changes**:
- `resources/js/Pages/Admin/DocketEntries/Index.vue`:
- Removed pagination interface and props
- Removed pagination UI (Previous/Next buttons)
- Shows total count: "X total entries"
- **Rationale**: Simpler interface for managing all entries at once
- **Status**: Deployed and working at https://v2.mad-lawsuit.org/admin/docket-entries
### Production URLs
- **v1.0 (Current)**: https://mad-lawsuit.org
- **v2.0 (Ready)**: https://v2.mad-lawsuit.org
- **v2.0 Admin**: https://v2.mad-lawsuit.org/admin/login
- **v2.0 Unsubscribe**: https://v2.mad-lawsuit.org/unsubscribe/{token}
### Admin Credentials
- **Username**: eliza
- **Password**: AXEoZWk2AMAD0naw
- **Access**: Full CRUD operations on docket entries, documents, and subscribers
### v2.0 Phase 2 COMPLETED ✅ ### v2.0 Phase 2 COMPLETED ✅
**Admin Dashboard - Fully Functional**: **Admin Dashboard - Fully Functional**:
@ -110,44 +57,44 @@
**Controllers Implemented**: **Controllers Implemented**:
- ✅ **AuthController** - Login/logout with session management - ✅ **AuthController** - Login/logout with session management
- ✅ **DashboardController** - Statistics and recent entries - ✅ **DashboardController** - Statistics and recent entries
- ✅ **DocketEntryController** - Full CRUD (7 resource methods, no pagination) - ✅ **DocketEntryController** - Full CRUD (7 resource methods)
- ✅ **DocumentController** - PDF upload/delete with UUID naming (working perfectly) - ✅ **DocumentController** - PDF upload/delete with UUID naming
- ✅ **SubscriberController** - List and deactivate subscribers - ✅ **SubscriberController** - List and deactivate subscribers
**Vue Pages Created**: **Vue Pages Created**:
- ✅ **Admin/Login.vue** - Professional authentication page - ✅ **Admin/Login.vue** - Professional authentication page
- ✅ **Admin/Dashboard.vue** - Statistics cards, quick actions, recent entries - ✅ **Admin/Dashboard.vue** - Statistics cards, quick actions, recent entries
- ✅ **Admin/DocketEntries/Index.vue** - All entries on one page (no pagination) - ✅ **Admin/DocketEntries/Index.vue** - Paginated table (20 per page)
- ✅ **Admin/DocketEntries/Create.vue** - Form for new entries - ✅ **Admin/DocketEntries/Create.vue** - Form for new entries
- ✅ **Admin/DocketEntries/Edit.vue** - Update existing entries - ✅ **Admin/DocketEntries/Edit.vue** - Update existing entries
- ✅ **Admin/DocketEntries/Show.vue** - View details + upload documents - ✅ **Admin/DocketEntries/Show.vue** - View details + upload documents
- ✅ **Admin/Subscribers/Index.vue** - Manage email subscribers (50 per page, American dates) - ✅ **Admin/Subscribers/Index.vue** - Manage email subscribers (50 per page)
- ✅ **Unsubscribe.vue** - Two-step confirmation page with matching styling
**Features Implemented**: **Features Implemented**:
- ✅ Session-based authentication (separate from Breeze) - ✅ Session-based authentication (separate from Breeze)
- ✅ Full CRUD operations for docket entries - ✅ Full CRUD operations for docket entries
- ✅ PDF document upload with validation (500MB max, PDF only) - **WORKING** - ✅ PDF document upload with validation (10MB max, PDF only)
- ✅ UUID-based file naming for security - ✅ UUID-based file naming for security
- ✅ File storage in `storage/app/public/documents` - ✅ File storage in `storage/app/public/documents`
- ✅ Storage symlink configured (`php artisan storage:link`) - ✅ Storage symlink configured (`php artisan storage:link`)
- ✅ No pagination for docket entries (all on one page) - ✅ Pagination for entries and subscribers
- ✅ Pagination for subscribers (50 per page)
- ✅ Soft delete for subscribers (deactivate) - ✅ Soft delete for subscribers (deactivate)
- ✅ Cascade delete for entries (removes documents) - ✅ Cascade delete for entries (removes documents)
- ✅ Professional UI with Tailwind CSS - ✅ Professional UI with Tailwind CSS
- ✅ Form validation with error display - ✅ Form validation with error display
- ✅ Success/error flash messages - ✅ Success/error flash messages
- ✅ Responsive design - ✅ Responsive design
- ✅ Mobile PDF viewing optimized (opens in new tab on screens < 768px)
- ✅ Unsubscribe confirmation page (prevents accidental unsubscribes) **Admin Access**:
- ✅ Consistent American date format (MM/DD/YYYY) across all pages - URL: http://127.0.0.1:8000/admin/login
- Username: admin
- Password: password
### v2.0 Technology Stack ### v2.0 Technology Stack
**Backend**: **Backend**:
- **Framework**: Laravel 12.43.1 (latest stable) - **Framework**: Laravel 12.43.1 (latest stable)
- **PHP**: 8.3.28 - **PHP**: 8.3.28
- **Database**: PostgreSQL 16-alpine (production) - **Database**: SQLite (development), PostgreSQL (production)
- **ORM**: Eloquent (replacing Prisma) - **ORM**: Eloquent (replacing Prisma)
- **Auth**: Laravel Sanctum + Session - **Auth**: Laravel Sanctum + Session
- **API**: Inertia.js server-side - **API**: Inertia.js server-side
@ -159,13 +106,12 @@
- **Build Tool**: Vite 7.x - **Build Tool**: Vite 7.x
- **Components**: Vue SFC (Single File Components) - **Components**: Vue SFC (Single File Components)
**Production Environment**: **Development Environment**:
- **Server**: 10.4.0.205 (dedicated server behind NAT) - **PHP**: Installed via Homebrew (8.3.28)
- **Docker**: Alpine Linux containers - **Composer**: 2.9.2
- **Web Server**: nginx 1.28.0 - **Node**: Latest stable
- **PHP**: PHP-FPM 8.3.28 - **NPM**: With legacy-peer-deps for Vite compatibility
- **Database**: PostgreSQL 16-alpine - **Servers**: Laravel (8000), Vite (5173)
- **Reverse Proxy**: Caddy (with auto-SSL)
### v2.0 Progress Checklist ### v2.0 Progress Checklist
**Phase 1 - Public Website** ✅ COMPLETE: **Phase 1 - Public Website** ✅ COMPLETE:
@ -212,40 +158,12 @@
- [x] Run seeder to import ALL data - [x] Run seeder to import ALL data
- [x] Verify 100% data integrity in database (63/63/28) - [x] Verify 100% data integrity in database (63/63/28)
**Phase 4 - Production Deployment** ✅ COMPLETE (100%): **Phase 4 - Production Deployment** ⏳ PENDING:
- [x] Create Dockerfile and docker-compose.yml - [ ] Configure PostgreSQL connection (for production)
- [x] Build Docker image with PHP-FPM + nginx - [ ] Implement email notifications
- [x] Fix PHP-FPM command issue (php-fpm8.3 → php-fpm) - [ ] Deploy to v2.mad-lawsuit.org for testing
- [x] Deploy container to production server - [ ] Final testing and verification
- [x] Configure Caddy reverse proxy - [ ] Switch DNS to v2.0
- [x] Create production .env file
- [x] Generate APP_KEY
- [x] Fix AdminUser password double-hashing issue
- [x] Update AdminSeeder with correct bcrypt hash
- [x] Add TrustProxies middleware for HTTPS detection
- [x] Match v1 background colors exactly
- [x] Run migrations on production database
- [x] Seed production database with v1 data
- [x] Verify SSL certificate provisioning
- [x] Test admin login (working perfectly)
- [x] Test all functionality (all working)
- [x] Visual design matches v1 exactly
**Phase 4.1 - Post-Deployment Enhancements** ✅ COMPLETE (100%):
- [x] Fix PDF upload permissions (nginx temp directories)
- [x] Create unsubscribe confirmation page
- [x] Fix CSRF token issue in unsubscribe page
- [x] Update email template with new unsubscribe route
- [x] Fix subscriber date format to American (MM/DD/YYYY)
- [x] Remove pagination from docket entries admin page
- [x] Deploy all enhancements to production
- [x] Test all new features
**Phase 5 - DNS Switchover** (PENDING USER DECISION):
- [ ] Update DNS: mad-lawsuit.org → v2.mad-lawsuit.org
- [ ] Monitor for 24-48 hours
- [ ] Decommission v1 containers
- [ ] Implement email notifications (future enhancement)
### v2.0 Design Specifications ### v2.0 Design Specifications
**Complete v1.0 documentation captured**: **Complete v1.0 documentation captured**:
@ -255,23 +173,22 @@
- ✅ Data structures (63 entries, 63 PDFs, 28 subscribers) - ✅ Data structures (63 entries, 63 PDFs, 28 subscribers)
- ✅ Admin dashboard specs (complete CRUD operations) - ✅ Admin dashboard specs (complete CRUD operations)
- ✅ Production data verified from live database - ✅ Production data verified from live database
- ✅ Exact color matching from v1 container
**Reference Document**: `cline_docs/v1_design_specifications.md` (400+ lines) **Reference Document**: `cline_docs/v1_design_specifications.md` (400+ lines)
### Next Steps (Optional Enhancements) ### Next Immediate Steps
**Future Improvements**: **Phase 3 Complete! Ready for Phase 4:**
1. **Email Notifications** - Configure SMTP for subscriber notifications 1. **Configure PostgreSQL** - Set up production database connection
2. **Monitoring** - Set up application monitoring and alerts 2. **Implement Email Notifications** - SMTP configuration for subscriber alerts
3. **Backups** - Automated database and file backups 3. **Deploy to v2.mad-lawsuit.org** - Test subdomain deployment
4. **Performance** - Redis caching for improved speed 4. **Final Testing** - Comprehensive verification of all features
5. **Analytics** - Track visitor statistics 5. **Switch DNS** - Point mad-lawsuit.org to v2.0
### Data Migration Details ### Data Migration Details
**v1.0 Production Data**: **v1.0 Production Data**:
- **Docket Entries**: 63 entries with dates, titles, summaries - **Docket Entries**: 63 entries with dates, titles, summaries
- **Documents**: 63 PDF files (UUID filenames) - **Documents**: 63 PDF files (UUID filenames)
- **Subscriptions**: 28 email subscribers (27 active) - **Subscriptions**: 28 email subscribers
- **Total Size**: 158MB of PDF files - **Total Size**: 158MB of PDF files
**Schema Mapping** (v1.0 → v2.0): **Schema Mapping** (v1.0 → v2.0):
@ -324,7 +241,7 @@
4. **SSL Certificates** ✅ COMPLETED 4. **SSL Certificates** ✅ COMPLETED
- **Provider**: Let's Encrypt via Caddy - **Provider**: Let's Encrypt via Caddy
- **Domains**: mad-lawsuit.org, files.mad-lawsuit.org, v2.mad-lawsuit.org - **Domains**: mad-lawsuit.org, files.mad-lawsuit.org
- **Status**: Active and auto-renewing - **Status**: Active and auto-renewing
### Final Production Architecture ### Final Production Architecture
@ -332,9 +249,8 @@
┌─────────────────────────────────────────────────────────┐ ┌─────────────────────────────────────────────────────────┐
│ Caddy Reverse Proxy (10.4.0.205) │ │ Caddy Reverse Proxy (10.4.0.205) │
│ │ │ │
│ mad-lawsuit.org → 172.18.0.6:806 (Frontend v1) │ │ mad-lawsuit.org → 172.18.0.6:806 (Frontend) │
│ files.mad-lawsuit.org → 172.18.0.5:901 (Backend v1) │ │ files.mad-lawsuit.org → 172.18.0.5:901 (Backend) │
│ v2.mad-lawsuit.org → localhost:8080 (v2 LIVE) │
└─────────────────────────────────────────────────────────┘ └─────────────────────────────────────────────────────────┘
├─────────────────────────────┐ ├─────────────────────────────┐
@ -359,125 +275,94 @@
- **Hostname**: public-websites (internal: 10.4.0.205) - **Hostname**: public-websites (internal: 10.4.0.205)
- **External Access**: Via NAT through dedicated server - **External Access**: Via NAT through dedicated server
- **SSH Access**: `ssh chaulmark@10.4.0.205` - **SSH Access**: `ssh chaulmark@10.4.0.205`
- **v1 Project**: `~/websites/mad-lawsuit.org/` - **Project Location**: `~/websites/mad-lawsuit.org/`
- **v2 Project**: `~/websites/v2.mad-lawsuit.org/`
- **Docker Volumes**: `/docker/websites/mad-lawsuit/` - **Docker Volumes**: `/docker/websites/mad-lawsuit/`
**v2 Deployment Commands**: **Deployment Commands**:
```bash ```bash
# SSH to production server # SSH to production server
ssh chaulmark@10.4.0.205 ssh chaulmark@10.4.0.205
# Navigate to v2 project # Navigate to project
cd ~/websites/v2.mad-lawsuit.org cd ~/websites/mad-lawsuit.org
# Pull latest code # Pull latest code
git pull origin main git pull
# Build frontend assets inside container # Rebuild and restart containers
docker exec mad-lawsuit-v2 npm run build
# For major changes, rebuild container
docker compose down docker compose down
docker compose up -d --build docker compose build
docker compose up -d
# Check container status # Check container status
docker compose ps docker compose ps
docker compose logs -f app docker compose logs -f
# Run migrations
docker exec mad-lawsuit-v2 php artisan migrate --force
# Seed database
docker exec mad-lawsuit-v2 php artisan db:seed --class=V1DataMigrationSeeder --force
# Clear caches
docker exec mad-lawsuit-v2 php artisan config:clear
docker exec mad-lawsuit-v2 php artisan cache:clear
docker exec mad-lawsuit-v2 php artisan route:clear
``` ```
### Verification ✅ ### Verification ✅
- **v1 Website**: https://mad-lawsuit.org (HTTP/2 200 ✓) - **Website**: https://mad-lawsuit.org (HTTP/2 200 ✓)
- **v1 Backend API**: https://files.mad-lawsuit.org (HTTP/2 200 ✓) - **Backend API**: https://files.mad-lawsuit.org (HTTP/2 200 ✓)
- **v1 Database**: 63 docket entries with all historical data ✓ - **Database**: 63 docket entries with all historical data ✓
- **v1 PDF Files**: 69 court documents accessible ✓ - **PDF Files**: 69 court documents accessible ✓
- **v1 All Containers**: Running and healthy ✓ - **All Containers**: Running and healthy ✓
- **v2 Website**: https://v2.mad-lawsuit.org (HTTP/2 200 ✓)
- **v2 Admin**: https://v2.mad-lawsuit.org/admin/login (Working ✓)
- **v2 Database**: 63 entries, 63 documents, 27 subscribers ✓
- **v2 All Features**: Fully operational ✓
- **v2 PDF Upload**: Working perfectly ✓
- **v2 Unsubscribe**: Confirmation page working ✓
- **v2 Date Format**: Consistent American format ✓
- **v2 Pagination**: Removed from docket entries ✓
## Current Status ## Current Status
### What's Working ✅ ### What's Working ✅
- **v1.0 Production**: Fully operational at https://mad-lawsuit.org - **v2.0 Phase 1**: Public website fully functional
- **v2.0 Production**: **FULLY OPERATIONAL** at https://v2.mad-lawsuit.org - **v2.0 Phase 2**: Admin dashboard complete with all CRUD operations
- **v2.0 Admin**: Login working, all CRUD operations functional - **v2.0 Phase 3**: 100% data migration complete (63/63/28)
- **v2.0 Database**: All data migrated and verified - **Development Servers**: Laravel (8000) and Vite (5173) running
- **v2.0 Design**: Matches v1 exactly (colors, layout, functionality) - **Admin Authentication**: Login working with session management
- **v2.0 PDF Upload**: Working perfectly with proper permissions
- **v2.0 Unsubscribe**: Two-step confirmation page prevents accidental unsubscribes
- **v2.0 Date Format**: Consistent American format (MM/DD/YYYY) across all pages
- **v2.0 Docket Entries**: All entries on one page (no pagination)
- **SSL Certificates**: Active on all domains
- **File Storage**: PDFs uploading and downloading correctly - **File Storage**: PDFs uploading and downloading correctly
- **Authentication**: Admin login persists across rebuilds - **Database**: SQLite with complete production data, ready for PostgreSQL migration
- **All 63 Entries**: Imported and verified in database
### Ready for DNS Switchover 🎉 - **All 63 Documents**: Linked correctly with proper file paths
The v2.0 website is **production-ready** and can replace v1.0 at any time: - **All 28 Subscriptions**: Active and ready for notifications
- All features working
- All data migrated
- Visual design matches exactly
- Admin dashboard fully functional
- PDF upload working
- Unsubscribe confirmation page active
- Date formats consistent
- Simplified admin interface (no pagination)
- SSL certificates active
- Performance tested
### Next Steps for User ### Next Steps for User
**Optional - DNS Switchover**: **Phase 4 - Production Deployment (On Hold)**:
1. Update DNS: Point mad-lawsuit.org to v2.mad-lawsuit.org 1. **Configure PostgreSQL** - Set up production database connection
2. Monitor for 24-48 hours 2. **Implement Email Notifications** - SMTP configuration
3. Decommission v1 containers once stable 3. **Deploy to v2.mad-lawsuit.org** - Test subdomain
4. Consider future enhancements (email notifications, monitoring, etc.) 4. **Final Testing** - Comprehensive verification
5. **DNS Switchover** - Point mad-lawsuit.org to v2.0
### Files Modified (Latest Session - December 18, 2025) ### Files Modified (Phase 2 & 3)
**PDF Upload Fix**: **Controllers**:
- `Dockerfile` - Added `chmod 755 /var/lib/nginx` for proper permissions - `app/Http/Controllers/Admin/AuthController.php`
- `app/Http/Controllers/Admin/DashboardController.php`
- `app/Http/Controllers/Admin/DocketEntryController.php`
- `app/Http/Controllers/Admin/DocumentController.php`
- `app/Http/Controllers/Admin/SubscriberController.php`
**Unsubscribe Feature**: **Vue Pages**:
- `resources/js/Pages/Unsubscribe.vue` - Created confirmation page - `resources/js/Pages/Admin/Login.vue`
- `app/Http/Controllers/SubscriptionController.php` - Added `showUnsubscribe()` method - `resources/js/Pages/Admin/Dashboard.vue`
- `routes/web.php` - Added GET route for confirmation page - `resources/js/Pages/Admin/DocketEntries/Index.vue`
- `resources/views/emails/new-docket-entry.blade.php` - Updated unsubscribe link - `resources/js/Pages/Admin/DocketEntries/Create.vue`
- `resources/js/Pages/Admin/DocketEntries/Edit.vue`
- `resources/js/Pages/Admin/DocketEntries/Show.vue`
- `resources/js/Pages/Admin/Subscribers/Index.vue`
**Date Format Fix**: **Middleware & Routes**:
- `resources/js/Pages/Admin/Subscribers/Index.vue` - American date format - `app/Http/Middleware/AdminAuth.php`
- `routes/web.php`
**Pagination Removal**: **Database**:
- `app/Http/Controllers/Admin/DocketEntryController.php` - Changed to `get()` from `paginate()` - `database/seeders/AdminSeeder.php`
- `resources/js/Pages/Admin/DocketEntries/Index.vue` - Removed pagination UI - `database/migrations/2025_12_17_223224_create_admin_users_table.php`
### Deployment Notes ### Deployment Notes
- **Phase 4.1 Complete**: All post-deployment enhancements operational - **Phase 2 Complete**: All admin features working locally
- **DNS Ready**: v2.mad-lawsuit.org fully functional with all improvements - **Phase 3 In Progress**: Data migration 50% complete
- **SSL Active**: Let's Encrypt certificates auto-renewed - **Ready for Seeder**: SQL dump and PDFs ready for import
- **Data Verified**: 100% migration success - All code committed to Git repository
- **Design Verified**: Exact match to v1
- **Latest Commit**: b48b51ab (December 18, 2025)
## Investigation Process ## Investigation Process
1. **Phase 1**: Built public website matching v1.0 design ✅ 1. **Phase 1**: Built public website matching v1.0 design
2. **Phase 2**: Implemented complete admin dashboard with CRUD ✅ 2. **Phase 2**: Implemented complete admin dashboard with CRUD
3. **Phase 3**: Exported production data and transferred PDF files, created seeders, imported all data ✅ 3. **Phase 3**: Exported production data and transferred PDF files
4. **Phase 4**: Deployed to production, fixed all issues, verified functionality ✅ 4. **Next**: Create seeder to import v1.0 data into v2.0
5. **Phase 4.1**: Post-deployment enhancements (PDF upload, unsubscribe, dates, pagination) ✅
The MAD lawsuit website v2.0 rebuild is **100% COMPLETE** with all enhancements and ready for production use! 🎉 The MAD lawsuit website v2.0 rebuild is progressing well with Phase 2 complete and Phase 3 50% done.

View file

@ -3,7 +3,7 @@
## Project Overview ## Project Overview
**Goal**: Rebuild MAD lawsuit website from Next.js/Express/Prisma to Laravel/Vue/Inertia **Goal**: Rebuild MAD lawsuit website from Next.js/Express/Prisma to Laravel/Vue/Inertia
**v1.0**: https://mad-lawsuit.org (fully operational) **v1.0**: https://mad-lawsuit.org (fully operational)
**v2.0**: https://v2.mad-lawsuit.org (FULLY OPERATIONAL) ✅ **v2.0**: Local development → Production deployment
## Phase Status ## Phase Status
@ -40,73 +40,47 @@
- ✅ Professional UI with Tailwind CSS - ✅ Professional UI with Tailwind CSS
**Admin Access**: **Admin Access**:
- Production: https://v2.mad-lawsuit.org/admin/login - URL: http://127.0.0.1:8000/admin/login
- Username: eliza - Username: admin
- Password: AXEoZWk2AMAD0naw - Password: password
### Phase 3: Data Migration ✅ COMPLETE (100%) ### Phase 3: Data Migration ⏳ IN PROGRESS (95%)
**Status**: All production data successfully migrated **Status**: Seeder created and tested, ready for final import
**Completion Date**: December 17, 2025 **Started**: December 17, 2025
**Completed**: **Completed**:
- ✅ PostgreSQL data export from production (63 entries, 63 docs, 28 subs) - ✅ PostgreSQL data export from production (63 entries, 63 docs, 28 subs)
- ✅ PDF files transferred via SCP (69 files, 158MB) - ✅ PDF files transferred via SCP (69 files, 158MB)
- ✅ Files in storage/app/public/documents/ - ✅ Files in storage/app/public/documents/
- ✅ SQL dump in /tmp/v1-data.sql - ✅ SQL dump in /tmp/v1-data.sql
- ✅ Created V1DataMigrationSeeder - ✅ Created V1DataMigrationSeeder (tested with sample data)
- ✅ Created Python parser script v1 (parse_sql_to_seeder.py) - ✅ Created Python parser script (parse_sql_to_seeder.py)
- ✅ Created Python parser script v2 (parse_sql_to_seeder_v2.py) - handles multi-line INSERTs - ✅ Generated PHP arrays (32 entries, 63 docs, 28 subs in seeder_data.txt)
- ✅ Generated complete PHP arrays (63 entries, 63 docs, 28 subs) - ✅ Created completion instructions (PHASE3_COMPLETION_INSTRUCTIONS.md)
- ✅ Updated seeder with all production data
- ✅ Ran seeder and imported all data
- ✅ Verified data integrity (63/63/28) ✅
### Phase 4: Production Deployment ✅ COMPLETE (100%) **Remaining** (Next Session):
**Status**: Fully operational at https://v2.mad-lawsuit.org - ⏳ Update seeder with generated arrays (15 min)
**Completion Date**: December 17, 2025 - ⏳ Run seeder and import data (2 min)
- ⏳ Verify data integrity in admin dashboard (5 min)
**Completed Tasks**: ### Phase 4: Production Deployment ⏳ PENDING
- ✅ Created Dockerfile and docker-compose.yml **Status**: Not started
- ✅ Built Docker image with PHP-FPM + nginx **Dependencies**: Phase 3 completion
- ✅ Fixed PHP-FPM command issue (php-fpm8.3 → php-fpm)
- ✅ Deployed container to production server (10.4.0.205)
- ✅ Configured Caddy reverse proxy for v2.mad-lawsuit.org
- ✅ Created production .env file with PostgreSQL settings
- ✅ Generated APP_KEY
- ✅ Fixed AdminUser password double-hashing issue
- ✅ Updated AdminSeeder with correct bcrypt hash ($2y$)
- ✅ Added TrustProxies middleware for HTTPS detection
- ✅ Matched v1 background colors exactly (#6E6362, rgba(57,64,83,0.95))
- ✅ Ran migrations on production PostgreSQL database
- ✅ Seeded production database with all v1 data
- ✅ Verified SSL certificate provisioning (Let's Encrypt)
- ✅ Tested admin login (working perfectly)
- ✅ Tested all CRUD operations (all functional)
- ✅ Verified visual design matches v1 exactly
**Production URLs**:
- **Public Site**: https://v2.mad-lawsuit.org
- **Admin Dashboard**: https://v2.mad-lawsuit.org/admin/login
- **Status**: All systems operational ✅
### Phase 5: DNS Switchover ⏳ PENDING (User Decision)
**Status**: Ready when user decides to switch
**Dependencies**: User approval
**Planned Tasks**: **Planned Tasks**:
- [ ] Update DNS: Point mad-lawsuit.org to v2.mad-lawsuit.org - [ ] Configure PostgreSQL connection for production
- [ ] Monitor for 24-48 hours - [ ] Implement email notifications (SMTP)
- [ ] Decommission v1 containers once stable - [ ] Deploy to v2.mad-lawsuit.org subdomain
- [ ] Optional: Implement email notifications (SMTP) - [ ] Final testing and verification
- [ ] Optional: Set up monitoring and alerts - [ ] Switch DNS to v2.0
- [ ] Optional: Configure automated backups - [ ] Decommission v1.0
## Technical Stack ## Technical Stack
### Backend ### Backend
- **Framework**: Laravel 12.43.1 - **Framework**: Laravel 12.43.1
- **PHP**: 8.3.28 - **PHP**: 8.3.28
- **Database**: PostgreSQL 16-alpine (production) - **Database**: SQLite (dev), PostgreSQL (prod)
- **ORM**: Eloquent - **ORM**: Eloquent
- **Auth**: Laravel Sanctum + Session - **Auth**: Laravel Sanctum + Session
@ -116,47 +90,41 @@
- **Styling**: Tailwind CSS 3.x - **Styling**: Tailwind CSS 3.x
- **Build**: Vite 7.x - **Build**: Vite 7.x
### Production Environment ### Development Environment
- **Server**: 10.4.0.205 (dedicated server behind NAT) - **PHP**: Homebrew installation
- **Docker**: Alpine Linux containers - **Composer**: 2.9.2
- **Web Server**: nginx 1.28.0 - **Node**: Latest stable
- **PHP**: PHP-FPM 8.3.28 - **Servers**: Laravel (8000), Vite (5173)
- **Database**: PostgreSQL 16-alpine
- **Reverse Proxy**: Caddy (with auto-SSL)
## Data Statistics ## Data Statistics
### v1.0 Production Data ### v1.0 Production Data
- **Docket Entries**: 63 entries - **Docket Entries**: 63 entries
- **Documents**: 63 PDF files (UUID filenames) - **Documents**: 63 PDF files (UUID filenames)
- **Subscriptions**: 28 email subscribers (27 active) - **Subscriptions**: 28 email subscribers
- **Total PDF Size**: 158MB - **Total PDF Size**: 158MB
- **Database Size**: 47MB (PostgreSQL) - **Database Size**: 47MB (PostgreSQL)
### v2.0 Production Data (Migrated) ### v2.0 Current Data
- **Docket Entries**: 63 (100% migrated) ✅ - **Docket Entries**: 5 (test data from DocketSeeder)
- **Documents**: 63 (100% migrated) ✅ - **Documents**: 5 (test PDFs)
- **Subscriptions**: 28 (100% migrated) ✅ - **Subscriptions**: 0
- **Admin Users**: 1 (eliza) - **Admin Users**: 1 (admin/password)
- **Status**: All data verified and operational ✅
## Key Milestones ## Key Milestones
### Completed ✅ ### Completed ✅
1. **December 17, 2025 (5:00 PM)**: Phase 1 complete - Public website functional 1. **December 17, 2025**: Phase 1 complete - Public website functional
2. **December 17, 2025 (5:30 PM)**: Phase 2 complete - Admin dashboard with full CRUD 2. **December 17, 2025**: Phase 2 complete - Admin dashboard with full CRUD
3. **December 17, 2025 (6:00 PM)**: Phase 3 started - Data export and PDF transfer 3. **December 17, 2025**: Phase 3 started - Data export and PDF transfer complete
4. **December 17, 2025 (7:00 PM)**: Phase 3 complete - All data migrated and verified
5. **December 17, 2025 (8:00 PM)**: Phase 4 started - Docker deployment
6. **December 17, 2025 (10:30 PM)**: Phase 4 complete - Production fully operational ✅
### Upcoming ⏳ ### Upcoming ⏳
7. **Future**: Phase 5 - DNS switchover (when user decides) 4. **Next**: Complete Phase 3 - Create seeder and import production data
8. **Optional**: Email notifications, monitoring, backups 5. **Future**: Phase 4 - Production deployment and DNS switch
## Files Created/Modified ## Files Created/Modified
### Controllers (8 files) ### Controllers (7 files)
- `app/Http/Controllers/HomeController.php` - `app/Http/Controllers/HomeController.php`
- `app/Http/Controllers/SubscriptionController.php` - `app/Http/Controllers/SubscriptionController.php`
- `app/Http/Controllers/DocumentController.php` - `app/Http/Controllers/DocumentController.php`
@ -188,33 +156,13 @@
- `database/migrations/2025_12_17_223201_create_subscriptions_table.php` - `database/migrations/2025_12_17_223201_create_subscriptions_table.php`
- `database/migrations/2025_12_17_223224_create_admin_users_table.php` - `database/migrations/2025_12_17_223224_create_admin_users_table.php`
### Seeders (3 files) ### Seeders (2 files)
- `database/seeders/DocketSeeder.php` (test data) - `database/seeders/DocketSeeder.php` (test data)
- `database/seeders/AdminSeeder.php` (admin user) - `database/seeders/AdminSeeder.php` (admin user)
- `database/seeders/V1DataMigrationSeeder.php` (production data)
### Middleware & Routes ### Middleware & Routes
- `app/Http/Middleware/AdminAuth.php` - `app/Http/Middleware/AdminAuth.php`
- `app/Http/Middleware/TrustProxies.php`
- `routes/web.php` - `routes/web.php`
- `bootstrap/app.php`
### Docker & Deployment
- `Dockerfile`
- `docker-compose.yml`
- `docker/nginx.conf`
- `docker/default.conf`
- `docker/supervisord.conf`
- `.env.production`
- `DEPLOYMENT.md`
### Data Migration Scripts
- `parse_sql_to_seeder.py` (v1 - single-line INSERTs)
- `parse_sql_to_seeder_v2.py` (v2 - multi-line INSERTs)
- `update_seeder.py` (v1)
- `update_seeder_v2.py` (v2)
- `check_v1_data.js` (verification script)
- `DATA_COMPARISON_REPORT.md`
## Testing Status ## Testing Status
@ -237,51 +185,45 @@
- [x] Deactivate subscriber - [x] Deactivate subscriber
- [x] Pagination works correctly - [x] Pagination works correctly
### Phase 3 Testing ✅ ### Phase 3 Testing ⏳
- [x] Import all 63 docket entries - [ ] Import all 63 docket entries
- [x] Verify all 63 documents accessible - [ ] Verify all 63 documents accessible
- [x] Confirm 28 subscribers imported - [ ] Confirm 28 subscribers imported
- [x] Test entry-document relationships - [ ] Test entry-document relationships
- [x] Verify timestamps preserved - [ ] Verify timestamps preserved
- [x] Check data integrity - [ ] Check data integrity
### Phase 4 Testing ✅
- [x] Docker container builds successfully
- [x] nginx and PHP-FPM running
- [x] Database connection working
- [x] Migrations run successfully
- [x] Seeder imports all data
- [x] SSL certificate provisioned
- [x] Admin login functional
- [x] All CRUD operations working
- [x] File uploads/downloads working
- [x] Visual design matches v1 exactly
## Known Issues ## Known Issues
### Current Issues ### Current Issues
- None - all features working correctly in production ✅ - None - all implemented features working correctly
### Future Enhancements ### Future Considerations
- Email notification system (SMTP configuration) - Email notification system needs SMTP configuration
- Application monitoring and alerts - PostgreSQL connection for production deployment
- Automated database backups - SSL certificates for v2.mad-lawsuit.org subdomain
- Redis caching for performance
- Analytics tracking
## Next Actions ## Next Actions
### Immediate (Optional) ### Immediate (Phase 3 Completion)
1. **DNS Switchover** - When user decides to replace v1 with v2 1. Create `database/seeders/V1DataMigrationSeeder.php`
2. **Monitoring** - Set up application monitoring 2. Parse SQL INSERT statements from `/tmp/v1-data.sql`
3. **Backups** - Configure automated backups 3. Transform to Laravel Eloquent creates
4. **Email** - Implement SMTP for subscriber notifications 4. Run seeder: `php artisan db:seed --class=V1DataMigrationSeeder`
5. Verify in admin dashboard
### Long-term (Optional) ### Short-term (Phase 4 Preparation)
1. Performance optimization with Redis 1. Configure PostgreSQL connection in `.env`
2. Analytics integration 2. Test database connection
3. Enhanced admin features 3. Implement email notification system
4. Mobile app considerations 4. Prepare deployment scripts
### Long-term (Production Deployment)
1. Deploy to v2.mad-lawsuit.org
2. Final testing on production subdomain
3. Switch DNS from v1.0 to v2.0
4. Monitor for issues
5. Decommission v1.0 after verification
## Success Metrics ## Success Metrics
@ -296,80 +238,24 @@
- File upload/download working: **100%** - File upload/download working: **100%**
- UI/UX professional: **100%** - UI/UX professional: **100%**
### Phase 3 ### Phase 3
- Data export complete: **100%** - Data export complete: **100%**
- PDF transfer complete: **100%** - PDF transfer complete: **100%**
- Seeder creation: **100%** - Seeder creation: **100%**
- Python parser created: **100%** - Python parser created: **100%**
- PHP arrays generated: **100%** - PHP arrays generated: **100%**
- Data import: **100%** - Data import: **0%** (next session)
- Verification: **100%** - Verification: **0%** (next session)
- **Overall Phase 3**: **100%** - **Overall Phase 3**: **95%**
### Phase 4 ✅ ### Phase 4 ⏳
- Docker deployment: **100%** - Not started: **0%**
- Database setup: **100%**
- SSL certificates: **100%**
- Admin login: **100%**
- All features tested: **100%**
- Visual design match: **100%**
- **Overall Phase 4**: **100%**
## Overall Project Progress ## Overall Project Progress
**Phases Complete**: 4 / 5 (80%) **Phases Complete**: 2 / 4 (50%)
**Current Phase**: 5 (Pending user decision for DNS switchover) **Current Phase**: 3 (95% complete)
**Status**: **PRODUCTION READY** **Estimated Completion**: Phase 3 - 20 minutes (next session), Phase 4 - 2-4 hours
--- ---
## 🎉 PROJECT COMPLETE 🎉 *Last Updated: December 17, 2025 at 5:51 PM MST*
The MAD Lawsuit Website v2.0 is **fully operational** and ready for production use!
- **Public Site**: https://v2.mad-lawsuit.org
- **Admin Dashboard**: https://v2.mad-lawsuit.org/admin/login
- **All Data Migrated**: 63 entries, 63 documents, 28 subscribers
- **Visual Design**: Exact match to v1
- **All Features**: Working perfectly
- **SSL**: Active and auto-renewing
- **Performance**: Tested and verified
The website can replace v1.0 at any time with a simple DNS update.
---
*Last Updated: December 18, 2025 at 10:00 AM MST*
*Status: PRODUCTION READY ✅*
## Recent Updates (December 18, 2025)
### Bug Fixes ✅
1. **Mobile PDF Viewing** - Fixed iOS Safari scrolling issue
- Desktop: Opens PDF in modal viewer
- Mobile (< 768px): Opens PDF in new tab for native viewing
- File: `resources/js/Pages/Home.vue`
2. **Admin Subscribers Page** - Fixed blank page error
- Changed pagination from `->through()` to `->items()`
- File: `app/Http/Controllers/Admin/SubscriberController.php`
### Configuration Updates ✅
3. **File Upload Limit Increased** - From 10MB to 500MB
- Laravel validation: `max:512000` (500MB)
- nginx: `client_max_body_size 550M`
- PHP: `upload_max_filesize = 550M`, `post_max_size = 550M`
- Files: `app/Http/Controllers/Admin/DocumentController.php`, `docker/nginx.conf`, `Dockerfile`
### Documentation ✅
4. **README.md** - Comprehensive project documentation added
- Project overview and purpose
- Complete technology stack
- Feature documentation
- Getting started guide
- Project structure
- Security information
- Database schema
- API endpoints
- Known issues and solutions
All changes committed to Git repository (commits: 9ad05833, 0187c2d8, 94fe847c, 4bfe4436)

View file

@ -1,43 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('document_accesses', function (Blueprint $table) {
$table->id();
$table->foreignId('document_id')->constrained()->cascadeOnDelete();
$table->timestamp('accessed_at');
// Null for rows backfilled from the old nginx log: that log recorded
// only the docker gateway address, so the real client IP for anything
// before 2026-07-30 was never captured and cannot be recovered.
$table->string('ip_address', 45)->nullable();
$table->text('user_agent')->nullable();
// 'nginx-backfill' = imported from the archived access log (no IP).
// 'live' = recorded by the app at download time (has IP).
$table->string('source', 20)->default('live');
$table->timestamps();
$table->index(['document_id', 'accessed_at']);
$table->index('accessed_at');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('document_accesses');
}
};

View file

@ -12,17 +12,14 @@ class AdminSeeder extends Seeder
*/ */
public function run(): void public function run(): void
{ {
// Create Eliza admin user // Create default admin user
// Insert directly to avoid any model mutators AdminUser::create([
\DB::table('admin_users')->insert([ 'username' => 'admin',
'username' => 'eliza', 'password' => 'password', // Will be auto-hashed by AdminUser model
'password' => '$2y$12$0ALddIeVIN8UN9.6Des9reVTdu4RpIRgHoQzowPp1wYwjK7.RafhK', // Password: AXEoZWk2AMAD0naw
'created_at' => now(),
'updated_at' => now(),
]); ]);
$this->command->info('Admin user created successfully!'); $this->command->info('Admin user created successfully!');
$this->command->info('Username: eliza'); $this->command->info('Username: admin');
$this->command->info('Password: (same as v1.0)'); $this->command->info('Password: password');
} }
} }

View file

@ -15,10 +15,11 @@ class DatabaseSeeder extends Seeder
*/ */
public function run(): void public function run(): void
{ {
// Migration complete - no automatic seeding // User::factory(10)->create();
// Data is already in production database
// Only seed manually if needed: User::factory()->create([
// php artisan db:seed --class=AdminSeeder 'name' => 'Test User',
// php artisan db:seed --class=V1DataMigrationSeeder 'email' => 'test@example.com',
]);
} }
} }

View file

@ -25,7 +25,10 @@ class V1DataMigrationSeeder extends Seeder
*/ */
public function run(): void public function run(): void
{ {
// Clear existing data (foreign keys handled by database) // Disable foreign key checks for clean import
DB::statement('PRAGMA foreign_keys = OFF');
// Clear existing data
Document::truncate(); Document::truncate();
DocketEntry::truncate(); DocketEntry::truncate();
Subscription::truncate(); Subscription::truncate();
@ -39,8 +42,8 @@ class V1DataMigrationSeeder extends Seeder
// Import subscriptions // Import subscriptions
$this->importSubscriptions(); $this->importSubscriptions();
// Fix PostgreSQL sequences after importing data with specific IDs // Re-enable foreign key checks
$this->fixSequences(); DB::statement('PRAGMA foreign_keys = ON');
$this->command->info('✅ v1.0 data migration complete!'); $this->command->info('✅ v1.0 data migration complete!');
$this->command->info(' - Docket Entries: ' . DocketEntry::count()); $this->command->info(' - Docket Entries: ' . DocketEntry::count());
@ -48,23 +51,6 @@ class V1DataMigrationSeeder extends Seeder
$this->command->info(' - Subscriptions: ' . Subscription::count()); $this->command->info(' - Subscriptions: ' . Subscription::count());
} }
/**
* Fix PostgreSQL sequences after importing data with specific IDs.
* This prevents "duplicate key value violates unique constraint" errors.
*/
private function fixSequences(): void
{
$tables = ['docket_entries', 'documents', 'subscriptions'];
foreach ($tables as $table) {
$maxId = DB::table($table)->max('id');
if ($maxId) {
DB::statement("SELECT setval('{$table}_id_seq', " . ($maxId + 1) . ", false)");
$this->command->info(" - Fixed {$table} sequence (set to " . ($maxId + 1) . ")");
}
}
}
private function importDocketEntries(): void private function importDocketEntries(): void
{ {
$entries = [ $entries = [

View file

@ -8,8 +8,9 @@ services:
container_name: mad-lawsuit-v2 container_name: mad-lawsuit-v2
restart: unless-stopped restart: unless-stopped
ports: ports:
- "806:80" - "8080:80"
volumes: volumes:
- ./storage:/var/www/html/storage
- ./storage/app/public/documents:/var/www/html/storage/app/public/documents - ./storage/app/public/documents:/var/www/html/storage/app/public/documents
environment: environment:
- APP_ENV=production - APP_ENV=production

View file

@ -22,7 +22,7 @@ http {
tcp_nodelay on; tcp_nodelay on;
keepalive_timeout 65; keepalive_timeout 65;
types_hash_max_size 2048; types_hash_max_size 2048;
client_max_body_size 550M; client_max_body_size 20M;
gzip on; gzip on;
gzip_vary on; gzip_vary on;

View file

@ -5,7 +5,7 @@ logfile=/var/log/supervisor/supervisord.log
pidfile=/var/run/supervisord.pid pidfile=/var/run/supervisord.pid
[program:php-fpm] [program:php-fpm]
command=php-fpm -F command=php-fpm8.3 -F
stdout_logfile=/dev/stdout stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0 stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr stderr_logfile=/dev/stderr

View file

@ -1,27 +0,0 @@
<?php
// Fix PostgreSQL sequences after seeding
// Run with: php artisan tinker < fix_sequences.php
use Illuminate\Support\Facades\DB;
echo "Fixing PostgreSQL sequences...\n";
// Fix docket_entries sequence
$result = DB::statement("SELECT setval('docket_entries_id_seq', (SELECT MAX(id) FROM docket_entries));");
echo "✓ Fixed docket_entries_id_seq\n";
// Fix documents sequence
$result = DB::statement("SELECT setval('documents_id_seq', (SELECT MAX(id) FROM documents));");
echo "✓ Fixed documents_id_seq\n";
// Fix subscriptions sequence
$result = DB::statement("SELECT setval('subscriptions_id_seq', (SELECT MAX(id) FROM subscriptions));");
echo "✓ Fixed subscriptions_id_seq\n";
// Fix admin_users sequence
$result = DB::statement("SELECT setval('admin_users_id_seq', (SELECT MAX(id) FROM admin_users));");
echo "✓ Fixed admin_users_id_seq\n";
echo "\nAll sequences fixed successfully!\n";
echo "You can now create new docket entries.\n";

View file

@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { watch, ref } from 'vue'; import { ref, watch, onMounted, onUnmounted } from 'vue';
interface Props { interface Props {
isOpen: boolean; isOpen: boolean;
@ -12,11 +12,33 @@ const emit = defineEmits<{
close: []; close: [];
}>(); }>();
const copied = ref(false); const isMobile = ref(false);
// Check if device is mobile/tablet
const checkMobile = () => {
isMobile.value = window.innerWidth < 1024; // Less than 1024px = mobile/tablet
};
onMounted(() => {
checkMobile();
window.addEventListener('resize', checkMobile);
});
onUnmounted(() => {
window.removeEventListener('resize', checkMobile);
});
// Handle body scroll lock when modal is open
watch(() => props.isOpen, (isOpen) => { watch(() => props.isOpen, (isOpen) => {
if (isOpen) { if (isOpen) {
document.body.style.overflow = 'hidden'; document.body.style.overflow = 'hidden';
// On mobile, open PDF directly in new tab
if (isMobile.value) {
window.open(props.documentUrl, '_blank');
// Close modal immediately on mobile
emit('close');
}
} else { } else {
document.body.style.overflow = 'unset'; document.body.style.overflow = 'unset';
} }
@ -31,18 +53,12 @@ const handleBackdropClick = (e: MouseEvent) => {
const handleDownload = () => { const handleDownload = () => {
window.open(props.documentUrl, '_blank'); window.open(props.documentUrl, '_blank');
}; };
const handleCopyLink = async () => {
const url = new URL(props.documentUrl, window.location.origin).href;
await navigator.clipboard.writeText(url);
copied.value = true;
setTimeout(() => { copied.value = false; }, 2000);
};
</script> </script>
<template> <template>
<!-- Only show modal on desktop (>= 1024px) -->
<div <div
v-if="isOpen" v-if="isOpen && !isMobile"
class="fixed inset-0 bg-black/75 flex items-center justify-center z-50 p-4" class="fixed inset-0 bg-black/75 flex items-center justify-center z-50 p-4"
@click="handleBackdropClick" @click="handleBackdropClick"
> >
@ -61,18 +77,6 @@ const handleCopyLink = async () => {
</h2> </h2>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<button
@click="handleCopyLink"
class="flex items-center gap-2 px-4 py-2 bg-[#7CAE7A] text-white rounded-md hover:bg-[#6b9c69] transition-colors text-sm font-medium"
>
<svg v-if="!copied" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
</svg>
<svg v-else class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
{{ copied ? '✓ Copied!' : 'Copy Link' }}
</button>
<button <button
@click="handleDownload" @click="handleDownload"
class="flex items-center gap-2 px-4 py-2 bg-[#7CAE7A] text-white rounded-md hover:bg-[#6b9c69] transition-colors text-sm font-medium" class="flex items-center gap-2 px-4 py-2 bg-[#7CAE7A] text-white rounded-md hover:bg-[#6b9c69] transition-colors text-sm font-medium"

View file

@ -10,19 +10,11 @@ interface DocketEntry {
documents_count: number; documents_count: number;
} }
interface TopDocument {
id: number;
title: string;
downloads: number;
}
interface Props { interface Props {
total_entries: number; total_entries: number;
total_documents: number; total_documents: number;
total_subscribers: number; total_subscribers: number;
recent_entries: DocketEntry[]; recent_entries: DocketEntry[];
downloads_60d: number;
top_documents_60d: TopDocument[];
} }
const props = defineProps<Props>(); const props = defineProps<Props>();
@ -69,7 +61,7 @@ const logout = () => {
<!-- Main Content --> <!-- Main Content -->
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> <main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Statistics Cards --> <!-- Statistics Cards -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8"> <div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<!-- Total Entries --> <!-- Total Entries -->
<div class="bg-white rounded-lg shadow p-6"> <div class="bg-white rounded-lg shadow p-6">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
@ -120,58 +112,6 @@ const logout = () => {
</div> </div>
</div> </div>
</div> </div>
<!-- Document Downloads (60 days) -->
<Link
:href="route('admin.document-access.index')"
class="bg-white rounded-lg shadow p-6 hover:shadow-md transition-shadow"
>
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-gray-600">Downloads (60 days)</p>
<p class="text-3xl font-bold text-gray-900 mt-2">
{{ props.downloads_60d }}
</p>
<p class="text-xs text-gray-500 mt-1">Excludes crawlers</p>
</div>
<div class="p-3 bg-amber-100 rounded-full">
<svg class="w-8 h-8 text-amber-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
</div>
</div>
</Link>
</div>
<!-- Most Requested Documents -->
<div class="bg-white rounded-lg shadow mb-8">
<div class="px-6 py-4 border-b border-gray-200 flex justify-between items-center">
<div>
<h2 class="text-xl font-bold text-gray-900">Most Requested Documents</h2>
<p class="text-sm text-gray-600 mt-1">Last 60 days</p>
</div>
<Link
:href="route('admin.document-access.index')"
class="px-4 py-2 text-sm text-blue-600 hover:text-blue-800 transition-colors"
>
View all &amp; change dates &rarr;
</Link>
</div>
<div class="divide-y divide-gray-200">
<div
v-for="doc in props.top_documents_60d"
:key="doc.id"
class="px-6 py-4 flex justify-between items-center hover:bg-gray-50 transition-colors"
>
<span class="text-sm font-medium text-gray-900">{{ doc.title }}</span>
<span class="px-3 py-1 text-xs font-medium text-amber-700 bg-amber-100 rounded-full whitespace-nowrap ml-4">
{{ doc.downloads }} download{{ doc.downloads !== 1 ? 's' : '' }}
</span>
</div>
<div v-if="props.top_documents_60d.length === 0" class="px-6 py-8 text-center text-gray-500">
No document downloads recorded in the last 60 days.
</div>
</div>
</div> </div>
<!-- Quick Actions --> <!-- Quick Actions -->

View file

@ -10,8 +10,16 @@ interface DocketEntry {
documents_count: number; documents_count: number;
} }
interface Pagination {
current_page: number;
last_page: number;
per_page: number;
total: number;
}
interface Props { interface Props {
entries: DocketEntry[]; entries: DocketEntry[];
pagination: Pagination;
} }
const props = defineProps<Props>(); const props = defineProps<Props>();
@ -36,7 +44,7 @@ const deleteEntry = (id: number, title: string) => {
Manage Docket Entries Manage Docket Entries
</h1> </h1>
<p class="text-sm text-gray-600 mt-1"> <p class="text-sm text-gray-600 mt-1">
{{ props.entries.length }} total entries {{ props.pagination.total }} total entries
</p> </p>
</div> </div>
<div class="flex gap-4"> <div class="flex gap-4">
@ -135,6 +143,31 @@ const deleteEntry = (id: number, title: string) => {
</tbody> </tbody>
</table> </table>
</div> </div>
<!-- Pagination -->
<div v-if="props.pagination.last_page > 1" class="px-6 py-4 border-t border-gray-200">
<div class="flex items-center justify-between">
<div class="text-sm text-gray-700">
Showing page {{ props.pagination.current_page }} of {{ props.pagination.last_page }}
</div>
<div class="flex gap-2">
<Link
v-if="props.pagination.current_page > 1"
:href="route('admin.docket-entries.index', { page: props.pagination.current_page - 1 })"
class="px-3 py-1 text-sm bg-white border border-gray-300 rounded-md hover:bg-gray-50 transition-colors"
>
Previous
</Link>
<Link
v-if="props.pagination.current_page < props.pagination.last_page"
:href="route('admin.docket-entries.index', { page: props.pagination.current_page + 1 })"
class="px-3 py-1 text-sm bg-white border border-gray-300 rounded-md hover:bg-gray-50 transition-colors"
>
Next
</Link>
</div>
</div>
</div>
</div> </div>
</main> </main>
</div> </div>

View file

@ -69,12 +69,6 @@ const deleteEntry = () => {
} }
}; };
const sendNotification = () => {
if (confirm('Send email notification to all subscribers about this docket entry?')) {
router.post(route('admin.docket-entries.send-notification', props.entry.id));
}
};
const formatFileSize = (bytes: number): string => { const formatFileSize = (bytes: number): string => {
if (bytes < 1024) return bytes + ' B'; if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
@ -235,20 +229,6 @@ const formatFileSize = (bytes: number): string => {
</form> </form>
</div> </div>
<!-- Send Notification -->
<div class="bg-blue-50 border border-blue-200 rounded-lg p-6">
<h2 class="text-lg font-bold text-blue-900 mb-2">Email Notification</h2>
<p class="text-sm text-blue-700 mb-4">
Send email notification to all active subscribers about this docket entry.
</p>
<PrimaryButton
@click="sendNotification"
class="w-full justify-center bg-blue-600 hover:bg-blue-700"
>
Send Notification
</PrimaryButton>
</div>
<!-- Danger Zone --> <!-- Danger Zone -->
<div class="bg-red-50 border border-red-200 rounded-lg p-6"> <div class="bg-red-50 border border-red-200 rounded-lg p-6">
<h2 class="text-lg font-bold text-red-900 mb-2">Danger Zone</h2> <h2 class="text-lg font-bold text-red-900 mb-2">Danger Zone</h2>

View file

@ -1,231 +0,0 @@
<script setup lang="ts">
import { Head, Link, router } from '@inertiajs/vue3';
import { computed, ref } from 'vue';
interface DocumentRow {
id: number;
title: string;
downloads: number;
unique_visitors: number;
last_accessed: string;
}
interface DailyRow {
day: string;
downloads: number;
}
interface Props {
documents: DocumentRow[];
daily: DailyRow[];
filters: { from: string; to: string; include_bots: boolean };
totals: { downloads: number; documents_touched: number; unique_visitors: number };
ip_data_starts: string;
range_predates_ip_data: boolean;
}
const props = defineProps<Props>();
const from = ref(props.filters.from);
const to = ref(props.filters.to);
const includeBots = ref(props.filters.include_bots);
const apply = () => {
router.get(
route('admin.document-access.index'),
{ from: from.value, to: to.value, include_bots: includeBots.value },
{ preserveState: true, preserveScroll: true },
);
};
const preset = (days: number) => {
const end = new Date();
const start = new Date();
start.setDate(start.getDate() - days);
from.value = start.toISOString().slice(0, 10);
to.value = end.toISOString().slice(0, 10);
apply();
};
// Scale the inline bar chart to the busiest day in range.
const peak = computed(() => Math.max(1, ...props.daily.map((d) => d.downloads)));
// Keep the CSV in step with whatever range is currently on screen.
const exportUrl = computed(() => {
const params = new URLSearchParams({
from: from.value,
to: to.value,
include_bots: includeBots.value ? '1' : '0',
});
return `${route('admin.document-access.export')}?${params.toString()}`;
});
</script>
<template>
<div class="min-h-screen bg-gray-100">
<Head title="Document Access" />
<header class="bg-white shadow">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div class="flex justify-between items-center">
<div>
<h1 class="text-3xl font-bold text-gray-900">Document Access</h1>
<p class="text-sm text-gray-600 mt-1">
How often each document has been downloaded
</p>
</div>
<Link
:href="route('admin.dashboard')"
class="px-4 py-2 text-sm text-gray-700 hover:text-gray-900 transition-colors"
>
&larr; Back to Dashboard
</Link>
</div>
</div>
</header>
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Filters -->
<div class="bg-white rounded-lg shadow p-6 mb-6">
<div class="flex flex-wrap items-end gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">From</label>
<input
v-model="from"
type="date"
class="border-gray-300 rounded-lg shadow-sm text-sm"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">To</label>
<input
v-model="to"
type="date"
class="border-gray-300 rounded-lg shadow-sm text-sm"
/>
</div>
<button
@click="apply"
class="px-4 py-2 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 transition-colors"
>
Apply
</button>
<div class="flex gap-2">
<button
v-for="p in [30, 60, 90]"
:key="p"
@click="preset(p)"
class="px-3 py-2 bg-gray-100 text-gray-700 text-sm rounded-lg hover:bg-gray-200 transition-colors"
>
Last {{ p }}d
</button>
</div>
<label class="flex items-center gap-2 ml-auto text-sm text-gray-700">
<input
v-model="includeBots"
type="checkbox"
@change="apply"
class="rounded border-gray-300"
/>
Include crawlers
</label>
<a
:href="exportUrl"
class="px-4 py-2 bg-green-600 text-white text-sm rounded-lg hover:bg-green-700 transition-colors"
>
Export CSV
</a>
</div>
</div>
<!-- Summary -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
<div class="bg-white rounded-lg shadow p-6">
<p class="text-sm font-medium text-gray-600">Total downloads</p>
<p class="text-3xl font-bold text-gray-900 mt-2">{{ props.totals.downloads }}</p>
</div>
<div class="bg-white rounded-lg shadow p-6">
<p class="text-sm font-medium text-gray-600">Documents downloaded</p>
<p class="text-3xl font-bold text-gray-900 mt-2">{{ props.totals.documents_touched }}</p>
</div>
<div class="bg-white rounded-lg shadow p-6">
<p class="text-sm font-medium text-gray-600">Unique visitors</p>
<p class="text-3xl font-bold text-gray-900 mt-2">
{{ props.totals.unique_visitors === 0 ? '—' : props.totals.unique_visitors }}
</p>
<p v-if="props.range_predates_ip_data" class="text-xs text-gray-500 mt-1">
Only counted from {{ props.ip_data_starts }}
</p>
</div>
</div>
<!-- Caveat: unique visitors are not knowable before IP logging began -->
<div
v-if="props.range_predates_ip_data"
class="bg-amber-50 border border-amber-200 rounded-lg p-4 mb-6"
>
<p class="text-sm text-amber-900">
<span class="font-semibold">Downloads, not unique visitors.</span>
Visitor IP addresses were only recorded from
{{ props.ip_data_starts }} onward. For any earlier period the same
person downloading a document ten times counts as ten downloads, and
the unique-visitor column will read 0.
</p>
</div>
<!-- Daily trend -->
<div v-if="props.daily.length > 0" class="bg-white rounded-lg shadow p-6 mb-6">
<h2 class="text-xl font-bold text-gray-900 mb-4">Downloads per day</h2>
<div class="flex items-end gap-1 h-40 overflow-x-auto">
<div
v-for="d in props.daily"
:key="d.day"
class="flex-1 min-w-[8px] bg-blue-500 hover:bg-blue-600 rounded-t transition-colors"
:style="{ height: (d.downloads / peak) * 100 + '%' }"
:title="`${d.day}: ${d.downloads} download${d.downloads !== 1 ? 's' : ''}`"
/>
</div>
<div class="flex justify-between text-xs text-gray-500 mt-2">
<span>{{ props.daily[0]?.day }}</span>
<span>peak {{ peak }}/day</span>
<span>{{ props.daily[props.daily.length - 1]?.day }}</span>
</div>
</div>
<!-- Per-document table -->
<div class="bg-white rounded-lg shadow overflow-hidden">
<div class="px-6 py-4 border-b border-gray-200">
<h2 class="text-xl font-bold text-gray-900">By document</h2>
</div>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Document</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Downloads</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Unique visitors</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Last accessed</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<tr v-for="doc in props.documents" :key="doc.id" class="hover:bg-gray-50">
<td class="px-6 py-4 text-sm font-medium text-gray-900">{{ doc.title }}</td>
<td class="px-6 py-4 text-sm text-gray-900 text-right">{{ doc.downloads }}</td>
<td class="px-6 py-4 text-sm text-right" :class="doc.unique_visitors === 0 ? 'text-gray-400' : 'text-gray-900'">
{{ doc.unique_visitors === 0 ? '—' : doc.unique_visitors }}
</td>
<td class="px-6 py-4 text-sm text-gray-600 text-right">{{ doc.last_accessed }}</td>
</tr>
<tr v-if="props.documents.length === 0">
<td colspan="4" class="px-6 py-8 text-center text-gray-500">
No downloads recorded in this date range.
</td>
</tr>
</tbody>
</table>
</div>
</div>
</main>
</div>
</template>

View file

@ -159,7 +159,7 @@ const deactivateSubscriber = (id: number, email: string) => {
</span> </span>
</td> </td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-600"> <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-600">
{{ new Date(subscriber.created_at).toLocaleDateString('en-US', { month: '2-digit', day: '2-digit', year: 'numeric' }) }} {{ subscriber.created_at }}
</td> </td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium"> <td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<button <button

View file

@ -42,24 +42,10 @@ const toggleEntry = (id: number) => {
const openDocument = (doc: Document, event: MouseEvent) => { const openDocument = (doc: Document, event: MouseEvent) => {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
const documentUrl = `/api/documents/${doc.id}/download`;
// Detect iOS devices (iPhone, iPad, iPod)
// Modern iPads report as MacIntel, so check for touch points
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) ||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
// On mobile (< 768px) or iOS devices, open PDF directly in new tab
// On desktop (non-iOS), use modal viewer
if (window.innerWidth < 768 || isIOS) {
window.open(documentUrl, '_blank');
} else {
selectedDocument.value = { selectedDocument.value = {
title: doc.title, title: doc.title,
url: documentUrl url: `/api/documents/${doc.id}/download`
}; };
}
}; };
const closeViewer = () => { const closeViewer = () => {
@ -106,9 +92,9 @@ const subscribe = async () => {
<template> <template>
<Head title="Court Docket - Elizabeth Kragh v. Montana Association of the Deaf" /> <Head title="Court Docket - Elizabeth Kragh v. Montana Association of the Deaf" />
<div class="min-h-screen" style="background-color: #6E6362;"> <div class="min-h-screen" style="background-color: #4a5568;">
<!-- Hero Section --> <!-- Hero Section -->
<div class="py-16 px-4" style="background-color: rgba(57, 64, 83, 0.95);"> <div class="py-16 px-4" style="background: linear-gradient(135deg, #3f4a5c 0%, #4a5568 100%);">
<div class="max-w-4xl mx-auto text-center"> <div class="max-w-4xl mx-auto text-center">
<h1 class="text-[2.5rem] font-bold text-white mb-2 leading-tight"> <h1 class="text-[2.5rem] font-bold text-white mb-2 leading-tight">
ELIZABETH KRAGH<br /> ELIZABETH KRAGH<br />
@ -131,8 +117,8 @@ const subscribe = async () => {
@click="subscribe" @click="subscribe"
class="px-6 py-3 rounded-lg font-semibold text-white transition-colors" class="px-6 py-3 rounded-lg font-semibold text-white transition-colors"
style="background-color: #6b9080;" style="background-color: #6b9080;"
@mouseover="($event.target as HTMLElement).style.backgroundColor = '#5a8070'" @mouseover="$event.target.style.backgroundColor = '#5a8070'"
@mouseout="($event.target as HTMLElement).style.backgroundColor = '#6b9080'" @mouseout="$event.target.style.backgroundColor = '#6b9080'"
> >
Subscribe to Updates Subscribe to Updates
</button> </button>
@ -213,8 +199,8 @@ const subscribe = async () => {
@click="openDocument(doc, $event)" @click="openDocument(doc, $event)"
class="inline-block px-4 py-2 rounded-lg text-white font-semibold mr-2 mb-2 transition-colors" class="inline-block px-4 py-2 rounded-lg text-white font-semibold mr-2 mb-2 transition-colors"
style="background-color: #6b9080;" style="background-color: #6b9080;"
@mouseover="($event.target as HTMLElement).style.backgroundColor = '#5a8070'" @mouseover="$event.target.style.backgroundColor = '#5a8070'"
@mouseout="($event.target as HTMLElement).style.backgroundColor = '#6b9080'" @mouseout="$event.target.style.backgroundColor = '#6b9080'"
> >
📄 {{ doc.title }} 📄 {{ doc.title }}
</button> </button>

View file

@ -1,153 +0,0 @@
<script setup lang="ts">
import { Head, router } from '@inertiajs/vue3';
import { ref } from 'vue';
interface Props {
token: string;
email?: string;
}
const props = defineProps<Props>();
const isProcessing = ref(false);
const message = ref('');
const isSuccess = ref(false);
const confirmUnsubscribe = async () => {
isProcessing.value = true;
try {
const response = await fetch(`/api/unsubscribe/${props.token}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': (document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement)?.content || '',
},
});
const data = await response.json();
if (data.success) {
isSuccess.value = true;
message.value = data.message;
} else {
isSuccess.value = false;
message.value = data.message;
}
} catch (error) {
isSuccess.value = false;
message.value = 'An error occurred. Please try again.';
} finally {
isProcessing.value = false;
}
};
const goHome = () => {
window.location.href = '/';
};
</script>
<template>
<Head title="Unsubscribe - Court Docket Notifications" />
<div class="min-h-screen flex items-center justify-center px-4" style="background-color: #6E6362;">
<div class="max-w-md w-full">
<!-- Unsubscribe Card -->
<div class="bg-white rounded-lg shadow-lg p-8">
<div v-if="!message" class="text-center">
<!-- Icon -->
<div class="mx-auto w-16 h-16 rounded-full flex items-center justify-center mb-4"
style="background-color: #FEF3C7;">
<svg class="w-8 h-8 text-yellow-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<h1 class="text-2xl font-bold text-gray-900 mb-2">
Unsubscribe from Notifications?
</h1>
<p class="text-gray-600 mb-6">
Are you sure you want to unsubscribe from email notifications for court docket updates?
</p>
<p v-if="email" class="text-sm text-gray-500 mb-6">
Email: <span class="font-semibold">{{ email }}</span>
</p>
<!-- Action Buttons -->
<div class="space-y-3">
<button
@click="confirmUnsubscribe"
:disabled="isProcessing"
class="w-full px-6 py-3 rounded-lg font-semibold text-white transition-colors"
:class="isProcessing ? 'opacity-50 cursor-not-allowed' : ''"
style="background-color: #EF4444;"
@mouseover="!isProcessing && (($event.target as HTMLElement).style.backgroundColor = '#DC2626')"
@mouseout="!isProcessing && (($event.target as HTMLElement).style.backgroundColor = '#EF4444')"
>
{{ isProcessing ? 'Processing...' : 'Yes, Unsubscribe' }}
</button>
<button
@click="goHome"
:disabled="isProcessing"
class="w-full px-6 py-3 rounded-lg font-semibold transition-colors"
:class="isProcessing ? 'opacity-50 cursor-not-allowed' : ''"
style="background-color: #6b9080; color: white;"
@mouseover="!isProcessing && (($event.target as HTMLElement).style.backgroundColor = '#5a8070')"
@mouseout="!isProcessing && (($event.target as HTMLElement).style.backgroundColor = '#6b9080')"
>
Cancel
</button>
</div>
</div>
<!-- Success/Error Message -->
<div v-else class="text-center">
<!-- Success Icon -->
<div v-if="isSuccess" class="mx-auto w-16 h-16 rounded-full flex items-center justify-center mb-4"
style="background-color: #D1FAE5;">
<svg class="w-8 h-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
</div>
<!-- Error Icon -->
<div v-else class="mx-auto w-16 h-16 rounded-full flex items-center justify-center mb-4"
style="background-color: #FEE2E2;">
<svg class="w-8 h-8 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
<h2 class="text-2xl font-bold text-gray-900 mb-2">
{{ isSuccess ? 'Unsubscribed Successfully' : 'Error' }}
</h2>
<p class="text-gray-600 mb-6">
{{ message }}
</p>
<button
@click="goHome"
class="w-full px-6 py-3 rounded-lg font-semibold text-white transition-colors"
style="background-color: #6b9080;"
@mouseover="($event.target as HTMLElement).style.backgroundColor = '#5a8070'"
@mouseout="($event.target as HTMLElement).style.backgroundColor = '#6b9080'"
>
Return to Home
</button>
</div>
</div>
<!-- Footer -->
<div class="text-center mt-6">
<p class="text-sm text-gray-300">
You can resubscribe anytime from the homepage
</p>
</div>
</div>
</div>
</template>

View file

@ -1,153 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>New Court Filing Notification</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
line-height: 1.6;
color: #333;
margin: 0;
padding: 0;
background-color: #f4f4f4;
}
.email-container {
max-width: 600px;
margin: 20px auto;
background-color: #ffffff;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.header {
background: linear-gradient(135deg, #1f2937 0%, #374151 100%);
color: white;
padding: 30px 20px;
text-align: center;
}
.header h1 {
margin: 0;
font-size: 24px;
font-weight: 600;
}
.header p {
margin: 5px 0 0 0;
font-size: 14px;
opacity: 0.9;
}
.content {
padding: 30px 20px;
}
.filing-info {
background-color: #f9fafb;
border-left: 4px solid #3b82f6;
padding: 20px;
margin: 20px 0;
border-radius: 4px;
}
.filing-info .label {
font-weight: 600;
color: #374151;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 5px;
}
.filing-info .title {
font-size: 20px;
font-weight: 600;
color: #1f2937;
margin-bottom: 10px;
}
.filing-info .date {
color: #6b7280;
font-size: 14px;
margin-bottom: 15px;
}
.filing-info .summary {
color: #4b5563;
font-size: 15px;
line-height: 1.6;
}
.cta-button {
display: inline-block;
background-color: #3b82f6;
color: white;
text-decoration: none;
padding: 12px 30px;
border-radius: 6px;
font-weight: 600;
margin: 20px 0;
transition: background-color 0.3s;
}
.cta-button:hover {
background-color: #2563eb;
}
.footer {
background-color: #f9fafb;
padding: 20px;
text-align: center;
font-size: 12px;
color: #6b7280;
border-top: 1px solid #e5e7eb;
}
.footer a {
color: #3b82f6;
text-decoration: none;
}
.footer a:hover {
text-decoration: underline;
}
.divider {
height: 1px;
background-color: #e5e7eb;
margin: 20px 0;
}
</style>
</head>
<body>
<div class="email-container">
<div class="header">
<h1>New Court Filing</h1>
<p>Eliza Kragh v. Montana Association of the Deaf</p>
</div>
<div class="content">
<p>A new document has been filed in the case:</p>
<div class="filing-info">
<div class="label">Filing Date</div>
<div class="date">{{ $entry->date->format('F j, Y') }}</div>
<div class="label">Title</div>
<div class="title">{{ $entry->title }}</div>
<div class="label">Summary</div>
<div class="summary">{{ $entry->summary }}</div>
</div>
<center>
<a href="{{ config('app.url') }}" class="cta-button">View Full Docket</a>
</center>
<div class="divider"></div>
<p style="font-size: 14px; color: #6b7280;">
You are receiving this email because you subscribed to notifications for new court filings in this case.
</p>
</div>
<div class="footer">
<p>
<strong>Eliza Kragh v. Montana Association of the Deaf</strong><br>
Court Docket Notification System
</p>
<p style="margin-top: 10px;">
<a href="{{ url('/unsubscribe/' . $unsubscribeToken) }}">Unsubscribe from notifications</a>
</p>
</div>
</div>
</body>
</html>

View file

@ -13,8 +13,7 @@ Route::get('/', [HomeController::class, 'index'])->name('home');
// Subscription API routes // Subscription API routes
Route::post('/api/subscribe', [SubscriptionController::class, 'subscribe'])->name('api.subscribe'); Route::post('/api/subscribe', [SubscriptionController::class, 'subscribe'])->name('api.subscribe');
Route::get('/unsubscribe/{token}', [SubscriptionController::class, 'showUnsubscribe'])->name('unsubscribe'); Route::get('/api/unsubscribe/{token}', [SubscriptionController::class, 'unsubscribe'])->name('api.unsubscribe');
Route::post('/api/unsubscribe/{token}', [SubscriptionController::class, 'unsubscribe'])->name('api.unsubscribe');
// Document download route // Document download route
Route::get('/api/documents/{id}/download', [DocumentController::class, 'download'])->name('api.documents.download'); Route::get('/api/documents/{id}/download', [DocumentController::class, 'download'])->name('api.documents.download');
@ -32,16 +31,11 @@ Route::prefix('admin')->name('admin.')->group(function () {
// Docket entry routes // Docket entry routes
Route::resource('docket-entries', \App\Http\Controllers\Admin\DocketEntryController::class); Route::resource('docket-entries', \App\Http\Controllers\Admin\DocketEntryController::class);
Route::post('/docket-entries/{docketEntry}/send-notification', [\App\Http\Controllers\Admin\DocketEntryController::class, 'sendNotification'])->name('docket-entries.send-notification');
// Document routes // Document routes
Route::post('/docket-entries/{docketEntry}/documents', [\App\Http\Controllers\Admin\DocumentController::class, 'store'])->name('documents.store'); Route::post('/docket-entries/{docketEntry}/documents', [\App\Http\Controllers\Admin\DocumentController::class, 'store'])->name('documents.store');
Route::delete('/documents/{document}', [\App\Http\Controllers\Admin\DocumentController::class, 'destroy'])->name('documents.destroy'); Route::delete('/documents/{document}', [\App\Http\Controllers\Admin\DocumentController::class, 'destroy'])->name('documents.destroy');
// Document access statistics
Route::get('/document-access', [\App\Http\Controllers\Admin\DocumentAccessController::class, 'index'])->name('document-access.index');
Route::get('/document-access/export', [\App\Http\Controllers\Admin\DocumentAccessController::class, 'export'])->name('document-access.export');
// Subscriber routes // Subscriber routes
Route::get('/subscribers', [\App\Http\Controllers\Admin\SubscriberController::class, 'index'])->name('subscribers.index'); Route::get('/subscribers', [\App\Http\Controllers\Admin\SubscriberController::class, 'index'])->name('subscribers.index');
Route::delete('/subscribers/{subscription}', [\App\Http\Controllers\Admin\SubscriberController::class, 'destroy'])->name('subscribers.destroy'); Route::delete('/subscribers/{subscription}', [\App\Http\Controllers\Admin\SubscriberController::class, 'destroy'])->name('subscribers.destroy');