@core/comments 기반 상품 리뷰: 구매 인증, 사진, 출처 표시된 가져온 리뷰, 평점, 리뷰 요청 메일
설치
genpm add @core/reviews포함 내용
- src/lib/reviews/에 소스 코드, 파일 5개. (14kB)
- src/lib/reviews/AGENTS.md에 AI 규칙, 그리고 IDE 규칙 파일.
- .env.example에 추가되는 환경 변수: REVIEW_REQUEST_DELAY_DAYS.
- @core/auth, @core/catalog, @core/comments, @core/db, @core/email, @core/jobs, @core/orders을(를) 자동으로 해결합니다.
README
이 패키지에는 README가 없습니다.
이것이 AI가 src/lib/reviews에서 작업할 때 읽는 내용 그대로입니다. 그 외에는 컨텍스트에 아무것도 추가되지 않습니다.
@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.
.genpmignore 적용 후 주입될 정확한 트리입니다. 고정 대상:
// 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();
이 패키지는 MCP 서버를 선언하지 않습니다.
| 버전 | 커밋 | 게시일 | 검사 |
|---|---|---|---|
| 1.0.1 | 9c40b7a | 5시간 전 | 검사 통과 |
- 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
- 제안됨
- GenPM은 npm 명령을 제안하고, 동의한 경우에만 실행합니다.
- 사용하는 패키지 (1)
- @core/kit-store ^1.0.0
- 검사
- 검사 통과 · 문제 0건
- 커밋
- v1.0.1 → 9c40b7a86c884d5c4711aee662c9b7d674cdbab4 · 가져온 뒤 검증됨
- 스크립트
- 없음. GenPM은 패키지 코드를 절대 실행하지 않습니다.
- 라이선스
- MIT
- 품질
- 100/100
- 인정된 라이선스충족
- AGENTS.md에 목적 설명충족
- AGENTS.md에 통합 단계충족
- AGENTS.md에 규칙 또는 금지 사항충족
- 테스트 포함충족
- 보안 검사 통과충족
- 최근 6개월 내 게시충족
- 인증된 게시자충족
- 요약과 키워드충족
- 신고
- 문제가 있나요?