- Media (voice/video/image/file): private bucket, presigned PUT upload, reference-only publish, membership-gated presigned GET for viewing - Avatars: public bucket, presigned PUT + direct public URL, profile.update event - messages.media_key + media_meta (migration 1720000000005_media) - Explicit MinIO region on presigning client (avoids getBucketRegion network call) - Event type values sourced from EventType constants (no raw string literals) - Web: attach button, MediaView renderer, avatar upload in topbar Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BFRyKxkKEjfAgpXygzoNiD
211 lines
6.8 KiB
TypeScript
211 lines
6.8 KiB
TypeScript
import type { FastifyInstance } from 'fastify';
|
|
import {
|
|
sendMessageBodySchema,
|
|
editMessageBodySchema,
|
|
reactionBodySchema,
|
|
messageSchema,
|
|
messageListSchema,
|
|
mediaUrlSchema,
|
|
errorSchema,
|
|
} from '@altricade/core';
|
|
import type { SendMessageBody, EditMessageBody, ReactionBody } from '@altricade/core';
|
|
|
|
const bearerAuth = [{ bearerAuth: [] }];
|
|
const idParamsSchema = {
|
|
type: 'object',
|
|
required: ['id'],
|
|
properties: { id: { type: 'string', format: 'uuid' } },
|
|
} as const;
|
|
const messageParamsSchema = {
|
|
type: 'object',
|
|
required: ['id', 'messageId'],
|
|
properties: {
|
|
id: { type: 'string', format: 'uuid' },
|
|
messageId: { type: 'string', format: 'uuid' },
|
|
},
|
|
} as const;
|
|
const reactionParamsSchema = {
|
|
type: 'object',
|
|
required: ['id', 'messageId', 'emoji'],
|
|
properties: {
|
|
id: { type: 'string', format: 'uuid' },
|
|
messageId: { type: 'string', format: 'uuid' },
|
|
emoji: { type: 'string', minLength: 1, maxLength: 32 },
|
|
},
|
|
} as const;
|
|
const historyQuerySchema = {
|
|
type: 'object',
|
|
properties: {
|
|
before: { type: 'integer', minimum: 1 },
|
|
limit: { type: 'integer', minimum: 1, maximum: 100, default: 50 },
|
|
},
|
|
} as const;
|
|
|
|
export const messagesRoutes = (app: FastifyInstance): Promise<void> => {
|
|
app.get<{ Params: { id: string }; Querystring: { before?: number; limit: number } }>(
|
|
'/conversations/:id/messages',
|
|
{
|
|
schema: {
|
|
tags: ['messages'],
|
|
summary: 'Load conversation message history (newest first)',
|
|
security: bearerAuth,
|
|
params: idParamsSchema,
|
|
querystring: historyQuerySchema,
|
|
response: { 200: messageListSchema, 401: errorSchema, 403: errorSchema },
|
|
},
|
|
preHandler: app.authenticate,
|
|
},
|
|
async (request, reply) => {
|
|
const user = request.authUser;
|
|
if (user === undefined) return reply.code(401).send({ error: 'unauthorized' });
|
|
const before = request.query.before ?? null;
|
|
const history = await app.messagesService.history(
|
|
request.params.id,
|
|
user.id,
|
|
before,
|
|
request.query.limit,
|
|
);
|
|
return reply.send(history);
|
|
},
|
|
);
|
|
|
|
app.post<{ Params: { id: string }; Body: SendMessageBody }>(
|
|
'/conversations/:id/messages',
|
|
{
|
|
schema: {
|
|
tags: ['messages'],
|
|
summary: 'Send a message to a conversation',
|
|
security: bearerAuth,
|
|
params: idParamsSchema,
|
|
body: sendMessageBodySchema,
|
|
response: { 200: messageSchema, 201: messageSchema, 401: errorSchema, 403: errorSchema },
|
|
},
|
|
preHandler: app.authenticate,
|
|
},
|
|
async (request, reply) => {
|
|
const user = request.authUser;
|
|
if (user === undefined) return reply.code(401).send({ error: 'unauthorized' });
|
|
const result = await app.messagesService.send(request.params.id, user.id, request.body);
|
|
return reply.code(result.created ? 201 : 200).send(result.message);
|
|
},
|
|
);
|
|
|
|
app.patch<{ Params: { id: string; messageId: string }; Body: EditMessageBody }>(
|
|
'/conversations/:id/messages/:messageId',
|
|
{
|
|
schema: {
|
|
tags: ['messages'],
|
|
summary: 'Edit a message (sender only)',
|
|
security: bearerAuth,
|
|
params: messageParamsSchema,
|
|
body: editMessageBodySchema,
|
|
response: { 200: messageSchema, 401: errorSchema, 403: errorSchema, 404: errorSchema },
|
|
},
|
|
preHandler: app.authenticate,
|
|
},
|
|
async (request, reply) => {
|
|
const user = request.authUser;
|
|
if (user === undefined) return reply.code(401).send({ error: 'unauthorized' });
|
|
const message = await app.messagesService.edit(
|
|
request.params.id,
|
|
request.params.messageId,
|
|
user.id,
|
|
request.body.content,
|
|
);
|
|
return reply.send(message);
|
|
},
|
|
);
|
|
|
|
app.delete<{ Params: { id: string; messageId: string } }>(
|
|
'/conversations/:id/messages/:messageId',
|
|
{
|
|
schema: {
|
|
tags: ['messages'],
|
|
summary: 'Delete a message (sender only, soft delete)',
|
|
security: bearerAuth,
|
|
params: messageParamsSchema,
|
|
response: { 401: errorSchema, 403: errorSchema, 404: errorSchema },
|
|
},
|
|
preHandler: app.authenticate,
|
|
},
|
|
async (request, reply) => {
|
|
const user = request.authUser;
|
|
if (user === undefined) return reply.code(401).send({ error: 'unauthorized' });
|
|
await app.messagesService.remove(request.params.id, request.params.messageId, user.id);
|
|
return reply.code(204).send();
|
|
},
|
|
);
|
|
|
|
app.post<{ Params: { id: string; messageId: string }; Body: ReactionBody }>(
|
|
'/conversations/:id/messages/:messageId/reactions',
|
|
{
|
|
schema: {
|
|
tags: ['messages'],
|
|
summary: 'Add a reaction',
|
|
security: bearerAuth,
|
|
params: messageParamsSchema,
|
|
body: reactionBodySchema,
|
|
response: { 401: errorSchema, 403: errorSchema, 404: errorSchema },
|
|
},
|
|
preHandler: app.authenticate,
|
|
},
|
|
async (request, reply) => {
|
|
const user = request.authUser;
|
|
if (user === undefined) return reply.code(401).send({ error: 'unauthorized' });
|
|
await app.messagesService.addReaction(
|
|
request.params.id,
|
|
request.params.messageId,
|
|
user.id,
|
|
request.body.emoji,
|
|
);
|
|
return reply.code(204).send();
|
|
},
|
|
);
|
|
|
|
app.get<{ Params: { id: string; messageId: string } }>(
|
|
'/conversations/:id/messages/:messageId/media-url',
|
|
{
|
|
schema: {
|
|
tags: ['media'],
|
|
summary: 'Get a presigned download URL for a message attachment (member only)',
|
|
security: bearerAuth,
|
|
params: messageParamsSchema,
|
|
response: { 200: mediaUrlSchema, 401: errorSchema, 403: errorSchema, 404: errorSchema },
|
|
},
|
|
preHandler: app.authenticate,
|
|
},
|
|
async (request, reply) => {
|
|
const user = request.authUser;
|
|
if (user === undefined) return reply.code(401).send({ error: 'unauthorized' });
|
|
const url = await app.messagesService.mediaUrl(request.params.id, request.params.messageId, user.id);
|
|
return reply.send({ url });
|
|
},
|
|
);
|
|
|
|
app.delete<{ Params: { id: string; messageId: string; emoji: string } }>(
|
|
'/conversations/:id/messages/:messageId/reactions/:emoji',
|
|
{
|
|
schema: {
|
|
tags: ['messages'],
|
|
summary: 'Remove a reaction',
|
|
security: bearerAuth,
|
|
params: reactionParamsSchema,
|
|
response: { 401: errorSchema, 403: errorSchema, 404: errorSchema },
|
|
},
|
|
preHandler: app.authenticate,
|
|
},
|
|
async (request, reply) => {
|
|
const user = request.authUser;
|
|
if (user === undefined) return reply.code(401).send({ error: 'unauthorized' });
|
|
await app.messagesService.removeReaction(
|
|
request.params.id,
|
|
request.params.messageId,
|
|
user.id,
|
|
decodeURIComponent(request.params.emoji),
|
|
);
|
|
return reply.code(204).send();
|
|
},
|
|
);
|
|
|
|
return Promise.resolve();
|
|
};
|