diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..8154990 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,310 @@ +--- +name: CI + +on: + push: + branches: [master, dev] + pull_request: + types: [opened, synchronize, reopened] + +jobs: + # Check if CI should be skipped based on branch name or commit message + skip-ci-check: + runs-on: ubuntu-latest + outputs: + should-skip: ${{ steps.check.outputs.skip }} + steps: + - name: Check out code (for commit message) + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Check if CI should be skipped + id: check + run: | + # Simple skip pattern: @skipci (case-insensitive) + SKIP_PATTERN="@skipci" + + # Get branch name (works for both push and PR) + BRANCH_NAME="${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}" + + # Get commit message (works for both push and PR) + COMMIT_MSG="${GITHUB_EVENT_HEAD_COMMIT_MESSAGE:-}" + if [ -z "$COMMIT_MSG" ]; then + COMMIT_MSG="${GITHUB_EVENT_PULL_REQUEST_HEAD_COMMIT_MESSAGE:-}" + fi + if [ -z "$COMMIT_MSG" ]; then + COMMIT_MSG=$(git log -1 --pretty=%B 2>/dev/null || echo "") + fi + + SKIP=0 + + # Check branch name (case-insensitive) + if echo "$BRANCH_NAME" | grep -qiF "$SKIP_PATTERN"; then + echo "Skipping CI: branch name contains '$SKIP_PATTERN'" + SKIP=1 + fi + + # Check commit message (case-insensitive) + if [ $SKIP -eq 0 ] && [ -n "$COMMIT_MSG" ]; then + if echo "$COMMIT_MSG" | grep -qiF "$SKIP_PATTERN"; then + echo "Skipping CI: commit message contains '$SKIP_PATTERN'" + SKIP=1 + fi + fi + + echo "skip=$SKIP" >> $GITHUB_OUTPUT + echo "Branch: $BRANCH_NAME" + echo "Commit: ${COMMIT_MSG:0:50}..." + echo "Skip CI: $SKIP" + + lint-and-type-check: + needs: skip-ci-check + runs-on: ubuntu-latest + if: needs.skip-ci-check.outputs.should-skip != '1' + container: + image: node:20-bullseye + steps: + - name: Check out code + uses: actions/checkout@v4 + + - name: Install admin-frontend dependencies + run: | + cd admin-frontend + npm ci + + - name: Run ESLint (admin-frontend) + run: | + cd admin-frontend + npm run lint || true + continue-on-error: true + + - name: Install viewer-frontend dependencies + run: | + cd viewer-frontend + npm ci + + - name: Type check (viewer-frontend) + run: | + cd viewer-frontend + npm run type-check || true + continue-on-error: true + + python-lint: + needs: skip-ci-check + runs-on: ubuntu-latest + if: needs.skip-ci-check.outputs.should-skip != '1' + container: + image: python:3.12-slim + steps: + - name: Check out code + uses: actions/checkout@v4 + + - name: Install Python dependencies + run: | + pip install --no-cache-dir flake8 black mypy pylint + + - name: Check Python syntax + run: | + find backend -name "*.py" -exec python -m py_compile {} \; || true + continue-on-error: true + + - name: Run flake8 + run: | + flake8 backend --max-line-length=100 --ignore=E501,W503 || true + continue-on-error: true + + test-backend: + needs: skip-ci-check + runs-on: ubuntu-latest + if: needs.skip-ci-check.outputs.should-skip != '1' + container: + image: python:3.12-slim + services: + postgres: + image: postgres:15 + env: + POSTGRES_PASSWORD: postgres + POSTGRES_DB: punimtag_test + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + redis: + image: redis:7-alpine + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 6379:6379 + env: + DATABASE_URL: postgresql+psycopg2://postgres:postgres@postgres:5432/punimtag_test + DATABASE_URL_AUTH: postgresql+psycopg2://postgres:postgres@postgres:5432/punimtag_auth_test + REDIS_URL: redis://redis:6379/0 + steps: + - name: Check out code + uses: actions/checkout@v4 + + - name: Install Python dependencies + run: | + apt-get update && apt-get install -y postgresql-client + pip install --no-cache-dir -r requirements.txt + + - name: Run backend tests + run: | + export PYTHONPATH=$(pwd) + python -m pytest tests/ -v || true + continue-on-error: true + + build: + needs: skip-ci-check + runs-on: ubuntu-latest + if: needs.skip-ci-check.outputs.should-skip != '1' + container: + image: node:20-bullseye + steps: + - name: Check out code + uses: actions/checkout@v4 + + - name: Install admin-frontend dependencies + run: | + cd admin-frontend + npm ci + + - name: Build admin-frontend + run: | + cd admin-frontend + npm run build + env: + VITE_API_URL: http://localhost:8000 + + - name: Install viewer-frontend dependencies + run: | + cd viewer-frontend + npm ci + + - name: Generate Prisma Client + run: | + cd viewer-frontend + npx prisma generate + + - name: Build viewer-frontend + run: | + cd viewer-frontend + npm run build + env: + DATABASE_URL: postgresql://postgres:postgres@localhost:5432/punimtag + DATABASE_URL_AUTH: postgresql://postgres:postgres@localhost:5432/punimtag_auth + NEXTAUTH_SECRET: test-secret-key-for-ci + NEXTAUTH_URL: http://localhost:3001 + + secret-scanning: + needs: skip-ci-check + if: needs.skip-ci-check.outputs.should-skip != '1' + runs-on: ubuntu-latest + container: + image: zricethezav/gitleaks:latest + steps: + - name: Install Node.js for checkout action + run: | + apk add --no-cache nodejs npm curl + + - name: Check out code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Scan for secrets + run: gitleaks detect --source . --no-banner --redact --exit-code 0 + continue-on-error: true + + dependency-scan: + needs: skip-ci-check + if: needs.skip-ci-check.outputs.should-skip != '1' + runs-on: ubuntu-latest + container: + image: aquasec/trivy:latest + steps: + - name: Install Node.js for checkout action + run: | + apk add --no-cache nodejs npm curl + + - name: Check out code + uses: actions/checkout@v4 + + - name: Dependency vulnerability scan (Trivy) + run: | + trivy fs \ + --scanners vuln \ + --severity HIGH,CRITICAL \ + --ignore-unfixed \ + --timeout 10m \ + --skip-dirs .git,node_modules,venv \ + --exit-code 0 \ + . + + - name: Secret scan (Trivy) + run: | + trivy fs \ + --scanners secret \ + --timeout 10m \ + --skip-dirs .git,node_modules,venv \ + --exit-code 0 \ + . + + sast-scan: + needs: skip-ci-check + if: needs.skip-ci-check.outputs.should-skip != '1' + runs-on: ubuntu-latest + container: + image: ubuntu:22.04 + steps: + - name: Install Node.js for checkout action + run: | + apt-get update && apt-get install -y curl + curl -fsSL https://deb.nodesource.com/setup_20.x | bash - + apt-get install -y nodejs + + - name: Check out code + uses: actions/checkout@v4 + + - name: Install Semgrep + run: | + apt-get update && apt-get install -y python3 python3-pip + pip3 install semgrep + + - name: Run Semgrep scan + run: semgrep --config=auto --error + continue-on-error: true + + workflow-summary: + runs-on: ubuntu-latest + needs: [lint-and-type-check, python-lint, test-backend, build, secret-scanning, dependency-scan, sast-scan] + if: always() + steps: + - name: Generate workflow summary + run: | + echo "## ๐Ÿ” CI Workflow Summary" >> $GITHUB_STEP_SUMMARY || true + echo "" >> $GITHUB_STEP_SUMMARY || true + echo "### Job Results" >> $GITHUB_STEP_SUMMARY || true + echo "" >> $GITHUB_STEP_SUMMARY || true + echo "| Job | Status |" >> $GITHUB_STEP_SUMMARY || true + echo "|-----|--------|" >> $GITHUB_STEP_SUMMARY || true + echo "| ๐Ÿ“ Lint & Type Check | ${{ needs.lint-and-type-check.result }} |" >> $GITHUB_STEP_SUMMARY || true + echo "| ๐Ÿ Python Lint | ${{ needs.python-lint.result }} |" >> $GITHUB_STEP_SUMMARY || true + echo "| ๐Ÿงช Backend Tests | ${{ needs.test-backend.result }} |" >> $GITHUB_STEP_SUMMARY || true + echo "| ๐Ÿ—๏ธ Build | ${{ needs.build.result }} |" >> $GITHUB_STEP_SUMMARY || true + echo "| ๐Ÿ” Secret Scanning | ${{ needs.secret-scanning.result }} |" >> $GITHUB_STEP_SUMMARY || true + echo "| ๐Ÿ“ฆ Dependency Scan | ${{ needs.dependency-scan.result }} |" >> $GITHUB_STEP_SUMMARY || true + echo "| ๐Ÿ” SAST Scan | ${{ needs.sast-scan.result }} |" >> $GITHUB_STEP_SUMMARY || true + echo "" >> $GITHUB_STEP_SUMMARY || true + echo "### ๐Ÿ“Š Summary" >> $GITHUB_STEP_SUMMARY || true + echo "" >> $GITHUB_STEP_SUMMARY || true + echo "All security and validation checks have completed." >> $GITHUB_STEP_SUMMARY || true + continue-on-error: true + diff --git a/package.json b/package.json index 4a248a8..e398a00 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,12 @@ "lint:admin": "npm run lint --prefix admin-frontend", "lint:viewer": "npm run lint --prefix viewer-frontend", "lint:all": "npm run lint:admin && npm run lint:viewer", + "type-check:viewer": "npm run type-check --prefix viewer-frontend", + "lint:python": "flake8 backend --max-line-length=100 --ignore=E501,W503 || true", + "lint:python:syntax": "find backend -name '*.py' -exec python -m py_compile {} \\;", + "test:backend": "export PYTHONPATH=$(pwd) && python -m pytest tests/ -v", + "test:all": "npm run test:backend", + "ci:local": "npm run lint:all && npm run type-check:viewer && npm run lint:python && npm run test:backend && npm run build:all", "deploy:dev": "npm run build:all && echo 'โœ… Build complete. Ready for deployment to dev server (10.0.10.121)'", "deploy:dev:prepare": "npm run build:all && mkdir -p deploy/package && cp -r backend deploy/package/ && cp -r admin-frontend/dist deploy/package/admin-frontend-dist && cp -r viewer-frontend/.next deploy/package/viewer-frontend-next && cp requirements.txt deploy/package/ && cp .env.example deploy/package/ && echo 'โœ… Deployment package prepared in deploy/package/'" }, diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..6414fca --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,67 @@ +# Scripts Directory + +This directory contains utility scripts organized by purpose. + +## Directory Structure + +### `db/` - Database Utilities +Database management and migration scripts: +- `drop_all_tables.py` - Drop all database tables +- `drop_all_tables_web.py` - Drop all web database tables +- `grant_auth_db_permissions.py` - Grant permissions on auth database +- `migrate_sqlite_to_postgresql.py` - Migrate from SQLite to PostgreSQL +- `recreate_tables_web.py` - Recreate web database tables +- `show_db_tables.py` - Display database table information + +### `debug/` - Debug and Analysis Scripts +Debugging and analysis tools: +- `analyze_all_faces.py` - Analyze all faces in database +- `analyze_pose_matching.py` - Analyze face pose matching +- `analyze_poses.py` - Analyze face poses +- `check_database_tables.py` - Check database table structure +- `check_identified_poses_web.py` - Check identified poses in web database +- `check_two_faces_pose.py` - Compare poses of two faces +- `check_yaw_angles.py` - Check face yaw angles +- `debug_pose_classification.py` - Debug pose classification +- `diagnose_frontend_issues.py` - Diagnose frontend issues +- `test_eye_visibility.py` - Test eye visibility detection +- `test_pose_calculation.py` - Test pose calculation + +### `utils/` - Utility Scripts +General utility scripts: +- `fix_admin_password.py` - Fix admin user password +- `update_reported_photo_status.py` - Update reported photo status + +## Root-Level Scripts + +Project-specific scripts remain in the repository root: +- `install.sh` - Installation script +- `run_api_with_worker.sh` - Start API with worker +- `start_backend.sh` - Start backend server +- `stop_backend.sh` - Stop backend server +- `run_worker.sh` - Run RQ worker +- `demo.sh` - Demo helper script + +## Database Shell Scripts + +Database-related shell scripts remain in `scripts/`: +- `drop_auth_database.sh` - Drop auth database +- `grant_auth_db_delete_permission.sh` - Grant delete permissions +- `setup_postgresql.sh` - Set up PostgreSQL + +## Usage + +Most scripts can be run directly: +```bash +# Database utilities +python scripts/db/show_db_tables.py + +# Debug scripts +python scripts/debug/analyze_all_faces.py + +# Utility scripts +python scripts/utils/fix_admin_password.py +``` + +Some scripts may require environment variables or database connections. Check individual script documentation or comments for specific requirements. + diff --git a/scripts/db/drop_all_tables.py b/scripts/db/drop_all_tables.py new file mode 100755 index 0000000..afb1804 --- /dev/null +++ b/scripts/db/drop_all_tables.py @@ -0,0 +1,78 @@ +import sqlite3 +import sys +import os + + +def drop_all_tables(db_path: str) -> None: + if not os.path.exists(db_path): + print(f"Database not found: {db_path}") + return + + conn = sqlite3.connect(db_path) + try: + conn.isolation_level = None # autocommit mode for DDL + cur = conn.cursor() + + # Disable foreign key enforcement to allow dropping in any order + cur.execute("PRAGMA foreign_keys = OFF;") + + # Collect tables and views + cur.execute("SELECT name, type FROM sqlite_master WHERE type IN ('table','view') AND name NOT LIKE 'sqlite_%';") + objects = cur.fetchall() + print(f"DB: {db_path}") + if not objects: + print("No user tables or views found.") + return + + # Drop views first, then tables + views = [name for name, t in objects if t == 'view'] + tables = [name for name, t in objects if t == 'table'] + + print(f"Found {len(tables)} tables and {len(views)} views.") + for v in views: + print(f"Dropping view: {v}") + cur.execute(f"DROP VIEW IF EXISTS \"{v}\";") + + for t in tables: + print(f"Dropping table: {t}") + cur.execute(f"DROP TABLE IF EXISTS \"{t}\";") + + # Vacuum to clean up + cur.execute("VACUUM;") + print("Done.") + finally: + conn.close() + + +def list_tables(db_path: str) -> None: + if not os.path.exists(db_path): + print(f"Database not found: {db_path}") + return + conn = sqlite3.connect(db_path) + try: + cur = conn.cursor() + cur.execute("SELECT name, type FROM sqlite_master WHERE type IN ('table','view') AND name NOT LIKE 'sqlite_%' ORDER BY type, name;") + objects = cur.fetchall() + print(f"DB: {db_path}") + if not objects: + print("No user tables or views found.") + return + for name, t in objects: + print(f"- {t}: {name}") + finally: + conn.close() + + +if __name__ == "__main__": + # Usage: python drop_all_tables.py [ ...] + paths = sys.argv[1:] + if not paths: + base = os.path.abspath(os.path.join(os.path.dirname(__file__), '..')) + paths = [os.path.join(base, 'photos.db'), os.path.join(base, 'data', 'photos.db')] + + for p in paths: + list_tables(p) + for p in paths: + drop_all_tables(p) + for p in paths: + list_tables(p) diff --git a/scripts/db/drop_all_tables_web.py b/scripts/db/drop_all_tables_web.py new file mode 100644 index 0000000..3b25377 --- /dev/null +++ b/scripts/db/drop_all_tables_web.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +"""Drop all tables from the web database to start fresh.""" + +import sys +import os + +# Add project root to path +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from sqlalchemy import inspect +from backend.db.session import engine, get_database_url +from backend.db.models import Base + +# Ordered list ensures foreign-key dependents drop first +TARGET_TABLES = [ + "photo_favorites", + "phototaglinkage", + "person_encodings", + "faces", + "tags", + "photos", + "people", +] + + +def drop_all_tables(): + """Drop all tables from the database.""" + db_url = get_database_url() + print(f"Connecting to database: {db_url}") + + inspector = inspect(engine) + existing_tables = set(inspector.get_table_names()) + + print("\nDropping selected tables...") + for table_name in TARGET_TABLES: + if table_name not in Base.metadata.tables: + print(f" โš ๏ธ Table '{table_name}' not found in metadata, skipping.") + continue + if table_name not in existing_tables: + print(f" โ„น๏ธ Table '{table_name}' does not exist in database, skipping.") + continue + table = Base.metadata.tables[table_name] + print(f" ๐Ÿ—‘๏ธ Dropping '{table_name}'...") + table.drop(bind=engine, checkfirst=True) + + print("โœ… Selected tables dropped successfully!") + print("\nYou can now recreate tables using:") + print(" python scripts/recreate_tables_web.py") + + +if __name__ == "__main__": + try: + drop_all_tables() + except Exception as e: + print(f"โŒ Error dropping tables: {e}") + import traceback + traceback.print_exc() + sys.exit(1) + diff --git a/scripts/db/grant_auth_db_permissions.py b/scripts/db/grant_auth_db_permissions.py new file mode 100755 index 0000000..2f23ffc --- /dev/null +++ b/scripts/db/grant_auth_db_permissions.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +"""Grant DELETE permission on auth database users table. + +This script grants DELETE permission to the database user specified in DATABASE_URL_AUTH. +It requires superuser access (postgres user) to grant permissions. +""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path +from urllib.parse import urlparse + +from dotenv import load_dotenv +from sqlalchemy import create_engine, text + +# Load environment variables +env_path = Path(__file__).parent.parent.parent / ".env" +load_dotenv(dotenv_path=env_path) + + +def parse_database_url(db_url: str) -> dict: + """Parse database URL into components.""" + # Handle postgresql+psycopg2:// format + if db_url.startswith("postgresql+psycopg2://"): + db_url = db_url.replace("postgresql+psycopg2://", "postgresql://") + + parsed = urlparse(db_url) + return { + "user": parsed.username, + "password": parsed.password, + "host": parsed.hostname or "localhost", + "port": parsed.port or 5432, + "database": parsed.path.lstrip("/"), + } + + +def grant_delete_permission() -> None: + """Grant DELETE permission on users and pending_photos tables in auth database.""" + auth_db_url = os.getenv("DATABASE_URL_AUTH") + if not auth_db_url: + print("โŒ Error: DATABASE_URL_AUTH environment variable not set") + sys.exit(1) + + if not auth_db_url.startswith("postgresql"): + print("โ„น๏ธ Auth database is not PostgreSQL. No permissions to grant.") + return + + db_info = parse_database_url(auth_db_url) + db_user = db_info["user"] + db_name = db_info["database"] + + print(f"๐Ÿ“‹ Granting DELETE permission on auth database tables...") + print(f" Database: {db_name}") + print(f" User: {db_user}") + + # Tables that need DELETE permission + tables = ["users", "pending_photos", "pending_identifications", "inappropriate_photo_reports"] + + # Connect as postgres superuser to grant permissions + # Try to connect as postgres user (superuser) + try: + # Try to get postgres password from environment or use peer authentication + postgres_url = f"postgresql://postgres@{db_info['host']}:{db_info['port']}/{db_name}" + engine = create_engine(postgres_url) + + with engine.connect() as conn: + for table in tables: + try: + # Grant DELETE permission + conn.execute(text(f""" + GRANT DELETE ON TABLE {table} TO {db_user} + """)) + print(f" โœ… Granted DELETE on {table}") + except Exception as e: + # Table might not exist, skip it + print(f" โš ๏ธ Could not grant DELETE on {table}: {e}") + conn.commit() + + print(f"โœ… Successfully granted DELETE permissions to user '{db_user}'") + return + except Exception as e: + # If connecting as postgres fails, try with the same user (might have grant privileges) + print(f"โš ๏ธ Could not connect as postgres user: {e}") + print(f" Trying with current database user...") + + try: + engine = create_engine(auth_db_url) + with engine.connect() as conn: + for table in tables: + try: + # Try to grant permission + conn.execute(text(f""" + GRANT DELETE ON TABLE {table} TO {db_user} + """)) + print(f" โœ… Granted DELETE on {table}") + except Exception as e2: + print(f" โš ๏ธ Could not grant DELETE on {table}: {e2}") + conn.commit() + + print(f"โœ… Successfully granted DELETE permissions to user '{db_user}'") + return + except Exception as e2: + print(f"โŒ Failed to grant permission: {e2}") + print(f"\n๐Ÿ’ก To grant permission manually, run as postgres superuser:") + for table in tables: + print(f" sudo -u postgres psql -d {db_name} -c \"GRANT DELETE ON TABLE {table} TO {db_user};\"") + sys.exit(1) + + +if __name__ == "__main__": + grant_delete_permission() + + diff --git a/scripts/db/migrate_sqlite_to_postgresql.py b/scripts/db/migrate_sqlite_to_postgresql.py new file mode 100644 index 0000000..fe3f9c3 --- /dev/null +++ b/scripts/db/migrate_sqlite_to_postgresql.py @@ -0,0 +1,264 @@ +#!/usr/bin/env python3 +""" +Migrate data from SQLite to PostgreSQL database. + +This script: +1. Creates PostgreSQL databases if they don't exist +2. Creates all tables in PostgreSQL +3. Migrates all data from SQLite to PostgreSQL +""" + +from __future__ import annotations + +import sys +import os +from pathlib import Path + +# Add project root to path +project_root = Path(__file__).parent.parent +sys.path.insert(0, str(project_root)) + +from sqlalchemy import create_engine, inspect, text +from sqlalchemy.orm import sessionmaker +from backend.db.models import Base +from backend.db.session import get_database_url +import sqlite3 + +def create_postgresql_databases(): + """Create PostgreSQL databases if they don't exist.""" + from urllib.parse import urlparse + + # Get database URLs from environment + db_url = os.getenv("DATABASE_URL", "postgresql+psycopg2://punimtag:punimtag_password@localhost:5432/punimtag") + auth_db_url = os.getenv("DATABASE_URL_AUTH", "postgresql://punimtag:punimtag_password@localhost:5432/punimtag_auth") + + # Parse URLs + main_parsed = urlparse(db_url.replace("postgresql+psycopg2://", "postgresql://")) + auth_parsed = urlparse(auth_db_url.replace("postgresql+psycopg2://", "postgresql://")) + + main_db_name = main_parsed.path.lstrip("/") + auth_db_name = auth_parsed.path.lstrip("/") + + # Connect to postgres database to create other databases + postgres_url = f"postgresql://{main_parsed.username}:{main_parsed.password}@{main_parsed.hostname}:{main_parsed.port or 5432}/postgres" + + try: + engine = create_engine(postgres_url) + with engine.connect() as conn: + # Check if databases exist + result = conn.execute(text("SELECT 1 FROM pg_database WHERE datname = :name"), {"name": main_db_name}) + if not result.fetchone(): + conn.execute(text("COMMIT")) # End any transaction + conn.execute(text(f'CREATE DATABASE "{main_db_name}"')) + print(f"โœ… Created database: {main_db_name}") + else: + print(f"โœ… Database already exists: {main_db_name}") + + result = conn.execute(text("SELECT 1 FROM pg_database WHERE datname = :name"), {"name": auth_db_name}) + if not result.fetchone(): + conn.execute(text("COMMIT")) + conn.execute(text(f'CREATE DATABASE "{auth_db_name}"')) + print(f"โœ… Created database: {auth_db_name}") + else: + print(f"โœ… Database already exists: {auth_db_name}") + except Exception as e: + print(f"โš ๏ธ Error creating databases: {e}") + print(" Make sure PostgreSQL is running and credentials are correct") + +def migrate_data(): + """Migrate data from SQLite to PostgreSQL.""" + print("=" * 80) + print("MIGRATING DATA FROM SQLITE TO POSTGRESQL") + print("=" * 80) + + # Get database URLs + sqlite_url = "sqlite:///data/punimtag.db" + postgres_url = os.getenv("DATABASE_URL", "postgresql+psycopg2://punimtag:punimtag_password@localhost:5432/punimtag") + + if not postgres_url.startswith("postgresql"): + print("โŒ DATABASE_URL is not set to PostgreSQL") + print(" Set DATABASE_URL in .env file to PostgreSQL connection string") + return False + + # Connect to both databases + sqlite_engine = create_engine(sqlite_url) + postgres_engine = create_engine(postgres_url) + + # Create tables in PostgreSQL + print("\n๐Ÿ“‹ Creating tables in PostgreSQL...") + Base.metadata.create_all(bind=postgres_engine) + print("โœ… Tables created") + + # Get table names + inspector = inspect(sqlite_engine) + all_tables = inspector.get_table_names() + + # Exclude system tables + all_tables = [t for t in all_tables if not t.startswith("sqlite_")] + + # Define migration order (respecting foreign key constraints) + # Tables with no dependencies first, then dependent tables + migration_order = [ + "alembic_version", # Migration tracking (optional) + "photos", # Base table + "people", # Base table + "tags", # Base table + "users", # Base table + "faces", # Depends on photos, people, users + "person_encodings", # Depends on people, faces + "phototaglinkage", # Depends on photos, tags + "photo_favorites", # Depends on photos + "photo_person_linkage", # Depends on photos, people, users + "role_permissions", # Base table + ] + + # Filter to only tables that exist + tables = [t for t in migration_order if t in all_tables] + # Add any remaining tables not in the order list + for t in all_tables: + if t not in tables: + tables.append(t) + + print(f"\n๐Ÿ“Š Found {len(tables)} tables to migrate: {', '.join(tables)}") + + # Boolean columns mapping (SQLite stores as integer, PostgreSQL needs boolean) + boolean_columns = { + "photos": ["processed"], + "faces": ["is_primary_encoding", "excluded"], + "users": ["is_active", "is_admin", "password_change_required"], + "role_permissions": ["allowed"], + } + + # Columns that might be missing in SQLite but required in PostgreSQL + # Map: table_name -> {column: default_value} + default_values = { + "photos": {"file_hash": "migrated"}, # file_hash might be missing in old SQLite + } + + # Migrate each table + with sqlite_engine.connect() as sqlite_conn, postgres_engine.connect() as postgres_conn: + for table in tables: + print(f"\n๐Ÿ”„ Migrating table: {table}") + + # Get row count + count_result = sqlite_conn.execute(text(f"SELECT COUNT(*) FROM {table}")) + row_count = count_result.scalar() + + if row_count == 0: + print(f" โญ๏ธ Table is empty, skipping") + continue + + print(f" ๐Ÿ“ฆ {row_count} rows to migrate") + + # Check if table already has data in PostgreSQL + try: + pg_count_result = postgres_conn.execute(text(f'SELECT COUNT(*) FROM "{table}"')) + pg_count = pg_count_result.scalar() + if pg_count > 0: + print(f" โš ๏ธ Table already has {pg_count} rows in PostgreSQL") + # Auto-truncate for non-interactive mode, or ask in interactive + print(f" ๐Ÿ—‘๏ธ Truncating existing data...") + postgres_conn.execute(text(f'TRUNCATE TABLE "{table}" CASCADE')) + postgres_conn.commit() + except Exception as e: + # Table might not exist yet, that's OK + pass + + # Get column names and types from SQLite + columns_result = sqlite_conn.execute(text(f"PRAGMA table_info({table})")) + column_info = columns_result.fetchall() + sqlite_columns = [row[1] for row in column_info] + + # Get PostgreSQL column names + pg_inspector = inspect(postgres_engine) + pg_columns_info = pg_inspector.get_columns(table) + pg_columns = [col['name'] for col in pg_columns_info] + + # Use PostgreSQL columns (they're the source of truth) + columns = pg_columns + + # Get boolean columns for this table + table_bool_cols = boolean_columns.get(table, []) + + # Get default values for missing columns + table_defaults = default_values.get(table, {}) + + # Build SELECT statement for SQLite (only select columns that exist) + select_cols = [col for col in columns if col in sqlite_columns] + select_sql = f"SELECT {', '.join(select_cols)} FROM {table}" + + # Fetch all data + data_result = sqlite_conn.execute(text(select_sql)) + rows = data_result.fetchall() + + # Insert into PostgreSQL + inserted = 0 + for row in rows: + try: + # Build insert statement with boolean conversion + values = {} + for i, col in enumerate(select_cols): + val = row[i] + # Convert integer booleans to Python booleans for PostgreSQL + if col in table_bool_cols: + val = bool(val) if val is not None else None + values[col] = val + + # Add default values for missing columns + for col, default_val in table_defaults.items(): + if col not in values and col in columns: + values[col] = default_val + + # Only insert columns we have values for (that exist in PostgreSQL) + insert_cols = [col for col in columns if col in values] + cols_str = ', '.join([f'"{c}"' for c in insert_cols]) + placeholders = ', '.join([f':{c}' for c in insert_cols]) + insert_sql = f'INSERT INTO "{table}" ({cols_str}) VALUES ({placeholders})' + + postgres_conn.execute(text(insert_sql), values) + inserted += 1 + + if inserted % 100 == 0: + postgres_conn.commit() + print(f" โœ… Inserted {inserted}/{row_count} rows...", end='\r') + + except Exception as e: + print(f"\n โŒ Error inserting row: {e}") + print(f" Row data: {dict(zip(columns, row))}") + postgres_conn.rollback() + break + + postgres_conn.commit() + print(f" โœ… Migrated {inserted}/{row_count} rows from {table}") + + print("\n" + "=" * 80) + print("โœ… MIGRATION COMPLETE") + print("=" * 80) + print("\nNext steps:") + print("1. Update .env file to use PostgreSQL:") + print(" DATABASE_URL=postgresql+psycopg2://punimtag:punimtag_password@localhost:5432/punimtag") + print("2. Restart the backend API") + print("3. Restart the viewer frontend") + print("4. Verify data in viewer frontend") + + return True + +if __name__ == "__main__": + print("๐Ÿ”ง SQLite to PostgreSQL Migration Tool\n") + + # Check if SQLite database exists + sqlite_path = project_root / "data" / "punimtag.db" + if not sqlite_path.exists(): + print(f"โŒ SQLite database not found: {sqlite_path}") + sys.exit(1) + + print(f"โœ… Found SQLite database: {sqlite_path}") + + # Create PostgreSQL databases + print("\n๐Ÿ“ฆ Creating PostgreSQL databases...") + create_postgresql_databases() + + # Migrate data + print("\n") + migrate_data() + diff --git a/scripts/db/recreate_tables_web.py b/scripts/db/recreate_tables_web.py new file mode 100644 index 0000000..3a53a71 --- /dev/null +++ b/scripts/db/recreate_tables_web.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python3 +"""Recreate all tables from models (fresh start).""" + +import sys +import os + +# Add project root to path +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from backend.db.models import Base +from backend.db.session import engine, get_database_url + + +def recreate_tables(): + """Recreate all tables from models.""" + db_url = get_database_url() + print(f"Connecting to database: {db_url}") + + # Create all tables from models + print("\nCreating all tables from models...") + Base.metadata.create_all(bind=engine) + + print("โœ… All tables created successfully!") + print("โœ… Database is now fresh and ready to use!") + + +if __name__ == "__main__": + try: + recreate_tables() + except Exception as e: + print(f"โŒ Error recreating tables: {e}") + import traceback + traceback.print_exc() + sys.exit(1) + diff --git a/scripts/db/show_db_tables.py b/scripts/db/show_db_tables.py new file mode 100644 index 0000000..5dd6238 --- /dev/null +++ b/scripts/db/show_db_tables.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +"""Show all tables and their structures in the database.""" + +import sys +import os + +# Add project root to path +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from sqlalchemy import inspect, text +from backend.db.session import engine, get_database_url +from backend.db.models import Base + + +def show_table_structure(table_name: str, inspector): + """Show the structure of a table.""" + print(f"\n{'='*80}") + print(f"Table: {table_name}") + print(f"{'='*80}") + + # Get columns + columns = inspector.get_columns(table_name) + print("\nColumns:") + print(f"{'Name':<30} {'Type':<25} {'Nullable':<10} {'Primary Key':<12} {'Default'}") + print("-" * 100) + + for col in columns: + col_type = str(col['type']) + nullable = "Yes" if col['nullable'] else "No" + primary_key = "Yes" if col.get('primary_key', False) else "No" + default = str(col.get('default', ''))[:30] if col.get('default') else '' + print(f"{col['name']:<30} {col_type:<25} {nullable:<10} {primary_key:<12} {default}") + + # Get indexes + indexes = inspector.get_indexes(table_name) + if indexes: + print("\nIndexes:") + for idx in indexes: + unique = "UNIQUE" if idx.get('unique', False) else "" + columns_str = ", ".join(idx['column_names']) + print(f" {idx['name']}: {columns_str} {unique}") + + # Get foreign keys + foreign_keys = inspector.get_foreign_keys(table_name) + if foreign_keys: + print("\nForeign Keys:") + for fk in foreign_keys: + constrained_cols = ", ".join(fk['constrained_columns']) + referred_table = fk['referred_table'] + referred_cols = ", ".join(fk['referred_columns']) + print(f" {constrained_cols} -> {referred_table}({referred_cols})") + + +def show_all_tables(): + """Show all tables and their structures.""" + db_url = get_database_url() + print(f"Database: {db_url}") + print(f"\n{'='*80}") + + # Create inspector + inspector = inspect(engine) + + # Get all table names + table_names = inspector.get_table_names() + + if not table_names: + print("No tables found in database.") + print("\nTables should be created on web app startup.") + print("\nHere are the table structures from models:") + + # Show from models instead + from backend.db.models import Photo, Person, Face, PersonEmbedding, Tag, PhotoTag + + models = [ + ("photos", Photo), + ("people", Person), + ("faces", Face), + ("person_embeddings", PersonEmbedding), + ("tags", Tag), + ("photo_tags", PhotoTag), + ] + + for table_name, model in models: + print(f"\n{'='*80}") + print(f"Table: {table_name}") + print(f"{'='*80}") + print("\nColumns:") + for col in model.__table__.columns: + nullable = "Yes" if col.nullable else "No" + primary_key = "Yes" if col.primary_key else "No" + default = str(col.default) if col.default else '' + print(f" {col.name:<30} {col.type!s:<25} Nullable: {nullable:<10} PK: {primary_key:<12} Default: {default}") + + # Show indexes + indexes = model.__table__.indexes + if indexes: + print("\nIndexes:") + for idx in indexes: + unique = "UNIQUE" if idx.unique else "" + cols = ", ".join([c.name for c in idx.columns]) + print(f" {idx.name}: {cols} {unique}") + + # Show foreign keys + fks = [fk for fk in model.__table__.foreign_keys] + if fks: + print("\nForeign Keys:") + for fk in fks: + print(f" {fk.parent.name} -> {fk.column.table.name}({fk.column.name})") + + return + + print(f"\nFound {len(table_names)} table(s):") + for table_name in sorted(table_names): + print(f" - {table_name}") + + # Show structure for each table + for table_name in sorted(table_names): + show_table_structure(table_name, inspector) + + +if __name__ == "__main__": + try: + show_all_tables() + except Exception as e: + print(f"โŒ Error showing tables: {e}") + import traceback + traceback.print_exc() + sys.exit(1) + diff --git a/scripts/debug/analyze_all_faces.py b/scripts/debug/analyze_all_faces.py new file mode 100644 index 0000000..9a7c623 --- /dev/null +++ b/scripts/debug/analyze_all_faces.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +""" +Analyze all faces to see why most don't have angle data +""" + +import sqlite3 +import os + +db_path = "data/punimtag.db" + +if not os.path.exists(db_path): + print(f"โŒ Database not found: {db_path}") + exit(1) + +conn = sqlite3.connect(db_path) +conn.row_factory = sqlite3.Row +cursor = conn.cursor() + +# Get total faces +cursor.execute("SELECT COUNT(*) FROM faces") +total_faces = cursor.fetchone()[0] + +# Get faces with angle data +cursor.execute("SELECT COUNT(*) FROM faces WHERE yaw_angle IS NOT NULL OR pitch_angle IS NOT NULL OR roll_angle IS NOT NULL") +faces_with_angles = cursor.fetchone()[0] + +# Get faces without any angle data +faces_without_angles = total_faces - faces_with_angles + +print("=" * 80) +print("FACE ANGLE DATA ANALYSIS") +print("=" * 80) +print(f"\nTotal faces: {total_faces}") +print(f"Faces WITH angle data: {faces_with_angles}") +print(f"Faces WITHOUT angle data: {faces_without_angles}") +print(f"Percentage with angle data: {(faces_with_angles/total_faces*100):.1f}%") + +# Check pose_mode distribution +print("\n" + "=" * 80) +print("POSE_MODE DISTRIBUTION") +print("=" * 80) +cursor.execute(""" + SELECT pose_mode, COUNT(*) as count + FROM faces + GROUP BY pose_mode + ORDER BY count DESC +""") + +pose_modes = cursor.fetchall() +for row in pose_modes: + percentage = (row['count'] / total_faces) * 100 + print(f" {row['pose_mode']:<30} : {row['count']:>4} ({percentage:>5.1f}%)") + +# Check faces with pose_mode=frontal but might have high yaw +print("\n" + "=" * 80) +print("FACES WITH POSE_MODE='frontal' BUT NO ANGLE DATA") +print("=" * 80) +print("(These faces might actually be profile faces but weren't analyzed)") + +cursor.execute(""" + SELECT COUNT(*) + FROM faces + WHERE pose_mode = 'frontal' + AND yaw_angle IS NULL + AND pitch_angle IS NULL + AND roll_angle IS NULL +""") +frontal_no_data = cursor.fetchone()[0] +print(f" Faces with pose_mode='frontal' and no angle data: {frontal_no_data}") + +# Check if pose detection is being run for all faces +print("\n" + "=" * 80) +print("ANALYSIS") +print("=" * 80) +print(f"Only {faces_with_angles} out of {total_faces} faces have angle data stored.") +print("This suggests that pose detection is NOT being run for all faces.") +print("\nPossible reasons:") +print(" 1. Pose detection may have been disabled or failed for most faces") +print(" 2. Only faces processed recently have pose data") +print(" 3. Pose detection might only run when RetinaFace is available") + +conn.close() + diff --git a/scripts/debug/analyze_pose_matching.py b/scripts/debug/analyze_pose_matching.py new file mode 100644 index 0000000..91653fd --- /dev/null +++ b/scripts/debug/analyze_pose_matching.py @@ -0,0 +1,156 @@ +#!/usr/bin/env python3 +""" +Analyze why only 6 faces have yaw angle data - investigate the matching process +""" + +import sqlite3 +import os +import json + +db_path = "data/punimtag.db" + +if not os.path.exists(db_path): + print(f"โŒ Database not found: {db_path}") + exit(1) + +conn = sqlite3.connect(db_path) +conn.row_factory = sqlite3.Row +cursor = conn.cursor() + +# Get total faces +cursor.execute("SELECT COUNT(*) FROM faces") +total_faces = cursor.fetchone()[0] + +# Get faces with angle data +cursor.execute("SELECT COUNT(*) FROM faces WHERE yaw_angle IS NOT NULL") +faces_with_yaw = cursor.fetchone()[0] + +# Get faces without angle data +cursor.execute("SELECT COUNT(*) FROM faces WHERE yaw_angle IS NULL AND pitch_angle IS NULL AND roll_angle IS NULL") +faces_without_angles = cursor.fetchone()[0] + +print("=" * 80) +print("POSE DATA COVERAGE ANALYSIS") +print("=" * 80) +print(f"\nTotal faces: {total_faces}") +print(f"Faces WITH yaw angle: {faces_with_yaw}") +print(f"Faces WITHOUT any angle data: {faces_without_angles}") +print(f"Coverage: {(faces_with_yaw/total_faces*100):.1f}%") + +# Check pose_mode distribution +print("\n" + "=" * 80) +print("POSE_MODE DISTRIBUTION") +print("=" * 80) +cursor.execute(""" + SELECT pose_mode, COUNT(*) as count, + SUM(CASE WHEN yaw_angle IS NOT NULL THEN 1 ELSE 0 END) as with_yaw, + SUM(CASE WHEN pitch_angle IS NOT NULL THEN 1 ELSE 0 END) as with_pitch, + SUM(CASE WHEN roll_angle IS NOT NULL THEN 1 ELSE 0 END) as with_roll + FROM faces + GROUP BY pose_mode + ORDER BY count DESC +""") + +pose_modes = cursor.fetchall() +for row in pose_modes: + print(f"\n{row['pose_mode']}:") + print(f" Total: {row['count']}") + print(f" With yaw: {row['with_yaw']}") + print(f" With pitch: {row['with_pitch']}") + print(f" With roll: {row['with_roll']}") + +# Check photos and see if some photos have pose data while others don't +print("\n" + "=" * 80) +print("POSE DATA BY PHOTO") +print("=" * 80) +cursor.execute(""" + SELECT + p.id as photo_id, + p.filename, + COUNT(f.id) as total_faces, + SUM(CASE WHEN f.yaw_angle IS NOT NULL THEN 1 ELSE 0 END) as faces_with_yaw, + SUM(CASE WHEN f.pitch_angle IS NOT NULL THEN 1 ELSE 0 END) as faces_with_pitch, + SUM(CASE WHEN f.roll_angle IS NOT NULL THEN 1 ELSE 0 END) as faces_with_roll + FROM photos p + LEFT JOIN faces f ON f.photo_id = p.id + GROUP BY p.id, p.filename + HAVING COUNT(f.id) > 0 + ORDER BY faces_with_yaw DESC, total_faces DESC + LIMIT 20 +""") + +photos = cursor.fetchall() +print(f"\n{'Photo ID':<10} {'Filename':<40} {'Total':<8} {'Yaw':<6} {'Pitch':<7} {'Roll':<6}") +print("-" * 80) +for row in photos: + print(f"{row['photo_id']:<10} {row['filename'][:38]:<40} {row['total_faces']:<8} " + f"{row['faces_with_yaw']:<6} {row['faces_with_pitch']:<7} {row['faces_with_roll']:<6}") + +# Check if there's a pattern - maybe older photos don't have pose data +print("\n" + "=" * 80) +print("ANALYSIS") +print("=" * 80) + +# Check date added vs pose data +cursor.execute(""" + SELECT + DATE(p.date_added) as date_added, + COUNT(f.id) as total_faces, + SUM(CASE WHEN f.yaw_angle IS NOT NULL THEN 1 ELSE 0 END) as faces_with_yaw + FROM photos p + LEFT JOIN faces f ON f.photo_id = p.id + GROUP BY DATE(p.date_added) + ORDER BY date_added DESC +""") + +dates = cursor.fetchall() +print("\nFaces by date added:") +print(f"{'Date':<15} {'Total':<8} {'With Yaw':<10} {'Coverage':<10}") +print("-" * 50) +for row in dates: + coverage = (row['faces_with_yaw'] / row['total_faces'] * 100) if row['total_faces'] > 0 else 0 + print(f"{row['date_added'] or 'NULL':<15} {row['total_faces']:<8} {row['faces_with_yaw']:<10} {coverage:.1f}%") + +# Check if pose detection might be failing for some photos +print("\n" + "=" * 80) +print("POSSIBLE REASONS FOR LOW COVERAGE") +print("=" * 80) +print("\n1. Pose detection might not be running for all photos") +print("2. Matching between DeepFace and RetinaFace might be failing (IoU threshold too strict?)") +print("3. RetinaFace might not be detecting faces in some photos") +print("4. Photos might have been processed before pose detection was fully implemented") + +# Check if there are photos with multiple faces where some have pose data and some don't +cursor.execute(""" + SELECT + p.id as photo_id, + p.filename, + COUNT(f.id) as total_faces, + SUM(CASE WHEN f.yaw_angle IS NOT NULL THEN 1 ELSE 0 END) as faces_with_yaw, + SUM(CASE WHEN f.yaw_angle IS NULL THEN 1 ELSE 0 END) as faces_without_yaw + FROM photos p + JOIN faces f ON f.photo_id = p.id + GROUP BY p.id, p.filename + HAVING COUNT(f.id) > 1 + AND SUM(CASE WHEN f.yaw_angle IS NOT NULL THEN 1 ELSE 0 END) > 0 + AND SUM(CASE WHEN f.yaw_angle IS NULL THEN 1 ELSE 0 END) > 0 + ORDER BY total_faces DESC + LIMIT 10 +""") + +mixed_photos = cursor.fetchall() +if mixed_photos: + print("\n" + "=" * 80) + print("PHOTOS WITH MIXED POSE DATA (some faces have it, some don't)") + print("=" * 80) + print(f"\n{'Photo ID':<10} {'Filename':<40} {'Total':<8} {'With Yaw':<10} {'Without Yaw':<12}") + print("-" * 80) + for row in mixed_photos: + print(f"{row['photo_id']:<10} {row['filename'][:38]:<40} {row['total_faces']:<8} " + f"{row['faces_with_yaw']:<10} {row['faces_without_yaw']:<12}") + print("\nโš ๏ธ This suggests matching is failing for some faces even when pose detection runs") +else: + print("\nโœ… No photos found with mixed pose data (all or nothing per photo)") + +conn.close() + diff --git a/scripts/debug/analyze_poses.py b/scripts/debug/analyze_poses.py new file mode 100644 index 0000000..17115f7 --- /dev/null +++ b/scripts/debug/analyze_poses.py @@ -0,0 +1,192 @@ +#!/usr/bin/env python3 +""" +Analyze pose_mode values in the faces table +""" + +import sqlite3 +import sys +import os +from collections import Counter +from typing import Dict, List, Tuple + +# Default database path +DEFAULT_DB_PATH = "data/photos.db" + + +def analyze_poses(db_path: str) -> None: + """Analyze pose_mode values in faces table""" + + if not os.path.exists(db_path): + print(f"โŒ Database not found: {db_path}") + return + + print(f"๐Ÿ“Š Analyzing poses in database: {db_path}\n") + + try: + conn = sqlite3.connect(db_path) + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + + # Get total number of faces + cursor.execute("SELECT COUNT(*) FROM faces") + total_faces = cursor.fetchone()[0] + print(f"Total faces in database: {total_faces}\n") + + if total_faces == 0: + print("No faces found in database.") + conn.close() + return + + # Get pose_mode distribution + cursor.execute(""" + SELECT pose_mode, COUNT(*) as count + FROM faces + GROUP BY pose_mode + ORDER BY count DESC + """) + + pose_modes = cursor.fetchall() + + print("=" * 60) + print("POSE_MODE DISTRIBUTION") + print("=" * 60) + for row in pose_modes: + pose_mode = row['pose_mode'] or 'NULL' + count = row['count'] + percentage = (count / total_faces) * 100 + print(f" {pose_mode:30s} : {count:6d} ({percentage:5.1f}%)") + + print("\n" + "=" * 60) + print("ANGLE STATISTICS") + print("=" * 60) + + # Yaw angle statistics + cursor.execute(""" + SELECT + COUNT(*) as total, + COUNT(yaw_angle) as with_yaw, + MIN(yaw_angle) as min_yaw, + MAX(yaw_angle) as max_yaw, + AVG(yaw_angle) as avg_yaw + FROM faces + WHERE yaw_angle IS NOT NULL + """) + yaw_stats = cursor.fetchone() + + # Pitch angle statistics + cursor.execute(""" + SELECT + COUNT(*) as total, + COUNT(pitch_angle) as with_pitch, + MIN(pitch_angle) as min_pitch, + MAX(pitch_angle) as max_pitch, + AVG(pitch_angle) as avg_pitch + FROM faces + WHERE pitch_angle IS NOT NULL + """) + pitch_stats = cursor.fetchone() + + # Roll angle statistics + cursor.execute(""" + SELECT + COUNT(*) as total, + COUNT(roll_angle) as with_roll, + MIN(roll_angle) as min_roll, + MAX(roll_angle) as max_roll, + AVG(roll_angle) as avg_roll + FROM faces + WHERE roll_angle IS NOT NULL + """) + roll_stats = cursor.fetchone() + + print(f"\nYaw Angle:") + print(f" Faces with yaw data: {yaw_stats['with_yaw']}") + if yaw_stats['with_yaw'] > 0: + print(f" Min: {yaw_stats['min_yaw']:.1f}ยฐ") + print(f" Max: {yaw_stats['max_yaw']:.1f}ยฐ") + print(f" Avg: {yaw_stats['avg_yaw']:.1f}ยฐ") + + print(f"\nPitch Angle:") + print(f" Faces with pitch data: {pitch_stats['with_pitch']}") + if pitch_stats['with_pitch'] > 0: + print(f" Min: {pitch_stats['min_pitch']:.1f}ยฐ") + print(f" Max: {pitch_stats['max_pitch']:.1f}ยฐ") + print(f" Avg: {pitch_stats['avg_pitch']:.1f}ยฐ") + + print(f"\nRoll Angle:") + print(f" Faces with roll data: {roll_stats['with_roll']}") + if roll_stats['with_roll'] > 0: + print(f" Min: {roll_stats['min_roll']:.1f}ยฐ") + print(f" Max: {roll_stats['max_roll']:.1f}ยฐ") + print(f" Avg: {roll_stats['avg_roll']:.1f}ยฐ") + + # Sample faces with different poses + print("\n" + "=" * 60) + print("SAMPLE FACES BY POSE") + print("=" * 60) + + for row in pose_modes[:10]: # Top 10 pose modes + pose_mode = row['pose_mode'] + cursor.execute(""" + SELECT id, photo_id, pose_mode, yaw_angle, pitch_angle, roll_angle + FROM faces + WHERE pose_mode = ? + LIMIT 3 + """, (pose_mode,)) + samples = cursor.fetchall() + + print(f"\n{pose_mode}:") + for sample in samples: + yaw_str = f"{sample['yaw_angle']:.1f}ยฐ" if sample['yaw_angle'] is not None else "N/A" + pitch_str = f"{sample['pitch_angle']:.1f}ยฐ" if sample['pitch_angle'] is not None else "N/A" + roll_str = f"{sample['roll_angle']:.1f}ยฐ" if sample['roll_angle'] is not None else "N/A" + print(f" Face ID {sample['id']}: " + f"yaw={yaw_str} " + f"pitch={pitch_str} " + f"roll={roll_str}") + + conn.close() + + except sqlite3.Error as e: + print(f"โŒ Database error: {e}") + except Exception as e: + print(f"โŒ Error: {e}") + + +def check_web_database() -> None: + """Check if web database exists and analyze it""" + # Common web database locations + web_db_paths = [ + "data/punimtag.db", # Default web database + "data/web_photos.db", + "data/photos_web.db", + "web_photos.db", + ] + + for db_path in web_db_paths: + if os.path.exists(db_path): + print(f"\n{'='*60}") + print(f"WEB DATABASE: {db_path}") + print(f"{'='*60}\n") + analyze_poses(db_path) + break + + +if __name__ == "__main__": + # Check desktop database + desktop_db = DEFAULT_DB_PATH + if os.path.exists(desktop_db): + analyze_poses(desktop_db) + + # Check web database + check_web_database() + + # If no database found, list what we tried + if not os.path.exists(desktop_db): + print(f"โŒ Desktop database not found: {desktop_db}") + print("\nTrying to find database files...") + for root, dirs, files in os.walk("data"): + for file in files: + if file.endswith(('.db', '.sqlite', '.sqlite3')): + print(f" Found: {os.path.join(root, file)}") + diff --git a/scripts/debug/check_database_tables.py b/scripts/debug/check_database_tables.py new file mode 100644 index 0000000..485748f --- /dev/null +++ b/scripts/debug/check_database_tables.py @@ -0,0 +1,135 @@ +#!/usr/bin/env python3 +""" +Check what tables exist in the punimtag main database and their record counts. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +# Add project root to path +project_root = Path(__file__).parent.parent +sys.path.insert(0, str(project_root)) + +from sqlalchemy import create_engine, inspect, text +from backend.db.session import get_database_url + + +def check_database_tables() -> None: + """Check all tables in the database and their record counts.""" + database_url = get_database_url() + + print("=" * 80) + print("PUNIMTAG MAIN DATABASE - TABLE INFORMATION") + print("=" * 80) + print(f"\nDatabase URL: {database_url.replace('://', '://****') if '://' in database_url else database_url}\n") + + # Create engine + connect_args = {} + if database_url.startswith("sqlite"): + connect_args = {"check_same_thread": False} + + engine = create_engine(database_url, connect_args=connect_args) + + try: + # Get inspector to list tables + inspector = inspect(engine) + all_tables = inspector.get_table_names() + + if not all_tables: + print("โŒ No tables found in database.") + return + + print(f"Found {len(all_tables)} tables:\n") + + # Expected tables from models + expected_tables = { + "photos", + "people", + "faces", + "person_encodings", + "tags", + "phototaglinkage", + "photo_favorites", + "users", + "photo_person_linkage", + "role_permissions", + } + + # Connect and query each table + with engine.connect() as conn: + print(f"{'Table Name':<30} {'Record Count':<15} {'Status'}") + print("-" * 80) + + for table_name in sorted(all_tables): + # Skip SQLite system tables + if table_name.startswith("sqlite_"): + continue + + try: + # Get record count + if database_url.startswith("sqlite"): + result = conn.execute(text(f"SELECT COUNT(*) FROM {table_name}")) + else: + result = conn.execute(text(f'SELECT COUNT(*) FROM "{table_name}"')) + + count = result.scalar() + + # Check if it's an expected table + status = "โœ… Expected" if table_name in expected_tables else "โš ๏ธ Unexpected" + + print(f"{table_name:<30} {count:<15} {status}") + + except Exception as e: + print(f"{table_name:<30} {'ERROR':<15} โŒ {str(e)[:50]}") + + print("-" * 80) + + # Summary + print("\n๐Ÿ“Š Summary:") + with engine.connect() as conn: + total_records = 0 + tables_with_data = 0 + for table_name in sorted(all_tables): + if table_name.startswith("sqlite_"): + continue + try: + if database_url.startswith("sqlite"): + result = conn.execute(text(f"SELECT COUNT(*) FROM {table_name}")) + else: + result = conn.execute(text(f'SELECT COUNT(*) FROM "{table_name}"')) + count = result.scalar() + total_records += count + if count > 0: + tables_with_data += 1 + except: + pass + + print(f" Total tables: {len([t for t in all_tables if not t.startswith('sqlite_')])}") + print(f" Tables with records: {tables_with_data}") + print(f" Total records across all tables: {total_records:,}") + + # Check for missing expected tables + missing_tables = expected_tables - set(all_tables) + if missing_tables: + print(f"\nโš ๏ธ Missing expected tables: {', '.join(sorted(missing_tables))}") + + # Check for unexpected tables + unexpected_tables = set(all_tables) - expected_tables - {"alembic_version"} + unexpected_tables = {t for t in unexpected_tables if not t.startswith("sqlite_")} + if unexpected_tables: + print(f"\nโ„น๏ธ Additional tables found: {', '.join(sorted(unexpected_tables))}") + + except Exception as e: + print(f"โŒ Error connecting to database: {e}") + import traceback + traceback.print_exc() + return + + print("\n" + "=" * 80) + + +if __name__ == "__main__": + check_database_tables() + diff --git a/scripts/debug/check_identified_poses_web.py b/scripts/debug/check_identified_poses_web.py new file mode 100644 index 0000000..50c03b7 --- /dev/null +++ b/scripts/debug/check_identified_poses_web.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +"""Check all identified faces for pose information (web database)""" + +import sys +import os + +# Add project root to path +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from backend.db.models import Face, Person, Photo +from backend.db.session import get_database_url + +def check_identified_faces(): + """Check all identified faces for pose information""" + db_url = get_database_url() + print(f"Connecting to database: {db_url}") + + engine = create_engine(db_url) + Session = sessionmaker(bind=engine) + session = Session() + + try: + # Get all identified faces with pose information + faces = ( + session.query(Face, Person, Photo) + .join(Person, Face.person_id == Person.id) + .join(Photo, Face.photo_id == Photo.id) + .filter(Face.person_id.isnot(None)) + .order_by(Person.id, Face.id) + .all() + ) + + if not faces: + print("No identified faces found.") + return + + print(f"\n{'='*80}") + print(f"Found {len(faces)} identified faces") + print(f"{'='*80}\n") + + # Group by person + by_person = {} + for face, person, photo in faces: + person_id = person.id + if person_id not in by_person: + by_person[person_id] = [] + by_person[person_id].append((face, person, photo)) + + # Print summary + print("SUMMARY BY PERSON:") + print("-" * 80) + for person_id, person_faces in by_person.items(): + person = person_faces[0][1] + person_name = f"{person.first_name} {person.last_name}" + pose_modes = [f[0].pose_mode for f in person_faces] + frontal_count = sum(1 for p in pose_modes if p == 'frontal') + profile_count = sum(1 for p in pose_modes if 'profile' in p) + other_count = len(pose_modes) - frontal_count - profile_count + + print(f"\nPerson {person_id}: {person_name}") + print(f" Total faces: {len(person_faces)}") + print(f" Frontal: {frontal_count}") + print(f" Profile: {profile_count}") + print(f" Other: {other_count}") + print(f" Pose modes: {set(pose_modes)}") + + # Print detailed information + print(f"\n{'='*80}") + print("DETAILED FACE INFORMATION:") + print(f"{'='*80}\n") + + for face, person, photo in faces: + person_name = f"{person.first_name} {person.last_name}" + print(f"Face ID: {face.id}") + print(f" Person: {person_name} (ID: {face.person_id})") + print(f" Photo: {photo.filename}") + print(f" Pose Mode: {face.pose_mode}") + print(f" Yaw: {face.yaw_angle:.2f}ยฐ" if face.yaw_angle is not None else " Yaw: None") + print(f" Pitch: {face.pitch_angle:.2f}ยฐ" if face.pitch_angle is not None else " Pitch: None") + print(f" Roll: {face.roll_angle:.2f}ยฐ" if face.roll_angle is not None else " Roll: None") + print(f" Confidence: {face.face_confidence:.3f}") + print(f" Quality: {face.quality_score:.3f}") + print(f" Location: {face.location}") + print() + + finally: + session.close() + +if __name__ == "__main__": + try: + check_identified_faces() + except Exception as e: + print(f"โŒ Error: {e}") + import traceback + traceback.print_exc() + sys.exit(1) + diff --git a/scripts/debug/check_two_faces_pose.py b/scripts/debug/check_two_faces_pose.py new file mode 100755 index 0000000..a9b8074 --- /dev/null +++ b/scripts/debug/check_two_faces_pose.py @@ -0,0 +1,188 @@ +#!/usr/bin/env python3 +"""Check two identified faces and analyze why their pose modes are wrong""" + +import sys +import os +import json + +# Add project root to path +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from backend.db.models import Face, Person, Photo +from backend.db.session import get_database_url +from src.utils.pose_detection import PoseDetector + +def check_two_faces(face_id1: int = None, face_id2: int = None): + """Check two identified faces and analyze their pose modes""" + db_url = get_database_url() + print(f"Connecting to database: {db_url}") + + engine = create_engine(db_url) + Session = sessionmaker(bind=engine) + session = Session() + + try: + # Get all identified faces + query = ( + session.query(Face, Person, Photo) + .join(Person, Face.person_id == Person.id) + .join(Photo, Face.photo_id == Photo.id) + .filter(Face.person_id.isnot(None)) + .order_by(Face.id) + ) + + if face_id1: + query = query.filter(Face.id == face_id1) + elif face_id2: + query = query.filter(Face.id == face_id2) + + faces = query.limit(2).all() + + if len(faces) < 2: + print(f"Found {len(faces)} identified face(s). Need 2 faces to compare.") + if len(faces) == 0: + print("No identified faces found.") + return + print("\nShowing available identified faces:") + all_faces = ( + session.query(Face, Person, Photo) + .join(Person, Face.person_id == Person.id) + .join(Photo, Face.photo_id == Photo.id) + .filter(Face.person_id.isnot(None)) + .order_by(Face.id) + .limit(10) + .all() + ) + for face, person, photo in all_faces: + print(f" Face ID: {face.id}, Person: {person.first_name} {person.last_name}, Photo: {photo.filename}, Pose: {face.pose_mode}") + return + + print(f"\n{'='*80}") + print("ANALYZING TWO IDENTIFIED FACES") + print(f"{'='*80}\n") + + for idx, (face, person, photo) in enumerate(faces, 1): + person_name = f"{person.first_name} {person.last_name}" + + print(f"{'='*80}") + print(f"FACE {idx}: ID {face.id}") + print(f"{'='*80}") + print(f"Person: {person_name} (ID: {face.person_id})") + print(f"Photo: {photo.filename}") + print(f"Current Pose Mode: {face.pose_mode}") + print(f"Yaw: {face.yaw_angle:.2f}ยฐ" if face.yaw_angle is not None else "Yaw: None") + print(f"Pitch: {face.pitch_angle:.2f}ยฐ" if face.pitch_angle is not None else "Pitch: None") + print(f"Roll: {face.roll_angle:.2f}ยฐ" if face.roll_angle is not None else "Roll: None") + print(f"Face Width: {face.face_width if hasattr(face, 'face_width') else 'N/A'}") + print(f"Confidence: {face.face_confidence:.3f}") + print(f"Quality: {face.quality_score:.3f}") + print(f"Location: {face.location}") + + # Parse landmarks if available + landmarks = None + if face.landmarks: + try: + landmarks = json.loads(face.landmarks) + print(f"\nLandmarks:") + for key, value in landmarks.items(): + print(f" {key}: {value}") + except json.JSONDecodeError: + print(f"\nLandmarks: (invalid JSON)") + + # Recalculate pose mode using current logic + print(f"\n{'โ”€'*80}") + print("RECALCULATING POSE MODE:") + print(f"{'โ”€'*80}") + + # Calculate face width from landmarks if available + face_width = None + if landmarks: + face_width = PoseDetector.calculate_face_width_from_landmarks(landmarks) + print(f"Calculated face_width from landmarks: {face_width}") + + # Recalculate pose mode + recalculated_pose = PoseDetector.classify_pose_mode( + face.yaw_angle, + face.pitch_angle, + face.roll_angle, + face_width, + landmarks + ) + + print(f"Recalculated Pose Mode: {recalculated_pose}") + + if recalculated_pose != face.pose_mode: + print(f"โš ๏ธ MISMATCH! Current: '{face.pose_mode}' vs Recalculated: '{recalculated_pose}'") + + # Analyze why + print(f"\nAnalysis:") + if face.yaw_angle is None: + print(f" - Yaw is None") + if landmarks: + left_eye = landmarks.get('left_eye') + right_eye = landmarks.get('right_eye') + nose = landmarks.get('nose') + missing = [] + if not left_eye: + missing.append('left_eye') + if not right_eye: + missing.append('right_eye') + if not nose: + missing.append('nose') + if missing: + print(f" - Missing landmarks: {', '.join(missing)}") + print(f" - Should be classified as profile (missing landmarks)") + else: + print(f" - All landmarks present") + if face_width: + print(f" - Face width: {face_width}px") + if face_width < 25.0: + print(f" - Face width < 25px, should be profile") + else: + print(f" - Face width >= 25px, should be frontal") + else: + print(f" - No landmarks available") + else: + abs_yaw = abs(face.yaw_angle) + print(f" - Yaw angle: {face.yaw_angle:.2f}ยฐ (abs: {abs_yaw:.2f}ยฐ)") + if abs_yaw >= 30.0: + expected = "profile_left" if face.yaw_angle < 0 else "profile_right" + print(f" - |yaw| >= 30ยฐ, should be '{expected}'") + else: + print(f" - |yaw| < 30ยฐ, should be 'frontal'") + else: + print(f"โœ“ Pose mode matches recalculated value") + + print() + + finally: + session.close() + +if __name__ == "__main__": + face_id1 = None + face_id2 = None + + if len(sys.argv) > 1: + try: + face_id1 = int(sys.argv[1]) + except ValueError: + print(f"Invalid face ID: {sys.argv[1]}") + sys.exit(1) + + if len(sys.argv) > 2: + try: + face_id2 = int(sys.argv[2]) + except ValueError: + print(f"Invalid face ID: {sys.argv[2]}") + sys.exit(1) + + try: + check_two_faces(face_id1, face_id2) + except Exception as e: + print(f"โŒ Error: {e}") + import traceback + traceback.print_exc() + sys.exit(1) + diff --git a/scripts/debug/check_yaw_angles.py b/scripts/debug/check_yaw_angles.py new file mode 100644 index 0000000..d2e399f --- /dev/null +++ b/scripts/debug/check_yaw_angles.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +""" +Check yaw angles in database to see why profile faces aren't being detected +""" + +import sqlite3 +import os + +db_path = "data/punimtag.db" + +if not os.path.exists(db_path): + print(f"โŒ Database not found: {db_path}") + exit(1) + +conn = sqlite3.connect(db_path) +conn.row_factory = sqlite3.Row +cursor = conn.cursor() + +# Get all faces with yaw data +cursor.execute(""" + SELECT id, pose_mode, yaw_angle, pitch_angle, roll_angle + FROM faces + WHERE yaw_angle IS NOT NULL + ORDER BY ABS(yaw_angle) DESC +""") + +faces = cursor.fetchall() + +print(f"Found {len(faces)} faces with yaw data\n") +print("=" * 80) +print("YAW ANGLE ANALYSIS") +print("=" * 80) +print(f"\n{'Face ID':<10} {'Pose Mode':<25} {'Yaw':<10} {'Should be Profile?'}") +print("-" * 80) + +PROFILE_THRESHOLD = 30.0 # From pose_detection.py + +profile_count = 0 +for face in faces: + yaw = face['yaw_angle'] + pose_mode = face['pose_mode'] + is_profile = abs(yaw) >= PROFILE_THRESHOLD + should_be_profile = "YES" if is_profile else "NO" + + if is_profile: + profile_count += 1 + + print(f"{face['id']:<10} {pose_mode:<25} {yaw:>8.2f}ยฐ {should_be_profile}") + +print("\n" + "=" * 80) +print(f"Total faces with yaw data: {len(faces)}") +print(f"Faces with |yaw| >= {PROFILE_THRESHOLD}ยฐ (should be profile): {profile_count}") +print(f"Faces currently classified as profile: {cursor.execute('SELECT COUNT(*) FROM faces WHERE pose_mode LIKE \"profile%\"').fetchone()[0]}") +print("=" * 80) + +# Check yaw distribution +print("\n" + "=" * 80) +print("YAW ANGLE DISTRIBUTION") +print("=" * 80) +cursor.execute(""" + SELECT + CASE + WHEN ABS(yaw_angle) < 30 THEN 'frontal (< 30ยฐ)' + WHEN ABS(yaw_angle) >= 30 AND ABS(yaw_angle) < 60 THEN 'profile (30-60ยฐ)' + WHEN ABS(yaw_angle) >= 60 THEN 'extreme profile (>= 60ยฐ)' + ELSE 'unknown' + END as category, + COUNT(*) as count + FROM faces + WHERE yaw_angle IS NOT NULL + GROUP BY category + ORDER BY count DESC +""") + +distribution = cursor.fetchall() +for row in distribution: + print(f" {row['category']}: {row['count']} faces") + +conn.close() + diff --git a/scripts/debug/debug_pose_classification.py b/scripts/debug/debug_pose_classification.py new file mode 100755 index 0000000..48966ee --- /dev/null +++ b/scripts/debug/debug_pose_classification.py @@ -0,0 +1,253 @@ +#!/usr/bin/env python3 +"""Debug pose classification for identified faces + +This script helps identify why poses might be incorrectly classified. +It shows detailed pose information and can recalculate poses from photos. +""" + +import sys +import os +import json +from typing import Optional, List, Tuple + +# Add project root to path +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from backend.db.models import Face, Person, Photo +from backend.db.session import get_database_url +from src.utils.pose_detection import PoseDetector + + +def analyze_pose_classification( + face_id: Optional[int] = None, + person_id: Optional[int] = None, + recalculate: bool = False, +) -> None: + """Analyze pose classification for identified faces. + + Args: + face_id: Specific face ID to check (None = all identified faces) + person_id: Specific person ID to check (None = all persons) + recalculate: If True, recalculate pose from photo to verify classification + """ + db_url = get_database_url() + print(f"Connecting to database: {db_url}") + + engine = create_engine(db_url) + Session = sessionmaker(bind=engine) + session = Session() + + try: + # Build query + query = ( + session.query(Face, Person, Photo) + .join(Person, Face.person_id == Person.id) + .join(Photo, Face.photo_id == Photo.id) + .filter(Face.person_id.isnot(None)) + ) + + if face_id: + query = query.filter(Face.id == face_id) + if person_id: + query = query.filter(Person.id == person_id) + + faces = query.order_by(Person.id, Face.id).all() + + if not faces: + print("No identified faces found matching criteria.") + return + + print(f"\n{'='*80}") + print(f"Found {len(faces)} identified face(s)") + print(f"{'='*80}\n") + + pose_detector = None + if recalculate: + try: + pose_detector = PoseDetector() + print("Pose detector initialized for recalculation\n") + except Exception as e: + print(f"Warning: Could not initialize pose detector: {e}") + print("Skipping recalculation\n") + recalculate = False + + for face, person, photo in faces: + person_name = f"{person.first_name} {person.last_name}" + + print(f"{'='*80}") + print(f"Face ID: {face.id}") + print(f"Person: {person_name} (ID: {person.id})") + print(f"Photo: {photo.filename}") + print(f"Photo Path: {photo.path}") + print(f"{'-'*80}") + + # Current stored pose information + print("STORED POSE INFORMATION:") + print(f" Pose Mode: {face.pose_mode}") + print(f" Yaw Angle: {face.yaw_angle:.2f}ยฐ" if face.yaw_angle is not None else " Yaw Angle: None") + print(f" Pitch Angle: {face.pitch_angle:.2f}ยฐ" if face.pitch_angle is not None else " Pitch Angle: None") + print(f" Roll Angle: {face.roll_angle:.2f}ยฐ" if face.roll_angle is not None else " Roll Angle: None") + print(f" Face Confidence: {face.face_confidence:.3f}") + print(f" Quality Score: {face.quality_score:.3f}") + + # Parse location + try: + location = json.loads(face.location) if isinstance(face.location, str) else face.location + print(f" Location: {location}") + except: + print(f" Location: {face.location}") + + # Analyze classification + print(f"\nPOSE CLASSIFICATION ANALYSIS:") + yaw = face.yaw_angle + pitch = face.pitch_angle + roll = face.roll_angle + + if yaw is not None: + abs_yaw = abs(yaw) + print(f" Yaw: {yaw:.2f}ยฐ (absolute: {abs_yaw:.2f}ยฐ)") + + if abs_yaw < 30.0: + expected_mode = "frontal" + print(f" โ†’ Expected: {expected_mode} (yaw < 30ยฐ)") + elif yaw <= -30.0: + expected_mode = "profile_left" + print(f" โ†’ Expected: {expected_mode} (yaw <= -30ยฐ, face turned left)") + elif yaw >= 30.0: + expected_mode = "profile_right" + print(f" โ†’ Expected: {expected_mode} (yaw >= 30ยฐ, face turned right)") + else: + expected_mode = "unknown" + print(f" โ†’ Expected: {expected_mode} (edge case)") + + if face.pose_mode != expected_mode: + print(f" โš ๏ธ MISMATCH: Stored pose_mode='{face.pose_mode}' but expected '{expected_mode}'") + else: + print(f" โœ“ Classification matches expected mode") + else: + print(f" Yaw: None (cannot determine pose from yaw)") + print(f" โš ๏ธ Warning: Yaw angle is missing, pose classification may be unreliable") + + # Recalculate if requested + if recalculate and pose_detector and photo.path and os.path.exists(photo.path): + print(f"\nRECALCULATING POSE FROM PHOTO:") + try: + pose_faces = pose_detector.detect_pose_faces(photo.path) + + if not pose_faces: + print(" No faces detected in photo") + else: + # Try to match face by location + face_location = location if isinstance(location, dict) else json.loads(face.location) if isinstance(face.location, str) else {} + face_x = face_location.get('x', 0) + face_y = face_location.get('y', 0) + face_w = face_location.get('w', 0) + face_h = face_location.get('h', 0) + face_center_x = face_x + face_w / 2 + face_center_y = face_y + face_h / 2 + + best_match = None + best_distance = float('inf') + + for pose_face in pose_faces: + pose_area = pose_face.get('facial_area', {}) + if isinstance(pose_area, dict): + pose_x = pose_area.get('x', 0) + pose_y = pose_area.get('y', 0) + pose_w = pose_area.get('w', 0) + pose_h = pose_area.get('h', 0) + pose_center_x = pose_x + pose_w / 2 + pose_center_y = pose_y + pose_h / 2 + + # Calculate distance between centers + distance = ((face_center_x - pose_center_x) ** 2 + + (face_center_y - pose_center_y) ** 2) ** 0.5 + + if distance < best_distance: + best_distance = distance + best_match = pose_face + + if best_match: + recalc_yaw = best_match.get('yaw_angle') + recalc_pitch = best_match.get('pitch_angle') + recalc_roll = best_match.get('roll_angle') + recalc_face_width = best_match.get('face_width') + recalc_pose_mode = best_match.get('pose_mode') + + print(f" Recalculated Yaw: {recalc_yaw:.2f}ยฐ" if recalc_yaw is not None else " Recalculated Yaw: None") + print(f" Recalculated Pitch: {recalc_pitch:.2f}ยฐ" if recalc_pitch is not None else " Recalculated Pitch: None") + print(f" Recalculated Roll: {recalc_roll:.2f}ยฐ" if recalc_roll is not None else " Recalculated Roll: None") + print(f" Face Width: {recalc_face_width:.2f}px" if recalc_face_width is not None else " Face Width: None") + print(f" Recalculated Pose Mode: {recalc_pose_mode}") + + # Compare + if recalc_pose_mode != face.pose_mode: + print(f" โš ๏ธ MISMATCH: Stored='{face.pose_mode}' vs Recalculated='{recalc_pose_mode}'") + + if recalc_yaw is not None and face.yaw_angle is not None: + # Convert Decimal to float for comparison + stored_yaw = float(face.yaw_angle) + yaw_diff = abs(recalc_yaw - stored_yaw) + if yaw_diff > 1.0: # More than 1 degree difference + print(f" โš ๏ธ Yaw difference: {yaw_diff:.2f}ยฐ") + else: + print(" Could not match face location to detected faces") + + except Exception as e: + print(f" Error recalculating: {e}") + import traceback + traceback.print_exc() + + print() + + print(f"{'='*80}") + print("Analysis complete") + print(f"{'='*80}\n") + + finally: + session.close() + + +def main(): + """Main entry point""" + import argparse + + parser = argparse.ArgumentParser( + description="Debug pose classification for identified faces" + ) + parser.add_argument( + "--face-id", + type=int, + help="Specific face ID to check" + ) + parser.add_argument( + "--person-id", + type=int, + help="Specific person ID to check" + ) + parser.add_argument( + "--recalculate", + action="store_true", + help="Recalculate pose from photo to verify classification" + ) + + args = parser.parse_args() + + try: + analyze_pose_classification( + face_id=args.face_id, + person_id=args.person_id, + recalculate=args.recalculate, + ) + except Exception as e: + print(f"โŒ Error: {e}") + import traceback + traceback.print_exc() + sys.exit(1) + + +if __name__ == "__main__": + main() + diff --git a/scripts/debug/diagnose_frontend_issues.py b/scripts/debug/diagnose_frontend_issues.py new file mode 100644 index 0000000..9249ca9 --- /dev/null +++ b/scripts/debug/diagnose_frontend_issues.py @@ -0,0 +1,160 @@ +#!/usr/bin/env python3 +""" +Diagnose frontend issues: +1. Check if backend API is running and accessible +2. Check database connection +3. Test search endpoint +""" + +import os +import sys +import requests +from pathlib import Path + +# Add project root to path +project_root = Path(__file__).parent.parent +sys.path.insert(0, str(project_root)) + +from backend.db.session import get_database_url, engine +from sqlalchemy import text + +def check_backend_api(): + """Check if backend API is running.""" + print("=" * 80) + print("BACKEND API CHECK") + print("=" * 80) + + try: + # Check if docs endpoint is accessible + response = requests.get("http://127.0.0.1:8000/docs", timeout=5) + if response.status_code == 200: + print("โœ… Backend API is running (docs accessible)") + else: + print(f"โš ๏ธ Backend API returned status {response.status_code}") + except requests.exceptions.ConnectionError: + print("โŒ Backend API is NOT running or not accessible") + print(" Start it with: cd backend && uvicorn app:app --reload") + return False + except Exception as e: + print(f"โŒ Error checking backend API: {e}") + return False + + # Check search endpoint (requires auth) + try: + response = requests.get( + "http://127.0.0.1:8000/api/v1/photos", + params={"search_type": "processed", "page": 1, "page_size": 1}, + timeout=5 + ) + if response.status_code == 200: + print("โœ… Search endpoint is accessible (no auth required for this query)") + elif response.status_code == 401: + print("โš ๏ธ Search endpoint requires authentication") + print(" User needs to log in through admin frontend") + else: + print(f"โš ๏ธ Search endpoint returned status {response.status_code}") + except Exception as e: + print(f"โš ๏ธ Error checking search endpoint: {e}") + + return True + +def check_database(): + """Check database connection and photo count.""" + print("\n" + "=" * 80) + print("DATABASE CHECK") + print("=" * 80) + + db_url = get_database_url() + print(f"Database URL: {db_url.replace('://', '://****') if '://' in db_url else db_url}") + + try: + with engine.connect() as conn: + # Check photo count + result = conn.execute(text("SELECT COUNT(*) FROM photos WHERE processed = 1")) + count = result.scalar() + print(f"โœ… Database connection successful") + print(f" Processed photos: {count}") + + if count == 0: + print("โš ๏ธ No processed photos found in database") + print(" This explains why viewer frontend shows 0 photos") + else: + print(f" Database has {count} processed photos") + + except Exception as e: + print(f"โŒ Database connection error: {e}") + return False + + return True + +def check_viewer_frontend_config(): + """Check viewer frontend configuration.""" + print("\n" + "=" * 80) + print("VIEWER FRONTEND CONFIGURATION") + print("=" * 80) + + viewer_env = project_root / "viewer-frontend" / ".env" + if not viewer_env.exists(): + print("โŒ viewer-frontend/.env file not found") + return False + + with open(viewer_env) as f: + content = f.read() + if "DATABASE_URL" in content: + # Extract DATABASE_URL + for line in content.split("\n"): + if line.startswith("DATABASE_URL="): + db_url = line.split("=", 1)[1].strip().strip('"') + print(f"Viewer frontend DATABASE_URL: {db_url.replace('://', '://****') if '://' in db_url else db_url}") + + # Check if it matches actual database + actual_db = get_database_url() + if "postgresql" in db_url and "sqlite" in actual_db: + print("โŒ MISMATCH: Viewer frontend configured for PostgreSQL") + print(" but actual database is SQLite") + print("\n SOLUTION OPTIONS:") + print(" 1. Change viewer-frontend/.env DATABASE_URL to SQLite:") + print(f' DATABASE_URL="file:../data/punimtag.db"') + print(" 2. Update Prisma schema to use SQLite provider") + print(" 3. Migrate database to PostgreSQL") + return False + elif "sqlite" in db_url and "sqlite" in actual_db: + print("โœ… Viewer frontend configured for SQLite (matches actual database)") + else: + print("โš ๏ธ Database type mismatch or unclear") + else: + print("โš ๏ธ DATABASE_URL not found in viewer-frontend/.env") + + return True + +def main(): + print("\n๐Ÿ” DIAGNOSING FRONTEND ISSUES\n") + + backend_ok = check_backend_api() + db_ok = check_database() + viewer_config_ok = check_viewer_frontend_config() + + print("\n" + "=" * 80) + print("SUMMARY") + print("=" * 80) + + if not backend_ok: + print("โŒ Backend API is not running - admin frontend search will fail") + else: + print("โœ… Backend API is running") + + if not db_ok: + print("โŒ Database connection issue") + else: + print("โœ… Database connection OK") + + if not viewer_config_ok: + print("โŒ Viewer frontend configuration issue - needs to be fixed") + else: + print("โœ… Viewer frontend configuration OK") + + print("\n" + "=" * 80) + +if __name__ == "__main__": + main() + diff --git a/scripts/debug/test_eye_visibility.py b/scripts/debug/test_eye_visibility.py new file mode 100644 index 0000000..2503d06 --- /dev/null +++ b/scripts/debug/test_eye_visibility.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +""" +Test if RetinaFace provides both eyes for profile faces or if one eye is missing +""" + +import sys +import os + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) + +try: + from src.utils.pose_detection import PoseDetector, RETINAFACE_AVAILABLE + from pathlib import Path + + if not RETINAFACE_AVAILABLE: + print("โŒ RetinaFace not available") + exit(1) + + detector = PoseDetector() + + # Find test images + test_image_paths = ["demo_photos", "data/uploads"] + test_image = None + + for path in test_image_paths: + if os.path.exists(path): + for ext in ['.jpg', '.jpeg', '.png']: + for img_file in Path(path).glob(f'*{ext}'): + test_image = str(img_file) + break + if test_image: + break + + if not test_image: + print("โŒ No test image found") + exit(1) + + print(f"Testing with: {test_image}\n") + print("=" * 80) + print("EYE VISIBILITY ANALYSIS") + print("=" * 80) + + faces = detector.detect_faces_with_landmarks(test_image) + + if not faces: + print("โŒ No faces detected") + exit(1) + + print(f"Found {len(faces)} face(s)\n") + + for face_key, face_data in faces.items(): + landmarks = face_data.get('landmarks', {}) + print(f"{face_key}:") + print(f" Landmarks available: {list(landmarks.keys())}") + + left_eye = landmarks.get('left_eye') + right_eye = landmarks.get('right_eye') + nose = landmarks.get('nose') + + print(f" Left eye: {left_eye}") + print(f" Right eye: {right_eye}") + print(f" Nose: {nose}") + + # Check if both eyes are present + both_eyes_present = left_eye is not None and right_eye is not None + only_left_eye = left_eye is not None and right_eye is None + only_right_eye = left_eye is None and right_eye is not None + no_eyes = left_eye is None and right_eye is None + + print(f"\n Eye visibility:") + print(f" Both eyes present: {both_eyes_present}") + print(f" Only left eye: {only_left_eye}") + print(f" Only right eye: {only_right_eye}") + print(f" No eyes: {no_eyes}") + + # Calculate yaw if possible + yaw = detector.calculate_yaw_from_landmarks(landmarks) + print(f" Yaw angle: {yaw:.2f}ยฐ" if yaw is not None else " Yaw angle: None (requires both eyes)") + + # Calculate face width if both eyes present + if both_eyes_present: + face_width = abs(right_eye[0] - left_eye[0]) + print(f" Face width (eye distance): {face_width:.2f} pixels") + + # If face width is very small, it might be a profile view + if face_width < 20: + print(f" โš ๏ธ Very small face width - likely extreme profile view") + + # Classify pose + pitch = detector.calculate_pitch_from_landmarks(landmarks) + roll = detector.calculate_roll_from_landmarks(landmarks) + pose_mode = detector.classify_pose_mode(yaw, pitch, roll) + + print(f" Pose mode: {pose_mode}") + print() + + print("\n" + "=" * 80) + print("CONCLUSION") + print("=" * 80) + print(""" +If RetinaFace provides both eyes even for profile faces: + - We can use eye distance (face width) as an indicator + - Small face width (< 20-30 pixels) suggests extreme profile + - But we can't directly use 'missing eye' as a signal + +If RetinaFace sometimes only provides one eye for profile faces: + - We can check if left_eye or right_eye is None + - If only one eye is present, it's likely a profile view + - This would be a strong indicator for profile detection + """) + +except ImportError as e: + print(f"โŒ Import error: {e}") + print("Make sure you're in the project directory and dependencies are installed") + diff --git a/scripts/debug/test_pose_calculation.py b/scripts/debug/test_pose_calculation.py new file mode 100644 index 0000000..ac01cf7 --- /dev/null +++ b/scripts/debug/test_pose_calculation.py @@ -0,0 +1,161 @@ +#!/usr/bin/env python3 +""" +Test pitch and roll angle calculations to investigate issues +""" + +import sys +import os + +# Add src to path +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) + +try: + from src.utils.pose_detection import PoseDetector, RETINAFACE_AVAILABLE + import sqlite3 + from pathlib import Path + + def test_retinaface_landmarks(): + """Test what landmarks RetinaFace actually provides""" + if not RETINAFACE_AVAILABLE: + print("โŒ RetinaFace not available") + return + + print("=" * 60) + print("TESTING RETINAFACE LANDMARKS") + print("=" * 60) + + # Try to find a test image + test_image_paths = [ + "demo_photos", + "data/uploads", + "data" + ] + + detector = PoseDetector() + test_image = None + + for path in test_image_paths: + if os.path.exists(path): + for ext in ['.jpg', '.jpeg', '.png']: + for img_file in Path(path).glob(f'*{ext}'): + test_image = str(img_file) + break + if test_image: + break + + if not test_image: + print("โŒ No test image found") + return + + print(f"Using test image: {test_image}") + + # Detect faces + faces = detector.detect_faces_with_landmarks(test_image) + + if not faces: + print("โŒ No faces detected") + return + + print(f"\nโœ… Found {len(faces)} face(s)") + + for face_key, face_data in faces.items(): + print(f"\n{face_key}:") + landmarks = face_data.get('landmarks', {}) + print(f" Landmarks keys: {list(landmarks.keys())}") + + for landmark_name, position in landmarks.items(): + print(f" {landmark_name}: {position}") + + # Test calculations + yaw = detector.calculate_yaw_from_landmarks(landmarks) + pitch = detector.calculate_pitch_from_landmarks(landmarks) + roll = detector.calculate_roll_from_landmarks(landmarks) + + print(f"\n Calculated angles:") + print(f" Yaw: {yaw:.2f}ยฐ" if yaw is not None else " Yaw: None") + print(f" Pitch: {pitch:.2f}ยฐ" if pitch is not None else " Pitch: None") + print(f" Roll: {roll:.2f}ยฐ" if roll is not None else " Roll: None") + + # Check which landmarks are missing for pitch + required_for_pitch = ['left_eye', 'right_eye', 'left_mouth', 'right_mouth', 'nose'] + missing = [lm for lm in required_for_pitch if lm not in landmarks] + if missing: + print(f" โš ๏ธ Missing landmarks for pitch: {missing}") + + # Check roll calculation + if roll is not None: + left_eye = landmarks.get('left_eye') + right_eye = landmarks.get('right_eye') + if left_eye and right_eye: + dx = right_eye[0] - left_eye[0] + dy = right_eye[1] - left_eye[1] + print(f" Roll calculation details:") + print(f" dx (right_eye[0] - left_eye[0]): {dx:.2f}") + print(f" dy (right_eye[1] - left_eye[1]): {dy:.2f}") + print(f" atan2(dy, dx) = {roll:.2f}ยฐ") + + # Normalize to [-90, 90] range + normalized_roll = roll + if normalized_roll > 90: + normalized_roll = normalized_roll - 180 + elif normalized_roll < -90: + normalized_roll = normalized_roll + 180 + print(f" Normalized to [-90, 90]: {normalized_roll:.2f}ยฐ") + + pose_mode = detector.classify_pose_mode(yaw, pitch, roll) + print(f" Pose mode: {pose_mode}") + + def analyze_database_angles(): + """Analyze angles in database to find patterns""" + db_path = "data/punimtag.db" + + if not os.path.exists(db_path): + print(f"โŒ Database not found: {db_path}") + return + + print("\n" + "=" * 60) + print("ANALYZING DATABASE ANGLES") + print("=" * 60) + + conn = sqlite3.connect(db_path) + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + + # Get faces with angle data + cursor.execute(""" + SELECT id, pose_mode, yaw_angle, pitch_angle, roll_angle + FROM faces + WHERE yaw_angle IS NOT NULL OR pitch_angle IS NOT NULL OR roll_angle IS NOT NULL + LIMIT 20 + """) + + faces = cursor.fetchall() + print(f"\nFound {len(faces)} faces with angle data\n") + + for face in faces: + print(f"Face ID {face['id']}: {face['pose_mode']}") + print(f" Yaw: {face['yaw_angle']:.2f}ยฐ" if face['yaw_angle'] else " Yaw: None") + print(f" Pitch: {face['pitch_angle']:.2f}ยฐ" if face['pitch_angle'] else " Pitch: None") + print(f" Roll: {face['roll_angle']:.2f}ยฐ" if face['roll_angle'] else " Roll: None") + + # Check roll normalization + if face['roll_angle'] is not None: + roll = face['roll_angle'] + normalized = roll + if normalized > 90: + normalized = normalized - 180 + elif normalized < -90: + normalized = normalized + 180 + print(f" Roll normalized: {normalized:.2f}ยฐ") + print() + + conn.close() + + if __name__ == "__main__": + test_retinaface_landmarks() + analyze_database_angles() + +except ImportError as e: + print(f"โŒ Import error: {e}") + print("Make sure you're in the project directory and dependencies are installed") + diff --git a/scripts/utils/fix_admin_password.py b/scripts/utils/fix_admin_password.py new file mode 100644 index 0000000..5ab7c7a --- /dev/null +++ b/scripts/utils/fix_admin_password.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 +"""Fix admin user password in database.""" + +import sys +import os + +# Add project root to path +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from backend.db.session import get_db +from backend.db.models import User +from backend.utils.password import hash_password, verify_password + +def fix_admin_password(): + """Set admin user password to 'admin'.""" + db = next(get_db()) + try: + admin_user = db.query(User).filter(User.username == 'admin').first() + + if not admin_user: + print("โŒ Admin user not found in database") + return False + + # Set password to 'admin' + new_hash = hash_password('admin') + admin_user.password_hash = new_hash + admin_user.is_active = True + admin_user.is_admin = True + db.commit() + + # Verify it works + if verify_password('admin', new_hash): + print("โœ… Admin password updated successfully") + print(" Username: admin") + print(" Password: admin") + return True + else: + print("โŒ Password verification failed after update") + return False + except Exception as e: + print(f"โŒ Error: {e}") + import traceback + traceback.print_exc() + db.rollback() + return False + finally: + db.close() + +if __name__ == "__main__": + success = fix_admin_password() + sys.exit(0 if success else 1) + + diff --git a/scripts/utils/update_reported_photo_status.py b/scripts/utils/update_reported_photo_status.py new file mode 100644 index 0000000..a69f682 --- /dev/null +++ b/scripts/utils/update_reported_photo_status.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 +"""Update status of a reported photo in the auth database.""" + +import sys +from pathlib import Path + +# Add project root to path +project_root = Path(__file__).parent.parent +sys.path.insert(0, str(project_root)) + +from sqlalchemy import text +from backend.db.session import get_auth_database_url, AuthSessionLocal + +def update_reported_photo_status(report_id: int, new_status: str): + """Update the status of a reported photo.""" + if AuthSessionLocal is None: + raise ValueError("Auth database not configured. Set DATABASE_URL_AUTH environment variable.") + + db = AuthSessionLocal() + try: + # First check if the report exists and get its current status + check_result = db.execute(text(""" + SELECT id, status, review_notes + FROM inappropriate_photo_reports + WHERE id = :report_id + """), {"report_id": report_id}) + + row = check_result.fetchone() + if not row: + print(f"โŒ Reported photo {report_id} not found in database.") + return + + current_status = row.status + review_notes = row.review_notes + + print(f"๐Ÿ“‹ Current status: '{current_status}'") + if review_notes: + print(f"๐Ÿ“ Review notes: '{review_notes}'") + + if current_status == new_status: + print(f"โ„น๏ธ Status is already '{new_status}'. No update needed.") + return + + # Update the status + result = db.execute(text(""" + UPDATE inappropriate_photo_reports + SET status = :new_status + WHERE id = :report_id + """), { + "new_status": new_status, + "report_id": report_id + }) + + db.commit() + + if result.rowcount > 0: + print(f"โœ… Successfully updated reported photo {report_id} status from '{current_status}' to '{new_status}'") + else: + print(f"โš ๏ธ No rows updated.") + + except Exception as e: + db.rollback() + print(f"โŒ Error updating reported photo status: {str(e)}") + raise + finally: + db.close() + +def find_reported_photo_by_note(search_note: str): + """Find reported photos by review notes.""" + if AuthSessionLocal is None: + raise ValueError("Auth database not configured. Set DATABASE_URL_AUTH environment variable.") + + db = AuthSessionLocal() + try: + result = db.execute(text(""" + SELECT id, photo_id, status, review_notes, reported_at + FROM inappropriate_photo_reports + WHERE review_notes LIKE :search_pattern + ORDER BY id DESC + """), {"search_pattern": f"%{search_note}%"}) + + rows = result.fetchall() + if not rows: + print(f"โŒ No reported photos found with note containing '{search_note}'") + return [] + + print(f"๐Ÿ“‹ Found {len(rows)} reported photo(s) with note containing '{search_note}':\n") + for row in rows: + print(f" ID: {row.id}, Photo ID: {row.photo_id}, Status: {row.status}") + print(f" Notes: {row.review_notes}") + print(f" Reported at: {row.reported_at}\n") + + return rows + + except Exception as e: + print(f"โŒ Error searching for reported photos: {str(e)}") + raise + finally: + db.close() + +if __name__ == "__main__": + if len(sys.argv) < 3: + print("Usage: python scripts/update_reported_photo_status.py ") + print(" OR: python scripts/update_reported_photo_status.py search ") + print("Example: python scripts/update_reported_photo_status.py 57 dismissed") + print("Example: python scripts/update_reported_photo_status.py search 'agree. removed'") + sys.exit(1) + + if sys.argv[1] == "search": + search_text = sys.argv[2] + find_reported_photo_by_note(search_text) + else: + report_id = int(sys.argv[1]) + new_status = sys.argv[2] + update_reported_photo_status(report_id, new_status) + diff --git a/viewer-frontend/.cursorignore b/viewer-frontend/.cursorignore new file mode 100644 index 0000000..5178c33 --- /dev/null +++ b/viewer-frontend/.cursorignore @@ -0,0 +1,15 @@ +# Ignore history files and directories +.history/ +*.history +*_YYYYMMDDHHMMSS.* +*_timestamp.* + +# Ignore backup files +*.bak +*.backup +*~ + +# Ignore temporary files +*.tmp +*.temp + diff --git a/viewer-frontend/.cursorrules b/viewer-frontend/.cursorrules new file mode 100644 index 0000000..32e4496 --- /dev/null +++ b/viewer-frontend/.cursorrules @@ -0,0 +1,31 @@ +# Cursor Rules for PunimTag Viewer + +## File Management + +- NEVER create history files or backup files with timestamps +- NEVER create files in .history/ directory +- NEVER create files with patterns like: *_YYYYMMDDHHMMSS.* or *_timestamp.* +- DO NOT use Local History extension features that create history files +- When editing files, edit them directly - do not create timestamped copies + +## Code Style + +- Use TypeScript for all new files +- Follow Next.js 14 App Router conventions +- Use shadcn/ui components when available +- Prefer Server Components over Client Components when possible +- Use 'use client' directive only when necessary (interactivity, hooks, browser APIs) + +## File Naming + +- Use kebab-case for file names: `photo-grid.tsx`, `search-content.tsx` +- Use PascalCase for component names: `PhotoGrid`, `SearchContent` +- Use descriptive, clear names - avoid abbreviations + +## Development Practices + +- Edit files in place - do not create backup copies +- Use Git for version control, not file history extensions +- Test changes before committing +- Follow the existing code structure and patterns + diff --git a/viewer-frontend/.env.example b/viewer-frontend/.env.example new file mode 100644 index 0000000..15aac4c --- /dev/null +++ b/viewer-frontend/.env.example @@ -0,0 +1,19 @@ +# Database Configuration +# Read-only database connection (for reading photos, faces, people, tags) +DATABASE_URL="postgresql://viewer_readonly:password@localhost:5432/punimtag" + +# Write-capable database connection (for user registration, pending identifications) +# If not set, will fall back to DATABASE_URL +# Option 1: Use the same user (after granting write permissions) +# DATABASE_URL_WRITE="postgresql://viewer_readonly:password@localhost:5432/punimtag" +# Option 2: Use a separate write user (recommended) +DATABASE_URL_WRITE="postgresql://viewer_write:password@localhost:5432/punimtag" + +# NextAuth Configuration +# Generate a secure secret using: openssl rand -base64 32 +NEXTAUTH_SECRET="your-secret-key-here-generate-with-openssl-rand-base64-32" +NEXTAUTH_URL="http://localhost:3001" + +# Site Configuration +NEXT_PUBLIC_SITE_NAME="PunimTag Photo Viewer" +NEXT_PUBLIC_SITE_DESCRIPTION="Family Photo Gallery" diff --git a/viewer-frontend/.gitignore b/viewer-frontend/.gitignore new file mode 100644 index 0000000..1cf6372 --- /dev/null +++ b/viewer-frontend/.gitignore @@ -0,0 +1,48 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* +!.env.example + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +/app/generated/prisma + +# history files (from Local History extension) +.history/ +*.history diff --git a/viewer-frontend/.npmrc b/viewer-frontend/.npmrc new file mode 100644 index 0000000..6eccebb --- /dev/null +++ b/viewer-frontend/.npmrc @@ -0,0 +1 @@ +# Ensure npm doesn't treat this as a workspace diff --git a/viewer-frontend/EMAIL_VERIFICATION_SETUP.md b/viewer-frontend/EMAIL_VERIFICATION_SETUP.md new file mode 100644 index 0000000..0e691a7 --- /dev/null +++ b/viewer-frontend/EMAIL_VERIFICATION_SETUP.md @@ -0,0 +1,156 @@ +# Email Verification Setup + +This document provides step-by-step instructions to complete the email verification setup. + +## โœ… Already Completed + +1. โœ… Resend package installed +2. โœ… Prisma schema updated +3. โœ… Prisma client regenerated +4. โœ… Code implementation complete +5. โœ… API endpoints created +6. โœ… UI components updated + +## ๐Ÿ”ง Remaining Steps + +### Step 1: Run Database Migration + +The database migration needs to be run as a PostgreSQL superuser (or a user with ALTER TABLE permissions). + +**Option A: Using psql as postgres user** +```bash +sudo -u postgres psql -d punimtag_auth -f migrations/add-email-verification-columns.sql +``` + +**Option B: Using psql with password** +```bash +psql -U postgres -d punimtag_auth -f migrations/add-email-verification-columns.sql +``` + +**Option C: Manual SQL execution** +Connect to your database and run: +```sql +\c punimtag_auth + +ALTER TABLE users +ADD COLUMN IF NOT EXISTS email_verified BOOLEAN NOT NULL DEFAULT true; + +ALTER TABLE users +ADD COLUMN IF NOT EXISTS email_confirmation_token VARCHAR(255) UNIQUE; + +ALTER TABLE users +ADD COLUMN IF NOT EXISTS email_confirmation_token_expiry TIMESTAMP; + +CREATE INDEX IF NOT EXISTS idx_users_email_confirmation_token ON users(email_confirmation_token); + +UPDATE users +SET email_verified = true +WHERE email_confirmation_token IS NULL; +``` + +### Step 2: Set Up Resend + +1. **Sign up for Resend:** + - Go to [resend.com](https://resend.com) + - Create a free account (3,000 emails/month free tier) + +2. **Get your API key:** + - Go to API Keys in your Resend dashboard + - Create a new API key + - Copy the key (starts with `re_`) + +3. **Add to your `.env` file:** + ```bash + RESEND_API_KEY="re_your_api_key_here" + RESEND_FROM_EMAIL="noreply@yourdomain.com" + ``` + + **For development/testing:** + - You can use Resend's test domain: `onboarding@resend.dev` + - No domain verification needed for testing + + **For production:** + - Verify your domain in Resend dashboard + - Use your verified domain: `noreply@yourdomain.com` + +### Step 3: Verify Setup + +1. **Check database columns:** + ```sql + \c punimtag_auth + \d users + ``` + You should see: + - `email_verified` (boolean) + - `email_confirmation_token` (varchar) + - `email_confirmation_token_expiry` (timestamp) + +2. **Test registration:** + - Go to your registration page + - Create a new account + - Check your email for the confirmation message + - Click the confirmation link + - Try logging in + +3. **Test resend:** + - If email doesn't arrive, try the "Resend confirmation email" option on the login page + +## ๐Ÿ” Troubleshooting + +### "must be owner of table users" +- You need to run the migration as a PostgreSQL superuser +- Use `sudo -u postgres` or connect as the `postgres` user + +### "Failed to send confirmation email" +- Check that `RESEND_API_KEY` is set correctly in `.env` +- Verify the API key is valid in Resend dashboard +- Check server logs for detailed error messages + +### "Email not verified" error on login +- Make sure the user clicked the confirmation link +- Check that the token hasn't expired (24 hours) +- Use "Resend confirmation email" to get a new link + +### Existing users can't log in +- The migration sets `email_verified = true` for existing users automatically +- If issues persist, manually update: + ```sql + UPDATE users SET email_verified = true WHERE email_confirmation_token IS NULL; + ``` + +## ๐Ÿ“ Environment Variables Summary + +Add these to your `.env` file: + +```bash +# Required for email verification +RESEND_API_KEY="re_your_api_key_here" +RESEND_FROM_EMAIL="noreply@yourdomain.com" + +# Optional: Override base URL for email links +# NEXT_PUBLIC_APP_URL="http://localhost:3001" +``` + +## โœ… Verification Checklist + +- [ ] Database migration run successfully +- [ ] `email_verified` column exists in `users` table +- [ ] `email_confirmation_token` column exists +- [ ] `email_confirmation_token_expiry` column exists +- [ ] `RESEND_API_KEY` set in `.env` +- [ ] `RESEND_FROM_EMAIL` set in `.env` +- [ ] Test registration sends email +- [ ] Email confirmation link works +- [ ] Login works after verification +- [ ] Resend confirmation email works + +## ๐ŸŽ‰ You're Done! + +Once all steps are complete, email verification is fully functional. New users will need to verify their email before they can log in. + + + + + + + diff --git a/viewer-frontend/FACE_TOOLTIP_ANALYSIS.md b/viewer-frontend/FACE_TOOLTIP_ANALYSIS.md new file mode 100644 index 0000000..609d108 --- /dev/null +++ b/viewer-frontend/FACE_TOOLTIP_ANALYSIS.md @@ -0,0 +1,191 @@ +# Face Tooltip and Click-to-Identify Analysis + +## Issues Identified + +### 1. **Image Reference Not Being Set Properly** + +**Location:** `PhotoViewerClient.tsx` lines 546-549, 564-616 + +**Problem:** +- The `imageRef` is set in `handleImageLoad` callback (line 548) +- However, `findFaceAtPoint` checks if `imageRef.current` exists (line 569) +- If `imageRef.current` is null, face detection fails completely +- Next.js `Image` component with `fill` prop may not reliably trigger `onLoad` or the ref may not be accessible + +**Evidence:** +```typescript +const findFaceAtPoint = useCallback((x: number, y: number) => { + // ... + if (!imageRef.current || !containerRef.current) { + return null; // โ† This will prevent ALL face detection if ref isn't set + } + // ... +}, [currentPhoto.faces]); +``` + +**Impact:** If `imageRef.current` is null, `findFaceAtPoint` always returns null, so: +- No faces are detected on hover +- `hoveredFace` state never gets set +- Tooltips never appear +- Click detection never works + +--- + +### 2. **Tooltip Logic Issues** + +**Location:** `PhotoViewerClient.tsx` lines 155-159 + +**Problem:** The tooltip logic has restrictive conditions: + +```typescript +const hoveredFaceTooltip = hoveredFace + ? hoveredFace.personName + ? (isLoggedIn ? hoveredFace.personName : null) // โ† Issue: hides name if not logged in + : (!session || hasWriteAccess ? 'Identify' : null) // โ† Issue: hides "Identify" for logged-in users without write access + : null; +``` + +**Issues:** +1. **Identified faces:** Tooltip only shows if user is logged in. If not logged in, tooltip is `null` even though face is identified. +2. **Unidentified faces:** Tooltip shows "Identify" only if: + - User is NOT signed in, OR + - User has write access + - If user is logged in but doesn't have write access, tooltip is `null` + +**Expected Behavior:** +- Identified faces should show person name regardless of login status +- Unidentified faces should show "Identify" if user has write access (or is not logged in) + +--- + +### 3. **Click Handler Logic Issues** + +**Location:** `PhotoViewerClient.tsx` lines 661-686 + +**Problem:** The click handler has restrictive conditions: + +```typescript +const handleClick = useCallback((e: React.MouseEvent) => { + // ... + const face = findFaceAtPoint(e.clientX, e.clientY); + + // Only allow clicking if: face is identified, or user is not signed in, or user has write access + if (face && (face.person || !session || hasWriteAccess)) { + setClickedFace({...}); + setIsDialogOpen(true); + } +}, [findFaceAtPoint, session, hasWriteAccess, currentPhoto, isVideoPlaying]); +``` + +**Issues:** +1. If `findFaceAtPoint` returns null (due to imageRef issue), click never works +2. If user is logged in without write access and face is unidentified, click is blocked +3. The condition `face.person || !session || hasWriteAccess` means: + - Can click identified faces (anyone) + - Can click unidentified faces only if not logged in OR has write access + - Logged-in users without write access cannot click unidentified faces + +**Expected Behavior:** +- Should allow clicking unidentified faces if user has write access +- Should allow clicking identified faces to view/edit (if has write access) + +--- + +### 4. **Click Handler Event Blocking** + +**Location:** `PhotoViewerClient.tsx` lines 1096-1106 + +**Problem:** The click handler checks for buttons and zoom controls, but also checks `isDragging`: + +```typescript +onClick={(e) => { + // Don't handle click if it's on a button or zoom controls + const target = e.target as HTMLElement; + if (target.closest('button') || target.closest('[aria-label*="Zoom"]') || target.closest('[aria-label*="Reset zoom"]')) { + return; + } + // For images, only handle click if not dragging + if (!isDragging || zoom === 1) { // โ† Issue: if dragging and zoomed, click is ignored + handleClick(e); + } +}} +``` + +**Issue:** If user is dragging (panning) and then clicks, the click is ignored. This might prevent face clicks if there's any drag state. + +--- + +### 5. **Data Structure Mismatch (Potential)** + +**Location:** `page.tsx` line 182 vs `PhotoViewerClient.tsx` line 33 + +**Problem:** +- Database query uses `Face` (capital F) and `Person` (capital P) +- Component expects `faces` (lowercase) and `person` (lowercase) +- Serialization function should handle this, but if it doesn't, faces won't be available + +**Evidence:** +- `page.tsx` line 182: `Face: faces.filter(...)` (capital F) +- `PhotoViewerClient.tsx` line 33: `faces?: FaceWithLocation[]` (lowercase) +- Component accesses `currentPhoto.faces` (lowercase) + +**Impact:** If serialization doesn't transform `Face` โ†’ `faces`, then `currentPhoto.faces` will be undefined, and face detection won't work. + +--- + +## Root Cause Analysis + +### Primary Issue: Image Reference +The most likely root cause is that `imageRef.current` is not being set properly, which causes: +1. `findFaceAtPoint` to always return null +2. No face detection on hover +3. No tooltips +4. No click detection + +### Secondary Issues: Logic Conditions +Even if imageRef works, the tooltip and click logic have restrictive conditions that prevent: +- Showing tooltips for identified faces when not logged in +- Showing "Identify" tooltip for logged-in users without write access +- Clicking unidentified faces for logged-in users without write access + +--- + +## Recommended Fixes + +### Fix 1: Ensure Image Reference is Set +- Add a ref directly to the Image component's container or use a different approach +- Add fallback to find image element via DOM query if ref isn't set +- Add debug logging to verify ref is being set + +### Fix 2: Fix Tooltip Logic +- Show person name for identified faces regardless of login status +- Show "Identify" for unidentified faces only if user has write access (or is not logged in) + +### Fix 3: Fix Click Handler Logic +- Allow clicking unidentified faces if user has write access +- Allow clicking identified faces to view/edit (if has write access) +- Remove the `isDragging` check or make it more lenient + +### Fix 4: Verify Data Structure +- Ensure serialization transforms `Face` โ†’ `faces` and `Person` โ†’ `person` +- Add debug logging to verify faces are present in `currentPhoto.faces` + +### Fix 5: Add Debug Logging +- Log when `imageRef.current` is set +- Log when `findFaceAtPoint` is called and what it returns +- Log when `hoveredFace` state changes +- Log when click handler is triggered and what conditions are met + +--- + +## Testing Checklist + +After fixes, verify: +- [ ] Image ref is set after image loads +- [ ] Hovering over identified face shows person name (logged in and not logged in) +- [ ] Hovering over unidentified face shows "Identify" if user has write access +- [ ] Clicking identified face opens dialog (if has write access) +- [ ] Clicking unidentified face opens dialog (if has write access) +- [ ] Tooltips appear at correct position near cursor +- [ ] Click works even after panning/zooming + diff --git a/viewer-frontend/GRANT_PERMISSIONS.md b/viewer-frontend/GRANT_PERMISSIONS.md new file mode 100644 index 0000000..badf62f --- /dev/null +++ b/viewer-frontend/GRANT_PERMISSIONS.md @@ -0,0 +1,114 @@ +# Granting Database Permissions + +This document describes how to grant read-only permissions to the `viewer_readonly` user on the main `punimtag` database tables. + +## Quick Reference + +**โœ… WORKING METHOD (tested and confirmed):** +```bash +PGPASSWORD=punimtag_password psql -h localhost -U punimtag -d punimtag -f grant_readonly_permissions.sql +``` + +## When to Run This + +Run this script when you see errors like: +- `permission denied for table photos` +- `permission denied for table people` +- `permission denied for table faces` +- Any other "permission denied" errors when accessing database tables + +This typically happens when: +- Database tables are recreated/dropped +- Database is restored from backup +- Permissions are accidentally revoked +- Setting up a new environment + +## Methods + +### Method 1: Using punimtag user (Recommended - Tested) + +```bash +PGPASSWORD=punimtag_password psql -h localhost -U punimtag -d punimtag -f grant_readonly_permissions.sql +``` + +### Method 2: Using postgres user + +```bash +PGPASSWORD=postgres_password psql -h localhost -U postgres -d punimtag -f grant_readonly_permissions.sql +``` + +### Method 3: Using sudo + +```bash +sudo -u postgres psql -d punimtag -f grant_readonly_permissions.sql +``` + +### Method 4: Manual connection + +```bash +psql -U punimtag -d punimtag +``` + +Then paste these commands: +```sql +GRANT CONNECT ON DATABASE punimtag TO viewer_readonly; +GRANT USAGE ON SCHEMA public TO viewer_readonly; +GRANT SELECT ON TABLE photos TO viewer_readonly; +GRANT SELECT ON TABLE people TO viewer_readonly; +GRANT SELECT ON TABLE faces TO viewer_readonly; +GRANT SELECT ON TABLE person_encodings TO viewer_readonly; +GRANT SELECT ON TABLE tags TO viewer_readonly; +GRANT SELECT ON TABLE phototaglinkage TO viewer_readonly; +GRANT SELECT ON TABLE photo_favorites TO viewer_readonly; +GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO viewer_readonly; +``` + +## Verification + +After granting permissions, verify they work: + +1. **Check permissions script:** + ```bash + npm run check:permissions + ``` + +2. **Check health endpoint:** + ```bash + curl http://localhost:3001/api/health + ``` + +3. **Test the website:** + - Refresh the browser + - Photos should load without permission errors + - Search functionality should work + +## What Permissions Are Granted + +The script grants the following permissions to `viewer_readonly`: + +- **CONNECT** on database `punimtag` +- **USAGE** on schema `public` +- **SELECT** on tables: + - `photos` + - `people` + - `faces` + - `person_encodings` + - `tags` + - `phototaglinkage` + - `photo_favorites` +- **USAGE, SELECT** on all sequences in schema `public` +- **Default privileges** for future tables (optional) + +## Notes + +- Replace `punimtag_password` with the actual password for the `punimtag` user (found in `.env` file) +- The `viewer_readonly` user should only have SELECT permissions (read-only) +- If you need write access, use `DATABASE_URL_WRITE` with a different user (`viewer_write`) + + + + + + + + diff --git a/viewer-frontend/README.md b/viewer-frontend/README.md new file mode 100644 index 0000000..f72ddbd --- /dev/null +++ b/viewer-frontend/README.md @@ -0,0 +1,485 @@ +# PunimTag Photo Viewer + +A modern, fast, and beautiful photo viewing website that connects to your PunimTag PostgreSQL database. + +## ๐Ÿš€ Quick Start + +### Prerequisites + +See the [Prerequisites Guide](docs/PREREQUISITES.md) for a complete list of required and optional software. + +**Required:** +- Node.js 20+ (currently using 18.19.1 - may need upgrade) +- PostgreSQL database with PunimTag schema +- Read-only database user (see setup below) + +**Optional:** +- **FFmpeg** (for video thumbnail generation) - See [FFmpeg Setup Guide](docs/FFMPEG_SETUP.md) +- **libvips** (for image watermarking) - See [Prerequisites Guide](docs/PREREQUISITES.md) +- **Resend API Key** (for email verification) +- **Network-accessible storage** (for photo uploads) + +### Installation + +**Quick Setup (Recommended):** +```bash +# Run the comprehensive setup script +npm run setup +``` + +This will: +- Install all npm dependencies +- Set up Sharp library (for image processing) +- Generate Prisma clients +- Set up database tables (if DATABASE_URL_AUTH is configured) +- Create admin user (if needed) +- Verify the setup + +**Manual Setup:** +1. **Install dependencies:** + ```bash + npm run install:deps + # Or manually: + npm install + npm run prisma:generate:all + ``` + + The install script will: + - Check Node.js version + - Install npm dependencies + - Set up Sharp library (for image processing) + - Generate Prisma clients + - Check for optional system dependencies (libvips, FFmpeg) + +2. **Set up environment variables:** + Create a `.env` file in the root directory: + ```bash + DATABASE_URL="postgresql://viewer_readonly:password@localhost:5432/punimtag" + DATABASE_URL_WRITE="postgresql://viewer_write:password@localhost:5432/punimtag" + DATABASE_URL_AUTH="postgresql://viewer_write:password@localhost:5432/punimtag_auth" + NEXTAUTH_SECRET="your-secret-key-here" + NEXTAUTH_URL="http://localhost:3001" + NEXT_PUBLIC_SITE_NAME="PunimTag Photo Viewer" + NEXT_PUBLIC_SITE_DESCRIPTION="Family Photo Gallery" + # Email verification (Resend) + RESEND_API_KEY="re_your_resend_api_key_here" + RESEND_FROM_EMAIL="noreply@yourdomain.com" + # Optional: Override base URL for email links (defaults to NEXTAUTH_URL) + # NEXT_PUBLIC_APP_URL="http://localhost:3001" + # Upload directory for pending photos (REQUIRED - must be network-accessible) + # RECOMMENDED: Use the same server as your database (see docs/NETWORK_SHARE_SETUP.md) + # Examples: + # Database server via SSHFS: /mnt/db-server-uploads/pending-photos + # Separate network share: /mnt/shared/pending-photos + # Windows: \\server\share\pending-photos (mapped to drive) + UPLOAD_DIR="/mnt/db-server-uploads/pending-photos" + # Or use PENDING_PHOTOS_DIR as an alias + # PENDING_PHOTOS_DIR="/mnt/network-share/pending-photos" + ``` + + **Note:** Generate a secure `NEXTAUTH_SECRET` using: + ```bash + openssl rand -base64 32 + ``` + +3. **Grant read-only permissions on main database tables:** + + The read-only user needs SELECT permissions on all main tables. If you see "permission denied" errors, run: + + **โœ… WORKING METHOD (tested and confirmed):** + ```bash + PGPASSWORD=punimtag_password psql -h localhost -U punimtag -d punimtag -f grant_readonly_permissions.sql + ``` + + **Alternative methods:** + ```bash + # Using postgres user: + PGPASSWORD=postgres_password psql -h localhost -U postgres -d punimtag -f grant_readonly_permissions.sql + + # Using sudo: + sudo -u postgres psql -d punimtag -f grant_readonly_permissions.sql + ``` + + **Check permissions:** + ```bash + npm run check:permissions + ``` + + This will verify all required permissions and provide instructions if any are missing. + + **For Face Identification (Write Access):** + + You have two options to enable write access for face identification: + + **Option 1: Grant write permissions to existing user** (simpler) + ```bash + # Run as PostgreSQL superuser: + psql -U postgres -d punimtag -f grant_write_permissions.sql + ``` + Then use the same `DATABASE_URL` for both read and write operations. + + **Option 2: Create a separate write user** (more secure) + ```bash + # Run as PostgreSQL superuser: + psql -U postgres -d punimtag -f create_write_user.sql + ``` + Then add to your `.env` file: + ```bash + DATABASE_URL_WRITE="postgresql://viewer_write:password@localhost:5432/punimtag" + ``` + +4. **Create database tables for authentication:** + ```bash + # Run as PostgreSQL superuser: + psql -U postgres -d punimtag_auth -f create_auth_tables.sql + ``` + + **Add pending_photos table for photo uploads:** + ```bash + # Run as PostgreSQL superuser: + psql -U postgres -d punimtag_auth -f migrations/add-pending-photos-table.sql + ``` + + **Add email verification columns:** + ```bash + # Run as PostgreSQL superuser: + psql -U postgres -d punimtag_auth -f migrations/add-email-verification-columns.sql + ``` + + Then grant permissions to your write user: + ```sql + -- If using viewer_write user: + GRANT SELECT, INSERT, UPDATE ON TABLE users TO viewer_write; + GRANT SELECT, INSERT, UPDATE ON TABLE pending_identifications TO viewer_write; + GRANT SELECT, INSERT, UPDATE ON TABLE pending_photos TO viewer_write; + GRANT USAGE, SELECT ON SEQUENCE users_id_seq TO viewer_write; + GRANT USAGE, SELECT ON SEQUENCE pending_identifications_id_seq TO viewer_write; + GRANT USAGE, SELECT ON SEQUENCE pending_photos_id_seq TO viewer_write; + + -- Or if using viewer_readonly with write permissions: + GRANT SELECT, INSERT, UPDATE ON TABLE users TO viewer_readonly; + GRANT SELECT, INSERT, UPDATE ON TABLE pending_identifications TO viewer_readonly; + GRANT SELECT, INSERT, UPDATE ON TABLE pending_photos TO viewer_readonly; + GRANT USAGE, SELECT ON SEQUENCE users_id_seq TO viewer_readonly; + GRANT USAGE, SELECT ON SEQUENCE pending_identifications_id_seq TO viewer_readonly; + GRANT USAGE, SELECT ON SEQUENCE pending_photos_id_seq TO viewer_readonly; + ``` + +5. **Generate Prisma client:** + ```bash + npx prisma generate + ``` + +6. **Run development server:** + ```bash + npm run dev + ``` + +7. **Open your browser:** + Navigate to http://localhost:3000 + +## ๐Ÿ“ Project Structure + +``` +punimtag-viewer/ +โ”œโ”€โ”€ app/ # Next.js 14 App Router +โ”‚ โ”œโ”€โ”€ layout.tsx # Root layout +โ”‚ โ”œโ”€โ”€ page.tsx # Home page (photo grid with search) +โ”‚ โ”œโ”€โ”€ HomePageContent.tsx # Client component for home page +โ”‚ โ”œโ”€โ”€ search/ # Search page +โ”‚ โ”‚ โ”œโ”€โ”€ page.tsx # Search page +โ”‚ โ”‚ โ””โ”€โ”€ SearchContent.tsx # Search content component +โ”‚ โ””โ”€โ”€ api/ # API routes +โ”‚ โ”œโ”€โ”€ search/ # Search API endpoint +โ”‚ โ””โ”€โ”€ photos/ # Photo API endpoints +โ”œโ”€โ”€ components/ # React components +โ”‚ โ”œโ”€โ”€ PhotoGrid.tsx # Photo grid with tooltips +โ”‚ โ”œโ”€โ”€ search/ # Search components +โ”‚ โ”‚ โ”œโ”€โ”€ CollapsibleSearch.tsx # Collapsible search bar +โ”‚ โ”‚ โ”œโ”€โ”€ FilterPanel.tsx # Filter panel +โ”‚ โ”‚ โ”œโ”€โ”€ PeopleFilter.tsx # People filter +โ”‚ โ”‚ โ”œโ”€โ”€ DateRangeFilter.tsx # Date range filter +โ”‚ โ”‚ โ”œโ”€โ”€ TagFilter.tsx # Tag filter +โ”‚ โ”‚ โ””โ”€โ”€ SearchBar.tsx # Search bar component +โ”‚ โ””โ”€โ”€ ui/ # shadcn/ui components +โ”œโ”€โ”€ lib/ # Utilities +โ”‚ โ”œโ”€โ”€ db.ts # Prisma client +โ”‚ โ””โ”€โ”€ queries.ts # Database query helpers +โ”œโ”€โ”€ prisma/ +โ”‚ โ””โ”€โ”€ schema.prisma # Database schema +โ””โ”€โ”€ public/ # Static assets +``` + +## ๐Ÿ” Database Setup + +### Create Read-Only User + +On your PostgreSQL server, run: + +```sql +-- Create read-only user +CREATE USER viewer_readonly WITH PASSWORD 'your_secure_password'; + +-- Grant permissions +GRANT CONNECT ON DATABASE punimtag TO viewer_readonly; +GRANT USAGE ON SCHEMA public TO viewer_readonly; +GRANT SELECT ON ALL TABLES IN SCHEMA public TO viewer_readonly; + +-- Grant on future tables +ALTER DEFAULT PRIVILEGES IN SCHEMA public +GRANT SELECT ON TABLES TO viewer_readonly; + +-- Verify no write permissions +REVOKE INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public FROM viewer_readonly; +``` + +## ๐ŸŽจ Features + +- โœ… Photo grid with responsive layout +- โœ… Image optimization with Next.js Image +- โœ… Read-only database access +- โœ… Type-safe queries with Prisma +- โœ… Modern, clean design +- โœ… **Collapsible search bar** on main page with filters +- โœ… **Search functionality** - Search by people, dates, and tags +- โœ… **Photo tooltips** - Hover over photos to see people names +- โœ… **Search page** - Dedicated search page at `/search` +- โœ… **Filter panel** - People, date range, and tag filters + +## โœ‰๏ธ Email Verification + +The application includes email verification for new user registrations. Users must verify their email address before they can sign in. + +### Setup + +1. **Get a Resend API Key:** + - Sign up at [resend.com](https://resend.com) + - Create an API key in your dashboard + - Add it to your `.env` file: + ```bash + RESEND_API_KEY="re_your_api_key_here" + RESEND_FROM_EMAIL="noreply@yourdomain.com" + ``` + +2. **Run the Database Migration:** + ```bash + psql -U postgres -d punimtag_auth -f migrations/add-email-verification-columns.sql + ``` + +3. **Configure Email Domain (Optional):** + - For production, verify your domain in Resend + - Update `RESEND_FROM_EMAIL` to use your verified domain + - For development, you can use Resend's test domain (`onboarding@resend.dev`) + +### How It Works + +1. **Registration:** When a user signs up, they receive a confirmation email with a verification link +2. **Verification:** Users click the link to verify their email address +3. **Login:** Users must verify their email before they can sign in +4. **Resend:** Users can request a new confirmation email if needed + +### Features + +- โœ… Secure token-based verification (24-hour expiration) +- โœ… Email verification required before login +- โœ… Resend confirmation email functionality +- โœ… User-friendly error messages +- โœ… Backward compatible (existing users are auto-verified) + +## ๐Ÿ“ค Photo Uploads + +Users can upload photos for admin review. Uploaded photos are stored on a **network-accessible location** (required) and tracked in the database. + +### Storage Location + +Uploaded photos are stored in a directory structure organized by user ID: +``` +{UPLOAD_DIR}/ + โ””โ”€โ”€ {userId}/ + โ””โ”€โ”€ {timestamp}-{filename} +``` + +**Configuration (REQUIRED):** +- **Must** set `UPLOAD_DIR` or `PENDING_PHOTOS_DIR` environment variable +- **Must** point to a network-accessible location (database server recommended) +- The directory will be created automatically if it doesn't exist + +**Recommended: Use Database Server** + +The simplest setup is to use the same server where your PostgreSQL database is located: + +1. **Create directory on database server:** + ```bash + ssh user@db-server.example.com + sudo mkdir -p /var/punimtag/uploads/pending-photos + ``` + +2. **Mount database server on web server (via SSHFS):** + ```bash + sudo apt-get install sshfs + sudo mkdir -p /mnt/db-server-uploads + sudo sshfs user@db-server.example.com:/var/punimtag/uploads /mnt/db-server-uploads + ``` + +3. **Set in .env:** + ```bash + UPLOAD_DIR="/mnt/db-server-uploads/pending-photos" + ``` + +**See full setup guide:** [`docs/NETWORK_SHARE_SETUP.md`](docs/NETWORK_SHARE_SETUP.md) + +**Important:** +- Ensure the web server process has read/write permissions +- The approval system must have read access to the same location +- Test network connectivity and permissions before deploying + +### Database Tracking + +Upload metadata is stored in the `pending_photos` table in the `punimtag_auth` database: +- File location and metadata +- User who uploaded +- Status: `pending`, `approved`, `rejected` +- Review information (when reviewed, by whom, rejection reason) + +### Access for Approval System + +The approval system can: +1. **Read files from disk** using the `file_path` from the database +2. **Query the database** for pending photos: + ```sql + SELECT * FROM pending_photos WHERE status = 'pending' ORDER BY submitted_at; + ``` +3. **Update status** after review: + ```sql + UPDATE pending_photos + SET status = 'approved', reviewed_at = NOW(), reviewed_by = {admin_user_id} + WHERE id = {photo_id}; + ``` + +## ๐Ÿšง Coming Soon + +- [ ] Photo detail page with lightbox +- [ ] Infinite scroll +- [ ] Favorites system +- [ ] People and tags browsers +- [ ] Authentication (optional) + +## ๐Ÿ“š Documentation + +For complete documentation, see: +- [Quick Start Guide](../../punimtag/docs/PHOTO_VIEWER_QUICKSTART.md) +- [Complete Plan](../../punimtag/docs/PHOTO_VIEWER_PLAN.md) +- [Architecture](../../punimtag/docs/PHOTO_VIEWER_ARCHITECTURE.md) + +## ๐Ÿ› ๏ธ Development + +### Available Scripts + +- `npm run dev` - Start development server +- `npm run build` - Build for production +- `npm run start` - Start production server +- `npm run lint` - Run ESLint +- `npm run check:permissions` - Check database permissions and provide fix instructions + +### Prisma Commands + +- `npx prisma generate` - Generate Prisma client +- `npx prisma studio` - Open Prisma Studio (database browser) +- `npx prisma db pull` - Pull schema from database + +## ๐Ÿ” Troubleshooting + +### Permission Denied Errors + +If you see "permission denied for table photos" errors: + +1. **Check permissions:** + ```bash + npm run check:permissions + ``` + +2. **Grant permissions (WORKING METHOD - tested and confirmed):** + ```bash + PGPASSWORD=punimtag_password psql -h localhost -U punimtag -d punimtag -f grant_readonly_permissions.sql + ``` + + **Alternative methods:** + ```bash + # Using postgres user: + PGPASSWORD=postgres_password psql -h localhost -U postgres -d punimtag -f grant_readonly_permissions.sql + + # Using sudo: + sudo -u postgres psql -d punimtag -f grant_readonly_permissions.sql + ``` + +3. **Or check health endpoint:** + ```bash + curl http://localhost:3001/api/health + ``` + +### Database Connection Issues + +- Verify `DATABASE_URL` is set correctly in `.env` +- Check that the database user exists and has the correct password +- Ensure PostgreSQL is running and accessible + +## โš ๏ธ Known Issues + +- Node.js version: Currently using Node 18.19.1, but Next.js 16 requires >=20.9.0 + - **Solution:** Upgrade Node.js or use Node Version Manager (nvm) + +## ๐Ÿ“ Notes + +### Image Serving (Hybrid Approach) + +The application automatically detects and handles two types of photo storage: + +1. **HTTP/HTTPS URLs** (SharePoint, CDN, etc.) + - If `photo.path` starts with `http://` or `https://`, images are served directly + - Next.js Image optimization is applied automatically + - Configure allowed domains in `next.config.ts` โ†’ `remotePatterns` + +2. **File System Paths** (Local storage) + - If `photo.path` is a file system path, images are served via API proxy + - Make sure photo file paths are accessible from the Next.js server + - No additional configuration needed + +**Benefits:** +- โœ… Works with both SharePoint URLs and local file system +- โœ… Automatic detection - no configuration needed per photo +- โœ… Optimal performance for both storage types +- โœ… No N+1 database queries (path passed via query parameter) + +### Search Features + +The application includes a powerful search system: + +1. **Collapsible Search Bar** (Main Page) + - Minimized by default to save space + - Click to expand and reveal full filter panel + - Shows active filter count badge + - Filters photos in real-time + +2. **Search Filters** + - **People Filter**: Multi-select searchable dropdown + - **Date Range Filter**: Presets (Today, This Week, This Month, This Year) or custom range + - **Tag Filter**: Multi-select searchable tag filter + - All filters work together with AND logic + +3. **Photo Tooltips** + - Hover over any photo to see people names + - Shows "People: Name1, Name2" if people are identified + - Falls back to filename if no people identified + +4. **Search Page** (`/search`) + - Dedicated search page with full filter panel + - URL query parameter sync for shareable search links + - Pagination support + +## ๐Ÿค Contributing + +This is a private project. For questions or issues, refer to the main PunimTag documentation. + +--- + +**Built with:** Next.js 14, React, TypeScript, Prisma, Tailwind CSS diff --git a/viewer-frontend/SETUP.md b/viewer-frontend/SETUP.md new file mode 100644 index 0000000..e5e623c --- /dev/null +++ b/viewer-frontend/SETUP.md @@ -0,0 +1,264 @@ +# PunimTag Photo Viewer - Setup Instructions + +## โœ… What's Been Completed + +1. โœ… Next.js 14 project created with TypeScript and Tailwind CSS +2. โœ… Core dependencies installed: + - Prisma ORM + - TanStack Query + - React Photo Album + - Yet Another React Lightbox + - Lucide React (icons) + - Framer Motion (animations) + - Date-fns (date handling) + - shadcn/ui components (button, input, select, calendar, popover, badge, checkbox, tooltip) +3. โœ… Prisma schema created matching PunimTag database structure +4. โœ… Database connection utility created (`lib/db.ts`) +5. โœ… Initial home page with photo grid component +6. โœ… Next.js image optimization configured +7. โœ… shadcn/ui initialized +8. โœ… **Collapsible search bar** on main page +9. โœ… **Search functionality** - Search by people, dates, and tags +10. โœ… **Search API endpoint** (`/api/search`) +11. โœ… **Search page** at `/search` +12. โœ… **Photo tooltips** showing people names on hover +13. โœ… **Filter components** - People, Date Range, and Tag filters + +## ๐Ÿ”ง Next Steps to Complete Setup + +### 1. Configure Database Connection + +Create a `.env` file in the project root: + +```bash +DATABASE_URL="postgresql://viewer_readonly:your_password@localhost:5432/punimtag" +NEXT_PUBLIC_SITE_NAME="PunimTag Photo Viewer" +NEXT_PUBLIC_SITE_DESCRIPTION="Family Photo Gallery" +``` + +**Important:** Replace `your_password` with the actual password for the read-only database user. + +### 2. Create Read-Only Database User (if not already done) + +Connect to your PostgreSQL database and run: + +```sql +-- Create read-only user +CREATE USER viewer_readonly WITH PASSWORD 'your_secure_password'; + +-- Grant permissions +GRANT CONNECT ON DATABASE punimtag TO viewer_readonly; +GRANT USAGE ON SCHEMA public TO viewer_readonly; +GRANT SELECT ON ALL TABLES IN SCHEMA public TO viewer_readonly; + +-- Grant on future tables +ALTER DEFAULT PRIVILEGES IN SCHEMA public +GRANT SELECT ON TABLES TO viewer_readonly; + +-- Verify no write permissions +REVOKE INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public FROM viewer_readonly; +``` + +### 3. Install System Dependencies (Optional but Recommended) + +**For Image Watermarking (libvips):** +```bash +# Ubuntu/Debian +sudo apt update +sudo apt install libvips-dev + +# Rebuild sharp package after installing libvips +cd viewer-frontend +npm rebuild sharp +``` + +**For Video Thumbnails (FFmpeg):** +```bash +# Ubuntu/Debian +sudo apt install ffmpeg +``` + +**Note:** The application will work without these, but: +- Without libvips: Images will be served without watermarks +- Without FFmpeg: Videos will show placeholder thumbnails + +### 4. Generate Prisma Client + +```bash +cd /home/ladmin/Code/punimtag-viewer +npx prisma generate +``` + +### 5. Test Database Connection + +```bash +# Optional: Open Prisma Studio to browse database +npx prisma studio +``` + +### 6. Run Development Server + +```bash +npm run dev +``` + +Open http://localhost:3000 in your browser. + +## โš ๏ธ Known Issues + +### Node.js Version Warning + +The project was created with Next.js 16, which requires Node.js >=20.9.0, but the system currently has Node.js 18.19.1. + +**Solutions:** + +1. **Upgrade Node.js** (Recommended): + ```bash + # Using nvm (Node Version Manager) + nvm install 20 + nvm use 20 + ``` + +2. **Or use Next.js 14** (if you prefer to stay on Node 18): + ```bash + npm install next@14 react@18 react-dom@18 + ``` + +## ๐Ÿ“ Project Structure + +``` +punimtag-viewer/ +โ”œโ”€โ”€ app/ +โ”‚ โ”œโ”€โ”€ layout.tsx # Root layout with Inter font +โ”‚ โ”œโ”€โ”€ page.tsx # Home page (server component) +โ”‚ โ”œโ”€โ”€ HomePageContent.tsx # Home page client component with search +โ”‚ โ”œโ”€โ”€ search/ # Search page +โ”‚ โ”‚ โ”œโ”€โ”€ page.tsx # Search page (server component) +โ”‚ โ”‚ โ””โ”€โ”€ SearchContent.tsx # Search content (client component) +โ”‚ โ”œโ”€โ”€ api/ # API routes +โ”‚ โ”‚ โ”œโ”€โ”€ search/ # Search API endpoint +โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ route.ts # Search route handler +โ”‚ โ”‚ โ””โ”€โ”€ photos/ # Photo API endpoints +โ”‚ โ””โ”€โ”€ globals.css # Global styles (updated by shadcn) +โ”œโ”€โ”€ components/ +โ”‚ โ”œโ”€โ”€ PhotoGrid.tsx # Photo grid with tooltips +โ”‚ โ”œโ”€โ”€ search/ # Search components +โ”‚ โ”‚ โ”œโ”€โ”€ CollapsibleSearch.tsx # Collapsible search bar +โ”‚ โ”‚ โ”œโ”€โ”€ FilterPanel.tsx # Filter panel container +โ”‚ โ”‚ โ”œโ”€โ”€ PeopleFilter.tsx # People filter component +โ”‚ โ”‚ โ”œโ”€โ”€ DateRangeFilter.tsx # Date range filter +โ”‚ โ”‚ โ”œโ”€โ”€ TagFilter.tsx # Tag filter component +โ”‚ โ”‚ โ””โ”€โ”€ SearchBar.tsx # Search bar (for future text search) +โ”‚ โ””โ”€โ”€ ui/ # shadcn/ui components +โ”‚ โ”œโ”€โ”€ button.tsx +โ”‚ โ”œโ”€โ”€ input.tsx +โ”‚ โ”œโ”€โ”€ select.tsx +โ”‚ โ”œโ”€โ”€ calendar.tsx +โ”‚ โ”œโ”€โ”€ popover.tsx +โ”‚ โ”œโ”€โ”€ badge.tsx +โ”‚ โ”œโ”€โ”€ checkbox.tsx +โ”‚ โ””โ”€โ”€ tooltip.tsx +โ”œโ”€โ”€ lib/ +โ”‚ โ”œโ”€โ”€ db.ts # Prisma client +โ”‚ โ”œโ”€โ”€ queries.ts # Database query helpers +โ”‚ โ””โ”€โ”€ utils.ts # Utility functions (from shadcn) +โ”œโ”€โ”€ prisma/ +โ”‚ โ””โ”€โ”€ schema.prisma # Database schema +โ””โ”€โ”€ .env # Environment variables (create this) +``` + +## ๐ŸŽจ Adding shadcn/ui Components + +To add UI components as needed: + +```bash +npx shadcn@latest add button +npx shadcn@latest add card +npx shadcn@latest add input +npx shadcn@latest add dialog +# ... etc +``` + +## ๐Ÿš€ Next Development Steps + +After setup is complete, follow the Quick Start Guide to add: + +1. **Photo Detail Page** - Individual photo view with lightbox +2. **People Browser** - Browse photos by person +3. **Tags Browser** - Browse photos by tag +4. **Infinite Scroll** - Load more photos as user scrolls +5. **Favorites System** - Allow users to favorite photos + +## โœจ Current Features + +### Search & Filtering +- โœ… **Collapsible Search Bar** on main page + - Minimized by default, click to expand + - Shows active filter count badge + - Real-time photo filtering + +- โœ… **Search Filters** + - People filter with searchable dropdown + - Date range filter with presets and custom range + - Tag filter with searchable dropdown + - All filters work together (AND logic) + +- โœ… **Search Page** (`/search`) + - Full search interface + - URL query parameter sync + - Pagination support + +### Photo Display +- โœ… **Photo Tooltips** + - Hover over photos to see people names + - Shows "People: Name1, Name2" format + - Falls back to filename if no people identified + +- โœ… **Photo Grid** + - Responsive grid layout + - Optimized image loading + - Hover effects + +## ๐Ÿ“š Documentation + +- **Quick Start Guide:** `/home/ladmin/Code/punimtag/docs/PHOTO_VIEWER_QUICKSTART.md` +- **Complete Plan:** `/home/ladmin/Code/punimtag/docs/PHOTO_VIEWER_PLAN.md` +- **Architecture:** `/home/ladmin/Code/punimtag/docs/PHOTO_VIEWER_ARCHITECTURE.md` + +## ๐Ÿ†˜ Troubleshooting + +### "Can't connect to database" +- Check `.env` file has correct `DATABASE_URL` +- Verify database is running +- Test connection: `psql -U viewer_readonly -d punimtag -h localhost` + +### "Prisma Client not generated" +- Run: `npx prisma generate` + +### "Module not found: @/..." +- Check `tsconfig.json` has `"@/*": ["./*"]` in paths + +### "Images not loading" + +**For File System Paths:** +- Verify photo file paths in database are accessible from the Next.js server +- Check that the API route (`/api/photos/[id]/image`) is working +- Check server logs for file not found errors + +**For HTTP/HTTPS URLs (SharePoint, CDN):** +- Verify the URL format in database (should start with `http://` or `https://`) +- Check `next.config.ts` has the domain configured in `remotePatterns` +- For SharePoint Online: `**.sharepoint.com` is already configured +- For on-premises SharePoint: Uncomment and update the hostname in `next.config.ts` +- Verify the URLs are publicly accessible or authentication is configured + +--- + +**Project Location:** `/home/ladmin/Code/punimtag-viewer` + +**Ready to continue development!** ๐Ÿš€ + + + + + diff --git a/viewer-frontend/SETUP_AUTH.md b/viewer-frontend/SETUP_AUTH.md new file mode 100644 index 0000000..05fa877 --- /dev/null +++ b/viewer-frontend/SETUP_AUTH.md @@ -0,0 +1,131 @@ +# Authentication Setup Guide + +This guide will help you set up the authentication and pending identifications functionality. + +## Prerequisites + +1. โœ… Code changes are complete +2. โœ… `.env` file is configured with `NEXTAUTH_SECRET` and database URLs +3. โš ๏ธ Database tables need to be created +4. โš ๏ธ Database permissions need to be granted + +## Step-by-Step Setup + +### 1. Create Database Tables + +Run the SQL script to create the new tables: + +```bash +psql -U postgres -d punimtag -f create_auth_tables.sql +``` + +Or manually run the SQL commands in `create_auth_tables.sql`. + +### 2. Grant Database Permissions + +You need to grant write permissions for the new tables. Choose one option: + +#### Option A: If using separate write user (`viewer_write`) + +```sql +-- Connect as postgres superuser +psql -U postgres -d punimtag + +-- Grant permissions +GRANT SELECT, INSERT, UPDATE ON TABLE users TO viewer_write; +GRANT SELECT, INSERT, UPDATE ON TABLE pending_identifications TO viewer_write; +GRANT USAGE, SELECT ON SEQUENCE users_id_seq TO viewer_write; +GRANT USAGE, SELECT ON SEQUENCE pending_identifications_id_seq TO viewer_write; +``` + +#### Option B: If using same user with write permissions (`viewer_readonly`) + +```sql +-- Connect as postgres superuser +psql -U postgres -d punimtag + +-- Grant permissions +GRANT SELECT, INSERT, UPDATE ON TABLE users TO viewer_readonly; +GRANT SELECT, INSERT, UPDATE ON TABLE pending_identifications TO viewer_readonly; +GRANT USAGE, SELECT ON SEQUENCE users_id_seq TO viewer_readonly; +GRANT USAGE, SELECT ON SEQUENCE pending_identifications_id_seq TO viewer_readonly; +``` + +### 3. Generate Prisma Client + +After creating the tables, regenerate the Prisma client: + +```bash +npx prisma generate +``` + +### 4. Verify Setup + +1. **Check tables exist:** + ```sql + \dt users + \dt pending_identifications + ``` + +2. **Test user registration:** + - Start the dev server: `npm run dev` + - Navigate to `http://localhost:3001/register` + - Try creating a new user account + - Check if the user appears in the database: + ```sql + SELECT * FROM users; + ``` + +3. **Test face identification:** + - Log in with your new account + - Open a photo with faces + - Click on a face to identify it + - Check if pending identification is created: + ```sql + SELECT * FROM pending_identifications; + ``` + +## Troubleshooting + +### Error: "permission denied for table users" + +**Solution:** Grant write permissions to your database user (see Step 2 above). + +### Error: "relation 'users' does not exist" + +**Solution:** Run the `create_auth_tables.sql` script (see Step 1 above). + +### Error: "PrismaClientValidationError" + +**Solution:** Regenerate Prisma client: `npx prisma generate` + +### Registration page shows error + +**Check:** +1. `.env` file has `DATABASE_URL_WRITE` configured +2. Database user has INSERT permission on `users` table +3. Prisma client is up to date: `npx prisma generate` + +## What Works Now + +โœ… User registration (`/register`) +โœ… User login (`/login`) +โœ… Face identification (requires login) +โœ… Pending identifications saved to database +โœ… Authentication checks in place + +## What's Not Implemented Yet + +โŒ Admin approval interface (to approve/reject pending identifications) +โŒ Applying approved identifications to the main `people` and `faces` tables + +## Next Steps + +Once everything is working: +1. Test user registration +2. Test face identification +3. Verify pending identifications are saved correctly +4. (Future) Implement admin approval interface + + + diff --git a/viewer-frontend/SETUP_AUTH_DATABASE.md b/viewer-frontend/SETUP_AUTH_DATABASE.md new file mode 100644 index 0000000..34670aa --- /dev/null +++ b/viewer-frontend/SETUP_AUTH_DATABASE.md @@ -0,0 +1,180 @@ +# Setting Up Separate Auth Database + +This guide explains how to set up a separate database for authentication and pending identifications, so you don't need to write to the read-only `punimtag` database. + +## Why a Separate Database? + +The `punimtag` database is read-only, but we need to store: +- User accounts (for login/authentication) +- Pending identifications (face identifications waiting for admin approval) + +By using a separate database (`punimtag_auth`), we can: +- โœ… Keep the punimtag database completely read-only +- โœ… Store user data and identifications separately +- โœ… Maintain data integrity without foreign key constraints across databases + +## Setup Steps + +### 1. Create the Auth Database + +Run the SQL script as a PostgreSQL superuser: + +```bash +psql -U postgres -f setup-auth-database.sql +``` + +Or connect to PostgreSQL and run manually: + +```sql +-- Create the database +CREATE DATABASE punimtag_auth; + +-- Connect to it +\c punimtag_auth + +-- Then run the rest of setup-auth-database.sql +``` + +### 2. Configure Environment Variables + +Add `DATABASE_URL_AUTH` to your `.env` file: + +```bash +DATABASE_URL_AUTH="postgresql://username:password@localhost:5432/punimtag_auth" +``` + +**Note:** You can use the same PostgreSQL user that has access to the punimtag database, or create a separate user specifically for the auth database. + +### 3. Generate Prisma Clients + +Generate both Prisma clients: + +```bash +# Generate main client (for punimtag database) +npm run prisma:generate + +# Generate auth client (for punimtag_auth database) +npm run prisma:generate:auth + +# Or generate both at once: +npm run prisma:generate:all +``` + +### 4. Create Admin User + +After the database is set up and Prisma clients are generated, create an admin user: + +```bash +npx tsx scripts/create-admin-user.ts +``` + +This will create an admin user with: +- **Email:** admin@admin.com +- **Password:** admin +- **Role:** Admin (can approve identifications) + +### 5. Verify Setup + +1. **Check tables exist:** + ```sql + \c punimtag_auth + \dt + ``` + You should see `users` and `pending_identifications` tables. + +2. **Check admin user:** + ```sql + SELECT email, name, is_admin FROM users WHERE email = 'admin@admin.com'; + ``` + +3. **Test registration:** + - Go to http://localhost:3001/register + - Create a new user account + - Verify it appears in the `punimtag_auth` database + +4. **Test login:** + - Go to http://localhost:3001/login + - Login with admin@admin.com / admin + +## Database Structure + +### `punimtag_auth` Database + +- **users** - User accounts for authentication +- **pending_identifications** - Face identifications pending admin approval + +### `punimtag` Database (Read-Only) + +- **photos** - Photo metadata +- **faces** - Detected faces in photos +- **people** - Identified people +- **tags** - Photo tags +- etc. + +## Important Notes + +### Foreign Key Constraints + +The `pending_identifications.face_id` field references `faces.id` in the `punimtag` database, but we **cannot use a foreign key constraint** across databases. The application validates that faces exist when creating pending identifications. + +### Face ID Validation + +When a user identifies a face, the application: +1. Validates the `faceId` exists in the `punimtag` database (read-only check) +2. Stores the identification in `punimtag_auth.pending_identifications` (write operation) + +This ensures data integrity without requiring write access to the punimtag database. + +## Troubleshooting + +### "Cannot find module '../node_modules/.prisma/client-auth'" + +Make sure you've generated the auth Prisma client: +```bash +npm run prisma:generate:auth +``` + +### "relation 'users' does not exist" + +Make sure you've created the auth database and run the setup script: +```bash +psql -U postgres -f setup-auth-database.sql +``` + +### "permission denied for table users" + +Make sure your database user has the necessary permissions. You can grant them with: +```sql +GRANT ALL PRIVILEGES ON DATABASE punimtag_auth TO your_user; +GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO your_user; +GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO your_user; +``` + +### "DATABASE_URL_AUTH is not defined" + +Make sure you've added `DATABASE_URL_AUTH` to your `.env` file. + +## Migration from Old Setup + +If you previously had `users` and `pending_identifications` tables in the `punimtag` database: + +1. **Export existing data** (if any): + ```sql + \c punimtag + \copy users TO 'users_backup.csv' CSV HEADER; + \copy pending_identifications TO 'pending_identifications_backup.csv' CSV HEADER; + ``` + +2. **Create the new auth database** (follow steps above) + +3. **Import data** (if needed): + ```sql + \c punimtag_auth + \copy users FROM 'users_backup.csv' CSV HEADER; + \copy pending_identifications FROM 'pending_identifications_backup.csv' CSV HEADER; + ``` + +4. **Update your `.env` file** with `DATABASE_URL_AUTH` + +5. **Regenerate Prisma clients** and restart your application + diff --git a/viewer-frontend/SETUP_INSTRUCTIONS.md b/viewer-frontend/SETUP_INSTRUCTIONS.md new file mode 100644 index 0000000..24abed4 --- /dev/null +++ b/viewer-frontend/SETUP_INSTRUCTIONS.md @@ -0,0 +1,86 @@ +# Setup Instructions for Authentication + +Follow these steps to set up authentication and create the admin user. + +## Step 1: Create Database Tables + +Run the SQL script as a PostgreSQL superuser: + +```bash +psql -U postgres -d punimtag -f setup-auth-complete.sql +``` + +Or connect to your database and run the SQL manually: + +```sql +-- Connect to database +\c punimtag + +-- Then run the contents of setup-auth-complete.sql +``` + +## Step 2: Create Admin User + +After the tables are created, run the Node.js script to create the admin user: + +```bash +npx tsx scripts/create-admin-user.ts +``` + +This will create an admin user with: +- **Email:** admin@admin.com +- **Password:** admin +- **Role:** Admin (can approve identifications) + +## Step 3: Regenerate Prisma Client + +```bash +npx prisma generate +``` + +## Step 4: Verify Setup + +1. **Check tables exist:** + ```sql + \dt users + \dt pending_identifications + ``` + +2. **Check admin user:** + ```sql + SELECT email, name, is_admin FROM users WHERE email = 'admin@admin.com'; + ``` + +3. **Test registration:** + - Go to http://localhost:3001/register + - Create a new user account + - Verify it appears in the database + +4. **Test admin login:** + - Go to http://localhost:3001/login + - Login with admin@admin.com / admin + +## Permission Model + +- **Regular Users:** Can INSERT into `pending_identifications` (identify faces) +- **Admin Users:** Can UPDATE `pending_identifications` (approve/reject identifications) +- **Application Level:** The `isAdmin` field in the User model controls who can approve + +## Troubleshooting + +### "permission denied for table users" +Make sure you've granted permissions: +```sql +GRANT SELECT, INSERT, UPDATE ON TABLE users TO viewer_write; +GRANT SELECT, INSERT, UPDATE ON TABLE pending_identifications TO viewer_write; +GRANT USAGE, SELECT ON SEQUENCE users_id_seq TO viewer_write; +GRANT USAGE, SELECT ON SEQUENCE pending_identifications_id_seq TO viewer_write; +``` + +### "relation 'users' does not exist" +Run `setup-auth-complete.sql` first to create the tables. + +### "Authentication failed" +Check your `.env` file has correct `DATABASE_URL_WRITE` credentials. + + diff --git a/viewer-frontend/STOP_OLD_SERVER.md b/viewer-frontend/STOP_OLD_SERVER.md new file mode 100644 index 0000000..5544110 --- /dev/null +++ b/viewer-frontend/STOP_OLD_SERVER.md @@ -0,0 +1,73 @@ +# How to Stop the Old PunimTag Server + +## Quick Instructions + +### Option 1: Kill the Process (Already Done) +The old server has been stopped. If you need to do it manually: + +```bash +# Find the process +lsof -i :3000 + +# Kill it (replace PID with actual process ID) +kill +``` + +### Option 2: Find and Stop All PunimTag Processes + +```bash +# Find all PunimTag processes +ps aux | grep punimtag | grep -v grep + +# Kill the frontend (Vite) process +pkill -f "vite.*punimtag" + +# Or kill by port +lsof -ti :3000 | xargs kill +``` + +### Option 3: Stop from Terminal Where It's Running + +If you have the terminal open where the old server is running: +- Press `Ctrl+C` to stop it + +## Start the New Photo Viewer + +After stopping the old server, start the new one: + +```bash +cd /home/ladmin/Code/punimtag-viewer +npm run dev +``` + +The new server will start on http://localhost:3000 + +## Check What's Running + +```bash +# Check what's on port 3000 +lsof -i :3000 + +# Check all Node processes +ps aux | grep node | grep -v grep +``` + +## If Port 3000 is Still Busy + +If port 3000 is still in use, you can: + +1. **Use a different port for the new viewer:** + ```bash + PORT=3001 npm run dev + ``` + Then open http://localhost:3001 + +2. **Or kill all processes on port 3000:** + ```bash + lsof -ti :3000 | xargs kill -9 + ``` + + + + + diff --git a/viewer-frontend/app/HomePageContent.tsx b/viewer-frontend/app/HomePageContent.tsx new file mode 100644 index 0000000..3a19e3b --- /dev/null +++ b/viewer-frontend/app/HomePageContent.tsx @@ -0,0 +1,1100 @@ +'use client'; + +import { useState, useEffect, useRef } from 'react'; +import { useSearchParams, useRouter } from 'next/navigation'; +import { useSession } from 'next-auth/react'; +import { Person, Tag, Photo } from '@prisma/client'; +import { CollapsibleSearch } from '@/components/search/CollapsibleSearch'; +import { PhotoGrid } from '@/components/PhotoGrid'; +import { PhotoViewerClient } from '@/components/PhotoViewerClient'; +import { SearchFilters } from '@/components/search/FilterPanel'; +import { Loader2, CheckSquare, Square } from 'lucide-react'; +import { TagSelectionDialog } from '@/components/TagSelectionDialog'; +import { PageHeader } from '@/components/PageHeader'; +import JSZip from 'jszip'; + +interface HomePageContentProps { + initialPhotos: Photo[]; + people: Person[]; + tags: Tag[]; +} + +export function HomePageContent({ initialPhotos, people, tags }: HomePageContentProps) { + const router = useRouter(); + const searchParams = useSearchParams(); + const { data: session } = useSession(); + const isLoggedIn = Boolean(session); + + // Initialize filters from URL params to persist state across navigation + const [filters, setFilters] = useState(() => { + const peopleParam = searchParams.get('people'); + const tagsParam = searchParams.get('tags'); + const dateFromParam = searchParams.get('dateFrom'); + const dateToParam = searchParams.get('dateTo'); + const peopleModeParam = searchParams.get('peopleMode'); + const tagsModeParam = searchParams.get('tagsMode'); + const mediaTypeParam = searchParams.get('mediaType'); + const favoritesOnlyParam = searchParams.get('favoritesOnly'); + + return { + people: peopleParam ? peopleParam.split(',').map(Number).filter(Boolean) : [], + tags: tagsParam ? tagsParam.split(',').map(Number).filter(Boolean) : [], + dateFrom: dateFromParam ? new Date(dateFromParam) : undefined, + dateTo: dateToParam ? new Date(dateToParam) : undefined, + peopleMode: peopleModeParam === 'all' ? 'all' : 'any', + tagsMode: tagsModeParam === 'all' ? 'all' : 'any', + mediaType: (mediaTypeParam as 'all' | 'photos' | 'videos') || 'all', + favoritesOnly: favoritesOnlyParam === 'true', + }; + }); + + // Check if we have active filters from URL on initial load + const hasInitialFilters = Boolean( + filters.people.length > 0 || + filters.tags.length > 0 || + filters.dateFrom || + filters.dateTo || + (filters.mediaType && filters.mediaType !== 'all') || + filters.favoritesOnly === true + ); + + // Only use initialPhotos if there are no filters from URL + const [photos, setPhotos] = useState(hasInitialFilters ? [] : initialPhotos); + const [loading, setLoading] = useState(hasInitialFilters); // Start loading if filters are active + const [loadingMore, setLoadingMore] = useState(false); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [hasMore, setHasMore] = useState(initialPhotos.length === 30); + const observerTarget = useRef(null); + const pageRef = useRef(1); + const isLoadingRef = useRef(false); + const scrollRestoredRef = useRef(false); + const isInitialMount = useRef(true); + const photosInitializedRef = useRef(false); + const isClosingModalRef = useRef(false); + + // Modal state - read photo query param + const photoParam = searchParams.get('photo'); + const photosParam = searchParams.get('photos'); + const indexParam = searchParams.get('index'); + const autoplayParam = searchParams.get('autoplay') === 'true'; + const [modalPhoto, setModalPhoto] = useState(null); + const [modalPhotos, setModalPhotos] = useState([]); + const [modalIndex, setModalIndex] = useState(0); + const [modalLoading, setModalLoading] = useState(false); + const [selectionMode, setSelectionMode] = useState(false); + const [selectedPhotoIds, setSelectedPhotoIds] = useState([]); + const [isPreparingDownload, setIsPreparingDownload] = useState(false); + const [tagDialogOpen, setTagDialogOpen] = useState(false); + const [isBulkFavoriting, setIsBulkFavoriting] = useState(false); + const [refreshFavoritesKey, setRefreshFavoritesKey] = useState(0); + + const hasActiveFilters = + filters.people.length > 0 || + filters.tags.length > 0 || + filters.dateFrom || + filters.dateTo || + (filters.mediaType && filters.mediaType !== 'all') || + filters.favoritesOnly === true; + + // Update URL when filters change (without page reload) + // Skip on initial mount since filters are already initialized from URL + useEffect(() => { + // Skip URL update on initial mount + if (isInitialMount.current) { + isInitialMount.current = false; + return; + } + + // Skip if we're closing the modal (to prevent reload) + if (isClosingModalRef.current) { + return; + } + + const params = new URLSearchParams(); + + if (filters.people.length > 0) { + params.set('people', filters.people.join(',')); + if (filters.peopleMode && filters.peopleMode !== 'any') { + params.set('peopleMode', filters.peopleMode); + } + } + if (filters.tags.length > 0) { + params.set('tags', filters.tags.join(',')); + if (filters.tagsMode && filters.tagsMode !== 'any') { + params.set('tagsMode', filters.tagsMode); + } + } + if (filters.dateFrom) { + params.set('dateFrom', filters.dateFrom.toISOString().split('T')[0]); + } + if (filters.dateTo) { + params.set('dateTo', filters.dateTo.toISOString().split('T')[0]); + } + if (filters.mediaType && filters.mediaType !== 'all') { + params.set('mediaType', filters.mediaType); + } + if (filters.favoritesOnly) { + params.set('favoritesOnly', 'true'); + } + + const newUrl = params.toString() ? `/?${params.toString()}` : '/'; + router.replace(newUrl, { scroll: false }); + + // Clear saved scroll position when filters change (user is starting a new search) + sessionStorage.removeItem('homePageScrollY'); + scrollRestoredRef.current = false; + photosInitializedRef.current = false; // Reset photos initialization flag + }, [filters, router]); + + // Restore scroll position when returning from photo viewer + // Wait for photos to be loaded and rendered before restoring scroll to prevent flash + useEffect(() => { + if (scrollRestoredRef.current) return; + + const scrollY = sessionStorage.getItem('homePageScrollY'); + if (!scrollY) { + scrollRestoredRef.current = true; + return; + } + + // Wait for loading to complete + if (loading) return; + + // Wait for photos to be initialized (either from initial state or fetched) + // This prevents flash by ensuring we only restore after everything is stable + if (!photosInitializedRef.current && photos.length === 0) { + // Photos not ready yet, wait + return; + } + + photosInitializedRef.current = true; + + // Restore scroll after DOM is fully rendered + // Use multiple animation frames to ensure all images are laid out + requestAnimationFrame(() => { + requestAnimationFrame(() => { + if (!scrollRestoredRef.current) { + window.scrollTo({ top: parseInt(scrollY, 10), behavior: 'instant' }); + scrollRestoredRef.current = true; + } + }); + }); + }, [loading, photos.length]); + + // Save scroll position before navigating away + useEffect(() => { + const handleScroll = () => { + sessionStorage.setItem('homePageScrollY', window.scrollY.toString()); + }; + + // Throttle scroll events + let timeoutId: NodeJS.Timeout; + const throttledScroll = () => { + clearTimeout(timeoutId); + timeoutId = setTimeout(handleScroll, 100); + }; + + window.addEventListener('scroll', throttledScroll, { passive: true }); + return () => { + window.removeEventListener('scroll', throttledScroll); + clearTimeout(timeoutId); + }; + }, []); + + // Handle photo modal - use existing photos data, only fetch if not available + useEffect(() => { + // Skip if we're intentionally closing the modal + if (isClosingModalRef.current) { + isClosingModalRef.current = false; + return; + } + + if (!photoParam) { + setModalPhoto(null); + setModalPhotos([]); + return; + } + + const photoId = parseInt(photoParam, 10); + if (isNaN(photoId)) return; + + // If we already have this photo in modalPhotos, just update the index - no fetch needed! + if (modalPhotos.length > 0) { + const existingModalPhoto = modalPhotos.find((p) => p.id === photoId); + if (existingModalPhoto) { + console.log('[HomePageContent] Using existing modal photo:', { + photoId: existingModalPhoto.id, + hasFaces: !!existingModalPhoto.faces, + hasFace: !!existingModalPhoto.Face, + facesCount: existingModalPhoto.faces?.length || existingModalPhoto.Face?.length || 0, + photoKeys: Object.keys(existingModalPhoto), + }); + // Photo is already in modal list, just update index - instant, no API calls! + const photoIds = photosParam ? photosParam.split(',').map(Number).filter(Boolean) : []; + const parsedIndex = indexParam ? parseInt(indexParam, 10) : 0; + if (photoIds.length > 0 && !isNaN(parsedIndex) && parsedIndex >= 0 && parsedIndex < modalPhotos.length) { + setModalIndex(parsedIndex); + setModalPhoto(existingModalPhoto); + return; // Skip all fetching! + } + } + } + + // First, try to find the photo in the already-loaded photos + const existingPhoto = photos.find((p) => p.id === photoId); + + if (existingPhoto) { + // Photo is already loaded, use it directly - no database access! + console.log('[HomePageContent] Using existing photo from photos array:', { + photoId: existingPhoto.id, + hasFaces: !!existingPhoto.faces, + hasFace: !!(existingPhoto as any).Face, + facesCount: existingPhoto.faces?.length || (existingPhoto as any).Face?.length || 0, + photoKeys: Object.keys(existingPhoto), + }); + setModalPhoto(existingPhoto); + + // If we have a photo list context, use existing photos + if (photosParam && indexParam) { + const photoIds = photosParam.split(',').map(Number).filter(Boolean); + const parsedIndex = parseInt(indexParam, 10); + + if (photoIds.length > 0 && !isNaN(parsedIndex)) { + // Check if modalPhotos already has all these photos in the right order + const currentPhotoIds = modalPhotos.map((p) => p.id); + const needsRebuild = + currentPhotoIds.length !== photoIds.length || + currentPhotoIds.some((id, idx) => id !== photoIds[idx]); + + if (needsRebuild) { + // Build photo list from existing photos - no API calls! + const photoMap = new Map(photos.map((p) => [p.id, p])); + const orderedPhotos = photoIds + .map((id) => photoMap.get(id)) + .filter(Boolean) as typeof photos; + + setModalPhotos(orderedPhotos); + } + setModalIndex(parsedIndex); + } else { + if (modalPhotos.length !== 1 || modalPhotos[0]?.id !== existingPhoto.id) { + setModalPhotos([existingPhoto]); + } + setModalIndex(0); + } + } else { + if (modalPhotos.length !== 1 || modalPhotos[0]?.id !== existingPhoto.id) { + setModalPhotos([existingPhoto]); + } + setModalIndex(0); + } + setModalLoading(false); + return; + } + + // Photo not in loaded list, need to fetch it (should be rare) + const fetchPhotoData = async () => { + setModalLoading(true); + try { + const photoResponse = await fetch(`/api/photos/${photoId}`); + if (!photoResponse.ok) throw new Error('Failed to fetch photo'); + const photoData = await photoResponse.json(); + + // Serialize the photo (handle Decimal fields) + console.log('[HomePageContent] Photo data from API:', { + photoId: photoData.id, + hasFaces: !!photoData.faces, + facesCount: photoData.faces?.length || 0, + facesRaw: photoData.faces, + photoDataKeys: Object.keys(photoData), + }); + + const serializedPhoto = { + ...photoData, + faces: photoData.faces?.map((face: any) => ({ + ...face, + confidence: face.confidence ? Number(face.confidence) : 0, + qualityScore: face.qualityScore ? Number(face.qualityScore) : 0, + faceConfidence: face.faceConfidence ? Number(face.faceConfidence) : 0, + yawAngle: face.yawAngle ? Number(face.yawAngle) : null, + pitchAngle: face.pitchAngle ? Number(face.pitchAngle) : null, + rollAngle: face.rollAngle ? Number(face.rollAngle) : null, + })), + }; + + console.log('[HomePageContent] Serialized photo:', { + photoId: serializedPhoto.id, + hasFaces: !!serializedPhoto.faces, + facesCount: serializedPhoto.faces?.length || 0, + faces: serializedPhoto.faces, + }); + + setModalPhoto(serializedPhoto); + + // For navigation, try to use existing photos first, then fetch missing ones + if (photosParam && indexParam) { + const photoIds = photosParam.split(',').map(Number).filter(Boolean); + const parsedIndex = parseInt(indexParam, 10); + + if (photoIds.length > 0 && !isNaN(parsedIndex)) { + const photoMap = new Map(photos.map((p) => [p.id, p])); + const missingIds = photoIds.filter((id) => !photoMap.has(id)); + + // Fetch only missing photos + let fetchedPhotos: any[] = []; + if (missingIds.length > 0) { + const photoPromises = missingIds.map((id) => + fetch(`/api/photos/${id}`).then((res) => res.json()) + ); + const fetchedData = await Promise.all(photoPromises); + fetchedPhotos = fetchedData.map((p: any) => ({ + ...p, + faces: p.faces?.map((face: any) => ({ + ...face, + confidence: face.confidence ? Number(face.confidence) : 0, + qualityScore: face.qualityScore ? Number(face.qualityScore) : 0, + faceConfidence: face.faceConfidence ? Number(face.faceConfidence) : 0, + yawAngle: face.yawAngle ? Number(face.yawAngle) : null, + pitchAngle: face.pitchAngle ? Number(face.pitchAngle) : null, + rollAngle: face.rollAngle ? Number(face.rollAngle) : null, + })), + })); + } + + // Combine existing and fetched photos + fetchedPhotos.forEach((p) => photoMap.set(p.id, p)); + // Include all photos (videos and images) for navigation + const orderedPhotos = photoIds + .map((id) => photoMap.get(id)) + .filter(Boolean) as typeof photos; + + setModalPhotos(orderedPhotos); + // Use the original index (videos are included in navigation) + setModalIndex(Math.min(parsedIndex, orderedPhotos.length - 1)); + } else { + setModalPhotos([serializedPhoto]); + setModalIndex(0); + } + } else { + setModalPhotos([serializedPhoto]); + setModalIndex(0); + } + } catch (error) { + console.error('Error fetching photo data:', error); + } finally { + setModalLoading(false); + } + }; + + fetchPhotoData(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [photoParam, photosParam, indexParam]); // Only depend on URL params - photos is accessed but we check modalPhotos first + + // Handle starting slideshow + const handleStartSlideshow = () => { + if (photos.length === 0) return; + + // Filter out videos from slideshow (only show images) + const imagePhotos = photos.filter((p) => p.media_type !== 'video'); + if (imagePhotos.length === 0) return; + + // Set first image as modal photo + setModalPhoto(imagePhotos[0]); + setModalPhotos(imagePhotos); + setModalIndex(0); + + // Update URL to open first photo with autoPlay + const params = new URLSearchParams(window.location.search); + params.set('photo', imagePhotos[0].id.toString()); + params.set('photos', imagePhotos.map((p) => p.id).join(',')); + params.set('index', '0'); + params.set('autoplay', 'true'); + router.replace(`/?${params.toString()}`, { scroll: false }); + }; + + const handleToggleSelectionMode = () => { + setSelectionMode((prev) => { + if (prev) { + setSelectedPhotoIds([]); + } + return !prev; + }); + }; + + const handleTogglePhotoSelection = (photoId: number) => { + setSelectedPhotoIds((prev) => { + if (prev.includes(photoId)) { + return prev.filter((id) => id !== photoId); + } + return [...prev, photoId]; + }); + }; + + const handleSelectAll = () => { + const allPhotoIds = photos.map((photo) => photo.id); + setSelectedPhotoIds(allPhotoIds); + }; + + const handleClearAll = () => { + setSelectedPhotoIds([]); + }; + + const getPhotoFilename = (photo: Photo) => { + if (photo.filename?.trim()) { + return photo.filename.trim(); + } + const path = photo.path || ''; + if (path) { + const segments = path.split(/[/\\]/); + const lastSegment = segments.pop(); + if (lastSegment) { + return lastSegment; + } + } + return `photo-${photo.id}.jpg`; + }; + + const getPhotoDownloadUrl = ( + photo: Photo, + options?: { forceProxy?: boolean; watermark?: boolean } + ) => { + const path = photo.path || ''; + const isExternal = path.startsWith('http://') || path.startsWith('https://'); + if (isExternal && !options?.forceProxy) { + return path; + } + + const params = new URLSearchParams(); + if (options?.watermark) { + params.set('watermark', 'true'); + } + const query = params.toString(); + + return `/api/photos/${photo.id}/image${query ? `?${query}` : ''}`; + }; + + const downloadPhotosAsZip = async ( + photoIds: number[], + photoMap: Map + ) => { + const zip = new JSZip(); + let filesAdded = 0; + + for (const photoId of photoIds) { + const photo = photoMap.get(photoId); + if (!photo) continue; + + const response = await fetch( + getPhotoDownloadUrl(photo, { + forceProxy: true, + watermark: !isLoggedIn, + }) + ); + if (!response.ok) { + throw new Error(`Failed to download photo ${photoId}`); + } + + const blob = await response.blob(); + zip.file(getPhotoFilename(photo), blob); + filesAdded += 1; + } + + if (filesAdded === 0) { + throw new Error('No photos available to download.'); + } + + const zipBlob = await zip.generateAsync({ type: 'blob' }); + const url = URL.createObjectURL(zipBlob); + const link = document.createElement('a'); + link.href = url; + link.download = `photos-${new Date().toISOString().split('T')[0]}.zip`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + }; + + const handleDownloadSelected = async () => { + if ( + selectedPhotoIds.length === 0 || + typeof window === 'undefined' || + isPreparingDownload + ) { + return; + } + + const photoMap = new Map(photos.map((photo) => [photo.id, photo])); + + if (selectedPhotoIds.length === 1) { + const photo = photoMap.get(selectedPhotoIds[0]); + if (!photo) { + return; + } + const link = document.createElement('a'); + link.href = getPhotoDownloadUrl(photo, { watermark: !isLoggedIn }); + link.download = getPhotoFilename(photo); + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + return; + } + + try { + setIsPreparingDownload(true); + await downloadPhotosAsZip(selectedPhotoIds, photoMap); + } catch (error) { + console.error('Error downloading selected photos:', error); + alert('Failed to download selected photos. Please try again.'); + } finally { + setIsPreparingDownload(false); + } + }; + + const handleTagSelected = () => { + if (selectedPhotoIds.length === 0) { + return; + } + setTagDialogOpen(true); + }; + + const handleBulkFavorite = async () => { + if (selectedPhotoIds.length === 0 || !isLoggedIn || isBulkFavoriting) { + return; + } + + setIsBulkFavoriting(true); + + try { + const response = await fetch('/api/photos/favorites/bulk', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + photoIds: selectedPhotoIds, + action: 'add', // Always add to favorites (skip if already favorited) + }), + }); + + if (!response.ok) { + const error = await response.json(); + if (response.status === 401) { + alert('Please sign in to favorite photos'); + } else { + alert(error.error || 'Failed to update favorites'); + } + return; + } + + const data = await response.json(); + + // Trigger PhotoGrid to refetch favorite statuses + setRefreshFavoritesKey(prev => prev + 1); + } catch (error) { + console.error('Error bulk favoriting photos:', error); + alert('Failed to update favorites. Please try again.'); + } finally { + setIsBulkFavoriting(false); + } + }; + + useEffect(() => { + if (selectedPhotoIds.length === 0) { + return; + } + const availableIds = new Set(photos.map((photo) => photo.id)); + setSelectedPhotoIds((prev) => { + const filtered = prev.filter((id) => availableIds.has(id)); + return filtered.length === prev.length ? prev : filtered; + }); + }, [photos, selectedPhotoIds.length]); + + useEffect(() => { + if (tagDialogOpen && selectedPhotoIds.length === 0) { + setTagDialogOpen(false); + } + }, [tagDialogOpen, selectedPhotoIds.length]); + + // Handle closing the modal + const handleCloseModal = () => { + // Set flag to prevent useEffect from running + isClosingModalRef.current = true; + + // Clear modal state immediately (no reload, instant close) + setModalPhoto(null); + setModalPhotos([]); + setModalIndex(0); + + // Update URL directly using history API to avoid triggering Next.js router effects + // This prevents any reload or re-fetch when closing + const params = new URLSearchParams(window.location.search); + params.delete('photo'); + params.delete('photos'); + params.delete('index'); + params.delete('autoplay'); + + const newUrl = params.toString() ? `/?${params.toString()}` : '/'; + // Use window.history directly to avoid Next.js router processing + window.history.replaceState( + { ...window.history.state, as: newUrl, url: newUrl }, + '', + newUrl + ); + + // Reset flag after a short delay to allow effects to see it + setTimeout(() => { + isClosingModalRef.current = false; + }, 100); + }; + + // Fetch photos when filters change (reset to page 1) + useEffect(() => { + // If no filters, use initial photos and fetch total count + if (!hasActiveFilters) { + // Only update photos if they're different to prevent unnecessary re-renders + const photosChanged = photos.length !== initialPhotos.length || + photos.some((p, i) => p.id !== initialPhotos[i]?.id); + + if (photosChanged) { + setPhotos(initialPhotos); + photosInitializedRef.current = true; + } else if (photos.length > 0) { + // Photos are already set correctly + photosInitializedRef.current = true; + } + + setPage(1); + pageRef.current = 1; + isLoadingRef.current = false; + // Fetch total count for display (use search API with no filters) + fetch('/api/search?page=1&pageSize=1') + .then((res) => res.json()) + .then((data) => { + setTotal(data.total); + setHasMore(initialPhotos.length < data.total); + }) + .catch(() => { + setTotal(initialPhotos.length); + setHasMore(false); + }); + return; + } + + const fetchPhotos = async () => { + setLoading(true); + try { + const params = new URLSearchParams(); + + if (filters.people.length > 0) { + params.set('people', filters.people.join(',')); + if (filters.peopleMode) { + params.set('peopleMode', filters.peopleMode); + } + } + if (filters.tags.length > 0) { + params.set('tags', filters.tags.join(',')); + if (filters.tagsMode) { + params.set('tagsMode', filters.tagsMode); + } + } + if (filters.dateFrom) { + params.set('dateFrom', filters.dateFrom.toISOString().split('T')[0]); + } + if (filters.dateTo) { + params.set('dateTo', filters.dateTo.toISOString().split('T')[0]); + } + if (filters.mediaType && filters.mediaType !== 'all') { + params.set('mediaType', filters.mediaType); + } + if (filters.favoritesOnly) { + params.set('favoritesOnly', 'true'); + } + params.set('page', '1'); + params.set('pageSize', '30'); + + const response = await fetch(`/api/search?${params.toString()}`); + if (!response.ok) throw new Error('Failed to search photos'); + + const data = await response.json(); + setPhotos(data.photos); + photosInitializedRef.current = true; + setTotal(data.total); + setHasMore(data.photos.length < data.total); + setPage(1); + pageRef.current = 1; + isLoadingRef.current = false; + } catch (error) { + console.error('Error searching photos:', error); + } finally { + setLoading(false); + } + }; + + fetchPhotos(); + }, [filters, hasActiveFilters, initialPhotos]); + + // Infinite scroll observer + useEffect(() => { + const observer = new IntersectionObserver( + (entries) => { + // Don't load if we've already loaded all photos + if (photos.length >= total && total > 0) { + setHasMore(false); + return; + } + + if (entries[0].isIntersecting && hasMore && !loadingMore && !loading && photos.length < total && !isLoadingRef.current) { + const fetchMore = async () => { + if (isLoadingRef.current) { + console.log('Already loading, skipping observer trigger'); + return; + } + isLoadingRef.current = true; + setLoadingMore(true); + const nextPage = pageRef.current + 1; + pageRef.current = nextPage; + + console.log('Observer triggered - loading page', nextPage, { currentPhotos: photos.length, total }); + + try { + const params = new URLSearchParams(); + + if (filters.people.length > 0) { + params.set('people', filters.people.join(',')); + if (filters.peopleMode) { + params.set('peopleMode', filters.peopleMode); + } + } + if (filters.tags.length > 0) { + params.set('tags', filters.tags.join(',')); + if (filters.tagsMode) { + params.set('tagsMode', filters.tagsMode); + } + } + if (filters.dateFrom) { + params.set('dateFrom', filters.dateFrom.toISOString().split('T')[0]); + } + if (filters.dateTo) { + params.set('dateTo', filters.dateTo.toISOString().split('T')[0]); + } + if (filters.mediaType && filters.mediaType !== 'all') { + params.set('mediaType', filters.mediaType); + } + if (filters.favoritesOnly) { + params.set('favoritesOnly', 'true'); + } + params.set('page', nextPage.toString()); + params.set('pageSize', '30'); + + const response = await fetch(`/api/search?${params.toString()}`); + if (!response.ok) throw new Error('Failed to load more photos'); + + const data = await response.json(); + + // If we got 0 photos, we've reached the end + if (data.photos.length === 0) { + console.log('Got 0 photos, reached the end. Setting hasMore to false'); + setHasMore(false); + return; + } + + setPhotos((prev) => { + // Filter out duplicates by photo ID + const existingIds = new Set(prev.map((p) => p.id)); + const uniqueNewPhotos = data.photos.filter((p: Photo) => !existingIds.has(p.id)); + const newPhotos = [...prev, ...uniqueNewPhotos]; + + // Stop loading if we've loaded all photos or got no new photos + const hasMorePhotos = newPhotos.length < data.total && + uniqueNewPhotos.length > 0; + + console.log('Loading page', nextPage, { + prevCount: prev.length, + newCount: data.photos.length, + uniqueNew: uniqueNewPhotos.length, + totalNow: newPhotos.length, + totalExpected: data.total, + hasMore: hasMorePhotos, + loadedAll: newPhotos.length >= data.total + }); + + // Always set hasMore to false if we've loaded all photos or got no new unique photos + if (newPhotos.length >= data.total || uniqueNewPhotos.length === 0) { + console.log('All photos loaded or no new photos! Setting hasMore to false', { + newPhotos: newPhotos.length, + total: data.total, + uniqueNew: uniqueNewPhotos.length + }); + setHasMore(false); + } else { + setHasMore(hasMorePhotos); + } + + return newPhotos; + }); + setPage(nextPage); + } catch (error) { + console.error('Error loading more photos:', error); + setHasMore(false); // Stop on error + } finally { + setLoadingMore(false); + isLoadingRef.current = false; + } + }; + + fetchMore(); + } else { + // If we have all photos, make sure hasMore is false + if (photos.length >= total && total > 0) { + console.log('Observer: Already have all photos, setting hasMore to false', { photos: photos.length, total }); + setHasMore(false); + } + } + }, + { threshold: 0.1 } + ); + + const currentTarget = observerTarget.current; + if (currentTarget) { + observer.observe(currentTarget); + } + + return () => { + if (currentTarget) { + observer.unobserve(currentTarget); + } + }; + }, [hasMore, loadingMore, loading, filters, photos.length, total]); + + // Ensure we load the last page when we're close to the end + useEffect(() => { + // Don't run if we've already loaded all photos + if (photos.length >= total && total > 0) { + if (hasMore) { + console.log('All photos loaded, setting hasMore to false', { photos: photos.length, total }); + setHasMore(false); + } + return; + } + + // If we're very close to the end (1-5 photos remaining), load immediately + const remaining = total - photos.length; + if (remaining > 0 && remaining <= 5 && !loadingMore && !loading && !isLoadingRef.current && hasMore) { + console.log('Very close to end, loading remaining photos immediately', { remaining, photos: photos.length, total }); + + const fetchRemaining = async () => { + isLoadingRef.current = true; + setLoadingMore(true); + const nextPage = pageRef.current + 1; + pageRef.current = nextPage; + + try { + const params = new URLSearchParams(); + + if (filters.people.length > 0) { + params.set('people', filters.people.join(',')); + if (filters.peopleMode) { + params.set('peopleMode', filters.peopleMode); + } + } + if (filters.tags.length > 0) { + params.set('tags', filters.tags.join(',')); + if (filters.tagsMode) { + params.set('tagsMode', filters.tagsMode); + } + } + if (filters.dateFrom) { + params.set('dateFrom', filters.dateFrom.toISOString().split('T')[0]); + } + if (filters.dateTo) { + params.set('dateTo', filters.dateTo.toISOString().split('T')[0]); + } + if (filters.mediaType && filters.mediaType !== 'all') { + params.set('mediaType', filters.mediaType); + } + if (filters.favoritesOnly) { + params.set('favoritesOnly', 'true'); + } + params.set('page', nextPage.toString()); + params.set('pageSize', '30'); + + const response = await fetch(`/api/search?${params.toString()}`); + if (!response.ok) throw new Error('Failed to load more photos'); + + const data = await response.json(); + + if (data.photos.length === 0) { + console.log('Got 0 photos, reached the end'); + setHasMore(false); + return; + } + + setPhotos((prev) => { + const existingIds = new Set(prev.map((p) => p.id)); + const uniqueNewPhotos = data.photos.filter((p: Photo) => !existingIds.has(p.id)); + const newPhotos = [...prev, ...uniqueNewPhotos]; + + const allLoaded = newPhotos.length >= data.total; + const noNewPhotos = uniqueNewPhotos.length === 0; + + console.log('Loaded remaining photos:', { + prevCount: prev.length, + newCount: data.photos.length, + uniqueNew: uniqueNewPhotos.length, + totalNow: newPhotos.length, + totalExpected: data.total, + allLoaded, + noNewPhotos + }); + + if (allLoaded || noNewPhotos) { + console.log('All photos loaded or no new photos, stopping'); + setHasMore(false); + } else { + setHasMore(newPhotos.length < data.total && uniqueNewPhotos.length > 0); + } + + return newPhotos; + }); + setPage(nextPage); + } catch (error) { + console.error('Error loading remaining photos:', error); + setHasMore(false); + } finally { + setLoadingMore(false); + isLoadingRef.current = false; + } + }; + + // Small delay to avoid race conditions + const timeoutId = setTimeout(() => { + if (!isLoadingRef.current && !loadingMore && !loading && photos.length < total) { + fetchRemaining(); + } + }, 50); + + return () => clearTimeout(timeoutId); + } + }, [photos.length, total, hasMore, loadingMore, loading, filters]); + + return ( + <> + +
+ +
+ {loading ? ( +
+ +
+ ) : ( + <> +
+
+
+ {hasActiveFilters ? ( + `Found ${total} photo${total !== 1 ? 's' : ''} - Showing ${photos.length}` + ) : ( + total > 0 ? ( + `Showing ${photos.length} of ${total} photo${total !== 1 ? 's' : ''}` + ) : ( + `Showing ${photos.length} photo${photos.length !== 1 ? 's' : ''}` + ) + )} +
+ {selectionMode && ( +
+ + +
+ )} +
+
+ + + {/* Infinite scroll sentinel */} +
+ {loadingMore && ( + + )} +
+ + {!hasMore && photos.length > 0 && ( +
+ No more photos to load +
+ )} + + )} +
+
+ + {/* Photo Modal Overlay */} + {photoParam && modalPhoto && !modalLoading && ( + + )} + + {photoParam && modalLoading && ( +
+ +
+ )} + + { + setSelectionMode(false); + setSelectedPhotoIds([]); + }} + /> + + ); +} + diff --git a/viewer-frontend/app/admin/users/ManageUsersContent.tsx b/viewer-frontend/app/admin/users/ManageUsersContent.tsx new file mode 100644 index 0000000..2159a79 --- /dev/null +++ b/viewer-frontend/app/admin/users/ManageUsersContent.tsx @@ -0,0 +1,666 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Trash2, Plus, Edit2 } from 'lucide-react'; +import { Badge } from '@/components/ui/badge'; +import { Checkbox } from '@/components/ui/checkbox'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { isValidEmail } from '@/lib/utils'; + +interface User { + id: number; + email: string; + name: string | null; + isAdmin: boolean; + hasWriteAccess: boolean; + isActive?: boolean; + createdAt: string; + updatedAt: string; +} + +type UserStatusFilter = 'all' | 'active' | 'inactive'; + +export function ManageUsersContent() { + const [users, setUsers] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [successMessage, setSuccessMessage] = useState(null); + const [statusFilter, setStatusFilter] = useState('active'); + const [isAddDialogOpen, setIsAddDialogOpen] = useState(false); + const [isEditDialogOpen, setIsEditDialogOpen] = useState(false); + const [editingUser, setEditingUser] = useState(null); + const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); + const [userToDelete, setUserToDelete] = useState(null); + + // Form state + const [formData, setFormData] = useState({ + email: '', + password: '', + name: '', + hasWriteAccess: false, + isAdmin: false, + isActive: true, + }); + + // Fetch users + const fetchUsers = useCallback(async () => { + try { + setLoading(true); + setError(null); + + console.log('[ManageUsers] Fetching users with filter:', statusFilter); + const url = statusFilter === 'all' + ? '/api/users?status=all' + : statusFilter === 'inactive' + ? '/api/users?status=inactive' + : '/api/users?status=active'; + + console.log('[ManageUsers] Fetching from URL:', url); + const response = await fetch(url, { + credentials: 'include', // Ensure cookies are sent + }); + + console.log('[ManageUsers] Response status:', response.status, response.statusText); + + let data; + const contentType = response.headers.get('content-type'); + console.log('[ManageUsers] Content-Type:', contentType); + + try { + const text = await response.text(); + console.log('[ManageUsers] Response text:', text); + data = text ? JSON.parse(text) : {}; + } catch (parseError) { + console.error('[ManageUsers] Failed to parse response:', parseError); + throw new Error(`Server error (${response.status}): Invalid JSON response`); + } + + console.log('[ManageUsers] Parsed data:', data); + + if (!response.ok) { + const errorMsg = data?.error || data?.details || data?.message || `HTTP ${response.status}: ${response.statusText}`; + console.error('[ManageUsers] API Error:', { + status: response.status, + statusText: response.statusText, + data + }); + throw new Error(errorMsg); + } + + if (!data.users) { + console.warn('[ManageUsers] Response missing users array:', data); + setUsers([]); + } else { + console.log('[ManageUsers] Successfully loaded', data.users.length, 'users'); + setUsers(data.users); + } + } catch (err: any) { + console.error('[ManageUsers] Error fetching users:', err); + setError(err.message || 'Failed to load users'); + } finally { + setLoading(false); + } + }, [statusFilter]); + + // Debug: Log when statusFilter changes + useEffect(() => { + console.log('[ManageUsers] statusFilter state changed to:', statusFilter); + }, [statusFilter]); + + useEffect(() => { + fetchUsers(); + }, [fetchUsers]); + + // Handle add user + const handleAddUser = async () => { + try { + setError(null); + + // Client-side validation + if (!formData.name || formData.name.trim().length === 0) { + setError('Name is required'); + return; + } + + if (!formData.email || !isValidEmail(formData.email)) { + setError('Please enter a valid email address'); + return; + } + + const response = await fetch('/api/users', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(formData), + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || 'Failed to create user'); + } + + setIsAddDialogOpen(false); + setFormData({ email: '', password: '', name: '', hasWriteAccess: false, isAdmin: false, isActive: true }); + fetchUsers(); + } catch (err: any) { + setError(err.message || 'Failed to create user'); + } + }; + + + // Handle edit user + const handleEditUser = async () => { + if (!editingUser) return; + + try { + setError(null); + + // Client-side validation + if (!formData.name || formData.name.trim().length === 0) { + setError('Name is required'); + return; + } + + const updateData: any = {}; + if (formData.email !== editingUser.email) { + updateData.email = formData.email; + } + if (formData.name !== editingUser.name) { + updateData.name = formData.name; + } + if (formData.password) { + updateData.password = formData.password; + } + if (formData.hasWriteAccess !== editingUser.hasWriteAccess) { + updateData.hasWriteAccess = formData.hasWriteAccess; + } + if (formData.isAdmin !== editingUser.isAdmin) { + updateData.isAdmin = formData.isAdmin; + } + // Treat undefined/null as true, so only check if explicitly false + const currentIsActive = editingUser.isActive !== false; + if (formData.isActive !== currentIsActive) { + updateData.isActive = formData.isActive; + } + + if (Object.keys(updateData).length === 0) { + setIsEditDialogOpen(false); + return; + } + + const response = await fetch(`/api/users/${editingUser.id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(updateData), + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || 'Failed to update user'); + } + + setIsEditDialogOpen(false); + setEditingUser(null); + setFormData({ email: '', password: '', name: '', hasWriteAccess: false, isAdmin: false, isActive: true }); + fetchUsers(); + } catch (err: any) { + setError(err.message || 'Failed to update user'); + } + }; + + // Handle delete user + const handleDeleteUser = async () => { + if (!userToDelete) return; + + try { + setError(null); + setSuccessMessage(null); + const response = await fetch(`/api/users/${userToDelete.id}`, { + method: 'DELETE', + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || 'Failed to delete user'); + } + + const data = await response.json(); + setDeleteConfirmOpen(false); + setUserToDelete(null); + + // Check if user was deactivated instead of deleted + if (data.deactivated) { + setSuccessMessage( + `User ${userToDelete.email} was deactivated (not deleted) because they have ${data.relatedRecords?.pendingLinkages || 0} pending linkages, ${data.relatedRecords?.photoFavorites || 0} favorites, and other related records.` + ); + } else { + setSuccessMessage(`User ${userToDelete.email} was deleted successfully.`); + } + + // Clear success message after 5 seconds + setTimeout(() => setSuccessMessage(null), 5000); + + fetchUsers(); + } catch (err: any) { + setError(err.message || 'Failed to delete user'); + } + }; + + // Open edit dialog + const openEditDialog = (user: User) => { + setEditingUser(user); + setFormData({ + email: user.email, + password: '', + name: user.name || '', + hasWriteAccess: user.hasWriteAccess, + isAdmin: user.isAdmin, + isActive: user.isActive !== false, // Treat undefined/null as true + }); + setIsEditDialogOpen(true); + }; + + if (loading) { + return ( +
+
Loading users...
+
+ ); + } + + return ( +
+
+
+

Manage Users

+

+ Manage user accounts and permissions +

+
+
+
+ + +
+ +
+
+ + {error && ( +
+ {error} +
+ )} + + {successMessage && ( +
+ {successMessage} +
+ )} + +
+
+ + + + + + + + + + + + + + {users.map((user) => ( + + + + + + + + + + ))} + +
EmailNameStatusRoleWrite AccessCreatedActions
{user.email}{user.name || '-'} + {user.isActive === false ? ( + Inactive + ) : ( + Active + )} + + {user.isAdmin ? ( + Admin + ) : ( + User + )} + + + {user.hasWriteAccess ? 'Yes' : 'No'} + + + {new Date(user.createdAt).toLocaleDateString()} + +
+ + +
+
+
+
+ + {/* Add User Dialog */} + + + + Add New User + + Create a new user account. Write access can be granted later. + + +
+
+ + + setFormData({ ...formData, email: e.target.value }) + } + placeholder="user@example.com" + /> +
+
+ + + setFormData({ ...formData, password: e.target.value }) + } + placeholder="Minimum 6 characters" + /> +
+
+ + + setFormData({ ...formData, name: e.target.value }) + } + placeholder="Enter full name" + required + /> +
+
+ + +
+
+ + setFormData({ ...formData, hasWriteAccess: !!checked }) + } + /> + +
+
+ + + + +
+
+ + {/* Edit User Dialog */} + + + + Edit User + + Update user information. Leave password blank to keep current password. + + +
+
+ + + setFormData({ ...formData, email: e.target.value }) + } + placeholder="user@example.com" + /> +
+
+ + + setFormData({ ...formData, password: e.target.value }) + } + placeholder="Leave blank to keep current password" + /> +
+
+ + + setFormData({ ...formData, name: e.target.value }) + } + placeholder="Enter full name" + required + /> +
+
+ + +
+
+ + setFormData({ ...formData, hasWriteAccess: !!checked }) + } + /> + +
+
+ + setFormData({ ...formData, isActive: !!checked }) + } + /> + +
+
+ + + + +
+
+ + {/* Delete Confirmation Dialog */} + + + + Delete User + + Are you sure you want to delete {userToDelete?.email}? This action + cannot be undone. + + + + + + + + +
+ ); +} + diff --git a/viewer-frontend/app/admin/users/ManageUsersPageClient.tsx b/viewer-frontend/app/admin/users/ManageUsersPageClient.tsx new file mode 100644 index 0000000..ebe2260 --- /dev/null +++ b/viewer-frontend/app/admin/users/ManageUsersPageClient.tsx @@ -0,0 +1,84 @@ +'use client'; + +import { useEffect } from 'react'; +import { createPortal } from 'react-dom'; +import { X } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { ManageUsersContent } from './ManageUsersContent'; +import Image from 'next/image'; +import Link from 'next/link'; +import UserMenu from '@/components/UserMenu'; + +interface ManageUsersPageClientProps { + onClose?: () => void; +} + +export function ManageUsersPageClient({ onClose }: ManageUsersPageClientProps) { + const handleClose = () => { + if (onClose) { + onClose(); + } + }; + + useEffect(() => { + // Prevent body scroll when overlay is open + document.body.style.overflow = 'hidden'; + return () => { + document.body.style.overflow = 'unset'; + }; + }, []); + + const overlayContent = ( +
+
+ {/* Close button */} +
+ +
+ + {/* Header */} +
+
+ + PunimTag + +
+ +
+
+

+ Browse our photo collection +

+
+ + {/* Manage Users content */} +
+ +
+
+
+ ); + + // Render in portal to ensure it's above everything + if (typeof window === 'undefined') { + return null; + } + + return createPortal(overlayContent, document.body); +} + diff --git a/viewer-frontend/app/admin/users/page.tsx b/viewer-frontend/app/admin/users/page.tsx new file mode 100644 index 0000000..7bfc57c --- /dev/null +++ b/viewer-frontend/app/admin/users/page.tsx @@ -0,0 +1,20 @@ +import { redirect } from 'next/navigation'; +import { auth } from '@/app/api/auth/[...nextauth]/route'; +import { isAdmin } from '@/lib/permissions'; +import { ManageUsersContent } from './ManageUsersContent'; + +export default async function ManageUsersPage() { + const session = await auth(); + + if (!session?.user) { + redirect('/login?callbackUrl=/admin/users'); + } + + const admin = await isAdmin(); + if (!admin) { + redirect('/'); + } + + return ; +} + diff --git a/viewer-frontend/app/api/auth/[...nextauth]/route.ts b/viewer-frontend/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..b0a3fbe --- /dev/null +++ b/viewer-frontend/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,155 @@ +import NextAuth from 'next-auth'; +import CredentialsProvider from 'next-auth/providers/credentials'; +import { prismaAuth } from '@/lib/db'; +import bcrypt from 'bcryptjs'; + +export const { handlers, signIn, signOut, auth } = NextAuth({ + secret: process.env.NEXTAUTH_SECRET, + providers: [ + CredentialsProvider({ + name: 'Credentials', + credentials: { + email: { label: 'Email', type: 'email' }, + password: { label: 'Password', type: 'password' }, + }, + async authorize(credentials) { + try { + if (!credentials?.email || !credentials?.password) { + console.log('[AUTH] Missing credentials'); + return null; + } + + console.log('[AUTH] Attempting to find user:', credentials.email); + const user = await prismaAuth.user.findUnique({ + where: { email: credentials.email as string }, + select: { + id: true, + email: true, + name: true, + passwordHash: true, + isAdmin: true, + hasWriteAccess: true, + emailVerified: true, + isActive: true, + }, + }); + + if (!user) { + console.log('[AUTH] User not found:', credentials.email); + return null; + } + + console.log('[AUTH] User found, checking password...'); + const isPasswordValid = await bcrypt.compare( + credentials.password as string, + user.passwordHash + ); + + if (!isPasswordValid) { + console.log('[AUTH] Invalid password for user:', credentials.email); + return null; + } + + // Check if email is verified + if (!user.emailVerified) { + console.log('[AUTH] Email not verified for user:', credentials.email); + return null; // Return null to indicate failed login + } + + // Check if user is active (treat null/undefined as true) + if (user.isActive === false) { + console.log('[AUTH] User is inactive:', credentials.email); + return null; // Return null to indicate failed login + } + + console.log('[AUTH] Login successful for:', credentials.email); + + return { + id: user.id.toString(), + email: user.email, + name: user.name || undefined, + isAdmin: user.isAdmin, + hasWriteAccess: user.hasWriteAccess, + }; + } catch (error: any) { + console.error('[AUTH] Error during authorization:', error); + return null; + } + }, + }), + ], + pages: { + signIn: '/login', + signOut: '/', + }, + session: { + strategy: 'jwt', + maxAge: 24 * 60 * 60, // 24 hours in seconds + updateAge: 1 * 60 * 60, // Refresh session every 1 hour (more frequent validation) + }, + jwt: { + maxAge: 24 * 60 * 60, // 24 hours in seconds + }, + callbacks: { + async jwt({ token, user, trigger }) { + // Set expiration time when user first logs in + if (user) { + token.id = user.id; + token.email = user.email; + token.isAdmin = user.isAdmin; + token.hasWriteAccess = user.hasWriteAccess; + token.exp = Math.floor(Date.now() / 1000) + (24 * 60 * 60); // 24 hours from now + } + + // Refresh user data from database on token refresh to get latest hasWriteAccess and isActive + // This ensures permissions are up-to-date even if granted after login + if (token.email && !user) { + try { + const dbUser = await prismaAuth.user.findUnique({ + where: { email: token.email as string }, + select: { + id: true, + email: true, + isAdmin: true, + hasWriteAccess: true, + isActive: true, + }, + }); + + if (dbUser) { + // Check if user is still active (treat null/undefined as true) + if (dbUser.isActive === false) { + // User was deactivated, invalidate token + return null as any; + } + token.id = dbUser.id.toString(); + token.isAdmin = dbUser.isAdmin; + token.hasWriteAccess = dbUser.hasWriteAccess; + } + } catch (error) { + console.error('[AUTH] Error refreshing user data:', error); + // Continue with existing token data if refresh fails + } + } + + return token; + }, + async session({ session, token }) { + // If token is null or expired, return null session to force logout + if (!token || (token.exp && token.exp < Math.floor(Date.now() / 1000))) { + return null as any; + } + + if (session.user) { + session.user.id = token.id as string; + session.user.email = token.email as string; + session.user.isAdmin = token.isAdmin as boolean; + session.user.hasWriteAccess = token.hasWriteAccess as boolean; + } + return session; + }, + }, +}); + +export const { GET, POST } = handlers; + diff --git a/viewer-frontend/app/api/auth/check-verification/route.ts b/viewer-frontend/app/api/auth/check-verification/route.ts new file mode 100644 index 0000000..22dc432 --- /dev/null +++ b/viewer-frontend/app/api/auth/check-verification/route.ts @@ -0,0 +1,72 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { prismaAuth } from '@/lib/db'; +import bcrypt from 'bcryptjs'; + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { email, password } = body; + + if (!email || !password) { + return NextResponse.json( + { error: 'Email and password are required' }, + { status: 400 } + ); + } + + // Find user + const user = await prismaAuth.user.findUnique({ + where: { email }, + select: { + id: true, + email: true, + passwordHash: true, + emailVerified: true, + isActive: true, + }, + }); + + if (!user) { + return NextResponse.json( + { verified: false, exists: false }, + { status: 200 } + ); + } + + // Check if user is active (treat null/undefined as true) + if (user.isActive === false) { + return NextResponse.json( + { verified: false, exists: true, passwordValid: false, active: false }, + { status: 200 } + ); + } + + // Check password + const isPasswordValid = await bcrypt.compare(password, user.passwordHash); + + if (!isPasswordValid) { + return NextResponse.json( + { verified: false, exists: true, passwordValid: false }, + { status: 200 } + ); + } + + // Return verification status + return NextResponse.json( + { + verified: user.emailVerified, + exists: true, + passwordValid: true + }, + { status: 200 } + ); + } catch (error: any) { + console.error('Error checking verification:', error); + return NextResponse.json( + { error: 'Failed to check verification status' }, + { status: 500 } + ); + } +} + + diff --git a/viewer-frontend/app/api/auth/forgot-password/route.ts b/viewer-frontend/app/api/auth/forgot-password/route.ts new file mode 100644 index 0000000..bb8d0f3 --- /dev/null +++ b/viewer-frontend/app/api/auth/forgot-password/route.ts @@ -0,0 +1,103 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { prismaAuth } from '@/lib/db'; +import { generatePasswordResetToken, sendPasswordResetEmail } from '@/lib/email'; +import { isValidEmail } from '@/lib/utils'; + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { email } = body; + + if (!email) { + return NextResponse.json( + { error: 'Email is required' }, + { status: 400 } + ); + } + + if (!isValidEmail(email)) { + return NextResponse.json( + { error: 'Please enter a valid email address' }, + { status: 400 } + ); + } + + // Find user + const user = await prismaAuth.user.findUnique({ + where: { email }, + }); + + // Don't reveal if user exists or not for security + // Always return success message + if (!user) { + return NextResponse.json( + { message: 'If an account with that email exists, a password reset email has been sent.' }, + { status: 200 } + ); + } + + // Check if user is active + if (user.isActive === false) { + return NextResponse.json( + { message: 'If an account with that email exists, a password reset email has been sent.' }, + { status: 200 } + ); + } + + // Generate password reset token + const resetToken = generatePasswordResetToken(); + const tokenExpiry = new Date(); + tokenExpiry.setHours(tokenExpiry.getHours() + 1); // Token expires in 1 hour + + // Update user with reset token + await prismaAuth.user.update({ + where: { id: user.id }, + data: { + passwordResetToken: resetToken, + passwordResetTokenExpiry: tokenExpiry, + }, + }); + + // Send password reset email + try { + console.log('[FORGOT-PASSWORD] Attempting to send password reset email to:', user.email); + await sendPasswordResetEmail(user.email, user.name, resetToken); + console.log('[FORGOT-PASSWORD] Password reset email sent successfully to:', user.email); + } catch (emailError: any) { + console.error('[FORGOT-PASSWORD] Error sending password reset email:', emailError); + console.error('[FORGOT-PASSWORD] Error details:', { + message: emailError?.message, + name: emailError?.name, + response: emailError?.response, + statusCode: emailError?.statusCode, + }); + // Clear the token if email fails + await prismaAuth.user.update({ + where: { id: user.id }, + data: { + passwordResetToken: null, + passwordResetTokenExpiry: null, + }, + }); + return NextResponse.json( + { + error: 'Failed to send password reset email', + details: emailError?.message || 'Unknown error' + }, + { status: 500 } + ); + } + + return NextResponse.json( + { message: 'If an account with that email exists, a password reset email has been sent.' }, + { status: 200 } + ); + } catch (error: any) { + console.error('Error processing password reset request:', error); + return NextResponse.json( + { error: 'Failed to process password reset request' }, + { status: 500 } + ); + } +} + diff --git a/viewer-frontend/app/api/auth/register/route.ts b/viewer-frontend/app/api/auth/register/route.ts new file mode 100644 index 0000000..eec521c --- /dev/null +++ b/viewer-frontend/app/api/auth/register/route.ts @@ -0,0 +1,105 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { prismaAuth } from '@/lib/db'; +import bcrypt from 'bcryptjs'; +import { generateEmailConfirmationToken, sendEmailConfirmation } from '@/lib/email'; +import { isValidEmail } from '@/lib/utils'; + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { email, password, name } = body; + + // Validate input + if (!email || !password || !name) { + return NextResponse.json( + { error: 'Email, password, and name are required' }, + { status: 400 } + ); + } + + if (name.trim().length === 0) { + return NextResponse.json( + { error: 'Name cannot be empty' }, + { status: 400 } + ); + } + + if (!isValidEmail(email)) { + return NextResponse.json( + { error: 'Please enter a valid email address' }, + { status: 400 } + ); + } + + if (password.length < 6) { + return NextResponse.json( + { error: 'Password must be at least 6 characters' }, + { status: 400 } + ); + } + + // Check if user already exists + const existingUser = await prismaAuth.user.findUnique({ + where: { email }, + }); + + if (existingUser) { + return NextResponse.json( + { error: 'User with this email already exists' }, + { status: 409 } + ); + } + + // Hash password + const passwordHash = await bcrypt.hash(password, 10); + + // Generate email confirmation token + const confirmationToken = generateEmailConfirmationToken(); + const tokenExpiry = new Date(); + tokenExpiry.setHours(tokenExpiry.getHours() + 24); // Token expires in 24 hours + + // Create user (without write access by default, email not verified) + const user = await prismaAuth.user.create({ + data: { + email, + passwordHash, + name: name.trim(), + hasWriteAccess: false, // New users don't have write access by default + emailVerified: false, + emailConfirmationToken: confirmationToken, + emailConfirmationTokenExpiry: tokenExpiry, + }, + select: { + id: true, + email: true, + name: true, + createdAt: true, + }, + }); + + // Send confirmation email + try { + await sendEmailConfirmation(email, name.trim(), confirmationToken); + } catch (emailError) { + console.error('Error sending confirmation email:', emailError); + // Don't fail registration if email fails, but log it + // User can request a resend later + } + + return NextResponse.json( + { + message: 'User created successfully. Please check your email to confirm your account.', + user, + requiresEmailConfirmation: true + }, + { status: 201 } + ); + } catch (error: any) { + console.error('Error registering user:', error); + return NextResponse.json( + { error: 'Failed to register user', details: error.message }, + { status: 500 } + ); + } +} + diff --git a/viewer-frontend/app/api/auth/resend-confirmation/route.ts b/viewer-frontend/app/api/auth/resend-confirmation/route.ts new file mode 100644 index 0000000..19cc682 --- /dev/null +++ b/viewer-frontend/app/api/auth/resend-confirmation/route.ts @@ -0,0 +1,81 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { prismaAuth } from '@/lib/db'; +import { generateEmailConfirmationToken, sendEmailConfirmationResend } from '@/lib/email'; + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { email } = body; + + if (!email) { + return NextResponse.json( + { error: 'Email is required' }, + { status: 400 } + ); + } + + // Find user + const user = await prismaAuth.user.findUnique({ + where: { email }, + }); + + if (!user) { + // Don't reveal if user exists or not for security + return NextResponse.json( + { message: 'If an account with that email exists, a confirmation email has been sent.' }, + { status: 200 } + ); + } + + // If already verified, don't send another email + if (user.emailVerified) { + return NextResponse.json( + { message: 'Email is already verified.' }, + { status: 200 } + ); + } + + // Generate new token + const confirmationToken = generateEmailConfirmationToken(); + const tokenExpiry = new Date(); + tokenExpiry.setHours(tokenExpiry.getHours() + 24); // Token expires in 24 hours + + // Update user with new token + await prismaAuth.user.update({ + where: { id: user.id }, + data: { + emailConfirmationToken: confirmationToken, + emailConfirmationTokenExpiry: tokenExpiry, + }, + }); + + // Send confirmation email + try { + await sendEmailConfirmationResend(user.email, user.name, confirmationToken); + } catch (emailError) { + console.error('Error sending confirmation email:', emailError); + return NextResponse.json( + { error: 'Failed to send confirmation email' }, + { status: 500 } + ); + } + + return NextResponse.json( + { message: 'Confirmation email has been sent. Please check your inbox.' }, + { status: 200 } + ); + } catch (error: any) { + console.error('Error resending confirmation email:', error); + return NextResponse.json( + { error: 'Failed to resend confirmation email', details: error.message }, + { status: 500 } + ); + } +} + + + + + + + diff --git a/viewer-frontend/app/api/auth/reset-password/route.ts b/viewer-frontend/app/api/auth/reset-password/route.ts new file mode 100644 index 0000000..1c572fb --- /dev/null +++ b/viewer-frontend/app/api/auth/reset-password/route.ts @@ -0,0 +1,82 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { prismaAuth } from '@/lib/db'; +import bcrypt from 'bcryptjs'; + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { token, password } = body; + + if (!token || !password) { + return NextResponse.json( + { error: 'Token and password are required' }, + { status: 400 } + ); + } + + if (password.length < 6) { + return NextResponse.json( + { error: 'Password must be at least 6 characters' }, + { status: 400 } + ); + } + + // Find user with this token + const user = await prismaAuth.user.findUnique({ + where: { passwordResetToken: token }, + }); + + if (!user) { + return NextResponse.json( + { error: 'Invalid or expired reset token' }, + { status: 400 } + ); + } + + // Check if token has expired + if (user.passwordResetTokenExpiry && user.passwordResetTokenExpiry < new Date()) { + // Clear expired token + await prismaAuth.user.update({ + where: { id: user.id }, + data: { + passwordResetToken: null, + passwordResetTokenExpiry: null, + }, + }); + return NextResponse.json( + { error: 'Reset token has expired. Please request a new password reset.' }, + { status: 400 } + ); + } + + // Hash new password + const passwordHash = await bcrypt.hash(password, 10); + + // Update password and clear reset token + await prismaAuth.user.update({ + where: { id: user.id }, + data: { + passwordHash, + passwordResetToken: null, + passwordResetTokenExpiry: null, + }, + }); + + return NextResponse.json( + { message: 'Password has been reset successfully. You can now sign in with your new password.' }, + { status: 200 } + ); + } catch (error: any) { + console.error('Error resetting password:', error); + return NextResponse.json( + { error: 'Failed to reset password' }, + { status: 500 } + ); + } +} + + + + + + diff --git a/viewer-frontend/app/api/auth/verify-email/route.ts b/viewer-frontend/app/api/auth/verify-email/route.ts new file mode 100644 index 0000000..b4e17ae --- /dev/null +++ b/viewer-frontend/app/api/auth/verify-email/route.ts @@ -0,0 +1,67 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { prismaAuth } from '@/lib/db'; + +export async function GET(request: NextRequest) { + try { + const searchParams = request.nextUrl.searchParams; + const token = searchParams.get('token'); + + if (!token) { + return NextResponse.redirect( + new URL('/login?error=missing_token', request.url) + ); + } + + // Find user with this token + const user = await prismaAuth.user.findUnique({ + where: { emailConfirmationToken: token }, + }); + + if (!user) { + return NextResponse.redirect( + new URL('/login?error=invalid_token', request.url) + ); + } + + // Check if token has expired + if (user.emailConfirmationTokenExpiry && user.emailConfirmationTokenExpiry < new Date()) { + return NextResponse.redirect( + new URL('/login?error=token_expired', request.url) + ); + } + + // Check if already verified + if (user.emailVerified) { + return NextResponse.redirect( + new URL('/login?message=already_verified', request.url) + ); + } + + // Verify the email + await prismaAuth.user.update({ + where: { id: user.id }, + data: { + emailVerified: true, + emailConfirmationToken: null, + emailConfirmationTokenExpiry: null, + }, + }); + + // Redirect to login with success message + return NextResponse.redirect( + new URL('/login?verified=true', request.url) + ); + } catch (error: any) { + console.error('Error verifying email:', error); + return NextResponse.redirect( + new URL('/login?error=verification_failed', request.url) + ); + } +} + + + + + + + diff --git a/viewer-frontend/app/api/debug-session/route.ts b/viewer-frontend/app/api/debug-session/route.ts new file mode 100644 index 0000000..f5449e3 --- /dev/null +++ b/viewer-frontend/app/api/debug-session/route.ts @@ -0,0 +1,23 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { auth } from '@/app/api/auth/[...nextauth]/route'; + +// Debug endpoint to check session +export async function GET(request: NextRequest) { + try { + const session = await auth(); + + return NextResponse.json({ + hasSession: !!session, + user: session?.user || null, + userId: session?.user?.id || null, + isAdmin: session?.user?.isAdmin || false, + hasWriteAccess: session?.user?.hasWriteAccess || false, + }, { status: 200 }); + } catch (error: any) { + return NextResponse.json( + { error: 'Failed to get session', details: error.message }, + { status: 500 } + ); + } +} + diff --git a/viewer-frontend/app/api/faces/[id]/identify/route.ts b/viewer-frontend/app/api/faces/[id]/identify/route.ts new file mode 100644 index 0000000..f5b2d1c --- /dev/null +++ b/viewer-frontend/app/api/faces/[id]/identify/route.ts @@ -0,0 +1,174 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { prisma, prismaAuth } from '@/lib/db'; +import { auth } from '@/app/api/auth/[...nextauth]/route'; + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + // Check authentication + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json( + { error: 'Authentication required. Please sign in to identify faces.' }, + { status: 401 } + ); + } + + // Check write access + if (!session.user.hasWriteAccess) { + return NextResponse.json( + { error: 'Write access required. You need write access to identify faces. Please contact an administrator.' }, + { status: 403 } + ); + } + + const { id } = await params; + const faceId = parseInt(id, 10); + + if (isNaN(faceId)) { + return NextResponse.json( + { error: 'Invalid face ID' }, + { status: 400 } + ); + } + + const body = await request.json(); + const { personId, firstName, lastName, middleName, maidenName, dateOfBirth } = body; + + let finalFirstName: string; + let finalLastName: string; + let finalMiddleName: string | null = null; + let finalMaidenName: string | null = null; + let finalDateOfBirth: Date | null = null; + + // If personId is provided, fetch person data from database + if (personId) { + const person = await prisma.person.findUnique({ + where: { id: parseInt(personId, 10) }, + }); + + if (!person) { + return NextResponse.json( + { error: 'Person not found' }, + { status: 404 } + ); + } + + finalFirstName = person.first_name; + finalLastName = person.last_name; + finalMiddleName = person.middle_name; + finalMaidenName = person.maiden_name; + finalDateOfBirth = person.date_of_birth; + } else { + // Validate required fields for new person + if (!firstName || !lastName) { + return NextResponse.json( + { error: 'First name and last name are required' }, + { status: 400 } + ); + } + + finalFirstName = firstName; + finalLastName = lastName; + finalMiddleName = middleName || null; + finalMaidenName = maidenName || null; + + // Parse date of birth if provided + const dob = dateOfBirth ? new Date(dateOfBirth) : null; + if (dateOfBirth && dob && isNaN(dob.getTime())) { + return NextResponse.json( + { error: 'Invalid date of birth' }, + { status: 400 } + ); + } + finalDateOfBirth = dob; + } + + // Check if face exists (use read client for this - from punimtag database) + const face = await prisma.face.findUnique({ + where: { id: faceId }, + include: { Person: true }, + }); + + if (!face) { + return NextResponse.json( + { error: 'Face not found' }, + { status: 404 } + ); + } + + const userId = parseInt(session.user.id, 10); + if (isNaN(userId)) { + return NextResponse.json( + { error: 'Invalid user session' }, + { status: 401 } + ); + } + + // Check if there's already a pending identification for this face by this user + // Use auth client (connects to punimtag_auth database) + const existingPending = await prismaAuth.pendingIdentification.findFirst({ + where: { + faceId, + userId, + status: 'pending', + }, + }); + + if (existingPending) { + // Update existing pending identification + const updated = await prismaAuth.pendingIdentification.update({ + where: { id: existingPending.id }, + data: { + firstName: finalFirstName, + lastName: finalLastName, + middleName: finalMiddleName, + maidenName: finalMaidenName, + dateOfBirth: finalDateOfBirth, + }, + }); + + return NextResponse.json({ + message: 'Identification updated and pending approval', + pendingIdentification: updated, + }); + } + + // Create new pending identification + const pendingIdentification = await prismaAuth.pendingIdentification.create({ + data: { + faceId, + userId, + firstName: finalFirstName, + lastName: finalLastName, + middleName: finalMiddleName, + maidenName: finalMaidenName, + dateOfBirth: finalDateOfBirth, + status: 'pending', + }, + }); + + return NextResponse.json({ + message: 'Identification submitted and pending approval', + pendingIdentification, + }); + } catch (error: any) { + console.error('Error identifying face:', error); + + // Handle unique constraint violation + if (error.code === 'P2002') { + return NextResponse.json( + { error: 'A person with these details already exists' }, + { status: 409 } + ); + } + + return NextResponse.json( + { error: 'Failed to identify face', details: error.message }, + { status: 500 } + ); + } +} + diff --git a/viewer-frontend/app/api/health/route.ts b/viewer-frontend/app/api/health/route.ts new file mode 100644 index 0000000..c310cd5 --- /dev/null +++ b/viewer-frontend/app/api/health/route.ts @@ -0,0 +1,87 @@ +import { NextResponse } from 'next/server'; +import { prisma } from '@/lib/db'; + +/** + * Health check endpoint that verifies database connectivity and permissions + * This runs automatically and can help detect permission issues early + */ +export async function GET() { + const checks: Record = {}; + + // Check database connection + try { + await prisma.$connect(); + checks.database_connection = { + status: 'ok', + message: 'Database connection successful', + }; + } catch (error: any) { + checks.database_connection = { + status: 'error', + message: `Database connection failed: ${error.message}`, + }; + return NextResponse.json( + { + status: 'error', + checks, + message: 'Database health check failed', + }, + { status: 503 } + ); + } + + // Check permissions on key tables + const tables = [ + { name: 'photos', query: () => prisma.photo.findFirst() }, + { name: 'people', query: () => prisma.person.findFirst() }, + { name: 'faces', query: () => prisma.face.findFirst() }, + { name: 'tags', query: () => prisma.tag.findFirst() }, + ]; + + for (const table of tables) { + try { + await table.query(); + checks[`table_${table.name}`] = { + status: 'ok', + message: `SELECT permission on ${table.name} table is OK`, + }; + } catch (error: any) { + if (error.message?.includes('permission denied')) { + checks[`table_${table.name}`] = { + status: 'error', + message: `Permission denied on ${table.name} table. Run grant_readonly_permissions.sql as superuser.`, + }; + } else { + checks[`table_${table.name}`] = { + status: 'error', + message: `Error accessing ${table.name}: ${error.message}`, + }; + } + } + } + + const hasErrors = Object.values(checks).some((check) => check.status === 'error'); + + return NextResponse.json( + { + status: hasErrors ? 'error' : 'ok', + checks, + timestamp: new Date().toISOString(), + ...(hasErrors && { + fixInstructions: { + message: 'To fix permission errors, run as PostgreSQL superuser:', + command: 'psql -U postgres -d punimtag -f grant_readonly_permissions.sql', + }, + }), + }, + { status: hasErrors ? 503 : 200 } + ); +} + + + + + + + + diff --git a/viewer-frontend/app/api/people/route.ts b/viewer-frontend/app/api/people/route.ts new file mode 100644 index 0000000..ad4f9e1 --- /dev/null +++ b/viewer-frontend/app/api/people/route.ts @@ -0,0 +1,81 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { prisma } from '@/lib/db'; + +export async function GET(request: NextRequest) { + try { + const people = await prisma.person.findMany({ + orderBy: [ + { first_name: 'asc' }, + { last_name: 'asc' }, + ], + select: { + id: true, + first_name: true, + last_name: true, + middle_name: true, + maiden_name: true, + date_of_birth: true, + created_date: true, + }, + }); + + // Transform snake_case to camelCase for frontend + const transformedPeople = people.map((person) => ({ + id: person.id, + firstName: person.first_name, + lastName: person.last_name, + middleName: person.middle_name, + maidenName: person.maiden_name, + dateOfBirth: person.date_of_birth, + createdDate: person.created_date, + })); + + return NextResponse.json({ people: transformedPeople }, { status: 200 }); + } catch (error: any) { + // Handle corrupted data errors (P2023) + if (error?.code === 'P2023' || error?.message?.includes('Conversion failed')) { + console.warn('Corrupted person data detected, attempting fallback query'); + try { + // Try with minimal fields first + const people = await prisma.person.findMany({ + select: { + id: true, + first_name: true, + last_name: true, + // Exclude potentially corrupted optional fields + }, + orderBy: [ + { first_name: 'asc' }, + { last_name: 'asc' }, + ], + }); + + // Transform snake_case to camelCase for frontend + const transformedPeople = people.map((person) => ({ + id: person.id, + firstName: person.first_name, + lastName: person.last_name, + middleName: null, + maidenName: null, + dateOfBirth: null, + createdDate: null, + })); + + return NextResponse.json({ people: transformedPeople }, { status: 200 }); + } catch (fallbackError: any) { + console.error('Fallback person query also failed:', fallbackError); + return NextResponse.json( + { error: 'Failed to fetch people', details: fallbackError.message }, + { status: 500 } + ); + } + } + + console.error('Error fetching people:', error); + return NextResponse.json( + { error: 'Failed to fetch people', details: error.message }, + { status: 500 } + ); + } +} + diff --git a/viewer-frontend/app/api/search/route.ts b/viewer-frontend/app/api/search/route.ts new file mode 100644 index 0000000..aa52627 --- /dev/null +++ b/viewer-frontend/app/api/search/route.ts @@ -0,0 +1,394 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { prisma, prismaAuth } from '@/lib/db'; +import { serializePhotos } from '@/lib/serialize'; +import { auth } from '@/app/api/auth/[...nextauth]/route'; + +export async function GET(request: NextRequest) { + try { + const searchParams = request.nextUrl.searchParams; + + // Parse query parameters + const people = searchParams.get('people')?.split(',').filter(Boolean).map(Number) || []; + const peopleMode = (searchParams.get('peopleMode') || 'any') as 'any' | 'all'; + const tags = searchParams.get('tags')?.split(',').filter(Boolean).map(Number) || []; + const tagsMode = (searchParams.get('tagsMode') || 'any') as 'any' | 'all'; + const dateFrom = searchParams.get('dateFrom'); + const dateTo = searchParams.get('dateTo'); + const mediaType = (searchParams.get('mediaType') || 'all') as 'all' | 'photos' | 'videos'; + const favoritesOnly = searchParams.get('favoritesOnly') === 'true'; + const page = parseInt(searchParams.get('page') || '1', 10); + const pageSize = parseInt(searchParams.get('pageSize') || '30', 10); + const skip = (page - 1) * pageSize; + + // Get user session for favorites filter + const session = await auth(); + let favoritePhotoIds: number[] = []; + + if (favoritesOnly && session?.user?.id) { + const userId = parseInt(session.user.id, 10); + if (!isNaN(userId)) { + try { + const favorites = await prismaAuth.photoFavorite.findMany({ + where: { userId }, + select: { photoId: true }, + }); + favoritePhotoIds = favorites.map(f => f.photoId); + + // If user has no favorites, return empty result + if (favoritePhotoIds.length === 0) { + return NextResponse.json({ + photos: [], + total: 0, + page, + pageSize, + totalPages: 0, + }); + } + } catch (error: any) { + // Handle case where table doesn't exist yet (P2021 = table does not exist) + if (error.code === 'P2021') { + console.warn('photo_favorites table does not exist yet. Run migration: migrations/add-photo-favorites-table.sql'); + } else { + console.error('Error fetching favorites:', error); + } + // If favorites table doesn't exist or error, treat as no favorites + if (favoritesOnly) { + return NextResponse.json({ + photos: [], + total: 0, + page, + pageSize, + totalPages: 0, + }); + } + } + } + } + + // Build where clause + const where: any = { + processed: true, + }; + + // Media type filter + if (mediaType !== 'all') { + if (mediaType === 'photos') { + where.media_type = 'image'; + } else if (mediaType === 'videos') { + where.media_type = 'video'; + } + } + + // Date filter + if (dateFrom || dateTo) { + where.date_taken = {}; + if (dateFrom) { + where.date_taken.gte = new Date(dateFrom); + } + if (dateTo) { + where.date_taken.lte = new Date(dateTo); + } + } + + // People filter + if (people.length > 0) { + if (peopleMode === 'all') { + // Photo must have ALL selected people + where.AND = where.AND || []; + people.forEach((personId) => { + where.AND.push({ + Face: { + some: { + person_id: personId, + }, + }, + }); + }); + } else { + // Photo has ANY of the selected people (default) + where.Face = { + some: { + person_id: { in: people }, + }, + }; + } + } + + // Tags filter + if (tags.length > 0) { + if (tagsMode === 'all') { + // Photo must have ALL selected tags + where.AND = where.AND || []; + tags.forEach((tagId) => { + where.AND.push({ + PhotoTagLinkage: { + some: { + tag_id: tagId, + }, + }, + }); + }); + } else { + // Photo has ANY of the selected tags (default) + where.PhotoTagLinkage = { + some: { + tag_id: { in: tags }, + }, + }; + } + } + + // Favorites filter + if (favoritesOnly && favoritePhotoIds.length > 0) { + where.id = { in: favoritePhotoIds }; + } else if (favoritesOnly && favoritePhotoIds.length === 0) { + // User has no favorites, return empty (already handled above, but keep for safety) + where.id = { in: [] }; + } + + // Execute query - load photos and relations separately + // Use raw query to read dates as strings and convert manually to avoid Prisma conversion issues + let photosBase: any[]; + let total: number; + + try { + // Build WHERE clause for raw SQL + const whereConditions: string[] = ['processed = true']; + const params: any[] = []; + let paramIndex = 1; // PostgreSQL uses $1, $2, etc. + + if (mediaType !== 'all') { + if (mediaType === 'photos') { + whereConditions.push(`media_type = $${paramIndex}`); + params.push('image'); + paramIndex++; + } else if (mediaType === 'videos') { + whereConditions.push(`media_type = $${paramIndex}`); + params.push('video'); + paramIndex++; + } + } + + if (dateFrom || dateTo) { + if (dateFrom) { + whereConditions.push(`date_taken >= $${paramIndex}`); + params.push(dateFrom); + paramIndex++; + } + if (dateTo) { + whereConditions.push(`date_taken <= $${paramIndex}`); + params.push(dateTo); + paramIndex++; + } + } + + // Handle people filter - embed IDs directly since they're safe integers + if (people.length > 0) { + const peopleIds = people.join(','); + whereConditions.push(`id IN ( + SELECT DISTINCT photo_id FROM faces WHERE person_id IN (${peopleIds}) + )`); + } + + // Handle tags filter - embed IDs directly since they're safe integers + if (tags.length > 0) { + const tagIds = tags.join(','); + whereConditions.push(`id IN ( + SELECT DISTINCT photo_id FROM phototaglinkage WHERE tag_id IN (${tagIds}) + )`); + } + + // Handle favorites filter - embed IDs directly since they're safe integers + if (favoritesOnly && favoritePhotoIds.length > 0) { + const favIds = favoritePhotoIds.join(','); + whereConditions.push(`id IN (${favIds})`); + } else if (favoritesOnly && favoritePhotoIds.length === 0) { + whereConditions.push('1 = 0'); // No favorites, return empty + } + + const whereClause = whereConditions.join(' AND '); + + // Build query parameters (LIMIT and OFFSET are embedded directly as they're safe integers) + const queryParams = [...params]; + const countParams = [...params]; + + // Use raw query to read dates as strings + // Note: LIMIT and OFFSET are embedded directly since they're integers and safe + const [photosRaw, totalResult] = await Promise.all([ + prisma.$queryRawUnsafe>( + `SELECT + id, + path, + filename, + date_added, + date_taken, + processed, + media_type + FROM photos + WHERE ${whereClause} + ORDER BY date_taken DESC, id DESC + LIMIT ${pageSize} OFFSET ${skip}`, + ...queryParams + ), + prisma.$queryRawUnsafe>( + `SELECT COUNT(*) as count FROM photos WHERE ${whereClause}`, + ...countParams + ), + ]); + + // Convert date strings to Date objects + photosBase = photosRaw.map(photo => ({ + id: photo.id, + path: photo.path, + filename: photo.filename, + date_added: new Date(photo.date_added), + date_taken: photo.date_taken ? new Date(photo.date_taken) : null, + processed: photo.processed, + media_type: photo.media_type, + })); + + total = Number(totalResult[0].count); + } catch (error: any) { + console.error('Error loading photos:', error); + throw error; + } + + // Load faces and tags separately + const photoIds = photosBase.map(p => p.id); + + // Fetch faces + let faces: any[] = []; + try { + faces = await prisma.face.findMany({ + where: { photo_id: { in: photoIds } }, + select: { + id: true, + photo_id: true, + person_id: true, + location: true, + confidence: true, + quality_score: true, + is_primary_encoding: true, + detector_backend: true, + model_name: true, + face_confidence: true, + exif_orientation: true, + pose_mode: true, + yaw_angle: true, + pitch_angle: true, + roll_angle: true, + landmarks: true, + identified_by_user_id: true, + excluded: true, + Person: { + select: { + id: true, + first_name: true, + last_name: true, + middle_name: true, + maiden_name: true, + date_of_birth: true, + created_date: true, + }, + }, + // Exclude encoding field (Bytes) to avoid P2023 conversion errors + }, + }); + } catch (faceError: any) { + if (faceError?.code === 'P2023' || faceError?.message?.includes('Conversion failed')) { + console.warn('Corrupted face data detected in search, skipping faces'); + faces = []; + } else { + throw faceError; + } + } + + // Fetch photo tag linkages with error handling + let photoTagLinkages: any[] = []; + try { + photoTagLinkages = await prisma.photoTagLinkage.findMany({ + where: { photo_id: { in: photoIds } }, + select: { + linkage_id: true, + photo_id: true, + tag_id: true, + linkage_type: true, + created_date: true, + Tag: { + select: { + id: true, + tag_name: true, + created_date: true, + }, + }, + }, + }); + } catch (linkageError: any) { + if (linkageError?.code === 'P2023' || linkageError?.message?.includes('Conversion failed')) { + console.warn('Corrupted photo tag linkage data detected, attempting fallback query'); + try { + // Try with minimal fields + photoTagLinkages = await prisma.photoTagLinkage.findMany({ + where: { photo_id: { in: photoIds } }, + select: { + linkage_id: true, + photo_id: true, + tag_id: true, + // Exclude potentially corrupted fields + Tag: { + select: { + id: true, + tag_name: true, + // Exclude created_date if it's corrupted + }, + }, + }, + }); + } catch (fallbackError: any) { + console.error('Fallback photo tag linkage query also failed:', fallbackError); + // Return empty array as last resort to prevent API crash + photoTagLinkages = []; + } + } else { + throw linkageError; + } + } + + // Combine the data manually + const photos = photosBase.map(photo => ({ + ...photo, + Face: faces.filter(face => face.photo_id === photo.id), + PhotoTagLinkage: photoTagLinkages.filter(link => link.photo_id === photo.id), + })); + + return NextResponse.json({ + photos: serializePhotos(photos), + total, + page, + pageSize, + totalPages: Math.ceil(total / pageSize), + }); + } catch (error) { + console.error('Search error:', error); + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + const errorStack = error instanceof Error ? error.stack : undefined; + console.error('Error details:', { errorMessage, errorStack, error }); + return NextResponse.json( + { + error: 'Failed to search photos', + details: errorMessage, + ...(process.env.NODE_ENV === 'development' && { stack: errorStack }) + }, + { status: 500 } + ); + } +} + diff --git a/viewer-frontend/app/api/users/[id]/route.ts b/viewer-frontend/app/api/users/[id]/route.ts new file mode 100644 index 0000000..68025c1 --- /dev/null +++ b/viewer-frontend/app/api/users/[id]/route.ts @@ -0,0 +1,324 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { prismaAuth } from '@/lib/db'; +import { isAdmin } from '@/lib/permissions'; +import bcrypt from 'bcryptjs'; + +// PATCH /api/users/[id] - Update user (admin only) +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + // Check if user is admin + const admin = await isAdmin(); + if (!admin) { + return NextResponse.json( + { error: 'Unauthorized. Admin access required.' }, + { status: 403 } + ); + } + + const { id } = await params; + const userId = parseInt(id, 10); + if (isNaN(userId)) { + return NextResponse.json( + { error: 'Invalid user ID' }, + { status: 400 } + ); + } + + const body = await request.json(); + const { hasWriteAccess, name, password, email, isAdmin: isAdminValue, isActive } = body; + + // Prevent users from removing their own admin status + const session = await import('@/app/api/auth/[...nextauth]/route').then( + (m) => m.auth() + ); + if (session?.user?.id && parseInt(session.user.id, 10) === userId) { + if (isAdminValue === false) { + return NextResponse.json( + { error: 'You cannot remove your own admin status' }, + { status: 400 } + ); + } + } + + // Build update data + const updateData: { + hasWriteAccess?: boolean; + name?: string; + passwordHash?: string; + email?: string; + isAdmin?: boolean; + isActive?: boolean; + } = {}; + + if (typeof hasWriteAccess === 'boolean') { + updateData.hasWriteAccess = hasWriteAccess; + } + + if (typeof isAdminValue === 'boolean') { + updateData.isAdmin = isAdminValue; + } + + if (typeof isActive === 'boolean') { + updateData.isActive = isActive; + } + + if (name !== undefined) { + if (!name || name.trim().length === 0) { + return NextResponse.json( + { error: 'Name is required and cannot be empty' }, + { status: 400 } + ); + } + updateData.name = name.trim(); + } + + if (email !== undefined) { + // Basic email validation + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(email)) { + return NextResponse.json( + { error: 'Invalid email format' }, + { status: 400 } + ); + } + updateData.email = email; + } + + if (password) { + if (password.length < 6) { + return NextResponse.json( + { error: 'Password must be at least 6 characters' }, + { status: 400 } + ); + } + updateData.passwordHash = await bcrypt.hash(password, 10); + } + + if (Object.keys(updateData).length === 0) { + return NextResponse.json( + { error: 'No valid fields to update' }, + { status: 400 } + ); + } + + // Update user + const user = await prismaAuth.user.update({ + where: { id: userId }, + data: updateData, + select: { + id: true, + email: true, + name: true, + isAdmin: true, + hasWriteAccess: true, + isActive: true, + createdAt: true, + updatedAt: true, + }, + }); + + return NextResponse.json( + { message: 'User updated successfully', user }, + { status: 200 } + ); + } catch (error: any) { + console.error('Error updating user:', error); + if (error.code === 'P2025') { + return NextResponse.json( + { error: 'User not found' }, + { status: 404 } + ); + } + if (error.code === 'P2002') { + // Unique constraint violation (likely email already exists) + return NextResponse.json( + { error: 'Email already exists. Please use a different email address.' }, + { status: 409 } + ); + } + return NextResponse.json( + { error: 'Failed to update user', details: error.message }, + { status: 500 } + ); + } +} + +// DELETE /api/users/[id] - Delete user (admin only) +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + // Check if user is admin + const admin = await isAdmin(); + if (!admin) { + return NextResponse.json( + { error: 'Unauthorized. Admin access required.' }, + { status: 403 } + ); + } + + const { id } = await params; + const userId = parseInt(id, 10); + if (isNaN(userId)) { + return NextResponse.json( + { error: 'Invalid user ID' }, + { status: 400 } + ); + } + + // Prevent deleting yourself + const session = await import('@/app/api/auth/[...nextauth]/route').then( + (m) => m.auth() + ); + if (session?.user?.id && parseInt(session.user.id, 10) === userId) { + return NextResponse.json( + { error: 'You cannot delete your own account' }, + { status: 400 } + ); + } + + // Check if user has any related records in other tables + let pendingIdentifications = 0; + let pendingPhotos = 0; + let inappropriatePhotoReports = 0; + let pendingLinkages = 0; + let photoFavorites = 0; + + try { + [pendingIdentifications, pendingPhotos, inappropriatePhotoReports, pendingLinkages, photoFavorites] = await Promise.all([ + prismaAuth.pendingIdentification.count({ where: { userId } }), + prismaAuth.pendingPhoto.count({ where: { userId } }), + prismaAuth.inappropriatePhotoReport.count({ where: { userId } }), + prismaAuth.pendingLinkage.count({ where: { userId } }), + prismaAuth.photoFavorite.count({ where: { userId } }), + ]); + } catch (countError: any) { + console.error('Error counting related records:', countError); + // If counting fails, err on the side of caution and deactivate instead of delete + await prismaAuth.user.update({ + where: { id: userId }, + data: { isActive: false }, + }); + return NextResponse.json( + { + message: 'User deactivated successfully (error checking related records)', + deactivated: true + }, + { status: 200 } + ); + } + + console.log(`[DELETE User ${userId}] Related records:`, { + pendingIdentifications, + pendingPhotos, + inappropriatePhotoReports, + pendingLinkages, + photoFavorites, + }); + + // Ensure all counts are numbers and check explicitly + const counts = { + pendingIdentifications: Number(pendingIdentifications) || 0, + pendingPhotos: Number(pendingPhotos) || 0, + inappropriatePhotoReports: Number(inappropriatePhotoReports) || 0, + pendingLinkages: Number(pendingLinkages) || 0, + photoFavorites: Number(photoFavorites) || 0, + }; + + const hasRelatedRecords = + counts.pendingIdentifications > 0 || + counts.pendingPhotos > 0 || + counts.inappropriatePhotoReports > 0 || + counts.pendingLinkages > 0 || + counts.photoFavorites > 0; + + console.log(`[DELETE User ${userId}] hasRelatedRecords:`, hasRelatedRecords, 'Counts:', counts); + + if (hasRelatedRecords) { + console.log(`[DELETE User ${userId}] Deactivating user due to related records`); + // Set user as inactive instead of deleting + try { + await prismaAuth.user.update({ + where: { id: userId }, + data: { isActive: false }, + }); + console.log(`[DELETE User ${userId}] User deactivated successfully`); + } catch (updateError: any) { + console.error(`[DELETE User ${userId}] Error deactivating user:`, updateError); + throw updateError; + } + + return NextResponse.json( + { + message: 'User deactivated successfully (user has related records in other tables)', + deactivated: true, + relatedRecords: { + pendingIdentifications: counts.pendingIdentifications, + pendingPhotos: counts.pendingPhotos, + inappropriatePhotoReports: counts.inappropriatePhotoReports, + pendingLinkages: counts.pendingLinkages, + photoFavorites: counts.photoFavorites, + } + }, + { status: 200 } + ); + } + + console.log(`[DELETE User ${userId}] No related records found, proceeding with deletion`); + + // Double-check one more time before deleting (defensive programming) + const finalCheck = await Promise.all([ + prismaAuth.pendingIdentification.count({ where: { userId } }), + prismaAuth.pendingPhoto.count({ where: { userId } }), + prismaAuth.inappropriatePhotoReport.count({ where: { userId } }), + prismaAuth.pendingLinkage.count({ where: { userId } }), + prismaAuth.photoFavorite.count({ where: { userId } }), + ]); + + const finalHasRelatedRecords = finalCheck.some(count => count > 0); + + if (finalHasRelatedRecords) { + console.log(`[DELETE User ${userId}] Final check found related records, deactivating instead`); + await prismaAuth.user.update({ + where: { id: userId }, + data: { isActive: false }, + }); + return NextResponse.json( + { + message: 'User deactivated successfully (related records detected in final check)', + deactivated: true + }, + { status: 200 } + ); + } + + // No related records, safe to delete + console.log(`[DELETE User ${userId}] Confirmed no related records, deleting user`); + await prismaAuth.user.delete({ + where: { id: userId }, + }); + + console.log(`[DELETE User ${userId}] User deleted successfully`); + return NextResponse.json( + { message: 'User deleted successfully' }, + { status: 200 } + ); + } catch (error: any) { + console.error('Error deleting user:', error); + if (error.code === 'P2025') { + return NextResponse.json( + { error: 'User not found' }, + { status: 404 } + ); + } + return NextResponse.json( + { error: 'Failed to delete user', details: error.message }, + { status: 500 } + ); + } +} + diff --git a/viewer-frontend/app/api/users/route.ts b/viewer-frontend/app/api/users/route.ts new file mode 100644 index 0000000..f90d85e --- /dev/null +++ b/viewer-frontend/app/api/users/route.ts @@ -0,0 +1,173 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { prismaAuth } from '@/lib/db'; +import { isAdmin } from '@/lib/permissions'; +import bcrypt from 'bcryptjs'; +import { isValidEmail } from '@/lib/utils'; + +// GET /api/users - List all users (admin only) +export async function GET(request: NextRequest) { + try { + console.log('[API /users] Request received'); + + // Check if user is admin + console.log('[API /users] Checking admin status...'); + const admin = await isAdmin(); + console.log('[API /users] Admin check result:', admin); + + if (!admin) { + console.log('[API /users] Unauthorized - user is not admin'); + return NextResponse.json( + { error: 'Unauthorized. Admin access required.', message: 'You must be an administrator to access this resource.' }, + { status: 403 } + ); + } + + console.log('[API /users] User is admin, fetching users from database...'); + + // Get filter from query parameters + const { searchParams } = new URL(request.url); + const statusFilter = searchParams.get('status'); // 'all', 'active', 'inactive' + + // Build where clause based on filter + let whereClause: any = {}; + if (statusFilter === 'active') { + whereClause = { NOT: { isActive: false } }; // Active only (treat null/undefined as active) + } else if (statusFilter === 'inactive') { + whereClause = { isActive: false }; // Inactive only + } + // If 'all' or no filter, don't add where clause (get all users) + + const users = await prismaAuth.user.findMany({ + where: whereClause, + select: { + id: true, + email: true, + name: true, + isAdmin: true, + hasWriteAccess: true, + isActive: true, + createdAt: true, + updatedAt: true, + }, + orderBy: { + createdAt: 'desc', + }, + }); + + console.log('[API /users] Successfully fetched', users.length, 'users'); + return NextResponse.json({ users }, { status: 200 }); + } catch (error: any) { + console.error('[API /users] Error:', error); + console.error('[API /users] Error stack:', error.stack); + return NextResponse.json( + { + error: 'Failed to fetch users', + details: error.message, + message: error.message || 'An unexpected error occurred while fetching users.' + }, + { status: 500 } + ); + } +} + +// POST /api/users - Create new user (admin only) +export async function POST(request: NextRequest) { + try { + // Check if user is admin + const admin = await isAdmin(); + if (!admin) { + return NextResponse.json( + { error: 'Unauthorized. Admin access required.' }, + { status: 403 } + ); + } + + const body = await request.json(); + const { + email, + password, + name, + hasWriteAccess, + isAdmin: newUserIsAdmin, + } = body; + + // Validate input + if (!email || !password || !name) { + return NextResponse.json( + { error: 'Email, password, and name are required' }, + { status: 400 } + ); + } + + if (name.trim().length === 0) { + return NextResponse.json( + { error: 'Name cannot be empty' }, + { status: 400 } + ); + } + + if (!isValidEmail(email)) { + return NextResponse.json( + { error: 'Please enter a valid email address' }, + { status: 400 } + ); + } + + if (password.length < 6) { + return NextResponse.json( + { error: 'Password must be at least 6 characters' }, + { status: 400 } + ); + } + + // Check if user already exists + const existingUser = await prismaAuth.user.findUnique({ + where: { email }, + }); + + if (existingUser) { + return NextResponse.json( + { error: 'User with this email already exists' }, + { status: 409 } + ); + } + + // Hash password + const passwordHash = await bcrypt.hash(password, 10); + + // Create user (admin-created users are automatically verified) + const user = await prismaAuth.user.create({ + data: { + email, + passwordHash, + name: name.trim(), + hasWriteAccess: hasWriteAccess ?? false, + isAdmin: newUserIsAdmin ?? false, + emailVerified: true, // Admin-created users are automatically verified + emailConfirmationToken: null, // No confirmation token needed + emailConfirmationTokenExpiry: null, // No expiry needed + }, + select: { + id: true, + email: true, + name: true, + isAdmin: true, + hasWriteAccess: true, + createdAt: true, + updatedAt: true, + }, + }); + + return NextResponse.json( + { message: 'User created successfully', user }, + { status: 201 } + ); + } catch (error: any) { + console.error('Error creating user:', error); + return NextResponse.json( + { error: 'Failed to create user', details: error.message }, + { status: 500 } + ); + } +} + diff --git a/viewer-frontend/app/favicon.ico b/viewer-frontend/app/favicon.ico new file mode 100644 index 0000000..718d6fe Binary files /dev/null and b/viewer-frontend/app/favicon.ico differ diff --git a/viewer-frontend/app/globals.css b/viewer-frontend/app/globals.css new file mode 100644 index 0000000..e709622 --- /dev/null +++ b/viewer-frontend/app/globals.css @@ -0,0 +1,128 @@ +@import "tailwindcss"; +@import "tw-animate-css"; + +@custom-variant dark (&:is(.dark *)); + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --font-sans: var(--font-geist-sans); + --font-mono: var(--font-geist-mono); + --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); + --color-chart-5: var(--chart-5); + --color-chart-4: var(--chart-4); + --color-chart-3: var(--chart-3); + --color-chart-2: var(--chart-2); + --color-chart-1: var(--chart-1); + --color-ring: var(--ring); + --color-input: var(--input); + --color-border: var(--border); + --color-destructive: var(--destructive); + --color-accent-foreground: var(--accent-foreground); + --color-accent: var(--accent); + --color-muted-foreground: var(--muted-foreground); + --color-muted: var(--muted); + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-primary-foreground: var(--primary-foreground); + --color-primary: var(--primary); + --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); +} + +:root { + --radius: 0.625rem; + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + /* Blue as primary color (from logo) */ + --primary: #1e40af; + --primary-foreground: oklch(0.985 0 0); + /* Blue for secondary/interactive elements - standard blue */ + --secondary: #2563eb; + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + /* Blue accent */ + --accent: #1e40af; + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: #1e40af; + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: #1e40af; + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: #2563eb; + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: #1e40af; +} + +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + /* Dark blue for cards in dark mode */ + --card: oklch(0.25 0.08 250); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.25 0.08 250); + --popover-foreground: oklch(0.985 0 0); + /* Blue primary in dark mode (from logo) */ + --primary: #3b82f6; + --primary-foreground: oklch(0.145 0 0); + /* Blue secondary in dark mode - standard blue */ + --secondary: #3b82f6; + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: #3b82f6; + --accent-foreground: oklch(0.145 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: #3b82f6; + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.25 0.08 250); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: #3b82f6; + --sidebar-primary-foreground: oklch(0.145 0 0); + --sidebar-accent: #3b82f6; + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: #3b82f6; +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } +} diff --git a/viewer-frontend/app/layout.tsx b/viewer-frontend/app/layout.tsx new file mode 100644 index 0000000..952656d --- /dev/null +++ b/viewer-frontend/app/layout.tsx @@ -0,0 +1,30 @@ +import type { Metadata } from "next"; +import { Inter } from "next/font/google"; +import { SessionProviderWrapper } from "@/components/SessionProviderWrapper"; +import "./globals.css"; + +const inter = Inter({ + subsets: ["latin"], + variable: "--font-inter", +}); + +export const metadata: Metadata = { + title: "PunimTag Photo Viewer", + description: "Browse and search your family photos", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + + {children} + + + + ); +} diff --git a/viewer-frontend/app/login/page.tsx b/viewer-frontend/app/login/page.tsx new file mode 100644 index 0000000..ec8195c --- /dev/null +++ b/viewer-frontend/app/login/page.tsx @@ -0,0 +1,215 @@ +'use client'; + +import { useState } from 'react'; +import { signIn } from 'next-auth/react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import Link from 'next/link'; + +export default function LoginPage() { + const router = useRouter(); + const searchParams = useSearchParams(); + const callbackUrl = searchParams.get('callbackUrl') || '/'; + const registered = searchParams.get('registered') === 'true'; + const verified = searchParams.get('verified') === 'true'; + const passwordReset = searchParams.get('passwordReset') === 'true'; + + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const [emailNotVerified, setEmailNotVerified] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [isResending, setIsResending] = useState(false); + + const handleResendConfirmation = async () => { + setIsResending(true); + try { + const response = await fetch('/api/auth/resend-confirmation', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email }), + }); + const data = await response.json(); + if (response.ok) { + setError(''); + setEmailNotVerified(false); + alert('Confirmation email sent! Please check your inbox.'); + } else { + alert(data.error || 'Failed to resend confirmation email'); + } + } catch (err) { + alert('An error occurred. Please try again.'); + } finally { + setIsResending(false); + } + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + setEmailNotVerified(false); + setIsLoading(true); + + try { + // First check if email is verified + const checkResponse = await fetch('/api/auth/check-verification', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }), + }); + const checkData = await checkResponse.json(); + + if (!checkData.exists) { + setError('Invalid email or password'); + setIsLoading(false); + return; + } + + if (!checkData.passwordValid) { + setError('Invalid email or password'); + setIsLoading(false); + return; + } + + if (!checkData.verified) { + setEmailNotVerified(true); + setIsLoading(false); + return; + } + + // Email is verified, proceed with login + const result = await signIn('credentials', { + email, + password, + redirect: false, + }); + + if (result?.error) { + setError('Invalid email or password'); + } else { + router.push(callbackUrl); + router.refresh(); + } + } catch (err) { + setError('An error occurred. Please try again.'); + } finally { + setIsLoading(false); + } + }; + + return ( +
+
+
+

+ Sign in to your account +

+

+ Or{' '} + + create a new account + +

+
+
+ {registered && ( +
+

+ Account created successfully! Please check your email to confirm your account before signing in. +

+
+ )} + {verified && ( +
+

+ Email verified successfully! You can now sign in. +

+
+ )} + {passwordReset && ( +
+

+ Password reset successfully! You can now sign in with your new password. +

+
+ )} + {emailNotVerified && ( +
+

+ Please verify your email address before signing in. Check your inbox for a confirmation email. +

+ +
+ )} + {error && ( +
+

{error}

+
+ )} +
+
+ + { + setEmail(e.target.value); + // Clear email verification error when email changes + if (emailNotVerified) { + setEmailNotVerified(false); + } + }} + className="mt-1" + placeholder="you@example.com" + /> +
+
+ + setPassword(e.target.value)} + className="mt-1" + placeholder="โ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ข" + /> +
+
+ +
+ +
+
+
+
+ ); +} + diff --git a/viewer-frontend/app/page.tsx b/viewer-frontend/app/page.tsx new file mode 100644 index 0000000..15134ac --- /dev/null +++ b/viewer-frontend/app/page.tsx @@ -0,0 +1,236 @@ +import { prisma } from '@/lib/db'; +import { HomePageContent } from './HomePageContent'; +import { Photo } from '@prisma/client'; +import { serializePhotos, serializePeople, serializeTags } from '@/lib/serialize'; + +async function getAllPeople() { + try { + return await prisma.person.findMany({ + select: { + id: true, + first_name: true, + last_name: true, + middle_name: true, + maiden_name: true, + date_of_birth: true, + created_date: true, + }, + orderBy: [ + { first_name: 'asc' }, + { last_name: 'asc' }, + ], + }); + } catch (error: any) { + // Handle corrupted data errors (P2023) + if (error?.code === 'P2023' || error?.message?.includes('Conversion failed')) { + console.warn('Corrupted person data detected, attempting fallback query'); + try { + // Try with minimal fields first + return await prisma.person.findMany({ + select: { + id: true, + first_name: true, + last_name: true, + // Exclude potentially corrupted optional fields + }, + orderBy: [ + { first_name: 'asc' }, + { last_name: 'asc' }, + ], + }); + } catch (fallbackError: any) { + console.error('Fallback person query also failed:', fallbackError); + // Return empty array as last resort to prevent page crash + return []; + } + } + // Re-throw if it's a different error + throw error; + } +} + +async function getAllTags() { + try { + return await prisma.tag.findMany({ + select: { + id: true, + tag_name: true, + created_date: true, + }, + orderBy: { tag_name: 'asc' }, + }); + } catch (error: any) { + // Handle corrupted data errors (P2023) + if (error?.code === 'P2023' || error?.message?.includes('Conversion failed')) { + console.warn('Corrupted tag data detected, attempting fallback query'); + try { + // Try with minimal fields + return await prisma.tag.findMany({ + select: { + id: true, + tag_name: true, + // Exclude potentially corrupted date field + }, + orderBy: { tag_name: 'asc' }, + }); + } catch (fallbackError: any) { + console.error('Fallback tag query also failed:', fallbackError); + // Return empty array as last resort to prevent page crash + return []; + } + } + // Re-throw if it's a different error + throw error; + } +} + +export default async function HomePage() { + // Fetch photos from database + // Note: Make sure DATABASE_URL is set in .env file + let photos: any[] = []; // Using any to handle select-based query return type + let error: string | null = null; + + try { + // Fetch first page of photos (30 photos) for initial load + // Infinite scroll will load more as user scrolls + // Try to load with date fields first, fallback if corrupted data exists + let photosBase; + try { + // Use raw query to read dates as strings and convert manually to avoid Prisma conversion issues + const photosRaw = await prisma.$queryRaw>` + SELECT + id, + path, + filename, + date_added, + date_taken, + processed, + media_type + FROM photos + WHERE processed = true + ORDER BY date_taken DESC, id DESC + LIMIT 30 + `; + + photosBase = photosRaw.map(photo => ({ + id: photo.id, + path: photo.path, + filename: photo.filename, + date_added: new Date(photo.date_added), + date_taken: photo.date_taken ? new Date(photo.date_taken) : null, + processed: photo.processed, + media_type: photo.media_type, + })); + } catch (dateError: any) { + // If date fields are corrupted, load without them and use fallback values + // Check for P2023 error code or various date conversion error messages + const isDateError = dateError?.code === 'P2023' || + dateError?.message?.includes('Conversion failed') || + dateError?.message?.includes('Inconsistent column data') || + dateError?.message?.includes('Could not convert value'); + + if (isDateError) { + console.warn('Corrupted date data detected, loading photos without date fields'); + photosBase = await prisma.photo.findMany({ + where: { processed: true }, + select: { + id: true, + path: true, + filename: true, + processed: true, + media_type: true, + // Exclude date fields due to corruption + }, + orderBy: { id: 'desc' }, + take: 30, + }); + // Add fallback date values + photosBase = photosBase.map(photo => ({ + ...photo, + date_added: new Date(), + date_taken: null, + })); + } else { + throw dateError; + } + } + + // If base query works, load faces separately + const photoIds = photosBase.map(p => p.id); + const faces = await prisma.face.findMany({ + where: { photo_id: { in: photoIds } }, + select: { + id: true, + photo_id: true, + person_id: true, + location: true, + confidence: true, + quality_score: true, + is_primary_encoding: true, + detector_backend: true, + model_name: true, + face_confidence: true, + exif_orientation: true, + pose_mode: true, + yaw_angle: true, + pitch_angle: true, + roll_angle: true, + landmarks: true, + identified_by_user_id: true, + excluded: true, + Person: { + select: { + id: true, + first_name: true, + last_name: true, + middle_name: true, + maiden_name: true, + date_of_birth: true, + created_date: true, + }, + }, + // Exclude encoding field (Bytes) to avoid P2023 conversion errors + }, + }); + + // Combine the data manually + photos = photosBase.map(photo => ({ + ...photo, + Face: faces.filter(face => face.photo_id === photo.id), + })) as any; + } catch (err) { + error = err instanceof Error ? err.message : 'Failed to load photos'; + console.error('Error loading photos:', err); + } + + // Fetch people and tags for search + const [people, tags] = await Promise.all([ + getAllPeople(), + getAllTags(), + ]); + + return ( +
+ + {error ? ( +
+

Error loading photos

+

{error}

+

+ Make sure DATABASE_URL is configured in your .env file +

+
+ ) : ( + + )} +
+ ); +} diff --git a/viewer-frontend/app/photo/[id]/page.tsx b/viewer-frontend/app/photo/[id]/page.tsx new file mode 100644 index 0000000..1fffb17 --- /dev/null +++ b/viewer-frontend/app/photo/[id]/page.tsx @@ -0,0 +1,106 @@ +import { notFound } from 'next/navigation'; +import { PhotoViewerClient } from '@/components/PhotoViewerClient'; +import { prisma } from '@/lib/db'; +import { serializePhoto, serializePhotos } from '@/lib/serialize'; + +async function getPhoto(id: number) { + try { + const photo = await prisma.photo.findUnique({ + where: { id }, + include: { + faces: { + include: { + person: true, + }, + }, + photoTags: { + include: { + tag: true, + }, + }, + }, + }); + + return photo ? serializePhoto(photo) : null; + } catch (error) { + console.error('Error fetching photo:', error); + return null; + } +} + +async function getPhotosByIds(ids: number[]) { + try { + const photos = await prisma.photo.findMany({ + where: { + id: { in: ids }, + processed: true, + }, + include: { + faces: { + include: { + person: true, + }, + }, + photoTags: { + include: { + tag: true, + }, + }, + }, + orderBy: { dateTaken: 'desc' }, + }); + + return serializePhotos(photos); + } catch (error) { + console.error('Error fetching photos:', error); + return []; + } +} + +export default async function PhotoPage({ + params, + searchParams, +}: { + params: Promise<{ id: string }>; + searchParams: Promise<{ photos?: string; index?: string }>; +}) { + const { id } = await params; + const { photos: photosParam, index: indexParam } = await searchParams; + const photoId = parseInt(id, 10); + + if (isNaN(photoId)) { + notFound(); + } + + // Get the current photo + const photo = await getPhoto(photoId); + if (!photo) { + notFound(); + } + + // If we have a photo list context, fetch all photos for client-side navigation + let allPhotos: typeof photo[] = []; + let currentIndex = 0; + + if (photosParam && indexParam) { + const photoIds = photosParam.split(',').map(Number).filter(Boolean); + const parsedIndex = parseInt(indexParam, 10); + + if (photoIds.length > 0 && !isNaN(parsedIndex)) { + allPhotos = await getPhotosByIds(photoIds); + // Maintain the original order from the photoIds array + const photoMap = new Map(allPhotos.map((p) => [p.id, p])); + allPhotos = photoIds.map((id) => photoMap.get(id)).filter(Boolean) as typeof photo[]; + currentIndex = parsedIndex; + } + } + + return ( + + ); +} + diff --git a/viewer-frontend/app/register/page.tsx b/viewer-frontend/app/register/page.tsx new file mode 100644 index 0000000..8f64cd9 --- /dev/null +++ b/viewer-frontend/app/register/page.tsx @@ -0,0 +1,185 @@ +'use client'; + +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import Link from 'next/link'; +import { isValidEmail } from '@/lib/utils'; + +export default function RegisterPage() { + const router = useRouter(); + const [name, setName] = useState(''); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [error, setError] = useState(''); + const [isLoading, setIsLoading] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + + if (!name || name.trim().length === 0) { + setError('Name is required'); + return; + } + + if (!email || !isValidEmail(email)) { + setError('Please enter a valid email address'); + return; + } + + if (password !== confirmPassword) { + setError('Passwords do not match'); + return; + } + + if (password.length < 6) { + setError('Password must be at least 6 characters'); + return; + } + + setIsLoading(true); + + try { + const response = await fetch('/api/auth/register', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ email, password, name }), + }); + + const data = await response.json(); + + if (!response.ok) { + setError(data.error || 'Failed to create account'); + return; + } + + // Registration successful - clear form and redirect to login + setName(''); + setEmail(''); + setPassword(''); + setConfirmPassword(''); + setError(''); + + router.push('/login?registered=true'); + } catch (err) { + setError('An error occurred. Please try again.'); + } finally { + setIsLoading(false); + } + }; + + return ( +
+
+
+

+ Create your account +

+

+ Or{' '} + + sign in to your existing account + +

+
+
+ {error && ( +
+

{error}

+
+ )} +
+
+ + setName(e.target.value)} + className="mt-1" + placeholder="Your full name" + /> +
+
+ + setEmail(e.target.value)} + className="mt-1" + placeholder="you@example.com" + /> +
+
+ + setPassword(e.target.value)} + className="mt-1" + placeholder="โ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ข" + /> +

+ Must be at least 6 characters +

+
+
+ + setConfirmPassword(e.target.value)} + className="mt-1" + placeholder="โ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ข" + /> +
+
+ +
+ +
+
+
+
+ ); +} + + + diff --git a/viewer-frontend/app/reset-password/page.tsx b/viewer-frontend/app/reset-password/page.tsx new file mode 100644 index 0000000..dccaeaf --- /dev/null +++ b/viewer-frontend/app/reset-password/page.tsx @@ -0,0 +1,184 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import Link from 'next/link'; + +export default function ResetPasswordPage() { + const router = useRouter(); + const searchParams = useSearchParams(); + const token = searchParams.get('token'); + + const [password, setPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [error, setError] = useState(''); + const [success, setSuccess] = useState(false); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + if (!token) { + setError('Invalid reset link. Please request a new password reset.'); + } + }, [token]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + + if (!token) { + setError('Invalid reset link. Please request a new password reset.'); + return; + } + + if (password.length < 6) { + setError('Password must be at least 6 characters'); + return; + } + + if (password !== confirmPassword) { + setError('Passwords do not match'); + return; + } + + setIsLoading(true); + + try { + const response = await fetch('/api/auth/reset-password', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token, password }), + }); + + const data = await response.json(); + + if (!response.ok) { + setError(data.error || 'Failed to reset password'); + } else { + setSuccess(true); + // Redirect to login after 3 seconds + setTimeout(() => { + router.push('/login?passwordReset=true'); + }, 3000); + } + } catch (err) { + setError('An error occurred. Please try again.'); + } finally { + setIsLoading(false); + } + }; + + if (success) { + return ( +
+
+
+

+ Password reset successful +

+

+ Your password has been reset successfully. Redirecting to login... +

+
+
+

+ You can now sign in with your new password. +

+
+
+ + Go to login page + +
+
+
+ ); + } + + return ( +
+
+
+

+ Reset your password +

+

+ Enter your new password below +

+
+
+ {error && ( +
+

{error}

+
+ )} +
+
+ + setPassword(e.target.value)} + className="mt-1" + placeholder="โ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ข" + /> +

+ Must be at least 6 characters +

+
+
+ + setConfirmPassword(e.target.value)} + className="mt-1" + placeholder="โ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ข" + /> +
+
+ +
+ +
+
+ + Back to login + +
+
+
+
+ ); +} + + + + + + diff --git a/viewer-frontend/app/search/SearchContent.tsx b/viewer-frontend/app/search/SearchContent.tsx new file mode 100644 index 0000000..edd02a6 --- /dev/null +++ b/viewer-frontend/app/search/SearchContent.tsx @@ -0,0 +1,208 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { useSearchParams, useRouter } from 'next/navigation'; +import { Person, Tag, Photo } from '@prisma/client'; +import { FilterPanel, SearchFilters } from '@/components/search/FilterPanel'; +import { PhotoGrid } from '@/components/PhotoGrid'; +import { Button } from '@/components/ui/button'; +import { Loader2 } from 'lucide-react'; + +interface SearchContentProps { + people: Person[]; + tags: Tag[]; +} + +export function SearchContent({ people, tags }: SearchContentProps) { + const router = useRouter(); + const searchParams = useSearchParams(); + + // Initialize filters from URL params + const [filters, setFilters] = useState(() => { + const peopleParam = searchParams.get('people'); + const tagsParam = searchParams.get('tags'); + const dateFromParam = searchParams.get('dateFrom'); + const dateToParam = searchParams.get('dateTo'); + const mediaTypeParam = searchParams.get('mediaType'); + const favoritesOnlyParam = searchParams.get('favoritesOnly'); + + return { + people: peopleParam ? peopleParam.split(',').map(Number).filter(Boolean) : [], + tags: tagsParam ? tagsParam.split(',').map(Number).filter(Boolean) : [], + dateFrom: dateFromParam ? new Date(dateFromParam) : undefined, + dateTo: dateToParam ? new Date(dateToParam) : undefined, + mediaType: (mediaTypeParam as 'all' | 'photos' | 'videos') || 'all', + favoritesOnly: favoritesOnlyParam === 'true', + }; + }); + + const [photos, setPhotos] = useState([]); + const [loading, setLoading] = useState(false); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + + // Update URL when filters change + useEffect(() => { + const params = new URLSearchParams(); + + if (filters.people.length > 0) { + params.set('people', filters.people.join(',')); + } + if (filters.tags.length > 0) { + params.set('tags', filters.tags.join(',')); + } + if (filters.dateFrom) { + params.set('dateFrom', filters.dateFrom.toISOString().split('T')[0]); + } + if (filters.dateTo) { + params.set('dateTo', filters.dateTo.toISOString().split('T')[0]); + } + if (filters.mediaType && filters.mediaType !== 'all') { + params.set('mediaType', filters.mediaType); + } + if (filters.favoritesOnly) { + params.set('favoritesOnly', 'true'); + } + + const newUrl = params.toString() ? `/search?${params.toString()}` : '/search'; + router.replace(newUrl, { scroll: false }); + }, [filters, router]); + + // Reset to page 1 when filters change + useEffect(() => { + setPage(1); + }, [filters.people, filters.tags, filters.dateFrom, filters.dateTo, filters.mediaType, filters.favoritesOnly]); + + // Fetch photos when filters or page change + useEffect(() => { + const fetchPhotos = async () => { + setLoading(true); + try { + const params = new URLSearchParams(); + + if (filters.people.length > 0) { + params.set('people', filters.people.join(',')); + if (filters.peopleMode) { + params.set('peopleMode', filters.peopleMode); + } + } + if (filters.tags.length > 0) { + params.set('tags', filters.tags.join(',')); + if (filters.tagsMode) { + params.set('tagsMode', filters.tagsMode); + } + } + if (filters.dateFrom) { + params.set('dateFrom', filters.dateFrom.toISOString().split('T')[0]); + } + if (filters.dateTo) { + params.set('dateTo', filters.dateTo.toISOString().split('T')[0]); + } + if (filters.mediaType && filters.mediaType !== 'all') { + params.set('mediaType', filters.mediaType); + } + if (filters.favoritesOnly) { + params.set('favoritesOnly', 'true'); + } + params.set('page', page.toString()); + params.set('pageSize', '30'); + + const response = await fetch(`/api/search?${params.toString()}`); + if (!response.ok) throw new Error('Failed to search photos'); + + const data = await response.json(); + setPhotos(data.photos); + setTotal(data.total); + } catch (error) { + console.error('Error searching photos:', error); + } finally { + setLoading(false); + } + }; + + fetchPhotos(); + }, [filters, page]); + + const hasActiveFilters = + filters.people.length > 0 || + filters.tags.length > 0 || + filters.dateFrom || + filters.dateTo || + (filters.mediaType && filters.mediaType !== 'all') || + filters.favoritesOnly === true; + + return ( +
+ {/* Filter Panel */} +
+ +
+ + {/* Results */} +
+ {loading ? ( +
+ +
+ ) : ( + <> +
+
+ {total === 0 ? ( + hasActiveFilters ? ( + 'No photos found matching your filters' + ) : ( + 'Start by selecting filters to search photos' + ) + ) : ( + `Found ${total} photo${total !== 1 ? 's' : ''}` + )} +
+
+ + {photos.length > 0 ? ( + <> + + {total > 30 && ( +
+ + + Page {page} of {Math.ceil(total / 30)} + + +
+ )} + + ) : hasActiveFilters ? ( +
+

No photos found matching your filters

+
+ ) : ( +
+

Select filters to search photos

+
+ )} + + )} +
+
+ ); +} + diff --git a/viewer-frontend/app/search/page.tsx b/viewer-frontend/app/search/page.tsx new file mode 100644 index 0000000..93f6347 --- /dev/null +++ b/viewer-frontend/app/search/page.tsx @@ -0,0 +1,114 @@ +import { Suspense } from 'react'; +import { prisma } from '@/lib/db'; +import { SearchContent } from './SearchContent'; +import { PhotoGrid } from '@/components/PhotoGrid'; + +async function getAllPeople() { + try { + return await prisma.person.findMany({ + select: { + id: true, + first_name: true, + last_name: true, + middle_name: true, + maiden_name: true, + date_of_birth: true, + created_date: true, + }, + orderBy: [ + { first_name: 'asc' }, + { last_name: 'asc' }, + ], + }); + } catch (error: any) { + // Handle corrupted data errors (P2023) + if (error?.code === 'P2023' || error?.message?.includes('Conversion failed')) { + console.warn('Corrupted person data detected, attempting fallback query'); + try { + // Try with minimal fields first + return await prisma.person.findMany({ + select: { + id: true, + first_name: true, + last_name: true, + // Exclude potentially corrupted optional fields + }, + orderBy: [ + { first_name: 'asc' }, + { last_name: 'asc' }, + ], + }); + } catch (fallbackError: any) { + console.error('Fallback person query also failed:', fallbackError); + // Return empty array as last resort to prevent page crash + return []; + } + } + // Re-throw if it's a different error + throw error; + } +} + +async function getAllTags() { + try { + return await prisma.tag.findMany({ + select: { + id: true, + tag_name: true, + created_date: true, + }, + orderBy: { tag_name: 'asc' }, + }); + } catch (error: any) { + // Handle corrupted data errors (P2023) + if (error?.code === 'P2023' || error?.message?.includes('Conversion failed')) { + console.warn('Corrupted tag data detected, attempting fallback query'); + try { + // Try with minimal fields + return await prisma.tag.findMany({ + select: { + id: true, + tag_name: true, + // Exclude potentially corrupted date field + }, + orderBy: { tag_name: 'asc' }, + }); + } catch (fallbackError: any) { + console.error('Fallback tag query also failed:', fallbackError); + // Return empty array as last resort to prevent page crash + return []; + } + } + // Re-throw if it's a different error + throw error; + } +} + +export default async function SearchPage() { + const [people, tags] = await Promise.all([ + getAllPeople(), + getAllTags(), + ]); + + return ( +
+
+

+ Search Photos +

+

+ Find photos by people, dates, and tags +

+
+ + +
Loading search...
+ + }> + +
+
+ ); +} + diff --git a/viewer-frontend/app/test-images/page.tsx b/viewer-frontend/app/test-images/page.tsx new file mode 100644 index 0000000..f4a444c --- /dev/null +++ b/viewer-frontend/app/test-images/page.tsx @@ -0,0 +1,122 @@ +import { PhotoGrid } from '@/components/PhotoGrid'; +import { Photo } from '@prisma/client'; + +/** + * Test page to verify direct URL access vs API proxy + * + * This page displays test images to verify: + * 1. Direct access works for HTTP/HTTPS URLs + * 2. API proxy works for file system paths + * 3. Automatic detection is working correctly + */ +export default function TestImagesPage() { + // Test photos with different path types + const testPhotos: Photo[] = [ + // Test 1: Direct URL access (public test image) + { + id: 9991, + path: 'https://picsum.photos/800/600?random=1', + filename: 'test-direct-url-1.jpg', + dateAdded: new Date(), + dateTaken: null, + processed: true, + file_hash: 'test-hash-1', + media_type: 'image', + }, + // Test 2: Another direct URL + { + id: 9992, + path: 'https://picsum.photos/800/600?random=2', + filename: 'test-direct-url-2.jpg', + dateAdded: new Date(), + dateTaken: null, + processed: true, + file_hash: 'test-hash-2', + media_type: 'image', + }, + // Test 3: File system path (will use API proxy) + { + id: 9993, + path: '/nonexistent/path/test.jpg', + filename: 'test-file-system.jpg', + dateAdded: new Date(), + dateTaken: null, + processed: true, + file_hash: 'test-hash-3', + media_type: 'image', + }, + ]; + + return ( +
+
+

+ Image Source Test Page +

+

+ Testing direct URL access vs API proxy +

+
+ +
+

+ Test Instructions: +

+
    +
  1. Open browser DevTools (F12) โ†’ Network tab
  2. +
  3. Filter by "Img" to see image requests
  4. +
  5. + Direct URL images should show requests to{' '} + + picsum.photos + +
  6. +
  7. + File system images should show requests to{' '} + + /api/photos/... + +
  8. +
+
+ +
+

Test Images

+
+

+ Images 1-2: Direct URL access (should load from + picsum.photos) +

+

+ Image 3: File system path (will use API proxy, may + show error if file doesn't exist) +

+
+
+ + + +
+

Path Details:

+
+ {testPhotos.map((photo) => ( +
+
+ ID {photo.id}: {photo.path} +
+
+ Type:{' '} + {photo.path.startsWith('http://') || + photo.path.startsWith('https://') + ? 'โœ… Direct URL' + : '๐Ÿ“ File System (API Proxy)'} +
+
+ ))} +
+
+
+ ); +} + + diff --git a/viewer-frontend/app/upload/UploadContent.tsx b/viewer-frontend/app/upload/UploadContent.tsx new file mode 100644 index 0000000..edc8adb --- /dev/null +++ b/viewer-frontend/app/upload/UploadContent.tsx @@ -0,0 +1,367 @@ +'use client'; + +import { useState, useCallback, useRef } from 'react'; +import { useSession } from 'next-auth/react'; +import { Button } from '@/components/ui/button'; +import { Upload, X, CheckCircle2, AlertCircle, Loader2, Play, Pause } from 'lucide-react'; + +interface UploadedFile { + file: File; + preview: string; + id: string; + status: 'pending' | 'uploading' | 'success' | 'error'; + error?: string; +} + +interface FilePreviewItemProps { + uploadedFile: UploadedFile; + onRemove: (id: string) => void; +} + +function FilePreviewItem({ uploadedFile, onRemove }: FilePreviewItemProps) { + const videoRef = useRef(null); + const [isPlaying, setIsPlaying] = useState(false); + const isVideo = uploadedFile.file.type.startsWith('video/'); + + const togglePlay = useCallback(async (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + const video = videoRef.current; + if (!video) return; + + try { + if (video.paused) { + await video.play(); + setIsPlaying(true); + } else { + video.pause(); + setIsPlaying(false); + } + } catch (error) { + console.error('Error playing video:', error); + // If play() fails, try with muted + try { + video.muted = true; + await video.play(); + setIsPlaying(true); + } catch (mutedError) { + console.error('Error playing video even when muted:', mutedError); + } + } + }, []); + + return ( +
+ {isVideo ? ( + <> +