diff --git a/.agents/agent_assets_metadata.toml b/.agents/agent_assets_metadata.toml new file mode 100644 index 00000000..29a20467 --- /dev/null +++ b/.agents/agent_assets_metadata.toml @@ -0,0 +1,14 @@ +uploads = [] +generated = [] + +[[outputs]] +id = "OQ6_BDrejb-HuZQVwzvnR" +uri = "file://.local/stack-analyse-polly.md" +type = "text" +title = "Stack-Analyse & Migrationsbericht: Polly → Canonical Stack" + +[[outputs]] +id = "32j2umQnmIB66UTjwerbv" +uri = "file://.local/polly-canonical-stack-abgleich.md" +type = "text" +title = "Polly vs. Canonical Stack — Release-Risikobewertung" diff --git a/.canvas/assets/asset_-595277047.png b/.canvas/assets/asset_-595277047.png new file mode 100644 index 00000000..0c44680e Binary files /dev/null and b/.canvas/assets/asset_-595277047.png differ diff --git a/.canvas/assets/asset_559079389.png b/.canvas/assets/asset_559079389.png new file mode 100644 index 00000000..6211cc32 Binary files /dev/null and b/.canvas/assets/asset_559079389.png differ diff --git a/.env.example b/.env.example index 3a430325..89331698 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,8 @@ # Polly - Environment Variables Template # Copy this file to .env and fill in your values +# +# Variables prefixed with # are optional/commented out. +# Remove the # to activate them. # ============================================ # REQUIRED @@ -8,71 +11,128 @@ # PostgreSQL Database URL DATABASE_URL=postgresql://polly:your_password@localhost:5432/polly -# Session secret (minimum 32 characters, use a random string) +# Session secret (minimum 32 characters, use: openssl rand -base64 32) SESSION_SECRET=change-this-to-a-secure-random-string-at-least-32-chars # ============================================ -# APPLICATION URLs +# APPLICATION URL # ============================================ -# Public URL of your application +# Public URL of your application (used for OIDC redirects, email links, sharing) APP_URL=http://localhost:5000 -VITE_APP_URL=http://localhost:5000 + +# Legacy aliases (supported for backward compatibility, use APP_URL instead): +# BASE_URL, VITE_APP_URL # ============================================ # EMAIL (SMTP) - Optional # ============================================ # SMTP Server Configuration -SMTP_HOST=smtp.example.com -SMTP_PORT=587 -SMTP_SECURE=false -SMTP_USER=your-email@example.com -SMTP_PASSWORD=your-email-password +# SMTP_HOST=smtp.example.com +# SMTP_PORT=587 +# SMTP_SECURE=false +# SMTP_USER=your-email@example.com +# SMTP_PASSWORD=your-email-password + +# Sender address and name for outgoing emails +# FROM_EMAIL=noreply@yourdomain.com +# FROM_NAME=Polly -# From address for outgoing emails -EMAIL_FROM=noreply@yourdomain.com +# Legacy alias: EMAIL_FROM (same as FROM_EMAIL) +# Legacy alias: SMTP_PASS (same as SMTP_PASSWORD) # ============================================ # KEYCLOAK OIDC - Optional # ============================================ # Enable enterprise SSO with Keycloak -KEYCLOAK_REALM=your-realm -KEYCLOAK_CLIENT_ID=polly -KEYCLOAK_CLIENT_SECRET=your-client-secret -KEYCLOAK_AUTH_SERVER_URL=https://keycloak.example.com +# KEYCLOAK_REALM=your-realm +# KEYCLOAK_CLIENT_ID=polly +# KEYCLOAK_CLIENT_SECRET=your-client-secret +# KEYCLOAK_AUTH_SERVER_URL=https://keycloak.example.com + +# Full OIDC issuer URL (auto-derived from REALM + AUTH_SERVER_URL if not set) +# KEYCLOAK_ISSUER_URL=https://keycloak.example.com/realms/your-realm + +# Custom SSO button label (shown on login page instead of "Login with Keycloak") +# SSO_BUTTON_LABEL=Kita Hub Login + +# Hide the local username+password login form (default: false = form visible) +# Set to "true" to hide the local form when SSO is the primary login method +# HIDE_LOGIN_FORM=true + +# Legacy alias: KEYCLOAK_URL (same as KEYCLOAK_AUTH_SERVER_URL) + +# ============================================ +# AI ASSISTANT - Optional (GWDG SAIA / OpenAI-compatible) +# ============================================ + +# AI API endpoint (OpenAI-compatible, e.g., GWDG SAIA) +# AI_API_URL=https://saia.2.rahtiapp.fi/v1 + +# AI API key — when set via ENV, AI chat is auto-enabled (no admin toggle needed) +# AI_API_KEY=your-ai-api-key + +# Fallback AI API key (used when primary key hits rate limit HTTP 429) +# AI_API_KEY_FALLBACK=your-fallback-ai-api-key + +# AI model name (default: llama-3.3-70b-instruct) +# AI_MODEL=llama-3.3-70b-instruct # ============================================ # SECURITY SCANNING - Optional # ============================================ # ClamAV Antivirus (for file upload scanning) -CLAMAV_ENABLED=false -CLAMAV_HOST=localhost -CLAMAV_PORT=3310 +# CLAMAV_ENABLED=false +# CLAMAV_HOST=localhost +# CLAMAV_PORT=3310 -# Pentest-Tools.com API (for vulnerability scanning) -PENTEST_TOOLS_API_KEY=your-api-key-here +# Pentest-Tools.com Pro API (for vulnerability scanning) +# PENTEST_TOOLS_API_TOKEN=your-api-token-here # ============================================ -# DOCKER COMPOSE ONLY +# DOCKER / INITIAL ADMIN - Optional # ============================================ -# PostgreSQL credentials (only used by docker-compose) +# PostgreSQL credentials (only used by docker-compose bundled PostgreSQL) POSTGRES_USER=polly POSTGRES_PASSWORD=polly_secret POSTGRES_DB=polly +# Initial admin account (created/updated on Docker start) +# ADMIN_USERNAME=admin +# ADMIN_EMAIL=admin@polly.local +# ADMIN_PASSWORD=Admin123! + # Seed demo data on first start (true/false) -SEED_DEMO_DATA=false +# SEED_DEMO_DATA=false # ============================================ # ADVANCED # ============================================ # Node environment (development, production) -NODE_ENV=production +# NODE_ENV=production + +# Server port (default: 5000) +# PORT=5000 + +# Enable SSL for database connections (e.g., managed PostgreSQL with TLS) +# DATABASE_SSL=true + +# Force secure cookies even without HTTPS APP_URL (e.g., behind TLS-terminating proxy) +# FORCE_HTTPS=true + +# Logging level: debug, info, warn, error (default: info in production, debug in development) +# LOG_LEVEL=info + +# Chromium path for PDF export (auto-detected in Docker) +# PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium + +# Disable WCAG default theme enforcement without branding.local.json +# POLLY_WCAG_OVERRIDE=true -# Port (default: 5000) -PORT=5000 +# Custom header value for E2E test mode (default: polly-e2e-test-mode) +# TEST_MODE_SECRET=polly-e2e-test-mode diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 76bdc406..4e162223 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -53,22 +53,51 @@ variables: GITHUB_REPO_URL: "https://github.com/manfredsteger/polly.git" # Reusable script for cloning from GitHub (avoids git init issues) -# Note: Clears working directory first to avoid conflicts with downloaded artifacts +# Preserves node_modules from build artifact if present, clears everything else # Uses GITHUB_REPO_URL variable - override in GitLab CI/CD settings for your fork .github_clone: &github_clone + - '[ -d node_modules ] && mv node_modules /tmp/_nm_preserve || true' - rm -rf /tmp/polly-src 2>/dev/null || true - find . -mindepth 1 -delete 2>/dev/null || rm -rf ./* ./.[!.]* 2>/dev/null || true - git clone --depth=1 "${GITHUB_REPO_URL}" /tmp/polly-src + - rm -rf /tmp/polly-src/.git - cp -r /tmp/polly-src/. . - rm -rf /tmp/polly-src + - '[ -d /tmp/_nm_preserve ] && mv /tmp/_nm_preserve node_modules || true' -# Reusable script for npm install with cleanup (fixes error -116 and TAR_ENTRY_ERROR) +# Reusable script for npm install with retry logic +# Skips if node_modules already exists (from build artifact) +# Retries up to 3 times with 10s pause to handle NFS/ESTALE errors on Kubernetes runners .npm_install: &npm_install - - rm -rf node_modules 2>/dev/null || true - - npm cache clean --force 2>/dev/null || true - - rm -rf /tmp/.npm 2>/dev/null || true - - mkdir -p /tmp/.npm - - npm ci --no-audit --no-fund + - | + if [ -d node_modules ] && [ "$(ls -A node_modules 2>/dev/null)" ]; then + echo "=== Using existing node_modules (from build artifact) ===" + else + echo "=== No node_modules found, running npm ci with retry ===" + npm_install_attempt=1 + npm_install_max=3 + while [ $npm_install_attempt -le $npm_install_max ]; do + echo "--- npm ci attempt $npm_install_attempt/$npm_install_max ---" + rm -rf node_modules 2>/dev/null || true + npm cache clean --force 2>/dev/null || true + rm -rf /tmp/.npm 2>/dev/null || true + mkdir -p /tmp/.npm + if npm ci --no-audit --no-fund; then + echo "--- npm ci succeeded on attempt $npm_install_attempt ---" + break + fi + echo "--- npm ci failed on attempt $npm_install_attempt ---" + if [ $npm_install_attempt -lt $npm_install_max ]; then + echo "Waiting 10s before retry..." + sleep 10 + fi + npm_install_attempt=$((npm_install_attempt + 1)) + done + if [ $npm_install_attempt -gt $npm_install_max ]; then + echo "=== npm ci failed after $npm_install_max attempts ===" + exit 1 + fi + fi # Reusable script for waiting until PostgreSQL is ready (fixes flaky tests) .wait_for_postgres: &wait_for_postgres @@ -109,7 +138,10 @@ build: image: node:${NODE_VERSION} retry: max: 2 - when: unknown_failure + when: + - unknown_failure + - script_failure + - runner_system_failure before_script: - apt-get update && apt-get install -y git - *github_clone @@ -119,15 +151,20 @@ build: artifacts: paths: - dist/ + - node_modules/ expire_in: 1 hour typescript-check: stage: test image: node:${NODE_VERSION} - dependencies: [] + dependencies: + - build retry: max: 2 - when: unknown_failure + when: + - unknown_failure + - script_failure + - runner_system_failure before_script: - apt-get update && apt-get install -y git - *github_clone @@ -142,7 +179,9 @@ validate-translations: dependencies: [] retry: max: 2 - when: unknown_failure + when: + - unknown_failure + - runner_system_failure before_script: - apt-get update && apt-get install -y git - *github_clone @@ -153,10 +192,14 @@ validate-translations: unit-tests: stage: test image: node:${NODE_VERSION} - dependencies: [] + dependencies: + - build retry: max: 2 - when: unknown_failure + when: + - unknown_failure + - script_failure + - runner_system_failure before_script: - apt-get update && apt-get install -y git postgresql-client - *github_clone @@ -183,10 +226,14 @@ unit-tests: integration-tests: stage: test image: node:${NODE_VERSION} - dependencies: [] + dependencies: + - build retry: max: 2 - when: unknown_failure + when: + - unknown_failure + - script_failure + - runner_system_failure before_script: - apt-get update && apt-get install -y git postgresql-client - *github_clone @@ -209,10 +256,14 @@ integration-tests: api-tests: stage: test image: node:${NODE_VERSION} - dependencies: [] + dependencies: + - build retry: max: 2 - when: unknown_failure + when: + - unknown_failure + - script_failure + - runner_system_failure before_script: - apt-get update && apt-get install -y git postgresql-client - *github_clone @@ -235,10 +286,14 @@ api-tests: auth-tests: stage: test image: node:${NODE_VERSION} - dependencies: [] + dependencies: + - build retry: max: 2 - when: unknown_failure + when: + - unknown_failure + - script_failure + - runner_system_failure before_script: - apt-get update && apt-get install -y git postgresql-client - *github_clone @@ -261,10 +316,14 @@ auth-tests: poll-tests: stage: test image: node:${NODE_VERSION} - dependencies: [] + dependencies: + - build retry: max: 2 - when: unknown_failure + when: + - unknown_failure + - script_failure + - runner_system_failure before_script: - apt-get update && apt-get install -y git postgresql-client - *github_clone @@ -287,17 +346,18 @@ poll-tests: e2e-tests: stage: test image: mcr.microsoft.com/playwright:v1.52.0-noble - dependencies: [] + dependencies: + - build retry: max: 2 - when: unknown_failure + when: + - unknown_failure + - script_failure + - runner_system_failure before_script: - - rm -rf /tmp/polly-src 2>/dev/null || true - - git clone --depth=1 "${GITHUB_REPO_URL}" /tmp/polly-src - - cp -r /tmp/polly-src/. . - - rm -rf /tmp/polly-src + - apt-get update && apt-get install -y git postgresql-client curl + - *github_clone - mkdir -p playwright-report test-results - - apt-get update && apt-get install -y postgresql-client curl services: - postgres:15 variables: @@ -311,16 +371,10 @@ e2e-tests: NODE_ENV: "test" npm_config_cache: "/tmp/.npm" script: - - rm -rf node_modules 2>/dev/null || true - - npm cache clean --force 2>/dev/null || true - - rm -rf /tmp/.npm 2>/dev/null || true - - mkdir -p /tmp/.npm - - npm ci --no-audit --no-fund - # Wait for PostgreSQL to be ready before db:push + - *npm_install - *wait_for_postgres - npm run db:push - npm run dev & - # Wait for dev server to be ready (replaces unreliable sleep 15) - *wait_for_server - npx playwright test --reporter=list --timeout=60000 || true artifacts: @@ -334,10 +388,14 @@ e2e-tests: security-audit: stage: security image: node:${NODE_VERSION} - dependencies: [] + dependencies: + - build retry: max: 2 - when: unknown_failure + when: + - unknown_failure + - script_failure + - runner_system_failure before_script: - apt-get update && apt-get install -y git - *github_clone diff --git a/.replit b/.replit index 9c11fba7..39be4485 100644 --- a/.replit +++ b/.replit @@ -39,6 +39,10 @@ waitForPort = 5000 localPort = 5000 externalPort = 80 +[[ports]] +localPort = 5904 +externalPort = 3000 + [agent] integrations = ["javascript_sendgrid:1.0.0"] @@ -47,3 +51,9 @@ integrations = ["javascript_sendgrid:1.0.0"] [userenv.shared] ADMIN_USERNAME = "manfredsteger" ADMIN_EMAIL = "manfred.steger@ifp.bayern.de" +AI_API_URL = "https://saia.gwdg.de/v1" +AI_MODEL = "llama-3.3-70b-instruct" + +[postMerge] +path = "scripts/post-merge.sh" +timeoutMs = 20000 diff --git a/CHANGELOG.md b/CHANGELOG.md index 623f742e..bd86db13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,18 +7,83 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +*(no pending changes)* + +--- + +## [0.1.0-beta.2] - 2026-04-10 + ### Added + +#### AI-Powered Poll Creation (GWDG KISSKI Integration) +- AI assistant for poll creation via natural language input (German & English) +- **Free tier included** for all Polly installations (GWDG SAIA / KISSKI) +- Voice input (speech-to-text) via GWDG Whisper API with real-time waveform visualization +- Audio transcription endpoint (`POST /api/v1/ai/transcribe`) with ffmpeg WebM→MP3 conversion +- Whisper hallucination filter (removes artifacts like "Vielen Dank fürs Zuschauen") +- Large audio file chunking (>20 MB split into 150 s segments for transcription) +- Microphone permission handling with user-friendly error messages +- Drag-and-drop reordering for organization poll slots and AI suggestions +- AI suggestion preview with inline editing, follow-up refinement, and one-click apply +- AI rate limiting with configurable guest/user limits via admin panel + +#### Schedule Poll Improvements +- **Video conference URL**: Optional Videokonferenz-Link for schedule polls (shown in confirmation email and ICS) +- Chronological sorting of date options in finalization view +- Finalize button visible directly on the best-voted option +- Labeled voting links in calendar event descriptions (direct Yes/Maybe/No links) + +#### Notifications & Email +- **End Poll notifications for all poll types** (was Schedule-only before): + - Survey: winning option text shown in email ("Festgelegtes Ergebnis: …") + - Organization: compact slot summary in email (up to 5 slots with filled/capacity) + - Schedule: sends full date + time + ICS if a date was previously confirmed; generic notification otherwise +- Frontend "End Poll" notify-participants toggle is now wired to the backend (was disconnected in beta.1) +- Creator email always included in all finalization notification recipient lists +- Email deliverability improvements: correct bulk headers, List-Unsubscribe, precedence settings + +#### Administration & Configuration +- System-wide default language setting in admin panel +- Matrix Chat Integration admin panel — "Coming Soon" placeholder (planned for v1.0) +- Poll owner now gets admin/finalize features directly on the public poll URL (no separate admin link required) +- Finalize button visibility fixed for all poll types and for poll owners + +#### Export & Calendar +- CSV export now includes participant summary and total rows at the bottom +- ICS calendar: CANCELLED events removed from exports and email attachments +- ICS email status corrected (METHOD:REQUEST with TENTATIVE/CONFIRMED prefixes) +- Calendar events automatically cleaned up (old options marked CANCELLED, removed on re-export) + +#### Developer Experience +- OpenAPI 3.0 API documentation (`docs/openapi.yaml`) — full endpoint reference +- Architecture documentation (`docs/ARCHITECTURE.md`) updated with current structure - Accessibility (a11y) testing with axe-core and Playwright (WCAG 2.1 AA compliance) -- README badges for Build Status, License, TypeScript version, and Docker +- README badges: Build Status, License, TypeScript version, Docker +- Flutter integration guide (`docs/FLUTTER_INTEGRATION.md`) ### Fixed +- Multi-day organization poll time slots from AI suggestions now correctly preserve start/end times +- Date regex for AI-generated slots now accepts single-digit day/month formats (e.g., `5.9.2026`) +- Permissions-Policy header updated to allow microphone access (`microphone=(self)`) - Pentest-Tools scan ID extraction (now correctly reads `created_id` from API response) -- Missing admin warning translations (defaultAdminAccount, defaultAdminWarning, createNewAdminWarning) -- Docker schema migration for `language_preference` column in ensureSchema.ts +- Missing admin warning translations (`defaultAdminAccount`, `defaultAdminWarning`, `createNewAdminWarning`) +- Docker schema migration for `language_preference` column in `ensureSchema.ts` +- Duplicate date/time display removed from finalized poll confirmation view +- Vote deselection: re-submitting a vote form no longer clears already-selected options +- Missing translations added for vote editing and multiple UI strings (de + en) +- Test suite race conditions in auth tests resolved — all 55 tests pass reliably + +### Security +- File content validation: actual byte-level content type verified, not just MIME header +- Strengthened password hashing (increased bcrypt rounds) +- Inline scripts removed from HTML pages (CSP hardening) +- Stricter cookie settings (HttpOnly, Secure, SameSite enforcement) +- AI API keys proxied server-side — never exposed to the frontend +- `microphone` Permissions-Policy restricted to same-origin only --- -## [0.1.0-beta.1] - 2025-02-XX +## [0.1.0-beta.1] - 2025-02-24 ### Added @@ -73,7 +138,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Demo data seeding**: `SEED_DEMO_DATA=true` for instant testing - **Comprehensive test suite**: 200+ unit/integration tests with Vitest - **E2E testing**: Playwright-based end-to-end tests -- **API documentation**: OpenAPI 3.0 specification in `docs/openapi.yaml` - **CI/CD pipelines**: GitHub Actions and GitLab CI workflows ### Security @@ -101,9 +165,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 | Version | Date | Description | |---------|------|-------------| -| 0.1.0-beta.1 | 2025-02-XX | Initial beta release | +| 0.1.0-beta.2 | 2026-04-10 | AI integration, schedule improvements, notification fixes | +| 0.1.0-beta.1 | 2025-02-24 | Initial beta release | --- -[Unreleased]: https://github.com/manfredsteger/polly/compare/v0.1.0-beta.1...HEAD +[Unreleased]: https://github.com/manfredsteger/polly/compare/v0.1.0-beta.2...HEAD +[0.1.0-beta.2]: https://github.com/manfredsteger/polly/compare/v0.1.0-beta.1...v0.1.0-beta.2 [0.1.0-beta.1]: https://github.com/manfredsteger/polly/releases/tag/v0.1.0-beta.1 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f2fab947..f6deb6ac 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,7 +15,7 @@ Vielen Dank für Ihr Interesse, zu Polly beizutragen! Diese Anleitung erklärt, ### Voraussetzungen -- Node.js 20+ +- Node.js 22+ - PostgreSQL 15+ - npm oder yarn @@ -47,22 +47,31 @@ polly/ ├── client/ # React Frontend │ └── src/ │ ├── components/ # UI-Komponenten +│ │ └── ai/ # AI Chat Widget & Voice Input │ ├── pages/ # Seitenkomponenten │ ├── lib/ # Utilities -│ └── hooks/ # React Hooks +│ ├── hooks/ # React Hooks +│ └── locales/ # i18n Übersetzungen (de.json, en.json) ├── server/ # Express Backend -│ ├── routes.ts # API-Routen -│ ├── storage.ts # Datenbank-Interface +│ ├── routes/ # API-Routen (modular) +│ │ ├── admin.ts # Admin-Endpunkte +│ │ ├── auth.ts # Authentifizierung +│ │ ├── polls.ts # Umfragen-CRUD +│ │ ├── votes.ts # Abstimmungen +│ │ ├── ai.ts # AI-Assistent & Transkription +│ │ └── ... # Weitere Route-Module │ ├── services/ # Business-Logik +│ │ ├── aiService.ts # AI Poll-Erstellung (GWDG SAIA) +│ │ └── whisperService.ts # Spracheingabe (Whisper API) +│ ├── storage.ts # Datenbank-Interface │ └── tests/ # Backend-Tests -│ ├── auth/ # Authentifizierungstests -│ ├── api/ # API-Sicherheitstests -│ ├── polls/ # Umfragen-Tests -│ ├── security/ # Sicherheitstests -│ └── fixtures/ # Test-Daten & Helpers ├── shared/ # Geteilte TypeScript-Typen │ └── schema.ts # Drizzle-Schema & Zod-Validierung -└── e2e/ # Playwright E2E-Tests +├── docs/ # Dokumentation +│ ├── openapi.yaml # API-Spezifikation +│ └── SELF-HOSTING.md # Deployment-Anleitung +├── Dockerfile # Production Container +└── docker-compose.yml # Docker Setup ``` ## Tests schreiben diff --git a/DOCKERHUB.md b/DOCKERHUB.md index fc372ee7..8953a57b 100644 --- a/DOCKERHUB.md +++ b/DOCKERHUB.md @@ -41,6 +41,8 @@ docker run -d \ - **Admin Dashboard**: User management, branding, security scanning, email templates - **WCAG 2.1 AA**: Automatic color contrast auditing and correction - **ClamAV Integration**: Optional virus scanning for file uploads +- **AI Poll Assistant**: Create polls via natural language with GWDG SAIA (OpenAI-compatible API) +- **Voice Input**: Speech-to-text for AI chat using Whisper - **GDPR Compliant**: All data stays on your server, no external tracking ## Available Tags @@ -50,7 +52,7 @@ docker run -d \ | `manfredsteger/polly:latest` | Latest stable release | | `manfredsteger/polly:beta` | Latest beta release | | `manfredsteger/polly:rc` | Latest release candidate | -| `manfredsteger/polly:` | Specific version (e.g., `0.1.0-beta.1`) | +| `manfredsteger/polly:` | Specific version (e.g., `0.1.0-beta.2`) | ## Environment Variables @@ -61,6 +63,9 @@ docker run -d \ | `DATABASE_URL` | PostgreSQL connection string | Auto-configured in Docker Compose | | `SESSION_SECRET` | Session encryption key (min 32 chars) | Change in production! | +> **External / Managed PostgreSQL:** Set `DATABASE_URL` directly (e.g. `postgresql://user:pass@your-db-host:5432/polly`) — the entrypoint automatically parses host and port from it. Special characters in passwords are supported (URL-encoded). +> The `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB`, and `POSTGRES_HOST` variables are only used by the integrated Docker Compose setup and are ignored when `DATABASE_URL` is set. + ### Application | Variable | Description | Default | @@ -105,6 +110,15 @@ Start with ClamAV: docker compose --profile clamav up -d ``` +### AI Assistant (Optional) + +| Variable | Description | Default | +|----------|-------------|---------| +| `AI_API_URL` | OpenAI-compatible API endpoint | — | +| `AI_API_KEY` | API key for AI services | — | +| `AI_API_KEY_FALLBACK` | Fallback key (on HTTP 429) | — | +| `AI_MODEL` | AI model name | `llama-3.3-70b-instruct` | + ## Docker Compose The included `docker-compose.yml` provides a zero-config setup with PostgreSQL: diff --git a/Dockerfile b/Dockerfile index a3db414a..c9145950 100644 --- a/Dockerfile +++ b/Dockerfile @@ -70,6 +70,7 @@ FROM node:22-slim AS production # Install minimal runtime dependencies # - canvas/pdfkit: libcairo2 libpango-1.0-0 libjpeg62-turbo libgif7 libpixman-1-0 # - Puppeteer: chromium (system installation, not bundled) +# - ffmpeg: audio conversion for AI voice transcription (WebM → MP3) # - Database: postgresql-client (for pg_isready) # - Utilities: wget for health checks # Note: Full apt reset to guarantee fresh package lists (no stale GPG signatures) @@ -84,6 +85,7 @@ RUN rm -rf /var/lib/apt/lists/* /var/cache/apt/* /etc/apt/apt.conf.d/docker-clea libgif7 \ libpixman-1-0 \ chromium \ + ffmpeg \ fonts-liberation \ postgresql-client \ wget \ @@ -116,10 +118,8 @@ COPY --from=builder /app/drizzle.config.ts ./ COPY --from=builder /app/vite.config.ts ./ COPY vitest.config.ts ./ -# Copy client source files needed by UI consistency tests (alertConsistency.test.ts) -COPY --from=builder /app/client/src/components ./client/src/components -COPY --from=builder /app/client/src/pages ./client/src/pages -COPY --from=builder /app/client/src/index.css ./client/src/index.css +# Copy client source files needed by UI tests (alertConsistency, i18n-hardcoded-strings) +COPY --from=builder /app/client/src ./client/src # Copy migrations for schema setup COPY migrations ./migrations diff --git a/README.md b/README.md index 333fd712..52bc657a 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,8 @@ make complete - **QR Code Sharing**: Easy poll distribution via QR codes - **Full Customization**: Theme colors, logo, site name via admin panel - **Dark Mode**: System-wide dark mode with admin defaults +- **AI Poll Assistant**: Create polls via natural language with AI-powered suggestions (GWDG SAIA integration) +- **Voice Input**: Speech-to-text for AI chat with real-time waveform visualization - **Transactional Slot Booking**: PostgreSQL row-level locking prevents overbooking in organization polls ### Authentication Options @@ -206,39 +208,94 @@ npm run dev ## ⚙️ Configuration -### Required Environment Variables - -```env -DATABASE_URL=postgresql://user:password@localhost:5432/polly -SESSION_SECRET=your-secure-random-string-min-32-chars -``` - -### Optional: Email (SMTP) - -```env -SMTP_HOST=smtp.example.com -SMTP_PORT=587 -SMTP_SECURE=false -SMTP_USER=your-email@example.com -SMTP_PASSWORD=your-email-password -EMAIL_FROM=noreply@yourdomain.com -``` - -### Optional: Keycloak OIDC - -```env -KEYCLOAK_REALM=your-realm -KEYCLOAK_CLIENT_ID=polly -KEYCLOAK_CLIENT_SECRET=your-client-secret -KEYCLOAK_AUTH_SERVER_URL=https://keycloak.example.com -``` - -### Application URLs - -```env -APP_URL=https://your-app-url.com -VITE_APP_URL=https://your-app-url.com -``` +> **Full reference**: See [`.env.example`](.env.example) for a ready-to-use template with all variables. +> **Production guide**: See [`docs/SELF-HOSTING.md`](docs/SELF-HOSTING.md) for deployment-specific configuration. + +### Required + +| Variable | Description | Example | +|----------|-------------|---------| +| `DATABASE_URL` | PostgreSQL connection URL | `postgresql://user:pass@host:5432/polly` | +| `SESSION_SECRET` | Session encryption key (min 32 chars). Generate with `openssl rand -base64 32` | `a1b2c3d4...` | + +### Application URL + +| Variable | Description | Example | +|----------|-------------|---------| +| `APP_URL` | Public URL of your application (used for OIDC redirects, email links, QR codes, sharing) | `https://poll.example.com` | + +> **Note:** `BASE_URL` and `VITE_APP_URL` are supported as legacy aliases. Use `APP_URL` for new deployments. + +### Email (SMTP) — Optional + +| Variable | Description | Default | +|----------|-------------|---------| +| `SMTP_HOST` | SMTP server hostname | — | +| `SMTP_PORT` | SMTP port | `587` | +| `SMTP_SECURE` | Use TLS (`true`/`false`) | `false` | +| `SMTP_USER` | SMTP username | — | +| `SMTP_PASSWORD` | SMTP password | — | +| `FROM_EMAIL` | Sender address for outgoing emails | `noreply@polly.example.com` | +| `FROM_NAME` | Sender display name | `Polly` | + +> **Legacy aliases:** `EMAIL_FROM` (same as `FROM_EMAIL`), `SMTP_PASS` (same as `SMTP_PASSWORD`) + +### Keycloak OIDC — Optional + +| Variable | Description | Example | +|----------|-------------|---------| +| `KEYCLOAK_REALM` | Keycloak realm name | `university` | +| `KEYCLOAK_CLIENT_ID` | Client ID | `polly` | +| `KEYCLOAK_CLIENT_SECRET` | Client secret | `secret-uuid` | +| `KEYCLOAK_AUTH_SERVER_URL` | Keycloak base URL | `https://keycloak.example.com` | +| `KEYCLOAK_ISSUER_URL` | Full OIDC issuer URL (auto-derived if not set) | `https://keycloak.example.com/realms/myrealm` | +| `SSO_BUTTON_LABEL` | Custom login button text (e.g. "Kita Hub Login"). Also configurable in Admin panel | — | +| `HIDE_LOGIN_FORM` | Hide local username+password login form when SSO is primary | `false` | + +> **Legacy alias:** `KEYCLOAK_URL` (same as `KEYCLOAK_AUTH_SERVER_URL`) + +### AI Assistant — Optional + +| Variable | Description | Default | +|----------|-------------|---------| +| `AI_API_URL` | OpenAI-compatible API endpoint | — | +| `AI_API_KEY` | API key. When set via ENV, AI chat is **auto-enabled** without admin toggle | — | +| `AI_API_KEY_FALLBACK` | Fallback key (used on HTTP 429 rate limit) | — | +| `AI_MODEL` | AI model name | `llama-3.3-70b-instruct` | + +### Security Scanning — Optional + +| Variable | Description | Default | +|----------|-------------|---------| +| `CLAMAV_ENABLED` | Enable ClamAV virus scanning for uploads | `false` | +| `CLAMAV_HOST` | ClamAV daemon hostname | `clamav` | +| `CLAMAV_PORT` | ClamAV daemon port | `3310` | +| `PENTEST_TOOLS_API_TOKEN` | Pentest-Tools.com Pro API token | — | + +### Docker / Initial Admin — Optional + +| Variable | Description | Default | +|----------|-------------|---------| +| `ADMIN_USERNAME` | Initial admin username | `admin` | +| `ADMIN_EMAIL` | Initial admin email | `admin@polly.local` | +| `ADMIN_PASSWORD` | Initial admin password | `Admin123!` | +| `SEED_DEMO_DATA` | Seed demo polls on first start | `false` | +| `POSTGRES_USER` | Bundled PostgreSQL user (docker-compose only) | `polly` | +| `POSTGRES_PASSWORD` | Bundled PostgreSQL password (docker-compose only) | `polly_secret` | +| `POSTGRES_DB` | Bundled PostgreSQL database (docker-compose only) | `polly` | + +### Advanced + +| Variable | Description | Default | +|----------|-------------|---------| +| `PORT` | Server port | `5000` | +| `NODE_ENV` | Node environment | `production` | +| `DATABASE_SSL` | Enable SSL for database connections | `false` | +| `FORCE_HTTPS` | Force secure cookies (behind TLS-terminating proxy) | auto-detect from `APP_URL` | +| `LOG_LEVEL` | Logging level: `debug`, `info`, `warn`, `error` | `info` (prod) / `debug` (dev) | +| `PUPPETEER_EXECUTABLE_PATH` | Chromium path for PDF export | auto-detected | +| `POLLY_WCAG_OVERRIDE` | Disable WCAG default theme enforcement | `false` | +| `TEST_MODE_SECRET` | Custom header value for E2E test mode | `polly-e2e-test-mode` | ## 🏗️ Tech Stack @@ -250,6 +307,7 @@ VITE_APP_URL=https://your-app-url.com | **Backend** | Express.js, TypeScript | | **Database** | PostgreSQL, Drizzle ORM | | **Auth** | Passport.js, express-session | +| **AI** | GWDG SAIA (OpenAI-compatible), Whisper | ## 📁 Project Structure @@ -306,6 +364,7 @@ Access the admin panel at `/admin` to customize: - Role-based access control - CSRF protection - Secure password hashing with bcrypt +- AI API key proxying (server-side only) ## 🐳 Docker Deployment @@ -409,7 +468,7 @@ Polly is currently in **Beta Phase** (Q1-Q2 2025). Our focus areas: | Priority | Feature | Status | |----------|---------|--------| | 🔐 | **Keycloak SSO (OIDC)** - Enterprise single sign-on integration | In Progress | -| 🤖 | **AI Voice Control** - Create polls via speech with GWDG KISSKI Free Tier | Planned | +| 🤖 | **AI Voice Control** - Create polls via speech with GWDG KISSKI Free Tier | ✅ Done | | 🔌 | **OpenAI-Compatible API** - Support for custom AI providers | Planned | | 💬 | **Matrix / Element Chatbot** - Create and manage polls directly from Matrix chat | Version 1.0 | | 🇪🇺 | **European DC Focus** - Simplified deployment for EU data centers | Version 1.0 | diff --git a/ROADMAP.md b/ROADMAP.md index 6d15fb04..747716d5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -25,42 +25,57 @@ The initial development phase focused on building a solid foundation for a self- --- -## 🧪 Beta Phase (Q1-Q2 2025) +## 🧪 Beta Phase (Q1 2025 – Q2 2026) The beta phase focuses on enterprise readiness, AI integration, and community feedback. ### Core Goals #### 1. Single Sign-On (SSO) with Keycloak OIDC -- [ ] Full Keycloak OIDC integration testing -- [ ] Automatic role mapping (User, Admin, Manager) +- [x] Keycloak OIDC integration (basic) +- [x] Automatic role mapping (User, Admin, Manager) +- [ ] Full Keycloak end-to-end integration testing - [ ] Session synchronization with identity provider - [ ] Documentation for enterprise SSO setup -#### 2. AI-Powered Voice Control (GWDG KISSKI Integration) -- [ ] Integration with [GWDG KISSKI](https://kisski.gwdg.de) AI services -- [ ] **Free Tier included** for all Polly installations -- [ ] Voice-controlled poll creation with speech-to-text -- [ ] AI agent-guided form completion (no manual input required) -- [ ] Natural language commands: "Erstelle eine Terminumfrage für nächste Woche" -- [ ] OpenAI-compatible provider support for custom deployments - -> **Partner:** GWDG (Gesellschaft für wissenschaftliche Datenverarbeitung mbH Göttingen) is the primary AI integration partner, providing free AI capabilities to all Polly users. - -#### 3. Community & Stability +#### 2. AI-Powered Poll Creation & Voice Control (GWDG KISSKI Integration) ✅ *Released in beta.2* +- [x] Integration with [GWDG KISSKI](https://kisski.gwdg.de) AI services +- [x] **Free Tier included** for all Polly installations +- [x] Voice-controlled poll creation with speech-to-text (GWDG Whisper API) +- [x] AI agent-guided form completion (no manual input required) +- [x] Natural language commands: "Erstelle eine Terminumfrage für nächste Woche" +- [x] Drag-and-drop reordering of AI-suggested slots +- [x] Follow-up refinement via chat ("Füge noch Montag Abend hinzu") +- [x] AI rate limiting (configurable guest/user limits via admin panel) +- [x] OpenAI-compatible provider support for custom deployments + +#### 3. Schedule Poll Enhancements ✅ *Released in beta.2* +- [x] Video conference URL field (optional, shown in emails and ICS) +- [x] Chronological date sorting in finalization view +- [x] Finalize button visible on best-voted option +- [x] Labeled voting links in calendar event descriptions +- [x] CANCELLED events removed from ICS exports + +#### 4. Notification Improvements ✅ *Released in beta.2* +- [x] End Poll notifications for all poll types (not just Schedule) +- [x] Survey finalization email shows winning option text +- [x] Organization poll "End Poll" email shows slot summary +- [x] Creator always included in finalization email recipients +- [x] Frontend "End Poll" notify toggle wired to backend + +#### 5. Community & Stability - [ ] Community feedback collection and issue tracking -- [ ] Bug fixes and stability improvements - [ ] Performance optimization for large-scale deployments - [ ] Extended documentation for self-hosting -#### 4. Additional Integrations +#### 6. Additional Integrations - [ ] Additional language packs (community contributions welcome) - [ ] Webhook support for external automation - [ ] Enhanced calendar integrations --- -## 🎯 Version 1.0 (Target: H2 2025) +## 🎯 Version 1.0 (Target: H2 2026) The 1.0 release will focus on meeting the needs of European data centers and simplifying enterprise deployment. @@ -75,13 +90,12 @@ The 1.0 release will focus on meeting the needs of European data centers and sim #### Enterprise Features - [ ] Advanced analytics dashboard -- [ ] API rate limiting (admin-configurable) - [ ] Audit logging for compliance - [ ] Backup and restore utilities #### Mobile & Integrations -- [ ] Mobile app (React Native or Flutter) -- [ ] **Matrix / Element Chatbot** - Create and manage polls via Matrix messenger +- [ ] Mobile app (React Native or Flutter) — see `docs/FLUTTER_INTEGRATION.md` +- [ ] 🚧 **Matrix / Element Chatbot** *(Coming Soon)* - Create and manage polls via Matrix messenger - [ ] Bot account for self-hosted Matrix/Synapse servers - [ ] Poll creation via chat commands (e.g. `!poll create Terminumfrage ...`) - [ ] Vote notifications and reminders in Matrix rooms @@ -117,12 +131,13 @@ We welcome community input! If you have feature requests or suggestions: ## 📅 Release Timeline -| Phase | Target | Focus | -|-------|--------|-------| -| **Alpha** | 2024 - Q1 2025 | Core functionality, foundation | -| **Beta 0.1.0** | Q1-Q2 2025 | SSO, AI integration, stability | -| **Version 1.0** | H2 2025 | European DC support, enterprise features | +| Phase | Released | Focus | +|-------|----------|-------| +| **Alpha** | 2024 – Q1 2025 | Core functionality, foundation | +| **Beta 0.1.0-beta.1** | 2025-02-24 | Initial public beta — all poll types, auth, Docker | +| **Beta 0.1.0-beta.2** | 2026-04-10 | AI integration, schedule improvements, notification fixes | +| **Version 1.0** | Target: H2 2026 | European DC support, enterprise features | --- -*Last updated: February 2025* +*Last updated: April 2026* diff --git a/SECURITY.md b/SECURITY.md index d8c46f86..8fff98b4 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -40,6 +40,9 @@ Polly implements the following security measures: - **Server-side validation** with Zod schemas - **HTTP-only secure session cookies** - **Role-based access control** (User, Admin, Manager) +- **AI API key proxying** — Keys stored server-side only, never exposed to frontend +- **Permissions-Policy headers** — Microphone restricted to same-origin, camera/geolocation/payment disabled +- **Audio upload validation** — File type and size checks before AI transcription ## Security Best Practices for Self-Hosting @@ -49,6 +52,8 @@ Polly implements the following security measures: 4. **Restrict database access** to application server only 5. **Configure firewall rules** appropriately 6. **Enable audit logging** for compliance requirements +7. **Protect AI API keys** — Use environment variables, never commit keys to source control +8. **Monitor AI usage** — Check admin panel for unusual AI request patterns ## Acknowledgments diff --git a/client/index.html b/client/index.html index 14a8341c..1fb897b3 100644 --- a/client/index.html +++ b/client/index.html @@ -5,36 +5,7 @@ Polly - Open Source Polling System - +
diff --git a/client/public/branding-preload.js b/client/public/branding-preload.js new file mode 100644 index 00000000..df7a9530 --- /dev/null +++ b/client/public/branding-preload.js @@ -0,0 +1,28 @@ +(function() { + try { + var cached = localStorage.getItem('polly-branding-colors'); + if (cached) { + var colors = JSON.parse(cached); + var style = document.documentElement.style; + if (colors.primary) { + style.setProperty('--polly-orange', colors.primary); + style.setProperty('--primary', colors.primaryHSL); + } + if (colors.secondary) { + style.setProperty('--polly-blue', colors.secondary); + } + if (colors.schedule) { + style.setProperty('--color-schedule', colors.schedule); + style.setProperty('--color-schedule-light', colors.scheduleLight); + } + if (colors.survey) { + style.setProperty('--color-survey', colors.survey); + style.setProperty('--color-survey-light', colors.surveyLight); + } + if (colors.organization) { + style.setProperty('--color-organization', colors.organization); + style.setProperty('--color-organization-light', colors.organizationLight); + } + } + } catch (e) {} +})(); diff --git a/client/src/App.tsx b/client/src/App.tsx index 3ccaa6f8..fa64e20c 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,14 +1,22 @@ -import { lazy, Suspense } from "react"; +import { lazy, Suspense, useState } from "react"; import { Switch, Route } from "wouter"; import { queryClient } from "./lib/queryClient"; -import { QueryClientProvider } from "@tanstack/react-query"; +import { QueryClientProvider, useQueryClient } from "@tanstack/react-query"; import { Toaster } from "@/components/ui/toaster"; import { TooltipProvider } from "@/components/ui/tooltip"; -import { AuthProvider } from "@/contexts/AuthContext"; +import { AuthProvider, useAuth } from "@/contexts/AuthContext"; import { CustomizationProvider } from "@/contexts/CustomizationContext"; import { ThemeProvider } from "@/contexts/ThemeContext"; import Layout from "@/components/Layout"; import { Skeleton } from "@/components/ui/skeleton"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { ShieldAlert } from "lucide-react"; +import { apiRequest } from "@/lib/queryClient"; +import { useTranslation } from "react-i18next"; import Home from "@/pages/home"; import NotFound from "@/pages/not-found"; @@ -19,7 +27,6 @@ const Poll = lazy(() => import("@/pages/poll")); const PollSuccess = lazy(() => import("@/pages/poll-success")); const VoteSuccess = lazy(() => import("@/pages/vote-success")); const VoteEdit = lazy(() => import("@/pages/vote-edit")); -const Dashboard = lazy(() => import("@/pages/dashboard")); const Admin = lazy(() => import("@/pages/admin")); const Login = lazy(() => import("@/pages/login")); const MyPolls = lazy(() => import("@/pages/my-polls")); @@ -52,7 +59,6 @@ function Router() { - @@ -66,6 +72,88 @@ function Router() { ); } +function ForcePasswordChangeGuard({ children }: { children: React.ReactNode }) { + const { user, logout } = useAuth(); + const { t } = useTranslation(); + const qc = useQueryClient(); + const [currentPassword, setCurrentPassword] = useState(''); + const [newPassword, setNewPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + + if (!user?.isInitialAdmin) return <>{children}; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + if (newPassword !== confirmPassword) { + setError(t('forcePassword.passwordsMismatch')); + return; + } + if (newPassword.length < 8) { + setError(t('forcePassword.passwordTooShort')); + return; + } + setLoading(true); + try { + const res = await apiRequest('POST', '/api/v1/auth/change-password', { currentPassword, newPassword }); + if (!res.ok) { + const data = await res.json(); + setError(data.error || t('forcePassword.error')); + return; + } + qc.invalidateQueries({ queryKey: ['/api/v1/auth/me'] }); + } catch { + setError(t('forcePassword.error')); + } finally { + setLoading(false); + } + }; + + return ( + <> + {children} + {}}> + e.preventDefault()} onEscapeKeyDown={(e) => e.preventDefault()}> + +
+ + {t('forcePassword.title')} +
+ + {t('forcePassword.description')} + +
+
+ {error && ( + + {error} + + )} +
+ + setCurrentPassword(e.target.value)} required /> +
+
+ + setNewPassword(e.target.value)} required /> +
+
+ + setConfirmPassword(e.target.value)} required /> +
+
+ + +
+
+
+
+ + ); +} + function App() { return ( @@ -73,10 +161,12 @@ function App() { - - - - + + + + + + diff --git a/client/src/components/Layout.tsx b/client/src/components/Layout.tsx index cbd59538..6d717ca4 100644 --- a/client/src/components/Layout.tsx +++ b/client/src/components/Layout.tsx @@ -62,10 +62,9 @@ export default function Layout({ children }: LayoutProps) { return ; }; - const rawSiteName = settings?.branding?.siteName ?? ''; - const rawSiteNameAccent = settings?.branding?.siteNameAccent ?? ''; - const siteName = (rawSiteName || rawSiteNameAccent) ? rawSiteName : 'Poll'; - const siteNameAccent = (rawSiteName || rawSiteNameAccent) ? rawSiteNameAccent : 'y'; + const siteName = settings?.branding?.siteName ?? ''; + const siteNameAccent = settings?.branding?.siteNameAccent ?? ''; + const hasSiteTitle = !!(siteName || siteNameAccent); const logoUrl = settings?.branding?.logoUrl; const footerDescription = settings?.footer?.description || t('footer.defaultDescription'); const footerCopyright = settings?.footer?.copyrightText || t('footer.defaultCopyright'); @@ -75,7 +74,7 @@ export default function Layout({ children }: LayoutProps) { ]; return ( -
+
- {isAuthenticated && user && !user.emailVerified && ( + {isAuthenticated && user && !user.emailVerified && user.provider === 'local' && (
@@ -203,11 +204,13 @@ export default function Layout({ children }: LayoutProps) {
{logoUrl ? ( - {siteName} + {siteName ) : null} -

- {siteName}{siteNameAccent} -

+ {hasSiteTitle && ( +

+ {siteName}{siteNameAccent} +

+ )}

{footerDescription} diff --git a/client/src/components/LiveResultsView.tsx b/client/src/components/LiveResultsView.tsx index 0b501795..e932fa85 100644 --- a/client/src/components/LiveResultsView.tsx +++ b/client/src/components/LiveResultsView.tsx @@ -18,7 +18,8 @@ import { Eye, Clock, User, - Trophy + Trophy, + Lock } from 'lucide-react'; import { useLiveVoting } from '@/hooks/useLiveVoting'; import type { PollWithOptions, PollResults } from '@shared/schema'; @@ -79,6 +80,7 @@ export function LiveResultsView({ poll, publicToken, isAdminAccess = false }: Li } = useLiveVoting({ pollToken: publicToken, isPresenter: true, + adminToken: poll.adminToken, onResultsRefresh: handleResultsRefresh, onVoteFinalized: handleVoteFinalized, }); @@ -305,19 +307,24 @@ export function LiveResultsView({ poll, publicToken, isAdminAccess = false }: Li {poll.options.map(option => { const isWinner = winningOptionIds.has(option.id); + const isFinalOption = poll.finalOptionId != null && poll.finalOptionId > 0 && poll.finalOptionId === option.id; return (

- {isWinner && ( + {isFinalOption ? ( + + ) : isWinner ? ( - )} + ) : null}
+ {isFinalOption && ( + + {t('resultsChart.confirmed')} + + )}
); diff --git a/client/src/components/ResultsChart.tsx b/client/src/components/ResultsChart.tsx index 39730d45..e24c91a2 100644 --- a/client/src/components/ResultsChart.tsx +++ b/client/src/components/ResultsChart.tsx @@ -1,6 +1,17 @@ +import { MessageSquare } from "lucide-react"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; import { PollTypeBadge } from "@/components/ui/PollTypeBadge"; import { Progress } from "@/components/ui/progress"; import { Input } from "@/components/ui/input"; @@ -18,7 +29,13 @@ import { Mail, Pencil, Save, - ClipboardList + ClipboardList, + Lock, + Unlock, + CalendarCheck, + Loader2, + Video, + ExternalLink } from "lucide-react"; import Lightbox from "yet-another-react-lightbox"; import "yet-another-react-lightbox/styles.css"; @@ -27,6 +44,7 @@ import { useTranslation } from 'react-i18next'; import { Table } from "lucide-react"; import type { PollResults } from "@shared/schema"; import { useToast } from "@/hooks/use-toast"; +import { apiRequest } from "@/lib/queryClient"; import { formatScheduleOptionWithWeekday } from "@/lib/utils"; function FormattedOptionText({ text, startTime, locale = 'en' }: { text: string; startTime?: Date | string | null; locale?: string }) { @@ -41,11 +59,14 @@ function FormattedOptionText({ text, startTime, locale = 'en' }: { text: string; interface ResultsChartProps { results: PollResults; publicToken?: string; + adminToken?: string; isAdminAccess?: boolean; + isOwner?: boolean; onCapacityUpdate?: (optionId: number, newCapacity: number | null) => Promise; + onFinalize?: () => void; } -export function ResultsChart({ results, publicToken, isAdminAccess = false, onCapacityUpdate }: ResultsChartProps) { +export function ResultsChart({ results, publicToken, adminToken, isAdminAccess = false, isOwner = false, onCapacityUpdate, onFinalize }: ResultsChartProps) { const { poll, options, stats, participantCount } = results; const { toast } = useToast(); const { t, i18n } = useTranslation(); @@ -57,6 +78,81 @@ export function ResultsChart({ results, publicToken, isAdminAccess = false, onCa const [capacityError, setCapacityError] = useState(""); const isOrganization = poll.type === 'organization'; + const isSchedule = poll.type === 'schedule'; + const isFinalized = poll.finalOptionId != null && poll.finalOptionId > 0; + const [isFinalizingOption, setIsFinalizingOption] = useState(null); + const [confirmDialogOptionId, setConfirmDialogOptionId] = useState(null); + const [finalizeClosePoll, setFinalizeClosePoll] = useState(true); + const [finalizeNotify, setFinalizeNotify] = useState(true); + + const handleFinalize = async (optionId: number) => { + if (!adminToken) return; + setIsFinalizingOption(optionId); + try { + await apiRequest('POST', `/api/v1/polls/admin/${adminToken}/finalize`, { + optionId, + closePoll: finalizeClosePoll, + notifyParticipants: finalizeNotify, + }); + const parts: string[] = [isSchedule ? t('resultsChart.dateConfirmed') : t('resultsChart.resultConfirmed')]; + if (finalizeClosePoll) parts.push(t('resultsChart.pollClosed')); + if (finalizeNotify) parts.push(t('resultsChart.participantsNotified')); + toast({ title: t('common.success'), description: parts.join(' ') }); + onFinalize?.(); + } catch (error) { + toast({ title: t('common.error'), description: t('resultsChart.finalizeFailed'), variant: "destructive" }); + } finally { + setIsFinalizingOption(null); + setConfirmDialogOptionId(null); + } + }; + + const handleUnfinalize = async () => { + if (!adminToken) return; + setIsFinalizingOption(0); + try { + await apiRequest('POST', `/api/v1/polls/admin/${adminToken}/finalize`, { optionId: 0 }); + toast({ title: t('common.success'), description: isSchedule ? t('resultsChart.dateUnconfirmed') : t('resultsChart.resultUnconfirmed') }); + onFinalize?.(); + } catch (error) { + toast({ title: t('common.error'), description: t('resultsChart.finalizeFailed'), variant: "destructive" }); + } finally { + setIsFinalizingOption(null); + } + }; + + const handleExportICS = async () => { + if (!publicToken) return; + try { + const response = await fetch(`/api/v1/polls/${publicToken}/export/ics?lang=${i18n.language}`); + if (!response.ok) { + const data = await response.json().catch(() => null); + toast({ + title: t('common.error'), + description: data?.error || t('results.icsExportError'), + variant: "destructive", + }); + return; + } + const blob = await response.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + const disposition = response.headers.get('Content-Disposition'); + const filenameMatch = disposition?.match(/filename="?([^"]+)"?/); + a.download = filenameMatch?.[1] || 'poll.ics'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + } catch { + toast({ + title: t('common.error'), + description: t('results.icsExportError'), + variant: "destructive", + }); + } + }; const handleEditCapacity = (optionId: number, currentCapacity: number | null | undefined) => { setEditingCapacity(optionId); @@ -177,24 +273,30 @@ export function ResultsChart({ results, publicToken, isAdminAccess = false, onCa

- {isOrganization ? 'Eintragungen' : 'Ergebnisse'} + {isOrganization ? t('results.entries') : t('results.resultsTitle')}

{isOrganization - ? `${participantCount} ${participantCount === 1 ? 'Person hat' : 'Personen haben'} sich eingetragen` - : `${participantCount} ${participantCount === 1 ? 'Person hat' : 'Personen haben'} abgestimmt` + ? (participantCount === 1 ? t('results.personSignedUpSingular', { count: participantCount }) : t('results.personSignedUpPlural', { count: participantCount })) + : (participantCount === 1 ? t('results.personVotedSingular', { count: participantCount }) : t('results.personVotedPlural', { count: participantCount })) }

+ {isSchedule && isFinalized && ( + + )}
@@ -203,26 +305,26 @@ export function ResultsChart({ results, publicToken, isAdminAccess = false, onCa
- {isOrganization ? 'Gesamte Eintragungen' : 'Gesamte Abstimmungen'} + {isOrganization ? t('results.totalEntries') : t('results.totalVotes')} {isOrganization - ? `${results.votes.length} ${results.votes.length === 1 ? 'Eintragung' : 'Eintragungen'}` - : `${participantCount} ${participantCount === 1 ? 'Stimme' : 'Stimmen'}` + ? `${results.votes.length} ${results.votes.length === 1 ? t('results.entrySingular') : t('results.entriesPlural')}` + : `${participantCount} ${participantCount === 1 ? t('results.voteSingular') : t('results.votesPlural')}` }
- Teilnehmer: {participantCount} + {t('results.participantsLabel', { count: participantCount })}
{isOrganization - ? `Gesamte Eintragungen: ${results.votes.length}` - : `Gesamte Stimmen: ${results.votes.length}` + ? t('results.totalEntriesCount', { count: results.votes.length }) + : t('results.totalVotesCount', { count: results.votes.length }) }
@@ -230,6 +332,60 @@ export function ResultsChart({ results, publicToken, isAdminAccess = false, onCa + {/* Finalized Option Banner */} + {isFinalized && (() => { + const finalOption = options.find(opt => opt.id === poll.finalOptionId); + if (!finalOption) return null; + return ( + + +
+
+ +
+

+ {isSchedule ? t('resultsChart.confirmedDate') : t('resultsChart.confirmedResult')} +

+

+ +

+ {poll.videoConferenceUrl && ( +

+

+ )} +
+
+
+ {isSchedule && ( + + )} + {(isAdminAccess || isOwner) && adminToken && ( + + )} +
+
+
+
+ ); + })()} + {/* Best Option Highlight */} {bestOptionData && (
-

Beliebteste Option

+

{t('results.bestOption')}

- {bestOption.score} Punkte + {t('results.points', { count: bestOption.score })}

- Bewertung: Ja = 2 Punkte, Vielleicht = 1 Punkt, Nein = 0 Punkte + {t('results.scoringDescription')}

{bestOptionData.imageUrl && ( @@ -274,23 +430,59 @@ export function ResultsChart({ results, publicToken, isAdminAccess = false, onCa {bestOptionData.startTime && bestOptionData.endTime && (
- {new Date(bestOptionData.startTime).toLocaleDateString('de-DE')} + {new Date(bestOptionData.startTime).toLocaleDateString(i18n.language === 'de' ? 'de-DE' : 'en-US')} - {new Date(bestOptionData.startTime).toLocaleTimeString('de-DE', { + {new Date(bestOptionData.startTime).toLocaleTimeString(i18n.language === 'de' ? 'de-DE' : 'en-US', { hour: '2-digit', minute: '2-digit' - })} - {new Date(bestOptionData.endTime).toLocaleTimeString('de-DE', { + })} - {new Date(bestOptionData.endTime).toLocaleTimeString(i18n.language === 'de' ? 'de-DE' : 'en-US', { hour: '2-digit', minute: '2-digit' })}
)} + {poll.videoConferenceUrl && ( + + )} + {(isAdminAccess || isOwner) && adminToken && ( +
+ {isFinalized && poll.finalOptionId === bestOptionData.id ? ( + + + {t('resultsChart.confirmed')} + + ) : !isFinalized ? ( + + ) : null} +
+ )} )} - {/* Matrix View - Participants as rows, Options as columns */} - {!isOrganization && participants.length > 0 && ( + {/* Matrix View - Participants as rows, Options as columns (only non-freetext options) */} + {!isOrganization && participants.length > 0 && options.filter((o: any) => !o.isFreeText).length > 0 && ( @@ -306,7 +498,7 @@ export function ResultsChart({ results, publicToken, isAdminAccess = false, onCa {t('voting.participant')} - {options.map((option) => { + {options.filter((o: any) => !o.isFreeText).map((option) => { const isSchedule = poll.type === 'schedule' && option.startTime && option.endTime; return ( - {new Date(option.startTime!).toLocaleDateString('de-DE', { + {new Date(option.startTime!).toLocaleDateString(i18n.language === 'de' ? 'de-DE' : 'en-US', { weekday: 'short', day: '2-digit', month: '2-digit' })} - {new Date(option.startTime!).toLocaleTimeString('de-DE', { + {new Date(option.startTime!).toLocaleTimeString(i18n.language === 'de' ? 'de-DE' : 'en-US', { hour: '2-digit', minute: '2-digit' - })} - {new Date(option.endTime!).toLocaleTimeString('de-DE', { + })} - {new Date(option.endTime!).toLocaleTimeString(i18n.language === 'de' ? 'de-DE' : 'en-US', { hour: '2-digit', minute: '2-digit' })} @@ -350,7 +542,7 @@ export function ResultsChart({ results, publicToken, isAdminAccess = false, onCa {participant.name} - {options.map((option) => { + {options.filter((o: any) => !o.isFreeText).map((option) => { const vote = participant.votes.find((v: any) => v.optionId === option.id); const response = vote?.response; @@ -387,7 +579,7 @@ export function ResultsChart({ results, publicToken, isAdminAccess = false, onCa {t('results.total')} - {options.map((option) => { + {options.filter((o: any) => !o.isFreeText).map((option) => { const stat = stats.find(s => s.optionId === option.id); const yesCount = stat?.yesCount || 0; return ( @@ -407,6 +599,45 @@ export function ResultsChart({ results, publicToken, isAdminAccess = false, onCa )} + {/* Free-text answers section for survey polls */} + {poll.type === 'survey' && options.some((o: any) => o.isFreeText) && ( +
+ {options.filter((o: any) => o.isFreeText).map((option: any) => { + const answers = results.votes + .filter((v: any) => v.optionId === option.id && v.response === 'freetext' && v.freeTextAnswer) + .map((v: any) => ({ name: v.voterName, text: v.freeTextAnswer as string })); + return ( + + + + + {option.text} + +

{t('results.openAnswers')} · {answers.length} {answers.length === 1 ? t('voting.participant') : t('polls.participants')}

+
+ + {answers.length === 0 ? ( +

{t('results.noAnswersYet')}

+ ) : ( +
    + {answers.map((a, i) => ( +
  1. + {i + 1}. + {poll.resultsPublic && ( + [{a.name}] + )} + {a.text} +
  2. + ))} +
+ )} +
+
+ ); + })} +
+ )} + {/* Matrix View for Organization Polls - Participants as rows, Slots as columns */} {isOrganization && options.length > 0 && ( @@ -439,16 +670,16 @@ export function ResultsChart({ results, publicToken, isAdminAccess = false, onCa {option.startTime && option.endTime && ( - {new Date(option.startTime).toLocaleDateString('de-DE', { + {new Date(option.startTime).toLocaleDateString(i18n.language === 'de' ? 'de-DE' : 'en-US', { weekday: 'short', day: '2-digit', month: '2-digit' })}
- {new Date(option.startTime).toLocaleTimeString('de-DE', { + {new Date(option.startTime).toLocaleTimeString(i18n.language === 'de' ? 'de-DE' : 'en-US', { hour: '2-digit', minute: '2-digit' - })} - {new Date(option.endTime).toLocaleTimeString('de-DE', { + })} - {new Date(option.endTime).toLocaleTimeString(i18n.language === 'de' ? 'de-DE' : 'en-US', { hour: '2-digit', minute: '2-digit' })} @@ -508,7 +739,7 @@ export function ResultsChart({ results, publicToken, isAdminAccess = false, onCa ) : ( - Noch keine Eintragungen vorhanden + {t('results.noEntriesYet')} )} @@ -516,7 +747,7 @@ export function ResultsChart({ results, publicToken, isAdminAccess = false, onCa - Gesamt + {t('results.total')} {options.map((option) => { const slotVotes = results.votes.filter(v => v.optionId === option.id && v.response === 'yes'); @@ -541,7 +772,7 @@ export function ResultsChart({ results, publicToken, isAdminAccess = false, onCa {isOrganization ? ( - Slots und Eintragungen + {t('results.slotsAndEntries')}
@@ -646,7 +877,7 @@ export function ResultsChart({ results, publicToken, isAdminAccess = false, onCa <> {signupCount} / {capacity || '∞'} - {isFull && voll} + {isFull && {t('results.full')}} {isAdminAccess && onCapacityUpdate && (
); @@ -699,7 +930,7 @@ export function ResultsChart({ results, publicToken, isAdminAccess = false, onCa ) : ( - Detaillierte Ergebnisse + {t('results.detailedResults')}
@@ -707,34 +938,39 @@ export function ResultsChart({ results, publicToken, isAdminAccess = false, onCa - Option + {t('results.option')}
- Ja + {t('voting.yes')}
- Vielleicht + {t('voting.maybe')}
- Nein + {t('voting.no')}
- Punkte + {t('results.pointsHeader')} - (Ja=2, Vielleicht=1, Nein=0) + ({t('voting.yes')}=2, {t('voting.maybe')}=1, {t('voting.no')}=0)
+ {(isAdminAccess || isOwner) && adminToken && ( + + {isSchedule ? t('resultsChart.confirmColumn') : t('resultsChart.setResultColumn')} + + )} @@ -746,9 +982,10 @@ export function ResultsChart({ results, publicToken, isAdminAccess = false, onCa const yesPercent = total > 0 ? (stat.yesCount / total) * 100 : 0; const maybePercent = total > 0 ? (stat.maybeCount / total) * 100 : 0; const noPercent = total > 0 ? (stat.noCount / total) * 100 : 0; + const isFinalOption = isFinalized && poll.finalOptionId === stat.optionId; return ( - +
{/* Show image if available */} @@ -772,11 +1009,11 @@ export function ResultsChart({ results, publicToken, isAdminAccess = false, onCa
{option.startTime && option.endTime && (
- {new Date(option.startTime).toLocaleDateString('de-DE')} • {' '} - {new Date(option.startTime).toLocaleTimeString('de-DE', { + {new Date(option.startTime).toLocaleDateString(i18n.language === 'de' ? 'de-DE' : 'en-US')} • {' '} + {new Date(option.startTime).toLocaleTimeString(i18n.language === 'de' ? 'de-DE' : 'en-US', { hour: '2-digit', minute: '2-digit' - })} - {new Date(option.endTime).toLocaleTimeString('de-DE', { + })} - {new Date(option.endTime).toLocaleTimeString(i18n.language === 'de' ? 'de-DE' : 'en-US', { hour: '2-digit', minute: '2-digit' })} @@ -842,6 +1079,31 @@ export function ResultsChart({ results, publicToken, isAdminAccess = false, onCa )}
+ {(isAdminAccess || isOwner) && adminToken && ( + + {isFinalOption ? ( + + + {t('resultsChart.confirmed')} + + ) : ( + + )} + + )} ); })} @@ -970,7 +1232,7 @@ export function ResultsChart({ results, publicToken, isAdminAccess = false, onCa }}> {currentStat.yesCount} - Ja + {t('voting.yes')}
{/* Vielleicht Button Style */} @@ -987,7 +1249,7 @@ export function ResultsChart({ results, publicToken, isAdminAccess = false, onCa }}> ~ {currentStat.maybeCount} - Vielleicht + {t('voting.maybe')}
{/* Nein Button Style */} @@ -1004,7 +1266,7 @@ export function ResultsChart({ results, publicToken, isAdminAccess = false, onCa }}> {currentStat.noCount} - Nein + {t('voting.no')}
@@ -1018,7 +1280,7 @@ export function ResultsChart({ results, publicToken, isAdminAccess = false, onCa borderRadius: '8px' }}> - Punkte: {currentStat.score} + {t('results.pointsLabel', { count: currentStat.score })}
@@ -1032,6 +1294,65 @@ export function ResultsChart({ results, publicToken, isAdminAccess = false, onCa }, }} /> + + { if (!open) setConfirmDialogOptionId(null); }}> + + + {isSchedule ? t('resultsChart.confirmDialogTitle') : t('resultsChart.confirmResultDialogTitle')} + +
+

+ {(() => { + if (confirmDialogOptionId === null) return ''; + const opt = options.find(o => o.id === confirmDialogOptionId); + if (!opt) return ''; + const localeCode = i18n.language === 'de' ? 'de-DE' : 'en-US'; + const dateStr = opt.startTime ? new Date(opt.startTime).toLocaleDateString(localeCode, { weekday: 'long', day: '2-digit', month: 'long', year: 'numeric' }) : opt.text; + const timeStr = opt.startTime && opt.endTime + ? `${new Date(opt.startTime).toLocaleTimeString(localeCode, { hour: '2-digit', minute: '2-digit' })} – ${new Date(opt.endTime).toLocaleTimeString(localeCode, { hour: '2-digit', minute: '2-digit' })}` + : ''; + return isSchedule + ? t('resultsChart.confirmDialogDescription', { date: dateStr, time: timeStr }) + : t('resultsChart.confirmResultDialogDescription', { option: opt.text }); + })()} +

+
+ + +
+
+
+
+ + {t('common.cancel')} + { if (confirmDialogOptionId !== null) handleFinalize(confirmDialogOptionId); }} + disabled={isFinalizingOption !== null} + > + {isFinalizingOption !== null ? : } + {isSchedule ? t('resultsChart.confirmDate') : t('resultsChart.setResult')} + + +
+
); } diff --git a/client/src/components/SimpleImageVoting.tsx b/client/src/components/SimpleImageVoting.tsx index 63acb6fa..369503d0 100644 --- a/client/src/components/SimpleImageVoting.tsx +++ b/client/src/components/SimpleImageVoting.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import Lightbox from "yet-another-react-lightbox"; import "yet-another-react-lightbox/styles.css"; @@ -16,6 +16,15 @@ function FormattedOptionText({ text, startTime, locale = 'en' }: { text: string; return <>{text}; } +function TimeOnlyText({ text, startTime, locale = 'en' }: { text: string; startTime?: Date | string | null; locale?: string }) { + const startTimeStr = startTime instanceof Date ? startTime.toISOString() : startTime; + const formatted = formatScheduleOptionWithWeekday(text, startTimeStr, locale); + if (formatted.isSchedule && formatted.time) { + return <>{formatted.time}; + } + return <>{text}; +} + interface SimpleImageVotingProps { options: PollOption[]; onVote: (optionId: string, response: 'yes' | 'no' | 'maybe') => void; @@ -25,6 +34,141 @@ interface SimpleImageVotingProps { allowMaybe?: boolean; } +interface DayGroup { + dateKey: string; + dateLabel: string; + options: PollOption[]; +} + +function getDateSortKey(option: PollOption): string { + if (option.startTime) { + const d = new Date(option.startTime); + if (!isNaN(d.getTime())) { + return d.toISOString(); + } + } + const match = option.text.match(/^(\d{1,2})\.(\d{1,2})\.?(\d{2,4})?\s*/); + if (match) { + const day = match[1].padStart(2, '0'); + const month = match[2].padStart(2, '0'); + const year = match[3] || new Date().getFullYear().toString(); + const fullYear = year.length === 2 ? `20${year}` : year; + const timePart = option.text.replace(match[0], '').trim(); + const timeMatch = timePart.match(/^(\d{1,2}):(\d{2})/); + const time = timeMatch ? `${timeMatch[1].padStart(2, '0')}:${timeMatch[2]}` : '00:00'; + return `${fullYear}-${month}-${day}T${time}`; + } + return '9999-99-99'; +} + +function getDateKey(option: PollOption): string { + if (option.startTime) { + const d = new Date(option.startTime); + if (!isNaN(d.getTime())) { + return `${d.getFullYear()}-${(d.getMonth() + 1).toString().padStart(2, '0')}-${d.getDate().toString().padStart(2, '0')}`; + } + } + const match = option.text.match(/^(\d{1,2})\.(\d{1,2})\.?(\d{2,4})?\s*/); + if (match) { + const day = match[1].padStart(2, '0'); + const month = match[2].padStart(2, '0'); + const year = match[3] || new Date().getFullYear().toString(); + const fullYear = year.length === 2 ? `20${year}` : year; + return `${fullYear}-${month}-${day}`; + } + return ''; +} + +function getDateLabel(option: PollOption, locale: string): string { + const startTimeStr = option.startTime instanceof Date ? option.startTime.toISOString() : option.startTime; + const formatted = formatScheduleOptionWithWeekday(option.text, startTimeStr, locale); + if (formatted.isSchedule) { + return formatted.dateWithWeekday; + } + return option.text; +} + +function groupByDay(options: PollOption[], locale: string): DayGroup[] { + const sorted = [...options].sort((a, b) => getDateSortKey(a).localeCompare(getDateSortKey(b))); + + const groups: DayGroup[] = []; + let currentKey = ''; + let currentGroup: DayGroup | null = null; + + for (const opt of sorted) { + const key = getDateKey(opt); + if (key && key !== currentKey) { + currentKey = key; + currentGroup = { dateKey: key, dateLabel: getDateLabel(opt, locale), options: [] }; + groups.push(currentGroup); + } + if (currentGroup && key) { + currentGroup.options.push(opt); + } else { + const noDateGroup = groups.find(g => g.dateKey === ''); + if (noDateGroup) { + noDateGroup.options.push(opt); + } else { + groups.push({ dateKey: '', dateLabel: '', options: [opt] }); + } + } + } + + return groups; +} + +function VoteButtons({ + optionId, currentVote, allowMaybe, disabled, onVote, t, testIdSuffix +}: { + optionId: string | number; + currentVote?: 'yes' | 'no' | 'maybe'; + allowMaybe: boolean; + disabled: boolean; + onVote: (id: string | number, response: 'yes' | 'no' | 'maybe') => void; + t: (key: string) => string; + testIdSuffix: string; +}) { + return ( +
+ + {allowMaybe && ( + + )} + +
+ ); +} + export function SimpleImageVoting({ options, onVote, @@ -33,17 +177,24 @@ export function SimpleImageVoting({ adminPreview = false, allowMaybe = true }: SimpleImageVotingProps) { - const { i18n } = useTranslation(); + const { t, i18n } = useTranslation(); const [votes, setVotes] = useState>(existingVotes); const [lightboxOpen, setLightboxOpen] = useState(false); const [lightboxIndex, setLightboxIndex] = useState(0); - // Filter options that have images const imageOptions = options.filter(option => option.imageUrl && option.imageUrl.trim()); const textOptions = options.filter(option => !option.imageUrl || !option.imageUrl.trim()); const hasImages = imageOptions.length > 0; - // Create slides for lightbox + const isSchedulePoll = useMemo(() => { + return textOptions.some(opt => opt.startTime || /^\d{1,2}\.\d{1,2}\./.test(opt.text)); + }, [textOptions]); + + const dayGroups = useMemo(() => { + if (!isSchedulePoll) return null; + return groupByDay(textOptions, i18n.language); + }, [textOptions, isSchedulePoll, i18n.language]); + const slides = imageOptions.map(option => ({ src: option.imageUrl || '', alt: option.altText || option.text, @@ -51,7 +202,6 @@ export function SimpleImageVoting({ height: 900 })); - // Update votes when external votes change useEffect(() => { setVotes(existingVotes); }, [existingVotes]); @@ -67,21 +217,17 @@ export function SimpleImageVoting({ setLightboxOpen(true); }; - // Update lightbox when votes change useEffect(() => { - // Force re-render of lightbox when votes change if (lightboxOpen) { - // The lightbox will automatically re-render due to state change } }, [votes, lightboxOpen]); if (!hasImages && textOptions.length === 0) { - return
No options available
; + return
{t('voting.noOptionsAvailable')}
; } return (
- {/* Image grid with voting buttons */} {hasImages && (
{imageOptions.map((option, index) => { @@ -99,10 +245,9 @@ export function SimpleImageVoting({ />
- Click to enlarge + {t('voting.clickToEnlarge')}
- {/* Vote indicator */} {currentVote && (
- {currentVote === 'yes' ? 'Ja' : - currentVote === 'maybe' ? 'Vielleicht' : 'Nein'} + {currentVote === 'yes' ? t('voting.yes') : + currentVote === 'maybe' ? t('voting.maybe') : t('voting.no')}
)}
- {/* Title and voting buttons below each image */}

{!adminPreview && (
{allowMaybe && ( )}
)} @@ -166,62 +310,111 @@ export function SimpleImageVoting({
)} - {/* Text-only options */} {textOptions.length > 0 && ( -
-

Text Optionen

- {textOptions.map((option, textIndex) => { - const currentVote = votes[String(option.id)]; - const globalIndex = imageOptions.length + textIndex; - return ( -
-

- {!adminPreview && ( -
- - {allowMaybe && ( - +
+

{t('voting.textOptions')}

+ + {isSchedulePoll && dayGroups ? ( +
+ {dayGroups.map((group) => ( +
+ {group.dateLabel && ( +
+

{group.dateLabel}

+
+
+ )} +
+ {group.options.map((option) => { + const currentVote = votes[String(option.id)]; + const globalIndex = options.findIndex(o => o.id === option.id); + return ( +
+
+ +
+ {!adminPreview && ( + + )} +
+ ); + })} +
+
+ ))} +
+ ) : ( +
+ {textOptions.map((option, textIndex) => { + const currentVote = votes[String(option.id)]; + const globalIndex = imageOptions.length + textIndex; + return ( +
+

+ {!adminPreview && ( +
+ + {allowMaybe && ( + + )} + +
)} -
- )} -
- ); - })} + ); + })} +
+ )}
)} - {/* Yet Another React Lightbox */} setLightboxOpen(false)} @@ -233,8 +426,7 @@ export function SimpleImageVoting({ render={{ buttonPrev: slides.length <= 1 ? () => null : undefined, buttonNext: slides.length <= 1 ? () => null : undefined, - slide: ({ slide, offset, rect }) => { - // Get the current slide index from the slide itself + slide: ({ slide }) => { const currentSlideIndex = slides.findIndex(s => s.src === slide.src); const currentOption = imageOptions[currentSlideIndex >= 0 ? currentSlideIndex : lightboxIndex]; const currentVote = votes[String(currentOption?.id)]; @@ -251,7 +443,6 @@ export function SimpleImageVoting({ }} /> - {/* Title overlay at top */} {currentOption && (
)} - {/* Voting buttons at bottom */} {currentOption && !adminPreview && (
- Ja + {t('voting.yes')} {allowMaybe && ( @@ -334,7 +524,7 @@ export function SimpleImageVoting({ }} > - Vielleicht + {t('voting.maybe')} )} @@ -360,7 +550,7 @@ export function SimpleImageVoting({ }} > - Nein + {t('voting.no')}
)} @@ -376,4 +566,4 @@ export function SimpleImageVoting({ />
); -} \ No newline at end of file +} diff --git a/client/src/components/VotingInterface.tsx b/client/src/components/VotingInterface.tsx index 1f77340a..6ac3028b 100644 --- a/client/src/components/VotingInterface.tsx +++ b/client/src/components/VotingInterface.tsx @@ -72,6 +72,7 @@ export function VotingInterface({ poll, isAdminAccess = false }: VotingInterface const [voterName, setVoterName] = useState(""); const [voterEmail, setVoterEmail] = useState(""); const [votes, setVotes] = useState>({}); + const [freeTextAnswers, setFreeTextAnswers] = useState>({}); const [orgaBookings, setOrgaBookings] = useState([]); const [hasOrgaChanges, setHasOrgaChanges] = useState(false); const [showSelfVote, setShowSelfVote] = useState(false); @@ -497,8 +498,10 @@ export function VotingInterface({ poll, isAdminAccess = false }: VotingInterface return; } } else { + const hasFreeTextOptions = poll.options.some((o: any) => o.isFreeText); const votesToSubmit = Object.entries(votes); - if (votesToSubmit.length === 0) { + const hasFreeTextAnswers = hasFreeTextOptions && Object.values(freeTextAnswers).some(v => v?.trim()); + if (votesToSubmit.length === 0 && !hasFreeTextAnswers) { toast({ title: t('common.error'), description: t('votingInterface.pleaseSelectOption'), @@ -542,14 +545,32 @@ export function VotingInterface({ poll, isAdminAccess = false }: VotingInterface sessionStorage.setItem('vote-success-data', JSON.stringify(successData)); } else if (poll.type === 'survey') { // For surveys: Use bulk vote endpoint to ensure atomicity - const votesToSubmit = Object.entries(votes); + // Include free-text answers for isFreeText options + const freeTextOptions = poll.options.filter((o: any) => o.isFreeText); + const freeTextVotes = freeTextOptions + .filter((o: any) => freeTextAnswers[o.id]?.trim()) + .map((o: any) => ({ + optionId: o.id, + response: 'freetext' as const, + freeTextAnswer: freeTextAnswers[o.id].trim(), + })); + const regularVotes = Object.entries(votes).map(([optionId, response]) => ({ + optionId: parseInt(optionId), + response, + })); + const allVotes = [...regularVotes, ...freeTextVotes]; + if (allVotes.length === 0) { + toast({ + title: t('common.error'), + description: t('votingInterface.pleaseSelectOption'), + variant: "destructive", + }); + return; + } const bulkVoteData = { voterName: voterName.trim(), voterEmail: voterEmail.trim(), - votes: votesToSubmit.map(([optionId, response]) => ({ - optionId: parseInt(optionId), - response - })) + votes: allVotes, }; const response = await apiRequest("POST", `/api/v1/polls/${poll.publicToken}/vote-bulk`, bulkVoteData); @@ -952,18 +973,42 @@ export function VotingInterface({ poll, isAdminAccess = false }: VotingInterface ) ) : ( canVote && (showSelfVote || !isAdminAccess) ? ( - handleVote(parseInt(optionId), response)} - existingVotes={Object.fromEntries( - Object.entries(votes).map(([id, response]) => [id, response]) + <> + {poll.options.filter((o: any) => o.isFreeText).length > 0 && ( +
+ {poll.options.filter((o: any) => o.isFreeText).map((option: any) => ( +
+ +