Skip to content

Commit cbcf0d8

Browse files
authored
Merge pull request #15 from denvudd/dev
release: release v1.1.0
2 parents 7b4db2f + 382cd15 commit cbcf0d8

141 files changed

Lines changed: 28006 additions & 3847 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.ai/ARCHITECTURE.md

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ wishpicks/
5050
| Form validation | VeeValidate + Zod | Client-side schemas that mirror backend validation |
5151
| HTTP layer | Native `$fetch` / `useFetch` | Built into Nuxt, no extra dependencies |
5252
| Theme | Nuxt UI Theme component | Simple way to implement theme toggle |
53+
| Drag and drop | `vue-draggable-plus` | SortableJS wrapper for Vue 3; used for reordering wish item images |
5354

5455
### Backend — `apps/api`
5556

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

70-
### Infrastructure — zero-cost MVP
71+
### Infrastructure
7172

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

82-
> **Rule:** Every infrastructure choice must have a free tier covering ~1,000 users. No paid services at MVP stage.
83+
> **Rule:** Every infrastructure choice must have a free tier covering ~1,000 users.
8384
8485
---
8586

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

@@ -297,7 +302,6 @@ DELETE /api/items/:id
297302
PATCH /api/items/:id/position # update sort order
298303
299304
POST /api/items/parse-url # { url } → scraped fields preview
300-
POST /api/wishlists/:id/items/from-url # parse + create in one step
301305
```
302306

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

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

506510
### Phase 2 — Core product
507511

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
# Email Verification
2+
3+
## Description
4+
5+
Implemented the email verification.
6+
7+
## Session Log
8+
9+
### Bug Fix — OTP service Redis guard
10+
11+
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.
12+
13+
2. Fixed with per-function degradation logic:
14+
- `generate_and_store_otp`: raises `RuntimeError("Redis unavailable")` — registration flow wraps this in try/except, so it degrades gracefully
15+
- `verify_otp`: raises `OTPInvalidError` — user gets a `400 OTP_INVALID`, which is the correct response when the stored code cannot be retrieved
16+
- `check_and_set_cooldown`: returns early (skips enforcement) — allows resend to proceed rather than blocking the user when Redis is down
17+
18+
### Frontend — Email Verification
19+
20+
3. Added `is_email_verified: boolean` to `AuthUser` interface in `stores/useAuthStore.ts`.
21+
22+
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.
23+
24+
5. Updated `composables/useAuth.ts`:
25+
- `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)
26+
- Added `verifyEmail(code)` — calls API, updates store, navigates to `/dashboard`
27+
- Added `resendVerification()` — delegates to API, no store update needed
28+
29+
6. Added locale keys to both `locales/en.json` and `locales/uk.json`:
30+
- `auth.verify_email.*` block (title, subtitle, instructions, code, submit, resend, resend_cooldown, resend_success, no_code, skip)
31+
- New error codes: `OTP_INVALID`, `OTP_MAX_ATTEMPTS`, `RESEND_COOLDOWN`, `EMAIL_ALREADY_VERIFIED`
32+
33+
7. Created `pages/auth/verify-email.vue`:
34+
- `definePageMeta({ middleware: 'auth' })` — unauthenticated users redirected to `/login`
35+
- `onMounted` guard: if `store.user.is_email_verified` is already true → redirect to `/dashboard` (handles direct URL access)
36+
- OTP input field (`inputmode="numeric"`, `maxlength="6"`, `autocomplete="one-time-code"`)
37+
- Submit handler: calls `verifyEmail(form.code)`, shows error on `OTP_INVALID` / `OTP_MAX_ATTEMPTS`
38+
- Resend button with 60-second client-side countdown timer (`setInterval`), cleaned up in `onUnmounted`
39+
- Success alert on resend
40+
- "Continue without verifying" skip link to `/dashboard`
41+
42+
43+
## Session Outcomes
44+
45+
- OTP Redis guard bug fixed in `apps/api/app/services/otp.py`
46+
- `POST /api/auth/verify-email` and `POST /api/auth/resend-verification` fully wired to frontend
47+
- Email verification page functional at `/auth/verify-email`
48+
- Registration flow redirects unverified users to verify-email page
49+
- `<UiAlert :show="condition">` pattern established for all animated alerts — login, register, verify-email pages updated
50+
- Locale strings complete in both `uk` and `en`
51+
52+
## Lessons Learned
53+
54+
- **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.
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
# App Layout + User Profile (Web)
2+
3+
## Description
4+
5+
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.
6+
7+
## Session Log
8+
9+
### Backend — auto-generate username at registration
10+
11+
1. Added `_generate_username_from_email(email)` helper in `apps/api/app/services/auth.py`:
12+
- Derives a prefix from the email local-part (`re.sub` to keep `[a-z0-9_]`, truncated to 20 chars)
13+
- Appends a 4-digit random suffix
14+
- Retries up to 5 times on collision; leaves `username=None` if all collide
15+
16+
2. Modified `register()` to call the helper and assign `username` before `User(...)` creation.
17+
18+
### Frontend — composables
19+
20+
3. Created `apps/web/composables/api/useUsersApi.ts`:
21+
- `updateProfile(body)``PATCH /api/users/me` via `apiFetch`, returns `AuthUser`
22+
23+
4. Created `apps/web/composables/useUsers.ts` (root-level, auto-imported):
24+
- Wraps `useUsersApi` and calls `store.setUser(updated)` after a successful update
25+
- Mirrors the `useAuth``useAuthApi` pattern; pages never call `useUsersApi` directly
26+
27+
### Frontend — Nuxt layout fix
28+
29+
5. Updated `apps/web/app.vue` to wrap `<NuxtPage>` with `<NuxtLayout>`:
30+
- Without this, named layouts declared in `definePageMeta` are silently ignored
31+
32+
### Frontend — layouts/app.vue
33+
34+
6. Created `apps/web/layouts/app.vue`:
35+
- Desktop (`lg+`): sticky `AppHeader` + always-visible `aside` (w-56) + `<slot>`
36+
- Mobile/tablet (`< lg`): hamburger in header triggers `USlideover` (side="left") containing `AppSidebar`
37+
- `AppSidebar` emits `navigate` to close the drawer on link click
38+
39+
### Frontend — AppHeader
40+
41+
7. Created `apps/web/components/layout/AppHeader.vue`:
42+
- Left: hamburger `UButton` (hidden on `lg+`) + logo `NuxtLink`
43+
- Right: `UiColorModeToggle` + `UDropdownMenu` with `UAvatar` trigger
44+
- Dropdown: Settings (`/settings`) and Logout (calls `useAuth().logout`)
45+
46+
### Frontend — AppSidebar
47+
48+
8. Created `apps/web/components/layout/AppSidebar.vue`:
49+
- 5 nav links: Collections (`/dashboard`), Interesting (`/saved`), Profile (`/profile`), Reserved (`/reserved`), Settings (`/settings`)
50+
- Active state via `useRoute` comparison; emits `navigate` on each click
51+
52+
### Frontend — pages
53+
54+
9. Replaced `apps/web/pages/profile.vue`:
55+
- `definePageMeta({ layout: 'app', middleware: 'auth', ssr: false })`
56+
- Editable display name with `isDirty` save button (calls `useUsers().updateProfile`)
57+
- Read-only `@username`; avatar placeholder
58+
- Share button: copies `window.location.origin + /u/ + username` to clipboard
59+
- Stub sections: "My wishlists" and "Wish board"
60+
61+
10. Created `apps/web/pages/u/[username].vue`:
62+
- Public, no auth, no layout (`definePageMeta({ ssr: false })`)
63+
- Shows `@username`, stub sections, `useSeoMeta` title
64+
65+
11. Created `apps/web/pages/reserved.vue` and `apps/web/pages/settings.vue` as stubs (`layout: 'app'`, `middleware: 'auth'`)
66+
67+
12. Updated `pages/dashboard.vue` and `pages/saved.vue` to declare `layout: 'app'`
68+
69+
### Frontend — ColorModeToggle
70+
71+
13. Created `apps/web/components/ui/ColorModeToggle.vue`:
72+
- 2-state cycle: dark ↔ light (system preference treated as dark)
73+
- Animated icon swap via `<Transition name="wp-spinner" mode="out-in">` reusing existing `wp-spin-in`/`wp-spin-out` keyframes from `main.css`
74+
- Hover rotation effect (`transform: rotate(18deg)`)
75+
- `<ClientOnly>` wrapper with static fallback (avoids SSR hydration mismatch)
76+
- `aria-label` changes with state via i18n keys
77+
78+
14. Replaced `UColorModeButton` in `AppHeader.vue` with `<UiColorModeToggle />`
79+
80+
15. Replaced inline theme toggle block in `pages/index.vue` with `<UiColorModeToggle />`; removed now-unused `colorMode` ref and scoped styles
81+
82+
### Frontend — i18n
83+
84+
16. Added `theme.*` keys (`switch_to_light`, `switch_to_dark`, `switch_to_system`) to both `locales/uk.json` and `locales/en.json`
85+
17. Added `profile.edit` and `profile.cancel` keys (added by linter pass)
86+
87+
## Session Outcomes
88+
89+
- Authenticated app shell fully working: header + collapsible sidebar + named Nuxt layout
90+
- `/profile` page: display name edit, share profile link, stub sections
91+
- `/u/[username]` public profile page
92+
- Stub pages for `/reserved` and `/settings`
93+
- Custom animated `UiColorModeToggle` used in both the app header and landing page
94+
- Auto-generated username on registration
95+
- `<NuxtLayout>` fix ensures named layouts work across all pages
96+
97+
## Lessons Learned
98+
99+
- Nuxt 3 requires `<NuxtLayout>` in `app.vue`; omitting it causes named layouts to silently not apply — no error, just `<slot>` rendered directly.
100+
- 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).
101+
- Auth-gated pages that use `window`/`navigator` APIs should set `ssr: false` in `definePageMeta` to avoid SSR serialization errors.
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
# User Profile Read + Update (API)
2+
3+
## Description
4+
5+
Implemented `GET /api/users/me`, `PATCH /api/users/me`, `DELETE /api/users/me` endpoints.
6+
7+
## Session Log
8+
9+
### Refactor — shared cookie utilities
10+
11+
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`.
12+
13+
2. Updated `app/routers/auth.py` to import from `app.core.cookies` — all five call-sites updated (register, login, logout, refresh, google_callback).
14+
15+
### New — `app/schemas/users.py`
16+
17+
3. `UserUpdateRequest` — PATCH schema with optional `username`, `display_name`, `avatar_url`:
18+
- `username` validated against `^[a-z0-9_-]{3,30}$` via `field_validator`
19+
- All fields default to `None`; `model_fields_set` used in service to apply only provided fields
20+
21+
4. `UserProfileResponse` — envelope `data: UserResponse`, reuses existing `UserResponse` from `auth.py` to avoid duplication.
22+
23+
### New — `app/services/users.py`
24+
25+
5. `update_profile(db, user, data)`:
26+
- If `username` is in `model_fields_set` and not None → uniqueness check against other users → 409 `USERNAME_TAKEN` on conflict
27+
- Applies only fields present in `model_fields_set` via `setattr` loop
28+
- `commit` + `refresh` → returns updated user
29+
30+
6. `delete_account(db, user)`:
31+
- `db.delete(user)` — ORM cascade (`all, delete-orphan`) handles wishlists, refresh_tokens, saved_wishlists, saved_items
32+
- `commit`
33+
34+
### New — `app/routers/users.py`
35+
36+
7. Three endpoints, all rate-limited at `10/minute`:
37+
- `GET /me` → 200 `UserProfileResponse`
38+
- `PATCH /me` → 200 `UserProfileResponse`; 409 on username conflict; 422 on validation
39+
- `DELETE /me` → 204; clears auth cookies after account deletion
40+
41+
### Updated — `app/main.py`
42+
43+
8. Registered `users.router` at prefix `/api/users`.
44+
45+
### Modified — `apps/api/app/services/auth.py`
46+
47+
9. Added `import random` and `import re` at the top of the file.
48+
49+
10. Added `_generate_username_from_email(email: str) -> str` helper after `_verify_password`:
50+
- Strips the local part of the email (before `@`)
51+
- Lowercases and replaces non-`[a-z0-9]` characters with `_` via `re.sub`
52+
- Truncates to 20 characters, strips leading/trailing underscores; falls back to `"user"` if empty
53+
- Appends a random 4-digit suffix (`random.randint(1000, 9999)`)
54+
55+
11. Modified `register()` to call the helper in a retry loop before creating the `User` object:
56+
- Tries up to 5 candidates; each is checked against `User.username` in the DB
57+
- Sets `username` to the first available candidate; leaves it `None` if all 5 collide (graceful degradation — no registration failure)
58+
- `User(...)` now receives `username=username`
59+
60+
## Session Outcomes
61+
62+
- `GET /api/users/me`, `PATCH /api/users/me`, `DELETE /api/users/me` fully implemented
63+
- Cookie helpers centralized in `app/core/cookies.py`
64+
- `username` uniqueness enforced at service level with 409 `USERNAME_TAKEN`
65+
- All new email/password registrations receive an auto-generated username (e.g. `john_doe_4271`)
66+
- Uniqueness is guaranteed via DB check with 5-attempt retry; collision is astronomically unlikely (10 000 permutations per prefix)
67+
- Google OAuth registrations are unaffected — `google_login` calls a separate `upsert_google_user` path
68+
- No migration required — `username` column already existed and was already nullable
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
# Wishlist Management (Web)
2+
3+
## Description
4+
5+
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.
6+
7+
## Session Log
8+
9+
### Types
10+
11+
1. Extended `apps/web/types/api.ts` with wishlist-related types:
12+
- `WishlistVisibility`, `ReservationMode`, `EventType` union types
13+
- `WishlistResponse`, `WishlistInviteResponse`, `WishlistCreateBody`, `WishlistUpdateBody` interfaces
14+
- `ApiFetchError` interface for typed error handling
15+
16+
### Store
17+
18+
2. Replaced `apps/web/stores/useWishlistStore.ts`:
19+
- State: `wishlists[]`, `current`, `total`, `status` (`'idle' | 'loading' | 'error'`)
20+
- Actions: `setList`, `setCurrent`, `prependOne`, `updateOne`, `removeOne`, `setStatus`
21+
22+
### API composable
23+
24+
3. Created `apps/web/composables/api/useWishlistsApi.ts`:
25+
- Wraps `apiFetch` for all wishlist endpoints: `list`, `create`, `get`, `update`, `remove`, `listInvites`, `createInvite`, `deleteInvite`
26+
27+
### Business logic composable
28+
29+
4. Created `apps/web/composables/useWishlists.ts`:
30+
- Mirrors the `useAuth``useAuthApi` pattern (pages never call `useWishlistsApi` directly)
31+
- Owns a local `invites` ref per call — not in the store (invites are per-modal session)
32+
- `fetchOne` returns `null` on 403/404 so the detail page can redirect gracefully
33+
- `fetchInvites`, `addInvite`, `removeInvite` manage the local `invites` ref
34+
35+
### Components
36+
37+
5. Created `apps/web/components/wishlist/WishlistCard.vue`:
38+
- `UCard` + `UBadge` (visibility), item count via i18n pluralisation, event date/type
39+
40+
6. Created `apps/web/components/wishlist/WishlistEmptyState.vue`:
41+
- `UIcon` + descriptive text + `UButton` emitting `create`
42+
43+
7. Created `apps/web/components/wishlist/WishlistCreateModal.vue`:
44+
- `UModal` on desktop, `UDrawer` on mobile (resolved via `resolveComponent`)
45+
- `URadioGroup` for visibility and reservation mode options
46+
- `#body` + `#footer` slots; `title` prop for the header
47+
48+
8. Created `apps/web/components/wishlist/WishlistSettingsModal.vue`:
49+
- Same UModal/UDrawer wrapper
50+
- Three-tab layout (basic, access, booking) using `UTabs`
51+
- `URadioGroup` for visibility (access tab) and reservation mode (booking tab)
52+
- `USeparator` before delete zone
53+
- Invite list with revoke buttons; copy-link button for non-private wishlists
54+
- Watches `open` to prefetch invites when wishlist is already private
55+
56+
### Pages
57+
58+
9. Updated `apps/web/pages/dashboard.vue`:
59+
- Grid of `WishlistCard` components; `WishlistEmptyState` when empty
60+
- `USkeleton` for loading state (replaces custom animated divs)
61+
- `WishlistCreateModal` triggered by header button
62+
63+
10. Created `apps/web/pages/wishlists/[id]/index.vue`:
64+
- `fetchOne` on mount; redirects to dashboard on 403/404
65+
- Header with title, event info, and settings button → `WishlistSettingsModal`
66+
- `USkeleton` for loading state
67+
68+
11. Added redirect stubs:
69+
- `apps/web/pages/wishlists/new.vue``/dashboard`
70+
- `apps/web/pages/wishlists/[id]/settings.vue``/wishlists/:id`
71+
72+
### i18n
73+
74+
12. Added `wishlists.*` keys to both `locales/uk.json` and `locales/en.json`:
75+
- `title`, `new`, `empty_title`, `empty_body`, `items_count` (pluralised)
76+
- `create.*`, `visibility.*` (with descriptions), `reservation.*` (with descriptions)
77+
- `settings.*` (all three tabs + fields), `event_type.*`, `errors.*`
78+
79+
## Session Outcomes
80+
81+
- Full wishlist CRUD UI: create via modal on dashboard, view detail page, edit/delete via settings slideover
82+
- Visibility management with invite system for private wishlists
83+
- Responsive modal strategy: UModal on desktop, UDrawer on mobile
84+
- i18n complete for both Ukrainian and English
85+
86+
## Lessons Learned
87+
88+
- `<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.
89+
- `URadioGroup` items use `description` key (not `desc`) for the secondary line — matches the `descriptionKey` prop default.
90+
- 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.

0 commit comments

Comments
 (0)