From 924c6a3d704026ec7378724f04fc51fcefe4f113 Mon Sep 17 00:00:00 2001 From: Mohammed Zayan Date: Wed, 29 Jul 2026 05:47:57 +0300 Subject: [PATCH 01/10] Plan Phase 5 brand kit --- .specify/feature.json | 2 +- .../006-brand-kit/checklists/requirements.md | 35 +++++ specs/006-brand-kit/contracts/brand-kit.md | 68 +++++++++ specs/006-brand-kit/data-model.md | 39 +++++ specs/006-brand-kit/plan.md | 85 +++++++++++ specs/006-brand-kit/quickstart.md | 42 +++++ specs/006-brand-kit/research.md | 32 ++++ specs/006-brand-kit/spec.md | 128 ++++++++++++++++ specs/006-brand-kit/tasks.md | 144 ++++++++++++++++++ 9 files changed, 574 insertions(+), 1 deletion(-) create mode 100644 specs/006-brand-kit/checklists/requirements.md create mode 100644 specs/006-brand-kit/contracts/brand-kit.md create mode 100644 specs/006-brand-kit/data-model.md create mode 100644 specs/006-brand-kit/plan.md create mode 100644 specs/006-brand-kit/quickstart.md create mode 100644 specs/006-brand-kit/research.md create mode 100644 specs/006-brand-kit/spec.md create mode 100644 specs/006-brand-kit/tasks.md diff --git a/.specify/feature.json b/.specify/feature.json index f3119e3..ee62bbf 100644 --- a/.specify/feature.json +++ b/.specify/feature.json @@ -1 +1 @@ -{"feature_directory":"specs/005-signup-access"} +{"feature_directory":"specs/006-brand-kit"} diff --git a/specs/006-brand-kit/checklists/requirements.md b/specs/006-brand-kit/checklists/requirements.md new file mode 100644 index 0000000..c2e751a --- /dev/null +++ b/specs/006-brand-kit/checklists/requirements.md @@ -0,0 +1,35 @@ +# Specification Quality Checklist: Brand Kit Interview + +**Purpose**: Validate specification completeness and readiness for planning +**Created**: 2026-07-29 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details beyond user-visible behavior and domain constraints +- [x] Focused on brand-owner value and product needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No clarification markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance coverage +- [x] User scenarios cover zero-answer, partial, complete, and unauthorized flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No unrelated implementation details leak into the specification + +## Notes + +- The Phase 5 Brand Kit checkpoint requires both zero-answer and complete-kit verification; both are explicitly covered. +- Provider integrations and image-generation lifecycle checks are not applicable until later phases. diff --git a/specs/006-brand-kit/contracts/brand-kit.md b/specs/006-brand-kit/contracts/brand-kit.md new file mode 100644 index 0000000..1b59a8f --- /dev/null +++ b/specs/006-brand-kit/contracts/brand-kit.md @@ -0,0 +1,68 @@ +# Brand Kit API Contract + +Base path: `/api/v1/brands`. Every operation requires `Authorization: Bearer `. + +## GET `/brands/{brand_id}/kit` + +Returns the owner’s kit. If no row exists, return a valid empty kit with `status: "not_started"`, empty answers, null summary, and null `completed_at`. + +### Complete response + +```json +{ + "brand_id": "uuid", + "brand_name": "My Brand", + "answers": { + "tagline": "Innovation for everyone", + "tone": "professional", + "audience": "Small business owners aged 25-45", + "colors": ["#FF5733", "#3498DB"], + "avoid_words": "cheap, discount" + }, + "summary": "Brand: My Brand\nTagline: Innovation for everyone\n...", + "status": "complete", + "completed_at": "2026-07-29T00:00:00Z", + "updated_at": "2026-07-29T00:00:00Z" +} +``` + +## PUT `/brands/{brand_id}/kit` + +Upserts the existing brand name and kit answers. The client may send partial answers; status, summary, and completion time are derived by the server. For a partial request, omitted answer fields preserve their existing values, while explicit null or empty values clear the corresponding field. + +```json +{ + "name": "My Brand", + "answers": { + "tagline": "Innovation for everyone", + "tone": "professional", + "audience": "Small business owners aged 25-45", + "colors": ["#FF5733", "#3498DB"], + "avoid_words": "cheap, discount" + } +} +``` + +The response uses the same shape as GET. A partial save returns `in_progress` with `summary` and `completed_at` set to null; a valid complete save returns `complete` with summary and `completed_at`. + +## Validation + +- `name`: trimmed, 2–120 characters, updates the existing brand name. +- `tagline`: optional, at most 160 characters. +- `tone`: one of `formal`, `casual`, `playful`, `professional`, `friendly`. +- `audience`: optional while partial; 2–500 non-whitespace characters when supplied and required for completion. +- `colors`: zero values while partial, otherwise 1–3 valid hexadecimal colors for completion. +- `avoid_words`: optional. + +## Errors + +Use the project envelope `{ "error": { "code", "message", "request_id" } }`. + +| Condition | Status | Code | +|---|---:|---| +| Missing/invalid session | 401 | `UNAUTHORIZED` | +| Brand absent or not owned by caller | 404 | `BRAND_NOT_FOUND` | +| Invalid request fields | 400 | `VALIDATION_ERROR` | +| Brand cleanup in progress | 409 | `BRAND_CLEANUP_REQUIRED` | + +Unauthorized requests must not reveal whether another user’s brand or kit exists. diff --git a/specs/006-brand-kit/data-model.md b/specs/006-brand-kit/data-model.md new file mode 100644 index 0000000..59a7de2 --- /dev/null +++ b/specs/006-brand-kit/data-model.md @@ -0,0 +1,39 @@ +# Data Model: Brand Kit Interview + +## `brand_kits` + +One row belongs to exactly one `brands` row. `brand_id` is both the primary key and the foreign key, with `ON DELETE CASCADE`. + +| Field | Type | Rules | +|---|---|---| +| `brand_id` | UUID | Required; primary key; owned through the parent brand | +| `tagline` | text | Optional; maximum 160 characters | +| `tone` | `tone_t` | Optional while partial; required for `complete`; values: `formal`, `casual`, `playful`, `professional`, `friendly` | +| `audience` | text | Optional while partial; when supplied, 2–500 non-whitespace characters; required for `complete` | +| `colors` | text[] | Default empty array for partial kits; 1–3 valid hexadecimal colors required for `complete` | +| `avoid_words` | text | Optional | +| `summary` | text | Null or partial while incomplete; deterministic derived text when complete | +| `status` | `kit_status_t` | `not_started`, `in_progress`, or `complete`; derived from saved answers | +| `completed_at` | timestamptz | Set when complete; cleared when an edit makes the kit incomplete | +| `created_at` | timestamptz | Database default | +| `updated_at` | timestamptz | Database default and existing updated-at trigger | + +## Related `brands` response + +Brand API responses gain a derived `kit_status` field. It is `not_started` when no `brand_kits` row exists, otherwise it mirrors the kit row’s status. No duplicated status column is added to `brands`. + +## Validation and lifecycle + +1. No row / no answers → `not_started`. +2. A row with at least one saved answer but incomplete required fields → `in_progress`. +3. A row with a valid name and all required answers, including 1–3 colors → `complete`, summary populated, `completed_at` set. +4. Editing a complete kit so a required field becomes invalid → `in_progress`, summary cleared, `completed_at` cleared. + +The existing brand name is updated in the same transaction as the kit upsert. Each write locks the owned brand row; overlapping successful writes use last-successful-save-wins behavior. + +## Security and deletion + +- Enable and force RLS on `brand_kits`. +- Permit authenticated access only through an owner policy using the existing `private.is_brand_owner(brand_id)` helper. +- Grant backend service-role DML and include the table in startup privilege assertions. +- A deleted brand cascades to its kit; integration tests verify no kit row remains. diff --git a/specs/006-brand-kit/plan.md b/specs/006-brand-kit/plan.md new file mode 100644 index 0000000..b26f81b --- /dev/null +++ b/specs/006-brand-kit/plan.md @@ -0,0 +1,85 @@ +# Implementation Plan: Brand Kit Interview + +**Branch**: `006-brand-kit` | **Date**: 2026-07-29 | **Spec**: [spec.md](spec.md) + +**Input**: Feature specification from `specs/006-brand-kit/spec.md`, grounded in `docs/implementation-plan.md` Phase 5. + +## Summary + +Implement the six-step Brand Kit interview across the FastAPI API, Supabase schema, and Next.js dashboard. Add one owner-scoped `brand_kits` record per brand, derive status and summary server-side, expose authenticated GET/PUT endpoints, and add a wizard route with auto-save, explicit save, resume, completion summary, and navigation status. Existing brand ownership, error formatting, and hard-delete behavior will be reused. + +## Technical Context + +**Language/Version**: Python 3.13, TypeScript/React 18, Next.js 15 + +**Primary Dependencies**: FastAPI, Pydantic v2, SQLAlchemy text queries, Supabase PostgreSQL/RLS, Supabase SSR client, Playwright + +**Storage**: Supabase PostgreSQL `brand_kits` table with one row per brand; `brands` queries derive `kit_status` with a left join so zero-answer kits require no row + +**Testing**: Backend contract and unit tests, real Supabase integration/RLS tests, frontend Playwright E2E, ESLint, TypeScript/Next.js production build + +**Target Platform**: FastAPI service and Next.js browser dashboard + +**Project Type**: Full-stack web application + +**Performance Goals**: Owner-scoped kit GET/PUT requests should complete within 500ms p95 under normal local/application load, excluding token verification and database startup. + +**Constraints**: Use the existing `/api/v1` route convention and safe error envelope; never trust client ownership; enforce RLS and forced RLS; keep last-successful-save-wins semantics; do not call external AI providers for summary derivation. + +**Scale/Scope**: One kit per brand, six fixed questions, at most three colors per kit, one new migration, two API operations, one wizard route, and a brand navigation status indicator. + +## Constitution Check + +*GATE: Pass before research and re-check after design.* + +### Pre-design gate + +- Product Truth: PASS — Brand Kit is the planned Phase 5 capability supporting future image generation and remains brand-scoped. +- Brand Isolation: PASS — every kit is keyed by `brand_id`; backend ownership checks and database RLS will be tested. +- Data Rules: PASS — answers and derived summary are persisted; no provider secrets or generated assets are involved. +- Security: PASS — authenticated access, server-side ownership checks, forced RLS, and generic 404 responses are required. +- Hard Delete: PASS — the kit row is removed by the existing brand deletion cascade; integration coverage will verify it. +- Universal DoD: applicable acceptance, RLS/privilege, ownership, and deletion checks are assigned to tests. +- Brand-kit capability checks: REQUIRED — this feature implements the zero-answer and complete-kit scenarios. +- Provider, generation lifecycle, and PNG checks: N/A — this feature does not call providers or create generations. + +### Post-design gate + +- PASS — design adds one RLS-protected table with owner policy, includes the table in backend privilege assertions, keeps ownership in the service transaction, and covers zero-answer, partial, complete, invalid, cross-user, and hard-delete paths. +- N/A remains justified for provider and generation checks because no provider or generation behavior is changed. + +## Project Structure + +```text +supabase/migrations/00017_create_brand_kits.sql + +backend/app/models/brand_kit.py +backend/app/services/brand_kit_store.py +backend/app/routes/brand_kits.py +backend/app/config.py # privilege assertion update +backend/app/main.py # router registration +backend/app/models/brand.py # kit_status in brand responses +backend/app/services/brand_store.py # derived status in brand queries +backend/tests/contract/test_brand_kits.py +backend/tests/integration/test_brand_kit_rls.py +backend/tests/integration/test_brand_kits.py + +frontend/app/(dashboard)/brands/[brandId]/kit/page.tsx +frontend/app/(dashboard)/brands/[brandId]/page.tsx +frontend/app/(dashboard)/layout.tsx +frontend/tests/e2e/brand-kit.spec.ts + +specs/006-brand-kit/ +├── spec.md +├── plan.md +├── research.md +├── data-model.md +├── contracts/brand-kit.md +└── quickstart.md +``` + +**Structure Decision**: Follow the existing route/model/store separation in the backend and colocate the wizard under the existing brand dashboard route. Use the existing brand detail and dashboard layout as navigation integration points. + +## Complexity Tracking + +No constitution violations or additional projects are introduced. diff --git a/specs/006-brand-kit/quickstart.md b/specs/006-brand-kit/quickstart.md new file mode 100644 index 0000000..030b108 --- /dev/null +++ b/specs/006-brand-kit/quickstart.md @@ -0,0 +1,42 @@ +# Quickstart: Brand Kit Interview + +## Prerequisites + +- Local Supabase is running and migrations are applied. +- `backend/.env` contains the required backend settings and is exported before starting the API. +- Frontend dependencies are installed. + +## Manual validation + +1. Start the backend: + + ```bash + set -a + source backend/.env + set +a + make dev-backend + ``` + +2. In another terminal, start the frontend with `make dev-frontend`. +3. Sign in and create a brand at `http://localhost:3000/brands`. +4. Open the brand’s **Brand Kit** page from the brand detail screen. +5. Confirm a new kit loads with `not_started` and empty answers. +6. Save one answer, leave the page, return, and confirm the answer remains with `in_progress`. +7. Complete the six questions with 1–3 colors and confirm the summary, `complete` status, and completion time. +8. Edit a required answer to an invalid value and confirm the save is rejected without corrupting the prior valid state. +9. Use two authenticated users to confirm a non-owner receives generic `404 BRAND_NOT_FOUND` for another user’s kit. + +## Automated validation + +From the repository root: + +```bash +backend/.venv/bin/python -m pytest -q backend/tests/contract/test_brand_kits.py +backend/.venv/bin/python -m pytest -q backend/tests/integration/test_brand_kits.py backend/tests/integration/test_brand_kit_rls.py +cd frontend +npm run lint +npx playwright test tests/e2e/brand-kit.spec.ts +npm run build +``` + +The integration and E2E checks require local Supabase, the API, and the frontend to be running. Provider and image-generation checks are not part of this feature. diff --git a/specs/006-brand-kit/research.md b/specs/006-brand-kit/research.md new file mode 100644 index 0000000..f5763c5 --- /dev/null +++ b/specs/006-brand-kit/research.md @@ -0,0 +1,32 @@ +# Research: Brand Kit Interview + +## Decision: Add a dedicated `brand_kits` table with one row per brand + +The implementation plan defines `brand_kits.brand_id` as the primary key with `ON DELETE CASCADE`. This naturally enforces one kit per brand and makes brand deletion remove the kit. A missing row represents `not_started`; partial and complete work use the row’s status. + +## Decision: Derive status and summary on the server + +The backend will validate the six answer fields, derive `not_started`/`in_progress`/`complete`, set `completed_at`, and build the deterministic summary. The client will not submit an authoritative status or summary, preventing inconsistent state and keeping the summary independent of external providers. + +## Decision: Serialize saves by locking the owned brand row + +The existing stores already lock owned brands for mutations. The Brand Kit store will use the same transaction boundary, so overlapping saves complete in database order and the last successful transaction wins as clarified. No version column or merge algorithm is needed for the single-owner MVP. + +## Decision: Clear derived summary when a kit becomes incomplete + +The summary is only canonical for a complete set of required answers. When a later edit makes a kit incomplete, the store will set `summary` and `completed_at` to null and derive a new summary only after the required fields are valid again. This prevents future generation flows from consuming stale brand context. + +## Decision: Derive brand navigation status from a left join + +The current `brands` table does not contain a kit status column. Brand list/detail queries will left join `brand_kits` and default a missing row to `not_started`, allowing the dashboard selector and brand cards to show status without duplicating lifecycle state. + +## Decision: Use existing authenticated API and browser patterns + +The API will follow `/api/v1/brands/{brand_id}/kit`, `CurrentUserDep`, safe error envelopes, and the existing store dependency pattern. The wizard will use the existing Supabase session access token and `NEXT_PUBLIC_API_URL`, matching current brand and provider-key pages. + +## Alternatives considered + +- Storing all kit fields directly on `brands`: rejected because the approved schema and one-to-one lifecycle are already modeled as `brand_kits`. +- Letting the browser derive status or summary: rejected because clients cannot be trusted with validation or canonical lifecycle state. +- Adding optimistic version conflicts: rejected for this single-owner MVP because clarified behavior is last successful save wins. +- Calling an AI provider for summary generation: rejected because the Phase 5 template is deterministic and provider integrations belong to later phases. diff --git a/specs/006-brand-kit/spec.md b/specs/006-brand-kit/spec.md new file mode 100644 index 0000000..0f8c18c --- /dev/null +++ b/specs/006-brand-kit/spec.md @@ -0,0 +1,128 @@ +# Feature Specification: Brand Kit Interview + +**Feature Branch**: `006-brand-kit` + +**Created**: 2026-07-29 + +**Status**: Draft + +**Input**: User description: "Read docs/implementation-plan.md and create specification for Phase 5: Brand Kit." + +## Clarifications + +### Session 2026-07-29 + +- Q: What does the Name step do? → A: It edits the existing brand name; it does not create a separate kit name or a second brand. +- Q: How should unauthorized access to another user’s kit respond? → A: Return a generic not-found response with no kit data, without revealing whether the brand exists. +- Q: How should simultaneous saves from multiple tabs or sessions be handled? → A: The last successful save wins. +- Q: How many colors are required for a complete kit? → A: Require 1–3 valid hexadecimal colors. +- Q: When should wizard progress be saved? → A: Auto-save after each completed step and support an explicit save action. + +## User Scenarios & Testing + +### User Story 1 - Complete a brand kit (Priority: P1) + +A signed-in brand owner answers a guided set of questions so PostForge can capture the brand context used by future content generation. + +**Why this priority**: The brand kit is the Phase 5 foundation for consistent brand-aware generation. + +**Independent Test**: Create a brand, answer all six questions with valid values, complete the interview, and verify the answers, summary, and complete status are shown after reload. + +**Acceptance Scenarios**: + +1. **Given** a signed-in owner opens a brand with no kit answers, **When** they start the interview, **Then** the wizard presents six clearly ordered questions. +2. **Given** the owner supplies all required answers and optionally supplies the two optional answers, **When** they finish the interview, **Then** the kit is saved with status `complete`, a completion time, and a readable summary containing the brand context. +3. **Given** a completed kit, **When** the owner revisits it, **Then** the saved answers and completion summary are displayed and can be edited. + +### User Story 2 - Save and resume partial answers (Priority: P1) + +A brand owner can save progress with zero or some answers and return later without losing the work already entered. + +**Why this priority**: The checkpoint explicitly requires the interview to work with zero answers and complete kits; partial progress makes the six-step flow usable in practice. + +**Independent Test**: Open a new kit, save no answers and verify `not_started`; save only some answers and verify `in_progress`; reload and confirm those answers remain available for continuation. + +**Acceptance Scenarios**: + +1. **Given** a brand has no saved kit, **When** its kit is requested, **Then** the system returns an empty kit with status `not_started`. +2. **Given** the owner saves at least one answer but has not completed every required field, **When** the kit is requested, **Then** it returns the saved answers with status `in_progress`. +3. **Given** an in-progress kit, **When** the owner moves backward or leaves and later returns, **Then** the wizard restores the saved answers and current progress. +4. **Given** the owner omits an optional answer, **When** all required answers are valid, **Then** the kit can still reach `complete` and the summary identifies the optional value as unspecified. + +### User Story 3 - Keep kits private to their owner (Priority: P1) + +A brand owner can manage only kits belonging to their own brands, while other users receive a generic not-found response. + +**Why this priority**: Brand isolation is a non-negotiable product and security rule. + +**Independent Test**: Authenticate as two users, create a brand for each, and verify neither user can read or update the other user’s kit. + +**Acceptance Scenarios**: + +1. **Given** an authenticated owner requests their own brand kit, **When** the request is processed, **Then** the kit is returned. +2. **Given** an authenticated user requests another user’s brand kit by identifier, **When** the request is processed, **Then** no kit data is disclosed and a generic not-found response is returned. +3. **Given** a visitor has no valid session, **When** they request or update a brand kit, **Then** the request is rejected without exposing kit data. + +### Edge Cases + +- A kit is requested before any answers have been saved. +- A required answer is blank, whitespace-only, or outside its allowed length. +- A tone value is not one of the five supported choices. +- More than three colors are submitted, or a color is not a valid hexadecimal value. +- A user tries to mark a kit complete while a required answer is missing. +- A save is repeated with the same answers and must not create duplicate kits. +- Two tabs or sessions save different answers for the same brand at nearly the same time. +- A user edits a complete kit back to an incomplete state. +- A user attempts to access a deleted or nonexistent brand. +- A request is made without authentication or with an expired session. + +## Requirements + +### Functional Requirements + +- **FR-001**: The system MUST provide a guided six-question brand kit interview in this order: name, tagline, tone, audience, colors, and avoid words. +- **FR-002**: The Name step MUST edit the existing brand name, and the interview MUST NOT create a separate kit name or a second brand. +- **FR-003**: The tagline MUST be optional and limited to 160 characters when supplied. +- **FR-004**: Tone MUST be required and limited to `formal`, `casual`, `playful`, `professional`, or `friendly`. +- **FR-005**: Audience MUST be required and contain between 2 and 500 non-whitespace characters. +- **FR-006**: Colors MUST contain at least one and no more than three values, and each value MUST be a valid hexadecimal color. +- **FR-007**: Avoid words MUST be optional. +- **FR-008**: The system MUST allow a kit to be read when it has no saved answers and MUST return status `not_started` in that state. +- **FR-009**: The system MUST save partial answers and return status `in_progress` until all required fields are valid. +- **FR-010**: The system MUST allow a kit to be upserted repeatedly without creating duplicate kit records for the same brand; omitted answer fields in a partial save MUST preserve their existing values, explicit null or empty values MUST clear the corresponding field, and when saves overlap the last successful save MUST be retained. +- **FR-011**: The system MUST reject a request to save status `complete` when any required field is missing or invalid. +- **FR-012**: When all required fields are valid, the system MUST derive and save a readable summary containing the brand name, tagline, tone, audience, colors, and avoid-words value or an explicit unspecified value. +- **FR-013**: The system MUST set `completed_at` when a kit becomes complete and MUST clear or update completion state when later edits make it incomplete. +- **FR-014**: The wizard MUST support moving backward, moving forward, editing an existing kit, auto-saving after each completed step, and explicitly saving progress. +- **FR-015**: The wizard MUST provide a completion summary after all required answers are complete. +- **FR-016**: The brand navigation MUST show whether the kit is `not_started`, `in_progress`, or `complete`. +- **FR-017**: Every read and write operation MUST require an authenticated session and verify that the current user owns the requested brand. +- **FR-018**: The system MUST prevent one user from reading, changing, or inferring another user’s kit data, and MUST return a generic not-found response for unauthorized kit access. +- **FR-019**: Validation and authorization failures MUST use the project’s safe error format and MUST NOT expose secrets or unrelated user data. + +### Key Entities + +- **Brand**: An owner-controlled brand that anchors the kit and retains its existing name. +- **Brand Kit**: One editable kit per brand containing optional tagline and avoid-words values, required tone, audience, and colors, plus status, summary, and completion metadata. +- **Kit Status**: The lifecycle state `not_started`, `in_progress`, or `complete`. + +## Success Criteria + +### Measurable Outcomes + +- **SC-001**: 100% of new kits can be opened with zero answers and display a usable `not_started` state. +- **SC-002**: 100% of valid complete-kit submissions preserve all six question values and produce a summary containing each value. +- **SC-003**: 100% of partial saves reload with the previously saved answers and an accurate `in_progress` state. +- **SC-004**: 100% of attempts to save invalid required values are rejected with an actionable validation message and leave the prior valid kit unchanged. +- **SC-005**: 100% of tested cross-user read and write attempts return no other user’s kit data. +- **SC-006**: A new owner can complete the six-question interview in under 5 minutes without needing to know the underlying data model. +- **SC-007**: Returning to a saved kit restores the owner’s progress in one page load. + +## Assumptions + +- The existing brand record and ownership rules are reused; this feature does not add sharing or collaboration. +- Each brand has at most one kit, and deleting a brand removes its kit according to the existing hard-delete behavior. +- The six-question set and tone values are fixed for this phase; custom questions and custom tone values are out of scope. +- The summary is derived deterministically from the saved answers and does not require an external AI provider. +- The wizard auto-saves on step completion and also provides an explicit progress-save action; exact UI control wording can follow established product patterns. +- Brand-kit zero-answer and complete-kit checks are applicable because this feature implements the Brand Kit capability; provider and generation checks remain out of scope until their dependent phases. diff --git a/specs/006-brand-kit/tasks.md b/specs/006-brand-kit/tasks.md new file mode 100644 index 0000000..fa13a18 --- /dev/null +++ b/specs/006-brand-kit/tasks.md @@ -0,0 +1,144 @@ +# Tasks: Brand Kit Interview + +**Feature**: Phase 5 Brand Kit +**Branch**: `006-brand-kit` +**Design inputs**: `spec.md`, `plan.md`, `research.md`, `data-model.md`, `contracts/brand-kit.md`, `quickstart.md` + +## Implementation rules for every task + +- Use the existing FastAPI route prefix `/api/v1/brands` and the existing `CurrentUserDep` authentication dependency. +- Use the existing safe error envelope; never return raw SQL errors, access tokens, or another user’s data. +- Use the existing SQLAlchemy text-query store pattern and the existing `BrandStore.lock_owned_brand` ownership check. +- Run the narrowest relevant test after each implementation task. Keep all task checkboxes updated as work completes. + +## Phase 1: Setup + +- [ ] T001 Confirm the working tree is on branch `006-brand-kit` and read `specs/006-brand-kit/contracts/brand-kit.md` before editing implementation files. +- [ ] T002 [P] Create the backend test files `backend/tests/contract/test_brand_kits.py`, `backend/tests/unit/test_brand_kit_store.py`, `backend/tests/integration/test_brand_kits.py`, and `backend/tests/integration/test_brand_kit_rls.py` with imports and shared constants matching the existing brand/provider test style; do not implement production behavior yet. +- [ ] T003 [P] Create the frontend E2E file `frontend/tests/e2e/brand-kit.spec.ts` with the local app assumptions from `specs/006-brand-kit/quickstart.md`; keep test cases focused on the six-step wizard, partial resume, complete summary, and unauthorized access. + +## Phase 2: Foundational backend and database work + +**Purpose**: Complete this phase before implementing any user story. It creates the schema, shared models, route registration, and brand-status plumbing used by all stories. + +- [ ] T004 Create `supabase/migrations/00017_create_brand_kits.sql` with enum types `tone_t` (`formal`, `casual`, `playful`, `professional`, `friendly`) and `kit_status_t` (`not_started`, `in_progress`, `complete`), unless those types already exist; fail the migration if duplicate type creation would be unsafe. +- [ ] T005 Add the `brand_kits` table in `supabase/migrations/00017_create_brand_kits.sql` with `brand_id UUID PRIMARY KEY REFERENCES brands(id) ON DELETE CASCADE`, nullable `tagline` (max 160), nullable `tone`, nullable `audience`, `colors TEXT[] NOT NULL DEFAULT '{}'`, nullable `avoid_words`, nullable `summary`, `status` defaulting to `not_started`, nullable `completed_at`, `created_at`, and `updated_at`. +- [ ] T006 Add database constraints in `supabase/migrations/00017_create_brand_kits.sql`: audience must be 2–500 trimmed characters when non-null; colors must contain no more than 3 values; every stored color must match a six-digit hexadecimal format; a complete kit must have a valid tone, audience, and 1–3 colors; an incomplete kit must not retain `completed_at` or `summary`. +- [ ] T007 Add the existing `set_updated_at()` trigger, enable and force RLS on `brand_kits`, add an owner-only policy using `private.is_brand_owner(brand_id)`, revoke direct client DML where the repository convention requires it, and grant the backend service role the required DML in `supabase/migrations/00017_create_brand_kits.sql`. +- [ ] T008 Add the new table’s SELECT/INSERT/UPDATE/DELETE privilege checks to `_DATABASE_ROLE_PRIVILEGES` in `backend/app/config.py`; preserve the existing vault least-privilege checks and existing table checks. +- [ ] T009 [P] Create `backend/app/models/brand_kit.py` with Pydantic models for `BrandKitAnswers`, `BrandKitUpsert`, `BrandKit`, `KitStatus`, and `Tone`; validate trimmed name (2–120), tagline (0–160), audience (2–500 when supplied), tone enum, and colors (0 while partial or 1–3 valid hex colors when complete). +- [ ] T010 [P] Add `kit_status: Literal["not_started", "in_progress", "complete"]` to the brand response model in `backend/app/models/brand.py` without removing or renaming any existing brand response fields. +- [ ] T011 Update `backend/app/services/brand_store.py` list and get queries to left join `brand_kits`, return `kit_status = 'not_started'` when no kit row exists, and return the stored kit status otherwise; update `_to_brand` and related tests for the new response field. +- [ ] T012 [P] Create `backend/app/services/brand_kit_store.py` with dependency factory `get_brand_kit_store`, a service dataclass using the existing engine, deterministic summary derivation, and helper functions that map database rows to the Pydantic response without exposing internal columns. +- [ ] T013 Create `backend/app/routes/brand_kits.py` with router prefix `/api/v1/brands`, `GET /{brand_id}/kit`, and `PUT /{brand_id}/kit`; register the router in `backend/app/main.py` and ensure unauthenticated requests still produce `401 UNAUTHORIZED` through `CurrentUserDep`. +- [ ] T014 In `backend/app/routes/brand_kits.py`, map a missing or non-owned brand to `404 BRAND_NOT_FOUND`, invalid payloads to the existing `400 VALIDATION_ERROR` handler, cleanup-in-progress to `409 BRAND_CLEANUP_REQUIRED`, and never return a kit row for another owner. + +**Foundational checkpoint**: Apply migration `00017`, run the backend contract tests, and verify the API imports successfully before starting user-story implementation. + +## Phase 3: User Story 1 — Complete a brand kit (P1, MVP) + +**Goal**: An owner can answer all six questions, save a complete kit, see a deterministic summary, and edit it later. + +**Independent test**: Create a brand, submit valid answers for name, tagline, tone, audience, 1–3 colors, and avoid words, then GET the kit and verify `complete`, `completed_at`, all answers, and summary content. + +### Tests first + +- [ ] T015 [P] [US1] Add contract tests in `backend/tests/contract/test_brand_kits.py` for `GET` with no row returning `not_started`, valid `PUT` returning `complete`, optional tagline/avoid-words omission, and response fields matching `contracts/brand-kit.md`. +- [ ] T016 [P] [US1] Add unit tests in `backend/tests/unit/test_brand_kit_store.py` for deterministic summary text containing brand name, tagline, tone, audience, colors, and `None specified` for omitted optional answers. +- [ ] T017 [P] [US1] Add integration coverage in `backend/tests/integration/test_brand_kits.py` for creating a real brand, saving a complete kit, reading it back, editing it, and confirming only one `brand_kits` row exists. + +### Implementation + +- [ ] T018 [US1] Implement `BrandKitStore.get_kit` in `backend/app/services/brand_kit_store.py`: lock the requested owned brand before reading the kit, return an empty response with `not_started` when no row exists, and return saved answers/status/summary/timestamps when a row exists. +- [ ] T019 [US1] Implement `BrandKitStore.upsert_kit` in `backend/app/services/brand_kit_store.py`: lock the owned brand row, trim and update the existing brand name, preserve omitted answer fields, clear explicitly null/empty fields, validate the resulting answer set, derive status server-side, derive summary only for a valid complete kit, and insert or update by the `brand_id` primary key. +- [ ] T020 [US1] Complete the GET and PUT route handlers in `backend/app/routes/brand_kits.py`, including request logging with only event and request ID fields and response models matching `contracts/brand-kit.md`. +- [ ] T021 [US1] Add the Brand Kit entry point to `frontend/app/(dashboard)/brands/[brandId]/page.tsx`: render a clear link to `/brands/{brandId}/kit`, disable or explain unavailability when the brand is in cleanup, and preserve the existing logo/provider/delete sections. +- [ ] T022 [US1] Create `frontend/app/(dashboard)/brands/[brandId]/kit/page.tsx` with authenticated loading, six ordered steps (Name, Tagline, Tone, Audience, Colors, Avoid words), Previous/Next controls, field labels, inline validation, and a visible current-step indicator. +- [ ] T023 [US1] In `frontend/app/(dashboard)/brands/[brandId]/kit/page.tsx`, submit the full form to `PUT /v1/brands/{brandId}/kit` with the Supabase access token, display API validation errors, disable controls while saving, and display a complete summary after a successful complete response. +- [ ] T024 [US1] Add the complete-kit browser journey to `frontend/tests/e2e/brand-kit.spec.ts`: record a start timestamp, create or select a test brand, fill all six steps with valid values and two colors, submit, assert the summary and complete status within 5 minutes, reload, and assert the values remain. + +**US1 checkpoint**: `backend/.venv/bin/python -m pytest -q backend/tests/contract/test_brand_kits.py backend/tests/unit/test_brand_kit_store.py backend/.venv/bin/python -m pytest -q backend/tests/integration/test_brand_kits.py` (run as separate commands) and the complete-kit Playwright test pass. + +## Phase 4: User Story 2 — Save and resume partial answers (P1) + +**Goal**: A kit supports zero answers, partial progress, auto-save after each completed step, explicit save, resume after reload, and optional answers omitted on completion. + +**Independent test**: Open a new kit, verify `not_started`, save one step and leave, return and verify `in_progress` with that value, then complete the remaining required fields without tagline or avoid words. + +### Tests first + +- [ ] T025 [P] [US2] Add contract tests in `backend/tests/contract/test_brand_kits.py` for partial PUT returning `in_progress` with null summary/completed_at, omitted fields preserving stored values, explicit null/empty fields clearing values, empty colors being accepted while partial, invalid completion being rejected, and repeated PUT not creating a duplicate row. +- [ ] T026 [P] [US2] Add unit tests in `backend/tests/unit/test_brand_kit_store.py` for `not_started`/`in_progress`/`complete` transitions, clearing summary/completed_at when a complete kit becomes incomplete, and last-successful-save-wins transaction behavior. +- [ ] T027 [P] [US2] Add integration tests in `backend/tests/integration/test_brand_kits.py` for partial save/reload, omitted optional answers producing a complete summary with explicit unspecified values, invalid save preserving the prior valid row, and overlapping saves retaining the later committed values. + +### Implementation + +- [ ] T028 [US2] Extend `BrandKitStore.upsert_kit` in `backend/app/services/brand_kit_store.py` so omitted fields preserve existing values, explicit null/empty fields clear values, partial data is stored as `in_progress`, empty colors are allowed only for incomplete kits, incomplete edits clear `summary` and `completed_at`, and the owned-brand transaction lock provides last-successful-save-wins behavior. +- [ ] T029 [US2] Add explicit `Save progress` and automatic save-on-Next behavior to `frontend/app/(dashboard)/brands/[brandId]/kit/page.tsx`; keep the user on the current page after a partial save and show `Saved`, `Saving...`, or an actionable error state. +- [ ] T030 [US2] Add resume behavior to `frontend/app/(dashboard)/brands/[brandId]/kit/page.tsx`: populate all fields from GET, restore the first incomplete step, allow Previous navigation, and do not overwrite local edits with a stale load response. +- [ ] T031 [US2] Add the partial/resume browser journey to `frontend/tests/e2e/brand-kit.spec.ts`: verify empty `not_started`, save one answer, reload, verify `in_progress`, navigate backward/forward, and complete with optional fields omitted. +- [ ] T032 [US2] Add clear validation messages in `frontend/app/(dashboard)/brands/[brandId]/kit/page.tsx` for blank name, short audience, invalid tone, zero colors on completion, more than three colors, invalid hex color, and overlong tagline; prevent network submission when client validation fails. + +**US2 checkpoint**: Contract/unit/integration partial-state tests and the partial/resume Playwright test pass; a failed save leaves the previous valid response visible. + +## Phase 5: User Story 3 — Keep kits private to their owner (P1) + +**Goal**: Only the owning user can read or update a kit; unauthorized users receive generic not-found responses and no kit data. + +**Independent test**: Create two users and two brands, then verify owner GET/PUT succeeds while cross-user GET/PUT returns `404 BRAND_NOT_FOUND`; unauthenticated requests return `401`. + +### Tests first + +- [ ] T033 [P] [US3] Add contract tests in `backend/tests/contract/test_brand_kits.py` for missing authorization, malformed authorization, non-owner GET, non-owner PUT, nonexistent brand, and safe error envelope/code/message. +- [ ] T034 [P] [US3] Add direct RLS tests in `backend/tests/integration/test_brand_kit_rls.py` proving authenticated owner roles can read/write their own row, cannot read/write another user’s row, and cannot insert a row for another user’s brand. +- [ ] T035 [P] [US3] Extend `backend/tests/integration/test_brand_kits.py` to delete a brand and assert its `brand_kits` row is physically removed; also assert cross-user API responses contain no answer, summary, or brand-name data. + +### Implementation and verification + +- [ ] T036 [US3] Harden `backend/app/services/brand_kit_store.py` so every GET and PUT begins by resolving the brand through the owner-scoped lock and never queries a kit by `brand_id` alone before ownership is established. +- [ ] T037 [US3] Verify `supabase/migrations/00017_create_brand_kits.sql` RLS, forced RLS, owner policy, cascade behavior, and service-role grants against `backend/tests/integration/test_brand_kit_rls.py`; adjust only the migration or privilege assertion if a test exposes a mismatch. +- [ ] T038 [US3] Update `frontend/app/(dashboard)/layout.tsx` and `frontend/app/(dashboard)/brands/page.tsx` to display the derived `kit_status` (`Not started`, `In progress`, or `Complete`) without exposing another user’s brand data. +- [ ] T039 [US3] Add unauthorized and status-indicator coverage to `frontend/tests/e2e/brand-kit.spec.ts`, including redirecting unauthenticated visitors and showing the correct status after empty, partial, and complete saves. + +**US3 checkpoint**: API ownership, direct RLS, hard-delete, and frontend status tests pass with generic 404 behavior for non-owners. + +## Phase 6: Polish and cross-cutting validation + +- [ ] T040 [P] Update `backend/tests/contract/test_brands.py` and any affected brand fixtures to assert the new `kit_status` field without breaking existing brand CRUD behavior. +- [ ] T041 Run `backend/.venv/bin/python -m pytest -q backend/tests/contract/test_brand_kits.py backend/tests/unit/test_brand_kit_store.py backend/tests/contract/test_brands.py` and fix only failures caused by this feature. +- [ ] T042 Run `backend/.venv/bin/python -m pytest -q backend/tests` from the repository root and record the result in `specs/006-brand-kit/quickstart.md`. +- [ ] T043 Run `cd frontend && npm run lint`, `cd frontend && npx playwright test tests/e2e/brand-kit.spec.ts`, and `cd frontend && npm run build`; record exact outcomes in `specs/006-brand-kit/quickstart.md`. +- [ ] T044 Run the complete `specs/006-brand-kit/quickstart.md` manual/API validation sequence against local Supabase and confirm the documented zero-answer, partial, complete, unauthorized, and hard-delete outcomes. +- [ ] T045 Verify the applicable constitution checks in `.specify/memory/constitution.md`: acceptance layers, brand-kit zero/complete cases, RLS/forced RLS/privileges, server ownership, safe logging, and physical kit deletion; document provider/generation checks as N/A because this feature does not call them. +- [ ] T046 Measure owner-scoped kit GET/PUT p95 latency under normal local load using `backend/tests/integration/test_brand_kits.py` or a focused benchmark, and record the measured result against the 500ms goal in `specs/006-brand-kit/quickstart.md`. +- [ ] T047 Run `git diff --check`, review `git status --short`, and ensure `specs/006-brand-kit/tasks.md` has every completed task marked `[x]` before handoff, after T046 is complete. + +## Dependencies and execution order + +### Phase dependencies + +- Phase 1 must finish before Phase 2. +- Phase 2 must finish before any user story because it creates the migration, models, routes, and brand status field. +- US1 is the MVP and should be completed before US2 UI work; US2 extends the same store and wizard. +- US3 tests can be written in parallel with US1/US2, but RLS verification requires the migration from Phase 2 and the implemented store/routes. +- Phase 6 starts only after US1, US2, and US3 checkpoints pass. + +### Parallel opportunities + +- T002 and T003 can run in parallel. +- T009, T010, and T012 can run in parallel after the migration shape is agreed. +- Within each user story, test-writing tasks marked `[P]` can run in parallel because they edit different test files or independent test sections. +- T015–T017, T025–T027, and T033–T035 can be delegated to separate workers before their corresponding implementation tasks. +- T040 must finish before T041 because both tasks may update `backend/tests/contract/test_brands.py`. + +## MVP implementation strategy + +1. Complete Phase 1 and Phase 2. +2. Deliver US1 first: schema, owner-scoped GET/PUT, complete-kit summary, and the six-step wizard. +3. Deliver US2: partial state, auto-save, explicit save, resume, and incomplete validation. +4. Deliver US3: direct RLS, cross-user API tests, hard-delete verification, and status navigation. +5. Run all Phase 6 checks and update the task checkboxes/documentation. + +## Task format validation + +All implementation tasks use `- [ ] T###`, include `[P]` only for independently parallelizable work, include `[US1]`, `[US2]`, or `[US3]` only inside a user-story phase, and name the exact repository path to edit or validate. From f1159d4cd330d46505b880e963226ad800df3213 Mon Sep 17 00:00:00 2001 From: Mohammed Zayan Date: Wed, 29 Jul 2026 06:07:39 +0300 Subject: [PATCH 02/10] Refine brand kit planning artifacts --- specs/006-brand-kit/checklists/requirements.md | 1 + specs/006-brand-kit/contracts/brand-kit.md | 4 ++-- specs/006-brand-kit/data-model.md | 2 +- specs/006-brand-kit/plan.md | 6 ++++-- specs/006-brand-kit/quickstart.md | 2 +- specs/006-brand-kit/spec.md | 9 +++++---- specs/006-brand-kit/tasks.md | 12 ++++++------ 7 files changed, 20 insertions(+), 16 deletions(-) diff --git a/specs/006-brand-kit/checklists/requirements.md b/specs/006-brand-kit/checklists/requirements.md index c2e751a..10d9c47 100644 --- a/specs/006-brand-kit/checklists/requirements.md +++ b/specs/006-brand-kit/checklists/requirements.md @@ -32,4 +32,5 @@ ## Notes - The Phase 5 Brand Kit checkpoint requires both zero-answer and complete-kit verification; both are explicitly covered. +- FR-016 coverage is explicit in User Story 3, acceptance scenario 4, and tasks T038–T039. - Provider integrations and image-generation lifecycle checks are not applicable until later phases. diff --git a/specs/006-brand-kit/contracts/brand-kit.md b/specs/006-brand-kit/contracts/brand-kit.md index 1b59a8f..91ec047 100644 --- a/specs/006-brand-kit/contracts/brand-kit.md +++ b/specs/006-brand-kit/contracts/brand-kit.md @@ -43,7 +43,7 @@ Upserts the existing brand name and kit answers. The client may send partial ans } ``` -The response uses the same shape as GET. A partial save returns `in_progress` with `summary` and `completed_at` set to null; a valid complete save returns `complete` with summary and `completed_at`. +The response uses the same shape as GET. A request that persists zero answers, including an explicit “save no answers” request, returns `not_started` with `summary` and `completed_at` set to null. `in_progress` begins only after at least one answer has been saved. A valid complete save returns `complete` with summary and `completed_at`. ## Validation @@ -51,7 +51,7 @@ The response uses the same shape as GET. A partial save returns `in_progress` wi - `tagline`: optional, at most 160 characters. - `tone`: one of `formal`, `casual`, `playful`, `professional`, `friendly`. - `audience`: optional while partial; 2–500 non-whitespace characters when supplied and required for completion. -- `colors`: zero values while partial, otherwise 1–3 valid hexadecimal colors for completion. +- `colors`: zero values while partial, otherwise 1–3 colors matching the canonical six-digit hexadecimal format `#RRGGBB` (leading `#` required; hex digits are case-insensitive) for completion. - `avoid_words`: optional. ## Errors diff --git a/specs/006-brand-kit/data-model.md b/specs/006-brand-kit/data-model.md index 59a7de2..530625c 100644 --- a/specs/006-brand-kit/data-model.md +++ b/specs/006-brand-kit/data-model.md @@ -10,7 +10,7 @@ One row belongs to exactly one `brands` row. `brand_id` is both the primary key | `tagline` | text | Optional; maximum 160 characters | | `tone` | `tone_t` | Optional while partial; required for `complete`; values: `formal`, `casual`, `playful`, `professional`, `friendly` | | `audience` | text | Optional while partial; when supplied, 2–500 non-whitespace characters; required for `complete` | -| `colors` | text[] | Default empty array for partial kits; 1–3 valid hexadecimal colors required for `complete` | +| `colors` | text[] | Default empty array for partial kits; 1–3 colors matching the canonical six-digit hexadecimal format `#RRGGBB` (leading `#` required; hex digits are case-insensitive) required for `complete` | | `avoid_words` | text | Optional | | `summary` | text | Null or partial while incomplete; deterministic derived text when complete | | `status` | `kit_status_t` | `not_started`, `in_progress`, or `complete`; derived from saved answers | diff --git a/specs/006-brand-kit/plan.md b/specs/006-brand-kit/plan.md index b26f81b..08007c5 100644 --- a/specs/006-brand-kit/plan.md +++ b/specs/006-brand-kit/plan.md @@ -6,7 +6,7 @@ ## Summary -Implement the six-step Brand Kit interview across the FastAPI API, Supabase schema, and Next.js dashboard. Add one owner-scoped `brand_kits` record per brand, derive status and summary server-side, expose authenticated GET/PUT endpoints, and add a wizard route with auto-save, explicit save, resume, completion summary, and navigation status. Existing brand ownership, error formatting, and hard-delete behavior will be reused. +Implement the six-step Brand Kit interview across the FastAPI API, Supabase schema, and Next.js dashboard. Each brand has at most one owner-scoped `brand_kits` record; a brand with zero persisted answers has no kit row and is reported as `not_started`. Derive status and summary server-side, expose authenticated GET/PUT endpoints, and add a wizard route with auto-save, explicit save, resume, completion summary, and navigation status. Existing brand ownership, error formatting, and hard-delete behavior will be reused. ## Technical Context @@ -14,7 +14,7 @@ Implement the six-step Brand Kit interview across the FastAPI API, Supabase sche **Primary Dependencies**: FastAPI, Pydantic v2, SQLAlchemy text queries, Supabase PostgreSQL/RLS, Supabase SSR client, Playwright -**Storage**: Supabase PostgreSQL `brand_kits` table with one row per brand; `brands` queries derive `kit_status` with a left join so zero-answer kits require no row +**Storage**: Supabase PostgreSQL `brand_kits` table with at most one row per brand; `brands` queries derive `kit_status` with a left join, defaulting a missing zero-answer row to `not_started` **Testing**: Backend contract and unit tests, real Supabase integration/RLS tests, frontend Playwright E2E, ESLint, TypeScript/Next.js production build @@ -61,9 +61,11 @@ backend/app/main.py # router registration backend/app/models/brand.py # kit_status in brand responses backend/app/services/brand_store.py # derived status in brand queries backend/tests/contract/test_brand_kits.py +backend/tests/unit/test_brand_kit_store.py backend/tests/integration/test_brand_kit_rls.py backend/tests/integration/test_brand_kits.py +frontend/app/(dashboard)/brands/page.tsx frontend/app/(dashboard)/brands/[brandId]/kit/page.tsx frontend/app/(dashboard)/brands/[brandId]/page.tsx frontend/app/(dashboard)/layout.tsx diff --git a/specs/006-brand-kit/quickstart.md b/specs/006-brand-kit/quickstart.md index 030b108..ead4fd8 100644 --- a/specs/006-brand-kit/quickstart.md +++ b/specs/006-brand-kit/quickstart.md @@ -31,7 +31,7 @@ From the repository root: ```bash -backend/.venv/bin/python -m pytest -q backend/tests/contract/test_brand_kits.py +backend/.venv/bin/python -m pytest -q backend/tests/contract/test_brand_kits.py backend/tests/unit/test_brand_kit_store.py backend/.venv/bin/python -m pytest -q backend/tests/integration/test_brand_kits.py backend/tests/integration/test_brand_kit_rls.py cd frontend npm run lint diff --git a/specs/006-brand-kit/spec.md b/specs/006-brand-kit/spec.md index 0f8c18c..95ca498 100644 --- a/specs/006-brand-kit/spec.md +++ b/specs/006-brand-kit/spec.md @@ -15,7 +15,7 @@ - Q: What does the Name step do? → A: It edits the existing brand name; it does not create a separate kit name or a second brand. - Q: How should unauthorized access to another user’s kit respond? → A: Return a generic not-found response with no kit data, without revealing whether the brand exists. - Q: How should simultaneous saves from multiple tabs or sessions be handled? → A: The last successful save wins. -- Q: How many colors are required for a complete kit? → A: Require 1–3 valid hexadecimal colors. +- Q: How many colors are required for a complete kit? → A: Require 1–3 colors matching the canonical six-digit hexadecimal format `#RRGGBB` (the leading `#` is required; hex digits are case-insensitive). - Q: When should wizard progress be saved? → A: Auto-save after each completed step and support an explicit save action. ## User Scenarios & Testing @@ -62,13 +62,14 @@ A brand owner can manage only kits belonging to their own brands, while other us 1. **Given** an authenticated owner requests their own brand kit, **When** the request is processed, **Then** the kit is returned. 2. **Given** an authenticated user requests another user’s brand kit by identifier, **When** the request is processed, **Then** no kit data is disclosed and a generic not-found response is returned. 3. **Given** a visitor has no valid session, **When** they request or update a brand kit, **Then** the request is rejected without exposing kit data. +4. **Given** an owner’s kit is `not_started`, `in_progress`, or `complete`, **When** they view brand navigation, **Then** the navigation displays the matching kit status. ### Edge Cases - A kit is requested before any answers have been saved. - A required answer is blank, whitespace-only, or outside its allowed length. - A tone value is not one of the five supported choices. -- More than three colors are submitted, or a color is not a valid hexadecimal value. +- More than three colors are submitted, or a color does not match the canonical six-digit hexadecimal format `#RRGGBB` (leading `#` required; hex digits case-insensitive). - A user tries to mark a kit complete while a required answer is missing. - A save is repeated with the same answers and must not create duplicate kits. - Two tabs or sessions save different answers for the same brand at nearly the same time. @@ -84,8 +85,8 @@ A brand owner can manage only kits belonging to their own brands, while other us - **FR-002**: The Name step MUST edit the existing brand name, and the interview MUST NOT create a separate kit name or a second brand. - **FR-003**: The tagline MUST be optional and limited to 160 characters when supplied. - **FR-004**: Tone MUST be required and limited to `formal`, `casual`, `playful`, `professional`, or `friendly`. -- **FR-005**: Audience MUST be required and contain between 2 and 500 non-whitespace characters. -- **FR-006**: Colors MUST contain at least one and no more than three values, and each value MUST be a valid hexadecimal color. +- **FR-005**: For a kit to reach the `complete` lifecycle state, audience MUST be supplied and contain between 2 and 500 non-whitespace characters; partial saves MAY omit or leave audience empty. +- **FR-006**: For a kit to reach the `complete` lifecycle state, colors MUST contain at least one and no more than three values, and each value MUST match the canonical six-digit hexadecimal format `#RRGGBB` (leading `#` required; hex digits are case-insensitive); partial saves MAY omit or leave colors empty. - **FR-007**: Avoid words MUST be optional. - **FR-008**: The system MUST allow a kit to be read when it has no saved answers and MUST return status `not_started` in that state. - **FR-009**: The system MUST save partial answers and return status `in_progress` until all required fields are valid. diff --git a/specs/006-brand-kit/tasks.md b/specs/006-brand-kit/tasks.md index fa13a18..720dd7e 100644 --- a/specs/006-brand-kit/tasks.md +++ b/specs/006-brand-kit/tasks.md @@ -23,10 +23,10 @@ - [ ] T004 Create `supabase/migrations/00017_create_brand_kits.sql` with enum types `tone_t` (`formal`, `casual`, `playful`, `professional`, `friendly`) and `kit_status_t` (`not_started`, `in_progress`, `complete`), unless those types already exist; fail the migration if duplicate type creation would be unsafe. - [ ] T005 Add the `brand_kits` table in `supabase/migrations/00017_create_brand_kits.sql` with `brand_id UUID PRIMARY KEY REFERENCES brands(id) ON DELETE CASCADE`, nullable `tagline` (max 160), nullable `tone`, nullable `audience`, `colors TEXT[] NOT NULL DEFAULT '{}'`, nullable `avoid_words`, nullable `summary`, `status` defaulting to `not_started`, nullable `completed_at`, `created_at`, and `updated_at`. -- [ ] T006 Add database constraints in `supabase/migrations/00017_create_brand_kits.sql`: audience must be 2–500 trimmed characters when non-null; colors must contain no more than 3 values; every stored color must match a six-digit hexadecimal format; a complete kit must have a valid tone, audience, and 1–3 colors; an incomplete kit must not retain `completed_at` or `summary`. +- [ ] T006 Add database constraints in `supabase/migrations/00017_create_brand_kits.sql`: audience must be 2–500 trimmed characters when non-null; colors must contain no more than 3 values; every stored color must match the canonical six-digit hexadecimal format `#RRGGBB` (leading `#` required; hex digits are case-insensitive); a complete kit must have a valid tone, audience, and 1–3 colors; an incomplete kit must not retain `completed_at` or `summary`. - [ ] T007 Add the existing `set_updated_at()` trigger, enable and force RLS on `brand_kits`, add an owner-only policy using `private.is_brand_owner(brand_id)`, revoke direct client DML where the repository convention requires it, and grant the backend service role the required DML in `supabase/migrations/00017_create_brand_kits.sql`. - [ ] T008 Add the new table’s SELECT/INSERT/UPDATE/DELETE privilege checks to `_DATABASE_ROLE_PRIVILEGES` in `backend/app/config.py`; preserve the existing vault least-privilege checks and existing table checks. -- [ ] T009 [P] Create `backend/app/models/brand_kit.py` with Pydantic models for `BrandKitAnswers`, `BrandKitUpsert`, `BrandKit`, `KitStatus`, and `Tone`; validate trimmed name (2–120), tagline (0–160), audience (2–500 when supplied), tone enum, and colors (0 while partial or 1–3 valid hex colors when complete). +- [ ] T009 [P] Create `backend/app/models/brand_kit.py` with Pydantic models for `BrandKitAnswers`, `BrandKitUpsert`, `BrandKit`, `KitStatus`, and `Tone`; validate trimmed name (2–120), tagline (0–160), audience (2–500 when supplied), tone enum, and colors (0 while partial or 1–3 colors matching the canonical six-digit hexadecimal format `#RRGGBB` (leading `#` required; hex digits are case-insensitive) when complete). - [ ] T010 [P] Add `kit_status: Literal["not_started", "in_progress", "complete"]` to the brand response model in `backend/app/models/brand.py` without removing or renaming any existing brand response fields. - [ ] T011 Update `backend/app/services/brand_store.py` list and get queries to left join `brand_kits`, return `kit_status = 'not_started'` when no kit row exists, and return the stored kit status otherwise; update `_to_brand` and related tests for the new response field. - [ ] T012 [P] Create `backend/app/services/brand_kit_store.py` with dependency factory `get_brand_kit_store`, a service dataclass using the existing engine, deterministic summary derivation, and helper functions that map database rows to the Pydantic response without exposing internal columns. @@ -54,10 +54,10 @@ - [ ] T020 [US1] Complete the GET and PUT route handlers in `backend/app/routes/brand_kits.py`, including request logging with only event and request ID fields and response models matching `contracts/brand-kit.md`. - [ ] T021 [US1] Add the Brand Kit entry point to `frontend/app/(dashboard)/brands/[brandId]/page.tsx`: render a clear link to `/brands/{brandId}/kit`, disable or explain unavailability when the brand is in cleanup, and preserve the existing logo/provider/delete sections. - [ ] T022 [US1] Create `frontend/app/(dashboard)/brands/[brandId]/kit/page.tsx` with authenticated loading, six ordered steps (Name, Tagline, Tone, Audience, Colors, Avoid words), Previous/Next controls, field labels, inline validation, and a visible current-step indicator. -- [ ] T023 [US1] In `frontend/app/(dashboard)/brands/[brandId]/kit/page.tsx`, submit the full form to `PUT /v1/brands/{brandId}/kit` with the Supabase access token, display API validation errors, disable controls while saving, and display a complete summary after a successful complete response. +- [ ] T023 [US1] In `frontend/app/(dashboard)/brands/[brandId]/kit/page.tsx`, submit the full form to `PUT /api/v1/brands/{brand_id}/kit` with the Supabase access token, display API validation errors, disable controls while saving, and display a complete summary after a successful complete response. - [ ] T024 [US1] Add the complete-kit browser journey to `frontend/tests/e2e/brand-kit.spec.ts`: record a start timestamp, create or select a test brand, fill all six steps with valid values and two colors, submit, assert the summary and complete status within 5 minutes, reload, and assert the values remain. -**US1 checkpoint**: `backend/.venv/bin/python -m pytest -q backend/tests/contract/test_brand_kits.py backend/tests/unit/test_brand_kit_store.py backend/.venv/bin/python -m pytest -q backend/tests/integration/test_brand_kits.py` (run as separate commands) and the complete-kit Playwright test pass. +**US1 checkpoint**: Run these two shell commands separately: `backend/.venv/bin/python -m pytest -q backend/tests/contract/test_brand_kits.py backend/tests/unit/test_brand_kit_store.py` and `backend/.venv/bin/python -m pytest -q backend/tests/integration/test_brand_kits.py`. The complete-kit Playwright test must also pass. ## Phase 4: User Story 2 — Save and resume partial answers (P1) @@ -67,7 +67,7 @@ ### Tests first -- [ ] T025 [P] [US2] Add contract tests in `backend/tests/contract/test_brand_kits.py` for partial PUT returning `in_progress` with null summary/completed_at, omitted fields preserving stored values, explicit null/empty fields clearing values, empty colors being accepted while partial, invalid completion being rejected, and repeated PUT not creating a duplicate row. +- [ ] T025 [P] [US2] Add contract tests in `backend/tests/contract/test_brand_kits.py` for zero-answer PUT returning `not_started` with null summary/completed_at, at-least-one-answer PUT returning `in_progress`, omitted fields preserving stored values, explicit null/empty fields clearing values, empty colors being accepted while partial, invalid completion being rejected, and repeated PUT not creating a duplicate row. - [ ] T026 [P] [US2] Add unit tests in `backend/tests/unit/test_brand_kit_store.py` for `not_started`/`in_progress`/`complete` transitions, clearing summary/completed_at when a complete kit becomes incomplete, and last-successful-save-wins transaction behavior. - [ ] T027 [P] [US2] Add integration tests in `backend/tests/integration/test_brand_kits.py` for partial save/reload, omitted optional answers producing a complete summary with explicit unspecified values, invalid save preserving the prior valid row, and overlapping saves retaining the later committed values. @@ -77,7 +77,7 @@ - [ ] T029 [US2] Add explicit `Save progress` and automatic save-on-Next behavior to `frontend/app/(dashboard)/brands/[brandId]/kit/page.tsx`; keep the user on the current page after a partial save and show `Saved`, `Saving...`, or an actionable error state. - [ ] T030 [US2] Add resume behavior to `frontend/app/(dashboard)/brands/[brandId]/kit/page.tsx`: populate all fields from GET, restore the first incomplete step, allow Previous navigation, and do not overwrite local edits with a stale load response. - [ ] T031 [US2] Add the partial/resume browser journey to `frontend/tests/e2e/brand-kit.spec.ts`: verify empty `not_started`, save one answer, reload, verify `in_progress`, navigate backward/forward, and complete with optional fields omitted. -- [ ] T032 [US2] Add clear validation messages in `frontend/app/(dashboard)/brands/[brandId]/kit/page.tsx` for blank name, short audience, invalid tone, zero colors on completion, more than three colors, invalid hex color, and overlong tagline; prevent network submission when client validation fails. +- [ ] T032 [US2] Add clear validation messages in `frontend/app/(dashboard)/brands/[brandId]/kit/page.tsx` for blank name, short audience, invalid tone, zero colors on completion, more than three colors, colors not matching the canonical six-digit hexadecimal format `#RRGGBB` (leading `#` required; hex digits case-insensitive), and overlong tagline; prevent network submission when client validation fails. **US2 checkpoint**: Contract/unit/integration partial-state tests and the partial/resume Playwright test pass; a failed save leaves the previous valid response visible. From 6d9cc426ed097f63ec9c352f3be0d43f84b6c8cf Mon Sep 17 00:00:00 2001 From: Mohammed Zayan Date: Wed, 29 Jul 2026 06:10:03 +0300 Subject: [PATCH 03/10] Complete brand kit setup phase --- backend/tests/contract/test_brand_kits.py | 23 ++++++++++++ .../tests/integration/test_brand_kit_rls.py | 16 +++++++++ backend/tests/integration/test_brand_kits.py | 17 +++++++++ backend/tests/unit/test_brand_kit_store.py | 23 ++++++++++++ frontend/tests/e2e/brand-kit.spec.ts | 35 +++++++++++++++++++ specs/006-brand-kit/tasks.md | 6 ++-- 6 files changed, 117 insertions(+), 3 deletions(-) create mode 100644 backend/tests/contract/test_brand_kits.py create mode 100644 backend/tests/integration/test_brand_kit_rls.py create mode 100644 backend/tests/integration/test_brand_kits.py create mode 100644 backend/tests/unit/test_brand_kit_store.py create mode 100644 frontend/tests/e2e/brand-kit.spec.ts diff --git a/backend/tests/contract/test_brand_kits.py b/backend/tests/contract/test_brand_kits.py new file mode 100644 index 0000000..6111ae1 --- /dev/null +++ b/backend/tests/contract/test_brand_kits.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from uuid import UUID + +import pytest + + +BRAND_ID = UUID("22222222-2222-2222-2222-222222222222") +OWNER_USER_ID = "11111111-1111-1111-1111-111111111111" +COMPLETE_ANSWERS = { + "tagline": "Innovation for everyone", + "tone": "professional", + "audience": "Small business owners aged 25-45", + "colors": ["#FF5733", "#3498DB"], + "avoid_words": "cheap, discount", +} + + +@pytest.mark.skip(reason="Brand Kit API behavior starts in Phase 2") +def test_brand_kit_contract_scaffold() -> None: + assert BRAND_ID + assert OWNER_USER_ID + assert COMPLETE_ANSWERS diff --git a/backend/tests/integration/test_brand_kit_rls.py b/backend/tests/integration/test_brand_kit_rls.py new file mode 100644 index 0000000..62a076d --- /dev/null +++ b/backend/tests/integration/test_brand_kit_rls.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +from uuid import UUID + +import pytest + + +BRAND_ID = UUID("22222222-2222-2222-2222-222222222222") +OWNER_USER_ID = "11111111-1111-1111-1111-111111111111" +OTHER_USER_ID = "99999999-9999-9999-9999-999999999999" + + +@pytest.mark.skip(reason="Brand Kit RLS behavior starts in Phase 2") +def test_brand_kit_rls_scaffold() -> None: + assert BRAND_ID + assert OWNER_USER_ID != OTHER_USER_ID diff --git a/backend/tests/integration/test_brand_kits.py b/backend/tests/integration/test_brand_kits.py new file mode 100644 index 0000000..3dde023 --- /dev/null +++ b/backend/tests/integration/test_brand_kits.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from uuid import UUID + +import pytest + + +BRAND_ID = UUID("22222222-2222-2222-2222-222222222222") +OWNER_USER_ID = "11111111-1111-1111-1111-111111111111" +BRAND_NAME = "Brand Kit Integration" + + +@pytest.mark.skip(reason="Brand Kit integration behavior starts in Phase 2") +def test_brand_kit_integration_scaffold() -> None: + assert BRAND_ID + assert OWNER_USER_ID + assert BRAND_NAME diff --git a/backend/tests/unit/test_brand_kit_store.py b/backend/tests/unit/test_brand_kit_store.py new file mode 100644 index 0000000..b31f6ca --- /dev/null +++ b/backend/tests/unit/test_brand_kit_store.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from uuid import UUID + +import pytest + + +BRAND_ID = UUID("22222222-2222-2222-2222-222222222222") +BRAND_NAME = "My Brand" +COMPLETE_ANSWERS = { + "tagline": "Innovation for everyone", + "tone": "professional", + "audience": "Small business owners aged 25-45", + "colors": ["#FF5733", "#3498DB"], + "avoid_words": "cheap, discount", +} + + +@pytest.mark.skip(reason="BrandKitStore behavior starts in Phase 2") +def test_brand_kit_store_scaffold() -> None: + assert BRAND_ID + assert BRAND_NAME + assert COMPLETE_ANSWERS diff --git a/frontend/tests/e2e/brand-kit.spec.ts b/frontend/tests/e2e/brand-kit.spec.ts new file mode 100644 index 0000000..fe7953c --- /dev/null +++ b/frontend/tests/e2e/brand-kit.spec.ts @@ -0,0 +1,35 @@ +import { expect, test } from "@playwright/test"; + + +const BRAND_ID = "22222222-2222-2222-2222-222222222222"; +const BRAND_KIT_PATH = `/brands/${BRAND_ID}/kit`; + +test.describe.skip("Brand Kit wizard with the local app stack", () => { + test("completes the six ordered interview steps", async ({ page }) => { + await page.goto(BRAND_KIT_PATH); + + for (const step of ["Name", "Tagline", "Tone", "Audience", "Colors", "Avoid words"]) { + await expect(page.getByText(step, { exact: true })).toBeVisible(); + } + }); + + test("resumes partially saved answers after reload", async ({ page }) => { + await page.goto(BRAND_KIT_PATH); + await page.reload(); + + await expect(page.getByText("In progress", { exact: true })).toBeVisible(); + }); + + test("shows the deterministic summary after completion", async ({ page }) => { + await page.goto(BRAND_KIT_PATH); + + await expect(page.getByText("Complete", { exact: true })).toBeVisible(); + await expect(page.getByText(/Brand: My Brand/)).toBeVisible(); + }); + + test("redirects unauthorized visitors to login", async ({ page }) => { + await page.goto(BRAND_KIT_PATH); + + await expect(page).toHaveURL(/\/login$/); + }); +}); diff --git a/specs/006-brand-kit/tasks.md b/specs/006-brand-kit/tasks.md index 720dd7e..561d0d6 100644 --- a/specs/006-brand-kit/tasks.md +++ b/specs/006-brand-kit/tasks.md @@ -13,9 +13,9 @@ ## Phase 1: Setup -- [ ] T001 Confirm the working tree is on branch `006-brand-kit` and read `specs/006-brand-kit/contracts/brand-kit.md` before editing implementation files. -- [ ] T002 [P] Create the backend test files `backend/tests/contract/test_brand_kits.py`, `backend/tests/unit/test_brand_kit_store.py`, `backend/tests/integration/test_brand_kits.py`, and `backend/tests/integration/test_brand_kit_rls.py` with imports and shared constants matching the existing brand/provider test style; do not implement production behavior yet. -- [ ] T003 [P] Create the frontend E2E file `frontend/tests/e2e/brand-kit.spec.ts` with the local app assumptions from `specs/006-brand-kit/quickstart.md`; keep test cases focused on the six-step wizard, partial resume, complete summary, and unauthorized access. +- [X] T001 Confirm the working tree is on branch `006-brand-kit` and read `specs/006-brand-kit/contracts/brand-kit.md` before editing implementation files. +- [X] T002 [P] Create the backend test files `backend/tests/contract/test_brand_kits.py`, `backend/tests/unit/test_brand_kit_store.py`, `backend/tests/integration/test_brand_kits.py`, and `backend/tests/integration/test_brand_kit_rls.py` with imports and shared constants matching the existing brand/provider test style; do not implement production behavior yet. +- [X] T003 [P] Create the frontend E2E file `frontend/tests/e2e/brand-kit.spec.ts` with the local app assumptions from `specs/006-brand-kit/quickstart.md`; keep test cases focused on the six-step wizard, partial resume, complete summary, and unauthorized access. ## Phase 2: Foundational backend and database work From dd925c45ab2e30c56a46347b8d23f4f404b8b43c Mon Sep 17 00:00:00 2001 From: Mohammed Zayan Date: Wed, 29 Jul 2026 06:18:33 +0300 Subject: [PATCH 04/10] Strengthen brand kit validation artifacts --- frontend/tests/e2e/brand-kit.spec.ts | 55 ++++++++++++++++++++++++++-- specs/006-brand-kit/data-model.md | 23 ++++++++++++ 2 files changed, 75 insertions(+), 3 deletions(-) diff --git a/frontend/tests/e2e/brand-kit.spec.ts b/frontend/tests/e2e/brand-kit.spec.ts index fe7953c..1ba5999 100644 --- a/frontend/tests/e2e/brand-kit.spec.ts +++ b/frontend/tests/e2e/brand-kit.spec.ts @@ -8,13 +8,48 @@ test.describe.skip("Brand Kit wizard with the local app stack", () => { test("completes the six ordered interview steps", async ({ page }) => { await page.goto(BRAND_KIT_PATH); - for (const step of ["Name", "Tagline", "Tone", "Audience", "Colors", "Avoid words"]) { - await expect(page.getByText(step, { exact: true })).toBeVisible(); - } + await expect(page.getByRole("heading", { name: "Name", exact: true })).toBeVisible(); + await page.getByLabel("Brand name").fill("My Brand"); + await page.getByRole("button", { name: "Next" }).click(); + await expect(page.getByRole("heading", { name: "Tagline", exact: true })).toBeVisible(); + + await page.getByLabel("Tagline").fill("Innovation for everyone"); + await page.getByRole("button", { name: "Next" }).click(); + await expect(page.getByRole("heading", { name: "Tone", exact: true })).toBeVisible(); + + await page.getByLabel("Tone").selectOption("professional"); + await page.getByRole("button", { name: "Next" }).click(); + await expect(page.getByRole("heading", { name: "Audience", exact: true })).toBeVisible(); + + await page.getByLabel("Audience").fill("Small business owners aged 25-45"); + await page.getByRole("button", { name: "Next" }).click(); + await expect(page.getByRole("heading", { name: "Colors", exact: true })).toBeVisible(); + + await page.getByLabel("Colors").fill("#FF5733, #3498DB"); + await page.getByRole("button", { name: "Next" }).click(); + await expect(page.getByRole("heading", { name: "Avoid words", exact: true })).toBeVisible(); + + await page.getByLabel("Avoid words").fill("cheap, discount"); + await page.getByRole("button", { name: "Complete kit" }).click(); + await expect(page.getByText("Complete", { exact: true })).toBeVisible(); + await expect(page.getByText(/Brand: My Brand/)).toBeVisible(); }); test("resumes partially saved answers after reload", async ({ page }) => { await page.goto(BRAND_KIT_PATH); + + await page.getByLabel("Brand name").fill("My Brand"); + await page.getByRole("button", { name: "Next" }).click(); + await page.getByLabel("Tagline").fill(""); + await page.getByRole("button", { name: "Next" }).click(); + await page.getByLabel("Tone").selectOption(""); + await page.getByRole("button", { name: "Next" }).click(); + await page.getByLabel("Audience").fill(""); + await page.getByRole("button", { name: "Next" }).click(); + await page.getByLabel("Colors").fill(""); + await page.getByRole("button", { name: "Next" }).click(); + await page.getByLabel("Avoid words").fill(""); + await page.getByRole("button", { name: "Save progress" }).click(); await page.reload(); await expect(page.getByText("In progress", { exact: true })).toBeVisible(); @@ -23,6 +58,20 @@ test.describe.skip("Brand Kit wizard with the local app stack", () => { test("shows the deterministic summary after completion", async ({ page }) => { await page.goto(BRAND_KIT_PATH); + await page.getByLabel("Brand name").fill("My Brand"); + await page.getByRole("button", { name: "Next" }).click(); + await page.getByLabel("Tagline").fill("Innovation for everyone"); + await page.getByRole("button", { name: "Next" }).click(); + await page.getByLabel("Tone").selectOption("professional"); + await page.getByRole("button", { name: "Next" }).click(); + await page.getByLabel("Audience").fill("Small business owners aged 25-45"); + await page.getByRole("button", { name: "Next" }).click(); + await page.getByLabel("Colors").fill("#FF5733, #3498DB"); + await page.getByRole("button", { name: "Next" }).click(); + await page.getByLabel("Avoid words").fill("cheap, discount"); + await page.getByRole("button", { name: "Complete kit" }).click(); + await page.reload(); + await expect(page.getByText("Complete", { exact: true })).toBeVisible(); await expect(page.getByText(/Brand: My Brand/)).toBeVisible(); }); diff --git a/specs/006-brand-kit/data-model.md b/specs/006-brand-kit/data-model.md index 530625c..b67a6c9 100644 --- a/specs/006-brand-kit/data-model.md +++ b/specs/006-brand-kit/data-model.md @@ -29,6 +29,29 @@ Brand API responses gain a derived `kit_status` field. It is `not_started` when 3. A row with a valid name and all required answers, including 1–3 colors → `complete`, summary populated, `completed_at` set. 4. Editing a complete kit so a required field becomes invalid → `in_progress`, summary cleared, `completed_at` cleared. +## Deterministic summary + +Only a `complete` kit receives a summary; incomplete kits persist and expose `summary: null`. The server generates the exact same string for the persisted `summary` and API response using this field order and template, with a single line-feed (`\n`) between lines and no trailing newline: + +```text +Brand: {brand_name} +Tagline: {tagline} +Tone: {tone} +Audience: {audience} +Colors: {colors} +Avoid words: {avoid_words} +``` + +Before substitution, trim leading and trailing whitespace from every text value, collapse internal whitespace runs to one space, render `tone` in its enum spelling, normalize each color to uppercase canonical `#RRGGBB` while preserving array order, and join colors with `, `. Empty or omitted optional `tagline` and `avoid_words` values render as `None specified`; required values must already be valid. No client-supplied summary is accepted. + +Examples: + +- Complete without optional values → `Brand: Acme\nTagline: None specified\nTone: professional\nAudience: Small business owners\nColors: #FF5733, #3498DB\nAvoid words: None specified`. +- Incomplete with one saved answer → `status: in_progress`, `summary: null`. +- Complete with avoid words `cheap, discount` → the final line is `Avoid words: cheap, discount`; every other line remains in the same order and format. + +These rules make the persisted and API-visible summary deterministic for the same normalized answers. + The existing brand name is updated in the same transaction as the kit upsert. Each write locks the owned brand row; overlapping successful writes use last-successful-save-wins behavior. ## Security and deletion From 39377861008a5ab3223931c5fc54e5d62e36f930 Mon Sep 17 00:00:00 2001 From: Mohammed Zayan Date: Wed, 29 Jul 2026 06:48:21 +0300 Subject: [PATCH 05/10] Build Brand Kit foundation --- backend/app/config.py | 4 + backend/app/main.py | 2 + backend/app/models/brand.py | 1 + backend/app/models/brand_kit.py | 108 +++++++++++++++++ backend/app/routes/brand_kits.py | 89 ++++++++++++++ backend/app/services/brand_kit_store.py | 102 ++++++++++++++++ backend/app/services/brand_store.py | 38 ++++-- backend/tests/contract/test_brands.py | 3 + specs/006-brand-kit/tasks.md | 22 ++-- .../migrations/00017_create_brand_kits.sql | 114 ++++++++++++++++++ 10 files changed, 460 insertions(+), 23 deletions(-) create mode 100644 backend/app/models/brand_kit.py create mode 100644 backend/app/routes/brand_kits.py create mode 100644 backend/app/services/brand_kit_store.py create mode 100644 supabase/migrations/00017_create_brand_kits.sql diff --git a/backend/app/config.py b/backend/app/config.py index 7301570..639f885 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -80,6 +80,10 @@ def get_engine() -> Engine: AND has_table_privilege(current_user, 'public.brand_asset_operations', 'INSERT') AND has_table_privilege(current_user, 'public.brand_asset_operations', 'UPDATE') AND has_table_privilege(current_user, 'public.brand_asset_operations', 'DELETE') + AND has_table_privilege(current_user, 'public.brand_kits', 'SELECT') + AND has_table_privilege(current_user, 'public.brand_kits', 'INSERT') + AND has_table_privilege(current_user, 'public.brand_kits', 'UPDATE') + AND has_table_privilege(current_user, 'public.brand_kits', 'DELETE') AS application_dml, has_schema_privilege(current_user, 'vault', 'USAGE') AS vault_schema_usage, has_function_privilege( diff --git a/backend/app/main.py b/backend/app/main.py index f3c7857..74a302a 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -14,6 +14,7 @@ from .config import assert_database_role_privileges, load_settings from .routes.auth import router as auth_router +from .routes.brand_kits import router as brand_kits_router from .routes.brands import router as brands_router from .routes.health import router as health_router from .routes.me import router as me_router @@ -150,6 +151,7 @@ async def root() -> dict[str, str]: app.include_router(auth_router) +app.include_router(brand_kits_router) app.include_router(brands_router) app.include_router(health_router) app.include_router(me_router) diff --git a/backend/app/models/brand.py b/backend/app/models/brand.py index cce3e93..a2479bf 100644 --- a/backend/app/models/brand.py +++ b/backend/app/models/brand.py @@ -28,6 +28,7 @@ class Brand(BaseModel): name: str logo_url: str | None cleanup_state: Literal["normal", "cleanup_required"] = "normal" + kit_status: Literal["not_started", "in_progress", "complete"] = "not_started" created_at: datetime diff --git a/backend/app/models/brand_kit.py b/backend/app/models/brand_kit.py new file mode 100644 index 0000000..b99528c --- /dev/null +++ b/backend/app/models/brand_kit.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +import re +from datetime import datetime +from enum import Enum +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field, field_validator + + +_COLOR_PATTERN = re.compile(r"#[0-9A-Fa-f]{6}\Z") + + +class KitStatus(str, Enum): + NOT_STARTED = "not_started" + IN_PROGRESS = "in_progress" + COMPLETE = "complete" + + +class Tone(str, Enum): + FORMAL = "formal" + CASUAL = "casual" + PLAYFUL = "playful" + PROFESSIONAL = "professional" + FRIENDLY = "friendly" + + +class BrandKitAnswers(BaseModel): + model_config = ConfigDict(extra="forbid") + + tagline: str | None = None + tone: Tone | None = None + audience: str | None = None + colors: list[str] = Field(default_factory=list) + avoid_words: str | None = None + + @field_validator("tagline", "audience", "avoid_words", mode="before") + @classmethod + def trim_nullable_text(cls, value: object) -> object: + if not isinstance(value, str): + return value + normalized = value.strip() + return normalized or None + + @field_validator("tone", mode="before") + @classmethod + def clear_blank_tone(cls, value: object) -> object: + if isinstance(value, str): + normalized = value.strip() + return normalized or None + return value + + @field_validator("tagline") + @classmethod + def validate_tagline(cls, value: str | None) -> str | None: + if value is not None and len(value) > 160: + raise ValueError("tagline must be 160 characters or fewer.") + return value + + @field_validator("audience") + @classmethod + def validate_audience(cls, value: str | None) -> str | None: + if value is not None and not 2 <= len(value) <= 500: + raise ValueError("audience must be between 2 and 500 characters.") + return value + + @field_validator("colors", mode="before") + @classmethod + def clear_null_colors(cls, value: object) -> object: + return [] if value is None else value + + @field_validator("colors") + @classmethod + def normalize_colors(cls, value: list[str]) -> list[str]: + if len(value) > 3: + raise ValueError("colors must contain no more than 3 values.") + + normalized = [color.strip().upper() for color in value] + if any(_COLOR_PATTERN.fullmatch(color) is None for color in normalized): + raise ValueError("colors must use the #RRGGBB format.") + return normalized + + +class BrandKitUpsert(BaseModel): + model_config = ConfigDict(extra="forbid") + + name: str + answers: BrandKitAnswers = Field(default_factory=BrandKitAnswers) + + @field_validator("name") + @classmethod + def validate_name(cls, value: str) -> str: + normalized = value.strip() + if not 2 <= len(normalized) <= 120: + raise ValueError("name must be between 2 and 120 characters.") + return normalized + + +class BrandKit(BaseModel): + model_config = ConfigDict(extra="forbid") + + brand_id: UUID + brand_name: str + answers: BrandKitAnswers + summary: str | None + status: KitStatus + completed_at: datetime | None + updated_at: datetime | None diff --git a/backend/app/routes/brand_kits.py b/backend/app/routes/brand_kits.py new file mode 100644 index 0000000..ee32829 --- /dev/null +++ b/backend/app/routes/brand_kits.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +import logging +from typing import Annotated +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, Request, status + +from ..auth import CurrentUserDep +from ..models.brand_kit import BrandKit, BrandKitUpsert +from ..services.brand_kit_store import ( + BrandKitStore, + get_brand_kit_store, +) +from ..services.brand_store import BrandCleanupRequiredError + + +router = APIRouter(prefix="/api/v1/brands", tags=["brand-kits"]) +logger = logging.getLogger(__name__) + +BrandKitStoreDep = Annotated[BrandKitStore, Depends(get_brand_kit_store)] + + +def _not_found() -> HTTPException: + return HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={"code": "BRAND_NOT_FOUND", "message": "Brand not found."}, + ) + + +def _cleanup_required() -> HTTPException: + return HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail={ + "code": "BRAND_CLEANUP_REQUIRED", + "message": "Brand cleanup is required. Retry deletion.", + }, + ) + + +def _map_access_error(exc: Exception) -> HTTPException: + if isinstance(exc, BrandCleanupRequiredError): + return _cleanup_required() + return _not_found() + + +@router.get("/{brand_id}/kit", response_model=BrandKit) +def get_brand_kit( + request: Request, + brand_id: UUID, + current_user: CurrentUserDep, + brand_kit_store: BrandKitStoreDep, +) -> BrandKit: + try: + kit = brand_kit_store.get_kit(current_user.user_id, brand_id) + except (LookupError, BrandCleanupRequiredError) as exc: + raise _map_access_error(exc) from exc + + logger.info( + "brand_kits.get_success", + extra={ + "event": "brand_kits.get_success", + "request_id": getattr(request.state, "request_id", "unknown"), + }, + ) + return kit + + +@router.put("/{brand_id}/kit", response_model=BrandKit) +def put_brand_kit( + request: Request, + brand_id: UUID, + payload: BrandKitUpsert, + current_user: CurrentUserDep, + brand_kit_store: BrandKitStoreDep, +) -> BrandKit: + try: + kit = brand_kit_store.upsert_kit(current_user.user_id, brand_id, payload) + except (LookupError, BrandCleanupRequiredError) as exc: + raise _map_access_error(exc) from exc + + logger.info( + "brand_kits.put_success", + extra={ + "event": "brand_kits.put_success", + "request_id": getattr(request.state, "request_id", "unknown"), + }, + ) + return kit diff --git a/backend/app/services/brand_kit_store.py b/backend/app/services/brand_kit_store.py new file mode 100644 index 0000000..afc79bc --- /dev/null +++ b/backend/app/services/brand_kit_store.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass +from functools import lru_cache +from typing import Any +from uuid import UUID + +from sqlalchemy.engine import Engine + +from ..config import get_engine +from ..models.brand_kit import BrandKit, BrandKitAnswers, BrandKitUpsert +from .brand_store import BrandStore + + +def _collapse_whitespace(value: str) -> str: + return " ".join(value.split()) + + +def derive_summary( + brand_name: str, + answers: BrandKitAnswers | Mapping[str, Any], +) -> str: + normalized_answers = BrandKitAnswers.model_validate(answers) + normalized_name = _collapse_whitespace(brand_name) + audience = ( + _collapse_whitespace(normalized_answers.audience) + if normalized_answers.audience is not None + else "" + ) + if not normalized_name or normalized_answers.tone is None or not audience: + raise ValueError("A summary requires complete brand kit answers.") + if not normalized_answers.colors: + raise ValueError("A summary requires complete brand kit answers.") + + tagline = ( + _collapse_whitespace(normalized_answers.tagline) + if normalized_answers.tagline is not None + else "None specified" + ) + avoid_words = ( + _collapse_whitespace(normalized_answers.avoid_words) + if normalized_answers.avoid_words is not None + else "None specified" + ) + return "\n".join( + ( + f"Brand: {normalized_name}", + f"Tagline: {tagline}", + f"Tone: {normalized_answers.tone.value}", + f"Audience: {audience}", + f"Colors: {', '.join(normalized_answers.colors)}", + f"Avoid words: {avoid_words}", + ) + ) + + +@dataclass(frozen=True, slots=True) +class BrandKitStore: + engine: Engine + + @staticmethod + def _to_brand_kit(row: Mapping[str, Any]) -> BrandKit: + return BrandKit.model_validate( + { + "brand_id": row["brand_id"], + "brand_name": row["brand_name"], + "answers": { + "tagline": row["tagline"], + "tone": row["tone"], + "audience": row["audience"], + "colors": row["colors"], + "avoid_words": row["avoid_words"], + }, + "summary": row["summary"], + "status": row["status"], + "completed_at": row["completed_at"], + "updated_at": row["updated_at"], + } + ) + + def get_kit(self, user_id: str, brand_id: UUID) -> BrandKit: + with self.engine.begin() as connection: + brand = BrandStore.lock_owned_brand(connection, user_id, brand_id) + BrandStore.require_normal_brand(brand) + raise NotImplementedError("Brand kit reads are implemented in T018.") + + def upsert_kit( + self, + user_id: str, + brand_id: UUID, + payload: BrandKitUpsert, + ) -> BrandKit: + with self.engine.begin() as connection: + brand = BrandStore.lock_owned_brand(connection, user_id, brand_id) + BrandStore.require_normal_brand(brand) + raise NotImplementedError("Brand kit persistence is implemented in T019.") + + +@lru_cache(maxsize=1) +def get_brand_kit_store() -> BrandKitStore: + return BrandKitStore(get_engine()) diff --git a/backend/app/services/brand_store.py b/backend/app/services/brand_store.py index 3e8b76d..5d77875 100644 --- a/backend/app/services/brand_store.py +++ b/backend/app/services/brand_store.py @@ -57,7 +57,14 @@ def _to_brand(row: Mapping[str, Any]) -> Brand: "cleanup_required": "cleanup_required", }[row["deletion_state"]] return Brand.model_validate( - {"logo_url": logo_url, "cleanup_state": cleanup_state, **row} + { + "id": row["id"], + "name": row["name"], + "logo_url": logo_url, + "cleanup_state": cleanup_state, + "kit_status": row.get("kit_status", "not_started"), + "created_at": row["created_at"], + } ) @staticmethod @@ -69,10 +76,12 @@ def lock_owned_brand( row = connection.execute( text( """ - SELECT id, name, logo_path, deletion_state, created_at - FROM brands - WHERE id = :brand_id AND owner_user_id = :owner_user_id - FOR UPDATE + SELECT b.id, b.name, b.logo_path, b.deletion_state, b.created_at, + COALESCE(k.status::TEXT, 'not_started') AS kit_status + FROM brands AS b + LEFT JOIN brand_kits AS k ON k.brand_id = b.id + WHERE b.id = :brand_id AND b.owner_user_id = :owner_user_id + FOR UPDATE OF b """ ), {"brand_id": brand_id, "owner_user_id": user_id}, @@ -155,10 +164,12 @@ def list_brands(self, user_id: str) -> list[Brand]: rows = connection.execute( text( """ - SELECT id, name, logo_path, deletion_state, created_at - FROM brands - WHERE owner_user_id = :owner_user_id - ORDER BY created_at DESC, id DESC + SELECT b.id, b.name, b.logo_path, b.deletion_state, b.created_at, + COALESCE(k.status::TEXT, 'not_started') AS kit_status + FROM brands AS b + LEFT JOIN brand_kits AS k ON k.brand_id = b.id + WHERE b.owner_user_id = :owner_user_id + ORDER BY b.created_at DESC, b.id DESC """ ), {"owner_user_id": user_id}, @@ -171,9 +182,11 @@ def get_brand(self, user_id: str, brand_id: UUID) -> Brand: row = connection.execute( text( """ - SELECT id, name, logo_path, deletion_state, created_at - FROM brands - WHERE id = :brand_id AND owner_user_id = :owner_user_id + SELECT b.id, b.name, b.logo_path, b.deletion_state, b.created_at, + COALESCE(k.status::TEXT, 'not_started') AS kit_status + FROM brands AS b + LEFT JOIN brand_kits AS k ON k.brand_id = b.id + WHERE b.id = :brand_id AND b.owner_user_id = :owner_user_id """ ), {"brand_id": brand_id, "owner_user_id": user_id}, @@ -380,6 +393,7 @@ def publish_uploaded_logo( ).mappings().one_or_none() if row is None: raise BrandAssetOperationStaleError + row = {**row, "kit_status": brand["kit_status"]} return self._to_brand(row) def complete_asset_operation( diff --git a/backend/tests/contract/test_brands.py b/backend/tests/contract/test_brands.py index cd6e665..96f263e 100644 --- a/backend/tests/contract/test_brands.py +++ b/backend/tests/contract/test_brands.py @@ -355,6 +355,7 @@ def test_create_brand_returns_public_contract_shape(): "name": "Acme Coffee", "logo_url": None, "cleanup_state": "normal", + "kit_status": "not_started", "created_at": "2026-07-25T00:00:00Z", } finally: @@ -400,6 +401,7 @@ def test_list_brands_returns_empty_and_populated_contract_shapes(): "name": "New Brand", "logo_url": None, "cleanup_state": "normal", + "kit_status": "not_started", "created_at": "2026-07-26T00:00:00Z", }, { @@ -407,6 +409,7 @@ def test_list_brands_returns_empty_and_populated_contract_shapes(): "name": "First Brand", "logo_url": None, "cleanup_state": "normal", + "kit_status": "not_started", "created_at": "2026-07-25T00:00:00Z", }, ] diff --git a/specs/006-brand-kit/tasks.md b/specs/006-brand-kit/tasks.md index 561d0d6..998887a 100644 --- a/specs/006-brand-kit/tasks.md +++ b/specs/006-brand-kit/tasks.md @@ -21,17 +21,17 @@ **Purpose**: Complete this phase before implementing any user story. It creates the schema, shared models, route registration, and brand-status plumbing used by all stories. -- [ ] T004 Create `supabase/migrations/00017_create_brand_kits.sql` with enum types `tone_t` (`formal`, `casual`, `playful`, `professional`, `friendly`) and `kit_status_t` (`not_started`, `in_progress`, `complete`), unless those types already exist; fail the migration if duplicate type creation would be unsafe. -- [ ] T005 Add the `brand_kits` table in `supabase/migrations/00017_create_brand_kits.sql` with `brand_id UUID PRIMARY KEY REFERENCES brands(id) ON DELETE CASCADE`, nullable `tagline` (max 160), nullable `tone`, nullable `audience`, `colors TEXT[] NOT NULL DEFAULT '{}'`, nullable `avoid_words`, nullable `summary`, `status` defaulting to `not_started`, nullable `completed_at`, `created_at`, and `updated_at`. -- [ ] T006 Add database constraints in `supabase/migrations/00017_create_brand_kits.sql`: audience must be 2–500 trimmed characters when non-null; colors must contain no more than 3 values; every stored color must match the canonical six-digit hexadecimal format `#RRGGBB` (leading `#` required; hex digits are case-insensitive); a complete kit must have a valid tone, audience, and 1–3 colors; an incomplete kit must not retain `completed_at` or `summary`. -- [ ] T007 Add the existing `set_updated_at()` trigger, enable and force RLS on `brand_kits`, add an owner-only policy using `private.is_brand_owner(brand_id)`, revoke direct client DML where the repository convention requires it, and grant the backend service role the required DML in `supabase/migrations/00017_create_brand_kits.sql`. -- [ ] T008 Add the new table’s SELECT/INSERT/UPDATE/DELETE privilege checks to `_DATABASE_ROLE_PRIVILEGES` in `backend/app/config.py`; preserve the existing vault least-privilege checks and existing table checks. -- [ ] T009 [P] Create `backend/app/models/brand_kit.py` with Pydantic models for `BrandKitAnswers`, `BrandKitUpsert`, `BrandKit`, `KitStatus`, and `Tone`; validate trimmed name (2–120), tagline (0–160), audience (2–500 when supplied), tone enum, and colors (0 while partial or 1–3 colors matching the canonical six-digit hexadecimal format `#RRGGBB` (leading `#` required; hex digits are case-insensitive) when complete). -- [ ] T010 [P] Add `kit_status: Literal["not_started", "in_progress", "complete"]` to the brand response model in `backend/app/models/brand.py` without removing or renaming any existing brand response fields. -- [ ] T011 Update `backend/app/services/brand_store.py` list and get queries to left join `brand_kits`, return `kit_status = 'not_started'` when no kit row exists, and return the stored kit status otherwise; update `_to_brand` and related tests for the new response field. -- [ ] T012 [P] Create `backend/app/services/brand_kit_store.py` with dependency factory `get_brand_kit_store`, a service dataclass using the existing engine, deterministic summary derivation, and helper functions that map database rows to the Pydantic response without exposing internal columns. -- [ ] T013 Create `backend/app/routes/brand_kits.py` with router prefix `/api/v1/brands`, `GET /{brand_id}/kit`, and `PUT /{brand_id}/kit`; register the router in `backend/app/main.py` and ensure unauthenticated requests still produce `401 UNAUTHORIZED` through `CurrentUserDep`. -- [ ] T014 In `backend/app/routes/brand_kits.py`, map a missing or non-owned brand to `404 BRAND_NOT_FOUND`, invalid payloads to the existing `400 VALIDATION_ERROR` handler, cleanup-in-progress to `409 BRAND_CLEANUP_REQUIRED`, and never return a kit row for another owner. +- [X] T004 Create `supabase/migrations/00017_create_brand_kits.sql` with enum types `tone_t` (`formal`, `casual`, `playful`, `professional`, `friendly`) and `kit_status_t` (`not_started`, `in_progress`, `complete`), unless those types already exist; fail the migration if duplicate type creation would be unsafe. +- [X] T005 Add the `brand_kits` table in `supabase/migrations/00017_create_brand_kits.sql` with `brand_id UUID PRIMARY KEY REFERENCES brands(id) ON DELETE CASCADE`, nullable `tagline` (max 160), nullable `tone`, nullable `audience`, `colors TEXT[] NOT NULL DEFAULT '{}'`, nullable `avoid_words`, nullable `summary`, `status` defaulting to `not_started`, nullable `completed_at`, `created_at`, and `updated_at`. +- [X] T006 Add database constraints in `supabase/migrations/00017_create_brand_kits.sql`: audience must be 2–500 trimmed characters when non-null; colors must contain no more than 3 values; every stored color must match the canonical six-digit hexadecimal format `#RRGGBB` (leading `#` required; hex digits are case-insensitive); a complete kit must have a valid tone, audience, and 1–3 colors; an incomplete kit must not retain `completed_at` or `summary`. +- [X] T007 Add the existing `set_updated_at()` trigger, enable and force RLS on `brand_kits`, add an owner-only policy using `private.is_brand_owner(brand_id)`, revoke direct client DML where the repository convention requires it, and grant the backend service role the required DML in `supabase/migrations/00017_create_brand_kits.sql`. +- [X] T008 Add the new table’s SELECT/INSERT/UPDATE/DELETE privilege checks to `_DATABASE_ROLE_PRIVILEGES` in `backend/app/config.py`; preserve the existing vault least-privilege checks and existing table checks. +- [X] T009 [P] Create `backend/app/models/brand_kit.py` with Pydantic models for `BrandKitAnswers`, `BrandKitUpsert`, `BrandKit`, `KitStatus`, and `Tone`; validate trimmed name (2–120), tagline (0–160), audience (2–500 when supplied), tone enum, and colors (0 while partial or 1–3 colors matching the canonical six-digit hexadecimal format `#RRGGBB` (leading `#` required; hex digits are case-insensitive) when complete). +- [X] T010 [P] Add `kit_status: Literal["not_started", "in_progress", "complete"]` to the brand response model in `backend/app/models/brand.py` without removing or renaming any existing brand response fields. +- [X] T011 Update `backend/app/services/brand_store.py` list and get queries to left join `brand_kits`, return `kit_status = 'not_started'` when no kit row exists, and return the stored kit status otherwise; update `_to_brand` and related tests for the new response field. +- [X] T012 [P] Create `backend/app/services/brand_kit_store.py` with dependency factory `get_brand_kit_store`, a service dataclass using the existing engine, deterministic summary derivation, and helper functions that map database rows to the Pydantic response without exposing internal columns. +- [X] T013 Create `backend/app/routes/brand_kits.py` with router prefix `/api/v1/brands`, `GET /{brand_id}/kit`, and `PUT /{brand_id}/kit`; register the router in `backend/app/main.py` and ensure unauthenticated requests still produce `401 UNAUTHORIZED` through `CurrentUserDep`. +- [X] T014 In `backend/app/routes/brand_kits.py`, map a missing or non-owned brand to `404 BRAND_NOT_FOUND`, invalid payloads to the existing `400 VALIDATION_ERROR` handler, cleanup-in-progress to `409 BRAND_CLEANUP_REQUIRED`, and never return a kit row for another owner. **Foundational checkpoint**: Apply migration `00017`, run the backend contract tests, and verify the API imports successfully before starting user-story implementation. diff --git a/supabase/migrations/00017_create_brand_kits.sql b/supabase/migrations/00017_create_brand_kits.sql new file mode 100644 index 0000000..f0653af --- /dev/null +++ b/supabase/migrations/00017_create_brand_kits.sql @@ -0,0 +1,114 @@ +DO $$ +DECLARE + actual_labels TEXT[]; + type_kind "char"; +BEGIN + SELECT + types.typtype, + array_agg(enum_values.enumlabel::TEXT ORDER BY enum_values.enumsortorder) + INTO type_kind, actual_labels + FROM pg_type AS types + JOIN pg_namespace AS namespaces ON namespaces.oid = types.typnamespace + LEFT JOIN pg_enum AS enum_values ON enum_values.enumtypid = types.oid + WHERE namespaces.nspname = 'public' + AND types.typname = 'tone_t' + GROUP BY types.oid, types.typtype; + + IF NOT FOUND THEN + CREATE TYPE public.tone_t AS ENUM ( + 'formal', 'casual', 'playful', 'professional', 'friendly' + ); + ELSIF type_kind <> 'e' + OR actual_labels IS DISTINCT FROM ARRAY[ + 'formal', 'casual', 'playful', 'professional', 'friendly' + ]::TEXT[] THEN + RAISE EXCEPTION 'public.tone_t exists with an incompatible definition'; + END IF; +END; +$$; + +DO $$ +DECLARE + actual_labels TEXT[]; + type_kind "char"; +BEGIN + SELECT + types.typtype, + array_agg(enum_values.enumlabel::TEXT ORDER BY enum_values.enumsortorder) + INTO type_kind, actual_labels + FROM pg_type AS types + JOIN pg_namespace AS namespaces ON namespaces.oid = types.typnamespace + LEFT JOIN pg_enum AS enum_values ON enum_values.enumtypid = types.oid + WHERE namespaces.nspname = 'public' + AND types.typname = 'kit_status_t' + GROUP BY types.oid, types.typtype; + + IF NOT FOUND THEN + CREATE TYPE public.kit_status_t AS ENUM ( + 'not_started', 'in_progress', 'complete' + ); + ELSIF type_kind <> 'e' + OR actual_labels IS DISTINCT FROM ARRAY[ + 'not_started', 'in_progress', 'complete' + ]::TEXT[] THEN + RAISE EXCEPTION 'public.kit_status_t exists with an incompatible definition'; + END IF; +END; +$$; + +CREATE TABLE brand_kits ( + brand_id UUID PRIMARY KEY REFERENCES brands(id) ON DELETE CASCADE, + tagline TEXT CHECK (tagline IS NULL OR char_length(tagline) <= 160), + tone public.tone_t, + audience TEXT CHECK ( + audience IS NULL OR char_length(btrim(audience)) BETWEEN 2 AND 500 + ), + colors TEXT[] NOT NULL DEFAULT '{}'::TEXT[], + avoid_words TEXT, + summary TEXT, + status public.kit_status_t NOT NULL DEFAULT 'not_started', + completed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT brand_kits_colors_valid CHECK ( + COALESCE(array_ndims(colors), 1) = 1 + AND cardinality(colors) <= 3 + AND array_position(colors, NULL) IS NULL + AND ( + cardinality(colors) = 0 + OR array_to_string(colors, ',') ~ + '^#[0-9A-Fa-f]{6}(,#[0-9A-Fa-f]{6}){0,2}$' + ) + ), + CONSTRAINT brand_kits_completion_valid CHECK ( + ( + status = 'complete' + AND tone IS NOT NULL + AND audience IS NOT NULL + AND cardinality(colors) BETWEEN 1 AND 3 + AND summary IS NOT NULL + AND completed_at IS NOT NULL + ) + OR ( + status <> 'complete' + AND summary IS NULL + AND completed_at IS NULL + ) + ) +); + +CREATE TRIGGER trg_brand_kits_updated_at + BEFORE UPDATE ON brand_kits + FOR EACH ROW + EXECUTE FUNCTION set_updated_at(); + +ALTER TABLE brand_kits ENABLE ROW LEVEL SECURITY; +ALTER TABLE brand_kits FORCE ROW LEVEL SECURITY; + +CREATE POLICY brand_kits_owner ON brand_kits + FOR ALL + USING (private.is_brand_owner(brand_id)) + WITH CHECK (private.is_brand_owner(brand_id)); + +REVOKE ALL ON brand_kits FROM PUBLIC, anon, authenticated; +GRANT SELECT, INSERT, UPDATE, DELETE ON brand_kits TO service_role; From 01ef7cca656affb26788bd3c4f43c284e5129a96 Mon Sep 17 00:00:00 2001 From: Mohammed Zayan Date: Thu, 30 Jul 2026 00:15:22 +0300 Subject: [PATCH 06/10] Implement complete Brand Kit flow --- backend/app/routes/brand_kits.py | 14 +- backend/app/services/brand_kit_store.py | 150 ++++++- backend/tests/contract/test_brand_kits.py | 191 ++++++++- backend/tests/integration/test_brand_kits.py | 121 +++++- backend/tests/unit/test_brand_kit_store.py | 53 ++- .../(dashboard)/brands/[brandId]/kit/page.tsx | 375 ++++++++++++++++++ .../app/(dashboard)/brands/[brandId]/page.tsx | 33 ++ frontend/tests/e2e/brand-kit.spec.ts | 124 +++++- specs/006-brand-kit/tasks.md | 20 +- 9 files changed, 1025 insertions(+), 56 deletions(-) create mode 100644 frontend/app/(dashboard)/brands/[brandId]/kit/page.tsx diff --git a/backend/app/routes/brand_kits.py b/backend/app/routes/brand_kits.py index ee32829..96b0e79 100644 --- a/backend/app/routes/brand_kits.py +++ b/backend/app/routes/brand_kits.py @@ -12,7 +12,7 @@ BrandKitStore, get_brand_kit_store, ) -from ..services.brand_store import BrandCleanupRequiredError +from ..services.brand_store import BrandCleanupRequiredError, BrandNameTakenError router = APIRouter(prefix="/api/v1/brands", tags=["brand-kits"]) @@ -38,6 +38,16 @@ def _cleanup_required() -> HTTPException: ) +def _name_taken() -> HTTPException: + return HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail={ + "code": "BRAND_NAME_TAKEN", + "message": "You already have a brand with this name.", + }, + ) + + def _map_access_error(exc: Exception) -> HTTPException: if isinstance(exc, BrandCleanupRequiredError): return _cleanup_required() @@ -76,6 +86,8 @@ def put_brand_kit( ) -> BrandKit: try: kit = brand_kit_store.upsert_kit(current_user.user_id, brand_id, payload) + except BrandNameTakenError as exc: + raise _name_taken() from exc except (LookupError, BrandCleanupRequiredError) as exc: raise _map_access_error(exc) from exc diff --git a/backend/app/services/brand_kit_store.py b/backend/app/services/brand_kit_store.py index afc79bc..ad7acc3 100644 --- a/backend/app/services/brand_kit_store.py +++ b/backend/app/services/brand_kit_store.py @@ -6,11 +6,13 @@ from typing import Any from uuid import UUID -from sqlalchemy.engine import Engine +from sqlalchemy import text +from sqlalchemy.engine import Connection, Engine +from sqlalchemy.exc import IntegrityError from ..config import get_engine -from ..models.brand_kit import BrandKit, BrandKitAnswers, BrandKitUpsert -from .brand_store import BrandStore +from ..models.brand_kit import BrandKit, BrandKitAnswers, BrandKitUpsert, KitStatus +from .brand_store import BrandNameTakenError, BrandStore def _collapse_whitespace(value: str) -> str: @@ -79,11 +81,69 @@ def _to_brand_kit(row: Mapping[str, Any]) -> BrandKit: } ) + @staticmethod + def _empty_kit(brand: Mapping[str, Any]) -> BrandKit: + return BrandKit( + brand_id=brand["id"], + brand_name=brand["name"], + answers=BrandKitAnswers(), + summary=None, + status=KitStatus.NOT_STARTED, + completed_at=None, + updated_at=None, + ) + + @staticmethod + def _kit_row( + connection: Connection, brand_id: UUID + ) -> Mapping[str, Any] | None: + return connection.execute( + text( + """ + SELECT brand_id, tagline, tone, audience, colors, avoid_words, + summary, status, completed_at, updated_at + FROM brand_kits + WHERE brand_id = :brand_id + """ + ), + {"brand_id": brand_id}, + ).mappings().one_or_none() + + @staticmethod + def _merged_answers( + existing: Mapping[str, Any] | None, + payload: BrandKitUpsert, + ) -> BrandKitAnswers: + current = { + "tagline": existing["tagline"] if existing else None, + "tone": existing["tone"] if existing else None, + "audience": existing["audience"] if existing else None, + "colors": list(existing["colors"] or []) if existing else [], + "avoid_words": existing["avoid_words"] if existing else None, + } + current.update(payload.answers.model_dump(exclude_unset=True)) + return BrandKitAnswers.model_validate(current) + + @staticmethod + def _has_saved_answer(answers: BrandKitAnswers) -> bool: + return any( + ( + answers.tagline, + answers.tone, + answers.audience, + answers.colors, + answers.avoid_words, + ) + ) + def get_kit(self, user_id: str, brand_id: UUID) -> BrandKit: with self.engine.begin() as connection: brand = BrandStore.lock_owned_brand(connection, user_id, brand_id) BrandStore.require_normal_brand(brand) - raise NotImplementedError("Brand kit reads are implemented in T018.") + row = self._kit_row(connection, brand_id) + if row is None: + return self._empty_kit(brand) + return self._to_brand_kit({"brand_name": brand["name"], **row}) def upsert_kit( self, @@ -91,10 +151,84 @@ def upsert_kit( brand_id: UUID, payload: BrandKitUpsert, ) -> BrandKit: - with self.engine.begin() as connection: - brand = BrandStore.lock_owned_brand(connection, user_id, brand_id) - BrandStore.require_normal_brand(brand) - raise NotImplementedError("Brand kit persistence is implemented in T019.") + try: + with self.engine.begin() as connection: + brand = BrandStore.lock_owned_brand(connection, user_id, brand_id) + BrandStore.require_normal_brand(brand) + existing = self._kit_row(connection, brand_id) + answers = self._merged_answers(existing, payload) + complete = ( + answers.tone is not None + and answers.audience is not None + and bool(answers.colors) + ) + status = KitStatus.COMPLETE if complete else ( + KitStatus.IN_PROGRESS if self._has_saved_answer(answers) + else KitStatus.NOT_STARTED + ) + summary = derive_summary(payload.name, answers) if complete else None + + connection.execute( + text( + """ + UPDATE brands + SET name = :name + WHERE id = :brand_id + """ + ), + {"brand_id": brand_id, "name": payload.name}, + ) + connection.execute( + text( + """ + INSERT INTO brand_kits ( + brand_id, tagline, tone, audience, colors, avoid_words, + summary, status, completed_at + ) VALUES ( + :brand_id, :tagline, CAST(:tone AS tone_t), :audience, + :colors, :avoid_words, :summary, + CAST(:status AS kit_status_t), + CASE WHEN :status = 'complete' THEN now() ELSE NULL END + ) + ON CONFLICT (brand_id) DO UPDATE SET + tagline = EXCLUDED.tagline, + tone = EXCLUDED.tone, + audience = EXCLUDED.audience, + colors = EXCLUDED.colors, + avoid_words = EXCLUDED.avoid_words, + summary = EXCLUDED.summary, + status = EXCLUDED.status, + completed_at = EXCLUDED.completed_at + """ + ), + { + "brand_id": brand_id, + "tagline": answers.tagline, + "tone": answers.tone.value if answers.tone else None, + "audience": answers.audience, + "colors": answers.colors, + "avoid_words": answers.avoid_words, + "summary": summary, + "status": status.value, + }, + ) + row = self._kit_row(connection, brand_id) + assert row is not None + updated_brand_name = payload.name + return self._to_brand_kit( + {"brand_name": updated_brand_name, **row} + ) + except IntegrityError as exc: + original = exc.orig + constraint_name = getattr( + getattr(original, "diag", None), "constraint_name", None + ) + if ( + getattr(original, "pgcode", None) == "23505" + and constraint_name == "uq_brands_owner_name_ci" + ): + raise BrandNameTakenError from exc + raise @lru_cache(maxsize=1) diff --git a/backend/tests/contract/test_brand_kits.py b/backend/tests/contract/test_brand_kits.py index 6111ae1..f642806 100644 --- a/backend/tests/contract/test_brand_kits.py +++ b/backend/tests/contract/test_brand_kits.py @@ -1,8 +1,17 @@ from __future__ import annotations +from dataclasses import dataclass +from datetime import UTC, datetime from uuid import UUID -import pytest +from fastapi.testclient import TestClient + +from backend.app.auth import CurrentUser, get_current_user +from backend.app.main import app +from backend.app.models.brand_kit import BrandKit, BrandKitUpsert +from backend.app.routes.brand_kits import get_brand_kit_store +from backend.app.services.brand_kit_store import derive_summary +from backend.app.services.brand_store import BrandNameTakenError BRAND_ID = UUID("22222222-2222-2222-2222-222222222222") @@ -14,10 +23,180 @@ "colors": ["#FF5733", "#3498DB"], "avoid_words": "cheap, discount", } +COMPLETE_SUMMARY = "\n".join( + ( + "Brand: My Brand", + "Tagline: Innovation for everyone", + "Tone: professional", + "Audience: Small business owners aged 25-45", + "Colors: #FF5733, #3498DB", + "Avoid words: cheap, discount", + ) +) +COMPLETED_AT = datetime(2026, 7, 29, tzinfo=UTC) + + +def _kit(*, answers=None, status="not_started", summary=None) -> BrandKit: + return BrandKit.model_validate( + { + "brand_id": BRAND_ID, + "brand_name": "My Brand", + "answers": answers or {}, + "summary": summary, + "status": status, + "completed_at": COMPLETED_AT if status == "complete" else None, + "updated_at": COMPLETED_AT if status == "complete" else None, + } + ) + + +@dataclass +class FakeBrandKitStore: + kit: BrandKit | None = None + saved_payloads: list[BrandKitUpsert] = None + + def __post_init__(self) -> None: + if self.saved_payloads is None: + self.saved_payloads = [] + + def get_kit(self, user_id: str, brand_id: UUID) -> BrandKit: + return self.kit or _kit() + + def upsert_kit( + self, user_id: str, brand_id: UUID, payload: BrandKitUpsert + ) -> BrandKit: + self.saved_payloads.append(payload) + self.kit = _kit( + answers=payload.answers, + status="complete", + summary=derive_summary(payload.name, payload.answers), + ) + return self.kit + + +class DuplicateNameStore(FakeBrandKitStore): + def upsert_kit( + self, user_id: str, brand_id: UUID, payload: BrandKitUpsert + ) -> BrandKit: + raise BrandNameTakenError + + +def _client(store: FakeBrandKitStore) -> TestClient: + app.dependency_overrides[get_current_user] = lambda: CurrentUser( + user_id=OWNER_USER_ID, + email="owner@example.com", + access_token="redacted", + ) + app.dependency_overrides[get_brand_kit_store] = lambda: store + return TestClient(app) + + +def test_get_without_row_returns_exact_not_started_shape(): + store = FakeBrandKitStore() + try: + with _client(store) as client: + response = client.get(f"/api/v1/brands/{BRAND_ID}/kit") + + assert response.status_code == 200 + assert response.json() == { + "brand_id": str(BRAND_ID), + "brand_name": "My Brand", + "answers": { + "tagline": None, + "tone": None, + "audience": None, + "colors": [], + "avoid_words": None, + }, + "summary": None, + "status": "not_started", + "completed_at": None, + "updated_at": None, + } + finally: + app.dependency_overrides.clear() + + +def test_put_complete_answers_returns_exact_public_shape(): + store = FakeBrandKitStore() + try: + with _client(store) as client: + response = client.put( + f"/api/v1/brands/{BRAND_ID}/kit", + json={"name": "My Brand", "answers": COMPLETE_ANSWERS}, + ) + + assert response.status_code == 200 + assert response.json() == { + "brand_id": str(BRAND_ID), + "brand_name": "My Brand", + "answers": COMPLETE_ANSWERS, + "summary": COMPLETE_SUMMARY, + "status": "complete", + "completed_at": "2026-07-29T00:00:00Z", + "updated_at": "2026-07-29T00:00:00Z", + } + assert store.saved_payloads[0].name == "My Brand" + finally: + app.dependency_overrides.clear() + + +def test_put_complete_answers_allows_optional_fields_to_be_omitted(): + store = FakeBrandKitStore() + answers = { + "tone": "professional", + "audience": COMPLETE_ANSWERS["audience"], + "colors": COMPLETE_ANSWERS["colors"], + } + try: + with _client(store) as client: + response = client.put( + f"/api/v1/brands/{BRAND_ID}/kit", + json={"name": "My Brand", "answers": answers}, + ) + + assert response.status_code == 200 + assert response.json()["answers"] == { + "tagline": None, + "tone": "professional", + "audience": COMPLETE_ANSWERS["audience"], + "colors": COMPLETE_ANSWERS["colors"], + "avoid_words": None, + } + assert response.json()["summary"] == "\n".join( + ( + "Brand: My Brand", + "Tagline: None specified", + "Tone: professional", + "Audience: Small business owners aged 25-45", + "Colors: #FF5733, #3498DB", + "Avoid words: None specified", + ) + ) + assert response.json()["status"] == "complete" + assert set(response.json()) == { + "brand_id", + "brand_name", + "answers", + "summary", + "status", + "completed_at", + "updated_at", + } + finally: + app.dependency_overrides.clear() + +def test_put_maps_duplicate_brand_name_to_conflict(): + try: + with _client(DuplicateNameStore()) as client: + response = client.put( + f"/api/v1/brands/{BRAND_ID}/kit", + json={"name": "Existing Brand", "answers": COMPLETE_ANSWERS}, + ) -@pytest.mark.skip(reason="Brand Kit API behavior starts in Phase 2") -def test_brand_kit_contract_scaffold() -> None: - assert BRAND_ID - assert OWNER_USER_ID - assert COMPLETE_ANSWERS + assert response.status_code == 409 + assert response.json()["error"]["code"] == "BRAND_NAME_TAKEN" + assert response.json()["error"]["request_id"] + finally: + app.dependency_overrides.clear() diff --git a/backend/tests/integration/test_brand_kits.py b/backend/tests/integration/test_brand_kits.py index 3dde023..2f73793 100644 --- a/backend/tests/integration/test_brand_kits.py +++ b/backend/tests/integration/test_brand_kits.py @@ -1,17 +1,120 @@ from __future__ import annotations -from uuid import UUID +import os +from uuid import uuid4 +import httpx import pytest +from fastapi.testclient import TestClient +from sqlalchemy import text -BRAND_ID = UUID("22222222-2222-2222-2222-222222222222") -OWNER_USER_ID = "11111111-1111-1111-1111-111111111111" -BRAND_NAME = "Brand Kit Integration" +def _required_env(name: str) -> str: + value = os.getenv(name) + if not value: + pytest.skip(f"{name} is required for integration tests") + return value -@pytest.mark.skip(reason="Brand Kit integration behavior starts in Phase 2") -def test_brand_kit_integration_scaffold() -> None: - assert BRAND_ID - assert OWNER_USER_ID - assert BRAND_NAME +def _signup_and_login( + client: httpx.Client, supabase_url: str, supabase_key: str, email: str +) -> tuple[str, str]: + signup = client.post( + f"{supabase_url}/auth/v1/signup", + headers={"apikey": supabase_key, "Content-Type": "application/json"}, + json={"email": email, "password": "12345678"}, + ) + assert signup.status_code in {200, 201} + token = client.post( + f"{supabase_url}/auth/v1/token?grant_type=password", + headers={"apikey": supabase_key, "Content-Type": "application/json"}, + json={"email": email, "password": "12345678"}, + ) + assert token.status_code == 200 + return signup.json()["user"]["id"], token.json()["access_token"] + + +def test_real_supabase_create_save_read_edit_and_single_kit_row(): + supabase_url = _required_env("SUPABASE_URL") + supabase_key = _required_env("SUPABASE_SECRET_KEY") + _required_env("SUPABASE_JWT_SECRET") + _required_env("DATABASE_URL") + + from backend.app.config import get_engine + from backend.app.main import app + + user_id: str | None = None + brand_id: str | None = None + with httpx.Client(timeout=30.0) as supabase_client: + try: + user_id, access_token = _signup_and_login( + supabase_client, + supabase_url, + supabase_key, + f"brand-kit-{uuid4().hex[:12]}@example.com", + ) + headers = {"Authorization": f"Bearer {access_token}"} + with TestClient(app) as client: + create = client.post( + "/api/v1/brands", headers=headers, json={"name": "My Brand"} + ) + assert create.status_code == 201 + brand_id = create.json()["id"] + + saved = client.put( + f"/api/v1/brands/{brand_id}/kit", + headers=headers, + json={ + "name": "My Brand", + "answers": { + "tone": "professional", + "audience": "Small business owners aged 25-45", + "colors": ["#FF5733", "#3498DB"], + }, + }, + ) + read = client.get(f"/api/v1/brands/{brand_id}/kit", headers=headers) + edited = client.put( + f"/api/v1/brands/{brand_id}/kit", + headers=headers, + json={ + "name": "My Brand Updated", + "answers": { + "tagline": "Better work, every day", + "tone": "friendly", + "audience": "Growing teams", + "colors": ["#123456"], + "avoid_words": "cheap", + }, + }, + ) + + assert saved.status_code == 200 + assert saved.json()["status"] == "complete" + assert read.status_code == 200 + assert read.json() == saved.json() + assert edited.status_code == 200 + assert edited.json()["brand_name"] == "My Brand Updated" + assert edited.json()["answers"]["tone"] == "friendly" + assert edited.json()["summary"].startswith("Brand: My Brand Updated\n") + + with get_engine().connect() as connection: + assert connection.execute( + text("SELECT count(*) FROM brand_kits WHERE brand_id = :brand_id"), + {"brand_id": brand_id}, + ).scalar_one() == 1 + finally: + if brand_id: + with get_engine().begin() as connection: + connection.execute( + text("DELETE FROM brands WHERE id = :brand_id"), + {"brand_id": brand_id}, + ) + if user_id: + supabase_client.delete( + f"{supabase_url}/auth/v1/admin/users/{user_id}", + headers={ + "apikey": supabase_key, + "Authorization": f"Bearer {supabase_key}", + }, + ) diff --git a/backend/tests/unit/test_brand_kit_store.py b/backend/tests/unit/test_brand_kit_store.py index b31f6ca..418d0c5 100644 --- a/backend/tests/unit/test_brand_kit_store.py +++ b/backend/tests/unit/test_brand_kit_store.py @@ -1,23 +1,44 @@ from __future__ import annotations -from uuid import UUID +from backend.app.services.brand_kit_store import derive_summary -import pytest +def test_derive_summary_is_deterministic_and_includes_all_answers(): + answers = { + "tagline": " Innovation for everyone ", + "tone": "professional", + "audience": " Small business owners aged 25-45 ", + "colors": ["#ff5733", "#3498db"], + "avoid_words": " cheap, discount ", + } -BRAND_ID = UUID("22222222-2222-2222-2222-222222222222") -BRAND_NAME = "My Brand" -COMPLETE_ANSWERS = { - "tagline": "Innovation for everyone", - "tone": "professional", - "audience": "Small business owners aged 25-45", - "colors": ["#FF5733", "#3498DB"], - "avoid_words": "cheap, discount", -} + assert derive_summary(" My Brand ", answers) == "\n".join( + ( + "Brand: My Brand", + "Tagline: Innovation for everyone", + "Tone: professional", + "Audience: Small business owners aged 25-45", + "Colors: #FF5733, #3498DB", + "Avoid words: cheap, discount", + ) + ) -@pytest.mark.skip(reason="BrandKitStore behavior starts in Phase 2") -def test_brand_kit_store_scaffold() -> None: - assert BRAND_ID - assert BRAND_NAME - assert COMPLETE_ANSWERS +def test_derive_summary_uses_none_specified_for_omitted_optional_answers(): + assert derive_summary( + "My Brand", + { + "tone": "professional", + "audience": "Small business owners aged 25-45", + "colors": ["#FF5733"], + }, + ) == "\n".join( + ( + "Brand: My Brand", + "Tagline: None specified", + "Tone: professional", + "Audience: Small business owners aged 25-45", + "Colors: #FF5733", + "Avoid words: None specified", + ) + ) diff --git a/frontend/app/(dashboard)/brands/[brandId]/kit/page.tsx b/frontend/app/(dashboard)/brands/[brandId]/kit/page.tsx new file mode 100644 index 0000000..2a7697e --- /dev/null +++ b/frontend/app/(dashboard)/brands/[brandId]/kit/page.tsx @@ -0,0 +1,375 @@ +"use client"; + +import Link from "next/link"; +import { useParams, useRouter } from "next/navigation"; +import { useEffect, useState, type FormEvent } from "react"; + +import { getPublicEnv } from "@/lib/runtime-env"; +import { supabase } from "@/lib/supabase/client"; + +type Tone = "formal" | "casual" | "playful" | "professional" | "friendly"; +type KitStatus = "not_started" | "in_progress" | "complete"; + +type FormValues = { + name: string; + tagline: string; + tone: Tone | ""; + audience: string; + colors: string; + avoidWords: string; +}; + +type KitResponse = { + brand_id: string; + brand_name: string; + answers: { + tagline: string | null; + tone: Tone | null; + audience: string | null; + colors: string[]; + avoid_words: string | null; + }; + summary: string | null; + status: KitStatus; + completed_at: string | null; + updated_at: string | null; +}; + +type ApiError = { error?: { message?: string } }; + +const STEPS = ["Name", "Tagline", "Tone", "Audience", "Colors", "Avoid words"] as const; + +const EMPTY_FORM: FormValues = { + name: "", + tagline: "", + tone: "", + audience: "", + colors: "", + avoidWords: "", +}; + +function colorsFromInput(value: string) { + return value + .split(",") + .map((color) => color.trim()) + .filter(Boolean); +} + +function validateStep(step: number, values: FormValues): string | null { + switch (step) { + case 0: + return values.name.trim().length < 2 || values.name.trim().length > 120 + ? "Name must be between 2 and 120 characters." + : null; + case 1: + return values.tagline.length > 160 ? "Tagline must be 160 characters or fewer." : null; + case 2: + return values.tone ? null : "Choose a tone."; + case 3: + return values.audience.trim().length < 2 || values.audience.trim().length > 500 + ? "Audience must be between 2 and 500 characters." + : null; + case 4: { + const colors = colorsFromInput(values.colors); + if (colors.length < 1 || colors.length > 3) { + return "Enter between 1 and 3 colors, separated by commas."; + } + if (colors.some((color) => !/^#[0-9a-fA-F]{6}$/.test(color))) { + return "Colors must use the #RRGGBB format."; + } + return null; + } + case 5: + return null; + default: + return null; + } +} + +function apiErrorMessage(body: ApiError | null, fallback: string) { + return body?.error?.message ?? fallback; +} + +export default function BrandKitPage() { + const { brandId } = useParams<{ brandId: string }>(); + const router = useRouter(); + const apiBase = getPublicEnv("NEXT_PUBLIC_API_URL"); + const [values, setValues] = useState(EMPTY_FORM); + const [step, setStep] = useState(0); + const [status, setStatus] = useState("not_started"); + const [summary, setSummary] = useState(null); + const [brandName, setBrandName] = useState(""); + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + const [error, setError] = useState(null); + const [validationError, setValidationError] = useState(null); + + useEffect(() => { + let active = true; + + async function loadKit() { + try { + const { data } = await supabase.auth.getSession(); + const session = data.session; + if (!session) { + router.push("/login"); + return; + } + + const response = await fetch( + `${apiBase}/v1/brands/${encodeURIComponent(brandId)}/kit`, + { headers: { Authorization: `Bearer ${session.access_token}` } } + ); + const body = (await response.json().catch(() => null)) as KitResponse | ApiError | null; + if (!response.ok) { + throw new Error(apiErrorMessage(body as ApiError | null, "Unable to load the Brand Kit.")); + } + + const kit = body as KitResponse; + if (active) { + setStatus(kit.status); + setBrandName(kit.brand_name); + setValues({ + name: kit.brand_name, + tagline: kit.answers.tagline ?? "", + tone: kit.answers.tone ?? "", + audience: kit.answers.audience ?? "", + colors: kit.answers.colors.join(", "), + avoidWords: kit.answers.avoid_words ?? "", + }); + setSummary(kit.summary); + } + } catch (loadError) { + if (active) { + setError(loadError instanceof Error ? loadError.message : "Unable to load the Brand Kit."); + } + } finally { + if (active) setIsLoading(false); + } + } + + void loadKit(); + return () => { + active = false; + }; + }, [apiBase, brandId, router]); + + function updateValue(field: keyof FormValues, value: string) { + setValues((current) => ({ ...current, [field]: value })); + setValidationError(null); + setError(null); + } + + function nextStep() { + const message = validateStep(step, values); + if (message) { + setValidationError(message); + return; + } + setValidationError(null); + setStep((current) => Math.min(current + 1, STEPS.length - 1)); + } + + function previousStep() { + setValidationError(null); + setStep((current) => Math.max(current - 1, 0)); + } + + async function saveKit(event: FormEvent) { + event.preventDefault(); + for (let index = 0; index < STEPS.length; index += 1) { + const message = validateStep(index, values); + if (message) { + setStep(index); + setValidationError(message); + return; + } + } + + setValidationError(null); + setError(null); + setIsSaving(true); + try { + const { data } = await supabase.auth.getSession(); + const session = data.session; + if (!session) { + router.push("/login"); + return; + } + + const response = await fetch( + `${apiBase}/v1/brands/${encodeURIComponent(brandId)}/kit`, + { + method: "PUT", + headers: { + Authorization: `Bearer ${session.access_token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + name: values.name.trim(), + answers: { + tagline: values.tagline.trim() || null, + tone: values.tone, + audience: values.audience.trim(), + colors: colorsFromInput(values.colors).map((color) => color.toUpperCase()), + avoid_words: values.avoidWords.trim() || null, + }, + }), + } + ); + const body = (await response.json().catch(() => null)) as KitResponse | ApiError | null; + if (!response.ok) { + setError(apiErrorMessage(body as ApiError | null, "Unable to save the Brand Kit.")); + return; + } + + const kit = body as KitResponse; + setStatus(kit.status); + setBrandName(kit.brand_name); + setSummary(kit.summary); + } catch { + setError("Unable to save the Brand Kit. Try again."); + } finally { + setIsSaving(false); + } + } + + if (isLoading) { + return

Loading Brand Kit...

; + } + + if (error && !brandName) { + return

{error}

; + } + + if (status === "complete" && summary) { + return ( +
+ + Back to {brandName} + +
+
+
+

Brand Kit

+

Complete

+
+ + Complete + +
+
+            {summary}
+          
+ +
+
+ ); + } + + const fieldProps = { + value: values, + updateValue, + disabled: isSaving, + }; + + return ( +
+
+ + Back to brand + +

Brand Kit

+

Build your brand identity

+

Step {step + 1} of {STEPS.length}

+
+ +
+ {STEPS.map((stepName, index) => ( +
+ ))} +
+ +
+

{STEPS[step]}

+
+ {step === 0 ? ( + + ) : null} + {step === 1 ? ( + + ) : null} + {step === 2 ? ( + + ) : null} + {step === 3 ? ( +