From 4ce1abe03c20d8b29ce1fb706e0a1a604b12bd2e Mon Sep 17 00:00:00 2001 From: Jan Sitarski Date: Fri, 10 Jul 2026 17:45:00 +0200 Subject: [PATCH 1/6] feat(outfits): add external source and authoring attribute fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds 'external' to the outfit_source enum — it names the write path (authored through the external authoring API), which the backend can attest to, consistent with the tagging origins — plus four nullable authoring attribute columns (season, formality, palette, notes) on outfits so authored suggestions and pairings can record the qualities the internal AI keeps implicit. Internal generation leaves them unset; existing rows are unaffected. --- backend/app/models/outfit.py | 7 +++ ...6a7b8c9d0_add_external_outfit_authoring.py | 46 +++++++++++++++++++ 2 files changed, 53 insertions(+) create mode 100644 backend/migrations/versions/e5f6a7b8c9d0_add_external_outfit_authoring.py diff --git a/backend/app/models/outfit.py b/backend/app/models/outfit.py index f345232b..11414172 100644 --- a/backend/app/models/outfit.py +++ b/backend/app/models/outfit.py @@ -39,6 +39,7 @@ class OutfitSource(enum.StrEnum): on_demand = "on_demand" manual = "manual" pairing = "pairing" + external = "external" class Outfit(Base): @@ -59,6 +60,12 @@ class Outfit(Base): style_notes: Mapped[str | None] = mapped_column(Text) ai_raw_response: Mapped[dict | None] = mapped_column(JSONB) + # Authoring attributes + season: Mapped[str | None] = mapped_column(String(20), nullable=True) + formality: Mapped[str | None] = mapped_column(String(50), nullable=True) + palette: Mapped[list | None] = mapped_column(JSONB, nullable=True) + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + # Status status: Mapped[OutfitStatus] = mapped_column( Enum(OutfitStatus, name="outfit_status", create_type=False), diff --git a/backend/migrations/versions/e5f6a7b8c9d0_add_external_outfit_authoring.py b/backend/migrations/versions/e5f6a7b8c9d0_add_external_outfit_authoring.py new file mode 100644 index 00000000..7a3ce2ad --- /dev/null +++ b/backend/migrations/versions/e5f6a7b8c9d0_add_external_outfit_authoring.py @@ -0,0 +1,46 @@ +"""Add the external outfit source and authoring attribute columns. + +'external' names the write path: outfits authored through the external +authoring API rather than generated internally, composed in the studio, or +derived from a pairing request. The backend can attest to the write path, so +the value carries the same server-derived semantics as the tagging origins. + +The four attribute columns (season, formality, palette, notes) let an author +record the outfit qualities the internal AI keeps implicit in its reasoning. +All nullable; existing rows and the internal generation paths are unaffected. + +Revision ID: e5f6a7b8c9d0 +Revises: d4e5f6a7b8c9 +Create Date: 2026-07-10 17:40:00.000000 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects.postgresql import JSONB + +# revision identifiers, used by Alembic. +revision: str = "e5f6a7b8c9d0" +down_revision: str | None = "d4e5f6a7b8c9" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.execute("ALTER TYPE outfit_source ADD VALUE IF NOT EXISTS 'external'") + + op.add_column("outfits", sa.Column("season", sa.String(length=20), nullable=True)) + op.add_column("outfits", sa.Column("formality", sa.String(length=50), nullable=True)) + op.add_column("outfits", sa.Column("palette", JSONB, nullable=True)) + op.add_column("outfits", sa.Column("notes", sa.Text(), nullable=True)) + + +def downgrade() -> None: + op.drop_column("outfits", "notes") + op.drop_column("outfits", "palette") + op.drop_column("outfits", "formality") + op.drop_column("outfits", "season") + + # Note: Cannot remove enum value in PostgreSQL, so 'external' will remain From e01d8337ba3a3e0cd092f5468eca2c99b3c7c95d Mon Sep 17 00:00:00 2001 From: Jan Sitarski Date: Fri, 10 Jul 2026 17:45:14 +0200 Subject: [PATCH 2/6] feat(outfits): add external authoring endpoints for suggestions and pairings Adds the write surface an external agent uses to author the outfits the internal LLM would otherwise generate: - POST /api/v1/outfits/suggestions persists an authored suggestion as Outfit(source=external), validating item ownership. - POST /api/v1/pairings/item/{item_id} persists an authored pairing as Outfit(source=external, source_item_id=item_id), mirroring the GET /pairings/item/{item_id} route shape. The source item leads the outfit when left out of the partner list, matching the internal generator. Both accept the new authoring attributes and are available regardless of the AI flags; both reuse the studio's ownership semantics (403 with OUTFIT_ITEM_OWNERSHIP). Outfit and pairing responses expose the new fields. Pairing list/delete queries now treat externally-authored rows anchored to a source item as pairings so they surface alongside generated ones; external rows without a source item are suggestions and stay out of the pairing surfaces. The existing 503s on /outfits/suggest and /pairings/generate when text AI is off are unchanged. --- backend/app/api/outfits.py | 74 +++++++++ backend/app/api/pairings.py | 85 +++++++++- backend/app/schemas/outfit.py | 40 +++++ .../app/services/external_outfit_service.py | 149 ++++++++++++++++++ backend/app/services/pairing_service.py | 16 +- backend/app/services/studio_service.py | 49 +++--- 6 files changed, 382 insertions(+), 31 deletions(-) create mode 100644 backend/app/schemas/outfit.py create mode 100644 backend/app/services/external_outfit_service.py diff --git a/backend/app/api/outfits.py b/backend/app/api/outfits.py index d45d4d29..80f906e0 100644 --- a/backend/app/api/outfits.py +++ b/backend/app/api/outfits.py @@ -22,7 +22,9 @@ ) from app.models.user import User from app.schemas.item import DEFAULT_WASH_INTERVALS +from app.schemas.outfit import MAX_AUTHORING_TEXT_LENGTH, OutfitAttributeFields from app.services.ai_service import AIDisabledError +from app.services.external_outfit_service import ExternalOutfitService from app.services.item_service import ItemService from app.services.learning_service import LearningService from app.services.outfit_service import OutfitListFilters, OutfitService @@ -188,6 +190,10 @@ class OutfitResponse(BaseModel): source: str reasoning: str | None = None style_notes: str | None = None + season: str | None = None + formality: str | None = None + palette: list[str] | None = None + notes: str | None = None highlights: list[str] | None = None weather: dict | None = None items: list[OutfitItemResponse] @@ -404,6 +410,10 @@ def outfit_to_response( source=outfit.source.value, reasoning=outfit.reasoning, style_notes=outfit.style_notes, + season=outfit.season, + formality=outfit.formality, + palette=outfit.palette, + notes=outfit.notes, highlights=highlights, weather=outfit.weather_data, items=items, @@ -488,6 +498,70 @@ async def suggest_outfit( return outfit_to_response(outfit, wore_instead_map, is_starter_suggestion=is_starter) +class SuggestionCreateRequest(OutfitAttributeFields): + model_config = ConfigDict(extra="forbid") + + items: list[UUID] = Field(min_length=1, max_length=20) + occasion: str = Field(max_length=50) + name: Annotated[str | None, Field(max_length=100)] = None + scheduled_for: date | None = Field( + default=None, description="Defaults to the user's current date" + ) + reasoning: Annotated[str | None, Field(max_length=MAX_AUTHORING_TEXT_LENGTH)] = None + style_notes: Annotated[str | None, Field(max_length=MAX_AUTHORING_TEXT_LENGTH)] = None + + @field_validator("occasion") + @classmethod + def validate_occasion(cls, v: str) -> str: + v = v.strip().lower() + if v not in VALID_OCCASIONS: + raise ValueError( + f"Invalid occasion '{v}'. Must be one of: {', '.join(sorted(VALID_OCCASIONS))}" + ) + return v + + +@router.post("/suggestions", response_model=OutfitResponse, status_code=status.HTTP_201_CREATED) +async def create_external_suggestion( + request: SuggestionCreateRequest, + db: Annotated[AsyncSession, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_user)], +) -> OutfitResponse: + """Persist an externally-authored suggestion; available regardless of the AI flags.""" + await rate_limit_by_user( + str(current_user.id), "external_suggestion", max_requests=20, window_seconds=60 + ) + + service = ExternalOutfitService(db) + try: + outfit = await service.create_suggestion( + user=current_user, + item_ids=request.items, + occasion=request.occasion, + name=request.name, + scheduled_for=request.scheduled_for, + reasoning=request.reasoning, + style_notes=request.style_notes, + season=request.season, + formality=request.formality, + palette=request.palette, + notes=request.notes, + ) + except ItemOwnershipError: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail={ + "error_code": "OUTFIT_ITEM_OWNERSHIP", + "message": "One or more items do not belong to you", + }, + ) from None + + await db.commit() + + full = await service.get_full_outfit(outfit.id) + return outfit_to_response(full) + + @router.get("", response_model=OutfitListResponse) async def list_outfits( db: Annotated[AsyncSession, Depends(get_db)], diff --git a/backend/app/api/pairings.py b/backend/app/api/pairings.py index 8a3aac2c..cfd6637a 100644 --- a/backend/app/api/pairings.py +++ b/backend/app/api/pairings.py @@ -4,20 +4,25 @@ from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, Query, status -from pydantic import BaseModel, Field, computed_field +from pydantic import BaseModel, ConfigDict, Field, computed_field from sqlalchemy import and_, select from sqlalchemy.ext.asyncio import AsyncSession from app.database import get_db -from app.models.outfit import Outfit, OutfitSource +from app.models.outfit import Outfit from app.models.user import User +from app.schemas.outfit import MAX_AUTHORING_TEXT_LENGTH, OutfitAttributeFields from app.services.ai_service import AIDisabledError +from app.services.external_outfit_service import ExternalOutfitService from app.services.pairing_service import ( + PAIRING_SOURCE_CLAUSE, AIGenerationError, InsufficientItemsError, PairingService, ) +from app.services.studio_service import ItemOwnershipError from app.utils.auth import get_current_user +from app.utils.rate_limit import rate_limit_by_user from app.utils.signed_urls import sign_image_url logger = logging.getLogger(__name__) @@ -101,6 +106,10 @@ class PairingResponse(BaseModel): source: str reasoning: str | None = None style_notes: str | None = None + season: str | None = None + formality: str | None = None + palette: list[str] | None = None + notes: str | None = None highlights: list[str] | None = None source_item: SourceItemResponse | None = None items: list[PairingItemResponse] @@ -202,6 +211,10 @@ def pairing_to_response(outfit: Outfit) -> PairingResponse: source=outfit.source.value, reasoning=outfit.reasoning, style_notes=outfit.style_notes, + season=outfit.season, + formality=outfit.formality, + palette=outfit.palette, + notes=outfit.notes, highlights=highlights, source_item=source_item_response, items=items, @@ -306,6 +319,72 @@ async def list_item_pairings( ) +class PairingCreateRequest(OutfitAttributeFields): + model_config = ConfigDict(extra="forbid") + + items: list[UUID] = Field( + min_length=1, max_length=20, description="Partner items to pair with the source item" + ) + scheduled_for: date | None = Field( + default=None, description="Defaults to the user's current date" + ) + reasoning: Annotated[str | None, Field(max_length=MAX_AUTHORING_TEXT_LENGTH)] = None + style_notes: Annotated[str | None, Field(max_length=MAX_AUTHORING_TEXT_LENGTH)] = None + + +@router.post("/item/{item_id}", response_model=PairingResponse, status_code=status.HTTP_201_CREATED) +async def create_external_pairing( + item_id: UUID, + request: PairingCreateRequest, + db: Annotated[AsyncSession, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_user)], +) -> PairingResponse: + """Persist an externally-authored pairing; available regardless of the AI flags.""" + await rate_limit_by_user( + str(current_user.id), "external_pairing", max_requests=20, window_seconds=60 + ) + + source_item = await PairingService(db).get_source_item(current_user.id, item_id) + if not source_item: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Source item not found or not available", + ) + + service = ExternalOutfitService(db) + try: + outfit = await service.create_pairing( + user=current_user, + source_item_id=item_id, + item_ids=request.items, + scheduled_for=request.scheduled_for, + reasoning=request.reasoning, + style_notes=request.style_notes, + season=request.season, + formality=request.formality, + palette=request.palette, + notes=request.notes, + ) + except ItemOwnershipError: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail={ + "error_code": "OUTFIT_ITEM_OWNERSHIP", + "message": "One or more items do not belong to you", + }, + ) from None + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e), + ) from None + + await db.commit() + + full = await service.get_full_outfit(outfit.id) + return pairing_to_response(full) + + @router.delete("/{pairing_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_pairing( pairing_id: UUID, @@ -316,7 +395,7 @@ async def delete_pairing( and_( Outfit.id == pairing_id, Outfit.user_id == current_user.id, - Outfit.source == OutfitSource.pairing, + PAIRING_SOURCE_CLAUSE, ) ) diff --git a/backend/app/schemas/outfit.py b/backend/app/schemas/outfit.py new file mode 100644 index 00000000..3d931bab --- /dev/null +++ b/backend/app/schemas/outfit.py @@ -0,0 +1,40 @@ +from typing import Annotated + +from pydantic import BaseModel, Field, field_validator + +MAX_AUTHORING_TEXT_LENGTH = 2000 + + +class OutfitAttributeFields(BaseModel): + """Optional descriptive outfit attributes shared by the authoring and studio + request schemas. Free-form, but canonically match the item tag vocabulary + (season: spring/summer/fall/winter/all-season; formality: very-casual + through very-formal). + """ + + season: Annotated[str | None, Field(max_length=20)] = None + formality: Annotated[str | None, Field(max_length=50)] = None + palette: list[str] | None = Field( + default=None, + max_length=10, + description="Dominant outfit colors, most prominent first", + ) + notes: Annotated[str | None, Field(max_length=MAX_AUTHORING_TEXT_LENGTH)] = None + + @field_validator("season", "formality") + @classmethod + def normalize_label(cls, v: str | None) -> str | None: + if v is None: + return None + return v.strip().lower() or None + + @field_validator("palette") + @classmethod + def validate_palette(cls, v: list[str] | None) -> list[str] | None: + if v is None: + return None + colors = [c.strip().lower() for c in v] + if any(not c or len(c) > 50 for c in colors): + raise ValueError("Palette colors must be 1-50 characters") + # [] collapses to None so "no palette" has a single representation + return colors or None diff --git a/backend/app/services/external_outfit_service.py b/backend/app/services/external_outfit_service.py new file mode 100644 index 00000000..f655d998 --- /dev/null +++ b/backend/app/services/external_outfit_service.py @@ -0,0 +1,149 @@ +"""Write surface for externally-authored outfits (suggestions and pairings). + +Authored rows are regular outfits with source='external'; no separate table. +""" + +from datetime import date +from uuid import UUID + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.models.outfit import ( + FamilyOutfitRating, + Outfit, + OutfitItem, + OutfitSource, + OutfitStatus, +) +from app.models.user import User +from app.services.studio_service import validate_item_ownership +from app.utils.timezone import get_user_today + + +class ExternalOutfitService: + def __init__(self, db: AsyncSession): + self.db = db + + async def get_full_outfit(self, outfit_id: UUID) -> Outfit: + result = await self.db.execute( + select(Outfit) + .where(Outfit.id == outfit_id) + .options( + selectinload(Outfit.items).selectinload(OutfitItem.item), + selectinload(Outfit.feedback), + selectinload(Outfit.source_item), + selectinload(Outfit.family_ratings).selectinload(FamilyOutfitRating.user), + ) + ) + return result.scalar_one() + + async def _persist_outfit( + self, + user: User, + *, + ordered_item_ids: list[UUID], + occasion: str, + name: str | None = None, + scheduled_for: date | None, + source_item_id: UUID | None = None, + reasoning: str | None, + style_notes: str | None, + season: str | None, + formality: str | None, + palette: list[str] | None, + notes: str | None, + ) -> Outfit: + await validate_item_ownership(self.db, user.id, ordered_item_ids) + + outfit = Outfit( + user_id=user.id, + occasion=occasion, + scheduled_for=scheduled_for or get_user_today(user), + source=OutfitSource.external, + status=OutfitStatus.pending, + name=name, + source_item_id=source_item_id, + reasoning=reasoning, + style_notes=style_notes, + season=season, + formality=formality, + palette=palette, + notes=notes, + ) + self.db.add(outfit) + await self.db.flush() + + for position, item_id in enumerate(ordered_item_ids): + self.db.add(OutfitItem(outfit_id=outfit.id, item_id=item_id, position=position)) + + await self.db.flush() + return outfit + + async def create_suggestion( + self, + user: User, + *, + item_ids: list[UUID], + occasion: str, + name: str | None = None, + scheduled_for: date | None = None, + reasoning: str | None = None, + style_notes: str | None = None, + season: str | None = None, + formality: str | None = None, + palette: list[str] | None = None, + notes: str | None = None, + ) -> Outfit: + """Persist an authored suggestion; item positions follow the request order.""" + return await self._persist_outfit( + user, + ordered_item_ids=list(dict.fromkeys(item_ids)), + occasion=occasion, + name=name, + scheduled_for=scheduled_for, + reasoning=reasoning, + style_notes=style_notes, + season=season, + formality=formality, + palette=palette, + notes=notes, + ) + + async def create_pairing( + self, + user: User, + *, + source_item_id: UUID, + item_ids: list[UUID], + scheduled_for: date | None = None, + reasoning: str | None = None, + style_notes: str | None = None, + season: str | None = None, + formality: str | None = None, + palette: list[str] | None = None, + notes: str | None = None, + ) -> Outfit: + """Persist an authored pairing; the source item leads when absent from item_ids.""" + ordered = ( + list(dict.fromkeys(item_ids)) + if source_item_id in item_ids + else list(dict.fromkeys([source_item_id, *item_ids])) + ) + if len(ordered) < 2: + raise ValueError("A pairing needs at least one partner item") + + return await self._persist_outfit( + user, + ordered_item_ids=ordered, + occasion="pairing", + scheduled_for=scheduled_for, + source_item_id=source_item_id, + reasoning=reasoning, + style_notes=style_notes, + season=season, + formality=formality, + palette=palette, + notes=notes, + ) diff --git a/backend/app/services/pairing_service.py b/backend/app/services/pairing_service.py index efda9863..ae4e97a9 100644 --- a/backend/app/services/pairing_service.py +++ b/backend/app/services/pairing_service.py @@ -3,7 +3,7 @@ import re from uuid import UUID -from sqlalchemy import and_, select +from sqlalchemy import and_, or_, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -19,6 +19,14 @@ PAIRING_PROMPT_TEMPLATE = load_prompt("item_pairing") +# A pairing is an internally-generated row, or an externally-authored one still +# anchored to its source item (external rows without one are suggestions). +# Internal rows stay pairings even after their source item is deleted (SET NULL). +PAIRING_SOURCE_CLAUSE = or_( + Outfit.source == OutfitSource.pairing, + and_(Outfit.source == OutfitSource.external, Outfit.source_item_id.is_not(None)), +) + class PairingService: def __init__(self, db: AsyncSession): @@ -317,7 +325,7 @@ async def get_pairings_for_item( base_query = select(Outfit).where( and_( Outfit.user_id == user_id, - Outfit.source == OutfitSource.pairing, + PAIRING_SOURCE_CLAUSE, Outfit.source_item_id == source_item_id, ) ) @@ -327,7 +335,7 @@ async def get_pairings_for_item( select(Outfit.id).where( and_( Outfit.user_id == user_id, - Outfit.source == OutfitSource.pairing, + PAIRING_SOURCE_CLAUSE, Outfit.source_item_id == source_item_id, ) ) @@ -362,7 +370,7 @@ async def get_all_pairings( # Base conditions conditions = [ Outfit.user_id == user_id, - Outfit.source == OutfitSource.pairing, + PAIRING_SOURCE_CLAUSE, ] # Filter by source item type if specified diff --git a/backend/app/services/studio_service.py b/backend/app/services/studio_service.py index 47c45c9f..36020389 100644 --- a/backend/app/services/studio_service.py +++ b/backend/app/services/studio_service.py @@ -33,6 +33,28 @@ class OutfitNotTemplateError(Exception): pass +async def validate_item_ownership( + db: AsyncSession, user_id: UUID, item_ids: list[UUID] +) -> list[ClothingItem]: + if not item_ids: + raise ValueError("items required") + + result = await db.execute( + select(ClothingItem).where( + and_( + ClothingItem.id.in_(item_ids), + ClothingItem.user_id == user_id, + ClothingItem.status == ItemStatus.ready, + ) + ) + ) + items = list(result.scalars().all()) + unique_requested = set(item_ids) + if len(items) != len(unique_requested): + raise ItemOwnershipError("one or more items do not belong to the caller") + return items + + class StudioService: CLONE_SOFT_IDEMPOTENCY_SECONDS = 5 @@ -40,27 +62,6 @@ def __init__(self, db: AsyncSession): self.db = db self.learning = LearningService(db) - async def _validate_item_ownership( - self, user_id: UUID, item_ids: list[UUID] - ) -> list[ClothingItem]: - if not item_ids: - raise ValueError("items required") - - result = await self.db.execute( - select(ClothingItem).where( - and_( - ClothingItem.id.in_(item_ids), - ClothingItem.user_id == user_id, - ClothingItem.status == ItemStatus.ready, - ) - ) - ) - items = list(result.scalars().all()) - unique_requested = set(item_ids) - if len(items) != len(unique_requested): - raise ItemOwnershipError("one or more items do not belong to the caller") - return items - def _order_items_canonically(self, items: list[ClothingItem]) -> list[ClothingItem]: type_map = {item.id: (item.type or "") for item in items} ordered_ids = canonical_item_order([i.id for i in items], type_map) @@ -141,7 +142,7 @@ async def create_from_scratch( mark_worn: bool, source_item_id: UUID | None, ) -> Outfit: - items = await self._validate_item_ownership(user.id, item_ids) + items = await validate_item_ownership(self.db, user.id, item_ids) ordered = self._order_items_canonically(items) effective_worn = scheduled_for if mark_worn else None @@ -203,7 +204,7 @@ async def create_wore_instead( if existing_replacement is not None: return existing_replacement - items = await self._validate_item_ownership(user.id, item_ids) + items = await validate_item_ownership(self.db, user.id, item_ids) ordered = self._order_items_canonically(items) effective_date = scheduled_for or original.scheduled_for @@ -382,7 +383,7 @@ async def patch_outfit( if outfit.feedback is not None and outfit.feedback.worn_at is not None: raise OutfitWornImmutableError("cannot modify items on a worn outfit") - new_items = await self._validate_item_ownership(user.id, items) + new_items = await validate_item_ownership(self.db, user.id, items) ordered = self._order_items_canonically(new_items) old_item_ids = [oi.item_id for oi in outfit.items] From 1873cb1512ac0f51ac860ca77fc93b73626d6f79 Mon Sep 17 00:00:00 2001 From: Jan Sitarski Date: Fri, 10 Jul 2026 17:45:45 +0200 Subject: [PATCH 3/6] feat(api): advertise external_suggestions and external_pairings in /capabilities The authoring endpoints for externally-authored suggestions and pairings now exist, so the features block reports the full external surface as available. --- backend/app/api/health.py | 4 ++-- backend/tests/test_capabilities.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/backend/app/api/health.py b/backend/app/api/health.py index 2de7f52d..d972e2a7 100644 --- a/backend/app/api/health.py +++ b/backend/app/api/health.py @@ -34,8 +34,8 @@ async def capabilities() -> dict[str, Any]: }, "features": { "external_tagging": True, - "external_suggestions": False, - "external_pairings": False, + "external_suggestions": True, + "external_pairings": True, }, "version": "1.0.0", } diff --git a/backend/tests/test_capabilities.py b/backend/tests/test_capabilities.py index f1188fe9..659c5d95 100644 --- a/backend/tests/test_capabilities.py +++ b/backend/tests/test_capabilities.py @@ -101,8 +101,8 @@ async def test_capabilities_default_on(client: AsyncClient): assert data["ai"] == {"vision": True, "text": True} assert data["features"] == { "external_tagging": True, - "external_suggestions": False, - "external_pairings": False, + "external_suggestions": True, + "external_pairings": True, } assert data["version"] == "1.0.0" From 67713ee37f3c911e7bf350a98d9f0dae8053713e Mon Sep 17 00:00:00 2001 From: Jan Sitarski Date: Fri, 10 Jul 2026 17:54:29 +0200 Subject: [PATCH 4/6] test(outfits): cover external outfit authoring Covers suggestion and pairing authoring end to end: persistence as Outfit(source=external), request-order item positions, source-item leading, ownership rejection (403 OUTFIT_ITEM_OWNERSHIP), occasion and attribute validation bounds, unknown-field rejection, availability with internal AI disabled, pairing list/delete inclusion, and a default-on regression assertion that internally-generated rows report the authoring attributes as null. --- backend/tests/test_external_authoring.py | 457 +++++++++++++++++++++++ 1 file changed, 457 insertions(+) create mode 100644 backend/tests/test_external_authoring.py diff --git a/backend/tests/test_external_authoring.py b/backend/tests/test_external_authoring.py new file mode 100644 index 00000000..2f5cf605 --- /dev/null +++ b/backend/tests/test_external_authoring.py @@ -0,0 +1,457 @@ +"""External outfit authoring: the agent-facing write surface for suggestions +and pairings (POST /outfits/suggestions, POST /pairings/item/{item_id}). + +Covers persistence as Outfit(source=external), ownership enforcement, the +authoring attributes (season/formality/palette/notes), pairing list/delete +inclusion, and availability with internal AI off. +""" + +from datetime import date +from uuid import uuid4 + +import pytest +from httpx import AsyncClient +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import Settings +from app.models.item import ClothingItem, ItemStatus +from app.models.outfit import Outfit, OutfitItem, OutfitSource, OutfitStatus +from app.models.user import User + + +def _make_item(user_id, item_type="shirt", **kwargs) -> ClothingItem: + return ClothingItem( + user_id=user_id, + type=item_type, + image_path=f"test/{uuid4()}.jpg", + status=ItemStatus.ready, + **kwargs, + ) + + +async def _make_wardrobe( + db_session: AsyncSession, user: User, types: list[str] +) -> list[ClothingItem]: + items = [_make_item(user.id, item_type=t) for t in types] + db_session.add_all(items) + await db_session.commit() + return items + + +async def _make_foreign_item(db_session: AsyncSession) -> ClothingItem: + unique_id = str(uuid4())[:8] + other = User( + id=uuid4(), + external_id=f"other-user-{unique_id}", + email=f"other-{unique_id}@example.com", + display_name="Other User", + timezone="UTC", + is_active=True, + onboarding_completed=False, + ) + db_session.add(other) + await db_session.flush() + item = _make_item(other.id) + db_session.add(item) + await db_session.commit() + return item + + +# --- POST /outfits/suggestions ---------------------------------------------- + + +@pytest.mark.asyncio +async def test_create_suggestion_persists_external_outfit( + client: AsyncClient, test_user, auth_headers, db_session: AsyncSession +): + shirt, jeans, sneakers = await _make_wardrobe( + db_session, test_user, ["shirt", "jeans", "sneakers"] + ) + + resp = await client.post( + "/api/v1/outfits/suggestions", + json={ + "items": [str(sneakers.id), str(shirt.id), str(jeans.id)], + "occasion": "casual", + "name": "Weekend look", + "reasoning": "Light layers for a mild day", + "style_notes": "Roll the sleeves", + "season": "Summer", + "formality": "casual", + "palette": ["Navy", " white "], + "notes": "Pairs well with the canvas tote", + }, + headers=auth_headers, + ) + assert resp.status_code == 201, resp.text + body = resp.json() + assert body["source"] == "external" + assert body["status"] == "pending" + assert body["occasion"] == "casual" + assert body["name"] == "Weekend look" + assert body["reasoning"] == "Light layers for a mild day" + assert body["style_notes"] == "Roll the sleeves" + assert body["season"] == "summer" + assert body["formality"] == "casual" + assert body["palette"] == ["navy", "white"] + assert body["notes"] == "Pairs well with the canvas tote" + assert body["scheduled_for"] is not None + # Positions follow the request order + assert [i["id"] for i in body["items"]] == [str(sneakers.id), str(shirt.id), str(jeans.id)] + + +@pytest.mark.asyncio +async def test_create_suggestion_defaults_attributes_to_null( + client: AsyncClient, test_user, auth_headers, db_session: AsyncSession +): + shirt, jeans = await _make_wardrobe(db_session, test_user, ["shirt", "jeans"]) + + resp = await client.post( + "/api/v1/outfits/suggestions", + json={"items": [str(shirt.id), str(jeans.id)], "occasion": "office", "palette": []}, + headers=auth_headers, + ) + assert resp.status_code == 201, resp.text + body = resp.json() + assert body["season"] is None + assert body["formality"] is None + # palette=[] collapses to null + assert body["palette"] is None + assert body["notes"] is None + assert body["name"] is None + + +@pytest.mark.asyncio +async def test_create_suggestion_accepts_explicit_scheduled_for( + client: AsyncClient, test_user, auth_headers, db_session: AsyncSession +): + shirt, jeans = await _make_wardrobe(db_session, test_user, ["shirt", "jeans"]) + + resp = await client.post( + "/api/v1/outfits/suggestions", + json={ + "items": [str(shirt.id), str(jeans.id)], + "occasion": "casual", + "scheduled_for": "2026-08-01", + }, + headers=auth_headers, + ) + assert resp.status_code == 201, resp.text + assert resp.json()["scheduled_for"] == "2026-08-01" + + +@pytest.mark.asyncio +async def test_create_suggestion_rejects_foreign_item( + client: AsyncClient, test_user, auth_headers, db_session: AsyncSession +): + (shirt,) = await _make_wardrobe(db_session, test_user, ["shirt"]) + foreign = await _make_foreign_item(db_session) + + resp = await client.post( + "/api/v1/outfits/suggestions", + json={"items": [str(shirt.id), str(foreign.id)], "occasion": "casual"}, + headers=auth_headers, + ) + assert resp.status_code == 403, resp.text + assert resp.json()["detail"]["error_code"] == "OUTFIT_ITEM_OWNERSHIP" + + +@pytest.mark.asyncio +async def test_create_suggestion_rejects_invalid_occasion( + client: AsyncClient, test_user, auth_headers, db_session: AsyncSession +): + (shirt,) = await _make_wardrobe(db_session, test_user, ["shirt"]) + + resp = await client.post( + "/api/v1/outfits/suggestions", + json={"items": [str(shirt.id)], "occasion": "space-walk"}, + headers=auth_headers, + ) + assert resp.status_code == 422, resp.text + + +@pytest.mark.asyncio +async def test_create_suggestion_rejects_unknown_fields( + client: AsyncClient, test_user, auth_headers, db_session: AsyncSession +): + (shirt,) = await _make_wardrobe(db_session, test_user, ["shirt"]) + + resp = await client.post( + "/api/v1/outfits/suggestions", + json={"items": [str(shirt.id)], "occasion": "casual", "source": "manual"}, + headers=auth_headers, + ) + assert resp.status_code == 422, resp.text + + +@pytest.mark.asyncio +async def test_create_suggestion_validates_attribute_bounds( + client: AsyncClient, test_user, auth_headers, db_session: AsyncSession +): + (shirt,) = await _make_wardrobe(db_session, test_user, ["shirt"]) + + too_many_colors = await client.post( + "/api/v1/outfits/suggestions", + json={ + "items": [str(shirt.id)], + "occasion": "casual", + "palette": [f"color-{i}" for i in range(11)], + }, + headers=auth_headers, + ) + assert too_many_colors.status_code == 422, too_many_colors.text + + oversized_notes = await client.post( + "/api/v1/outfits/suggestions", + json={"items": [str(shirt.id)], "occasion": "casual", "notes": "x" * 2001}, + headers=auth_headers, + ) + assert oversized_notes.status_code == 422, oversized_notes.text + + +@pytest.mark.asyncio +async def test_create_suggestion_available_with_ai_off( + client: AsyncClient, test_user, auth_headers, db_session: AsyncSession, monkeypatch +): + monkeypatch.setattr( + "app.services.ai_service.get_settings", + lambda: Settings(ai_internal_enabled=False), + ) + shirt, jeans = await _make_wardrobe(db_session, test_user, ["shirt", "jeans"]) + + resp = await client.post( + "/api/v1/outfits/suggestions", + json={"items": [str(shirt.id), str(jeans.id)], "occasion": "casual"}, + headers=auth_headers, + ) + assert resp.status_code == 201, resp.text + assert resp.json()["source"] == "external" + + +@pytest.mark.asyncio +async def test_suggestion_listed_under_external_source_filter( + client: AsyncClient, test_user, auth_headers, db_session: AsyncSession +): + shirt, jeans = await _make_wardrobe(db_session, test_user, ["shirt", "jeans"]) + + created = await client.post( + "/api/v1/outfits/suggestions", + json={"items": [str(shirt.id), str(jeans.id)], "occasion": "casual"}, + headers=auth_headers, + ) + assert created.status_code == 201, created.text + outfit_id = created.json()["id"] + + listed = await client.get("/api/v1/outfits?source=external", headers=auth_headers) + assert listed.status_code == 200, listed.text + assert outfit_id in [o["id"] for o in listed.json()["outfits"]] + + +@pytest.mark.asyncio +async def test_suggestion_not_listed_as_pairing( + client: AsyncClient, test_user, auth_headers, db_session: AsyncSession +): + shirt, jeans = await _make_wardrobe(db_session, test_user, ["shirt", "jeans"]) + + created = await client.post( + "/api/v1/outfits/suggestions", + json={"items": [str(shirt.id), str(jeans.id)], "occasion": "casual"}, + headers=auth_headers, + ) + assert created.status_code == 201, created.text + + pairings = await client.get("/api/v1/pairings", headers=auth_headers) + assert pairings.status_code == 200, pairings.text + assert created.json()["id"] not in [p["id"] for p in pairings.json()["pairings"]] + + +# --- POST /pairings/item/{item_id} ------------------------------------------ + + +@pytest.mark.asyncio +async def test_create_pairing_persists_external_pairing( + client: AsyncClient, test_user, auth_headers, db_session: AsyncSession +): + shirt, jeans, jacket = await _make_wardrobe(db_session, test_user, ["shirt", "jeans", "jacket"]) + + resp = await client.post( + f"/api/v1/pairings/item/{shirt.id}", + json={ + "items": [str(jeans.id), str(jacket.id)], + "reasoning": "Denim anchors the shirt", + "style_notes": "Keep the jacket open", + "season": "fall", + "formality": "smart-casual", + "palette": ["blue", "grey"], + "notes": "Good transitional-weather pick", + }, + headers=auth_headers, + ) + assert resp.status_code == 201, resp.text + body = resp.json() + assert body["source"] == "external" + assert body["occasion"] == "pairing" + assert body["source_item"]["id"] == str(shirt.id) + assert body["season"] == "fall" + assert body["formality"] == "smart-casual" + assert body["palette"] == ["blue", "grey"] + assert body["notes"] == "Good transitional-weather pick" + # The source item leads when left out of the partner list + assert [i["id"] for i in body["items"]] == [str(shirt.id), str(jeans.id), str(jacket.id)] + + +@pytest.mark.asyncio +async def test_create_pairing_respects_explicit_source_position( + client: AsyncClient, test_user, auth_headers, db_session: AsyncSession +): + shirt, jeans, jacket = await _make_wardrobe(db_session, test_user, ["shirt", "jeans", "jacket"]) + + resp = await client.post( + f"/api/v1/pairings/item/{shirt.id}", + json={"items": [str(jeans.id), str(shirt.id), str(jacket.id)]}, + headers=auth_headers, + ) + assert resp.status_code == 201, resp.text + body = resp.json() + assert [i["id"] for i in body["items"]] == [str(jeans.id), str(shirt.id), str(jacket.id)] + + +@pytest.mark.asyncio +async def test_create_pairing_unknown_source_item_404( + client: AsyncClient, test_user, auth_headers, db_session: AsyncSession +): + (jeans,) = await _make_wardrobe(db_session, test_user, ["jeans"]) + + resp = await client.post( + f"/api/v1/pairings/item/{uuid4()}", + json={"items": [str(jeans.id)]}, + headers=auth_headers, + ) + assert resp.status_code == 404, resp.text + + +@pytest.mark.asyncio +async def test_create_pairing_rejects_foreign_partner( + client: AsyncClient, test_user, auth_headers, db_session: AsyncSession +): + (shirt,) = await _make_wardrobe(db_session, test_user, ["shirt"]) + foreign = await _make_foreign_item(db_session) + + resp = await client.post( + f"/api/v1/pairings/item/{shirt.id}", + json={"items": [str(foreign.id)]}, + headers=auth_headers, + ) + assert resp.status_code == 403, resp.text + assert resp.json()["detail"]["error_code"] == "OUTFIT_ITEM_OWNERSHIP" + + +@pytest.mark.asyncio +async def test_create_pairing_requires_a_partner( + client: AsyncClient, test_user, auth_headers, db_session: AsyncSession +): + (shirt,) = await _make_wardrobe(db_session, test_user, ["shirt"]) + + resp = await client.post( + f"/api/v1/pairings/item/{shirt.id}", + json={"items": [str(shirt.id)]}, + headers=auth_headers, + ) + assert resp.status_code == 400, resp.text + + +@pytest.mark.asyncio +async def test_create_pairing_available_with_ai_off( + client: AsyncClient, test_user, auth_headers, db_session: AsyncSession, monkeypatch +): + monkeypatch.setattr( + "app.services.ai_service.get_settings", + lambda: Settings(ai_internal_enabled=False), + ) + shirt, jeans = await _make_wardrobe(db_session, test_user, ["shirt", "jeans"]) + + resp = await client.post( + f"/api/v1/pairings/item/{shirt.id}", + json={"items": [str(jeans.id)]}, + headers=auth_headers, + ) + assert resp.status_code == 201, resp.text + assert resp.json()["source"] == "external" + + +# --- Pairing list/delete inclusion ------------------------------------------ + + +@pytest.mark.asyncio +async def test_external_pairing_listed_alongside_generated( + client: AsyncClient, test_user, auth_headers, db_session: AsyncSession +): + shirt, jeans = await _make_wardrobe(db_session, test_user, ["shirt", "jeans"]) + + created = await client.post( + f"/api/v1/pairings/item/{shirt.id}", + json={"items": [str(jeans.id)]}, + headers=auth_headers, + ) + assert created.status_code == 201, created.text + pairing_id = created.json()["id"] + + all_pairings = await client.get("/api/v1/pairings", headers=auth_headers) + assert all_pairings.status_code == 200, all_pairings.text + assert pairing_id in [p["id"] for p in all_pairings.json()["pairings"]] + + item_pairings = await client.get(f"/api/v1/pairings/item/{shirt.id}", headers=auth_headers) + assert item_pairings.status_code == 200, item_pairings.text + assert pairing_id in [p["id"] for p in item_pairings.json()["pairings"]] + + +@pytest.mark.asyncio +async def test_generated_pairing_listing_unchanged( + client: AsyncClient, test_user, auth_headers, db_session: AsyncSession +): + shirt, jeans = await _make_wardrobe(db_session, test_user, ["shirt", "jeans"]) + + outfit = Outfit( + user_id=test_user.id, + occasion="pairing", + scheduled_for=date.today(), + status=OutfitStatus.pending, + source=OutfitSource.pairing, + source_item_id=shirt.id, + reasoning="Generated pairing", + ) + outfit.items.append(OutfitItem(item_id=shirt.id, position=0)) + outfit.items.append(OutfitItem(item_id=jeans.id, position=1)) + db_session.add(outfit) + await db_session.commit() + + listed = await client.get("/api/v1/pairings", headers=auth_headers) + assert listed.status_code == 200, listed.text + entries = [p for p in listed.json()["pairings"] if p["id"] == str(outfit.id)] + assert len(entries) == 1 + # Default-on regression: internally-generated rows report the attributes as null + assert entries[0]["season"] is None + assert entries[0]["formality"] is None + assert entries[0]["palette"] is None + assert entries[0]["notes"] is None + + +@pytest.mark.asyncio +async def test_delete_external_pairing( + client: AsyncClient, test_user, auth_headers, db_session: AsyncSession +): + shirt, jeans = await _make_wardrobe(db_session, test_user, ["shirt", "jeans"]) + + created = await client.post( + f"/api/v1/pairings/item/{shirt.id}", + json={"items": [str(jeans.id)]}, + headers=auth_headers, + ) + assert created.status_code == 201, created.text + pairing_id = created.json()["id"] + + deleted = await client.delete(f"/api/v1/pairings/{pairing_id}", headers=auth_headers) + assert deleted.status_code == 204, deleted.text + + listed = await client.get("/api/v1/pairings", headers=auth_headers) + assert pairing_id not in [p["id"] for p in listed.json()["pairings"]] From b3687067bbb2a5f38efa0597184284c757e3c439 Mon Sep 17 00:00:00 2001 From: Jan Sitarski Date: Fri, 10 Jul 2026 17:56:15 +0200 Subject: [PATCH 5/6] feat(outfits): accept authoring attributes in studio composer POST /outfits/studio accepts the same optional season, formality, palette, and notes fields as the external authoring endpoints, so a manually composed outfit can carry them too. Source stays manual and the endpoint remains behind the studio kill switch; omitted attributes default to null and existing requests are unchanged. --- backend/app/api/outfits.py | 6 ++- backend/app/services/studio_service.py | 8 ++++ backend/tests/test_external_authoring.py | 30 ++++++++++++++ backend/tests/test_studio_service.py | 53 ++++++++++++++++++++++++ 4 files changed, 96 insertions(+), 1 deletion(-) diff --git a/backend/app/api/outfits.py b/backend/app/api/outfits.py index 80f906e0..7316a233 100644 --- a/backend/app/api/outfits.py +++ b/backend/app/api/outfits.py @@ -1126,7 +1126,7 @@ def _check_studio_kill_switch() -> None: ) -class StudioCreateRequest(BaseModel): +class StudioCreateRequest(OutfitAttributeFields): model_config = ConfigDict(extra="forbid") items: list[UUID] = Field(min_length=1, max_length=20) @@ -1203,6 +1203,10 @@ async def create_studio_outfit( scheduled_for=request.scheduled_for, mark_worn=request.mark_worn, source_item_id=request.source_item_id, + season=request.season, + formality=request.formality, + palette=request.palette, + notes=request.notes, ) except ItemOwnershipError: raise HTTPException( diff --git a/backend/app/services/studio_service.py b/backend/app/services/studio_service.py index 36020389..6231e367 100644 --- a/backend/app/services/studio_service.py +++ b/backend/app/services/studio_service.py @@ -141,6 +141,10 @@ async def create_from_scratch( scheduled_for: date | None, mark_worn: bool, source_item_id: UUID | None, + season: str | None = None, + formality: str | None = None, + palette: list[str] | None = None, + notes: str | None = None, ) -> Outfit: items = await validate_item_ownership(self.db, user.id, item_ids) ordered = self._order_items_canonically(items) @@ -155,6 +159,10 @@ async def create_from_scratch( status=OutfitStatus.pending, name=name, source_item_id=source_item_id, + season=season, + formality=formality, + palette=palette, + notes=notes, ) self.db.add(outfit) await self.db.flush() diff --git a/backend/tests/test_external_authoring.py b/backend/tests/test_external_authoring.py index 2f5cf605..5b0ba6ac 100644 --- a/backend/tests/test_external_authoring.py +++ b/backend/tests/test_external_authoring.py @@ -455,3 +455,33 @@ async def test_delete_external_pairing( listed = await client.get("/api/v1/pairings", headers=auth_headers) assert pairing_id not in [p["id"] for p in listed.json()["pairings"]] + + +# --- POST /outfits/studio ---------------------------------------------------- + + +@pytest.mark.asyncio +async def test_studio_accepts_authoring_attributes( + client: AsyncClient, test_user, auth_headers, db_session: AsyncSession +): + shirt, jeans = await _make_wardrobe(db_session, test_user, ["shirt", "jeans"]) + + resp = await client.post( + "/api/v1/outfits/studio", + json={ + "items": [str(shirt.id), str(jeans.id)], + "occasion": "casual", + "season": "spring", + "formality": "casual", + "palette": ["green"], + "notes": "Studio compose with attributes", + }, + headers=auth_headers, + ) + assert resp.status_code == 201, resp.text + body = resp.json() + assert body["source"] == "manual" + assert body["season"] == "spring" + assert body["formality"] == "casual" + assert body["palette"] == ["green"] + assert body["notes"] == "Studio compose with attributes" diff --git a/backend/tests/test_studio_service.py b/backend/tests/test_studio_service.py index 833b27c4..513570b1 100644 --- a/backend/tests/test_studio_service.py +++ b/backend/tests/test_studio_service.py @@ -79,6 +79,59 @@ async def test_create_from_scratch(db_session, studio_user, wardrobe_items): assert outfit.feedback.accepted is True +@pytest.mark.asyncio +async def test_create_from_scratch_with_authoring_attributes( + db_session, studio_user, wardrobe_items +): + service = StudioService(db_session) + shirt, jeans = wardrobe_items[0], wardrobe_items[1] + + outfit = await service.create_from_scratch( + user=studio_user, + item_ids=[shirt.id, jeans.id], + occasion="casual", + name=None, + scheduled_for=None, + mark_worn=False, + source_item_id=None, + season="summer", + formality="smart-casual", + palette=["blue", "white"], + notes="Composed for the lake trip", + ) + await db_session.commit() + + assert outfit.source == OutfitSource.manual + assert outfit.season == "summer" + assert outfit.formality == "smart-casual" + assert outfit.palette == ["blue", "white"] + assert outfit.notes == "Composed for the lake trip" + + +@pytest.mark.asyncio +async def test_create_from_scratch_leaves_authoring_attributes_unset( + db_session, studio_user, wardrobe_items +): + service = StudioService(db_session) + shirt, jeans = wardrobe_items[0], wardrobe_items[1] + + outfit = await service.create_from_scratch( + user=studio_user, + item_ids=[shirt.id, jeans.id], + occasion="casual", + name=None, + scheduled_for=None, + mark_worn=False, + source_item_id=None, + ) + await db_session.commit() + + assert outfit.season is None + assert outfit.formality is None + assert outfit.palette is None + assert outfit.notes is None + + @pytest.mark.asyncio async def test_create_from_scratch_mark_worn(db_session, studio_user, wardrobe_items): service = StudioService(db_session) From a5ea071561e2d7358a0b3fe1e3d40da44834620e Mon Sep 17 00:00:00 2001 From: Jan Sitarski Date: Fri, 10 Jul 2026 22:33:39 +0200 Subject: [PATCH 6/6] feat(frontend): label externally-authored outfits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds 'external' to the OutfitSource union and gives it a distinct badge in the outfit card, outfit history, and family feed. Without this the outfit card falls through to the 'AI' badge — misstating provenance — and the two Record badge maps throw on the unknown key. Existing sources render unchanged. --- frontend/app/dashboard/family/feed/page.tsx | 6 ++++++ frontend/components/outfit-calendar.tsx | 3 ++- frontend/components/outfit-history-card.tsx | 7 ++++++- frontend/components/outfits/outfit-card.tsx | 8 ++++++++ frontend/components/pairing-card.tsx | 21 ++++++++++++++++----- frontend/lib/hooks/use-outfits.ts | 2 +- frontend/lib/types.ts | 2 +- frontend/messages/de/family.json | 1 + frontend/messages/de/history.json | 1 + frontend/messages/de/outfits.json | 1 + frontend/messages/de/pairings.json | 1 + frontend/messages/en/family.json | 1 + frontend/messages/en/history.json | 1 + frontend/messages/en/outfits.json | 1 + frontend/messages/en/pairings.json | 1 + frontend/messages/fr/family.json | 1 + frontend/messages/fr/history.json | 1 + frontend/messages/fr/outfits.json | 1 + frontend/messages/fr/pairings.json | 1 + frontend/messages/it/family.json | 1 + frontend/messages/it/history.json | 1 + frontend/messages/it/outfits.json | 1 + frontend/messages/it/pairings.json | 1 + frontend/messages/ja/family.json | 1 + frontend/messages/ja/history.json | 1 + frontend/messages/ja/outfits.json | 1 + frontend/messages/ja/pairings.json | 1 + frontend/messages/ko/family.json | 1 + frontend/messages/ko/history.json | 1 + frontend/messages/ko/outfits.json | 1 + frontend/messages/ko/pairings.json | 1 + frontend/messages/zh-CN/family.json | 1 + frontend/messages/zh-CN/history.json | 1 + frontend/messages/zh-CN/outfits.json | 1 + frontend/messages/zh-CN/pairings.json | 1 + frontend/messages/zh-TW/family.json | 1 + frontend/messages/zh-TW/history.json | 1 + frontend/messages/zh-TW/outfits.json | 1 + frontend/messages/zh-TW/pairings.json | 1 + 39 files changed, 72 insertions(+), 9 deletions(-) diff --git a/frontend/app/dashboard/family/feed/page.tsx b/frontend/app/dashboard/family/feed/page.tsx index c07ab069..65841bfc 100644 --- a/frontend/app/dashboard/family/feed/page.tsx +++ b/frontend/app/dashboard/family/feed/page.tsx @@ -12,6 +12,7 @@ import { Calendar, Zap, Edit3, + Bot, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; @@ -58,6 +59,11 @@ function SourceBadge({ source }: { source: OutfitSource }) { label: t('feed.sourceBadges.pairing'), className: 'bg-violet-500/10 text-violet-600 border-violet-500/20', }, + external: { + icon: Bot, + label: t('feed.sourceBadges.external'), + className: 'bg-teal-500/10 text-teal-600 border-teal-500/20', + }, }; const { icon: Icon, label, className } = config[source]; diff --git a/frontend/components/outfit-calendar.tsx b/frontend/components/outfit-calendar.tsx index c58204b0..79f7bc5e 100644 --- a/frontend/components/outfit-calendar.tsx +++ b/frontend/components/outfit-calendar.tsx @@ -111,7 +111,8 @@ export function OutfitCalendar({ const dateKey = format(day, 'yyyy-MM-dd'); const sources = outfitsByDate.get(dateKey); const hasScheduled = sources?.has('scheduled'); - const hasOnDemand = sources?.has('on_demand') || sources?.has('manual'); + const hasOnDemand = + sources?.has('on_demand') || sources?.has('manual') || sources?.has('external'); const isSelected = selectedDate && isSameDay(day, selectedDate); const isCurrentMonth = isSameMonth(day, currentMonth); const isDayToday = isToday(day); diff --git a/frontend/components/outfit-history-card.tsx b/frontend/components/outfit-history-card.tsx index 66be445d..af84f6b0 100644 --- a/frontend/components/outfit-history-card.tsx +++ b/frontend/components/outfit-history-card.tsx @@ -2,7 +2,7 @@ import { useState } from 'react'; import Link from 'next/link'; -import { Calendar, Zap, Edit3, ThumbsUp, ThumbsDown, Clock, Eye, Star, ArrowRight, Shirt, Users, ExternalLink } from 'lucide-react'; +import { Calendar, Zap, Edit3, ThumbsUp, ThumbsDown, Clock, Eye, Star, ArrowRight, Shirt, Users, ExternalLink, Bot } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Card, CardContent } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; @@ -76,6 +76,11 @@ function SourceBadge({ source }: { source: OutfitSource }) { label: t('sourceBadges.pairing'), className: 'bg-violet-500/10 text-violet-600 border-violet-500/20', }, + external: { + icon: Bot, + label: t('sourceBadges.external'), + className: 'bg-teal-500/10 text-teal-600 border-teal-500/20', + }, }; const { icon: Icon, label, className } = config[source]; diff --git a/frontend/components/outfits/outfit-card.tsx b/frontend/components/outfits/outfit-card.tsx index fb9a5c17..0676536e 100644 --- a/frontend/components/outfits/outfit-card.tsx +++ b/frontend/components/outfits/outfit-card.tsx @@ -5,6 +5,7 @@ import Image from 'next/image'; import { formatDistanceToNow, parseISO } from 'date-fns'; import { BookmarkCheck, + Bot, Layers, RefreshCw, Shirt, @@ -63,6 +64,13 @@ function getSourceBadge(outfit: Outfit, t: any): { className: 'bg-amber-100 text-amber-700 border-amber-200', }; } + if (outfit.source === 'external') { + return { + label: t('external'), + icon: , + className: 'bg-teal-100 text-teal-700 border-teal-200', + }; + } return { label: t('ai'), icon: , diff --git a/frontend/components/pairing-card.tsx b/frontend/components/pairing-card.tsx index d29aaf61..c4f20fcd 100644 --- a/frontend/components/pairing-card.tsx +++ b/frontend/components/pairing-card.tsx @@ -1,6 +1,6 @@ 'use client'; -import { Trash2, Star, Sparkles } from 'lucide-react'; +import { Trash2, Star, Sparkles, Bot } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Card, CardContent } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; @@ -54,10 +54,21 @@ export function PairingCard({ pairing, onFeedback, onPreview }: PairingCardProps {/* Header with source badge */}
- - - {t('badge')} - +
+ + + {t('badge')} + + {pairing.source === 'external' && ( + + + {t('externalBadge')} + + )} +