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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
5480398a93
commit
5d386bf752
@@ -5,6 +5,7 @@ import { CustomersModule } from './modules/customers/customers.module';
|
|||||||
import { CustomerAuthModule } from './modules/customer-auth/customer-auth.module';
|
import { CustomerAuthModule } from './modules/customer-auth/customer-auth.module';
|
||||||
import { SalesExpertAppModule } from './modules/sales-expert-app/sales-expert-app.module';
|
import { SalesExpertAppModule } from './modules/sales-expert-app/sales-expert-app.module';
|
||||||
import { CustomerProfileModule } from './modules/customer-profile/customer-profile.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 { CustomerReportModule } from './modules/customer-report/customer-report.module';
|
||||||
import { SmsModule } from './modules/sms/sms.module';
|
import { SmsModule } from './modules/sms/sms.module';
|
||||||
import { OrdersModule } from './modules/orders/orders.module';
|
import { OrdersModule } from './modules/orders/orders.module';
|
||||||
@@ -63,6 +64,7 @@ import { SalesAgentModule } from './modules/sales-agent/sales-agent.module';
|
|||||||
CustomerAuthModule,
|
CustomerAuthModule,
|
||||||
SalesExpertAppModule,
|
SalesExpertAppModule,
|
||||||
CustomerProfileModule,
|
CustomerProfileModule,
|
||||||
|
CustomerFavoritesModule,
|
||||||
CustomerReportModule,
|
CustomerReportModule,
|
||||||
SalesAgentModule,
|
SalesAgentModule,
|
||||||
OrdersModule,
|
OrdersModule,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -320,6 +320,14 @@ export const api = {
|
|||||||
getAccountingBalance: () => request<AccountingBalance>('/customer/me/accounting-balance'),
|
getAccountingBalance: () => request<AccountingBalance>('/customer/me/accounting-balance'),
|
||||||
getMyOrders: () => request<OrderDetail[]>('/customer/me/orders'),
|
getMyOrders: () => request<OrderDetail[]>('/customer/me/orders'),
|
||||||
getMyLedgerEntries: () => request<LedgerEntrySummary[]>('/customer/me/ledger-entries'),
|
getMyLedgerEntries: () => request<LedgerEntrySummary[]>('/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<OrderSummary>('/orders', { method: 'POST', body: JSON.stringify(dto) }),
|
createOrder: (dto: CreateOrderDto) => request<OrderSummary>('/orders', { method: 'POST', body: JSON.stringify(dto) }),
|
||||||
getLatestGoldPrice: (karatPerMille = 750) => request<GoldPriceQuote>(`/pricing/latest?karatPerMille=${karatPerMille}`),
|
getLatestGoldPrice: (karatPerMille = 750) => request<GoldPriceQuote>(`/pricing/latest?karatPerMille=${karatPerMille}`),
|
||||||
/** GET /home/slider — public, no auth. Empty array when the admin hasn't
|
/** GET /home/slider — public, no auth. Empty array when the admin hasn't
|
||||||
|
|||||||
@@ -1,22 +1,55 @@
|
|||||||
// Adapted from legacy henza_pwa/src/Mixins/favorite.ts + the `favorits` slice of
|
// 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
|
// myDataStore. Legacy kept favourites on the server (POST add-to-fav /
|
||||||
// new API has no favorites endpoint yet, so this is local-only (Pinia state,
|
// get-favorites, table major_favorites); so does this, through
|
||||||
// not persisted) until that backend support exists. Flagged — not a silent
|
// /customer/me/favorites (FAV-01). It used to be memory-only, so favourites
|
||||||
// behavior match, a deliberate stand-in.
|
// vanished on reload and the ones imported from legacy were never shown.
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import { api } from '../api/client'
|
||||||
|
import { useSessionStore } from './session'
|
||||||
|
|
||||||
export const useFavoritesStore = defineStore('favorites', {
|
export const useFavoritesStore = defineStore('favorites', () => {
|
||||||
state: () => ({
|
const ids = ref<string[]>([])
|
||||||
ids: [] as string[],
|
const session = useSessionStore()
|
||||||
}),
|
|
||||||
actions: {
|
async function load() {
|
||||||
toggle(id: string) {
|
try {
|
||||||
const index = this.ids.indexOf(id)
|
ids.value = (await api.getFavorites()).ids
|
||||||
if (index === -1) this.ids.push(id)
|
} catch (e) {
|
||||||
else this.ids.splice(index, 1)
|
// 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) {
|
{ immediate: true },
|
||||||
return this.ids.includes(id)
|
)
|
||||||
},
|
|
||||||
},
|
/** 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 }
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -336,6 +336,14 @@ export const api = {
|
|||||||
getAccountingBalance: () => request<AccountingBalance>('/customer/me/accounting-balance'),
|
getAccountingBalance: () => request<AccountingBalance>('/customer/me/accounting-balance'),
|
||||||
getMyOrders: () => request<OrderDetail[]>('/customer/me/orders'),
|
getMyOrders: () => request<OrderDetail[]>('/customer/me/orders'),
|
||||||
getMyLedgerEntries: () => request<LedgerEntrySummary[]>('/customer/me/ledger-entries'),
|
getMyLedgerEntries: () => request<LedgerEntrySummary[]>('/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<OrderSummary>('/orders', { method: 'POST', body: JSON.stringify(dto) }),
|
createOrder: (dto: CreateOrderDto) => request<OrderSummary>('/orders', { method: 'POST', body: JSON.stringify(dto) }),
|
||||||
getLatestGoldPrice: (karatPerMille = 750) => request<GoldPriceQuote>(`/pricing/latest?karatPerMille=${karatPerMille}`),
|
getLatestGoldPrice: (karatPerMille = 750) => request<GoldPriceQuote>(`/pricing/latest?karatPerMille=${karatPerMille}`),
|
||||||
|
|||||||
@@ -1,22 +1,55 @@
|
|||||||
// Adapted from legacy henza_pwa/src/Mixins/favorite.ts + the `favorits` slice of
|
// 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
|
// myDataStore. Legacy kept favourites on the server (POST add-to-fav /
|
||||||
// new API has no favorites endpoint yet, so this is local-only (Pinia state,
|
// get-favorites, table major_favorites); so does this, through
|
||||||
// not persisted) until that backend support exists. Flagged — not a silent
|
// /customer/me/favorites (FAV-01). It used to be memory-only, so favourites
|
||||||
// behavior match, a deliberate stand-in.
|
// vanished on reload and the ones imported from legacy were never shown.
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import { api } from '../api/client'
|
||||||
|
import { useSessionStore } from './session'
|
||||||
|
|
||||||
export const useFavoritesStore = defineStore('favorites', {
|
export const useFavoritesStore = defineStore('favorites', () => {
|
||||||
state: () => ({
|
const ids = ref<string[]>([])
|
||||||
ids: [] as string[],
|
const session = useSessionStore()
|
||||||
}),
|
|
||||||
actions: {
|
async function load() {
|
||||||
toggle(id: string) {
|
try {
|
||||||
const index = this.ids.indexOf(id)
|
ids.value = (await api.getFavorites()).ids
|
||||||
if (index === -1) this.ids.push(id)
|
} catch (e) {
|
||||||
else this.ids.splice(index, 1)
|
// 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) {
|
{ immediate: true },
|
||||||
return this.ids.includes(id)
|
)
|
||||||
},
|
|
||||||
},
|
/** 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 }
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user