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
| Param | Type | Validation | Notes |
|---|---|---|---|
id | string | (no UUID pipe) | The completed message id |
Body — RateMessageDto
| Field | Type | Required | Validation | Notes |
|---|---|---|---|---|
rating | number | ✓ | Min(1), Max(5) | Integer 1–5. Server re-checks the [1, 5] range after class-validator. |
comment | string | optional | IsString() | Currently a no-op write (see callout) — DTO accepts it, service ignores it. Send for forward-compat or skip. |
| Header | Required | Notes |
|---|---|---|
Authorization: Bearer <accessToken> | ✓ | JWT from POST /auth/login |
Response
200 OK — SuccessOnlyResponseDto
{
"success": true
}| Field | Type | Notes |
|---|---|---|
success | boolean | Always true on 200. Creator's profile (/creators/profile) avgRating + ratingCount reflect the new value on next read. |
Errors
| HTTP | code / i18nKey | Reason |
|---|---|---|
400 | message.rate.error.invalid_range | `rating < 1 |
400 | message.rate.error.invalid_status | Message.status !== 'COMPLETED' |
400 | (DTO validation) | rating not a number, missing |
401 | (guard) | Missing / invalid bearer token |
403 | message.rate.error.not_sender | You're not the message's senderId (only the fan who sent it can rate) |
404 | message.reply.error.not_found | No Message with this id |
404 | message.rate.error.not_found | Receiver has no CreatorProfile (data inconsistency — should not happen in normal flow) |
409 | message.rate.error.already_rated | CreatorRating row already exists for this messageId (Prisma P2002) |
429 | (throttle) | Rate limit exceeded (20 req/hour) |
503 | features.messaging_disabled | Admin kill switch MESSAGING is active |
Side effects
- Range guard (
1 <= rating <= 5). - Lookup + sender check — load
Message;not_foundif missing;not_senderifsenderId !== userId. - Status guard — must be
COMPLETED; otherwiseinvalid_status. - Lookup
CreatorProfilefor the receiver —not_foundif missing. - Inside one transaction:
CreatorRating.create({ id, creatorId, userId: fanId, messageId, rating })— Prisma P2002 → throwalready_rated.creatorRating.aggregate({ _avg: { rating }, _count: { rating } })for this creator.CreatorProfile.update({ avgRating: agg._avg.rating ?? 0, ratingCount: agg._count.rating }).
- Audit log:
[msg] Rated: {messageId} → {rating} stars. - 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
Authorization
bearer In: header
Path Parameters
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
| Source | Path | Lines |
|---|---|---|
| Controller | apps/api-core/src/modules/message/message.controller.ts | 201–217 (rateMessage) |
| DTO (request) | apps/api-core/src/modules/message/dto/index.ts | 33–38 (RateMessageDto) |
| DTO (response) | apps/api-core/src/common/dto/common-response.dto.ts | SuccessOnlyResponseDto |
| Service | apps/api-core/src/modules/message/message.service.ts | 767–806 (rateMessage) |
| Prisma models | packages/prisma/prisma/schema.prisma | Message.status (COMPLETED), CreatorRating (@@unique([messageId])), CreatorProfile.avgRating, CreatorProfile.ratingCount |
Reject Message
Creator-only rejection. Requires ESCROWED or DELIVERED status. Triggers full refund to fan's wallet for paid DMs (atomic claim + escrow refund). Optional reason captured for audit + notification.
List Notifications
Paginated in-app notification feed for the calling user. Optional read-state filter. Returns the full row including event key, title, body, and free-form data payload.