/** * @file OpenAPI fragment for `/api/chat` (server/routes/chat.js): chat between * the users of a server-mode install — direct chats and groups, text and * files. Every route acts on the caller's own rooms only (404 otherwise, an * admin included); local mode answers `400 ELOCALMODE`. * @author Nguyễn Ngọc Trí Vĩ */ const err = { $ref: "#/components/schemas/ErrorResponse" }; const json = (schema) => ({ content: { "application/json": { schema } } }); const fail = (codes) => Object.fromEntries(codes.map((c) => [String(c), { description: "Refused", ...json(err) }])); const idParam = { name: "id", in: "path", required: true, schema: { type: "integer" } }; const tags = [ { name: "Chat", description: "Chat between users (server mode): direct chats and groups in the pet's window. Only a room's members read it.", }, ]; const schemas = { ChatFile: { type: "object", properties: { id: { type: "string", example: "9f0c1b2a3d4e5f60718293a4b5c6d7e8" }, name: { type: "string", example: "screenshot.png" }, type: { type: "string", example: "image/png" }, size: { type: "integer", example: 20480 }, }, }, ChatMessage: { type: "object", properties: { id: { type: "integer", example: 42 }, roomId: { type: "integer", example: 3 }, userId: { type: "integer", nullable: true, example: 2 }, kind: { type: "string", enum: ["text", "system"], description: "`system`: a group change — body `created`, `renamed`, `left`, `added:` or `removed:`.", }, body: { type: "string", example: "Chào cả nhóm" }, createdAt: { type: "string", format: "date-time" }, files: { type: "array", items: { $ref: "#/components/schemas/ChatFile" } }, }, }, ChatRoom: { type: "object", properties: { id: { type: "integer", example: 3 }, kind: { type: "string", enum: ["dm", "group"] }, name: { type: "string", description: "A group's name; empty for a direct chat." }, createdBy: { type: "integer", nullable: true }, createdAt: { type: "string", format: "date-time" }, lastMessageAt: { type: "string", format: "date-time", nullable: true }, members: { type: "array", items: { type: "integer" } }, lastMessage: { allOf: [{ $ref: "#/components/schemas/ChatMessage" }], nullable: true }, lastReadId: { type: "integer" }, clearedId: { type: "integer", description: "Messages up to this id were deleted by the caller for themselves.", }, unread: { type: "integer", description: "Messages of others after `lastReadId`." }, reads: { type: "object", additionalProperties: { type: "integer" }, description: "Every member's read mark by user id — what the room shows as Seen.", }, }, }, }; const roomResp = json({ type: "object", properties: { room: { $ref: "#/components/schemas/ChatRoom" } }, }); const paths = { "/api/chat/people": { get: { tags: ["Chat"], summary: "Users the caller can chat with", description: "Every account but the caller: id, username, display name, avatar time, and `disabled` (not offered for a new chat).", responses: { 200: { description: "People", ...json({ type: "object", properties: { people: { type: "array", items: { type: "object", properties: { id: { type: "integer" }, username: { type: "string" }, displayName: { type: "string", nullable: true }, avatarAt: { type: "string", nullable: true }, disabled: { type: "boolean", description: "A disabled account: still named in old chats, never offered for a new one.", }, }, }, }, }, }), }, ...fail([400]), }, }, }, "/api/chat/rooms": { get: { tags: ["Chat"], summary: "The caller's rooms", description: "Most recently active first, each with its members, newest message and unread count.", responses: { 200: { description: "Rooms", ...json({ type: "object", properties: { rooms: { type: "array", items: { $ref: "#/components/schemas/ChatRoom" } }, }, }), }, ...fail([400]), }, }, post: { tags: ["Chat"], summary: "Open a direct chat or create a group", description: "`{kind: 'dm', userId}` answers the one direct chat of the pair (201 when made now, 200 when it existed); `{kind: 'group', name, userIds}` makes a group of the caller plus `userIds`. Broadcasts `chat_room` to the members.", requestBody: { required: true, ...json({ type: "object", required: ["kind"], properties: { kind: { type: "string", enum: ["dm", "group"] }, userId: { type: "integer" }, name: { type: "string", maxLength: 80 }, userIds: { type: "array", items: { type: "integer" } }, }, }), }, responses: { 200: { description: "The existing direct chat", ...roomResp }, 201: { description: "Created", ...roomResp }, ...fail([400]), }, }, }, "/api/chat/rooms/{id}": { delete: { tags: ["Chat"], summary: "Disband a group", description: "The group's creator only (`403 EFORBIDDEN`): deletes it for every member, its files included. Broadcasts `chat_room`.", parameters: [idParam], responses: { 200: { description: "Disbanded", ...json({ type: "object", properties: { ok: { type: "boolean" } } }), }, ...fail([400, 403, 404]), }, }, patch: { tags: ["Chat"], summary: "Rename a group, add or remove members", description: "Only the group's creator (`403 EFORBIDDEN`). Removed users are told too.", parameters: [idParam], requestBody: { required: true, ...json({ type: "object", properties: { name: { type: "string" }, add: { type: "array", items: { type: "integer" } }, remove: { type: "array", items: { type: "integer" } }, }, }), }, responses: { 200: { description: "Updated", ...roomResp }, ...fail([400, 403, 404]) }, }, }, "/api/chat/rooms/{id}/clear": { post: { tags: ["Chat"], summary: "Delete a chat for yourself", description: "Every message so far is gone for the caller only (`clearedId`); a direct chat also leaves their list until a new message arrives. Nobody else's copy changes — but what EVERY member has deleted is deleted for real (messages, files, and a direct chat both people deleted, whole).", parameters: [idParam], responses: { 200: { description: "Cleared", ...json({ type: "object", properties: { hidden: { type: "boolean" }, purged: { type: "boolean", description: "Every member has now deleted it: the chat is gone from the server.", }, }, }), }, ...fail([404]), }, }, }, "/api/chat/rooms/{id}/leave": { post: { tags: ["Chat"], summary: "Leave a group", description: "The last member out deletes the group and its files.", parameters: [idParam], responses: { 200: { description: "Left", ...json({ type: "object", properties: { ok: { type: "boolean" } } }), }, ...fail([400, 404]), }, }, }, "/api/chat/rooms/{id}/messages": { get: { tags: ["Chat"], summary: "A page of a room's messages", description: "Oldest first: the newest `limit` (default 50, max 200), or those before `before`.", parameters: [ idParam, { name: "before", in: "query", schema: { type: "integer" } }, { name: "limit", in: "query", schema: { type: "integer" } }, ], responses: { 200: { description: "Messages", ...json({ type: "object", properties: { messages: { type: "array", items: { $ref: "#/components/schemas/ChatMessage" } }, hasMore: { type: "boolean" }, }, }), }, ...fail([400, 404]), }, }, post: { tags: ["Chat"], summary: "Post a message", description: "JSON `{body}` or multipart `body` + up to 10 `files` of ≤ 25 MB. Broadcasts `chat_message` to the room's members.", parameters: [idParam], requestBody: { required: true, content: { "application/json": { schema: { type: "object", properties: { body: { type: "string", maxLength: 8000 } } }, }, "multipart/form-data": { schema: { type: "object", properties: { body: { type: "string" }, files: { type: "array", items: { type: "string", format: "binary" } }, }, }, }, }, }, responses: { 201: { description: "Posted", ...json({ type: "object", properties: { message: { $ref: "#/components/schemas/ChatMessage" } }, }), }, ...fail([400, 404, 413]), }, }, }, "/api/chat/rooms/{id}/messages/{messageId}": { delete: { tags: ["Chat"], summary: "Delete your own message", description: "Only the message's author (`403 EFORBIDDEN`, an admin included); its files go with it. Broadcasts `chat_deleted` to the members.", parameters: [ idParam, { name: "messageId", in: "path", required: true, schema: { type: "integer" } }, ], responses: { 200: { description: "Deleted", ...json({ type: "object", properties: { ok: { type: "boolean" } } }), }, ...fail([403, 404]), }, }, }, "/api/chat/rooms/{id}/read": { post: { tags: ["Chat"], summary: "Mark a room read up to a message", description: "Never moves back, never past the room's newest message. A mark that moved is told to the members as `chat_read` (`{roomId, userId, lastReadId}`).", parameters: [idParam], requestBody: { required: true, ...json({ type: "object", required: ["upTo"], properties: { upTo: { type: "integer" } } }), }, responses: { 200: { description: "Read mark", ...json({ type: "object", properties: { lastReadId: { type: "integer" } } }), }, ...fail([400, 404]), }, }, }, "/api/chat/rooms/{id}/files/{fileId}": { get: { tags: ["Chat"], summary: "One file of a room", description: "Members only. Served under a `sandbox` CSP and `nosniff`; PNG/JPEG/GIF/WebP inline, anything else as an attachment.", parameters: [ idParam, { name: "fileId", in: "path", required: true, schema: { type: "string" } }, ], responses: { 200: { description: "The file's bytes", content: { "application/octet-stream": {} } }, ...fail([400, 404]), }, }, }, }; module.exports = { tags, schemas, paths };