Avis produits sur @core/comments : achat vérifié, photos, avis importés signalés, notes et demande d'avis par e-mail
Installer
genpm add @core/reviewsCe que vous obtenez
- Source dans src/lib/reviews/, 5 fichiers. (14 ko)
- Règles IA dans src/lib/reviews/AGENTS.md, plus les fichiers de règles de l’IDE.
- Variables d’environnement ajoutées à .env.example : REVIEW_REQUEST_DELAY_DAYS.
- Résout @core/auth, @core/catalog, @core/comments, @core/db, @core/email, @core/jobs, @core/orders pour vous.
README
Ce paquet n’a pas de README.
Voici exactement ce que lit votre IA quand elle travaille dans src/lib/reviews. Rien d’autre n’est ajouté à son contexte.
@core/reviews — rules for AI agents
Purpose
Product reviews built on @core/comments (no duplicate comment system): a review is a comment on product/<id> with a
1–5 rating, plus review_meta (verified purchase, photos from @core/media, source) and review_stats per product.
"Verified" means the signed-in author has a paid and shipped order with that product (by user id or their account's
verified email); guest reviews are never verified, so the response never reveals whether an email bought. Imported reviews are always labeled with
their source and never verified. Asks customers for a review N days after delivery.
Map
index.ts— public API:postReview,listReviews,productRating,moderateReview,importReviews,verifiedOrder.reviews.ts— logic, stats and thereviews.requestjob (scheduled from the orderdeliveredevent on import).
Integration
- Requires @core/comments (moderation, anti-spam) and @core/orders. Env: optional
REVIEW_REQUEST_DELAY_DAYS(default 7,-1disables),SITE_URL,EMAIL_FROM. - Migrations as in
src/lib/db/AGENTS.md; import this module at startup. - Product page:
productRating(id)for stars and JSON-LD (jsonLd.product({ rating })from @core/seo),listReviews(id, { page, rating, sort })for the list; show a "Verified purchase" badge and, for imported ones, "Imported from <source>". - Review form →
postReview({ productId, rating, body, user | guest, photos }, { headers, fields })with @core/antispam fields. - Moderate in the comments admin (filter
entityType=product) and callmoderateReviewso stats update. - Verify: a buyer's review shows as verified after approval and the product rating changes.
Conventions
- Explain on the page how reviews are checked (EU Omnibus Directive): verified = confirmed order.
- Show all approved reviews, including negative ones; sort options must not hide them by default.
Don't
- Don't write, generate or buy fake reviews, and don't mark imported reviews as verified.
- Don't put
aggregateRatingin JSON-LD when there are no approved reviews. - Don't offer rewards conditioned on a positive review.
# @core/reviews — rules for AI agents
## Purpose
Product reviews built on @core/comments (no duplicate comment system): a review is a comment on `product/<id>` with a
1–5 rating, plus `review_meta` (verified purchase, photos from @core/media, source) and `review_stats` per product.
"Verified" means the signed-in author has a paid and shipped order with that product (by user id or their account's
verified email); guest reviews are never verified, so the response never reveals whether an email bought. Imported reviews are always labeled with
their source and never verified. Asks customers for a review N days after delivery.
## Map
- `index.ts` — public API: `postReview`, `listReviews`, `productRating`, `moderateReview`, `importReviews`, `verifiedOrder`.
- `reviews.ts` — logic, stats and the `reviews.request` job (scheduled from the order `delivered` event on import).
## Integration
1. Requires @core/comments (moderation, anti-spam) and @core/orders. Env: optional `REVIEW_REQUEST_DELAY_DAYS` (default 7, `-1` disables), `SITE_URL`, `EMAIL_FROM`.
2. Migrations as in `src/lib/db/AGENTS.md`; import this module at startup.
3. Product page: `productRating(id)` for stars and JSON-LD (`jsonLd.product({ rating })` from @core/seo), `listReviews(id, { page, rating, sort })`
for the list; show a "Verified purchase" badge and, for imported ones, "Imported from <source>".
4. Review form → `postReview({ productId, rating, body, user | guest, photos }, { headers, fields })` with @core/antispam fields.
5. Moderate in the comments admin (filter `entityType=product`) and call `moderateReview` so stats update.
6. Verify: a buyer's review shows as verified after approval and the product rating changes.
## Conventions
- Explain on the page how reviews are checked (EU Omnibus Directive): verified = confirmed order.
- Show all approved reviews, including negative ones; sort options must not hide them by default.
## Don't
- Don't write, generate or buy fake reviews, and don't mark imported reviews as verified.
- Don't put `aggregateRating` in JSON-LD when there are no approved reviews.
- Don't offer rewards conditioned on a positive review.
L’arborescence exacte qui sera injectée, après .genpmignore. Épinglée à
// Reseñas de producto = comentarios con valoración + compra verificada, fotos, origen y agregados.
import { and, desc, eq, inArray, or, sql } from 'drizzle-orm';
import { z } from 'zod';
import { users } from '../auth/index.ts';
import { type Comment, comments, moderate, type ModerationAction, postComment, ratingSummary } from '../comments/index.ts';
import { getProductById, products } from '../catalog/index.ts';
import { type Executor, getDb } from '../db/index.ts';
import { getEmailProvider } from '../email/index.ts';
import { defineJob } from '../jobs/index.ts';
import { onOrderEvent, orderLines, orders } from '../orders/index.ts';
import { reviewMeta, reviewStats } from './schema.ts';
export const ENTITY = 'product';
/**
* ¿Tiene este usuario con sesión un pedido pagado y enviado con el producto? Cuenta sus pedidos y los hechos como
* invitado con el email (verificado) de su cuenta. Devuelve el id del pedido. Los invitados nunca se verifican: un
* email escrito en un formulario no prueba nada y la respuesta revelaría si esa persona compró.
*/
export async function verifiedOrder(productId: string, who: { userId?: string | null }, db: Executor = getDb()): Promise<string | null> {
if (!who.userId) return null;
// @core/auth solo guarda emails verificados por el proveedor de acceso.
const [u] = await db.select({ email: users.email }).from(users).where(eq(users.id, who.userId));
if (!u) return null;
const owner = u.email ? or(eq(orders.userId, who.userId), eq(orders.email, u.email.trim().toLowerCase())) : eq(orders.userId, who.userId);
const [row] = await db
.select({ id: orders.id })
.from(orders)
.innerJoin(orderLines, eq(orderLines.orderId, orders.id))
.where(and(owner, eq(orderLines.productId, productId), inArray(orders.status, ['paid', 'partially_refunded']), inArray(orders.fulfillmentStatus, ['fulfilled', 'delivered'])))
.limit(1);
return row?.id ?? null;
}
/** Recalcula el agregado de un producto con las reseñas aprobadas. */
export async function recomputeStats(productId: string, db: Executor = getDb()): Promise<void> {
const s = await ratingSummary(ENTITY, productId, db);
const values = { count: s.count, average: Math.round(s.average * 100), histogram: s.histogram };
await db.insert(reviewStats).values({ productId, ...values }).onConflictDoUpdate({ target: reviewStats.productId, set: values });
}
export type ProductRating = { count: number; average: number; histogram: [number, number, number, number, number] };
export async function productRating(productId: string, db: Executor = getDb()): Promise<ProductRating> {
const [r] = await db.select().from(reviewStats).where(eq(reviewStats.productId, productId));
return r ? { count: r.count, average: r.average / 100, histogram: r.histogram } : { count: 0, average: 0, histogram: [0, 0, 0, 0, 0] };
}
/**
* Publica una reseña (pasa por moderación según @core/comments). La valoración es obligatoria. Marca compra
* verificada solo para usuarios con sesión que compraron (nunca para invitados). `photos` son ids de @core/media.
*/
export async function postReview(
input: {
productId: string;
rating: number;
body: string;
photos?: string[];
user?: { id: string; name: string | null } | null;
guest?: { name: string; email: string };
},
ctx: { headers: Headers; fields: FormData | Record<string, unknown>; skipAntispam?: boolean },
db: Executor = getDb(),
): Promise<Comment & { verified: boolean }> {
z.number().int().min(1).max(5).parse(input.rating);
if (!(await getProductById(input.productId, db))) throw new Error('unknown product');
const c = await postComment({ entityType: ENTITY, entityId: input.productId, body: input.body, rating: input.rating, user: input.user, ...(input.guest && { guest: input.guest }) }, ctx, db);
const orderId = input.user ? await verifiedOrder(input.productId, { userId: input.user.id }, db) : null;
await db.insert(reviewMeta).values({ commentId: c.id, productId: input.productId, verified: !!orderId, orderId, photos: (input.photos ?? []).slice(0, 6) });
await recomputeStats(input.productId, db);
return { ...c, verified: !!orderId };
}
/** Moderar una reseña y actualizar el agregado del producto. */
export async function moderateReview(commentId: string, action: ModerationAction, db: Executor = getDb()): Promise<Comment> {
const c = await moderate(commentId, action, db);
await recomputeStats(c.entityId, db);
return c;
}
const Imported = z.object({ author: z.string().min(1).max(80), rating: z.number().int().min(1).max(5), body: z.string().min(1).max(5000), date: z.coerce.date().optional() });
/**
* Importa reseñas de otro sitio (p. ej. del proveedor). Se publican aprobadas, NUNCA como verificadas, y con su
* origen visible (obligatorio informar de cómo se verifican: Directiva Ómnibus).
*/
export async function importReviews(productId: string, rows: Array<z.input<typeof Imported>>, source: string, db: Executor = getDb()): Promise<number> {
if (!/^[a-z0-9][a-z0-9-]{1,39}$/.test(source) || source === 'site') throw new Error('invalid source');
let n = 0;
for (const raw of rows) {
const r = Imported.parse(raw);
const [c] = await db
.insert(comments)
.values({ entityType: ENTITY, entityId: productId, authorName: r.author, body: r.body, rating: r.rating, status: 'approved', ...(r.date && { createdAt: r.date }) })
.returning();
await db.insert(reviewMeta).values({ commentId: c!.id, productId, verified: false, source });
n++;
}
await recomputeStats(productId, db);
return n;
}
export type PublicReview = { id: string; authorName: string; rating: number; body: string; createdAt: Date; verified: boolean; source: string; photos: string[] };
/** Reseñas aprobadas de un producto, con filtro por estrellas. */
export async function listReviews(productId: string, opts: { page?: number; perPage?: number; rating?: number; sort?: 'newest' | 'highest' | 'lowest' } = {}, db: Executor = getDb()): Promise<{ reviews: PublicReview[]; total: number }> {
const perPage = Math.min(Math.max(opts.perPage ?? 10, 1), 50);
const where = and(eq(comments.entityType, ENTITY), eq(comments.entityId, productId), eq(comments.status, 'approved'), sql`${comments.rating} is not null`, opts.rating ? eq(comments.rating, opts.rating) : undefined);
const order = opts.sort === 'highest' ? [desc(comments.rating), desc(comments.createdAt)] : opts.sort === 'lowest' ? [comments.rating, desc(comments.createdAt)] : [desc(comments.createdAt)];
const [total] = await db.select({ n: sql<number>`count(*)`.mapWith(Number) }).from(comments).where(where);
const rows = await db
.select({ c: comments, m: reviewMeta })
.from(comments)
.leftJoin(reviewMeta, eq(reviewMeta.commentId, comments.id))
.where(where)
.orderBy(...order, comments.id)
.limit(perPage)
.offset((Math.max(opts.page ?? 1, 1) - 1) * perPage);
return {
total: total?.n ?? 0,
reviews: rows.map(({ c, m }) => ({ id: c.id, authorName: c.authorName, rating: c.rating!, body: c.body, createdAt: c.createdAt, verified: m?.verified ?? false, source: m?.source ?? 'site', photos: m?.photos ?? [] })),
};
}
const delayDays = () => Number(process.env.REVIEW_REQUEST_DELAY_DAYS ?? 7);
/** Email pidiendo reseña N días después de la entrega (una por producto del pedido). */
export const reviewRequestJob = defineJob('reviews.request', z.object({ orderId: z.string() }), async ({ orderId }) => {
const from = process.env.EMAIL_FROM;
const site = process.env.SITE_URL;
if (!from || !site) return;
const [o] = await getDb().select().from(orders).where(eq(orders.id, orderId));
if (!o || (o.status !== 'paid' && o.status !== 'partially_refunded')) return;
const lines = await getDb().select({ productId: orderLines.productId, name: orderLines.name }).from(orderLines).where(eq(orderLines.orderId, orderId));
const ids = [...new Set(lines.map((l) => l.productId).filter((x): x is string => !!x))];
const prods = ids.length ? await getDb().select({ id: products.id, slug: products.slug, name: products.name }).from(products).where(inArray(products.id, ids)) : [];
if (!prods.length) return;
const links = prods.map((p) => [p.name, new URL(`/products/${p.slug}#reviews`, site).toString()] as const);
const esc = (s: string) => s.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"');
await getEmailProvider().send({
from,
to: o.email,
subject: 'How was your order?',
text: `We hope you're enjoying your purchase. Would you leave a review?\n\n${links.map(([n, u]) => `${n}: ${u}`).join('\n')}`,
html: `<p>We hope you're enjoying your purchase. Would you leave a review?</p><ul>${links.map(([n, u]) => `<li><a href="${esc(u)}">${esc(n)}</a></li>`).join('')}</ul>`,
});
});
/** Programa la solicitud de reseña al entregarse un pedido. Se llama al importar el módulo. */
export function registerReviewRequests(): void {
onOrderEvent('delivered', 'reviews.request', async (o) => {
if (delayDays() < 0) return;
await reviewRequestJob.enqueue({ orderId: o.id }, { runAt: new Date(Date.now() + delayDays() * 86_400_000), dedupeKey: `reviews.request:${o.id}` });
});
}
registerReviewRequests();
Ce paquet ne déclare aucun serveur MCP.
| Version | Commit | Publié | Analyse |
|---|---|---|---|
| 1.0.1 | 9c40b7a | il y a 3 heures | analyse réussie |
- genpm
- @core/auth ^1.1.0@core/catalog ^1.0.0@core/comments ^1.0.0@core/db ^1.0.0@core/email ^1.0.1@core/jobs ^1.0.0@core/orders ^1.0.0
- npm
- zod ^4.0.0
- proposé
- GenPM propose la commande npm et ne l’exécute que si vous acceptez.
- Utilisé par (1)
- @core/kit-store ^1.0.0
- analyse
- analyse réussie · 0 problème
- commit
- v1.0.1 → 9c40b7a86c884d5c4711aee662c9b7d674cdbab4 · vérifié après téléchargement
- scripts
- Aucun. GenPM n’exécute jamais le code des paquets.
- licence
- MIT
- Qualité
- 100/100
- Licence reconnuevalidé
- AGENTS.md explique son objectifvalidé
- AGENTS.md donne les étapes d’intégrationvalidé
- AGENTS.md liste conventions ou interditsvalidé
- Contient des testsvalidé
- Analyse de sécurité réussievalidé
- Publié au cours des 6 derniers moisvalidé
- Éditeur vérifiévalidé
- Résumé et mots-clésvalidé
- signalement
- Vous avez repéré un problème ?