Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions .ai/sessions/2026-05-15-saved-wishlists-and-items-api.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# Saved Wishlists & Items (API)

## Description

Implemented the full API for Phase 3 saved/copy features:
- Save/unsave wishlists (bookmark)
- Copy a wish item from someone else's wishlist into your own (with custom priority and notes), recording the source as a bookmark
- Added `preview_images` field (up to 3 item image URLs) to all wishlist responses to support the new card design

No DB migration was needed — `saved_wishlists` and `saved_items` tables already existed from the initial schema.

---

## Session Log

### Schema changes

- Modified `app/schemas/wishlists.py`:
- Added `preview_images: list[str]` to `WishlistResponse`

- Replaced `app/schemas/saved.py` with full schemas:
- `SavedWishlistResponse` — includes `owner_display_name`, `preview_images`, `saved_at`
- `SavedWishlistListData` / `SavedWishlistListResponse` / `SavedWishlistSingleResponse`
- `CopyItemRequest` — `wishlist_id`, `priority`, `notes`
- `SavedItemResponse` — original source item fields + `saved_at`
- `SavedItemListData` / `SavedItemListResponse`

### Service changes

- Modified `app/services/wishlists.py`:
- Added `_fetch_preview_images(db, wishlist_ids)` — batch query; groups up to 3 image URLs per wishlist in Python
- Added `get_preview_images(db, wishlist_id)` — single-wishlist variant, `LIMIT 3`
- Updated `list_wishlists` return type to `tuple[list[tuple[Wishlist, int, list[str]]], int]`

- Replaced `app/services/saved.py` with full implementation:
- `list_saved_wishlists` — joins `saved_wishlists → wishlists → users` with `selectinload(Wishlist.owner)` to avoid lazy-load errors; calls `_fetch_preview_images`
- `save_wishlist` — 404 if not found, 403 if own, 409 if already saved; eager-loads `owner`
- `unsave_wishlist` — checks `rowcount` for 404
- `list_saved_items` — joins `saved_items → wish_items`
- `copy_and_save_item` — checks source ownership (403 if own), target ownership (404 if not theirs), creates `WishItem` copy, inserts into `saved_items` with `ON CONFLICT DO NOTHING`
- `unsave_item` — removes bookmark only; does not touch the copied item

### Router changes

- Modified `app/routers/wishlists.py`:
- `_build_response` now takes `preview_images: list[str]` as third arg
- All endpoints updated: `list_wishlists` unpacks 3-tuple; `get_wishlist` / `update_wishlist` call `get_preview_images`; `create_wishlist` passes `[]`

- Replaced `app/routers/saved.py` with 6 endpoints:
- `GET /api/saved/wishlists`
- `POST /api/saved/wishlists/:id`
- `DELETE /api/saved/wishlists/:id`
- `GET /api/saved/items`
- `POST /api/saved/items/:id`
- `DELETE /api/saved/items/:id`

---

## Key Design Decisions

| Decision | Choice | Reason |
|---|---|---|
| Save item = copy, not bookmark-only | Creates new `WishItem` in user's wishlist; records source in `saved_items` | User explicitly confirmed this; the item shows up in their own wishlists |
| `saved_items` ON CONFLICT DO NOTHING | Copying same source item twice creates two copies but only one bookmark | Prevents 409 errors while still tracking what the user saved |
| Preview images via second query | Fetch all wishlist IDs first, then one batch query; group in Python | Simpler than lateral joins / window functions in SQLAlchemy async; still O(2) queries not N+1 |
| `selectinload(Wishlist.owner)` | Eager load in `list_saved_wishlists` and `save_wishlist` | Async SQLAlchemy raises MissingGreenlet on lazy relationship access |
| `_fetch_preview_images` imported from `services.wishlists` | Used by both `wishlists` service and `saved` service | No circular import; `wishlists` does not import `saved` |
108 changes: 108 additions & 0 deletions .ai/sessions/2026-05-15-saved-wishlists-and-items-web.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
# Saved Wishlists & Items (Web)

## Description

Implemented the full frontend for Phase 3 saved/copy features:
- **WishlistCard redesign** — image grid (0/1/2/3+ preview images), hover overlay with bookmark button, footer with title + item count, `href` prop for context-aware navigation
- **Save wishlist** — bookmark button on public wishlist page header; auth gate (UToast) for unauthenticated users; `isSaved` state populated on mount via `fetchWishlists()`
- **Saved wishlists tab** — second tab on dashboard ("Збережені"), URL query sync (`?tab=saved`), lazy fetch on tab activate, empty state, optimistic unsave
- **Copy wish item** — "Додати" hover button on public ItemCard (authenticated users only); `CopyItemModal` with item preview, wishlist select (pre-selects first), priority picker, notes textarea, toast on success
- **Anonymous reservation fix** — `onReserve` was calling the API directly without a name, causing NAME_REQUIRED 422 silently; replaced with `SharedReservationModal` which handles name collection, auth gate, and registered-only prompt

---

## Session Log

### Types (`apps/web/types/api.ts`)

- Added `preview_images: string[]` to `WishlistResponse`
- Added `SavedWishlistResponse` — id, title, description, visibility, slug, cover_url, item_count, preview_images, owner_display_name, saved_at
- Added `SavedItemResponse` — item_id, wishlist_id, title, image_url, images, product_url, price_min, price_max, currency, saved_at
- Added `CopyItemRequest` — wishlist_id, priority?, notes?

### i18n

- Added `saved.*` key group to `apps/web/locales/uk.json` and `apps/web/locales/en.json`: save, saved, unsave, add, tab_my, tab_saved, empty_wishlists_title/body, copy_modal_title, copy_wishlist_label, copy_priority_label, copy_notes_label, copy_submit, copy_submitting, copy_success, copy_no_wishlists, save_guest_prompt, cannot_save_own, errors.unknown

### API composable (`apps/web/composables/api/useSavedApi.ts`) — created

- `listWishlists(limit?, offset?)` — `GET /api/saved/wishlists`
- `saveWishlist(id)` — `POST /api/saved/wishlists/:id`
- `unsaveWishlist(id)` — `DELETE /api/saved/wishlists/:id`
- `listItems(limit?, offset?)` — `GET /api/saved/items`
- `copyItem(sourceItemId, body)` — `POST /api/saved/items/:id`
- `unsaveItem(sourceItemId)` — `DELETE /api/saved/items/:id`

### Store (`apps/web/stores/useSavedStore.ts`) — replaced stub

- State: `wishlists`, `wishlistIds: Set<string>`, `items`, `wishlistsTotal`, `itemsTotal`, `status`
- `wishlistIds` Set for O(1) `isSaved` lookup
- Actions: `setWishlists` (rebuilds Set), `addWishlist`, `removeWishlist`, `setItems`, `setStatus`

### Composable (`apps/web/composables/useSaved.ts`) — created

- `isSaved(wishlistId)` — O(1) via store Set
- `fetchWishlists()` — deduplicates if already loading
- `saveWishlist(id)` — calls API + `store.addWishlist`; catches 403 → toast "Не можна зберегти власний"
- `unsaveWishlist(id)` — optimistic: removes from store immediately, refetches on failure
- `toggleWishlist(id)` — isSaved → unsave, else save
- `fetchItems()`, `copyItem()`, `unsaveItem()`

### WishlistCard (`apps/web/components/wishlist/WishlistCard.vue`) — replaced

- Props: `wishlist: WishlistResponse | SavedWishlistResponse`, `canSave?: boolean`, `isSaved?: boolean`, `href?: string`
- Emits: `save`, `unsave`
- Image grid: 0 images → gift icon; 1 → full cover; 2 → grid-cols-2; 3+ → left full-height + right stacked 2
- Hover overlay (v-if="canSave"): bookmark icon button, top-right corner
- Footer: title (line-clamp-2) + item count
- Navigation: `href` prop overrides default `/w/:slug`; dashboard passes `/wishlists/:id`

### CopyItemModal (`apps/web/components/saved/CopyItemModal.vue`) — created

- Item preview (image thumb + title + price)
- USelect for target wishlist (pre-selects first option on open)
- Priority picker (reuses `ItemsItemPriorityPicker`)
- Notes textarea
- Error UAlert, loading submit button
- `watch(open, ..., { immediate: true, flush: 'sync' })` — ensures `fetchList()` + `setStatus('loading')` runs synchronously before first render, avoiding false "no wishlists" flash when component mounts with `open` already `true`
- `watch(wishlistOptions)` — sets first option when wishlists arrive asynchronously

### ItemCard (`apps/web/components/shared/ItemCard.vue`) — modified

- Added `group` class to root div
- Added `copy: [item: SharedItemResponse]` emit
- Added hover overlay with "Додати" button (authenticated only)
- `@click.stop` on both overlay wrapper and UButton to prevent click bubbling to card's `@click="$emit('open', item)"`

### Dashboard (`apps/web/pages/dashboard.vue`) — replaced

- `UTabs` with two items: `saved.tab_my` / `saved.tab_saved`
- `activeTab` synced with `?tab=saved` URL query param
- "My wishlists" tab: existing grid with `href="/wishlists/:id"` on WishlistCard
- "Saved" tab: lazy `fetchWishlists()` on activate; skeleton; empty state; WishlistCard with `canSave=true`, `isSaved=true`, `@unsave`

### Public wishlist page (`apps/web/pages/w/[slug]/index.vue`) — modified

- Save button in header: bookmark icon + label; authenticated → `toggleWishlist`; unauthenticated → UToast with login link
- `fetchWishlists()` called in `onMounted` if authenticated (ensures `isSaved` state is correct even on direct navigation)
- `@copy` emit on `SharedItemCard` → opens `SavedCopyItemModal`
- `SharedReservationModal` wired up (was missing — see fixes below)

### Anonymous reservation fix

- **Bug:** `onReserve` called `reservationsApi.reserve(item.id)` directly with no name → backend returns 422 NAME_REQUIRED for anonymous wishlists; error was silently swallowed
- **Fix:** `onReserve` now sets `reserveItem` + `reserveOpen = true`; `SharedReservationModal` handles name collection (already existed and had correct logic), auth gate, and registered-only prompt
- `onReserved(itemId, anonToken)` updates `displayItems` and saves `anonToken` to `reservationStore`

---

## Key Design Decisions

| Decision | Choice | Reason |
|---|---|---|
| `wishlistIds: Set<string>` in store | O(1) `isSaved` check | WishlistCard bookmark state read on every render; linear scan would be costly |
| Optimistic unsave | Remove from store immediately, refetch on API failure | Instant UI feedback; failure is rare |
| `href` prop on WishlistCard | Dashboard passes `/wishlists/:id`, everywhere else uses `/w/:slug` | Same card component used in two navigation contexts |
| `watch(open, ..., { immediate: true, flush: 'sync' })` in CopyItemModal | Ensures fetch runs before first render | Component mounts with `open` already `true` (parent sets `copyModalItem` then `copyOpen = true` synchronously); non-immediate watch would never fire for the initial value |
| `@click.stop` on both overlay div and UButton | Belt-and-suspenders | UButton renders a native `<button>`; stop on wrapper div catches bubbling, stop on UButton ensures the copy emit doesn't re-trigger the card's open handler |
| Anonymous reservation via ReservationModal | Open existing modal instead of inline API call | ReservationModal already had the name prompt UI; the page was bypassing it entirely |
2 changes: 2 additions & 0 deletions apps/api/app/models/wish_item.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ class WishItem(Base, UUIDMixin, TimestampMixin):
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
tags: Mapped[list[str] | None] = mapped_column(JSON, nullable=True)
images: Mapped[list[str] | None] = mapped_column(JSON, nullable=True)
image_width: Mapped[int | None] = mapped_column(Integer, nullable=True)
image_height: Mapped[int | None] = mapped_column(Integer, nullable=True)

wishlist: Mapped["Wishlist"] = relationship("Wishlist", back_populates="items")
reservation: Mapped["Reservation | None"] = relationship(
Expand Down
2 changes: 2 additions & 0 deletions apps/api/app/routers/items.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ async def parse_product_url(
title=result.title,
description=result.description,
image_url=result.image_url,
image_width=result.image_width,
image_height=result.image_height,
price=result.price,
currency=result.currency,
product_url=result.product_url,
Expand Down
4 changes: 2 additions & 2 deletions apps/api/app/routers/media.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,5 +28,5 @@ async def upload_image(
current_user: User = Depends(get_current_user),
) -> MediaUploadResponse:
file_bytes = await file.read()
url = await media_service.upload_image(file_bytes, file.content_type or "", folder)
return MediaUploadResponse(data=MediaUploadData(url=url))
url, width, height = await media_service.upload_image(file_bytes, file.content_type or "", folder)
return MediaUploadResponse(data=MediaUploadData(url=url, width=width, height=height))
31 changes: 29 additions & 2 deletions apps/api/app/routers/reservations.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import uuid

from fastapi import APIRouter, Depends, Header, Request
from fastapi import APIRouter, Depends, Header, Query, Request
from sqlalchemy.ext.asyncio import AsyncSession

from app.core.limiter import limiter
from app.dependencies.get_current_user import optional_current_user
from app.dependencies.get_current_user import get_current_user, optional_current_user
from app.dependencies.get_db import get_db
from app.models.user import User
from app.schemas.reservations import (
FulfillRequest,
MyReservationListData,
MyReservationListResponse,
ReservationCreate,
ReservationResponse,
ReservationSingleResponse,
Expand Down Expand Up @@ -81,3 +83,28 @@ async def fulfill_reservation(
current_user: User | None = Depends(optional_current_user),
) -> None:
await reservation_service.fulfill_reservation(db, item_id, body.is_fulfilled, current_user, x_anon_token)


@router.get(
"/reservations",
summary="List current user's reservations",
response_model=MyReservationListResponse,
responses={
401: {"description": "Not authenticated"},
},
)
async def list_my_reservations(
limit: int = Query(default=20, ge=1, le=100),
offset: int = Query(default=0, ge=0),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> MyReservationListResponse:
items, total = await reservation_service.list_my_reservations(db, current_user.id, limit, offset)
return MyReservationListResponse(
data=MyReservationListData(
items=items,
total=total,
limit=limit,
offset=offset,
)
)
Loading
Loading