BIO.RE
Messages

Rate Message

Fan rates a completed conversation 1–5 stars. Atomic create + recompute creator's avgRating / ratingCount in one transaction. Single-shot — duplicate attempts are 409 (P2002).

POST /api/v1/messages/:id/rate — 🔑 Bearer · Rate limit: 20 req / hour · Kill-switched

Fan rates the completed message thread 1–5 stars. Sender-only (the fan, not the creator). Status-only COMPLETED — pending / rejected / expired messages can't be rated. Creates a CreatorRating row AND recomputes CreatorProfile.avgRating + ratingCount in the same transaction, keeping the aggregates consistent.

Single-shot per message. A unique constraint on messageId enforces "one rating per message ever" — duplicate attempts surface as 409 already_rated. There is no "edit rating" endpoint; the rating you submit is immutable.

comment is accepted but stored differently. The DTO accepts an optional comment field; the service signature destructures it as _comment (prefix-underscore = "intentionally unused"). It's not currently persisted on the CreatorRating row — only the numeric rating is. This may change in a future iteration; for now, treat comment as a no-op write.

Request

Path parameters

ParamTypeValidationNotes
idstring(no UUID pipe)The completed message id

Body — RateMessageDto

FieldTypeRequiredValidationNotes
ratingnumberMin(1), Max(5)Integer 1–5. Server re-checks the [1, 5] range after class-validator.
commentstringoptionalIsString()Currently a no-op write (see callout) — DTO accepts it, service ignores it. Send for forward-compat or skip.
HeaderRequiredNotes
Authorization: Bearer <accessToken>JWT from POST /auth/login

Response

200 OKSuccessOnlyResponseDto

{
  "success": true
}
FieldTypeNotes
successbooleanAlways true on 200. Creator's profile (/creators/profile) avgRating + ratingCount reflect the new value on next read.

Errors

HTTPcode / i18nKeyReason
400message.rate.error.invalid_range`rating < 1
400message.rate.error.invalid_statusMessage.status !== 'COMPLETED'
400(DTO validation)rating not a number, missing
401(guard)Missing / invalid bearer token
403message.rate.error.not_senderYou're not the message's senderId (only the fan who sent it can rate)
404message.reply.error.not_foundNo Message with this id
404message.rate.error.not_foundReceiver has no CreatorProfile (data inconsistency — should not happen in normal flow)
409message.rate.error.already_ratedCreatorRating row already exists for this messageId (Prisma P2002)
429(throttle)Rate limit exceeded (20 req/hour)
503features.messaging_disabledAdmin kill switch MESSAGING is active

Side effects

  1. Range guard (1 <= rating <= 5).
  2. Lookup + sender check — load Message; not_found if missing; not_sender if senderId !== userId.
  3. Status guard — must be COMPLETED; otherwise invalid_status.
  4. Lookup CreatorProfile for the receiver — not_found if missing.
  5. Inside one transaction:
    • CreatorRating.create({ id, creatorId, userId: fanId, messageId, rating }) — Prisma P2002 → throw already_rated.
    • creatorRating.aggregate({ _avg: { rating }, _count: { rating } }) for this creator.
    • CreatorProfile.update({ avgRating: agg._avg.rating ?? 0, ratingCount: agg._count.rating }).
  6. Audit log: [msg] Rated: {messageId} → {rating} stars.
  7. No notification to the creator on rate (creator's profile aggregates update silently — fan-side action shouldn't ping the creator).

Code samples

curl -X POST https://api.bio.re/api/v1/messages/m1a2b3c4-d5e6-7890-abcd-ef1234567890/rate \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"rating": 5, "comment": "Great response!"}'
async function rateMessage(
  accessToken: string,
  messageId: string,
  rating: 1 | 2 | 3 | 4 | 5,
  comment?: string,
): Promise<void> {
  const res = await fetch(`https://api.bio.re/api/v1/messages/${messageId}/rate`, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${accessToken}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ rating, comment }),
  });
  const json = await res.json();
  if (!res.ok || !json.success) {
    throw Object.assign(new Error(json?.error?.message ?? 'Rate failed'), {
      code: json?.error?.code,
    });
  }
}
import { useMutation, useQueryClient } from '@tanstack/react-query';

export function useRateMessage() {
  const qc = useQueryClient();
  return useMutation({
    mutationFn: async (vars: { messageId: string; rating: number; comment?: string }) => {
      const res = await fetch(`/api/v1/messages/${vars.messageId}/rate`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ rating: vars.rating, comment: vars.comment }),
      });
      const json = await res.json();
      if (!res.ok || !json.success) {
        throw Object.assign(new Error(json?.error?.message ?? 'Rate failed'), {
          code: json?.error?.code,
          i18nKey: json?.error?.i18nKey,
        });
      }
    },
    onSuccess: () => {
      // Creator profile aggregates may have changed
      qc.invalidateQueries({ queryKey: ['creators', 'profile'] });
    },
  });
}

Try it

POST
/api/v1/messages/{id}/rate
AuthorizationBearer <token>

In: header

Path Parameters

id*string

Request Body

application/json

TypeScript Definitions

Use the request body type in TypeScript.

Response Body

application/json

application/json

application/json

application/json

application/json

curl -X POST "https://loading/api/v1/messages/string/rate" \  -H "Content-Type: application/json" \  -d '{    "rating": 5  }'
{
  "success": true
}
{
  "success": false,
  "error": {
    "code": "AUTH_UNAUTHORIZED",
    "message": "Invalid credentials",
    "i18nKey": "auth.login.invalid_credentials",
    "i18nVars": {
      "field": "email"
    },
    "details": [
      {
        "message": "email must be an email"
      }
    ],
    "correlationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
  }
}
{
  "success": false,
  "error": {
    "code": "AUTH_UNAUTHORIZED",
    "message": "Invalid credentials",
    "i18nKey": "auth.login.invalid_credentials",
    "i18nVars": {
      "field": "email"
    },
    "details": [
      {
        "message": "email must be an email"
      }
    ],
    "correlationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
  }
}
{
  "success": false,
  "error": {
    "code": "AUTH_UNAUTHORIZED",
    "message": "Invalid credentials",
    "i18nKey": "auth.login.invalid_credentials",
    "i18nVars": {
      "field": "email"
    },
    "details": [
      {
        "message": "email must be an email"
      }
    ],
    "correlationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
  }
}
{
  "success": false,
  "error": {
    "code": "AUTH_UNAUTHORIZED",
    "message": "Invalid credentials",
    "i18nKey": "auth.login.invalid_credentials",
    "i18nVars": {
      "field": "email"
    },
    "details": [
      {
        "message": "email must be an email"
      }
    ],
    "correlationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
  }
}

Source

SourcePathLines
Controllerapps/api-core/src/modules/message/message.controller.ts201–217 (rateMessage)
DTO (request)apps/api-core/src/modules/message/dto/index.ts33–38 (RateMessageDto)
DTO (response)apps/api-core/src/common/dto/common-response.dto.tsSuccessOnlyResponseDto
Serviceapps/api-core/src/modules/message/message.service.ts767–806 (rateMessage)
Prisma modelspackages/prisma/prisma/schema.prismaMessage.status (COMPLETED), CreatorRating (@@unique([messageId])), CreatorProfile.avgRating, CreatorProfile.ratingCount

On this page