# Listing Enhancer — listing-enhancer

Paste property basics and optional photos; get an MLS/fair-housing–safe listing description, highlight bullets, and photo alt text. Works with OpenAI Vision when available and degrades gracefully without it.

## Features
- Guided inputs:
  - Basic Info (textarea, 2000 chars max)
  - Photos (JPG/PNG/WEBP, up to 12 files, 40 MB total)
  - Tone (professional, warm, luxury, concise), Length (short/medium/long), Audience (buyers/renters)
  - Language (en, es), Generate Photo Alt Text (on/off), MLS/Fair Housing Safe (on/off)
- Outputs:
  - Listing description (text)
  - Highlights (bulleted list)
  - Photo captions/alt text (per image)
- History: saves past runs; browse and re-export
- Exports: CSV and JSON
- Optional AI:
  - With OpenAI Vision: generates description, highlights, and alt text
  - Fallback mode: produces structured, non-AI templated content if AI is disabled/unavailable
- Security: CSP headers, CSRF protection, prepared statements, output escaping, upload sanitization
- Rate limiting: 8 requests/minute (persisted)
- Light UI with teal accent; card layout
- Freemium quotas: 5 free runs, 100 pro runs (configurable)

## Requirements
- PHP 8.2+
  - Extensions: PDO (mysql), cURL, json, mbstring, fileinfo
- MySQL 8.x
- Web server (Apache/Nginx) or PHP built-in server for local use
- Recommended PHP ini for uploads:
  - upload_max_filesize ≥ 50M
  - post_max_size ≥ 50M
  - memory_limit ≥ 256M

## Quick Start
1. Database
   - Create database and run schema:

```sql
CREATE TABLE runs (
  id BIGINT PRIMARY KEY AUTO_INCREMENT,
  session_id VARCHAR(64) NOT NULL,
  ip VARBINARY(16) NULL,
  created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  basic_info TEXT NOT NULL,
  tone ENUM('professional','warm','luxury','concise') NOT NULL DEFAULT 'professional',
  length ENUM('short','medium','long') NOT NULL DEFAULT 'medium',
  audience ENUM('buyers','renters') NOT NULL DEFAULT 'buyers',
  generate_alt TINYINT(1) NOT NULL DEFAULT 1,
  mls_safe TINYINT(1) NOT NULL DEFAULT 1,
  language ENUM('en','es') NOT NULL DEFAULT 'en',
  ai_used TINYINT(1) NOT NULL DEFAULT 0,
  description MEDIUMTEXT NULL,
  highlights JSON NULL,
  photo_captions JSON NULL,
  error TEXT NULL,
  INDEX (session_id),
  INDEX (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE images (
  id BIGINT PRIMARY KEY AUTO_INCREMENT,
  run_id BIGINT NOT NULL,
  original_name VARCHAR(255) NOT NULL,
  stored_name VARCHAR(255) NOT NULL,
  mime VARCHAR(64) NOT NULL,
  size_bytes INT NOT NULL,
  width INT NULL,
  height INT NULL,
  created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  INDEX (run_id),
  CONSTRAINT fk_images_runs FOREIGN KEY (run_id) REFERENCES runs(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE rate_limits (
  id BIGINT PRIMARY KEY AUTO_INCREMENT,
  key_hash CHAR(64) NOT NULL,
  window_start TIMESTAMP NOT NULL,
  count INT NOT NULL DEFAULT 0,
  UNIQUE KEY uniq_key_window (key_hash, window_start)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
```

2. Environment
   - Set the environment variables below (system env or a .env file if you use a loader).
   - Create a writable upload directory (example): storage/uploads

3. Run locally
   - Place the single-page app (index.php) in your web root
   - Start: php -S 127.0.0.1:8000
   - Visit: http://127.0.0.1:8000

4. Deploy
   - Serve via HTTPS behind Nginx/Apache + PHP-FPM
   - Point the vhost to the app directory; keep storage/uploads writable and, if possible, outside web root
   - Set security headers (see Security section)
   - Provide OPENAI_API_KEY only if you want AI features

## Configuration (env vars)
| Variable | Required | Default | Description |
|---|---:|---|---|
| APP_ENV | no | production | Set to production or development |
| APP_URL | no | (auto) | Base URL used in links |
| APP_SECRET | yes | — | 32+ char secret for CSRF/signing |
| APP_DB_HOST | yes | 127.0.0.1 | MySQL host |
| APP_DB_PORT | no | 3306 | MySQL port |
| APP_DB_NAME | yes | — | MySQL database name |
| APP_DB_USER | yes | — | MySQL user |
| APP_DB_PASS | yes | — | MySQL password |
| APP_RATE_LIMIT_PER_MIN | no | 8 | Requests per minute per key/IP |
| APP_FREE_QUOTA | no | 5 | Free run quota (per IP/session/day) |
| APP_PRO_QUOTA | no | 100 | Pro run quota (if you gate by key) |
| APP_UPLOAD_DIR | no | storage/uploads | Directory for images (writable) |
| APP_MAX_FILES | no | 12 | Max photos per run |
| APP_MAX_UPLOAD_MB | no | 40 | Max total upload size (MB) |
| APP_CSP | no | strict | Toggle/level of CSP; strict recommended |
| APP_CRON_KEY | no | — | Secret to authorize maintenance tasks |
| OPENAI_ENABLED | no | 1 | 1=use OpenAI if configured; 0=disable |
| OPENAI_API_KEY | no | — | Your OpenAI API key (required for AI) |
| OPENAI_BASE_URL | no | https://api.openai.com | Override for self-hosted proxies |
| OPENAI_MODEL_VISION | no | gpt-4o-mini | Vision-capable model for images |
| OPENAI_MODEL_TEXT | no | gpt-4o-mini | Text model for description |
| OPENAI_TEMPERATURE | no | 0.6 | Sampling temperature |
| OPENAI_TIMEOUT_SEC | no | 30 | HTTP timeout for AI calls |

Notes:
- If OPENAI_ENABLED=0 or OPENAI_API_KEY is unset, the app runs in fallback mode and never sends data to OpenAI.
- The app uses PDO prepared statements and JSON columns (MySQL 8).
- Quotas (free/pro) are app-level soft limits; you can enforce via keys, IPs, or session.

## Usage
1. Fill Basic Info (property type, beds/baths, square footage, neighborhood, notable features).
2. Optionally upload photos (JPG/PNG/WEBP).
3. Choose tone, length, audience, language; toggle Generate Photo Alt Text and MLS/Fair Housing Safe.
4. Submit to generate:
   - Description
   - Highlights (bulleted)
   - Per-photo alt text/captions
5. Export or revisit past runs from History.

## Exports (CSV/JSON)
- Export the latest or a specific run:
  - Latest: GET /?export=json or /?export=csv
  - By id: GET /?export=json&id={run_id} or /?export=csv&id={run_id}
- CSV columns: id, created_at, tone, length, audience, language, ai_used, description, highlights_json, photo_captions_json
- JSON: full record including images and structured arrays

## Optional AI behavior and fallback mode
- With AI (OPENAI_ENABLED=1 and OPENAI_API_KEY set):
  - Sends Basic Info and optionally photos to OpenAI Vision
  - System prompt enforces MLS/Fair Housing–safe language; temperature defaults to 0.6
  - Returns description, highlights, and per-image alt text
- Fallback (no key, disabled, or AI error/timeout):
  - Creates a clean, structured, non-creative description based on Basic Info
  - Generates neutral, file-based alt text (e.g., “Property photo 1”) if Generate Photo Alt Text is on
  - Always applies MLS/Fair Housing safety filters
- Privacy: Photos and text are sent to OpenAI only when AI is enabled and you submit a run.

## Security
- Content Security Policy (recommended)
  - default-src 'self'; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; script-src 'self' 'nonce-...'; connect-src 'self' https://api.openai.com; base-uri 'none'; form-action 'self'; frame-ancestors 'none'
  - X-Content-Type-Options: nosniff; Referrer-Policy: no-referrer; X-Frame-Options: DENY; Permissions-Policy: geolocation=(), camera=(), microphone=()
- CSRF protection
  - Session-based token + hidden form field; double-submit cookie pattern; APP_SECRET used for token integrity
- Prepared statements and output escaping
  - All database writes/reads via PDO prepared statements
  - All HTML output escaped (htmlspecialchars) and attributes sanitized
- Upload hardening
  - Validates MIME via finfo; whitelists jpeg/png/webp; randomizes filenames; stores outside web root when possible
  - Enforces max 12 files and 40 MB total; strips EXIF on read (no server-side execution)
- Rate limits
  - 8/min per IP/session stored in rate_limits; 429 on exceed
- Compliance guardrails
  - “MLS/Fair Housing Safe” mode filters and avoids references to protected classes, steering, and exclusionary language
- Cookies
  - Secure, HttpOnly, SameSite=Lax; require HTTPS in production

## Maintenance and optional cron
- Prune old runs/rate-limit windows and orphaned uploads:
  - Protected endpoint: GET /?task=prune&key=APP_CRON_KEY
  - Example crontab (every night): curl -fsS https://your.app/?task=prune&key=... >/dev/null
- Rotate APP_SECRET and rotate cookies/tokens after deployments.

## Database overview
- runs: each generation request and outputs
- images: uploaded images linked to runs
- rate_limits: per-key/IP sliding or fixed windows

## Fair Housing and MLS safety
- The app aims for neutral, inclusive language and avoids discriminatory terms.
- You are responsible for final review and compliance with local laws and MLS rules. This tool is assistive, not legal advice.

## Acceptance checklist
- [ ] PHP 8.2+ with PDO, cURL, json, mbstring, fileinfo enabled
- [ ] MySQL 8 DB created; runs, images, rate_limits tables migrated
- [ ] APP_DB_* and APP_SECRET set; uploads directory writable
- [ ] CSP and security headers enabled in web server
- [ ] Rate limit and quotas configured (APP_RATE_LIMIT_PER_MIN, APP_FREE_QUOTA, APP_PRO_QUOTA)
- [ ] OpenAI configured (OPENAI_ENABLED=1 and OPENAI_API_KEY) or intentionally disabled for fallback
- [ ] Test run with photos (JPG/PNG/WEBP), verify description, highlights, alt text
- [ ] Export works (/?export=json and /?export=csv)
- [ ] Optional cron prune job configured (APP_CRON_KEY)

## License
MIT (default). See LICENSE or replace with your organization’s license.