Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
865cc0f
feat(api): implemented OTP email verification with Resend
denvudd May 6, 2026
78dd43b
fix(api): added redis null check in OTP verification and email functions
denvudd May 6, 2026
63b3bef
feat(web): implemented email verification using OTP
denvudd May 6, 2026
a34da88
Merge pull request #8 from denvudd/feat/email-verification
denvudd May 6, 2026
60aabb0
feat(api): add user profile endpoints and centralize auth cookie helpers
denvudd May 7, 2026
b09fc1b
feat(web): user profile page, app layout, migration for nuxt/ui & nux…
denvudd May 7, 2026
76c3ce6
Merge pull request #9 from denvudd/feat/user-profile
denvudd May 7, 2026
4dfbcf7
feat(api): implement wishlist invites feature with email notifications
denvudd May 7, 2026
7b9072e
feat(web): implement wishlist management UI with CRUD functionality a…
denvudd May 8, 2026
77bf0d3
Merge pull request #10 from denvudd/feat/wishlist-management
denvudd May 8, 2026
177b88e
feat(api): implemented wish item management with price range, notes, …
denvudd May 8, 2026
752cacc
feat(web): implemented CRUDs for wish items, share and priority features
denvudd May 10, 2026
720d5c2
feat(api): added filters for wishlist items, added store domains
denvudd May 10, 2026
2f177c4
feat(web): implement wish item filtering with reservation, fulfillmen…
denvudd May 10, 2026
4ffc74e
feat: implement reservation system with create, cancel, and fulfill f…
denvudd May 11, 2026
b95a11d
Merge pull request #11 from denvudd/feat/wish-item-management
denvudd May 11, 2026
1f5235c
feat(api): implement image upload functionality to Cloudinary with va…
denvudd May 11, 2026
b353903
feat(web): implement user settings page with avatar upload & profile …
denvudd May 11, 2026
38ac58e
Merge pull request #12 from denvudd/feat/cloudinary
denvudd May 11, 2026
65b203c
feat(api): add image URL validation for wish items
denvudd May 12, 2026
757d74e
feat(web): enhance item components with image upload and display func…
denvudd May 12, 2026
ba4d2cf
feat(api): add images field to wish items with validation for HTTP UR…
denvudd May 12, 2026
13cbfc9
feat(web): integrate drag-and-drop functionality for wish item images…
denvudd May 12, 2026
7cc740e
refactor(web): improve item card and detail modal layout, enhance fil…
denvudd May 13, 2026
a36e873
Merge pull request #13 from denvudd/feat/wish-items-images
denvudd May 13, 2026
b85d480
feat(api): add URL parsing endpoint to extract wish item fields
denvudd May 13, 2026
a5b1e17
feat(web): implement URL parsing in item entry modal and enhance form…
denvudd May 13, 2026
1734e5d
Merge pull request #14 from denvudd/feat/web-scrapping-for-wish-items
denvudd May 13, 2026
a96ce89
fix(ci): exclude migrations from ruff and sync package-lock
denvudd May 15, 2026
81dfc5c
Merge pull request #16 from denvudd/fix/exclude-migration-from-ruff
denvudd May 15, 2026
5ca58d2
feat(ci): add Nuxt preparation step to CI workflow, fixed ruff errors
denvudd May 15, 2026
de86620
feat(ci): added husky pre-commit with api and web linting
denvudd May 15, 2026
94feee4
Merge pull request #17 from denvudd/fix(ci)/nuxt-preparation-step
denvudd May 15, 2026
382cd15
fix(web): add optional chaining to IntersectionObserver callback in m…
denvudd May 15, 2026
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
16 changes: 10 additions & 6 deletions .ai/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ wishpicks/
| Form validation | VeeValidate + Zod | Client-side schemas that mirror backend validation |
| HTTP layer | Native `$fetch` / `useFetch` | Built into Nuxt, no extra dependencies |
| Theme | Nuxt UI Theme component | Simple way to implement theme toggle |
| Drag and drop | `vue-draggable-plus` | SortableJS wrapper for Vue 3; used for reordering wish item images |

### Backend — `apps/api`

Expand All @@ -67,7 +68,7 @@ wishpicks/
| Cache | Redis (Upstash) | URL parser cache, session blocklist; free serverless tier via HTTP |
| Settings | `pydantic-settings` | Typed config from `.env` file |

### Infrastructure — zero-cost MVP
### Infrastructure

| Concern | Decision | Notes |
|---|---|---|
Expand All @@ -79,7 +80,7 @@ wishpicks/
| Cache / Rate limit store | Upstash Redis | Free tier: 10k commands/day, 256 MB; HTTP-based, no sidecar needed |
| Local dev | Docker Compose | Postgres + Redis + API + Web, fully reproducible |

> **Rule:** Every infrastructure choice must have a free tier covering ~1,000 users. No paid services at MVP stage.
> **Rule:** Every infrastructure choice must have a free tier covering ~1,000 users.

---

Expand Down Expand Up @@ -212,12 +213,16 @@ User ──< RefreshToken
| `wishlist_id` | FK → wishlists | Cascade delete |
| `title` | text | |
| `description` | text, nullable | |
| `image_url` | text, nullable | |
| `price` | decimal(12,2), nullable | |
| `image_url` | text, nullable | Primary image (Cloudinary URL) |
| `images` | JSON array of text, nullable | Up to 5 additional Cloudinary URLs; ordered (position matters) |
| `price_min` | decimal(12,2), nullable | Exact price or range lower bound |
| `price_max` | decimal(12,2), nullable | Range upper bound; equals `price_min` for exact prices |
| `currency` | char(3) | ISO 4217, default `UAH` |
| `product_url` | text, nullable | Link to original product page |
| `priority` | smallint | `0` = normal, `1` = high, `2` = must-have |
| `is_surprise` | boolean | If true, hidden from owner on shared view |
| `notes` | text, nullable | Hints for gift-givers |
| `tags` | JSON array of text, nullable | Free-form tags |
| `position` | integer | Manual sort order within wishlist |
| `created_at` / `updated_at` | timestamptz | |

Expand Down Expand Up @@ -297,7 +302,6 @@ DELETE /api/items/:id
PATCH /api/items/:id/position # update sort order

POST /api/items/parse-url # { url } → scraped fields preview
POST /api/wishlists/:id/items/from-url # parse + create in one step
```

### Reservations
Expand Down Expand Up @@ -498,10 +502,10 @@ Phases represent logical groupings of work, completed in order. No dates attache

- Monorepo setup, Docker Compose configuration, environment variable structure
- Database schema design + initial Alembic migration
- Nuxt project setup: routing, i18n configuration, dark/light theme, auth composable + Pinia store
- Full auth system: register, login, logout, token refresh, refresh token rotation + theft detection
- Google OAuth integration
- User profile read + update
- Nuxt project setup: routing, i18n configuration, dark/light theme, auth composable + Pinia store

### Phase 2 — Core product

Expand Down
54 changes: 54 additions & 0 deletions .ai/sessions/2026-05-06-email-verification-web.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# Email Verification

## Description

Implemented the email verification.

## Session Log

### Bug Fix — OTP service Redis guard

1. Verified a reported bug: `generate_and_store_otp`, `verify_otp`, and `check_and_set_cooldown` in `app/services/otp.py` call Redis methods directly without checking if `redis is None`. `get_redis()` can return `None` on connection failure, causing `AttributeError`. Existing code elsewhere (e.g. `refresh_tokens`) already has `if redis:` guards; the new OTP functions lacked them.

2. Fixed with per-function degradation logic:
- `generate_and_store_otp`: raises `RuntimeError("Redis unavailable")` — registration flow wraps this in try/except, so it degrades gracefully
- `verify_otp`: raises `OTPInvalidError` — user gets a `400 OTP_INVALID`, which is the correct response when the stored code cannot be retrieved
- `check_and_set_cooldown`: returns early (skips enforcement) — allows resend to proceed rather than blocking the user when Redis is down

### Frontend — Email Verification

3. Added `is_email_verified: boolean` to `AuthUser` interface in `stores/useAuthStore.ts`.

4. Added `verifyEmail(code: string): Promise<AuthUser>` and `resendVerification(): Promise<void>` to `composables/api/useAuthApi.ts` — mapping to `POST /api/auth/verify-email` and `POST /api/auth/resend-verification` respectively.

5. Updated `composables/useAuth.ts`:
- `register()` now checks `user.is_email_verified` after registration; redirects to `/auth/verify-email` if false, `/dashboard` if true (Google OAuth users land on dashboard directly)
- Added `verifyEmail(code)` — calls API, updates store, navigates to `/dashboard`
- Added `resendVerification()` — delegates to API, no store update needed

6. Added locale keys to both `locales/en.json` and `locales/uk.json`:
- `auth.verify_email.*` block (title, subtitle, instructions, code, submit, resend, resend_cooldown, resend_success, no_code, skip)
- New error codes: `OTP_INVALID`, `OTP_MAX_ATTEMPTS`, `RESEND_COOLDOWN`, `EMAIL_ALREADY_VERIFIED`

7. Created `pages/auth/verify-email.vue`:
- `definePageMeta({ middleware: 'auth' })` — unauthenticated users redirected to `/login`
- `onMounted` guard: if `store.user.is_email_verified` is already true → redirect to `/dashboard` (handles direct URL access)
- OTP input field (`inputmode="numeric"`, `maxlength="6"`, `autocomplete="one-time-code"`)
- Submit handler: calls `verifyEmail(form.code)`, shows error on `OTP_INVALID` / `OTP_MAX_ATTEMPTS`
- Resend button with 60-second client-side countdown timer (`setInterval`), cleaned up in `onUnmounted`
- Success alert on resend
- "Continue without verifying" skip link to `/dashboard`


## Session Outcomes

- OTP Redis guard bug fixed in `apps/api/app/services/otp.py`
- `POST /api/auth/verify-email` and `POST /api/auth/resend-verification` fully wired to frontend
- Email verification page functional at `/auth/verify-email`
- Registration flow redirects unverified users to verify-email page
- `<UiAlert :show="condition">` pattern established for all animated alerts — login, register, verify-email pages updated
- Locale strings complete in both `uk` and `en`

## Lessons Learned

- **Vue `<Transition>` + scoped styles**: Transition class names (`.alert-enter-active` etc.) are added dynamically by Vue without the component's scoped data-attribute, so they won't match scoped CSS selectors. Always use non-scoped `<style>` for transition classes.
101 changes: 101 additions & 0 deletions .ai/sessions/2026-05-07-app-layout-and-profile-web.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
# App Layout + User Profile (Web)

## Description

Implemented the authenticated app shell (layout, header, sidebar) and the `/profile` page on the Nuxt 3 frontend. Also added auto-generated username at registration on the backend, a public profile page at `/u/[username]`, stub pages for reserved/settings, and a custom animated color-mode toggle component.

## Session Log

### Backend — auto-generate username at registration

1. Added `_generate_username_from_email(email)` helper in `apps/api/app/services/auth.py`:
- Derives a prefix from the email local-part (`re.sub` to keep `[a-z0-9_]`, truncated to 20 chars)
- Appends a 4-digit random suffix
- Retries up to 5 times on collision; leaves `username=None` if all collide

2. Modified `register()` to call the helper and assign `username` before `User(...)` creation.

### Frontend — composables

3. Created `apps/web/composables/api/useUsersApi.ts`:
- `updateProfile(body)` → `PATCH /api/users/me` via `apiFetch`, returns `AuthUser`

4. Created `apps/web/composables/useUsers.ts` (root-level, auto-imported):
- Wraps `useUsersApi` and calls `store.setUser(updated)` after a successful update
- Mirrors the `useAuth` → `useAuthApi` pattern; pages never call `useUsersApi` directly

### Frontend — Nuxt layout fix

5. Updated `apps/web/app.vue` to wrap `<NuxtPage>` with `<NuxtLayout>`:
- Without this, named layouts declared in `definePageMeta` are silently ignored

### Frontend — layouts/app.vue

6. Created `apps/web/layouts/app.vue`:
- Desktop (`lg+`): sticky `AppHeader` + always-visible `aside` (w-56) + `<slot>`
- Mobile/tablet (`< lg`): hamburger in header triggers `USlideover` (side="left") containing `AppSidebar`
- `AppSidebar` emits `navigate` to close the drawer on link click

### Frontend — AppHeader

7. Created `apps/web/components/layout/AppHeader.vue`:
- Left: hamburger `UButton` (hidden on `lg+`) + logo `NuxtLink`
- Right: `UiColorModeToggle` + `UDropdownMenu` with `UAvatar` trigger
- Dropdown: Settings (`/settings`) and Logout (calls `useAuth().logout`)

### Frontend — AppSidebar

8. Created `apps/web/components/layout/AppSidebar.vue`:
- 5 nav links: Collections (`/dashboard`), Interesting (`/saved`), Profile (`/profile`), Reserved (`/reserved`), Settings (`/settings`)
- Active state via `useRoute` comparison; emits `navigate` on each click

### Frontend — pages

9. Replaced `apps/web/pages/profile.vue`:
- `definePageMeta({ layout: 'app', middleware: 'auth', ssr: false })`
- Editable display name with `isDirty` save button (calls `useUsers().updateProfile`)
- Read-only `@username`; avatar placeholder
- Share button: copies `window.location.origin + /u/ + username` to clipboard
- Stub sections: "My wishlists" and "Wish board"

10. Created `apps/web/pages/u/[username].vue`:
- Public, no auth, no layout (`definePageMeta({ ssr: false })`)
- Shows `@username`, stub sections, `useSeoMeta` title

11. Created `apps/web/pages/reserved.vue` and `apps/web/pages/settings.vue` as stubs (`layout: 'app'`, `middleware: 'auth'`)

12. Updated `pages/dashboard.vue` and `pages/saved.vue` to declare `layout: 'app'`

### Frontend — ColorModeToggle

13. Created `apps/web/components/ui/ColorModeToggle.vue`:
- 2-state cycle: dark ↔ light (system preference treated as dark)
- Animated icon swap via `<Transition name="wp-spinner" mode="out-in">` reusing existing `wp-spin-in`/`wp-spin-out` keyframes from `main.css`
- Hover rotation effect (`transform: rotate(18deg)`)
- `<ClientOnly>` wrapper with static fallback (avoids SSR hydration mismatch)
- `aria-label` changes with state via i18n keys

14. Replaced `UColorModeButton` in `AppHeader.vue` with `<UiColorModeToggle />`

15. Replaced inline theme toggle block in `pages/index.vue` with `<UiColorModeToggle />`; removed now-unused `colorMode` ref and scoped styles

### Frontend — i18n

16. Added `theme.*` keys (`switch_to_light`, `switch_to_dark`, `switch_to_system`) to both `locales/uk.json` and `locales/en.json`
17. Added `profile.edit` and `profile.cancel` keys (added by linter pass)

## Session Outcomes

- Authenticated app shell fully working: header + collapsible sidebar + named Nuxt layout
- `/profile` page: display name edit, share profile link, stub sections
- `/u/[username]` public profile page
- Stub pages for `/reserved` and `/settings`
- Custom animated `UiColorModeToggle` used in both the app header and landing page
- Auto-generated username on registration
- `<NuxtLayout>` fix ensures named layouts work across all pages

## Lessons Learned

- Nuxt 3 requires `<NuxtLayout>` in `app.vue`; omitting it causes named layouts to silently not apply — no error, just `<slot>` rendered directly.
- Nuxt auto-import only scans the root `composables/` directory, not subdirectories like `composables/api/`. Composables in subdirs must be explicitly imported from a root-level wrapper (follow the `useAuth` → `useAuthApi` pattern).
- Auth-gated pages that use `window`/`navigator` APIs should set `ssr: false` in `definePageMeta` to avoid SSR serialization errors.
68 changes: 68 additions & 0 deletions .ai/sessions/2026-05-07-user-profile-api.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# User Profile Read + Update (API)

## Description

Implemented `GET /api/users/me`, `PATCH /api/users/me`, `DELETE /api/users/me` endpoints.

## Session Log

### Refactor — shared cookie utilities

1. Extracted `_set_auth_cookies` and `_clear_auth_cookies` from `app/routers/auth.py` into a new shared module `app/core/cookies.py` as `set_auth_cookies` / `clear_auth_cookies`.

2. Updated `app/routers/auth.py` to import from `app.core.cookies` — all five call-sites updated (register, login, logout, refresh, google_callback).

### New — `app/schemas/users.py`

3. `UserUpdateRequest` — PATCH schema with optional `username`, `display_name`, `avatar_url`:
- `username` validated against `^[a-z0-9_-]{3,30}$` via `field_validator`
- All fields default to `None`; `model_fields_set` used in service to apply only provided fields

4. `UserProfileResponse` — envelope `data: UserResponse`, reuses existing `UserResponse` from `auth.py` to avoid duplication.

### New — `app/services/users.py`

5. `update_profile(db, user, data)`:
- If `username` is in `model_fields_set` and not None → uniqueness check against other users → 409 `USERNAME_TAKEN` on conflict
- Applies only fields present in `model_fields_set` via `setattr` loop
- `commit` + `refresh` → returns updated user

6. `delete_account(db, user)`:
- `db.delete(user)` — ORM cascade (`all, delete-orphan`) handles wishlists, refresh_tokens, saved_wishlists, saved_items
- `commit`

### New — `app/routers/users.py`

7. Three endpoints, all rate-limited at `10/minute`:
- `GET /me` → 200 `UserProfileResponse`
- `PATCH /me` → 200 `UserProfileResponse`; 409 on username conflict; 422 on validation
- `DELETE /me` → 204; clears auth cookies after account deletion

### Updated — `app/main.py`

8. Registered `users.router` at prefix `/api/users`.

### Modified — `apps/api/app/services/auth.py`

9. Added `import random` and `import re` at the top of the file.

10. Added `_generate_username_from_email(email: str) -> str` helper after `_verify_password`:
- Strips the local part of the email (before `@`)
- Lowercases and replaces non-`[a-z0-9]` characters with `_` via `re.sub`
- Truncates to 20 characters, strips leading/trailing underscores; falls back to `"user"` if empty
- Appends a random 4-digit suffix (`random.randint(1000, 9999)`)

11. Modified `register()` to call the helper in a retry loop before creating the `User` object:
- Tries up to 5 candidates; each is checked against `User.username` in the DB
- Sets `username` to the first available candidate; leaves it `None` if all 5 collide (graceful degradation — no registration failure)
- `User(...)` now receives `username=username`

## Session Outcomes

- `GET /api/users/me`, `PATCH /api/users/me`, `DELETE /api/users/me` fully implemented
- Cookie helpers centralized in `app/core/cookies.py`
- `username` uniqueness enforced at service level with 409 `USERNAME_TAKEN`
- All new email/password registrations receive an auto-generated username (e.g. `john_doe_4271`)
- Uniqueness is guaranteed via DB check with 5-attempt retry; collision is astronomically unlikely (10 000 permutations per prefix)
- Google OAuth registrations are unaffected — `google_login` calls a separate `upsert_google_user` path
- No migration required — `username` column already existed and was already nullable
90 changes: 90 additions & 0 deletions .ai/sessions/2026-05-08-wishlist-management-web.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# Wishlist Management (Web)

## Description

Implemented the full wishlist management frontend in Nuxt 3: store, composables, API layer, components (cards, empty state, create modal, settings modal), and pages (dashboard grid, detail page). Followed up with a UI pass replacing USlideover with UModal/UDrawer and maximising Nuxt UI component usage throughout.

## Session Log

### Types

1. Extended `apps/web/types/api.ts` with wishlist-related types:
- `WishlistVisibility`, `ReservationMode`, `EventType` union types
- `WishlistResponse`, `WishlistInviteResponse`, `WishlistCreateBody`, `WishlistUpdateBody` interfaces
- `ApiFetchError` interface for typed error handling

### Store

2. Replaced `apps/web/stores/useWishlistStore.ts`:
- State: `wishlists[]`, `current`, `total`, `status` (`'idle' | 'loading' | 'error'`)
- Actions: `setList`, `setCurrent`, `prependOne`, `updateOne`, `removeOne`, `setStatus`

### API composable

3. Created `apps/web/composables/api/useWishlistsApi.ts`:
- Wraps `apiFetch` for all wishlist endpoints: `list`, `create`, `get`, `update`, `remove`, `listInvites`, `createInvite`, `deleteInvite`

### Business logic composable

4. Created `apps/web/composables/useWishlists.ts`:
- Mirrors the `useAuth` → `useAuthApi` pattern (pages never call `useWishlistsApi` directly)
- Owns a local `invites` ref per call — not in the store (invites are per-modal session)
- `fetchOne` returns `null` on 403/404 so the detail page can redirect gracefully
- `fetchInvites`, `addInvite`, `removeInvite` manage the local `invites` ref

### Components

5. Created `apps/web/components/wishlist/WishlistCard.vue`:
- `UCard` + `UBadge` (visibility), item count via i18n pluralisation, event date/type

6. Created `apps/web/components/wishlist/WishlistEmptyState.vue`:
- `UIcon` + descriptive text + `UButton` emitting `create`

7. Created `apps/web/components/wishlist/WishlistCreateModal.vue`:
- `UModal` on desktop, `UDrawer` on mobile (resolved via `resolveComponent`)
- `URadioGroup` for visibility and reservation mode options
- `#body` + `#footer` slots; `title` prop for the header

8. Created `apps/web/components/wishlist/WishlistSettingsModal.vue`:
- Same UModal/UDrawer wrapper
- Three-tab layout (basic, access, booking) using `UTabs`
- `URadioGroup` for visibility (access tab) and reservation mode (booking tab)
- `USeparator` before delete zone
- Invite list with revoke buttons; copy-link button for non-private wishlists
- Watches `open` to prefetch invites when wishlist is already private

### Pages

9. Updated `apps/web/pages/dashboard.vue`:
- Grid of `WishlistCard` components; `WishlistEmptyState` when empty
- `USkeleton` for loading state (replaces custom animated divs)
- `WishlistCreateModal` triggered by header button

10. Created `apps/web/pages/wishlists/[id]/index.vue`:
- `fetchOne` on mount; redirects to dashboard on 403/404
- Header with title, event info, and settings button → `WishlistSettingsModal`
- `USkeleton` for loading state

11. Added redirect stubs:
- `apps/web/pages/wishlists/new.vue` → `/dashboard`
- `apps/web/pages/wishlists/[id]/settings.vue` → `/wishlists/:id`

### i18n

12. Added `wishlists.*` keys to both `locales/uk.json` and `locales/en.json`:
- `title`, `new`, `empty_title`, `empty_body`, `items_count` (pluralised)
- `create.*`, `visibility.*` (with descriptions), `reservation.*` (with descriptions)
- `settings.*` (all three tabs + fields), `event_type.*`, `errors.*`

## Session Outcomes

- Full wishlist CRUD UI: create via modal on dashboard, view detail page, edit/delete via settings slideover
- Visibility management with invite system for private wishlists
- Responsive modal strategy: UModal on desktop, UDrawer on mobile
- i18n complete for both Ukrainian and English

## Lessons Learned

- `<component :is="'UModal'">` with a string name silently fails in Nuxt — Nuxt auto-import does not globally register components in the Vue component registry. Always use `resolveComponent('UModal')` at setup time when dynamic component selection is needed.
- `URadioGroup` items use `description` key (not `desc`) for the secondary line — matches the `descriptionKey` prop default.
- Both `UModal` and `UDrawer` share the same slot API (`#body`, `#footer`, `title` prop) — a single template works for both once the component reference is resolved correctly.
Loading
Loading