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:
s.esmaeili
2026-10-08 10:54:17 +03:30
co-authored by Claude Opus 5.5
parent 5480398a93
commit 5d386bf752
10 changed files with 257 additions and 34 deletions
@@ -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;
}