# Implementation Plan: Vídeo de Apresentação do Imóvel **Branch**: `033-video-apresentacao-imovel` | **Data**: 2026-04-22 | **Spec**: [spec.md](spec.md) **Input**: [specs/033-video-apresentacao-imovel/spec.md](spec.md) ## Summary Adicionar suporte a um vídeo de apresentação por imóvel: o administrador configura a URL (YouTube, Vimeo ou arquivo direto) e escolhe se o vídeo aparece integrado ao carrossel de fotos ou em seção exclusiva abaixo dele. A implementação consiste em uma migration Alembic (+2 colunas), extensões mínimas nos schemas Pydantic e no handler de update existente no backend, e três artefatos novos no frontend (`getEmbedUrl`, `VideoPlayer`, modificação do `PhotoCarousel`). --- ## Technical Context **Language/Version**: Python 3.12 (backend) · TypeScript 5.5 (frontend) **Primary Dependencies**: Flask 3.x, SQLAlchemy 2.x, Pydantic v2, Alembic (backend) · React 18, Tailwind CSS 3.4 (frontend) — **sem novas dependências** **Storage**: PostgreSQL 16 — tabela `properties` recebe `video_url VARCHAR(512) NULL` e `video_position VARCHAR(20) NOT NULL DEFAULT 'section'` **Testing**: pytest (backend) · testes manuais via browser (frontend) **Target Platform**: Web SPA (React) + REST API (Flask) **Project Type**: Web application — SaaS imobiliária **Performance Goals**: Preview do vídeo no admin ≤ 3s (SC-006); página de detalhe não deve sofrer regressão de performance (vídeo só carrega quando configurado) **Constraints**: Sem novas dependências npm; sem ENUM SQL (usar VARCHAR + validação Pydantic); `iframe` com `loading="lazy"` para não bloquear LCP **Scale/Scope**: Feature aditiva — 1 migration, 1 utilitário, 1 componente, modificações pontuais em 6 arquivos existentes --- ## Constitution Check | Princípio | Status | Notas | |-----------|--------|-------| | **I. Design-First** | ✅ PASSA | `VideoPlayer` usa `aspect-[16/9]`, `rounded-xl`, cores `bg-panel border border-white/5` do design system; nenhum estilo inline fora do sistema | | **II. Separation of Concerns** | ✅ PASSA | Backend persiste URL bruta; parse/transform de embed é 100% client-side; contrato REST documentado em `contracts/api.md` | | **III. Spec-Driven** | ✅ PASSA | spec.md aprovado → plan.md → tasks.md → implementação; ordem respeitada | | **IV. Data Integrity** | ✅ PASSA | Migration Alembic com `upgrade`/`downgrade`; campos declarados com nullable explícito; Pydantic `Literal` para `video_position`; sanitização de URL antes de persistir | | **V. Security** | ✅ PASSA | Endpoint admin protegido por `@require_admin`; sem secrets no frontend; `video_url` é dado público sem impacto de segurança; `iframe` com `allow` explícito | | **VI. Simplicity First** | ✅ PASSA | Sem novas dependências; helper puro sem estado; modificação do carrossel existente em vez de criar novo; YAGNI respeitado (1 vídeo por imóvel) | --- ## Project Structure ### Documentation (esta feature) ```text specs/033-video-apresentacao-imovel/ ├── plan.md ← este arquivo ├── research.md ← decisões técnicas documentadas ├── data-model.md ← modelo de dados e migration ├── quickstart.md ← guia de setup e teste ├── contracts/ │ └── api.md ← contratos REST + utilitários frontend └── tasks.md ← gerado por /speckit.tasks (próximo passo) ``` ### Source Code — Arquivos Afetados ```text backend/ ├── migrations/versions/ │ └── k3l4m5n6o7p8_add_video_to_properties.py [NOVO] ├── app/ │ ├── models/ │ │ └── property.py [MODIFICADO] │ ├── schemas/ │ │ └── property.py [MODIFICADO] │ └── routes/ │ └── admin.py [MODIFICADO] frontend/src/ ├── utils/ │ └── getEmbedUrl.ts [NOVO] ├── components/ │ └── PropertyDetail/ │ ├── VideoPlayer.tsx [NOVO] │ └── PhotoCarousel.tsx [MODIFICADO] ├── pages/ │ ├── PropertyDetailPage.tsx [MODIFICADO] │ └── admin/ │ └── PropertyForm.tsx [MODIFICADO] └── types/ └── property.ts [MODIFICADO] ``` --- ## Implementation Phases ### Fase 1 — Backend (sem frontend) #### 1.1 Migration Alembic **Arquivo**: `backend/migrations/versions/k3l4m5n6o7p8_add_video_to_properties.py` ```python """add video to properties Revision ID: k3l4m5n6o7p8 Revises: j2k3l4m5n6o7 Create Date: 2026-04-22 """ from alembic import op import sqlalchemy as sa revision = 'k3l4m5n6o7p8' down_revision = 'j2k3l4m5n6o7' branch_labels = None depends_on = None def upgrade() -> None: op.add_column('properties', sa.Column('video_url', sa.String(512), nullable=True)) op.add_column('properties', sa.Column('video_position', sa.String(20), nullable=False, server_default='section')) def downgrade() -> None: op.drop_column('properties', 'video_position') op.drop_column('properties', 'video_url') ``` #### 1.2 Model — `backend/app/models/property.py` Adicionar após `created_at`: ```python video_url = db.Column(db.String(512), nullable=True) video_position = db.Column(db.String(20), nullable=False, server_default='section') ``` #### 1.3 Schema público — `backend/app/schemas/property.py` Adicionar campos em `PropertyDetailOut`: ```python class PropertyDetailOut(PropertyOut): address: str | None = None code: str | None = None description: str | None = None video_url: str | None = None video_position: Literal['carousel', 'section'] = 'section' ``` #### 1.4 Schema admin + handler — `backend/app/routes/admin.py` **`PropertyAdminOut`** — adicionar campos: ```python video_url: str | None = None video_position: Literal['carousel', 'section'] = 'section' ``` **`PropertyAdminOut.from_prop`** — adicionar no `return cls(...)`: ```python video_url=p.video_url, video_position=p.video_position, ``` **`admin_update_property`** — adicionar `'video_position'` em `_SCALAR_FIELDS` e, fora do loop, o tratamento de `video_url`: ```python # Após o loop de _SCALAR_FIELDS: if 'video_url' in body: raw = body['video_url'] prop.video_url = raw.strip() if raw and raw.strip() else None ``` > **Nota**: `video_position` entra em `_SCALAR_FIELDS` normalmente. `video_url` precisa de tratamento fora do loop para sanitização (strip + string vazia → None). --- ### Fase 2 — Frontend Utilitário + Componente Base #### 2.1 `frontend/src/utils/getEmbedUrl.ts` — NOVO ```ts export type VideoType = 'youtube' | 'vimeo' | 'direct' | 'unknown' export interface EmbedResult { type: VideoType embedUrl: string | null } export function getEmbedUrl(url: string): EmbedResult { const trimmed = url.trim() if (!trimmed) return { type: 'unknown', embedUrl: null } // YouTube — watch, youtu.be, embed const ytWatch = trimmed.match( /(?:youtube\.com\/watch\?(?:.*&)?v=|youtu\.be\/)([A-Za-z0-9_-]{11})/ ) if (ytWatch) { return { type: 'youtube', embedUrl: `https://www.youtube.com/embed/${ytWatch[1]}` } } if (/youtube\.com\/embed\/[A-Za-z0-9_-]{11}/.test(trimmed)) { return { type: 'youtube', embedUrl: trimmed } } // Vimeo const vimeoMatch = trimmed.match(/vimeo\.com\/(\d+)/) if (vimeoMatch) { return { type: 'vimeo', embedUrl: `https://player.vimeo.com/video/${vimeoMatch[1]}` } } if (/player\.vimeo\.com\/video\/\d+/.test(trimmed)) { return { type: 'vimeo', embedUrl: trimmed } } // Arquivo direto if (/\.(mp4|webm)(\?.*)?$/i.test(trimmed)) { return { type: 'direct', embedUrl: trimmed } } return { type: 'unknown', embedUrl: null } } ``` #### 2.2 `frontend/src/components/PropertyDetail/VideoPlayer.tsx` — NOVO ```tsx import { getEmbedUrl } from '../../utils/getEmbedUrl' interface VideoPlayerProps { url: string className?: string } export default function VideoPlayer({ url, className = '' }: VideoPlayerProps) { const { type, embedUrl } = getEmbedUrl(url) if (!embedUrl) return null const wrapperClass = `relative w-full aspect-[16/9] rounded-xl overflow-hidden bg-panel border border-white/5 ${className}` if (type === 'direct') { return (
) } return (