From 5d386bf7525699fbe79416cd8de15172f169102e Mon Sep 17 00:00:00 2001 From: "s.esmaeili" Date: Thu, 8 Oct 2026 10:54:17 +0330 Subject: [PATCH] feat(favorites): keep customer favourites on the server, as legacy did (FAV-01) Both apps kept favourites in memory only, and the API had no route, so the 874 favourites imported from legacy major_favorites never showed. - GET customer/me/favorites, POST customer/me/favorites/toggle - apps load them on sign-in, clear on sign-out, toggle optimistically and roll back on failure Co-Authored-By: Claude Opus 5.5 --- apps/api/src/app.module.ts | 2 + .../customer-favorites.controller.ts | 30 +++++++++ .../customer-favorites.module.ts | 11 +++ .../customer-favorites.service.spec.ts | 49 ++++++++++++++ .../customer-favorites.service.ts | 40 +++++++++++ .../dto/toggle-favorite.dto.ts | 9 +++ apps/user-app-v2/src/api/client.ts | 8 +++ apps/user-app-v2/src/stores/favoritesStore.ts | 67 ++++++++++++++----- apps/user-app/src/api/client.ts | 8 +++ apps/user-app/src/stores/favoritesStore.ts | 67 ++++++++++++++----- 10 files changed, 257 insertions(+), 34 deletions(-) create mode 100644 apps/api/src/modules/customer-favorites/customer-favorites.controller.ts create mode 100644 apps/api/src/modules/customer-favorites/customer-favorites.module.ts create mode 100644 apps/api/src/modules/customer-favorites/customer-favorites.service.spec.ts create mode 100644 apps/api/src/modules/customer-favorites/customer-favorites.service.ts create mode 100644 apps/api/src/modules/customer-favorites/dto/toggle-favorite.dto.ts diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 058e66f..1366eb3 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -5,6 +5,7 @@ import { CustomersModule } from './modules/customers/customers.module'; import { CustomerAuthModule } from './modules/customer-auth/customer-auth.module'; import { SalesExpertAppModule } from './modules/sales-expert-app/sales-expert-app.module'; import { CustomerProfileModule } from './modules/customer-profile/customer-profile.module'; +import { CustomerFavoritesModule } from './modules/customer-favorites/customer-favorites.module'; import { CustomerReportModule } from './modules/customer-report/customer-report.module'; import { SmsModule } from './modules/sms/sms.module'; import { OrdersModule } from './modules/orders/orders.module'; @@ -63,6 +64,7 @@ import { SalesAgentModule } from './modules/sales-agent/sales-agent.module'; CustomerAuthModule, SalesExpertAppModule, CustomerProfileModule, + CustomerFavoritesModule, CustomerReportModule, SalesAgentModule, OrdersModule, diff --git a/apps/api/src/modules/customer-favorites/customer-favorites.controller.ts b/apps/api/src/modules/customer-favorites/customer-favorites.controller.ts new file mode 100644 index 0000000..5d429ca --- /dev/null +++ b/apps/api/src/modules/customer-favorites/customer-favorites.controller.ts @@ -0,0 +1,30 @@ +import { Body, Controller, Get, HttpCode, Post } from '@nestjs/common'; +import { CurrentCustomer, CustomerAuth } from '../customer-auth/customer.guards'; +import type { AuthenticatedCustomer } from '../customer-auth/customer-jwt.strategy'; +import { CustomerFavoritesService } from './customer-favorites.service'; +import { ToggleFavoriteDto } from './dto/toggle-favorite.dto'; + +/** + * Legacy endpoints (routes/api.php:84-85, customer guard): + * POST add-to-fav -> POST customer/me/favorites/toggle + * POST get-favorites -> GET customer/me/favorites + * The customer always comes from the token (D21); for a sales expert that is + * their trading account. + */ +@Controller('customer/me/favorites') +export class CustomerFavoritesController { + constructor(private readonly favorites: CustomerFavoritesService) {} + + @CustomerAuth() + @Get() + list(@CurrentCustomer() me: AuthenticatedCustomer) { + return this.favorites.list(me.id); + } + + @CustomerAuth() + @Post('toggle') + @HttpCode(200) + toggle(@Body() dto: ToggleFavoriteDto, @CurrentCustomer() me: AuthenticatedCustomer) { + return this.favorites.toggle(me.id, dto.productId); + } +} diff --git a/apps/api/src/modules/customer-favorites/customer-favorites.module.ts b/apps/api/src/modules/customer-favorites/customer-favorites.module.ts new file mode 100644 index 0000000..a054e20 --- /dev/null +++ b/apps/api/src/modules/customer-favorites/customer-favorites.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { PrismaModule } from '../../prisma/prisma.module'; +import { CustomerFavoritesController } from './customer-favorites.controller'; +import { CustomerFavoritesService } from './customer-favorites.service'; + +@Module({ + imports: [PrismaModule], + controllers: [CustomerFavoritesController], + providers: [CustomerFavoritesService], +}) +export class CustomerFavoritesModule {} diff --git a/apps/api/src/modules/customer-favorites/customer-favorites.service.spec.ts b/apps/api/src/modules/customer-favorites/customer-favorites.service.spec.ts new file mode 100644 index 0000000..c38e4fa --- /dev/null +++ b/apps/api/src/modules/customer-favorites/customer-favorites.service.spec.ts @@ -0,0 +1,49 @@ +import { NotFoundException } from '@nestjs/common'; +import { CustomerFavoritesService } from './customer-favorites.service'; + +/** FAV-01 — favourites live on the server, toggled the way legacy did. */ +describe('CustomerFavoritesService', () => { + function build(rows: { customerId: string; productId: string }[], products = ['p1', 'p2']) { + const prisma = { + customerFavorite: { + findMany: jest.fn(async ({ where }: { where: { customerId: string } }) => + rows.filter((r) => r.customerId === where.customerId).map((r) => ({ productId: r.productId })), + ), + deleteMany: jest.fn(async ({ where }: { where: { customerId: string; productId: string } }) => { + const before = rows.length; + rows.splice(0, rows.length, ...rows.filter((r) => !(r.customerId === where.customerId && r.productId === where.productId))); + return { count: before - rows.length }; + }), + upsert: jest.fn(async ({ create }: { create: { customerId: string; productId: string } }) => { + rows.push(create); + return create; + }), + }, + product: { + findUnique: jest.fn(async ({ where }: { where: { id: string } }) => (products.includes(where.id) ? { id: where.id } : null)), + }, + }; + return { service: new CustomerFavoritesService(prisma as never), rows }; + } + + it('lists only the caller’s own favourites', async () => { + const { service } = build([ + { customerId: 'c1', productId: 'p1' }, + { customerId: 'c2', productId: 'p2' }, + ]); + expect(await service.list('c1')).toEqual({ ids: ['p1'] }); + }); + + it('adds when absent and removes when present', async () => { + const { service, rows } = build([]); + expect(await service.toggle('c1', 'p1')).toEqual({ favorite: true }); + expect(rows).toEqual([{ customerId: 'c1', productId: 'p1' }]); + expect(await service.toggle('c1', 'p1')).toEqual({ favorite: false }); + expect(rows).toEqual([]); + }); + + it('refuses a product that does not exist', async () => { + const { service } = build([]); + await expect(service.toggle('c1', 'nope')).rejects.toBeInstanceOf(NotFoundException); + }); +}); diff --git a/apps/api/src/modules/customer-favorites/customer-favorites.service.ts b/apps/api/src/modules/customer-favorites/customer-favorites.service.ts new file mode 100644 index 0000000..90080c7 --- /dev/null +++ b/apps/api/src/modules/customer-favorites/customer-favorites.service.ts @@ -0,0 +1,40 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../../prisma/prisma.service'; + +/** + * The customer's favourite products, kept on the server as legacy did + * (`major_favorites`, ApiMobileController::toggleFavorite / getFavorites). + * The apps used to keep them in memory only, so they vanished on reload and + * the 874 favourites imported from legacy were never shown (FAV-01). + */ +@Injectable() +export class CustomerFavoritesService { + constructor(private readonly prisma: PrismaService) {} + + /** + * Product ids, newest first (legacy: `orderBy('updated_at', 'DESC')`). + * Legacy also hid inactive products; the apps render favourites from the + * catalogue, which already lists only active ones, so ids are enough. + */ + async list(customerId: string) { + const rows = await this.prisma.customerFavorite.findMany({ + where: { customerId }, + orderBy: { createdAt: 'desc' }, + select: { productId: true }, + }); + return { ids: rows.map((r) => r.productId) }; + } + + /** Legacy toggle: delete if present, else add. Answers the new state. */ + async toggle(customerId: string, productId: string) { + const key = { customerId_productId: { customerId, productId } }; + const removed = await this.prisma.customerFavorite.deleteMany({ where: { customerId, productId } }); + if (removed.count > 0) return { favorite: false }; + + const product = await this.prisma.product.findUnique({ where: { id: productId }, select: { id: true } }); + if (!product) throw new NotFoundException('محصول یافت نشد'); + // upsert, so a double tap racing itself cannot fail on the primary key. + await this.prisma.customerFavorite.upsert({ where: key, create: { customerId, productId }, update: {} }); + return { favorite: true }; + } +} diff --git a/apps/api/src/modules/customer-favorites/dto/toggle-favorite.dto.ts b/apps/api/src/modules/customer-favorites/dto/toggle-favorite.dto.ts new file mode 100644 index 0000000..d033d12 --- /dev/null +++ b/apps/api/src/modules/customer-favorites/dto/toggle-favorite.dto.ts @@ -0,0 +1,9 @@ +import { IsNotEmpty, IsString, MaxLength } from 'class-validator'; + +/** Legacy `POST add-to-fav` sent `{ id }`; the product id here is explicit. */ +export class ToggleFavoriteDto { + @IsString() + @IsNotEmpty({ message: 'شناسه‌ی محصول الزامی است' }) + @MaxLength(64) + productId!: string; +} diff --git a/apps/user-app-v2/src/api/client.ts b/apps/user-app-v2/src/api/client.ts index a215933..6f4beae 100644 --- a/apps/user-app-v2/src/api/client.ts +++ b/apps/user-app-v2/src/api/client.ts @@ -320,6 +320,14 @@ export const api = { getAccountingBalance: () => request('/customer/me/accounting-balance'), getMyOrders: () => request('/customer/me/orders'), getMyLedgerEntries: () => request('/customer/me/ledger-entries'), + /** Favourite product ids, newest first — legacy get-favorites (FAV-01). */ + getFavorites: () => request<{ ids: string[] }>('/customer/me/favorites'), + /** Legacy add-to-fav: adds or removes; answers the new state. */ + toggleFavorite: (productId: string) => + request<{ favorite: boolean }>('/customer/me/favorites/toggle', { + method: 'POST', + body: JSON.stringify({ productId }), + }), createOrder: (dto: CreateOrderDto) => request('/orders', { method: 'POST', body: JSON.stringify(dto) }), getLatestGoldPrice: (karatPerMille = 750) => request(`/pricing/latest?karatPerMille=${karatPerMille}`), /** GET /home/slider — public, no auth. Empty array when the admin hasn't diff --git a/apps/user-app-v2/src/stores/favoritesStore.ts b/apps/user-app-v2/src/stores/favoritesStore.ts index 7e92db8..6bc7692 100644 --- a/apps/user-app-v2/src/stores/favoritesStore.ts +++ b/apps/user-app-v2/src/stores/favoritesStore.ts @@ -1,22 +1,55 @@ // Adapted from legacy henza_pwa/src/Mixins/favorite.ts + the `favorits` slice of -// myDataStore. Legacy persisted favorites server-side via POST /add-to-fav; the -// new API has no favorites endpoint yet, so this is local-only (Pinia state, -// not persisted) until that backend support exists. Flagged — not a silent -// behavior match, a deliberate stand-in. +// myDataStore. Legacy kept favourites on the server (POST add-to-fav / +// get-favorites, table major_favorites); so does this, through +// /customer/me/favorites (FAV-01). It used to be memory-only, so favourites +// vanished on reload and the ones imported from legacy were never shown. import { defineStore } from 'pinia' +import { ref, watch } from 'vue' +import { api } from '../api/client' +import { useSessionStore } from './session' -export const useFavoritesStore = defineStore('favorites', { - state: () => ({ - ids: [] as string[], - }), - actions: { - toggle(id: string) { - const index = this.ids.indexOf(id) - if (index === -1) this.ids.push(id) - else this.ids.splice(index, 1) +export const useFavoritesStore = defineStore('favorites', () => { + const ids = ref([]) + const session = useSessionStore() + + async function load() { + try { + ids.value = (await api.getFavorites()).ids + } catch (e) { + // Offline or a dead session (the client already handles 401): keep what we have. + console.warn('favorites: load failed', e) + } + } + + // Loaded for whoever is signed in, cleared on logout — never another user's list. + watch( + () => session.token, + (token) => { + ids.value = [] + if (token) void load() }, - isFavorite(id: string) { - return this.ids.includes(id) - }, - }, + { immediate: true }, + ) + + /** Flips at once on screen, then confirms with the server (legacy toggled the same way). */ + async function toggle(id: string) { + const was = ids.value.includes(id) + ids.value = was ? ids.value.filter((x) => x !== id) : [id, ...ids.value] + try { + const { favorite } = await api.toggleFavorite(id) + const has = ids.value.includes(id) + if (favorite && !has) ids.value = [id, ...ids.value] + if (!favorite && has) ids.value = ids.value.filter((x) => x !== id) + } catch (e) { + // Put it back so the heart never shows a state the server does not hold. + ids.value = was ? [id, ...ids.value.filter((x) => x !== id)] : ids.value.filter((x) => x !== id) + console.warn('favorites: toggle failed', e) + } + } + + function isFavorite(id: string) { + return ids.value.includes(id) + } + + return { ids, load, toggle, isFavorite } }) diff --git a/apps/user-app/src/api/client.ts b/apps/user-app/src/api/client.ts index d1ba02f..85320b1 100644 --- a/apps/user-app/src/api/client.ts +++ b/apps/user-app/src/api/client.ts @@ -336,6 +336,14 @@ export const api = { getAccountingBalance: () => request('/customer/me/accounting-balance'), getMyOrders: () => request('/customer/me/orders'), getMyLedgerEntries: () => request('/customer/me/ledger-entries'), + /** Favourite product ids, newest first — legacy get-favorites (FAV-01). */ + getFavorites: () => request<{ ids: string[] }>('/customer/me/favorites'), + /** Legacy add-to-fav: adds or removes; answers the new state. */ + toggleFavorite: (productId: string) => + request<{ favorite: boolean }>('/customer/me/favorites/toggle', { + method: 'POST', + body: JSON.stringify({ productId }), + }), createOrder: (dto: CreateOrderDto) => request('/orders', { method: 'POST', body: JSON.stringify(dto) }), getLatestGoldPrice: (karatPerMille = 750) => request(`/pricing/latest?karatPerMille=${karatPerMille}`), diff --git a/apps/user-app/src/stores/favoritesStore.ts b/apps/user-app/src/stores/favoritesStore.ts index 7e92db8..6bc7692 100644 --- a/apps/user-app/src/stores/favoritesStore.ts +++ b/apps/user-app/src/stores/favoritesStore.ts @@ -1,22 +1,55 @@ // Adapted from legacy henza_pwa/src/Mixins/favorite.ts + the `favorits` slice of -// myDataStore. Legacy persisted favorites server-side via POST /add-to-fav; the -// new API has no favorites endpoint yet, so this is local-only (Pinia state, -// not persisted) until that backend support exists. Flagged — not a silent -// behavior match, a deliberate stand-in. +// myDataStore. Legacy kept favourites on the server (POST add-to-fav / +// get-favorites, table major_favorites); so does this, through +// /customer/me/favorites (FAV-01). It used to be memory-only, so favourites +// vanished on reload and the ones imported from legacy were never shown. import { defineStore } from 'pinia' +import { ref, watch } from 'vue' +import { api } from '../api/client' +import { useSessionStore } from './session' -export const useFavoritesStore = defineStore('favorites', { - state: () => ({ - ids: [] as string[], - }), - actions: { - toggle(id: string) { - const index = this.ids.indexOf(id) - if (index === -1) this.ids.push(id) - else this.ids.splice(index, 1) +export const useFavoritesStore = defineStore('favorites', () => { + const ids = ref([]) + const session = useSessionStore() + + async function load() { + try { + ids.value = (await api.getFavorites()).ids + } catch (e) { + // Offline or a dead session (the client already handles 401): keep what we have. + console.warn('favorites: load failed', e) + } + } + + // Loaded for whoever is signed in, cleared on logout — never another user's list. + watch( + () => session.token, + (token) => { + ids.value = [] + if (token) void load() }, - isFavorite(id: string) { - return this.ids.includes(id) - }, - }, + { immediate: true }, + ) + + /** Flips at once on screen, then confirms with the server (legacy toggled the same way). */ + async function toggle(id: string) { + const was = ids.value.includes(id) + ids.value = was ? ids.value.filter((x) => x !== id) : [id, ...ids.value] + try { + const { favorite } = await api.toggleFavorite(id) + const has = ids.value.includes(id) + if (favorite && !has) ids.value = [id, ...ids.value] + if (!favorite && has) ids.value = ids.value.filter((x) => x !== id) + } catch (e) { + // Put it back so the heart never shows a state the server does not hold. + ids.value = was ? [id, ...ids.value.filter((x) => x !== id)] : ids.value.filter((x) => x !== id) + console.warn('favorites: toggle failed', e) + } + } + + function isFavorite(id: string) { + return ids.value.includes(id) + } + + return { ids, load, toggle, isFavorite } })