fixing rolodex and search
This commit is contained in:
304
ADDRESS_VALIDATION_SERVICE.md
Normal file
304
ADDRESS_VALIDATION_SERVICE.md
Normal file
@@ -0,0 +1,304 @@
|
||||
# Address Validation Service Design
|
||||
|
||||
## Overview
|
||||
A separate Docker service for validating and standardizing addresses using a hybrid approach that prioritizes privacy and minimizes external API calls.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Service Design
|
||||
- **Standalone FastAPI service** running on port 8001
|
||||
- **SQLite database** containing USPS ZIP+4 data (~500MB)
|
||||
- **USPS API integration** for street-level validation when needed
|
||||
- **Redis cache** for validated addresses
|
||||
- **Internal HTTP API** for communication with main legal application
|
||||
|
||||
### Data Flow
|
||||
```
|
||||
1. Legal App → Address Service (POST /validate)
|
||||
2. Address Service checks local ZIP database
|
||||
3. If ZIP/city/state valid → return immediately
|
||||
4. If street validation needed → call USPS API
|
||||
5. Cache result in Redis
|
||||
6. Return standardized address to Legal App
|
||||
```
|
||||
|
||||
## Technical Requirements
|
||||
|
||||
### Dependencies
|
||||
- FastAPI framework
|
||||
- SQLAlchemy for database operations
|
||||
- SQLite for ZIP+4 database storage
|
||||
- Redis for caching validated addresses
|
||||
- httpx for USPS API calls
|
||||
- Pydantic for request/response validation
|
||||
|
||||
### Database Schema
|
||||
```sql
|
||||
-- ZIP+4 Database (from USPS monthly files)
|
||||
CREATE TABLE zip_codes (
|
||||
zip_code TEXT,
|
||||
plus4 TEXT,
|
||||
city TEXT,
|
||||
state TEXT,
|
||||
county TEXT,
|
||||
delivery_point TEXT,
|
||||
PRIMARY KEY (zip_code, plus4)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_zip_city ON zip_codes(zip_code, city);
|
||||
CREATE INDEX idx_city_state ON zip_codes(city, state);
|
||||
```
|
||||
|
||||
### API Endpoints
|
||||
|
||||
#### POST /validate
|
||||
Validate and standardize an address.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"street": "123 Main St",
|
||||
"city": "Anytown",
|
||||
"state": "CA",
|
||||
"zip": "90210",
|
||||
"strict": false // Optional: require exact match
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"valid": true,
|
||||
"confidence": 0.95,
|
||||
"source": "local", // "local", "usps_api", "cached"
|
||||
"standardized": {
|
||||
"street": "123 MAIN ST",
|
||||
"city": "ANYTOWN",
|
||||
"state": "CA",
|
||||
"zip": "90210",
|
||||
"plus4": "1234",
|
||||
"delivery_point": "12"
|
||||
},
|
||||
"corrections": [
|
||||
"Standardized street abbreviation ST"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### GET /health
|
||||
Health check endpoint.
|
||||
|
||||
#### POST /batch-validate
|
||||
Batch validation for multiple addresses (up to 50).
|
||||
|
||||
#### GET /stats
|
||||
Service statistics (cache hit rate, API usage, etc.).
|
||||
|
||||
## Privacy & Security Features
|
||||
|
||||
### Data Minimization
|
||||
- Only street numbers/names sent to USPS API when necessary
|
||||
- ZIP/city/state validation happens offline first
|
||||
- Validated addresses cached to avoid repeat API calls
|
||||
- No logging of personal addresses
|
||||
|
||||
### Rate Limiting
|
||||
- USPS API limited to 5 requests/second
|
||||
- Internal queue system for burst requests
|
||||
- Fallback to local-only validation when rate limited
|
||||
|
||||
### Caching Strategy
|
||||
- Redis cache with 30-day TTL for validated addresses
|
||||
- Cache key: SHA256 hash of normalized address
|
||||
- Cache hit ratio target: >80% after initial warmup
|
||||
|
||||
## Data Sources
|
||||
|
||||
### USPS ZIP+4 Database
|
||||
- **Source:** USPS Address Management System
|
||||
- **Update frequency:** Monthly
|
||||
- **Size:** ~500MB compressed, ~2GB uncompressed
|
||||
- **Format:** Fixed-width text files (legacy format)
|
||||
- **Download:** Automated monthly sync via USPS FTP
|
||||
|
||||
### USPS Address Validation API
|
||||
- **Endpoint:** https://secure.shippingapis.com/ShippingAPI.dll
|
||||
- **Rate limit:** 5 requests/second, 10,000/day free
|
||||
- **Authentication:** USPS Web Tools User ID required
|
||||
- **Response format:** XML (convert to JSON internally)
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1: Basic Service (1-2 days)
|
||||
- FastAPI service setup
|
||||
- Basic ZIP code validation using downloaded USPS data
|
||||
- Docker containerization
|
||||
- Simple /validate endpoint
|
||||
|
||||
### Phase 2: USPS Integration (1 day)
|
||||
- USPS API client implementation
|
||||
- Street-level validation
|
||||
- Error handling and fallbacks
|
||||
|
||||
### Phase 3: Caching & Optimization (1 day)
|
||||
- Redis integration
|
||||
- Performance optimization
|
||||
- Batch validation endpoint
|
||||
|
||||
### Phase 4: Data Management (1 day)
|
||||
- Automated USPS data downloads
|
||||
- Database update procedures
|
||||
- Monitoring and alerting
|
||||
|
||||
### Phase 5: Integration (0.5 day)
|
||||
- Update legal app to use address service
|
||||
- Form validation integration
|
||||
- Error handling in UI
|
||||
|
||||
## Docker Configuration
|
||||
|
||||
### Dockerfile
|
||||
```dockerfile
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
EXPOSE 8001
|
||||
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8001"]
|
||||
```
|
||||
|
||||
### Docker Compose Addition
|
||||
```yaml
|
||||
services:
|
||||
address-service:
|
||||
build: ./address-service
|
||||
ports:
|
||||
- "8001:8001"
|
||||
environment:
|
||||
- USPS_USER_ID=${USPS_USER_ID}
|
||||
- REDIS_URL=redis://redis:6379
|
||||
depends_on:
|
||||
- redis
|
||||
volumes:
|
||||
- ./address-service/data:/app/data
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
- `USPS_USER_ID`: USPS Web Tools user ID
|
||||
- `REDIS_URL`: Redis connection string
|
||||
- `ZIP_DB_PATH`: Path to SQLite ZIP database
|
||||
- `UPDATE_SCHEDULE`: Cron schedule for data updates
|
||||
- `API_RATE_LIMIT`: USPS API rate limit (default: 5/second)
|
||||
- `CACHE_TTL`: Cache time-to-live in seconds (default: 2592000 = 30 days)
|
||||
|
||||
## Monitoring & Metrics
|
||||
|
||||
### Key Metrics
|
||||
- Cache hit ratio
|
||||
- USPS API usage/limits
|
||||
- Response times (local vs API)
|
||||
- Validation success rates
|
||||
- Database update status
|
||||
|
||||
### Health Checks
|
||||
- Service availability
|
||||
- Database connectivity
|
||||
- Redis connectivity
|
||||
- USPS API connectivity
|
||||
- Disk space for ZIP database
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Graceful Degradation
|
||||
1. USPS API down → Fall back to local ZIP validation only
|
||||
2. Redis down → Skip caching, direct validation
|
||||
3. ZIP database corrupt → Use USPS API only
|
||||
4. All systems down → Return input address with warning
|
||||
|
||||
### Error Responses
|
||||
```json
|
||||
{
|
||||
"valid": false,
|
||||
"error": "USPS_API_UNAVAILABLE",
|
||||
"message": "Street validation temporarily unavailable",
|
||||
"fallback_used": "local_zip_only"
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Tests
|
||||
- Address normalization functions
|
||||
- ZIP database queries
|
||||
- USPS API client
|
||||
- Caching logic
|
||||
|
||||
### Integration Tests
|
||||
- Full validation workflow
|
||||
- Error handling scenarios
|
||||
- Performance benchmarks
|
||||
- Data update procedures
|
||||
|
||||
### Load Testing
|
||||
- Concurrent validation requests
|
||||
- Cache performance under load
|
||||
- USPS API rate limiting behavior
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Input Validation
|
||||
- Sanitize all address inputs
|
||||
- Prevent SQL injection in ZIP queries
|
||||
- Validate against malicious payloads
|
||||
|
||||
### Network Security
|
||||
- Internal service communication only
|
||||
- No direct external access to service
|
||||
- HTTPS for USPS API calls
|
||||
- Redis authentication if exposed
|
||||
|
||||
### Data Protection
|
||||
- No persistent logging of addresses
|
||||
- Secure cache key generation
|
||||
- Regular security updates for dependencies
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
### Phase 2 Features
|
||||
- International address validation (Google/SmartyStreets)
|
||||
- Address autocomplete suggestions
|
||||
- Geocoding integration
|
||||
- Delivery route optimization
|
||||
|
||||
### Performance Optimizations
|
||||
- Database partitioning by state
|
||||
- Compressed cache storage
|
||||
- Async batch processing
|
||||
- CDN for static ZIP data
|
||||
|
||||
## Cost Analysis
|
||||
|
||||
### Infrastructure Costs
|
||||
- Additional container resources: ~$10/month
|
||||
- Redis cache: ~$5/month
|
||||
- USPS ZIP data storage: Minimal
|
||||
- USPS API: Free tier (10K requests/day)
|
||||
|
||||
### Development Time
|
||||
- Initial implementation: 3-5 days
|
||||
- Testing and refinement: 1-2 days
|
||||
- Documentation and deployment: 0.5 day
|
||||
- **Total: 4.5-7.5 days**
|
||||
|
||||
### ROI
|
||||
- Improved data quality
|
||||
- Reduced shipping errors
|
||||
- Better client communication
|
||||
- Compliance with data standards
|
||||
- Foundation for future location-based features
|
||||
10
README.md
10
README.md
@@ -279,6 +279,16 @@ docker-compose --profile production up -d
|
||||
docker-compose ps
|
||||
```
|
||||
|
||||
## HTTP client usage in the frontend
|
||||
|
||||
- Prefer calling `window.http.wrappedFetch(url, options)` instead of `fetch` directly.
|
||||
- The wrapper automatically adds:
|
||||
- `Authorization: Bearer <token>` when available
|
||||
- `X-Correlation-ID` on every request
|
||||
- `Content-Type: application/json` when you pass a string body (e.g., from `JSON.stringify`)
|
||||
- It also exposes helpers: `window.http.parseErrorEnvelope`, `window.http.toError`, `window.http.formatAlert`.
|
||||
- The global `fetch` remains wrapped for compatibility, but will log a one-time deprecation warning. New code should use `window.http.wrappedFetch`.
|
||||
|
||||
### Traditional Deployment
|
||||
```bash
|
||||
# With gunicorn for production
|
||||
|
||||
@@ -96,7 +96,7 @@ async def search_by_phone(
|
||||
"""Search customers by phone number (legacy phone search feature)"""
|
||||
phones = db.query(Phone).join(Rolodex).filter(
|
||||
Phone.phone.contains(phone)
|
||||
).options(joinedload(Phone.rolodex)).all()
|
||||
).options(joinedload(Phone.rolodex_entry)).all()
|
||||
|
||||
results = []
|
||||
for phone_record in phones:
|
||||
@@ -104,10 +104,10 @@ async def search_by_phone(
|
||||
"phone": phone_record.phone,
|
||||
"location": phone_record.location,
|
||||
"customer": {
|
||||
"id": phone_record.rolodex.id,
|
||||
"name": f"{phone_record.rolodex.first or ''} {phone_record.rolodex.last}".strip(),
|
||||
"city": phone_record.rolodex.city,
|
||||
"state": phone_record.rolodex.abrev
|
||||
"id": phone_record.rolodex_entry.id,
|
||||
"name": f"{phone_record.rolodex_entry.first or ''} {phone_record.rolodex_entry.last}".strip(),
|
||||
"city": phone_record.rolodex_entry.city,
|
||||
"state": phone_record.rolodex_entry.abrev
|
||||
}
|
||||
})
|
||||
|
||||
@@ -178,21 +178,25 @@ async def list_customers(
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""List customers with pagination and search"""
|
||||
query = db.query(Rolodex).options(joinedload(Rolodex.phone_numbers))
|
||||
try:
|
||||
query = db.query(Rolodex).options(joinedload(Rolodex.phone_numbers))
|
||||
|
||||
if search:
|
||||
query = query.filter(
|
||||
or_(
|
||||
Rolodex.id.contains(search),
|
||||
Rolodex.last.contains(search),
|
||||
Rolodex.first.contains(search),
|
||||
Rolodex.city.contains(search),
|
||||
Rolodex.email.contains(search)
|
||||
if search:
|
||||
query = query.filter(
|
||||
or_(
|
||||
Rolodex.id.contains(search),
|
||||
Rolodex.last.contains(search),
|
||||
Rolodex.first.contains(search),
|
||||
Rolodex.city.contains(search),
|
||||
Rolodex.email.contains(search)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
customers = query.offset(skip).limit(limit).all()
|
||||
return customers
|
||||
customers = query.offset(skip).limit(limit).all()
|
||||
return customers
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Error loading customers: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/{customer_id}", response_model=CustomerResponse)
|
||||
|
||||
@@ -72,7 +72,7 @@ FIELD_MAPPINGS = {
|
||||
"A3": "a3",
|
||||
"City": "city",
|
||||
"Abrev": "abrev",
|
||||
# "St": "st", # Full state name - not mapped (model only has abrev)
|
||||
"St": None, # Full state name - skip this field as model only has abrev
|
||||
"Zip": "zip",
|
||||
"Email": "email",
|
||||
"DOB": "dob",
|
||||
@@ -366,6 +366,7 @@ def parse_date(date_str: str) -> Optional[datetime]:
|
||||
return None
|
||||
|
||||
|
||||
|
||||
def convert_value(value: str, field_name: str) -> Any:
|
||||
"""Convert string value to appropriate type based on field name"""
|
||||
if not value or value.strip() == "" or value.strip().lower() in ["null", "none", "n/a"]:
|
||||
@@ -483,8 +484,116 @@ async def import_csv_data(
|
||||
try:
|
||||
# Read CSV content
|
||||
content = await file.read()
|
||||
csv_content = content.decode('utf-8')
|
||||
csv_reader = csv.DictReader(io.StringIO(csv_content))
|
||||
|
||||
# Try multiple encodings for legacy CSV files
|
||||
encodings = ['utf-8', 'windows-1252', 'iso-8859-1', 'cp1252']
|
||||
csv_content = None
|
||||
for encoding in encodings:
|
||||
try:
|
||||
csv_content = content.decode(encoding)
|
||||
break
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
|
||||
if csv_content is None:
|
||||
raise HTTPException(status_code=400, detail="Could not decode CSV file. Please ensure it's saved in UTF-8, Windows-1252, or ISO-8859-1 encoding.")
|
||||
|
||||
# Preprocess CSV content to fix common legacy issues
|
||||
def preprocess_csv(content):
|
||||
lines = content.split('\n')
|
||||
cleaned_lines = []
|
||||
i = 0
|
||||
|
||||
while i < len(lines):
|
||||
line = lines[i]
|
||||
# If line doesn't have the expected number of commas, it might be a broken multi-line field
|
||||
if i == 0: # Header line
|
||||
cleaned_lines.append(line)
|
||||
expected_comma_count = line.count(',')
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Check if this line has the expected number of commas
|
||||
if line.count(',') < expected_comma_count:
|
||||
# This might be a continuation of the previous line
|
||||
# Try to merge with previous line
|
||||
if cleaned_lines:
|
||||
cleaned_lines[-1] += " " + line.replace('\n', ' ').replace('\r', ' ')
|
||||
else:
|
||||
cleaned_lines.append(line)
|
||||
else:
|
||||
cleaned_lines.append(line)
|
||||
i += 1
|
||||
|
||||
return '\n'.join(cleaned_lines)
|
||||
|
||||
# Custom robust parser for problematic legacy CSV files
|
||||
class MockCSVReader:
|
||||
def __init__(self, data, fieldnames):
|
||||
self.data = data
|
||||
self.fieldnames = fieldnames
|
||||
self.index = 0
|
||||
|
||||
def __iter__(self):
|
||||
return self
|
||||
|
||||
def __next__(self):
|
||||
if self.index >= len(self.data):
|
||||
raise StopIteration
|
||||
row = self.data[self.index]
|
||||
self.index += 1
|
||||
return row
|
||||
|
||||
try:
|
||||
lines = csv_content.strip().split('\n')
|
||||
if not lines:
|
||||
raise ValueError("Empty CSV file")
|
||||
|
||||
# Parse header using proper CSV parsing
|
||||
header_reader = csv.reader(io.StringIO(lines[0]))
|
||||
headers = next(header_reader)
|
||||
headers = [h.strip() for h in headers]
|
||||
print(f"DEBUG: Found {len(headers)} headers: {headers}")
|
||||
|
||||
# Parse data rows with proper CSV parsing
|
||||
rows_data = []
|
||||
skipped_rows = 0
|
||||
|
||||
for line_num, line in enumerate(lines[1:], start=2):
|
||||
# Skip empty lines
|
||||
if not line.strip():
|
||||
continue
|
||||
|
||||
try:
|
||||
# Use proper CSV parsing to handle commas within quoted fields
|
||||
line_reader = csv.reader(io.StringIO(line))
|
||||
fields = next(line_reader)
|
||||
fields = [f.strip() for f in fields]
|
||||
|
||||
# Skip rows that are clearly malformed (too few fields)
|
||||
if len(fields) < len(headers) // 2: # Less than half the expected fields
|
||||
skipped_rows += 1
|
||||
continue
|
||||
|
||||
# Pad or truncate to match header length
|
||||
while len(fields) < len(headers):
|
||||
fields.append('')
|
||||
fields = fields[:len(headers)]
|
||||
|
||||
row_dict = dict(zip(headers, fields))
|
||||
rows_data.append(row_dict)
|
||||
|
||||
except Exception as row_error:
|
||||
print(f"Skipping malformed row {line_num}: {row_error}")
|
||||
skipped_rows += 1
|
||||
continue
|
||||
|
||||
csv_reader = MockCSVReader(rows_data, headers)
|
||||
print(f"SUCCESS: Parsed {len(rows_data)} rows (skipped {skipped_rows} malformed rows)")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Custom parsing failed: {e}")
|
||||
raise HTTPException(status_code=400, detail=f"Could not parse CSV file. The file appears to have serious formatting issues. Error: {str(e)}")
|
||||
|
||||
imported_count = 0
|
||||
errors = []
|
||||
@@ -500,7 +609,7 @@ async def import_csv_data(
|
||||
model_data = {}
|
||||
|
||||
for csv_field, db_field in field_mapping.items():
|
||||
if csv_field in row:
|
||||
if csv_field in row and db_field is not None: # Skip fields mapped to None
|
||||
converted_value = convert_value(row[csv_field], csv_field)
|
||||
if converted_value is not None:
|
||||
model_data[db_field] = converted_value
|
||||
@@ -509,6 +618,15 @@ async def import_csv_data(
|
||||
if not any(model_data.values()):
|
||||
continue
|
||||
|
||||
# Special validation for models with required fields
|
||||
if model_class == Phone:
|
||||
if 'phone' not in model_data or not model_data['phone']:
|
||||
continue # Skip phone records without a phone number
|
||||
|
||||
if model_class == Rolodex:
|
||||
if 'last' not in model_data or not model_data['last']:
|
||||
continue # Skip rolodex records without a last name/company name
|
||||
|
||||
# Create model instance
|
||||
instance = model_class(**model_data)
|
||||
db.add(instance)
|
||||
@@ -542,6 +660,9 @@ async def import_csv_data(
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
print(f"IMPORT ERROR DEBUG: {type(e).__name__}: {str(e)}")
|
||||
import traceback
|
||||
print(f"TRACEBACK: {traceback.format_exc()}")
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=f"Import failed: {str(e)}")
|
||||
|
||||
@@ -616,8 +737,22 @@ async def validate_csv_file(
|
||||
|
||||
try:
|
||||
content = await file.read()
|
||||
csv_content = content.decode('utf-8')
|
||||
csv_reader = csv.DictReader(io.StringIO(csv_content))
|
||||
|
||||
# Try multiple encodings for legacy CSV files
|
||||
encodings = ['utf-8', 'windows-1252', 'iso-8859-1', 'cp1252']
|
||||
csv_content = None
|
||||
for encoding in encodings:
|
||||
try:
|
||||
csv_content = content.decode(encoding)
|
||||
break
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
|
||||
if csv_content is None:
|
||||
raise HTTPException(status_code=400, detail="Could not decode CSV file. Please ensure it's saved in UTF-8, Windows-1252, or ISO-8859-1 encoding.")
|
||||
|
||||
# Handle CSV parsing issues with legacy files
|
||||
csv_reader = csv.DictReader(io.StringIO(csv_content), delimiter=',', quotechar='"', quoting=csv.QUOTE_MINIMAL)
|
||||
|
||||
# Check headers
|
||||
csv_headers = csv_reader.fieldnames
|
||||
@@ -664,6 +799,9 @@ async def validate_csv_file(
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
print(f"VALIDATION ERROR DEBUG: {type(e).__name__}: {str(e)}")
|
||||
import traceback
|
||||
print(f"VALIDATION TRACEBACK: {traceback.format_exc()}")
|
||||
raise HTTPException(status_code=500, detail=f"Validation failed: {str(e)}")
|
||||
|
||||
|
||||
@@ -737,8 +875,27 @@ async def batch_import_csv_files(
|
||||
field_mapping = FIELD_MAPPINGS.get(file_type, {})
|
||||
|
||||
content = await file.read()
|
||||
csv_content = content.decode('utf-8-sig')
|
||||
csv_reader = csv.DictReader(io.StringIO(csv_content))
|
||||
|
||||
# Try multiple encodings for legacy CSV files
|
||||
encodings = ['utf-8-sig', 'utf-8', 'windows-1252', 'iso-8859-1', 'cp1252']
|
||||
csv_content = None
|
||||
for encoding in encodings:
|
||||
try:
|
||||
csv_content = content.decode(encoding)
|
||||
break
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
|
||||
if csv_content is None:
|
||||
results.append({
|
||||
"file_type": file_type,
|
||||
"status": "failed",
|
||||
"message": "Could not decode CSV file encoding"
|
||||
})
|
||||
continue
|
||||
|
||||
# Handle CSV parsing issues with legacy files
|
||||
csv_reader = csv.DictReader(io.StringIO(csv_content), delimiter=',', quotechar='"', quoting=csv.QUOTE_MINIMAL)
|
||||
|
||||
imported_count = 0
|
||||
errors = []
|
||||
@@ -752,7 +909,7 @@ async def batch_import_csv_files(
|
||||
try:
|
||||
model_data = {}
|
||||
for csv_field, db_field in field_mapping.items():
|
||||
if csv_field in row:
|
||||
if csv_field in row and db_field is not None: # Skip fields mapped to None
|
||||
converted_value = convert_value(row[csv_field], csv_field)
|
||||
if converted_value is not None:
|
||||
model_data[db_field] = converted_value
|
||||
|
||||
@@ -28,7 +28,7 @@ services:
|
||||
- delphi_dev_uploads:/app/uploads
|
||||
# Database backups
|
||||
- delphi_dev_backups:/app/backups
|
||||
command: python -m uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||
command: python -m uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload --reload-dir app --reload-dir templates --reload-dir static
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"description": "A modern Python web application built with FastAPI to replace the legacy Pascal-based database system. This system maintains the familiar keyboard shortcuts and workflows while providing a robust, modular backend with a clean web interface.",
|
||||
"main": "tailwind.config.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
"test": "jest"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -17,8 +17,13 @@
|
||||
"url": "https://github.com/HotSwapp/delphi-database/issues"
|
||||
},
|
||||
"homepage": "https://github.com/HotSwapp/delphi-database#readme",
|
||||
"jest": {
|
||||
"testEnvironment": "jsdom"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/forms": "^0.5.10",
|
||||
"tailwindcss": "^3.4.10"
|
||||
"tailwindcss": "^3.4.10",
|
||||
"jest": "^29.7.0",
|
||||
"jsdom": "^22.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
58
static/js/__tests__/alerts.test.js
Normal file
58
static/js/__tests__/alerts.test.js
Normal file
@@ -0,0 +1,58 @@
|
||||
/** @jest-environment jsdom */
|
||||
|
||||
const path = require('path');
|
||||
// Load sanitizer utility first so alerts can delegate to it
|
||||
require(path.join(__dirname, '..', 'sanitizer.js'));
|
||||
// Load the alerts module (IIFE attaches itself to window)
|
||||
require(path.join(__dirname, '..', 'alerts.js'));
|
||||
|
||||
describe('alerts._sanitize', () => {
|
||||
const sanitize = window.alerts && window.alerts._sanitize;
|
||||
|
||||
it('should be a function', () => {
|
||||
expect(typeof sanitize).toBe('function');
|
||||
});
|
||||
|
||||
it('removes <script> tags and event-handler attributes', () => {
|
||||
const dirty = '<img src="x" onerror="alert(1)"><script>alert("x")</script><p>Hello</p>';
|
||||
const clean = sanitize(dirty);
|
||||
expect(clean).toContain('<img src="x">');
|
||||
expect(clean).toContain('<p>Hello</p>');
|
||||
expect(clean).not.toMatch(/<script/i);
|
||||
expect(clean).not.toMatch(/onerror/i);
|
||||
});
|
||||
|
||||
it('uses DOMPurify after it is lazily loaded', async () => {
|
||||
// Ensure DOMPurify is not present initially
|
||||
delete window.DOMPurify;
|
||||
|
||||
const mockPurify = {
|
||||
sanitize: jest.fn((html) => `CLEAN:${html}`)
|
||||
};
|
||||
|
||||
// Spy on the shared sanitizer loader and inject DOMPurify once called
|
||||
const loaderSpy = jest
|
||||
.spyOn(window.htmlSanitizer, 'ensureDOMPurifyLoaded')
|
||||
.mockImplementation(() => {
|
||||
window.DOMPurify = mockPurify;
|
||||
return Promise.resolve(mockPurify);
|
||||
});
|
||||
|
||||
const dirty = '<span onclick="evil()">Hi</span>';
|
||||
|
||||
// First call: fallback sanitizer, DOMPurify not used yet
|
||||
const first = sanitize(dirty);
|
||||
expect(mockPurify.sanitize).not.toHaveBeenCalled();
|
||||
expect(loaderSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Wait for loader promise to resolve
|
||||
await loaderSpy.mock.results[0].value;
|
||||
|
||||
// Second call: should use DOMPurify
|
||||
const second = sanitize(dirty);
|
||||
expect(mockPurify.sanitize).toHaveBeenCalledTimes(1);
|
||||
expect(second).toBe(`CLEAN:${dirty}`);
|
||||
|
||||
loaderSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
56
static/js/__tests__/sanitizer.test.js
Normal file
56
static/js/__tests__/sanitizer.test.js
Normal file
@@ -0,0 +1,56 @@
|
||||
/** @jest-environment jsdom */
|
||||
|
||||
require('../sanitizer.js');
|
||||
|
||||
describe('htmlSanitizer', () => {
|
||||
it('escape() encodes special HTML chars', () => {
|
||||
const { escape } = window.htmlSanitizer;
|
||||
expect(escape('<div>')).toBe('<div>');
|
||||
expect(escape('Tom & Jerry')).toBe('Tom & Jerry');
|
||||
expect(escape('"quotes" and \'apostrophes\'')).toContain('"');
|
||||
});
|
||||
|
||||
it('sanitize() returns safe HTML and does not double-escape plain text', () => {
|
||||
const { sanitize, escape } = window.htmlSanitizer;
|
||||
const dirty = '<img src=x onerror=alert(1)><p>Hello</p>';
|
||||
const clean = sanitize(dirty);
|
||||
expect(clean).toContain('<img');
|
||||
expect(clean).toContain('<p>Hello</p>');
|
||||
expect(clean).not.toMatch(/onerror/i);
|
||||
|
||||
const text = '<b>bold</b>';
|
||||
const escaped = escape(text);
|
||||
const sanitizedEscaped = sanitize(escaped);
|
||||
expect(sanitizedEscaped).toBe(escaped);
|
||||
});
|
||||
|
||||
it('setSafeHTML sets sanitized HTML on the element', () => {
|
||||
const el = document.createElement('div');
|
||||
const dirty = '<img src=x onerror=alert(1)><p>Hello</p>';
|
||||
window.setSafeHTML(el, dirty);
|
||||
expect(el.innerHTML).toContain('<img');
|
||||
expect(el.innerHTML).toContain('<p>Hello</p>');
|
||||
expect(el.innerHTML).not.toMatch(/onerror/i);
|
||||
});
|
||||
|
||||
it('setSafeHTML uses DOMPurify when it becomes available after first call', () => {
|
||||
// Ensure not present initially
|
||||
delete window.DOMPurify;
|
||||
|
||||
const el = document.createElement('div');
|
||||
const html = '<em>hello</em>';
|
||||
|
||||
// First call: fallback sanitizer (no DOMPurify)
|
||||
window.setSafeHTML(el, html);
|
||||
|
||||
// Now make DOMPurify available
|
||||
const mockPurify = { sanitize: jest.fn((h) => `CLEAN:${h}`) };
|
||||
window.DOMPurify = mockPurify;
|
||||
|
||||
// Second call should use DOMPurify
|
||||
window.setSafeHTML(el, html);
|
||||
|
||||
expect(mockPurify.sanitize).toHaveBeenCalledTimes(1);
|
||||
expect(el.innerHTML).toBe(`CLEAN:${html}`);
|
||||
});
|
||||
});
|
||||
@@ -12,20 +12,20 @@
|
||||
|
||||
const TYPE_CLASSES = {
|
||||
success: {
|
||||
container: 'border-success-200 dark:border-success-800',
|
||||
icon: 'fa-solid fa-circle-check text-success-600 dark:text-success-400'
|
||||
container: 'border-green-300 dark:border-green-500 bg-green-50 dark:bg-green-800',
|
||||
icon: 'fa-solid fa-circle-check text-green-600 dark:text-green-300'
|
||||
},
|
||||
danger: {
|
||||
container: 'border-danger-200 dark:border-danger-800',
|
||||
icon: 'fa-solid fa-triangle-exclamation text-danger-600 dark:text-danger-400'
|
||||
container: 'border-red-300 dark:border-red-500 bg-red-50 dark:bg-red-800',
|
||||
icon: 'fa-solid fa-triangle-exclamation text-red-600 dark:text-red-300'
|
||||
},
|
||||
warning: {
|
||||
container: 'border-warning-200 dark:border-warning-800',
|
||||
icon: 'fa-solid fa-triangle-exclamation text-warning-600 dark:text-warning-400'
|
||||
container: 'border-yellow-300 dark:border-yellow-500 bg-yellow-50 dark:bg-yellow-800',
|
||||
icon: 'fa-solid fa-triangle-exclamation text-yellow-600 dark:text-yellow-300'
|
||||
},
|
||||
info: {
|
||||
container: 'border-info-200 dark:border-info-800',
|
||||
icon: 'fa-solid fa-circle-info text-info-600 dark:text-info-400'
|
||||
container: 'border-blue-300 dark:border-blue-500 bg-blue-50 dark:bg-blue-800',
|
||||
icon: 'fa-solid fa-circle-info text-blue-600 dark:text-blue-300'
|
||||
}
|
||||
};
|
||||
|
||||
@@ -34,6 +34,30 @@
|
||||
return TYPE_ALIASES[key] || 'info';
|
||||
}
|
||||
|
||||
// ---- DOMPurify Lazy Loader ------------------------------------------------
|
||||
// Delegated sanitizer: uses shared htmlSanitizer if available, else performs a minimal fallback
|
||||
function sanitizeHTML(dirty) {
|
||||
if (window.htmlSanitizer && typeof window.htmlSanitizer.sanitize === 'function') {
|
||||
return window.htmlSanitizer.sanitize(dirty);
|
||||
}
|
||||
|
||||
// Minimal inline fallback to guarantee some protection until sanitizer.js loads
|
||||
const temp = document.createElement('div');
|
||||
temp.innerHTML = dirty;
|
||||
temp.querySelectorAll('script, style').forEach((el) => el.remove());
|
||||
temp.querySelectorAll('*').forEach((el) => {
|
||||
Array.from(el.attributes).forEach((attr) => {
|
||||
const name = attr.name;
|
||||
const value = attr.value;
|
||||
if (/^on/i.test(name)) el.removeAttribute(name);
|
||||
if ((name === 'href' || name === 'src') && value && value.trim().toLowerCase().startsWith('javascript:')) {
|
||||
el.removeAttribute(name);
|
||||
}
|
||||
});
|
||||
});
|
||||
return temp.innerHTML;
|
||||
}
|
||||
|
||||
function getOrCreateContainer(containerId = 'notification-container') {
|
||||
let container = document.getElementById(containerId);
|
||||
if (!container) {
|
||||
@@ -63,7 +87,7 @@
|
||||
const container = getOrCreateContainer(containerId);
|
||||
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = `alert-notification max-w-sm w-[22rem] bg-white dark:bg-neutral-800 border rounded-lg shadow-lg p-4 transition-all duration-300 translate-x-4 opacity-0 ${
|
||||
wrapper.className = `alert-notification max-w-sm w-[22rem] border-2 rounded-lg shadow-xl p-4 transition-all duration-300 translate-x-4 opacity-0 ${
|
||||
(TYPE_CLASSES[tone] || TYPE_CLASSES.info).container
|
||||
}`;
|
||||
wrapper.setAttribute('role', role);
|
||||
@@ -84,17 +108,17 @@
|
||||
|
||||
if (title) {
|
||||
const titleEl = document.createElement('p');
|
||||
titleEl.className = 'text-sm font-semibold text-neutral-900 dark:text-neutral-100';
|
||||
titleEl.className = 'text-sm font-bold text-neutral-900 dark:text-white';
|
||||
titleEl.textContent = String(title);
|
||||
content.appendChild(titleEl);
|
||||
}
|
||||
|
||||
const text = document.createElement('div');
|
||||
text.className = 'text-xs mt-1 text-neutral-800 dark:text-neutral-200';
|
||||
text.className = 'text-sm mt-1 font-semibold text-neutral-900 dark:text-white';
|
||||
if (message instanceof Node) {
|
||||
text.appendChild(message);
|
||||
} else if (html) {
|
||||
text.innerHTML = String(message || '');
|
||||
text.innerHTML = sanitizeHTML(String(message || ''));
|
||||
} else {
|
||||
text.textContent = String(message || '');
|
||||
}
|
||||
@@ -177,7 +201,10 @@
|
||||
error: (message, options = {}) => show(message, 'danger', options),
|
||||
warning: (message, options = {}) => show(message, 'warning', options),
|
||||
info: (message, options = {}) => show(message, 'info', options),
|
||||
getOrCreateContainer
|
||||
getOrCreateContainer,
|
||||
// Internal: exposed for unit testing only (non-enumerable by default prototype iteration)
|
||||
_sanitize: sanitizeHTML,
|
||||
_ensureDOMPurifyLoaded: () => window.htmlSanitizer ? window.htmlSanitizer.ensureDOMPurifyLoaded() : Promise.resolve(null)
|
||||
};
|
||||
|
||||
// Expose globally
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,8 @@
|
||||
window.app = window.app || {};
|
||||
|
||||
const CORRELATION_HEADER = 'X-Correlation-ID';
|
||||
let warnedTokenStorage = false;
|
||||
let warnedDeprecatedPatch = false;
|
||||
|
||||
function generateCorrelationId() {
|
||||
try {
|
||||
@@ -34,6 +36,8 @@
|
||||
async function wrappedFetch(resource, options = {}) {
|
||||
const url = typeof resource === 'string' ? resource : (resource && resource.url) || '';
|
||||
const headers = normalizeHeaders(options.headers);
|
||||
const method = (options.method || 'GET').toUpperCase();
|
||||
const body = options.body;
|
||||
|
||||
// Inject correlation id if not present
|
||||
let outgoingCid = headers.get(CORRELATION_HEADER);
|
||||
@@ -48,10 +52,30 @@
|
||||
if (storedToken && !headers.has('Authorization')) {
|
||||
headers.set('Authorization', `Bearer ${storedToken}`);
|
||||
}
|
||||
// One-time security note if we detect token in localStorage
|
||||
if (!warnedTokenStorage && typeof localStorage !== 'undefined' && localStorage.getItem('auth_token')) {
|
||||
warnedTokenStorage = true;
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('Security note: auth tokens are read from localStorage. If this app is exposed to the internet, migrate to HttpOnly cookies.');
|
||||
}
|
||||
} catch (_) {
|
||||
// Ignore storage access errors (e.g., privacy mode)
|
||||
}
|
||||
|
||||
// Inject Content-Type: application/json for JSON string bodies when missing
|
||||
try {
|
||||
const hasContentType = headers.has('Content-Type');
|
||||
const methodAllowsBody = method !== 'GET' && method !== 'HEAD';
|
||||
if (methodAllowsBody && body != null && !hasContentType) {
|
||||
// Only auto-set for stringified JSON bodies to avoid interfering with FormData or other types
|
||||
if (typeof body === 'string') {
|
||||
headers.set('Content-Type', 'application/json');
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
// Best-effort only; ignore header normalization errors
|
||||
}
|
||||
|
||||
const requestInit = { ...options, headers };
|
||||
|
||||
const response = await originalFetch(resource, requestInit);
|
||||
@@ -132,10 +156,18 @@
|
||||
parseErrorEnvelope,
|
||||
toError,
|
||||
formatAlert,
|
||||
wrappedFetch,
|
||||
};
|
||||
|
||||
// Install wrapper
|
||||
window.fetch = wrappedFetch;
|
||||
// Install wrapper (deprecated). Keep for backward compatibility, but nudge callers to use window.http.wrappedFetch
|
||||
window.fetch = async function(...args) {
|
||||
if (!warnedDeprecatedPatch) {
|
||||
warnedDeprecatedPatch = true;
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('Deprecated: global fetch() is wrapped. Prefer window.http.wrappedFetch for clarity and testability.');
|
||||
}
|
||||
return wrappedFetch(...args);
|
||||
};
|
||||
})();
|
||||
|
||||
|
||||
|
||||
@@ -54,12 +54,8 @@ async function saveThemePreference(theme) {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
if (!token || isLoginPage()) return;
|
||||
try {
|
||||
await fetch('/api/auth/theme-preference', {
|
||||
await window.http.wrappedFetch('/api/auth/theme-preference', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ theme_preference: theme })
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -88,9 +84,7 @@ async function loadUserThemePreference() {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
if (!token || isLoginPage()) return;
|
||||
try {
|
||||
const response = await fetch('/api/auth/me', {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
const response = await window.http.wrappedFetch('/api/auth/me');
|
||||
if (response.ok) {
|
||||
const user = await response.json();
|
||||
if (user.theme_preference) {
|
||||
@@ -162,14 +156,9 @@ function validateField(field) {
|
||||
function setupAPIHelpers() {
|
||||
// Set up default headers for all API calls
|
||||
window.apiHeaders = {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json'
|
||||
};
|
||||
|
||||
if (app.token) {
|
||||
window.apiHeaders['Authorization'] = `Bearer ${app.token}`;
|
||||
}
|
||||
|
||||
// Start proactive refresh scheduling when a token is present
|
||||
if (app.token) {
|
||||
scheduleTokenRefresh();
|
||||
@@ -197,7 +186,7 @@ async function apiCall(url, options = {}) {
|
||||
};
|
||||
|
||||
try {
|
||||
let response = await fetch(url, config);
|
||||
let response = await window.http.wrappedFetch(url, config);
|
||||
const updateCorrelationFromResponse = (resp) => {
|
||||
try {
|
||||
const cid = resp && resp.headers ? resp.headers.get('X-Correlation-ID') : null;
|
||||
@@ -215,7 +204,7 @@ async function apiCall(url, options = {}) {
|
||||
headers: { ...window.apiHeaders, ...options.headers },
|
||||
...options
|
||||
};
|
||||
response = await fetch(url, retryConfig);
|
||||
response = await window.http.wrappedFetch(url, retryConfig);
|
||||
lastCorrelationId = updateCorrelationFromResponse(response);
|
||||
} catch (_) {
|
||||
// fall through to logout below
|
||||
@@ -269,7 +258,6 @@ function setAuthTokens(accessToken, newRefreshToken = null) {
|
||||
if (accessToken) {
|
||||
app.token = accessToken;
|
||||
localStorage.setItem('auth_token', accessToken);
|
||||
window.apiHeaders['Authorization'] = `Bearer ${accessToken}`;
|
||||
}
|
||||
if (newRefreshToken) {
|
||||
app.refreshToken = newRefreshToken;
|
||||
@@ -297,9 +285,7 @@ async function checkTokenValidity() {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
if (!token) return false;
|
||||
try {
|
||||
const response = await fetch('/api/auth/me', {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
const response = await window.http.wrappedFetch('/api/auth/me');
|
||||
if (!response.ok) {
|
||||
// Invalid token
|
||||
return false;
|
||||
@@ -334,9 +320,7 @@ async function checkUserPermissions() {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
if (!token || isLoginPage()) return;
|
||||
try {
|
||||
const response = await fetch('/api/auth/me', {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
const response = await window.http.wrappedFetch('/api/auth/me');
|
||||
if (response.ok) {
|
||||
const user = await response.json();
|
||||
app.user = user;
|
||||
@@ -361,9 +345,7 @@ async function getInactivityWarningMinutes() {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
if (!token) return 240;
|
||||
try {
|
||||
const resp = await fetch('/api/settings/inactivity_warning_minutes', {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
const resp = await window.http.wrappedFetch('/api/settings/inactivity_warning_minutes');
|
||||
if (!resp.ok) return 240;
|
||||
const data = await resp.json();
|
||||
if (typeof data.minutes === 'number') return data.minutes;
|
||||
@@ -512,9 +494,8 @@ async function logout(reason = null) {
|
||||
const rtoken = localStorage.getItem('refresh_token');
|
||||
try {
|
||||
if (rtoken) {
|
||||
await fetch('/api/auth/logout', {
|
||||
await window.http.wrappedFetch('/api/auth/logout', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ refresh_token: rtoken })
|
||||
});
|
||||
}
|
||||
@@ -531,7 +512,7 @@ async function logout(reason = null) {
|
||||
app.user = null;
|
||||
localStorage.removeItem('auth_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
delete window.apiHeaders['Authorization'];
|
||||
// Authorization header is injected by fetch wrapper; nothing to clean here
|
||||
|
||||
if (reason) {
|
||||
try { sessionStorage.setItem('logout_reason', reason); } catch (_) {}
|
||||
@@ -702,7 +683,7 @@ function displaySearchResults(container, results) {
|
||||
return;
|
||||
}
|
||||
|
||||
const resultsHtml = results.map(result => `
|
||||
const resultsHtmlRaw = results.map(result => `
|
||||
<div class="search-result p-2 border-bottom">
|
||||
<div class="flex justify-between">
|
||||
<div>
|
||||
@@ -713,8 +694,11 @@ function displaySearchResults(container, results) {
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
container.innerHTML = resultsHtml;
|
||||
if (window.setSafeHTML) {
|
||||
window.setSafeHTML(container, resultsHtmlRaw);
|
||||
} else {
|
||||
container.innerHTML = resultsHtmlRaw;
|
||||
}
|
||||
}
|
||||
|
||||
// Utility functions
|
||||
@@ -814,13 +798,9 @@ function setupGlobalErrorHandlers() {
|
||||
|
||||
async function postClientError(payload) {
|
||||
try {
|
||||
const headers = { 'Content-Type': 'application/json', 'Accept': 'application/json' };
|
||||
const token = (window.app && window.app.token) || localStorage.getItem('auth_token');
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
// Fire-and-forget; do not block UI
|
||||
fetch('/api/documents/client-error', {
|
||||
window.http.wrappedFetch('/api/documents/client-error', {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(payload)
|
||||
}).catch(() => {});
|
||||
} catch (_) {
|
||||
@@ -875,11 +855,10 @@ async function refreshToken() {
|
||||
if (app.refreshInProgress) return; // Avoid parallel refreshes
|
||||
app.refreshInProgress = true;
|
||||
try {
|
||||
const response = await fetch('/api/auth/refresh', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
||||
body: JSON.stringify({ refresh_token: app.refreshToken })
|
||||
});
|
||||
const response = await window.http.wrappedFetch('/api/auth/refresh', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ refresh_token: app.refreshToken })
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Refresh failed');
|
||||
}
|
||||
|
||||
91
static/js/sanitizer.js
Normal file
91
static/js/sanitizer.js
Normal file
@@ -0,0 +1,91 @@
|
||||
(function () {
|
||||
const DOMPURIFY_CDN = 'https://cdn.jsdelivr.net/npm/dompurify@3.0.4/dist/purify.min.js';
|
||||
let _domPurifyPromise = null;
|
||||
|
||||
function ensureDOMPurifyLoaded() {
|
||||
if (typeof window === 'undefined') {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
if (window.DOMPurify && typeof window.DOMPurify.sanitize === 'function') {
|
||||
return Promise.resolve(window.DOMPurify);
|
||||
}
|
||||
if (_domPurifyPromise) return _domPurifyPromise;
|
||||
|
||||
_domPurifyPromise = new Promise((resolve, reject) => {
|
||||
try {
|
||||
const script = document.createElement('script');
|
||||
script.src = DOMPURIFY_CDN;
|
||||
script.async = true;
|
||||
script.onload = () => {
|
||||
if (window.DOMPurify && window.DOMPurify.sanitize) {
|
||||
resolve(window.DOMPurify);
|
||||
} else {
|
||||
reject(new Error('DOMPurify failed to load'));
|
||||
}
|
||||
};
|
||||
script.onerror = () => reject(new Error('Failed to load DOMPurify'));
|
||||
document.head.appendChild(script);
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
|
||||
return _domPurifyPromise;
|
||||
}
|
||||
|
||||
// Basic fallback sanitizer when DOMPurify is not available yet.
|
||||
function fallbackSanitize(dirty) {
|
||||
const temp = document.createElement('div');
|
||||
temp.innerHTML = dirty;
|
||||
|
||||
// Remove script and style tags
|
||||
temp.querySelectorAll('script, style').forEach((el) => el.remove());
|
||||
|
||||
// Remove dangerous attributes
|
||||
temp.querySelectorAll('*').forEach((el) => {
|
||||
Array.from(el.attributes).forEach((attr) => {
|
||||
const name = attr.name;
|
||||
const value = attr.value;
|
||||
if (/^on/i.test(name)) {
|
||||
el.removeAttribute(name);
|
||||
return;
|
||||
}
|
||||
if ((name === 'href' || name === 'src') && value && value.trim().toLowerCase().startsWith('javascript:')) {
|
||||
el.removeAttribute(name);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return temp.innerHTML;
|
||||
}
|
||||
|
||||
function sanitizeHTML(dirty) {
|
||||
if (typeof window !== 'undefined' && window.DOMPurify && window.DOMPurify.sanitize) {
|
||||
return window.DOMPurify.sanitize(dirty);
|
||||
}
|
||||
// Trigger async load so the next call benefits
|
||||
ensureDOMPurifyLoaded().catch(() => {});
|
||||
return fallbackSanitize(dirty);
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const span = document.createElement('span');
|
||||
span.textContent = String(text == null ? '' : text);
|
||||
return span.innerHTML;
|
||||
}
|
||||
|
||||
function setSafeHTML(element, html) {
|
||||
if (!element) return;
|
||||
const sanitized = sanitizeHTML(String(html == null ? '' : html));
|
||||
element.innerHTML = sanitized;
|
||||
}
|
||||
|
||||
// Expose globally
|
||||
window.htmlSanitizer = {
|
||||
sanitize: sanitizeHTML,
|
||||
ensureDOMPurifyLoaded,
|
||||
escape: escapeHtml,
|
||||
setHTML: setSafeHTML
|
||||
};
|
||||
window.setSafeHTML = setSafeHTML;
|
||||
})();
|
||||
@@ -1113,15 +1113,7 @@ let currentUsers = [];
|
||||
let currentSettings = [];
|
||||
let userPagination = { page: 1, limit: 10 };
|
||||
|
||||
// Helper function for authenticated API calls
|
||||
function getAuthHeaders() {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
return {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
}
|
||||
|
||||
// Authorization and JSON headers are injected by window.http.wrappedFetch
|
||||
// Check if current user has admin access
|
||||
async function checkAdminAccess() {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
@@ -1131,11 +1123,7 @@ async function checkAdminAccess() {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/auth/me', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
const response = await window.http.wrappedFetch('/api/auth/me');
|
||||
|
||||
if (!response.ok) {
|
||||
window.location.href = '/login';
|
||||
@@ -1245,9 +1233,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
// System Health Functions
|
||||
async function loadSystemHealth() {
|
||||
try {
|
||||
const response = await fetch('/api/admin/health', {
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
const response = await window.http.wrappedFetch('/api/admin/health');
|
||||
const data = await response.json();
|
||||
|
||||
// Update status indicator
|
||||
@@ -1300,9 +1286,7 @@ async function loadSystemHealth() {
|
||||
|
||||
async function loadSystemStats() {
|
||||
try {
|
||||
const response = await fetch('/api/admin/stats', {
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
const response = await window.http.wrappedFetch('/api/admin/stats');
|
||||
const data = await response.json();
|
||||
|
||||
// Update dashboard cards
|
||||
@@ -1346,9 +1330,7 @@ async function loadUsers() {
|
||||
if (search) url += 'search=' + encodeURIComponent(search) + '&';
|
||||
if (filter === 'active') url += 'active_only=true&';
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
const response = await window.http.wrappedFetch(url);
|
||||
const users = await response.json();
|
||||
currentUsers = users;
|
||||
|
||||
@@ -1408,9 +1390,7 @@ function showCreateUserModal() {
|
||||
|
||||
async function editUser(userId) {
|
||||
try {
|
||||
const response = await fetch('/api/admin/users/' + userId, {
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
const response = await window.http.wrappedFetch('/api/admin/users/' + userId);
|
||||
const user = await response.json();
|
||||
|
||||
document.getElementById('userModalTitle').textContent = 'Edit User';
|
||||
@@ -1452,9 +1432,8 @@ async function saveUser() {
|
||||
const url = isEdit ? '/api/admin/users/' + userId : '/api/admin/users';
|
||||
const method = isEdit ? 'PUT' : 'POST';
|
||||
|
||||
const response = await fetch(url, {
|
||||
const response = await window.http.wrappedFetch(url, {
|
||||
method: method,
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify(userData)
|
||||
});
|
||||
|
||||
@@ -1490,9 +1469,8 @@ async function resetPassword() {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/admin/users/' + userId + '/reset-password', {
|
||||
const response = await window.http.wrappedFetch('/api/admin/users/' + userId + '/reset-password', {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({
|
||||
new_password: newPassword,
|
||||
confirm_password: confirmPassword
|
||||
@@ -1517,9 +1495,8 @@ async function deactivateUser(userId) {
|
||||
if (!confirm('Are you sure you want to deactivate this user?')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/admin/users/' + userId, {
|
||||
method: 'DELETE',
|
||||
headers: getAuthHeaders()
|
||||
const response = await window.http.wrappedFetch('/api/admin/users/' + userId, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
@@ -1539,9 +1516,7 @@ async function deactivateUser(userId) {
|
||||
// Settings Management Functions
|
||||
async function loadSettings() {
|
||||
try {
|
||||
const response = await fetch('/api/admin/settings', {
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
const response = await window.http.wrappedFetch('/api/admin/settings');
|
||||
const data = await response.json();
|
||||
currentSettings = data.settings;
|
||||
|
||||
@@ -1592,9 +1567,7 @@ function showCreateSettingModal() {
|
||||
|
||||
async function editSetting(settingKey) {
|
||||
try {
|
||||
const response = await fetch('/api/admin/settings/' + encodeURIComponent(settingKey), {
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
const response = await window.http.wrappedFetch('/api/admin/settings/' + encodeURIComponent(settingKey));
|
||||
const setting = await response.json();
|
||||
|
||||
document.getElementById('settingModalTitle').textContent = 'Edit Setting';
|
||||
@@ -1627,9 +1600,8 @@ async function saveSetting() {
|
||||
const url = isEdit ? '/api/admin/settings/' + encodeURIComponent(settingKey) : '/api/admin/settings';
|
||||
const method = isEdit ? 'PUT' : 'POST';
|
||||
|
||||
const response = await fetch(url, {
|
||||
const response = await window.http.wrappedFetch(url, {
|
||||
method: method,
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify(isEdit ? {
|
||||
setting_value: settingData.setting_value,
|
||||
description: settingData.description
|
||||
@@ -1688,9 +1660,8 @@ async function saveInactivitySetting() {
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
const response = await window.http.wrappedFetch(url, {
|
||||
method,
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to save setting');
|
||||
@@ -1708,9 +1679,8 @@ async function deleteSetting(settingKey) {
|
||||
if (!confirm('Are you sure you want to delete this setting?')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/admin/settings/' + encodeURIComponent(settingKey), {
|
||||
method: 'DELETE',
|
||||
headers: getAuthHeaders()
|
||||
const response = await window.http.wrappedFetch('/api/admin/settings/' + encodeURIComponent(settingKey), {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
@@ -1730,9 +1700,7 @@ async function deleteSetting(settingKey) {
|
||||
// Maintenance Functions
|
||||
async function loadLookupTables() {
|
||||
try {
|
||||
const response = await fetch('/api/admin/lookups/tables', {
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
const response = await window.http.wrappedFetch('/api/admin/lookups/tables');
|
||||
const data = await response.json();
|
||||
|
||||
const element = document.getElementById('lookup-tables');
|
||||
@@ -1756,9 +1724,8 @@ async function vacuumDatabase() {
|
||||
if (!confirm('This will optimize the database. Continue?')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/admin/maintenance/vacuum', {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders()
|
||||
const response = await window.http.wrappedFetch('/api/admin/maintenance/vacuum', {
|
||||
method: 'POST'
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
@@ -1779,9 +1746,8 @@ async function analyzeDatabase() {
|
||||
if (!confirm('This will analyze database statistics. Continue?')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/admin/maintenance/analyze', {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders()
|
||||
const response = await window.http.wrappedFetch('/api/admin/maintenance/analyze', {
|
||||
method: 'POST'
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
@@ -1818,9 +1784,7 @@ function addMaintenanceLog(operation, message) {
|
||||
// Backup Functions
|
||||
async function loadBackups() {
|
||||
try {
|
||||
const response = await fetch('/api/admin/backup/list', {
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
const response = await window.http.wrappedFetch('/api/admin/backup/list');
|
||||
const data = await response.json();
|
||||
|
||||
const tbody = document.getElementById('backup-list');
|
||||
@@ -1861,9 +1825,8 @@ async function createBackup() {
|
||||
if (!confirm('Create a new database backup?')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/admin/backup/create', {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders()
|
||||
const response = await window.http.wrappedFetch('/api/admin/backup/create', {
|
||||
method: 'POST'
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
@@ -1882,9 +1845,8 @@ async function createBackup() {
|
||||
|
||||
async function downloadBackup(filename) {
|
||||
try {
|
||||
const response = await fetch('/api/admin/backup/download', {
|
||||
method: 'GET',
|
||||
headers: getAuthHeaders()
|
||||
const response = await window.http.wrappedFetch('/api/admin/backup/download', {
|
||||
method: 'GET'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -1959,9 +1921,7 @@ async function loadIssues() {
|
||||
if (categoryFilter) url += 'category=' + encodeURIComponent(categoryFilter) + '&';
|
||||
if (assignedToMe) url += 'assigned_to_me=true&';
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
const response = await window.http.wrappedFetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load issues');
|
||||
@@ -1981,9 +1941,7 @@ async function loadIssues() {
|
||||
|
||||
async function loadIssueStats() {
|
||||
try {
|
||||
const response = await fetch('/api/support/stats', {
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
const response = await window.http.wrappedFetch('/api/support/stats');
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load issue stats');
|
||||
@@ -2082,9 +2040,7 @@ function filterIssues() {
|
||||
|
||||
async function viewIssue(issueId) {
|
||||
try {
|
||||
const response = await fetch('/api/support/tickets/' + issueId, {
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
const response = await window.http.wrappedFetch('/api/support/tickets/' + issueId);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load issue details');
|
||||
@@ -2174,9 +2130,7 @@ async function viewIssue(issueId) {
|
||||
async function loadUsersForAssignment() {
|
||||
try {
|
||||
if (allUsers.length === 0) {
|
||||
const response = await fetch('/api/admin/users', {
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
const response = await window.http.wrappedFetch('/api/admin/users');
|
||||
allUsers = await response.json();
|
||||
}
|
||||
|
||||
@@ -2235,9 +2189,8 @@ async function updateIssue() {
|
||||
assigned_to: document.getElementById('updateAssignee').value || null
|
||||
};
|
||||
|
||||
const response = await fetch('/api/support/tickets/' + window.currentIssueId, {
|
||||
const response = await window.http.wrappedFetch('/api/support/tickets/' + window.currentIssueId, {
|
||||
method: 'PUT',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify(updateData)
|
||||
});
|
||||
|
||||
@@ -2273,9 +2226,8 @@ async function addResponse() {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/support/tickets/' + window.currentIssueId + '/responses', {
|
||||
const response = await window.http.wrappedFetch('/api/support/tickets/' + window.currentIssueId + '/responses', {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({
|
||||
message: message,
|
||||
is_internal: isInternal
|
||||
@@ -2307,9 +2259,7 @@ let importInProgress = false;
|
||||
|
||||
async function loadAvailableImportFiles() {
|
||||
try {
|
||||
const response = await fetch('/api/import/available-files', {
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
const response = await window.http.wrappedFetch('/api/import/available-files');
|
||||
|
||||
if (!response.ok) throw new Error('Failed to load available files');
|
||||
|
||||
@@ -2351,9 +2301,7 @@ async function loadAvailableImportFiles() {
|
||||
|
||||
async function loadImportStatus() {
|
||||
try {
|
||||
const response = await fetch('/api/import/status', {
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
const response = await window.http.wrappedFetch('/api/import/status');
|
||||
|
||||
if (!response.ok) throw new Error('Failed to load import status');
|
||||
|
||||
@@ -2431,15 +2379,13 @@ async function validateAdminFile() {
|
||||
try {
|
||||
showAdminProgress(true, 'Validating file...');
|
||||
|
||||
const response = await fetch(`/api/import/validate/${fileType}`, {
|
||||
const response = await window.http.wrappedFetch(`/api/import/validate/${fileType}`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Validation failed');
|
||||
throw await window.http.toError(response, 'Validation failed');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
@@ -2447,7 +2393,10 @@ async function validateAdminFile() {
|
||||
|
||||
} catch (error) {
|
||||
console.error('Validation error:', error);
|
||||
showAlert('Validation failed: ' + error.message, 'error');
|
||||
const message = window.http && typeof window.http.formatAlert === 'function'
|
||||
? window.http.formatAlert(error, 'Validation failed')
|
||||
: 'Validation failed: ' + (error && error.message ? error.message : String(error));
|
||||
showAlert(message, 'error');
|
||||
} finally {
|
||||
showAdminProgress(false);
|
||||
}
|
||||
@@ -2550,15 +2499,13 @@ async function handleAdminImport(event) {
|
||||
try {
|
||||
showAdminProgress(true, 'Importing data...');
|
||||
|
||||
const response = await fetch(`/api/import/upload/${fileType}`, {
|
||||
const response = await window.http.wrappedFetch(`/api/import/upload/${fileType}`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Import failed');
|
||||
throw await window.http.toError(response, 'Import failed');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
@@ -2572,7 +2519,10 @@ async function handleAdminImport(event) {
|
||||
|
||||
} catch (error) {
|
||||
console.error('Import error:', error);
|
||||
showAlert('Import failed: ' + error.message, 'error');
|
||||
const message = window.http && typeof window.http.formatAlert === 'function'
|
||||
? window.http.formatAlert(error, 'Import failed')
|
||||
: 'Import failed: ' + (error && error.message ? error.message : String(error));
|
||||
showAlert(message, 'error');
|
||||
} finally {
|
||||
importInProgress = false;
|
||||
showAdminProgress(false);
|
||||
@@ -2640,14 +2590,12 @@ async function clearAdminTable() {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/import/clear/${fileType}`, {
|
||||
method: 'DELETE',
|
||||
headers: getAuthHeaders()
|
||||
const response = await window.http.wrappedFetch(`/api/import/clear/${fileType}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Clear operation failed');
|
||||
throw await window.http.toError(response, 'Clear operation failed');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
@@ -2658,7 +2606,10 @@ async function clearAdminTable() {
|
||||
|
||||
} catch (error) {
|
||||
console.error('Clear table error:', error);
|
||||
showAlert('Clear operation failed: ' + error.message, 'error');
|
||||
const message = window.http && typeof window.http.formatAlert === 'function'
|
||||
? window.http.formatAlert(error, 'Clear operation failed')
|
||||
: 'Clear operation failed: ' + (error && error.message ? error.message : String(error));
|
||||
showAlert(message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -362,8 +362,9 @@
|
||||
</script>
|
||||
|
||||
<!-- Custom JavaScript -->
|
||||
<!-- Fetch wrapper must be loaded first to augment global fetch() before other scripts -->
|
||||
<!-- Fetch wrapper should be loaded early. It exposes window.http.wrappedFetch and also wraps global fetch for compatibility. -->
|
||||
<script src="/static/js/fetch-wrapper.js"></script>
|
||||
<script src="/static/js/sanitizer.js"></script>
|
||||
<!-- Load main.js first so global handlers are registered before other scripts -->
|
||||
<script src="/static/js/main.js"></script>
|
||||
<script src="/static/js/alerts.js"></script>
|
||||
|
||||
@@ -32,12 +32,12 @@
|
||||
<div class="lg:col-span-2">
|
||||
<label for="searchInput" class="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">Search Customers</label>
|
||||
<div class="relative">
|
||||
<input type="text" id="searchInput" class="w-full pl-10 pr-4 py-3 bg-white dark:bg-neutral-800 border border-neutral-300 dark:border-neutral-600 rounded-lg text-neutral-900 dark:text-neutral-100 placeholder-neutral-400 dark:placeholder-neutral-500 focus:ring-2 focus:ring-primary-500 focus:border-transparent transition-all duration-200" placeholder="Search by name, ID, city, email...">
|
||||
<input type="text" id="searchInput" class="w-full pl-10 pr-4 py-3 bg-white dark:bg-neutral-800 border border-neutral-300 dark:border-neutral-600 rounded-lg text-neutral-900 dark:text-neutral-100 placeholder-neutral-400 dark:placeholder-neutral-500 focus:ring-2 focus:ring-primary-500 focus:border-transparent transition-all duration-200" placeholder="Start typing to search customers...">
|
||||
<div class="absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<i class="fa-solid fa-magnifying-glass text-neutral-400 dark:text-neutral-500"></i>
|
||||
</div>
|
||||
<button id="searchBtn" class="absolute inset-y-0 right-0 flex items-center pr-3 text-neutral-400 hover:text-primary-600 dark:text-neutral-500 dark:hover:text-primary-400 transition-colors">
|
||||
<i class="fa-solid fa-arrow-right text-lg"></i>
|
||||
<i id="searchIcon" class="fa-solid fa-magnifying-glass text-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -58,7 +58,7 @@
|
||||
<div class="max-w-md">
|
||||
<label for="phoneSearch" class="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">Phone Search</label>
|
||||
<div class="relative">
|
||||
<input type="text" id="phoneSearch" class="w-full pl-10 pr-4 py-3 bg-white dark:bg-neutral-800 border border-neutral-300 dark:border-neutral-600 rounded-lg text-neutral-900 dark:text-neutral-100 placeholder-neutral-400 dark:placeholder-neutral-500 focus:ring-2 focus:ring-primary-500 focus:border-transparent transition-all duration-200" placeholder="Search by phone number...">
|
||||
<input type="text" id="phoneSearch" class="w-full pl-10 pr-4 py-3 bg-white dark:bg-neutral-800 border border-neutral-300 dark:border-neutral-600 rounded-lg text-neutral-900 dark:text-neutral-100 placeholder-neutral-400 dark:placeholder-neutral-500 focus:ring-2 focus:ring-primary-500 focus:border-transparent transition-all duration-200" placeholder="Type phone number to search...">
|
||||
<div class="absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<i class="fa-solid fa-phone text-neutral-400 dark:text-neutral-500"></i>
|
||||
</div>
|
||||
@@ -77,13 +77,13 @@
|
||||
<table class="w-full text-sm text-left text-neutral-900 dark:text-neutral-100" id="customersTable">
|
||||
<thead class="bg-neutral-50 dark:bg-neutral-800/50">
|
||||
<tr class="border-b border-neutral-200 dark:border-neutral-700">
|
||||
<th class="px-6 py-4 text-xs font-semibold text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">ID</th>
|
||||
<th class="px-6 py-4 text-xs font-semibold text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Name</th>
|
||||
<th class="px-6 py-4 text-xs font-semibold text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Group</th>
|
||||
<th class="px-6 py-4 text-xs font-semibold text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Location</th>
|
||||
<th class="px-6 py-4 text-xs font-semibold text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Phone</th>
|
||||
<th class="px-6 py-4 text-xs font-semibold text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Email</th>
|
||||
<th class="px-6 py-4 text-xs font-semibold text-neutral-500 dark:text-neutral-400 uppercase tracking-wider text-right">Actions</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Customer</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Name</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Group</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Location</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Phone</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Email</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="customersTableBody" class="divide-y divide-neutral-200 dark:divide-neutral-700">
|
||||
@@ -277,7 +277,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/js/customers-tailwind.js"></script>
|
||||
<script src="/static/js/customers-tailwind.js?v=12"></script>
|
||||
|
||||
<script>
|
||||
// Initialize on page load
|
||||
@@ -296,16 +296,49 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
});
|
||||
|
||||
function setupEventListeners() {
|
||||
// Search functionality
|
||||
document.getElementById('searchBtn').addEventListener('click', performSearch);
|
||||
document.getElementById('searchInput').addEventListener('keypress', function(e) {
|
||||
if (e.key === 'Enter') performSearch();
|
||||
// Real-time search functionality with debouncing
|
||||
let searchTimeout;
|
||||
const searchInput = document.getElementById('searchInput');
|
||||
|
||||
searchInput.addEventListener('input', function(e) {
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(() => {
|
||||
performSearch();
|
||||
}, 300); // Wait 300ms after user stops typing
|
||||
});
|
||||
|
||||
// Phone search
|
||||
// Keep existing functionality for search button and Enter key
|
||||
document.getElementById('searchBtn').addEventListener('click', performSearch);
|
||||
searchInput.addEventListener('keypress', function(e) {
|
||||
if (e.key === 'Enter') {
|
||||
clearTimeout(searchTimeout);
|
||||
performSearch();
|
||||
}
|
||||
});
|
||||
|
||||
// Real-time phone search functionality with debouncing
|
||||
let phoneSearchTimeout;
|
||||
const phoneSearchInput = document.getElementById('phoneSearch');
|
||||
|
||||
phoneSearchInput.addEventListener('input', function(e) {
|
||||
clearTimeout(phoneSearchTimeout);
|
||||
phoneSearchTimeout = setTimeout(() => {
|
||||
if (phoneSearchInput.value.trim()) {
|
||||
performPhoneSearch();
|
||||
} else {
|
||||
// If phone search is cleared, go back to regular customer list
|
||||
loadCustomers();
|
||||
}
|
||||
}, 300); // Wait 300ms after user stops typing
|
||||
});
|
||||
|
||||
// Keep existing functionality for phone search button and Enter key
|
||||
document.getElementById('phoneSearchBtn').addEventListener('click', performPhoneSearch);
|
||||
document.getElementById('phoneSearch').addEventListener('keypress', function(e) {
|
||||
if (e.key === 'Enter') performPhoneSearch();
|
||||
phoneSearchInput.addEventListener('keypress', function(e) {
|
||||
if (e.key === 'Enter') {
|
||||
clearTimeout(phoneSearchTimeout);
|
||||
performPhoneSearch();
|
||||
}
|
||||
});
|
||||
|
||||
// Modal buttons
|
||||
@@ -313,6 +346,7 @@ function setupEventListeners() {
|
||||
document.getElementById('saveCustomerBtn').addEventListener('click', saveCustomer);
|
||||
document.getElementById('deleteCustomerBtn').addEventListener('click', deleteCustomer);
|
||||
document.getElementById('statsBtn').addEventListener('click', showStats);
|
||||
document.getElementById('addPhoneBtn').addEventListener('click', addPhoneNumber);
|
||||
|
||||
// Form validation
|
||||
const customerIdInput = document.getElementById('customerId');
|
||||
@@ -329,6 +363,9 @@ function closeStatsModal() {
|
||||
// Load customers with enhanced formatting
|
||||
async function loadCustomers(page = 0, search = '') {
|
||||
try {
|
||||
// Show loading state
|
||||
setSearchLoading(true);
|
||||
|
||||
const params = new URLSearchParams({
|
||||
skip: page * 50,
|
||||
limit: 50
|
||||
@@ -336,9 +373,7 @@ async function loadCustomers(page = 0, search = '') {
|
||||
|
||||
if (search) params.append('search', search);
|
||||
|
||||
const response = await fetch(`/api/customers/?${params}`, {
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
const response = await window.http.wrappedFetch(`/api/customers/?${params}`);
|
||||
|
||||
if (!response.ok) throw new Error('Failed to load customers');
|
||||
|
||||
@@ -348,11 +383,23 @@ async function loadCustomers(page = 0, search = '') {
|
||||
} catch (error) {
|
||||
console.error('Error loading customers:', error);
|
||||
showAlert('Error loading customers: ' + error.message, 'danger');
|
||||
} finally {
|
||||
// Hide loading state
|
||||
setSearchLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function setSearchLoading(isLoading) {
|
||||
const searchIcon = document.getElementById('searchIcon');
|
||||
if (isLoading) {
|
||||
searchIcon.className = 'fa-solid fa-spinner fa-spin text-lg';
|
||||
} else {
|
||||
searchIcon.className = 'fa-solid fa-magnifying-glass text-lg';
|
||||
}
|
||||
}
|
||||
|
||||
function performSearch() {
|
||||
currentSearch = document.getElementById('searchInput').value;
|
||||
currentSearch = document.getElementById('searchInput').value.trim();
|
||||
currentPage = 0;
|
||||
loadCustomers(currentPage, currentSearch);
|
||||
}
|
||||
@@ -362,9 +409,7 @@ async function performPhoneSearch() {
|
||||
if (!phone) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/customers/search/phone?phone=${encodeURIComponent(phone)}`, {
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
const response = await window.http.wrappedFetch(`/api/customers/search/phone?phone=${encodeURIComponent(phone)}`);
|
||||
|
||||
if (!response.ok) throw new Error('Phone search failed');
|
||||
|
||||
@@ -394,25 +439,25 @@ function displayPhoneSearchResults(results) {
|
||||
const row = document.createElement('tr');
|
||||
row.className = 'hover:bg-neutral-50 dark:hover:bg-neutral-800/50 transition-colors duration-150';
|
||||
row.innerHTML = `
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="text-sm font-medium text-neutral-900 dark:text-neutral-100">${result.customer.id}</div>
|
||||
<td class=\"px-3 py-2 whitespace-nowrap\">
|
||||
<div class=\"text-sm font-mono text-neutral-900 dark:text-neutral-100 truncate\" title=\"${result.customer.id}\">${result.customer.id}</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="text-sm font-medium text-neutral-900 dark:text-neutral-100">${result.customer.name}</div>
|
||||
<td class=\"px-3 py-2 whitespace-nowrap\">
|
||||
<div class=\"text-sm font-medium text-neutral-900 dark:text-neutral-100 truncate\" title=\"${result.customer.name}\">${result.customer.name}</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<td class=\"px-3 py-2 whitespace-nowrap\">
|
||||
<span class="text-neutral-400 dark:text-neutral-500">-</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-neutral-900 dark:text-neutral-100">
|
||||
<td class=\"px-3 py-2 whitespace-nowrap text-sm text-neutral-900 dark:text-neutral-100 truncate\" title=\"${result.customer.city}, ${result.customer.state}\">
|
||||
${result.customer.city}, ${result.customer.state}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-neutral-900 dark:text-neutral-100">
|
||||
<td class=\"px-3 py-2 whitespace-nowrap text-sm text-neutral-900 dark:text-neutral-100 truncate\" title=\"${result.location}: ${result.phone}\">
|
||||
<div class="font-semibold text-warning-600 dark:text-warning-400">${result.location}: ${result.phone}</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<td class=\"px-3 py-2 whitespace-nowrap\">
|
||||
<span class="text-neutral-400 dark:text-neutral-500">-</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<td class=\"px-3 py-2 whitespace-nowrap text-right text-sm font-medium\">
|
||||
<button onclick="editCustomer('${result.customer.id}')" class="inline-flex items-center gap-1 px-3 py-1.5 bg-primary-600 text-white hover:bg-primary-700 rounded-lg transition-colors duration-200 text-xs">
|
||||
<i class="fa-solid fa-pencil"></i>
|
||||
<span>Edit</span>
|
||||
@@ -425,9 +470,7 @@ function displayPhoneSearchResults(results) {
|
||||
|
||||
async function loadGroups() {
|
||||
try {
|
||||
const response = await fetch('/api/customers/groups', {
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
const response = await window.http.wrappedFetch('/api/customers/groups');
|
||||
|
||||
if (response.ok) {
|
||||
const groups = await response.json();
|
||||
@@ -446,9 +489,7 @@ async function loadGroups() {
|
||||
|
||||
async function loadStates() {
|
||||
try {
|
||||
const response = await fetch('/api/customers/states', {
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
const response = await window.http.wrappedFetch('/api/customers/states');
|
||||
|
||||
if (response.ok) {
|
||||
const states = await response.json();
|
||||
@@ -467,9 +508,7 @@ async function loadStates() {
|
||||
|
||||
async function showStats() {
|
||||
try {
|
||||
const response = await fetch('/api/customers/stats', {
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
const response = await window.http.wrappedFetch('/api/customers/stats');
|
||||
|
||||
if (!response.ok) throw new Error('Failed to load statistics');
|
||||
|
||||
@@ -520,25 +559,6 @@ function displayStats(stats) {
|
||||
document.getElementById('statsModal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
// Placeholder functions for now
|
||||
function editCustomer(customerId) {
|
||||
showAlert('Edit customer feature coming soon...', 'info');
|
||||
}
|
||||
|
||||
function viewCustomer(customerId) {
|
||||
showAlert('View customer feature coming soon...', 'info');
|
||||
}
|
||||
|
||||
function saveCustomer() {
|
||||
showAlert('Save customer feature coming soon...', 'info');
|
||||
}
|
||||
|
||||
function deleteCustomer() {
|
||||
showAlert('Delete customer feature coming soon...', 'info');
|
||||
}
|
||||
|
||||
function validateCustomerId() {
|
||||
// Placeholder
|
||||
}
|
||||
// Functions are now implemented in the external customers-tailwind.js file
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -191,11 +191,7 @@
|
||||
// Load dashboard data
|
||||
async function loadDashboardData() {
|
||||
try {
|
||||
const response = await fetch('/api/admin/stats', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`
|
||||
}
|
||||
});
|
||||
const response = await window.http.wrappedFetch('/api/admin/stats');
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
|
||||
@@ -446,9 +446,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
function initializeDocuments() {
|
||||
if (typeof apiGet === 'function') {
|
||||
// Ensure API headers are set up with token
|
||||
if (window.apiHeaders && token) {
|
||||
window.apiHeaders['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
// Authorization is injected by window.http.wrappedFetch
|
||||
|
||||
// Initialize the first tab as active
|
||||
document.getElementById('templates-tab').click();
|
||||
@@ -594,14 +592,7 @@ function setupEventHandlers() {
|
||||
document.getElementById('refreshQdrosBtn').addEventListener('click', loadQdros);
|
||||
}
|
||||
|
||||
// Helper function for authenticated API calls
|
||||
function getAuthHeaders() {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
return {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
}
|
||||
// Authorization and JSON headers are injected by window.http.wrappedFetch
|
||||
|
||||
async function loadTemplates() {
|
||||
try {
|
||||
@@ -624,9 +615,7 @@ async function loadTemplates() {
|
||||
|
||||
console.log('🔍 DEBUG: Making API call to:', url);
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
const response = await window.http.wrappedFetch(url);
|
||||
console.log('🔍 DEBUG: Response status:', response.status);
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -658,7 +647,7 @@ function createTemplateRow(template) {
|
||||
const row = document.createElement('tr');
|
||||
const variableCount = Object.keys(template.variables || {}).length;
|
||||
|
||||
row.innerHTML = `
|
||||
const rowHtml = `
|
||||
<td class="px-4 py-2"><code>${template.form_id}</code></td>
|
||||
<td class="px-4 py-2">${template.form_name}</td>
|
||||
<td class="px-4 py-2"><span class="inline-block px-2 py-0.5 text-xs rounded bg-neutral-100 text-neutral-700 border border-neutral-300">${template.category}</span></td>
|
||||
@@ -677,6 +666,7 @@ function createTemplateRow(template) {
|
||||
</div>
|
||||
</td>
|
||||
`;
|
||||
if (window.setSafeHTML) { window.setSafeHTML(row, rowHtml); } else { row.innerHTML = rowHtml; }
|
||||
|
||||
return row;
|
||||
}
|
||||
@@ -696,9 +686,7 @@ async function loadQdros() {
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
const response = await window.http.wrappedFetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ detail: 'Unknown error' }));
|
||||
@@ -727,7 +715,7 @@ function displayQdros(qdros) {
|
||||
function createQdroRow(qdro) {
|
||||
const row = document.createElement('tr');
|
||||
|
||||
row.innerHTML = `
|
||||
const qdroRowHtml = `
|
||||
<td><code>${qdro.file_no}</code></td>
|
||||
<td>${qdro.version}</td>
|
||||
<td>${qdro.participant_name || ''}</td>
|
||||
@@ -747,6 +735,7 @@ function createQdroRow(qdro) {
|
||||
</div>
|
||||
</td>
|
||||
`;
|
||||
if (window.setSafeHTML) { window.setSafeHTML(row, qdroRowHtml); } else { row.innerHTML = qdroRowHtml; }
|
||||
|
||||
return row;
|
||||
}
|
||||
@@ -768,9 +757,7 @@ async function loadCategories() {
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch('/api/documents/categories/', {
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
const response = await window.http.wrappedFetch('/api/documents/categories/');
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ detail: 'Unknown error' }));
|
||||
@@ -819,9 +806,7 @@ function openTemplateModal(templateId = null) {
|
||||
|
||||
async function loadTemplateForEditing(templateId) {
|
||||
try {
|
||||
const response = await fetch(`/api/documents/templates/${templateId}`, {
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
const response = await window.http.wrappedFetch(`/api/documents/templates/${templateId}`);
|
||||
if (!response.ok) throw new Error('Failed to load template');
|
||||
|
||||
const template = await response.json();
|
||||
@@ -856,11 +841,8 @@ async function saveTemplate() {
|
||||
const url = isEdit ? `/api/documents/templates/${templateData.form_id}` : '/api/documents/templates/';
|
||||
const method = isEdit ? 'PUT' : 'POST';
|
||||
|
||||
const response = await fetch(url, {
|
||||
const response = await window.http.wrappedFetch(url, {
|
||||
method: method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(templateData)
|
||||
});
|
||||
|
||||
@@ -901,9 +883,7 @@ function extractVariables(content) {
|
||||
|
||||
async function loadDocumentStats() {
|
||||
try {
|
||||
const response = await fetch('/api/documents/stats/summary', {
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
const response = await window.http.wrappedFetch('/api/documents/stats/summary');
|
||||
if (!response.ok) throw new Error('Failed to load statistics');
|
||||
|
||||
const stats = await response.json();
|
||||
@@ -918,7 +898,8 @@ async function loadDocumentStats() {
|
||||
Object.entries(stats.templates_by_category).forEach(([category, count]) => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'flex items-center justify-between mb-1';
|
||||
div.innerHTML = `<span>${category}</span><span class="inline-block px-2 py-0.5 text-xs rounded bg-neutral-200 text-neutral-700">${count}</span>`;
|
||||
const html = `<span>${category}</span><span class="inline-block px-2 py-0.5 text-xs rounded bg-neutral-200 text-neutral-700">${count}</span>`;
|
||||
if (window.setSafeHTML) { window.setSafeHTML(div, html); } else { div.innerHTML = html; }
|
||||
categoriesDiv.appendChild(div);
|
||||
});
|
||||
|
||||
@@ -932,11 +913,12 @@ async function loadDocumentStats() {
|
||||
stats.recent_activity.forEach(activity => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'mb-2 p-2 border rounded';
|
||||
div.innerHTML = `
|
||||
const activityHtml = `
|
||||
<small class="text-neutral-500">${activity.type}</small><br>
|
||||
<strong>File: ${activity.file_no}</strong><br>
|
||||
<span class="${getStatusBadgeClass(activity.status)}">${activity.status}</span>
|
||||
`;
|
||||
if (window.setSafeHTML) { window.setSafeHTML(div, activityHtml); } else { div.innerHTML = activityHtml; }
|
||||
activityDiv.appendChild(div);
|
||||
});
|
||||
}
|
||||
@@ -983,9 +965,8 @@ async function logClientError({ message, action = null, error = null, extra = nu
|
||||
user_agent: navigator.userAgent,
|
||||
extra
|
||||
};
|
||||
await fetch('/api/documents/client-error', {
|
||||
await window.http.wrappedFetch('/api/documents/client-error', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
} catch (_) {
|
||||
@@ -1010,9 +991,8 @@ async function generateFromTemplate(templateId) {
|
||||
async function deleteTemplate(templateId) {
|
||||
if (confirm('Are you sure you want to delete this template?')) {
|
||||
try {
|
||||
const response = await fetch(`/api/documents/templates/${templateId}`, {
|
||||
method: 'DELETE',
|
||||
headers: getAuthHeaders()
|
||||
const response = await window.http.wrappedFetch(`/api/documents/templates/${templateId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to delete template');
|
||||
@@ -1034,9 +1014,7 @@ function openGenerateModal() {
|
||||
|
||||
async function loadTemplatesForGeneration() {
|
||||
try {
|
||||
const response = await fetch('/api/documents/templates/', {
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
const response = await window.http.wrappedFetch('/api/documents/templates/');
|
||||
if (!response.ok) throw new Error('Failed to load templates');
|
||||
|
||||
const templates = await response.json();
|
||||
@@ -1057,9 +1035,7 @@ async function loadTemplatesForGeneration() {
|
||||
|
||||
async function loadTemplatePreview(templateId) {
|
||||
try {
|
||||
const response = await fetch(`/api/documents/templates/${templateId}`, {
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
const response = await window.http.wrappedFetch(`/api/documents/templates/${templateId}`);
|
||||
if (!response.ok) throw new Error('Failed to load template');
|
||||
|
||||
const template = await response.json();
|
||||
@@ -1100,9 +1076,8 @@ async function generateDocument() {
|
||||
});
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/documents/generate/${templateId}`, {
|
||||
const response = await window.http.wrappedFetch(`/api/documents/generate/${templateId}`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify(requestData)
|
||||
});
|
||||
|
||||
@@ -1131,7 +1106,7 @@ function addCustomVariableInput() {
|
||||
const container = document.getElementById('customVariables');
|
||||
const div = document.createElement('div');
|
||||
div.className = 'grid grid-cols-12 gap-2 mb-2 custom-var-input';
|
||||
div.innerHTML = `
|
||||
const customVarHtml = `
|
||||
<div class="col-span-12 md:col-span-5">
|
||||
<input type="text" class="w-full px-3 py-1.5 border border-neutral-300 dark:border-neutral-600 rounded var-name" placeholder="Variable name">
|
||||
</div>
|
||||
@@ -1144,6 +1119,7 @@ function addCustomVariableInput() {
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
if (window.setSafeHTML) { window.setSafeHTML(div, customVarHtml); } else { div.innerHTML = customVarHtml; }
|
||||
container.appendChild(div);
|
||||
}
|
||||
|
||||
|
||||
@@ -381,14 +381,7 @@ let lookupData = {
|
||||
employees: []
|
||||
};
|
||||
|
||||
// Helper function for authenticated API calls
|
||||
function getAuthHeaders() {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
return {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
}
|
||||
// Authorization and JSON headers are injected by window.http.wrappedFetch
|
||||
|
||||
// Initialize on page load
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
@@ -446,9 +439,9 @@ async function loadLookupData() {
|
||||
try {
|
||||
// Load all lookup data in parallel
|
||||
const [fileTypesRes, statusesRes, employeesRes] = await Promise.all([
|
||||
fetch('/api/files/lookups/file-types', { headers: getAuthHeaders() }),
|
||||
fetch('/api/files/lookups/file-statuses', { headers: getAuthHeaders() }),
|
||||
fetch('/api/files/lookups/employees', { headers: getAuthHeaders() })
|
||||
window.http.wrappedFetch('/api/files/lookups/file-types'),
|
||||
window.http.wrappedFetch('/api/files/lookups/file-statuses'),
|
||||
window.http.wrappedFetch('/api/files/lookups/employees')
|
||||
]);
|
||||
|
||||
if (fileTypesRes.ok) {
|
||||
@@ -505,9 +498,7 @@ async function loadFiles(page = 0, filters = {}) {
|
||||
...filters
|
||||
});
|
||||
|
||||
const response = await fetch(`/api/files/?${params}`, {
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
const response = await window.http.wrappedFetch(`/api/files/?${params}`);
|
||||
|
||||
if (!response.ok) throw new Error('Failed to load files');
|
||||
|
||||
@@ -628,9 +619,7 @@ function showAddFileModal() {
|
||||
|
||||
async function editFile(fileNo) {
|
||||
try {
|
||||
const response = await fetch(`/api/files/${fileNo}`, {
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
const response = await window.http.wrappedFetch(`/api/files/${fileNo}`);
|
||||
|
||||
if (!response.ok) throw new Error('Failed to load file');
|
||||
|
||||
@@ -687,9 +676,7 @@ function populateFileForm(file) {
|
||||
|
||||
async function loadClientInfo(clientId) {
|
||||
try {
|
||||
const response = await fetch(`/api/customers/${clientId}`, {
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
const response = await window.http.wrappedFetch(`/api/customers/${clientId}`);
|
||||
|
||||
if (response.ok) {
|
||||
const client = await response.json();
|
||||
@@ -705,9 +692,7 @@ async function loadClientInfo(clientId) {
|
||||
|
||||
async function loadFinancialSummary(fileNo) {
|
||||
try {
|
||||
const response = await fetch(`/api/files/${fileNo}/financial-summary`, {
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
const response = await window.http.wrappedFetch(`/api/files/${fileNo}/financial-summary`);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
@@ -771,9 +756,8 @@ async function saveFile() {
|
||||
const url = isEditing ? `/api/files/${editingFileNo}` : '/api/files/';
|
||||
const method = isEditing ? 'PUT' : 'POST';
|
||||
|
||||
const response = await fetch(url, {
|
||||
const response = await window.http.wrappedFetch(url, {
|
||||
method: method,
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify(fileData)
|
||||
});
|
||||
|
||||
@@ -798,9 +782,8 @@ async function deleteFile() {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/files/${editingFileNo}`, {
|
||||
method: 'DELETE',
|
||||
headers: getAuthHeaders()
|
||||
const response = await window.http.wrappedFetch(`/api/files/${editingFileNo}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to delete file');
|
||||
@@ -820,9 +803,8 @@ async function closeFile() {
|
||||
|
||||
try {
|
||||
const body = closeDate ? JSON.stringify({ close_date: closeDate }) : '';
|
||||
const response = await fetch(`/api/files/${editingFileNo}/close`, {
|
||||
const response = await window.http.wrappedFetch(`/api/files/${editingFileNo}/close`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: body
|
||||
});
|
||||
|
||||
@@ -841,9 +823,8 @@ async function closeFile() {
|
||||
|
||||
async function reopenFile() {
|
||||
try {
|
||||
const response = await fetch(`/api/files/${editingFileNo}/reopen`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders()
|
||||
const response = await window.http.wrappedFetch(`/api/files/${editingFileNo}/reopen`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to reopen file');
|
||||
@@ -871,9 +852,7 @@ async function searchClients() {
|
||||
const params = new URLSearchParams({ limit: 100 });
|
||||
if (search) params.append('search', search);
|
||||
|
||||
const response = await fetch(`/api/customers/?${params}`, {
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
const response = await window.http.wrappedFetch(`/api/customers/?${params}`);
|
||||
|
||||
if (!response.ok) throw new Error('Failed to search clients');
|
||||
|
||||
@@ -916,9 +895,7 @@ async function validateFileNumber() {
|
||||
if (!fileNo || isEditing) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/files/${fileNo}`, {
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
const response = await window.http.wrappedFetch(`/api/files/${fileNo}`);
|
||||
|
||||
if (response.ok) {
|
||||
showAlert('File number already exists', 'warning');
|
||||
@@ -951,9 +928,7 @@ async function performAdvancedSearch() {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/files/search/advanced?${params}`, {
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
const response = await window.http.wrappedFetch(`/api/files/search/advanced?${params}`);
|
||||
|
||||
if (!response.ok) throw new Error('Advanced search failed');
|
||||
|
||||
@@ -975,9 +950,7 @@ function clearAdvancedSearch() {
|
||||
|
||||
async function showStats() {
|
||||
try {
|
||||
const response = await fetch('/api/files/stats/summary', {
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
const response = await window.http.wrappedFetch('/api/files/stats/summary');
|
||||
|
||||
if (!response.ok) throw new Error('Failed to load statistics');
|
||||
|
||||
@@ -1057,7 +1030,7 @@ function showAlert(message, type = 'info') {
|
||||
|
||||
async function loadDocuments(fileNo) {
|
||||
try {
|
||||
const response = await fetch(`/api/documents/${fileNo}/uploaded`, { headers: getAuthHeaders() });
|
||||
const response = await window.http.wrappedFetch(`/api/documents/${fileNo}/uploaded`);
|
||||
if (!response.ok) throw new Error('Failed to load documents');
|
||||
const docs = await response.json();
|
||||
displayDocuments(docs);
|
||||
@@ -1126,10 +1099,10 @@ async function uploadDocument() {
|
||||
formData.append('description', description);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/documents/upload/${editingFileNo}`, {
|
||||
const response = await window.http.wrappedFetch(`/api/documents/upload/${editingFileNo}`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: getAuthHeaders() // Note: no Content-Type, browser sets it
|
||||
// Note: no Content-Type; browser sets multipart boundaries
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
@@ -1164,9 +1137,8 @@ function downloadDocument(path) {
|
||||
async function deleteDocument(docId) {
|
||||
if (!confirm('Are you sure you want to delete this document?')) return;
|
||||
try {
|
||||
const response = await fetch(`/api/documents/uploaded/${docId}`, {
|
||||
const response = await window.http.wrappedFetch(`/api/documents/uploaded/${docId}`, {
|
||||
method: 'DELETE',
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to delete');
|
||||
showAlert('Document deleted', 'success');
|
||||
@@ -1186,10 +1158,9 @@ async function updateDocumentDescription(docId, description) {
|
||||
const formData = new FormData();
|
||||
formData.append('description', description);
|
||||
try {
|
||||
const response = await fetch(`/api/documents/uploaded/${docId}`, {
|
||||
const response = await window.http.wrappedFetch(`/api/documents/uploaded/${docId}`, {
|
||||
method: 'PUT',
|
||||
body: formData,
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to update');
|
||||
showAlert('Description updated', 'success');
|
||||
|
||||
@@ -483,14 +483,7 @@ let dashboardData = null;
|
||||
let recentEntries = [];
|
||||
let unbilledData = null;
|
||||
|
||||
// Helper function for authenticated API calls
|
||||
function getAuthHeaders() {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
return {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
}
|
||||
// Authorization and JSON headers are injected by window.http.wrappedFetch
|
||||
|
||||
// Initialize on page load
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
@@ -555,9 +548,7 @@ function setupEventListeners() {
|
||||
|
||||
async function loadDashboardData() {
|
||||
try {
|
||||
const response = await fetch('/api/financial/financial-dashboard', {
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
const response = await window.http.wrappedFetch('/api/financial/financial-dashboard');
|
||||
|
||||
if (!response.ok) throw new Error('Failed to load dashboard data');
|
||||
|
||||
@@ -599,9 +590,7 @@ async function loadRecentTimeEntries() {
|
||||
const params = new URLSearchParams({ days });
|
||||
if (employee) params.append('employee', employee);
|
||||
|
||||
const response = await fetch(`/api/financial/time-entries/recent?${params}`, {
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
const response = await window.http.wrappedFetch(`/api/financial/time-entries/recent?${params}`);
|
||||
|
||||
if (!response.ok) throw new Error('Failed to load recent entries');
|
||||
|
||||
@@ -652,9 +641,7 @@ function displayRecentTimeEntries(entries) {
|
||||
|
||||
async function loadEmployeeOptions() {
|
||||
try {
|
||||
const response = await fetch('/api/files/lookups/employees', {
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
const response = await window.http.wrappedFetch('/api/files/lookups/employees');
|
||||
|
||||
if (response.ok) {
|
||||
const employees = await response.json();
|
||||
@@ -719,9 +706,8 @@ async function saveQuickTime() {
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/financial/time-entry/quick', {
|
||||
const response = await window.http.wrappedFetch('/api/financial/time-entry/quick', {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
@@ -769,9 +755,8 @@ async function savePayment() {
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/financial/payments/', {
|
||||
const response = await window.http.wrappedFetch('/api/financial/payments/', {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
@@ -818,9 +803,8 @@ async function saveExpense() {
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/financial/expenses/', {
|
||||
const response = await window.http.wrappedFetch('/api/financial/expenses/', {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
@@ -850,9 +834,7 @@ async function saveExpense() {
|
||||
async function showUnbilledModal() {
|
||||
// Load unbilled data
|
||||
try {
|
||||
const response = await fetch('/api/financial/unbilled-entries', {
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
const response = await window.http.wrappedFetch('/api/financial/unbilled-entries');
|
||||
|
||||
if (!response.ok) throw new Error('Failed to load unbilled entries');
|
||||
|
||||
@@ -979,9 +961,8 @@ async function billSelectedEntries() {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/financial/bill-entries', {
|
||||
const response = await window.http.wrappedFetch('/api/financial/bill-entries', {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ entry_ids: entryIds })
|
||||
});
|
||||
|
||||
@@ -1081,9 +1062,7 @@ async function validateQuickTimeFile() {
|
||||
if (!fileNo) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/files/${fileNo}`, {
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
const response = await window.http.wrappedFetch(`/api/files/${fileNo}`);
|
||||
|
||||
if (response.ok) {
|
||||
const file = await response.json();
|
||||
|
||||
@@ -235,13 +235,7 @@
|
||||
let availableFiles = {};
|
||||
let importInProgress = false;
|
||||
|
||||
// Helper function for authenticated API calls
|
||||
function getAuthHeaders() {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
return {
|
||||
'Authorization': `Bearer ${token}`
|
||||
};
|
||||
}
|
||||
// Authorization is injected by window.http.wrappedFetch
|
||||
|
||||
// Initialize on page load
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
@@ -288,9 +282,7 @@ function setupEventListeners() {
|
||||
|
||||
async function loadAvailableFiles() {
|
||||
try {
|
||||
const response = await fetch('/api/import/available-files', {
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
const response = await window.http.wrappedFetch('/api/import/available-files');
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
console.error('Authentication error - redirecting to login');
|
||||
@@ -335,9 +327,7 @@ async function loadAvailableFiles() {
|
||||
|
||||
async function loadImportStatus() {
|
||||
try {
|
||||
const response = await fetch('/api/import/status', {
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
const response = await window.http.wrappedFetch('/api/import/status');
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
console.error('Authentication error - redirecting to login');
|
||||
@@ -422,9 +412,8 @@ async function validateFile() {
|
||||
try {
|
||||
showProgress(true, 'Validating file...');
|
||||
|
||||
const response = await fetch(`/api/import/validate/${fileType}`, {
|
||||
const response = await window.http.wrappedFetch(`/api/import/validate/${fileType}`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: formData
|
||||
});
|
||||
|
||||
@@ -540,9 +529,8 @@ async function handleImport(event) {
|
||||
try {
|
||||
showProgress(true, 'Importing data...');
|
||||
|
||||
const response = await fetch(`/api/import/upload/${fileType}`, {
|
||||
const response = await window.http.wrappedFetch(`/api/import/upload/${fileType}`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: formData
|
||||
});
|
||||
|
||||
@@ -629,10 +617,9 @@ async function clearTable() {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/import/clear/${fileType}`, {
|
||||
method: 'DELETE',
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
const response = await window.http.wrappedFetch(`/api/import/clear/${fileType}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
@@ -769,11 +756,10 @@ async function handleBatchImport(event) {
|
||||
try {
|
||||
showProgress(true, 'Processing batch import...');
|
||||
|
||||
const response = await fetch('/api/import/batch-upload', {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: formData
|
||||
});
|
||||
const response = await window.http.wrappedFetch('/api/import/batch-upload', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Login - Delphi Consulting Group Database System</title>
|
||||
<script src="/static/js/fetch-wrapper.js"></script>
|
||||
<script src="/static/js/alerts.js"></script>
|
||||
<!-- Tailwind CSS -->
|
||||
<link href="/static/css/tailwind.css" rel="stylesheet">
|
||||
@@ -96,7 +97,7 @@
|
||||
|
||||
try {
|
||||
console.log('Sending request to /api/auth/login');
|
||||
const response = await fetch('/api/auth/login', {
|
||||
const response = await window.http.wrappedFetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
@@ -154,11 +155,7 @@
|
||||
|
||||
async function checkTokenAndRedirect(token) {
|
||||
try {
|
||||
const response = await fetch('/api/auth/me', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
const response = await window.http.wrappedFetch('/api/auth/me');
|
||||
|
||||
if (response.ok) {
|
||||
// Token is valid, redirect to customers page
|
||||
|
||||
@@ -443,7 +443,7 @@ function initializeAdvancedSearch() {
|
||||
|
||||
async function loadSearchFacets() {
|
||||
try {
|
||||
const response = await fetch('/api/search/facets');
|
||||
const response = await window.http.wrappedFetch('/api/search/facets');
|
||||
if (!response.ok) throw new Error('Failed to load search facets');
|
||||
|
||||
facetsData = await response.json();
|
||||
@@ -576,7 +576,7 @@ function setupKeyboardShortcuts() {
|
||||
|
||||
async function loadSearchSuggestions(query) {
|
||||
try {
|
||||
const response = await fetch(`/api/search/suggestions?q=${encodeURIComponent(query)}&limit=10`);
|
||||
const response = await window.http.wrappedFetch(`/api/search/suggestions?q=${encodeURIComponent(query)}&limit=10`);
|
||||
if (!response.ok) throw new Error('Failed to load suggestions');
|
||||
|
||||
const data = await response.json();
|
||||
@@ -631,11 +631,8 @@ async function performSearch(offset = 0) {
|
||||
currentSearchCriteria = criteria;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/search/advanced', {
|
||||
const response = await window.http.wrappedFetch('/api/search/advanced', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(criteria)
|
||||
});
|
||||
|
||||
|
||||
@@ -295,11 +295,8 @@ let supportSystem = {
|
||||
browser_info: this.browserInfo
|
||||
};
|
||||
|
||||
const response = await fetch('/api/support/tickets', {
|
||||
const response = await window.http.wrappedFetch('/api/support/tickets', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(ticketData)
|
||||
});
|
||||
|
||||
|
||||
3
test_deposits.csv
Normal file
3
test_deposits.csv
Normal file
@@ -0,0 +1,3 @@
|
||||
Deposit_Date,Total
|
||||
2024-01-01,100.00
|
||||
2024-01-02,200.50
|
||||
|
Reference in New Issue
Block a user