Stage 6 (part 1): new client mini-app look and order ratings

Plum hero with a club-card ticket, a 'Now' block with the live order timeline,
new order cards and order screen, QR club card screen, new nav
(Home / Orders / Chat / Card / Profile). Clients rate completed orders
(1-5 stars + comment); provider rating is recalculated.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
zabudko-ruandClaude Opus 5.5 committed 2026-10-21 03:51:58 +04:00
1 parent 8866616782
commit ff527ad6d2
18 files changed
+1536 -205

No files matched your search

+1
View File
@@ -19,3 +19,4 @@ export * from './sales.ts';
export * from './texts.ts';
export * from './promo.ts';
export * from './partners.ts';
export * from './ratings.ts';
+4
View File
@@ -12,6 +12,7 @@ const clientOrderInclude = {
history: { orderBy: { createdAt: 'asc' } },
checklist: { orderBy: { sortOrder: 'asc' } },
paymentMethod: true,
rating: { select: { score: true, comment: true } },
} satisfies Prisma.OrderInclude;
type ClientOrderRow = Prisma.OrderGetPayload<{ include: typeof clientOrderInclude }>;
@@ -27,6 +28,9 @@ function clientOrderView(o: ClientOrderRow) {
flow,
status: o.status,
active: !isTerminal(flow, o.status),
/** заказ выполнен — можно оценить */
done: getFlow(flow).done.includes(o.status),
rating: o.rating,
scheduledAt: o.scheduledAt,
durationMinutes: o.durationMinutes,
isOnline: o.isOnline,
+29
View File
@@ -0,0 +1,29 @@
import { getFlow, type ServiceFlow } from '@barev/core';
import { DomainError, type Ctx } from './context.ts';
/**
* Оценка клиента после выполненного заказа: 1–5 звёзд и комментарий.
* Повторная оценка заменяет прежнюю; рейтинг исполнителя (ratingSum/ratingCount) пересчитывается.
*/
export async function rateOrder(ctx: Ctx, input: { orderId: string; clientId: string; score: number; comment?: string | null }) {
const score = Math.round(input.score);
if (score < 1 || score > 5) throw new DomainError('INVALID_PARAMS');
const comment = input.comment?.trim() || null;
return ctx.db.$transaction(async (tx) => {
const order = await tx.order.findUnique({ where: { id: input.orderId }, include: { service: true, rating: true } });
if (!order || order.clientId !== input.clientId) throw new DomainError('NOT_FOUND');
if (!getFlow(order.service.flow as ServiceFlow).done.includes(order.status)) throw new DomainError('TRANSITION_NOT_ALLOWED');
const prev = order.rating;
const providerId = prev?.providerId ?? order.providerId;
const rating = prev
? await tx.rating.update({ where: { id: prev.id }, data: { score, comment } })
: await tx.rating.create({ data: { orderId: order.id, clientId: input.clientId, providerId, score, comment } });
if (providerId) {
await tx.provider.update({
where: { id: providerId },
data: prev ? { ratingSum: { increment: score - prev.score } } : { ratingSum: { increment: score }, ratingCount: { increment: 1 } },
});
}
return { score: rating.score, comment: rating.comment };
});
}
+38
View File
@@ -0,0 +1,38 @@
import { afterAll, beforeEach, describe, expect, it } from 'vitest';
import { createOrder, DomainError, giveConsent, rateOrder, startClient } from '../src/index.ts';
import { db, fixtures, makeCtx, NOW, slotAt, tgUser } from './helpers.ts';
let fx: Awaited<ReturnType<typeof fixtures>>;
beforeEach(async () => {
fx = await fixtures();
});
afterAll(() => db.$disconnect());
const code = (p: Promise<unknown>) => p.then(() => null, (e: unknown) => (e instanceof DomainError ? e.code : String(e)));
describe('оценка заказа клиентом', () => {
it('только после выполнения; повторная оценка заменяет прежнюю и пересчитывает рейтинг исполнителя', async () => {
const { ctx } = makeCtx(NOW);
const s = await startClient(ctx, tgUser(901));
await giveConsent(ctx, s.clientId);
const o = await createOrder(ctx, { clientId: s.clientId, serviceCode: 'cleaning', params: { cleaningType: 'regular', rooms: 2 }, scheduledAt: slotAt(2, 11), addressText: 'Pushkin 10', districtId: fx.kentron.id });
await db.order.update({ where: { id: o.id }, data: { providerId: fx.maria.id } });
const before = await db.provider.findUniqueOrThrow({ where: { id: fx.maria.id } });
expect(await code(rateOrder(ctx, { orderId: o.id, clientId: s.clientId, score: 5 }))).toBe('TRANSITION_NOT_ALLOWED');
await db.order.update({ where: { id: o.id }, data: { status: 'DONE' } });
const other = await startClient(ctx, tgUser(902));
expect(await code(rateOrder(ctx, { orderId: o.id, clientId: other.clientId, score: 5 }))).toBe('NOT_FOUND');
expect(await code(rateOrder(ctx, { orderId: o.id, clientId: s.clientId, score: 7 }))).toBe('INVALID_PARAMS');
expect(await rateOrder(ctx, { orderId: o.id, clientId: s.clientId, score: 3, comment: ' поздно пришла ' })).toEqual({ score: 3, comment: 'поздно пришла' });
let p = await db.provider.findUniqueOrThrow({ where: { id: fx.maria.id } });
expect([p.ratingSum - before.ratingSum, p.ratingCount - before.ratingCount]).toEqual([3, 1]);
await rateOrder(ctx, { orderId: o.id, clientId: s.clientId, score: 5 });
p = await db.provider.findUniqueOrThrow({ where: { id: fx.maria.id } });
expect([p.ratingSum - before.ratingSum, p.ratingCount - before.ratingCount]).toEqual([5, 1]);
expect(await db.rating.count({ where: { orderId: o.id } })).toBe(1);
});
});