Compare commits
51 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
041fb91182 | ||
|
|
04bd71aaaa | ||
| 13e7d23e0a | |||
| 8bc2a45e39 | |||
| b85187c92b | |||
| ecb2322ac6 | |||
| 81aaa22875 | |||
| d15a2fcb30 | |||
| 10b3f92cdd | |||
| 8243ee1a92 | |||
| 159ce11eb8 | |||
| 400678f4af | |||
| b48b51ab6e | |||
| 1f3bf22553 | |||
| e2930c6ce9 | |||
| 474df2aa61 | |||
| 80b5adf3f4 | |||
| a81f26ce88 | |||
| b2b061b57a | |||
| 6f66e0987a | |||
| 2f9670caf9 | |||
| 9687b9b9be | |||
| 2393f50b25 | |||
| 4bfe443633 | |||
| 94fe847c22 | |||
| 0187c2d822 | |||
| 9ad058339a | |||
| 7d792fe635 | |||
| 0273caca03 | |||
| 73b0e7ae9e | |||
| 7b409eba81 | |||
| d2d60f6e8b | |||
| 7258b35dda | |||
| 047f81b73f | |||
| 1b63d00560 | |||
| 539df42dc5 | |||
| a3c1ffd3b3 | |||
| f2183392e2 | |||
| ed43ef5622 | |||
| d36aa2a47d | |||
| d104bf5471 | |||
| f203bb03eb | |||
| 5d9997d3a6 | |||
| 9772e4cb83 | |||
| 9492a3b08f | |||
| d7517442d6 | |||
| 55a5165d20 | |||
| 1b8679965e | |||
| 790fe8a48a | |||
| 7583712730 | |||
| 90550d34b7 |
41 changed files with 2791 additions and 445 deletions
|
|
@ -3,10 +3,9 @@
|
|||
.gitignore
|
||||
.gitattributes
|
||||
|
||||
# Environment files (will be SCP'd separately)
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
# Environment files
|
||||
.env.backup
|
||||
.env.example
|
||||
|
||||
# Node modules (will be installed in container)
|
||||
node_modules/
|
||||
|
|
|
|||
46
.env
46
.env
|
|
@ -1,8 +1,9 @@
|
|||
APP_NAME=Laravel
|
||||
APP_ENV=local
|
||||
APP_KEY=base64:FabRZZvfMqwKRSPj2KYehBzYNrYI+hX247oLw58LRVs=
|
||||
APP_DEBUG=true
|
||||
APP_URL=http://localhost
|
||||
APP_NAME="MAD Lawsuit v2.0"
|
||||
APP_ENV=production
|
||||
APP_KEY=base64:dUzoSQvI3yjuaq3Sj6XMMpHw/1v/zPFRzwO6WzeADaQ=
|
||||
APP_DEBUG=false
|
||||
APP_TIMEZONE=UTC
|
||||
APP_URL=https://mad-lawsuit.org
|
||||
|
||||
APP_LOCALE=en
|
||||
APP_FALLBACK_LOCALE=en
|
||||
|
|
@ -11,21 +12,21 @@ APP_FAKER_LOCALE=en_US
|
|||
APP_MAINTENANCE_DRIVER=file
|
||||
# APP_MAINTENANCE_STORE=database
|
||||
|
||||
# PHP_CLI_SERVER_WORKERS=4
|
||||
PHP_CLI_SERVER_WORKERS=4
|
||||
|
||||
BCRYPT_ROUNDS=12
|
||||
|
||||
LOG_CHANNEL=stack
|
||||
LOG_STACK=single
|
||||
LOG_DEPRECATIONS_CHANNEL=null
|
||||
LOG_LEVEL=debug
|
||||
LOG_LEVEL=error
|
||||
|
||||
DB_CONNECTION=sqlite
|
||||
# DB_HOST=127.0.0.1
|
||||
# DB_PORT=3306
|
||||
# DB_DATABASE=laravel
|
||||
# DB_USERNAME=root
|
||||
# DB_PASSWORD=
|
||||
DB_CONNECTION=pgsql
|
||||
DB_HOST=postgres
|
||||
DB_PORT=5432
|
||||
DB_DATABASE=mad_lawsuit_v2
|
||||
DB_USERNAME=mad_user
|
||||
DB_PASSWORD=mK9#vL2$pQ7@wN4&xR8!zT6^yU3*bH5
|
||||
|
||||
SESSION_DRIVER=database
|
||||
SESSION_LIFETIME=120
|
||||
|
|
@ -38,7 +39,7 @@ FILESYSTEM_DISK=local
|
|||
QUEUE_CONNECTION=database
|
||||
|
||||
CACHE_STORE=database
|
||||
# CACHE_PREFIX=
|
||||
CACHE_PREFIX=
|
||||
|
||||
MEMCACHED_HOST=127.0.0.1
|
||||
|
||||
|
|
@ -47,14 +48,14 @@ REDIS_HOST=127.0.0.1
|
|||
REDIS_PASSWORD=null
|
||||
REDIS_PORT=6379
|
||||
|
||||
MAIL_MAILER=log
|
||||
MAIL_SCHEME=null
|
||||
MAIL_HOST=127.0.0.1
|
||||
MAIL_PORT=2525
|
||||
MAIL_USERNAME=null
|
||||
MAIL_PASSWORD=null
|
||||
MAIL_FROM_ADDRESS="hello@example.com"
|
||||
MAIL_FROM_NAME="${APP_NAME}"
|
||||
MAIL_MAILER=smtp
|
||||
MAIL_HOST=smtp.gmail.com
|
||||
MAIL_PORT=587
|
||||
MAIL_USERNAME=system@deafgain.org
|
||||
MAIL_PASSWORD=idjcxxwzvloorfuk
|
||||
MAIL_ENCRYPTION=tls
|
||||
MAIL_FROM_ADDRESS="system@deafgain.org"
|
||||
MAIL_FROM_NAME="MAD Lawsuit Court Docket"
|
||||
|
||||
AWS_ACCESS_KEY_ID=
|
||||
AWS_SECRET_ACCESS_KEY=
|
||||
|
|
@ -63,3 +64,4 @@ AWS_BUCKET=
|
|||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||
|
||||
VITE_APP_NAME="${APP_NAME}"
|
||||
ASSET_URL=https://mad-lawsuit.org
|
||||
|
|
|
|||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -1,9 +1,7 @@
|
|||
# Laravel & PHP
|
||||
*.log
|
||||
.DS_Store
|
||||
.env
|
||||
.env.backup
|
||||
.env.production
|
||||
.phpactor.json
|
||||
.phpunit.result.cache
|
||||
/.fleet
|
||||
|
|
|
|||
141
Caddyfile.remote
Normal file
141
Caddyfile.remote
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
{
|
||||
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
|
||||
}
|
||||
220
DEPLOYMENT_EMAIL_TESTING.md
Normal file
220
DEPLOYMENT_EMAIL_TESTING.md
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
# 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
|
||||
224
DNS_SWITCHOVER_INSTRUCTIONS.md
Normal file
224
DNS_SWITCHOVER_INSTRUCTIONS.md
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
# 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
|
||||
34
Dockerfile
34
Dockerfile
|
|
@ -18,6 +18,13 @@ RUN apk add --no-cache \
|
|||
# Install PHP extensions
|
||||
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
|
||||
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
|
||||
|
||||
|
|
@ -25,16 +32,16 @@ COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
|
|||
WORKDIR /var/www/html
|
||||
|
||||
# Copy composer files
|
||||
COPY composer.json composer.lock ./
|
||||
COPY composer.json ./
|
||||
|
||||
# Install PHP dependencies
|
||||
# Install PHP dependencies (generate composer.lock if missing)
|
||||
RUN composer install --no-dev --optimize-autoloader --no-scripts --no-interaction
|
||||
|
||||
# Copy package files
|
||||
COPY package.json package-lock.json* ./
|
||||
COPY package.json ./
|
||||
|
||||
# Install Node dependencies
|
||||
RUN npm ci --legacy-peer-deps
|
||||
# Install Node dependencies (generate package-lock.json if missing)
|
||||
RUN npm install --legacy-peer-deps
|
||||
|
||||
# Copy application files
|
||||
COPY . .
|
||||
|
|
@ -45,9 +52,20 @@ RUN npm run build
|
|||
# Generate optimized autoload files
|
||||
RUN composer dump-autoload --optimize
|
||||
|
||||
# Set permissions
|
||||
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 log directory for supervisor
|
||||
RUN mkdir -p /var/log/supervisor
|
||||
|
||||
# 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 docker/nginx.conf /etc/nginx/nginx.conf
|
||||
|
|
|
|||
298
README.md
298
README.md
|
|
@ -1,59 +1,279 @@
|
|||
<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>
|
||||
# MAD Lawsuit Court Docket Website
|
||||
|
||||
<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>
|
||||
A public-facing website for displaying court documents and filings for the lawsuit: **Elizabeth Kragh v. Montana Association of the Deaf**.
|
||||
|
||||
## About Laravel
|
||||
**Live Site**: https://v2.mad-lawsuit.org
|
||||
**Admin Dashboard**: https://v2.mad-lawsuit.org/admin/login
|
||||
|
||||
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:
|
||||
## 📋 Project Overview
|
||||
|
||||
- [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).
|
||||
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.
|
||||
|
||||
Laravel is accessible, powerful, and provides tools required for large, robust applications.
|
||||
### Purpose
|
||||
|
||||
## Learning Laravel
|
||||
- **Public Transparency**: Makes court proceedings accessible to anyone interested
|
||||
- **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
|
||||
|
||||
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.
|
||||
### Target Audience
|
||||
|
||||
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.
|
||||
- General public following the case
|
||||
- Legal community (attorneys, paralegals, law students)
|
||||
- Disability rights advocacy groups
|
||||
- Media and journalists
|
||||
- Researchers studying the case
|
||||
|
||||
## Laravel Sponsors
|
||||
## 🛠️ Technology Stack
|
||||
|
||||
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).
|
||||
### Backend
|
||||
- **Framework**: Laravel 12.43.1
|
||||
- **Language**: PHP 8.3.28
|
||||
- **Database**: PostgreSQL 16
|
||||
- **ORM**: Eloquent
|
||||
- **Authentication**: Laravel Sanctum + Session
|
||||
|
||||
### Premium Partners
|
||||
### Frontend
|
||||
- **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)
|
||||
|
||||
- **[Vehikl](https://vehikl.com)**
|
||||
- **[Tighten Co.](https://tighten.co)**
|
||||
- **[Kirschbaum Development Group](https://kirschbaumdevelopment.com)**
|
||||
- **[64 Robots](https://64robots.com)**
|
||||
- **[Curotec](https://www.curotec.com/services/technologies/laravel)**
|
||||
- **[DevSquad](https://devsquad.com/hire-laravel-developers)**
|
||||
- **[Redberry](https://redberry.international/laravel-development)**
|
||||
- **[Active Logic](https://activelogic.com)**
|
||||
### Infrastructure
|
||||
- **Containerization**: Docker with Alpine Linux
|
||||
- **Web Server**: nginx 1.28.0
|
||||
- **PHP**: PHP-FPM 8.3.28
|
||||
- **Reverse Proxy**: Caddy (with automatic SSL)
|
||||
- **Deployment**: Production server at 10.4.0.205
|
||||
|
||||
## Contributing
|
||||
## ✨ Features
|
||||
|
||||
Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions).
|
||||
### Public Website
|
||||
- **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
|
||||
|
||||
## Code of Conduct
|
||||
### Admin Dashboard
|
||||
- **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
|
||||
|
||||
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).
|
||||
## 🚀 Getting Started
|
||||
|
||||
## Security Vulnerabilities
|
||||
### Prerequisites
|
||||
- PHP 8.3+
|
||||
- Composer
|
||||
- Node.js 18+ and npm
|
||||
- PostgreSQL 16+
|
||||
- Docker (for production deployment)
|
||||
|
||||
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.
|
||||
### Local Development Setup
|
||||
|
||||
## License
|
||||
1. **Clone the repository**
|
||||
```bash
|
||||
git clone https://gitea.sigd.net/chaulmark/mad-lawsuit.git
|
||||
cd mad-lawsuit
|
||||
```
|
||||
|
||||
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).
|
||||
2. **Install PHP dependencies**
|
||||
```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 ✅
|
||||
|
|
|
|||
118
app/Console/Commands/ImportDocumentAccessLog.php
Normal file
118
app/Console/Commands/ImportDocumentAccessLog.php
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
<?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;
|
||||
}
|
||||
}
|
||||
|
|
@ -5,7 +5,10 @@ namespace App\Http\Controllers\Admin;
|
|||
use App\Http\Controllers\Controller;
|
||||
use App\Models\DocketEntry;
|
||||
use App\Models\Document;
|
||||
use App\Models\DocumentAccess;
|
||||
use App\Models\Subscription;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
|
|
@ -35,6 +38,41 @@ class DashboardController extends Controller
|
|||
}),
|
||||
];
|
||||
|
||||
$stats += $this->documentAccessSummary();
|
||||
|
||||
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,
|
||||
]),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,11 @@ namespace App\Http\Controllers\Admin;
|
|||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\DocketEntry;
|
||||
use App\Models\Subscription;
|
||||
use App\Mail\NewDocketEntryNotification;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
|
|
@ -16,24 +20,18 @@ class DocketEntryController extends Controller
|
|||
*/
|
||||
public function index(): Response
|
||||
{
|
||||
$paginated = DocketEntry::with('documents')
|
||||
$entries = DocketEntry::with('documents')
|
||||
->orderBy('date', 'desc')
|
||||
->paginate(20);
|
||||
->get();
|
||||
|
||||
return Inertia::render('Admin/DocketEntries/Index', [
|
||||
'entries' => $paginated->map(fn ($entry) => [
|
||||
'entries' => $entries->map(fn ($entry) => [
|
||||
'id' => $entry->id,
|
||||
'date' => $entry->date->format('m/d/Y'),
|
||||
'title' => $entry->title,
|
||||
'summary' => $entry->summary,
|
||||
'documents_count' => $entry->documents->count(),
|
||||
])->toArray(),
|
||||
'pagination' => [
|
||||
'current_page' => $paginated->currentPage(),
|
||||
'last_page' => $paginated->lastPage(),
|
||||
'per_page' => $paginated->perPage(),
|
||||
'total' => $paginated->total(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -133,4 +131,76 @@ class DocketEntryController extends Controller
|
|||
return redirect()->route('admin.docket-entries.index')
|
||||
->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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
199
app/Http/Controllers/Admin/DocumentAccessController.php
Normal file
199
app/Http/Controllers/Admin/DocumentAccessController.php
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
<?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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -17,39 +17,72 @@ class DocumentController extends Controller
|
|||
*/
|
||||
public function store(Request $request, DocketEntry $docketEntry): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'file' => 'required|file|mimes:pdf|max:10240', // 10MB max
|
||||
'title' => 'required|string|max:255',
|
||||
'summary' => 'nullable|string',
|
||||
]);
|
||||
try {
|
||||
\Log::info('=== PDF UPLOAD DEBUG START ===');
|
||||
\Log::info('Docket Entry ID: ' . $docketEntry->id);
|
||||
\Log::info('Request has file: ' . ($request->hasFile('file') ? 'YES' : 'NO'));
|
||||
|
||||
// Get the uploaded file
|
||||
$file = $request->file('file');
|
||||
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'));
|
||||
}
|
||||
|
||||
// Generate unique filename
|
||||
$storedFilename = Str::uuid() . '.pdf';
|
||||
$validated = $request->validate([
|
||||
'file' => 'required|file|mimes:pdf|max:512000', // 500MB max
|
||||
'title' => 'required|string|max:255',
|
||||
'summary' => 'nullable|string',
|
||||
]);
|
||||
\Log::info('Validation passed');
|
||||
|
||||
// Store file in storage/app/public/documents
|
||||
$filePath = $file->storeAs('documents', $storedFilename, 'public');
|
||||
// Get the uploaded file
|
||||
$file = $request->file('file');
|
||||
|
||||
// Get the highest display order for this entry
|
||||
$maxOrder = $docketEntry->documents()->max('display_order') ?? -1;
|
||||
// Generate unique filename
|
||||
$storedFilename = Str::uuid() . '.pdf';
|
||||
\Log::info('Generated filename: ' . $storedFilename);
|
||||
|
||||
// Create document record
|
||||
Document::create([
|
||||
'docket_entry_id' => $docketEntry->id,
|
||||
'title' => $validated['title'],
|
||||
'original_filename' => $file->getClientOriginalName(),
|
||||
'stored_filename' => $storedFilename,
|
||||
'file_path' => $filePath,
|
||||
'file_size' => $file->getSize(),
|
||||
'mime_type' => $file->getMimeType(),
|
||||
'summary' => $validated['summary'] ?? null,
|
||||
'display_order' => $maxOrder + 1,
|
||||
]);
|
||||
// Store file in storage/app/public/documents
|
||||
\Log::info('Attempting to store file...');
|
||||
$filePath = $file->storeAs('documents', $storedFilename, 'public');
|
||||
\Log::info('File stored at: ' . $filePath);
|
||||
|
||||
return redirect()->route('admin.docket-entries.show', $docketEntry->id)
|
||||
->with('success', 'Document uploaded successfully.');
|
||||
// Get the highest display order for this entry
|
||||
$maxOrder = $docketEntry->documents()->max('display_order') ?? -1;
|
||||
\Log::info('Max display order: ' . $maxOrder);
|
||||
|
||||
// Create document record
|
||||
\Log::info('Creating document record...');
|
||||
$document = Document::create([
|
||||
'docket_entry_id' => $docketEntry->id,
|
||||
'title' => $validated['title'],
|
||||
'original_filename' => $file->getClientOriginalName(),
|
||||
'stored_filename' => $storedFilename,
|
||||
'file_path' => $filePath,
|
||||
'file_size' => $file->getSize(),
|
||||
'mime_type' => $file->getMimeType(),
|
||||
'summary' => $validated['summary'] ?? null,
|
||||
'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)
|
||||
->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());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -19,12 +19,7 @@ class SubscriberController extends Controller
|
|||
->paginate(50);
|
||||
|
||||
return Inertia::render('Admin/Subscribers/Index', [
|
||||
'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'),
|
||||
]),
|
||||
'subscribers' => $subscribers->items(),
|
||||
'pagination' => [
|
||||
'current_page' => $subscribers->currentPage(),
|
||||
'last_page' => $subscribers->lastPage(),
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Document;
|
||||
use App\Models\DocumentAccess;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
|
@ -12,7 +13,7 @@ class DocumentController extends Controller
|
|||
/**
|
||||
* Download a document by ID.
|
||||
*/
|
||||
public function download(int $id): StreamedResponse
|
||||
public function download(Request $request, int $id): StreamedResponse
|
||||
{
|
||||
$document = Document::findOrFail($id);
|
||||
|
||||
|
|
@ -22,6 +23,17 @@ class DocumentController extends Controller
|
|||
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
|
||||
return Storage::disk('public')->download(
|
||||
$document->file_path,
|
||||
|
|
|
|||
|
|
@ -62,7 +62,24 @@ class SubscriptionController extends Controller
|
|||
}
|
||||
|
||||
/**
|
||||
* Unsubscribe using token.
|
||||
* Show unsubscribe confirmation page.
|
||||
*/
|
||||
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
|
||||
{
|
||||
|
|
|
|||
28
app/Http/Middleware/TrustProxies.php
Normal file
28
app/Http/Middleware/TrustProxies.php
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
<?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;
|
||||
}
|
||||
56
app/Mail/NewDocketEntryNotification.php
Normal file
56
app/Mail/NewDocketEntryNotification.php
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
<?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 [];
|
||||
}
|
||||
}
|
||||
|
|
@ -16,13 +16,6 @@ class AdminUser extends Model
|
|||
'password',
|
||||
];
|
||||
|
||||
/**
|
||||
* Automatically hash password when setting.
|
||||
*/
|
||||
public function setPasswordAttribute($value)
|
||||
{
|
||||
$this->attributes['password'] = Hash::make($value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify password.
|
||||
|
|
|
|||
48
app/Models/DocumentAccess.php
Normal file
48
app/Models/DocumentAccess.php
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
<?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;
|
||||
}
|
||||
}
|
||||
|
|
@ -11,6 +11,9 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||
health: '/up',
|
||||
)
|
||||
->withMiddleware(function (Middleware $middleware): void {
|
||||
// Trust proxies for HTTPS detection
|
||||
$middleware->trustProxies(at: '*');
|
||||
|
||||
$middleware->web(append: [
|
||||
\App\Http\Middleware\HandleInertiaRequests::class,
|
||||
\Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets::class,
|
||||
|
|
|
|||
|
|
@ -1,55 +1,108 @@
|
|||
# Active Context - Current Work Status
|
||||
|
||||
## Current Task: v2.0 PHASE 3 DATA MIGRATION - 100% COMPLETE ✅
|
||||
**MAD Lawsuit Website v2.0 - Data Migration from v1.0 Production**:
|
||||
- **v1.0 Status**: Fully operational at https://mad-lawsuit.org (Next.js + Express + React)
|
||||
- **v2.0 Goal**: Rebuild with Laravel + Inertia + Vue + TypeScript
|
||||
- **Current Phase**: Phase 3 - Data Migration (100% complete)
|
||||
- **Status**: ALL DATA MIGRATED SUCCESSFULLY - Ready for Phase 4 ✅
|
||||
## Current Task: DNS SWITCHOVER COMPLETE + EMAIL NOTIFICATIONS ENABLED ✅
|
||||
**MAD Lawsuit Website v2.0 - NOW LIVE AT MAIN DOMAIN**:
|
||||
- **v1.0 Status**: DECOMMISSIONED (containers stopped)
|
||||
- **v2.0 Status**: **LIVE AT https://mad-lawsuit.org** (Laravel + Inertia + Vue)
|
||||
- **Current Phase**: Phase 5 - DNS Switchover **COMPLETE** ✅
|
||||
- **Status**: All systems operational at main domain 🎉
|
||||
|
||||
### Phase 3 Complete - Data Migration ✅
|
||||
**All Steps Completed:**
|
||||
### Latest Changes (December 19, 2025)
|
||||
|
||||
1. **PostgreSQL Data Export** ✅
|
||||
- Exported via `pg_dump` from production server (10.4.0.205)
|
||||
- File: `/tmp/v1-data.sql` (local machine)
|
||||
- Contains: 63 docket entries, 63 documents, 28 subscriptions
|
||||
**Phase 5.1 - DNS Switchover** ✅ COMPLETE:
|
||||
- **Old Domain**: v2.mad-lawsuit.org (preview)
|
||||
- **New Domain**: mad-lawsuit.org (production)
|
||||
- **Changes Made**:
|
||||
- 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 ✅
|
||||
|
||||
2. **PDF Files Transfer** ✅
|
||||
- Copied via SCP from production: `/docker/websites/mad-lawsuit/uploads/*.pdf`
|
||||
- Destination: `storage/app/public/documents/`
|
||||
- Total: 69 PDF files (158MB transferred successfully)
|
||||
**Phase 5.2 - Email Notifications to All Subscribers** ✅ COMPLETE:
|
||||
- **Previous**: Only sent test emails to chris@deafgain.org
|
||||
- **Now**: Sends to all active subscribers in database
|
||||
- **Implementation**:
|
||||
- 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 ✅
|
||||
|
||||
3. **Parser Development** ✅
|
||||
- Created `parse_sql_to_seeder.py` (v1) - captured 32/63 entries
|
||||
- Identified limitation: only parsed single-line INSERT statements
|
||||
- Created `parse_sql_to_seeder_v2.py` - handles multi-line INSERTs
|
||||
- Successfully parsed ALL 63 entries from SQL dump
|
||||
**Phase 5.3 - iPad PDF Viewing Fix** ✅ COMPLETE:
|
||||
- **Issue**: iPad Safari couldn't scroll PDFs in modal viewer
|
||||
- **Solution**: Detect iOS devices and open PDFs in new tab (native Safari viewer)
|
||||
- **Implementation**:
|
||||
- Updated `resources/js/Pages/Home.vue`
|
||||
- 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 ✅
|
||||
|
||||
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/`
|
||||
### Latest Enhancements (December 18, 2025)
|
||||
|
||||
5. **Data Import Execution** ✅
|
||||
- Cleared database: `php artisan migrate:fresh --seed --seeder=AdminSeeder`
|
||||
- Ran seeder: `php artisan db:seed --class=V1DataMigrationSeeder`
|
||||
- Result: 63 entries, 63 documents, 28 subscriptions imported
|
||||
**Phase 4.1 - PDF Upload Fix** ✅ COMPLETE:
|
||||
- **Problem**: PDF uploads failing with "Permission denied" error
|
||||
- **Root Cause**: nginx temp directories had incorrect permissions (750 on parent `/var/lib/nginx`)
|
||||
- **Solution**: Updated Dockerfile to set parent directory to 755
|
||||
- **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)
|
||||
|
||||
6. **Data Integrity Verification** ✅
|
||||
- Database counts: 63/63/28 ✅
|
||||
- Sample entries verified (IDs 1-76)
|
||||
- All dates and titles accurate
|
||||
- Relationships intact (entries → documents)
|
||||
- File paths corrected for v2.0
|
||||
**Phase 4.2 - Unsubscribe Confirmation Page** ✅ COMPLETE:
|
||||
- **Feature**: Two-step unsubscribe confirmation to prevent accidental unsubscribes
|
||||
- **Implementation**:
|
||||
- Created `resources/js/Pages/Unsubscribe.vue` with beautiful confirmation UI
|
||||
- Matches site styling (#6E6362 background, #6b9080 green buttons)
|
||||
- Shows user's email address
|
||||
- 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}
|
||||
|
||||
**Files Created:**
|
||||
- `parse_sql_to_seeder_v2.py` - Multi-line INSERT parser
|
||||
- `update_seeder_v2.py` - Seeder automation script
|
||||
- `seeder_data_full.txt` - Complete parsed data (63 entries)
|
||||
- `DATA_COMPARISON_REPORT.md` - Detailed migration analysis
|
||||
- `check_v1_data.js` - v1.0 data verification script
|
||||
**Phase 4.3 - Date Format Consistency** ✅ COMPLETE:
|
||||
- **Issue**: Subscriber page showing inconsistent date format
|
||||
- **Fix**: Updated `resources/js/Pages/Admin/Subscribers/Index.vue`
|
||||
- **Format**: Now shows American format (MM/DD/YYYY) matching home page and docket entries
|
||||
- **Implementation**: `new Date(subscriber.created_at).toLocaleDateString('en-US', { month: '2-digit', day: '2-digit', year: 'numeric' })`
|
||||
- **Status**: All dates now consistent across the application
|
||||
|
||||
**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 ✅
|
||||
**Admin Dashboard - Fully Functional**:
|
||||
|
|
@ -57,44 +110,44 @@
|
|||
**Controllers Implemented**:
|
||||
- ✅ **AuthController** - Login/logout with session management
|
||||
- ✅ **DashboardController** - Statistics and recent entries
|
||||
- ✅ **DocketEntryController** - Full CRUD (7 resource methods)
|
||||
- ✅ **DocumentController** - PDF upload/delete with UUID naming
|
||||
- ✅ **DocketEntryController** - Full CRUD (7 resource methods, no pagination)
|
||||
- ✅ **DocumentController** - PDF upload/delete with UUID naming (working perfectly)
|
||||
- ✅ **SubscriberController** - List and deactivate subscribers
|
||||
|
||||
**Vue Pages Created**:
|
||||
- ✅ **Admin/Login.vue** - Professional authentication page
|
||||
- ✅ **Admin/Dashboard.vue** - Statistics cards, quick actions, recent entries
|
||||
- ✅ **Admin/DocketEntries/Index.vue** - Paginated table (20 per page)
|
||||
- ✅ **Admin/DocketEntries/Index.vue** - All entries on one page (no pagination)
|
||||
- ✅ **Admin/DocketEntries/Create.vue** - Form for new entries
|
||||
- ✅ **Admin/DocketEntries/Edit.vue** - Update existing entries
|
||||
- ✅ **Admin/DocketEntries/Show.vue** - View details + upload documents
|
||||
- ✅ **Admin/Subscribers/Index.vue** - Manage email subscribers (50 per page)
|
||||
- ✅ **Admin/Subscribers/Index.vue** - Manage email subscribers (50 per page, American dates)
|
||||
- ✅ **Unsubscribe.vue** - Two-step confirmation page with matching styling
|
||||
|
||||
**Features Implemented**:
|
||||
- ✅ Session-based authentication (separate from Breeze)
|
||||
- ✅ Full CRUD operations for docket entries
|
||||
- ✅ PDF document upload with validation (10MB max, PDF only)
|
||||
- ✅ PDF document upload with validation (500MB max, PDF only) - **WORKING**
|
||||
- ✅ UUID-based file naming for security
|
||||
- ✅ File storage in `storage/app/public/documents`
|
||||
- ✅ Storage symlink configured (`php artisan storage:link`)
|
||||
- ✅ Pagination for entries and subscribers
|
||||
- ✅ No pagination for docket entries (all on one page)
|
||||
- ✅ Pagination for subscribers (50 per page)
|
||||
- ✅ Soft delete for subscribers (deactivate)
|
||||
- ✅ Cascade delete for entries (removes documents)
|
||||
- ✅ Professional UI with Tailwind CSS
|
||||
- ✅ Form validation with error display
|
||||
- ✅ Success/error flash messages
|
||||
- ✅ Responsive design
|
||||
|
||||
**Admin Access**:
|
||||
- URL: http://127.0.0.1:8000/admin/login
|
||||
- Username: admin
|
||||
- Password: password
|
||||
- ✅ Mobile PDF viewing optimized (opens in new tab on screens < 768px)
|
||||
- ✅ Unsubscribe confirmation page (prevents accidental unsubscribes)
|
||||
- ✅ Consistent American date format (MM/DD/YYYY) across all pages
|
||||
|
||||
### v2.0 Technology Stack
|
||||
**Backend**:
|
||||
- **Framework**: Laravel 12.43.1 (latest stable)
|
||||
- **PHP**: 8.3.28
|
||||
- **Database**: SQLite (development), PostgreSQL (production)
|
||||
- **Database**: PostgreSQL 16-alpine (production)
|
||||
- **ORM**: Eloquent (replacing Prisma)
|
||||
- **Auth**: Laravel Sanctum + Session
|
||||
- **API**: Inertia.js server-side
|
||||
|
|
@ -106,12 +159,13 @@
|
|||
- **Build Tool**: Vite 7.x
|
||||
- **Components**: Vue SFC (Single File Components)
|
||||
|
||||
**Development Environment**:
|
||||
- **PHP**: Installed via Homebrew (8.3.28)
|
||||
- **Composer**: 2.9.2
|
||||
- **Node**: Latest stable
|
||||
- **NPM**: With legacy-peer-deps for Vite compatibility
|
||||
- **Servers**: Laravel (8000), Vite (5173)
|
||||
**Production Environment**:
|
||||
- **Server**: 10.4.0.205 (dedicated server behind NAT)
|
||||
- **Docker**: Alpine Linux containers
|
||||
- **Web Server**: nginx 1.28.0
|
||||
- **PHP**: PHP-FPM 8.3.28
|
||||
- **Database**: PostgreSQL 16-alpine
|
||||
- **Reverse Proxy**: Caddy (with auto-SSL)
|
||||
|
||||
### v2.0 Progress Checklist
|
||||
**Phase 1 - Public Website** ✅ COMPLETE:
|
||||
|
|
@ -158,12 +212,40 @@
|
|||
- [x] Run seeder to import ALL data
|
||||
- [x] Verify 100% data integrity in database (63/63/28)
|
||||
|
||||
**Phase 4 - Production Deployment** ⏳ PENDING:
|
||||
- [ ] Configure PostgreSQL connection (for production)
|
||||
- [ ] Implement email notifications
|
||||
- [ ] Deploy to v2.mad-lawsuit.org for testing
|
||||
- [ ] Final testing and verification
|
||||
- [ ] Switch DNS to v2.0
|
||||
**Phase 4 - Production Deployment** ✅ COMPLETE (100%):
|
||||
- [x] Create Dockerfile and docker-compose.yml
|
||||
- [x] Build Docker image with PHP-FPM + nginx
|
||||
- [x] Fix PHP-FPM command issue (php-fpm8.3 → php-fpm)
|
||||
- [x] Deploy container to production server
|
||||
- [x] Configure Caddy reverse proxy
|
||||
- [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
|
||||
**Complete v1.0 documentation captured**:
|
||||
|
|
@ -173,22 +255,23 @@
|
|||
- ✅ Data structures (63 entries, 63 PDFs, 28 subscribers)
|
||||
- ✅ Admin dashboard specs (complete CRUD operations)
|
||||
- ✅ Production data verified from live database
|
||||
- ✅ Exact color matching from v1 container
|
||||
|
||||
**Reference Document**: `cline_docs/v1_design_specifications.md` (400+ lines)
|
||||
|
||||
### Next Immediate Steps
|
||||
**Phase 3 Complete! Ready for Phase 4:**
|
||||
1. **Configure PostgreSQL** - Set up production database connection
|
||||
2. **Implement Email Notifications** - SMTP configuration for subscriber alerts
|
||||
3. **Deploy to v2.mad-lawsuit.org** - Test subdomain deployment
|
||||
4. **Final Testing** - Comprehensive verification of all features
|
||||
5. **Switch DNS** - Point mad-lawsuit.org to v2.0
|
||||
### Next Steps (Optional Enhancements)
|
||||
**Future Improvements**:
|
||||
1. **Email Notifications** - Configure SMTP for subscriber notifications
|
||||
2. **Monitoring** - Set up application monitoring and alerts
|
||||
3. **Backups** - Automated database and file backups
|
||||
4. **Performance** - Redis caching for improved speed
|
||||
5. **Analytics** - Track visitor statistics
|
||||
|
||||
### Data Migration Details
|
||||
**v1.0 Production Data**:
|
||||
- **Docket Entries**: 63 entries with dates, titles, summaries
|
||||
- **Documents**: 63 PDF files (UUID filenames)
|
||||
- **Subscriptions**: 28 email subscribers
|
||||
- **Subscriptions**: 28 email subscribers (27 active)
|
||||
- **Total Size**: 158MB of PDF files
|
||||
|
||||
**Schema Mapping** (v1.0 → v2.0):
|
||||
|
|
@ -241,7 +324,7 @@
|
|||
|
||||
4. **SSL Certificates** ✅ COMPLETED
|
||||
- **Provider**: Let's Encrypt via Caddy
|
||||
- **Domains**: mad-lawsuit.org, files.mad-lawsuit.org
|
||||
- **Domains**: mad-lawsuit.org, files.mad-lawsuit.org, v2.mad-lawsuit.org
|
||||
- **Status**: Active and auto-renewing
|
||||
|
||||
### Final Production Architecture
|
||||
|
|
@ -249,8 +332,9 @@
|
|||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Caddy Reverse Proxy (10.4.0.205) │
|
||||
│ │
|
||||
│ mad-lawsuit.org → 172.18.0.6:806 (Frontend) │
|
||||
│ files.mad-lawsuit.org → 172.18.0.5:901 (Backend) │
|
||||
│ mad-lawsuit.org → 172.18.0.6:806 (Frontend v1) │
|
||||
│ files.mad-lawsuit.org → 172.18.0.5:901 (Backend v1) │
|
||||
│ v2.mad-lawsuit.org → localhost:8080 (v2 LIVE) │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
│
|
||||
├─────────────────────────────┐
|
||||
|
|
@ -275,94 +359,125 @@
|
|||
- **Hostname**: public-websites (internal: 10.4.0.205)
|
||||
- **External Access**: Via NAT through dedicated server
|
||||
- **SSH Access**: `ssh chaulmark@10.4.0.205`
|
||||
- **Project Location**: `~/websites/mad-lawsuit.org/`
|
||||
- **v1 Project**: `~/websites/mad-lawsuit.org/`
|
||||
- **v2 Project**: `~/websites/v2.mad-lawsuit.org/`
|
||||
- **Docker Volumes**: `/docker/websites/mad-lawsuit/`
|
||||
|
||||
**Deployment Commands**:
|
||||
**v2 Deployment Commands**:
|
||||
```bash
|
||||
# SSH to production server
|
||||
ssh chaulmark@10.4.0.205
|
||||
|
||||
# Navigate to project
|
||||
cd ~/websites/mad-lawsuit.org
|
||||
# Navigate to v2 project
|
||||
cd ~/websites/v2.mad-lawsuit.org
|
||||
|
||||
# Pull latest code
|
||||
git pull
|
||||
git pull origin main
|
||||
|
||||
# Rebuild and restart containers
|
||||
# Build frontend assets inside container
|
||||
docker exec mad-lawsuit-v2 npm run build
|
||||
|
||||
# For major changes, rebuild container
|
||||
docker compose down
|
||||
docker compose build
|
||||
docker compose up -d
|
||||
docker compose up -d --build
|
||||
|
||||
# Check container status
|
||||
docker compose ps
|
||||
docker compose logs -f
|
||||
docker compose logs -f app
|
||||
|
||||
# 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 ✅
|
||||
- **Website**: https://mad-lawsuit.org (HTTP/2 200 ✓)
|
||||
- **Backend API**: https://files.mad-lawsuit.org (HTTP/2 200 ✓)
|
||||
- **Database**: 63 docket entries with all historical data ✓
|
||||
- **PDF Files**: 69 court documents accessible ✓
|
||||
- **All Containers**: Running and healthy ✓
|
||||
- **v1 Website**: https://mad-lawsuit.org (HTTP/2 200 ✓)
|
||||
- **v1 Backend API**: https://files.mad-lawsuit.org (HTTP/2 200 ✓)
|
||||
- **v1 Database**: 63 docket entries with all historical data ✓
|
||||
- **v1 PDF Files**: 69 court documents accessible ✓
|
||||
- **v1 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
|
||||
|
||||
### What's Working ✅
|
||||
- **v2.0 Phase 1**: Public website fully functional
|
||||
- **v2.0 Phase 2**: Admin dashboard complete with all CRUD operations
|
||||
- **v2.0 Phase 3**: 100% data migration complete (63/63/28)
|
||||
- **Development Servers**: Laravel (8000) and Vite (5173) running
|
||||
- **Admin Authentication**: Login working with session management
|
||||
- **v1.0 Production**: Fully operational at https://mad-lawsuit.org
|
||||
- **v2.0 Production**: **FULLY OPERATIONAL** at https://v2.mad-lawsuit.org
|
||||
- **v2.0 Admin**: Login working, all CRUD operations functional
|
||||
- **v2.0 Database**: All data migrated and verified
|
||||
- **v2.0 Design**: Matches v1 exactly (colors, layout, functionality)
|
||||
- **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
|
||||
- **Database**: SQLite with complete production data, ready for PostgreSQL migration
|
||||
- **All 63 Entries**: Imported and verified in database
|
||||
- **All 63 Documents**: Linked correctly with proper file paths
|
||||
- **All 28 Subscriptions**: Active and ready for notifications
|
||||
- **Authentication**: Admin login persists across rebuilds
|
||||
|
||||
### Ready for DNS Switchover 🎉
|
||||
The v2.0 website is **production-ready** and can replace v1.0 at any time:
|
||||
- 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
|
||||
**Phase 4 - Production Deployment (On Hold)**:
|
||||
1. **Configure PostgreSQL** - Set up production database connection
|
||||
2. **Implement Email Notifications** - SMTP configuration
|
||||
3. **Deploy to v2.mad-lawsuit.org** - Test subdomain
|
||||
4. **Final Testing** - Comprehensive verification
|
||||
5. **DNS Switchover** - Point mad-lawsuit.org to v2.0
|
||||
**Optional - DNS Switchover**:
|
||||
1. Update DNS: Point mad-lawsuit.org to v2.mad-lawsuit.org
|
||||
2. Monitor for 24-48 hours
|
||||
3. Decommission v1 containers once stable
|
||||
4. Consider future enhancements (email notifications, monitoring, etc.)
|
||||
|
||||
### Files Modified (Phase 2 & 3)
|
||||
**Controllers**:
|
||||
- `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`
|
||||
### Files Modified (Latest Session - December 18, 2025)
|
||||
**PDF Upload Fix**:
|
||||
- `Dockerfile` - Added `chmod 755 /var/lib/nginx` for proper permissions
|
||||
|
||||
**Vue Pages**:
|
||||
- `resources/js/Pages/Admin/Login.vue`
|
||||
- `resources/js/Pages/Admin/Dashboard.vue`
|
||||
- `resources/js/Pages/Admin/DocketEntries/Index.vue`
|
||||
- `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`
|
||||
**Unsubscribe Feature**:
|
||||
- `resources/js/Pages/Unsubscribe.vue` - Created confirmation page
|
||||
- `app/Http/Controllers/SubscriptionController.php` - Added `showUnsubscribe()` method
|
||||
- `routes/web.php` - Added GET route for confirmation page
|
||||
- `resources/views/emails/new-docket-entry.blade.php` - Updated unsubscribe link
|
||||
|
||||
**Middleware & Routes**:
|
||||
- `app/Http/Middleware/AdminAuth.php`
|
||||
- `routes/web.php`
|
||||
**Date Format Fix**:
|
||||
- `resources/js/Pages/Admin/Subscribers/Index.vue` - American date format
|
||||
|
||||
**Database**:
|
||||
- `database/seeders/AdminSeeder.php`
|
||||
- `database/migrations/2025_12_17_223224_create_admin_users_table.php`
|
||||
**Pagination Removal**:
|
||||
- `app/Http/Controllers/Admin/DocketEntryController.php` - Changed to `get()` from `paginate()`
|
||||
- `resources/js/Pages/Admin/DocketEntries/Index.vue` - Removed pagination UI
|
||||
|
||||
### Deployment Notes
|
||||
- **Phase 2 Complete**: All admin features working locally
|
||||
- **Phase 3 In Progress**: Data migration 50% complete
|
||||
- **Ready for Seeder**: SQL dump and PDFs ready for import
|
||||
- All code committed to Git repository
|
||||
- **Phase 4.1 Complete**: All post-deployment enhancements operational
|
||||
- **DNS Ready**: v2.mad-lawsuit.org fully functional with all improvements
|
||||
- **SSL Active**: Let's Encrypt certificates auto-renewed
|
||||
- **Data Verified**: 100% migration success
|
||||
- **Design Verified**: Exact match to v1
|
||||
- **Latest Commit**: b48b51ab (December 18, 2025)
|
||||
|
||||
## Investigation Process
|
||||
1. **Phase 1**: Built public website matching v1.0 design
|
||||
2. **Phase 2**: Implemented complete admin dashboard with CRUD
|
||||
3. **Phase 3**: Exported production data and transferred PDF files
|
||||
4. **Next**: Create seeder to import v1.0 data into v2.0
|
||||
1. **Phase 1**: Built public website matching v1.0 design ✅
|
||||
2. **Phase 2**: Implemented complete admin dashboard with CRUD ✅
|
||||
3. **Phase 3**: Exported production data and transferred PDF files, created seeders, imported all data ✅
|
||||
4. **Phase 4**: Deployed to production, fixed all issues, verified functionality ✅
|
||||
5. **Phase 4.1**: Post-deployment enhancements (PDF upload, unsubscribe, dates, pagination) ✅
|
||||
|
||||
The MAD lawsuit website v2.0 rebuild is progressing well with Phase 2 complete and Phase 3 50% done.
|
||||
The MAD lawsuit website v2.0 rebuild is **100% COMPLETE** with all enhancements and ready for production use! 🎉
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
## Project Overview
|
||||
**Goal**: Rebuild MAD lawsuit website from Next.js/Express/Prisma to Laravel/Vue/Inertia
|
||||
**v1.0**: https://mad-lawsuit.org (fully operational)
|
||||
**v2.0**: Local development → Production deployment
|
||||
**v2.0**: https://v2.mad-lawsuit.org (FULLY OPERATIONAL) ✅
|
||||
|
||||
## Phase Status
|
||||
|
||||
|
|
@ -40,47 +40,73 @@
|
|||
- ✅ Professional UI with Tailwind CSS
|
||||
|
||||
**Admin Access**:
|
||||
- URL: http://127.0.0.1:8000/admin/login
|
||||
- Username: admin
|
||||
- Password: password
|
||||
- Production: https://v2.mad-lawsuit.org/admin/login
|
||||
- Username: eliza
|
||||
- Password: AXEoZWk2AMAD0naw
|
||||
|
||||
### Phase 3: Data Migration ⏳ IN PROGRESS (95%)
|
||||
**Status**: Seeder created and tested, ready for final import
|
||||
**Started**: December 17, 2025
|
||||
### Phase 3: Data Migration ✅ COMPLETE (100%)
|
||||
**Status**: All production data successfully migrated
|
||||
**Completion Date**: December 17, 2025
|
||||
|
||||
**Completed**:
|
||||
- ✅ PostgreSQL data export from production (63 entries, 63 docs, 28 subs)
|
||||
- ✅ PDF files transferred via SCP (69 files, 158MB)
|
||||
- ✅ Files in storage/app/public/documents/
|
||||
- ✅ SQL dump in /tmp/v1-data.sql
|
||||
- ✅ Created V1DataMigrationSeeder (tested with sample data)
|
||||
- ✅ Created Python parser script (parse_sql_to_seeder.py)
|
||||
- ✅ Generated PHP arrays (32 entries, 63 docs, 28 subs in seeder_data.txt)
|
||||
- ✅ Created completion instructions (PHASE3_COMPLETION_INSTRUCTIONS.md)
|
||||
- ✅ Created V1DataMigrationSeeder
|
||||
- ✅ Created Python parser script v1 (parse_sql_to_seeder.py)
|
||||
- ✅ Created Python parser script v2 (parse_sql_to_seeder_v2.py) - handles multi-line INSERTs
|
||||
- ✅ Generated complete PHP arrays (63 entries, 63 docs, 28 subs)
|
||||
- ✅ Updated seeder with all production data
|
||||
- ✅ Ran seeder and imported all data
|
||||
- ✅ Verified data integrity (63/63/28) ✅
|
||||
|
||||
**Remaining** (Next Session):
|
||||
- ⏳ Update seeder with generated arrays (15 min)
|
||||
- ⏳ Run seeder and import data (2 min)
|
||||
- ⏳ Verify data integrity in admin dashboard (5 min)
|
||||
### Phase 4: Production Deployment ✅ COMPLETE (100%)
|
||||
**Status**: Fully operational at https://v2.mad-lawsuit.org
|
||||
**Completion Date**: December 17, 2025
|
||||
|
||||
### Phase 4: Production Deployment ⏳ PENDING
|
||||
**Status**: Not started
|
||||
**Dependencies**: Phase 3 completion
|
||||
**Completed Tasks**:
|
||||
- ✅ Created Dockerfile and docker-compose.yml
|
||||
- ✅ Built Docker image with PHP-FPM + nginx
|
||||
- ✅ 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**:
|
||||
- [ ] Configure PostgreSQL connection for production
|
||||
- [ ] Implement email notifications (SMTP)
|
||||
- [ ] Deploy to v2.mad-lawsuit.org subdomain
|
||||
- [ ] Final testing and verification
|
||||
- [ ] Switch DNS to v2.0
|
||||
- [ ] Decommission v1.0
|
||||
- [ ] Update DNS: Point mad-lawsuit.org to v2.mad-lawsuit.org
|
||||
- [ ] Monitor for 24-48 hours
|
||||
- [ ] Decommission v1 containers once stable
|
||||
- [ ] Optional: Implement email notifications (SMTP)
|
||||
- [ ] Optional: Set up monitoring and alerts
|
||||
- [ ] Optional: Configure automated backups
|
||||
|
||||
## Technical Stack
|
||||
|
||||
### Backend
|
||||
- **Framework**: Laravel 12.43.1
|
||||
- **PHP**: 8.3.28
|
||||
- **Database**: SQLite (dev), PostgreSQL (prod)
|
||||
- **Database**: PostgreSQL 16-alpine (production)
|
||||
- **ORM**: Eloquent
|
||||
- **Auth**: Laravel Sanctum + Session
|
||||
|
||||
|
|
@ -90,41 +116,47 @@
|
|||
- **Styling**: Tailwind CSS 3.x
|
||||
- **Build**: Vite 7.x
|
||||
|
||||
### Development Environment
|
||||
- **PHP**: Homebrew installation
|
||||
- **Composer**: 2.9.2
|
||||
- **Node**: Latest stable
|
||||
- **Servers**: Laravel (8000), Vite (5173)
|
||||
### Production Environment
|
||||
- **Server**: 10.4.0.205 (dedicated server behind NAT)
|
||||
- **Docker**: Alpine Linux containers
|
||||
- **Web Server**: nginx 1.28.0
|
||||
- **PHP**: PHP-FPM 8.3.28
|
||||
- **Database**: PostgreSQL 16-alpine
|
||||
- **Reverse Proxy**: Caddy (with auto-SSL)
|
||||
|
||||
## Data Statistics
|
||||
|
||||
### v1.0 Production Data
|
||||
- **Docket Entries**: 63 entries
|
||||
- **Documents**: 63 PDF files (UUID filenames)
|
||||
- **Subscriptions**: 28 email subscribers
|
||||
- **Subscriptions**: 28 email subscribers (27 active)
|
||||
- **Total PDF Size**: 158MB
|
||||
- **Database Size**: 47MB (PostgreSQL)
|
||||
|
||||
### v2.0 Current Data
|
||||
- **Docket Entries**: 5 (test data from DocketSeeder)
|
||||
- **Documents**: 5 (test PDFs)
|
||||
- **Subscriptions**: 0
|
||||
- **Admin Users**: 1 (admin/password)
|
||||
### v2.0 Production Data (Migrated)
|
||||
- **Docket Entries**: 63 (100% migrated) ✅
|
||||
- **Documents**: 63 (100% migrated) ✅
|
||||
- **Subscriptions**: 28 (100% migrated) ✅
|
||||
- **Admin Users**: 1 (eliza)
|
||||
- **Status**: All data verified and operational ✅
|
||||
|
||||
## Key Milestones
|
||||
|
||||
### Completed ✅
|
||||
1. **December 17, 2025**: Phase 1 complete - Public website functional
|
||||
2. **December 17, 2025**: Phase 2 complete - Admin dashboard with full CRUD
|
||||
3. **December 17, 2025**: Phase 3 started - Data export and PDF transfer complete
|
||||
1. **December 17, 2025 (5:00 PM)**: Phase 1 complete - Public website functional
|
||||
2. **December 17, 2025 (5:30 PM)**: Phase 2 complete - Admin dashboard with full CRUD
|
||||
3. **December 17, 2025 (6:00 PM)**: Phase 3 started - Data export and PDF transfer
|
||||
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 ⏳
|
||||
4. **Next**: Complete Phase 3 - Create seeder and import production data
|
||||
5. **Future**: Phase 4 - Production deployment and DNS switch
|
||||
7. **Future**: Phase 5 - DNS switchover (when user decides)
|
||||
8. **Optional**: Email notifications, monitoring, backups
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
### Controllers (7 files)
|
||||
### Controllers (8 files)
|
||||
- `app/Http/Controllers/HomeController.php`
|
||||
- `app/Http/Controllers/SubscriptionController.php`
|
||||
- `app/Http/Controllers/DocumentController.php`
|
||||
|
|
@ -156,13 +188,33 @@
|
|||
- `database/migrations/2025_12_17_223201_create_subscriptions_table.php`
|
||||
- `database/migrations/2025_12_17_223224_create_admin_users_table.php`
|
||||
|
||||
### Seeders (2 files)
|
||||
### Seeders (3 files)
|
||||
- `database/seeders/DocketSeeder.php` (test data)
|
||||
- `database/seeders/AdminSeeder.php` (admin user)
|
||||
- `database/seeders/V1DataMigrationSeeder.php` (production data)
|
||||
|
||||
### Middleware & Routes
|
||||
- `app/Http/Middleware/AdminAuth.php`
|
||||
- `app/Http/Middleware/TrustProxies.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
|
||||
|
||||
|
|
@ -185,45 +237,51 @@
|
|||
- [x] Deactivate subscriber
|
||||
- [x] Pagination works correctly
|
||||
|
||||
### Phase 3 Testing ⏳
|
||||
- [ ] Import all 63 docket entries
|
||||
- [ ] Verify all 63 documents accessible
|
||||
- [ ] Confirm 28 subscribers imported
|
||||
- [ ] Test entry-document relationships
|
||||
- [ ] Verify timestamps preserved
|
||||
- [ ] Check data integrity
|
||||
### Phase 3 Testing ✅
|
||||
- [x] Import all 63 docket entries
|
||||
- [x] Verify all 63 documents accessible
|
||||
- [x] Confirm 28 subscribers imported
|
||||
- [x] Test entry-document relationships
|
||||
- [x] Verify timestamps preserved
|
||||
- [x] 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
|
||||
|
||||
### Current Issues
|
||||
- None - all implemented features working correctly
|
||||
- None - all features working correctly in production ✅
|
||||
|
||||
### Future Considerations
|
||||
- Email notification system needs SMTP configuration
|
||||
- PostgreSQL connection for production deployment
|
||||
- SSL certificates for v2.mad-lawsuit.org subdomain
|
||||
### Future Enhancements
|
||||
- Email notification system (SMTP configuration)
|
||||
- Application monitoring and alerts
|
||||
- Automated database backups
|
||||
- Redis caching for performance
|
||||
- Analytics tracking
|
||||
|
||||
## Next Actions
|
||||
|
||||
### Immediate (Phase 3 Completion)
|
||||
1. Create `database/seeders/V1DataMigrationSeeder.php`
|
||||
2. Parse SQL INSERT statements from `/tmp/v1-data.sql`
|
||||
3. Transform to Laravel Eloquent creates
|
||||
4. Run seeder: `php artisan db:seed --class=V1DataMigrationSeeder`
|
||||
5. Verify in admin dashboard
|
||||
### Immediate (Optional)
|
||||
1. **DNS Switchover** - When user decides to replace v1 with v2
|
||||
2. **Monitoring** - Set up application monitoring
|
||||
3. **Backups** - Configure automated backups
|
||||
4. **Email** - Implement SMTP for subscriber notifications
|
||||
|
||||
### Short-term (Phase 4 Preparation)
|
||||
1. Configure PostgreSQL connection in `.env`
|
||||
2. Test database connection
|
||||
3. Implement email notification system
|
||||
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
|
||||
### Long-term (Optional)
|
||||
1. Performance optimization with Redis
|
||||
2. Analytics integration
|
||||
3. Enhanced admin features
|
||||
4. Mobile app considerations
|
||||
|
||||
## Success Metrics
|
||||
|
||||
|
|
@ -238,24 +296,80 @@
|
|||
- File upload/download working: **100%**
|
||||
- UI/UX professional: **100%**
|
||||
|
||||
### Phase 3 ⏳
|
||||
### Phase 3 ✅
|
||||
- Data export complete: **100%**
|
||||
- PDF transfer complete: **100%**
|
||||
- Seeder creation: **100%**
|
||||
- Python parser created: **100%**
|
||||
- PHP arrays generated: **100%**
|
||||
- Data import: **0%** (next session)
|
||||
- Verification: **0%** (next session)
|
||||
- **Overall Phase 3**: **95%**
|
||||
- Data import: **100%**
|
||||
- Verification: **100%**
|
||||
- **Overall Phase 3**: **100%** ✅
|
||||
|
||||
### Phase 4 ⏳
|
||||
- Not started: **0%**
|
||||
### Phase 4 ✅
|
||||
- Docker deployment: **100%**
|
||||
- Database setup: **100%**
|
||||
- SSL certificates: **100%**
|
||||
- Admin login: **100%**
|
||||
- All features tested: **100%**
|
||||
- Visual design match: **100%**
|
||||
- **Overall Phase 4**: **100%** ✅
|
||||
|
||||
## Overall Project Progress
|
||||
**Phases Complete**: 2 / 4 (50%)
|
||||
**Current Phase**: 3 (95% complete)
|
||||
**Estimated Completion**: Phase 3 - 20 minutes (next session), Phase 4 - 2-4 hours
|
||||
**Phases Complete**: 4 / 5 (80%)
|
||||
**Current Phase**: 5 (Pending user decision for DNS switchover)
|
||||
**Status**: **PRODUCTION READY** ✅
|
||||
|
||||
---
|
||||
|
||||
*Last Updated: December 17, 2025 at 5:51 PM MST*
|
||||
## 🎉 PROJECT COMPLETE 🎉
|
||||
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,43 @@
|
|||
<?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');
|
||||
}
|
||||
};
|
||||
|
|
@ -12,14 +12,17 @@ class AdminSeeder extends Seeder
|
|||
*/
|
||||
public function run(): void
|
||||
{
|
||||
// Create default admin user
|
||||
AdminUser::create([
|
||||
'username' => 'admin',
|
||||
'password' => 'password', // Will be auto-hashed by AdminUser model
|
||||
// Create Eliza admin user
|
||||
// Insert directly to avoid any model mutators
|
||||
\DB::table('admin_users')->insert([
|
||||
'username' => 'eliza',
|
||||
'password' => '$2y$12$0ALddIeVIN8UN9.6Des9reVTdu4RpIRgHoQzowPp1wYwjK7.RafhK', // Password: AXEoZWk2AMAD0naw
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$this->command->info('Admin user created successfully!');
|
||||
$this->command->info('Username: admin');
|
||||
$this->command->info('Password: password');
|
||||
$this->command->info('Username: eliza');
|
||||
$this->command->info('Password: (same as v1.0)');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,11 +15,10 @@ class DatabaseSeeder extends Seeder
|
|||
*/
|
||||
public function run(): void
|
||||
{
|
||||
// User::factory(10)->create();
|
||||
|
||||
User::factory()->create([
|
||||
'name' => 'Test User',
|
||||
'email' => 'test@example.com',
|
||||
]);
|
||||
// Migration complete - no automatic seeding
|
||||
// Data is already in production database
|
||||
// Only seed manually if needed:
|
||||
// php artisan db:seed --class=AdminSeeder
|
||||
// php artisan db:seed --class=V1DataMigrationSeeder
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,10 +25,7 @@ class V1DataMigrationSeeder extends Seeder
|
|||
*/
|
||||
public function run(): void
|
||||
{
|
||||
// Disable foreign key checks for clean import
|
||||
DB::statement('PRAGMA foreign_keys = OFF');
|
||||
|
||||
// Clear existing data
|
||||
// Clear existing data (foreign keys handled by database)
|
||||
Document::truncate();
|
||||
DocketEntry::truncate();
|
||||
Subscription::truncate();
|
||||
|
|
@ -42,8 +39,8 @@ class V1DataMigrationSeeder extends Seeder
|
|||
// Import subscriptions
|
||||
$this->importSubscriptions();
|
||||
|
||||
// Re-enable foreign key checks
|
||||
DB::statement('PRAGMA foreign_keys = ON');
|
||||
// Fix PostgreSQL sequences after importing data with specific IDs
|
||||
$this->fixSequences();
|
||||
|
||||
$this->command->info('✅ v1.0 data migration complete!');
|
||||
$this->command->info(' - Docket Entries: ' . DocketEntry::count());
|
||||
|
|
@ -51,6 +48,23 @@ class V1DataMigrationSeeder extends Seeder
|
|||
$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
|
||||
{
|
||||
$entries = [
|
||||
|
|
|
|||
|
|
@ -8,9 +8,8 @@ services:
|
|||
container_name: mad-lawsuit-v2
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8080:80"
|
||||
- "806:80"
|
||||
volumes:
|
||||
- ./storage:/var/www/html/storage
|
||||
- ./storage/app/public/documents:/var/www/html/storage/app/public/documents
|
||||
environment:
|
||||
- APP_ENV=production
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ http {
|
|||
tcp_nodelay on;
|
||||
keepalive_timeout 65;
|
||||
types_hash_max_size 2048;
|
||||
client_max_body_size 20M;
|
||||
client_max_body_size 550M;
|
||||
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ logfile=/var/log/supervisor/supervisord.log
|
|||
pidfile=/var/run/supervisord.pid
|
||||
|
||||
[program:php-fpm]
|
||||
command=php-fpm8.3 -F
|
||||
command=php-fpm -F
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
|
|
|
|||
27
fix_sequences.php
Normal file
27
fix_sequences.php
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<?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";
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted, onUnmounted } from 'vue';
|
||||
import { watch, ref } from 'vue';
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
|
|
@ -12,33 +12,11 @@ const emit = defineEmits<{
|
|||
close: [];
|
||||
}>();
|
||||
|
||||
const isMobile = ref(false);
|
||||
const copied = 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) => {
|
||||
if (isOpen) {
|
||||
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 {
|
||||
document.body.style.overflow = 'unset';
|
||||
}
|
||||
|
|
@ -53,12 +31,18 @@ const handleBackdropClick = (e: MouseEvent) => {
|
|||
const handleDownload = () => {
|
||||
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>
|
||||
|
||||
<template>
|
||||
<!-- Only show modal on desktop (>= 1024px) -->
|
||||
<div
|
||||
v-if="isOpen && !isMobile"
|
||||
v-if="isOpen"
|
||||
class="fixed inset-0 bg-black/75 flex items-center justify-center z-50 p-4"
|
||||
@click="handleBackdropClick"
|
||||
>
|
||||
|
|
@ -77,6 +61,18 @@ const handleDownload = () => {
|
|||
</h2>
|
||||
</div>
|
||||
<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
|
||||
@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"
|
||||
|
|
|
|||
|
|
@ -10,11 +10,19 @@ interface DocketEntry {
|
|||
documents_count: number;
|
||||
}
|
||||
|
||||
interface TopDocument {
|
||||
id: number;
|
||||
title: string;
|
||||
downloads: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
total_entries: number;
|
||||
total_documents: number;
|
||||
total_subscribers: number;
|
||||
recent_entries: DocketEntry[];
|
||||
downloads_60d: number;
|
||||
top_documents_60d: TopDocument[];
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
|
@ -61,7 +69,7 @@ const logout = () => {
|
|||
<!-- Main Content -->
|
||||
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<!-- Statistics Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
<!-- Total Entries -->
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
|
|
@ -112,6 +120,58 @@ const logout = () => {
|
|||
</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 & change dates →
|
||||
</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>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
|
|
|
|||
|
|
@ -10,16 +10,8 @@ interface DocketEntry {
|
|||
documents_count: number;
|
||||
}
|
||||
|
||||
interface Pagination {
|
||||
current_page: number;
|
||||
last_page: number;
|
||||
per_page: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
entries: DocketEntry[];
|
||||
pagination: Pagination;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
|
@ -44,7 +36,7 @@ const deleteEntry = (id: number, title: string) => {
|
|||
Manage Docket Entries
|
||||
</h1>
|
||||
<p class="text-sm text-gray-600 mt-1">
|
||||
{{ props.pagination.total }} total entries
|
||||
{{ props.entries.length }} total entries
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-4">
|
||||
|
|
@ -143,31 +135,6 @@ const deleteEntry = (id: number, title: string) => {
|
|||
</tbody>
|
||||
</table>
|
||||
</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>
|
||||
</main>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -69,6 +69,12 @@ 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 => {
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
|
|
@ -229,6 +235,20 @@ const formatFileSize = (bytes: number): string => {
|
|||
</form>
|
||||
</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 -->
|
||||
<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>
|
||||
|
|
|
|||
231
resources/js/Pages/Admin/DocumentAccess.vue
Normal file
231
resources/js/Pages/Admin/DocumentAccess.vue
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
<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"
|
||||
>
|
||||
← 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>
|
||||
|
|
@ -159,7 +159,7 @@ const deactivateSubscriber = (id: number, email: string) => {
|
|||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-600">
|
||||
{{ subscriber.created_at }}
|
||||
{{ new Date(subscriber.created_at).toLocaleDateString('en-US', { month: '2-digit', day: '2-digit', year: 'numeric' }) }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -42,10 +42,24 @@ const toggleEntry = (id: number) => {
|
|||
const openDocument = (doc: Document, event: MouseEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
selectedDocument.value = {
|
||||
title: doc.title,
|
||||
url: `/api/documents/${doc.id}/download`
|
||||
};
|
||||
|
||||
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 = {
|
||||
title: doc.title,
|
||||
url: documentUrl
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const closeViewer = () => {
|
||||
|
|
@ -92,9 +106,9 @@ const subscribe = async () => {
|
|||
<template>
|
||||
<Head title="Court Docket - Elizabeth Kragh v. Montana Association of the Deaf" />
|
||||
|
||||
<div class="min-h-screen" style="background-color: #4a5568;">
|
||||
<div class="min-h-screen" style="background-color: #6E6362;">
|
||||
<!-- Hero Section -->
|
||||
<div class="py-16 px-4" style="background: linear-gradient(135deg, #3f4a5c 0%, #4a5568 100%);">
|
||||
<div class="py-16 px-4" style="background-color: rgba(57, 64, 83, 0.95);">
|
||||
<div class="max-w-4xl mx-auto text-center">
|
||||
<h1 class="text-[2.5rem] font-bold text-white mb-2 leading-tight">
|
||||
ELIZABETH KRAGH<br />
|
||||
|
|
@ -117,8 +131,8 @@ const subscribe = async () => {
|
|||
@click="subscribe"
|
||||
class="px-6 py-3 rounded-lg font-semibold text-white transition-colors"
|
||||
style="background-color: #6b9080;"
|
||||
@mouseover="$event.target.style.backgroundColor = '#5a8070'"
|
||||
@mouseout="$event.target.style.backgroundColor = '#6b9080'"
|
||||
@mouseover="($event.target as HTMLElement).style.backgroundColor = '#5a8070'"
|
||||
@mouseout="($event.target as HTMLElement).style.backgroundColor = '#6b9080'"
|
||||
>
|
||||
Subscribe to Updates
|
||||
</button>
|
||||
|
|
@ -199,8 +213,8 @@ const subscribe = async () => {
|
|||
@click="openDocument(doc, $event)"
|
||||
class="inline-block px-4 py-2 rounded-lg text-white font-semibold mr-2 mb-2 transition-colors"
|
||||
style="background-color: #6b9080;"
|
||||
@mouseover="$event.target.style.backgroundColor = '#5a8070'"
|
||||
@mouseout="$event.target.style.backgroundColor = '#6b9080'"
|
||||
@mouseover="($event.target as HTMLElement).style.backgroundColor = '#5a8070'"
|
||||
@mouseout="($event.target as HTMLElement).style.backgroundColor = '#6b9080'"
|
||||
>
|
||||
📄 {{ doc.title }}
|
||||
</button>
|
||||
|
|
|
|||
153
resources/js/Pages/Unsubscribe.vue
Normal file
153
resources/js/Pages/Unsubscribe.vue
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
<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>
|
||||
153
resources/views/emails/new-docket-entry.blade.php
Normal file
153
resources/views/emails/new-docket-entry.blade.php
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
<!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>
|
||||
|
|
@ -13,7 +13,8 @@ Route::get('/', [HomeController::class, 'index'])->name('home');
|
|||
|
||||
// Subscription API routes
|
||||
Route::post('/api/subscribe', [SubscriptionController::class, 'subscribe'])->name('api.subscribe');
|
||||
Route::get('/api/unsubscribe/{token}', [SubscriptionController::class, 'unsubscribe'])->name('api.unsubscribe');
|
||||
Route::get('/unsubscribe/{token}', [SubscriptionController::class, 'showUnsubscribe'])->name('unsubscribe');
|
||||
Route::post('/api/unsubscribe/{token}', [SubscriptionController::class, 'unsubscribe'])->name('api.unsubscribe');
|
||||
|
||||
// Document download route
|
||||
Route::get('/api/documents/{id}/download', [DocumentController::class, 'download'])->name('api.documents.download');
|
||||
|
|
@ -31,11 +32,16 @@ Route::prefix('admin')->name('admin.')->group(function () {
|
|||
|
||||
// Docket entry routes
|
||||
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
|
||||
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');
|
||||
|
||||
// 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
|
||||
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');
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue