SavvySolve Docs

Rating System

Customer feedback collection and solver rating display after session completion

Rating System

The rating system captures customer feedback after sessions complete and maintains solver reputation metrics. When a customer finishes a support session and completes payment, they're prompted to rate their experience on a 1-5 star scale. These ratings aggregate into each solver's profile, helping future customers make informed decisions and giving solvers feedback on their service quality.

How It Works

Rating collection happens automatically within the customer session view. After a session is marked complete and payment is confirmed, the CompletedView component displays an interactive star rating interface. The customer taps their desired rating (1-5 stars), which triggers an API call to persist the feedback.

components/session/CustomerSessionView.tsx
function CompletedView({ session, token }: { session: SessionData; token: string }) {
  const [selectedRating, setSelectedRating] = useState<number | null>(null);

  // Check if already rated
  const { data: existingRating } = trpc.ratings.getBySession.useQuery(
    { sessionId: session.id, token },
    { enabled: !!token },
  );

  const submitRating = trpc.ratings.submit.useMutation();

  const handleRate = async (rating: number) => {
    setSelectedRating(rating);
    await submitRating.mutateAsync({
      sessionId: session.id,
      token,
      rating,
    });
  };
  // ...
}

The component queries for existing ratings on mount to prevent duplicate submissions. If a rating already exists for the session, the thank-you confirmation displays immediately instead of the rating interface.

Database Schema

Ratings are stored in a dedicated table with references to both the session and solver. The one-to-one relationship between sessions and ratings is enforced by a unique constraint on session_id.

lib/db/schema/ratings.ts
export const ratings = pgTable(
  "ratings",
  {
    id: uuid("id").primaryKey().defaultRandom(),
    sessionId: uuid("session_id")
      .notNull()
      .unique()
      .references(() => sessions.id, { onDelete: "cascade" }),
    solverId: uuid("solver_id")
      .notNull()
      .references(() => solvers.id, { onDelete: "cascade" }),
    rating: integer("rating").notNull(),
    comment: text("comment"),
    createdAt: timestamp("created_at", { withTimezone: true })
      .notNull()
      .defaultNow(),
  },
  (table) => [
    check("rating_range", sql`${table.rating} >= 1 AND ${table.rating} <= 5`),
  ]
);

A database-level check constraint ensures ratings stay within the valid 1-5 range, providing defense-in-depth beyond application validation.

API Reference

The ratings router exposes four procedures for managing feedback:

ratings.submit

Submits a new rating for a completed session. This is a public procedure that validates the session token to ensure only the actual customer can rate.

server/routers/ratings.ts
submit: publicProcedure
  .input(z.object({
    sessionId: z.string().uuid(),
    token: z.string().min(1),
    rating: z.number().int().min(1).max(5),
    comment: z.string().max(500).optional(),
  }))
  .mutation(async ({ input }) => {
    // Validates token, checks session is completed,
    // prevents duplicates, creates rating,
    // and updates solver stats
  })

Ratings below 3 stars are logged for potential admin review, allowing the team to follow up on negative experiences.

ratings.getBySession

Checks if a session has already been rated. Used by the UI to determine whether to show the rating form or thank-you message.

ratings.getBySolver

Retrieves paginated ratings for a specific solver. Requires authentication. Returns ratings with session metadata for context.

ratings.getSolverSummary

Returns a solver's rating summary including average rating, total count, and distribution across 1-5 stars. This is a public procedure used for displaying solver profiles.

Solver Stats Update

When a rating is submitted, the solver's aggregate statistics are automatically recalculated. The updateSolverRating helper queries all ratings for the solver and updates their stats JSONB field.

server/routers/ratings.ts
async function updateSolverRating(solverId: string): Promise<void> {
  const [result] = await db
    .select({
      avgRating: sql<number>`avg(${ratings.rating})`,
      totalRatings: sql<number>`count(*)`,
    })
    .from(ratings)
    .where(eq(ratings.solverId, solverId));

  // Update solver.stats with new averageRating and totalRatings
}

The average is rounded to one decimal place for clean display.

Display Components

Two components are available for showing ratings throughout the application:

SolverRating

Full rating display with proportionally filled stars, numeric average, and review count. Supports three sizes (sm, md, lg) and optional count display.

components/ui/solver-rating.tsx
<SolverRating rating={4.5} totalRatings={42} size="md" />

SolverRatingCompact

Minimal display showing a single filled star, numeric rating, and count in parentheses. Ideal for cards and list items where space is limited.

components/ui/solver-rating.tsx
<SolverRatingCompact rating={4.5} totalRatings={42} />

Security Considerations

Several measures prevent rating abuse:

  1. Token validation - Only requests with valid session access tokens can submit ratings
  2. Completion check - Sessions must be in completed status before rating
  3. Duplicate prevention - The unique constraint on session_id prevents multiple ratings per session
  4. Input validation - Zod schemas enforce rating range (1-5) and comment length (500 chars max)

Testing

The rating system includes comprehensive tests covering all edge cases:

bun run test server/routers/ratings.test.ts
bun run test components/ui/solver-rating.test.tsx

Key test scenarios include:

  • Token validation (wrong token rejected)
  • Session status check (can't rate active sessions)
  • Duplicate prevention (existing rating throws CONFLICT)
  • Input validation (rating out of range, comment too long)
  • Solver stats update calculation

On this page