diff --git a/app/backend/src/app.module.ts b/app/backend/src/app.module.ts index 128e4ad4d..e9627634e 100644 --- a/app/backend/src/app.module.ts +++ b/app/backend/src/app.module.ts @@ -50,6 +50,7 @@ import { throttlerModuleProfiles } from "./config/rate-limit.config"; import { EnvironmentParityModule } from "./environment-parity/environment-parity.module"; import { IndexerLagModule } from "./indexer-lag"; import { SupportBundleModule } from "./support-bundle/support-bundle.module"; +import { ChatModule } from "./chat/chat.module"; type AppImport = | Type @@ -95,6 +96,7 @@ type AppImport = EnvironmentParityModule, IndexerLagModule, SupportBundleModule, + ChatModule, ]; // In development, if SUPABASE_URL points to a localhost placeholder (i.e. you don't diff --git a/app/backend/src/chat/chat.module.ts b/app/backend/src/chat/chat.module.ts new file mode 100644 index 000000000..bfb07ebf9 --- /dev/null +++ b/app/backend/src/chat/chat.module.ts @@ -0,0 +1,24 @@ +import { Module } from "@nestjs/common"; + +import { SupabaseModule } from "../supabase/supabase.module"; +import { StudyRoomRepository } from "./study-room.repository"; +import { StudyRoomService } from "./study-room.service"; +import { StudyRoomController } from "./study-room.controller"; + +/** + * ChatModule + * + * Provides topic-based study rooms: creation, membership, and messaging. + * + * Required Supabase tables (see migration below): + * - study_rooms + * - study_room_members + * - study_room_messages + */ +@Module({ + imports: [SupabaseModule], + controllers: [StudyRoomController], + providers: [StudyRoomRepository, StudyRoomService], + exports: [StudyRoomService], +}) +export class ChatModule {} diff --git a/app/backend/src/chat/dto/study-room.dto.ts b/app/backend/src/chat/dto/study-room.dto.ts new file mode 100644 index 000000000..eb14f7a28 --- /dev/null +++ b/app/backend/src/chat/dto/study-room.dto.ts @@ -0,0 +1,166 @@ +import { + IsString, + IsOptional, + IsArray, + IsIn, + IsNumber, + IsNotEmpty, + MaxLength, + Min, + Max, + ArrayMaxSize, +} from "class-validator"; +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { Type } from "class-transformer"; + +import type { StudyRoomStatus } from "../entities/study-room.entity"; + +// --------------------------------------------------------------------------- +// Request DTOs +// --------------------------------------------------------------------------- + +export class CreateStudyRoomDto { + @ApiProperty({ + example: "rust", + description: "Topic slug that groups this room (e.g. rust, solidity, defi)", + }) + @IsString() + @IsNotEmpty() + @MaxLength(100) + topic!: string; + + @ApiProperty({ example: "Rust Ownership Deep Dive" }) + @IsString() + @IsNotEmpty() + @MaxLength(120) + name!: string; + + @ApiPropertyOptional({ + example: "A room for discussing Rust ownership and borrowing", + }) + @IsOptional() + @IsString() + @MaxLength(500) + description?: string; + + @ApiPropertyOptional({ + type: [String], + example: ["rust", "memory", "ownership"], + description: "Free-form tags for discoverability", + }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + @ArrayMaxSize(10) + tags?: string[]; + + @ApiPropertyOptional({ + example: 50, + description: + "Maximum number of participants allowed. Omit for unlimited.", + minimum: 1, + maximum: 1000, + }) + @IsOptional() + @IsNumber() + @Min(1) + @Max(1000) + @Type(() => Number) + maxParticipants?: number; +} + +export class UpdateStudyRoomDto { + @ApiPropertyOptional({ example: "Advanced Rust Ownership" }) + @IsOptional() + @IsString() + @IsNotEmpty() + @MaxLength(120) + name?: string; + + @ApiPropertyOptional({ example: "Updated description" }) + @IsOptional() + @IsString() + @MaxLength(500) + description?: string; + + @ApiPropertyOptional({ + type: [String], + example: ["rust", "advanced"], + }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + @ArrayMaxSize(10) + tags?: string[]; + + @ApiPropertyOptional({ enum: ["active", "archived"] }) + @IsOptional() + @IsIn(["active", "archived"]) + status?: StudyRoomStatus; + + @ApiPropertyOptional({ minimum: 1, maximum: 1000 }) + @IsOptional() + @IsNumber() + @Min(1) + @Max(1000) + @Type(() => Number) + maxParticipants?: number; +} + +export class SendMessageDto { + @ApiProperty({ example: "Can someone explain lifetimes?" }) + @IsString() + @IsNotEmpty() + @MaxLength(2000) + content!: string; +} + +// --------------------------------------------------------------------------- +// Response DTOs +// --------------------------------------------------------------------------- + +export class StudyRoomResponseDto { + @ApiProperty() id!: string; + @ApiProperty() topic!: string; + @ApiProperty() name!: string; + @ApiPropertyOptional() description!: string | null; + @ApiProperty({ type: [String] }) tags!: string[]; + @ApiProperty({ enum: ["active", "archived"] }) status!: StudyRoomStatus; + @ApiPropertyOptional() maxParticipants!: number | null; + @ApiProperty() createdByPublicKey!: string; + @ApiProperty() memberCount!: number; + @ApiProperty() createdAt!: string; + @ApiProperty() updatedAt!: string; +} + +export class StudyRoomMessageResponseDto { + @ApiProperty() id!: string; + @ApiProperty() roomId!: string; + @ApiProperty() senderPublicKey!: string; + @ApiProperty() content!: string; + @ApiProperty() createdAt!: string; + @ApiPropertyOptional() editedAt!: string | null; +} + +export class StudyRoomMemberResponseDto { + @ApiProperty() id!: string; + @ApiProperty() roomId!: string; + @ApiProperty() publicKey!: string; + @ApiProperty() joinedAt!: string; +} + +export class StudyRoomListResponseDto { + @ApiProperty({ type: [StudyRoomResponseDto] }) items!: StudyRoomResponseDto[]; + @ApiProperty() total!: number; + @ApiProperty() page!: number; + @ApiProperty() limit!: number; +} + +export class MessageListResponseDto { + @ApiProperty({ type: [StudyRoomMessageResponseDto] }) + items!: StudyRoomMessageResponseDto[]; + + @ApiProperty() total!: number; + @ApiProperty() page!: number; + @ApiProperty() limit!: number; +} diff --git a/app/backend/src/chat/entities/study-room.entity.ts b/app/backend/src/chat/entities/study-room.entity.ts new file mode 100644 index 000000000..87cc24a67 --- /dev/null +++ b/app/backend/src/chat/entities/study-room.entity.ts @@ -0,0 +1,30 @@ +export type StudyRoomStatus = "active" | "archived"; + +export interface StudyRoom { + id: string; + topic: string; + name: string; + description: string | null; + tags: string[]; + status: StudyRoomStatus; + maxParticipants: number | null; + createdByPublicKey: string; + createdAt: string; + updatedAt: string; +} + +export interface StudyRoomMember { + id: string; + roomId: string; + publicKey: string; + joinedAt: string; +} + +export interface StudyRoomMessage { + id: string; + roomId: string; + senderPublicKey: string; + content: string; + createdAt: string; + editedAt: string | null; +} diff --git a/app/backend/src/chat/migrations/001_create_study_rooms.sql b/app/backend/src/chat/migrations/001_create_study_rooms.sql new file mode 100644 index 000000000..d5cb732cd --- /dev/null +++ b/app/backend/src/chat/migrations/001_create_study_rooms.sql @@ -0,0 +1,61 @@ +-- Migration: create study rooms tables +-- Run this against your Supabase project via the SQL Editor or supabase db push. + +-- ============================================================================ +-- study_rooms +-- ============================================================================ +CREATE TABLE IF NOT EXISTS study_rooms ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + topic TEXT NOT NULL, + name TEXT NOT NULL, + description TEXT, + tags TEXT[] NOT NULL DEFAULT '{}', + status TEXT NOT NULL DEFAULT 'active' + CHECK (status IN ('active', 'archived')), + max_participants INTEGER CHECK (max_participants > 0), + created_by_public_key TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Index for topic-based queries (most common filter) +CREATE INDEX IF NOT EXISTS idx_study_rooms_topic + ON study_rooms (topic, status, created_at DESC); + +-- Index for creator lookup +CREATE INDEX IF NOT EXISTS idx_study_rooms_created_by + ON study_rooms (created_by_public_key); + +-- ============================================================================ +-- study_room_members +-- ============================================================================ +CREATE TABLE IF NOT EXISTS study_room_members ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + room_id UUID NOT NULL REFERENCES study_rooms(id) ON DELETE CASCADE, + public_key TEXT NOT NULL, + joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT uq_room_member UNIQUE (room_id, public_key) +); + +CREATE INDEX IF NOT EXISTS idx_study_room_members_room + ON study_room_members (room_id, joined_at ASC); + +CREATE INDEX IF NOT EXISTS idx_study_room_members_key + ON study_room_members (public_key); + +-- ============================================================================ +-- study_room_messages +-- ============================================================================ +CREATE TABLE IF NOT EXISTS study_room_messages ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + room_id UUID NOT NULL REFERENCES study_rooms(id) ON DELETE CASCADE, + sender_public_key TEXT NOT NULL, + content TEXT NOT NULL CHECK (char_length(content) <= 2000), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + edited_at TIMESTAMPTZ +); + +-- Paginated message history (newest first per room) +CREATE INDEX IF NOT EXISTS idx_study_room_messages_room + ON study_room_messages (room_id, created_at DESC); diff --git a/app/backend/src/chat/study-room.controller.ts b/app/backend/src/chat/study-room.controller.ts new file mode 100644 index 000000000..4c4511cd1 --- /dev/null +++ b/app/backend/src/chat/study-room.controller.ts @@ -0,0 +1,320 @@ +import { + Controller, + Get, + Post, + Patch, + Delete, + Body, + Param, + Query, + HttpCode, + HttpStatus, + Logger, + ParseIntPipe, + DefaultValuePipe, + ParseUUIDPipe, +} from "@nestjs/common"; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiParam, + ApiQuery, +} from "@nestjs/swagger"; + +import { StudyRoomService } from "./study-room.service"; +import { + CreateStudyRoomDto, + UpdateStudyRoomDto, + SendMessageDto, + StudyRoomResponseDto, + StudyRoomListResponseDto, + StudyRoomMessageResponseDto, + MessageListResponseDto, + StudyRoomMemberResponseDto, +} from "./dto/study-room.dto"; +import type { RoomWithMemberCount } from "./study-room.service"; +import type { StudyRoomMember, StudyRoomMessage } from "./entities/study-room.entity"; + +/** + * Study Rooms API + * + * Topic-based collaborative study rooms for the RustAcademy platform. + * Callers identify themselves via the `x-public-key` header + * (the Stellar / wallet public key used throughout the system). + * + * Routes: + * GET /chat/rooms - list rooms (filterable by topic) + * POST /chat/rooms - create a new room + * GET /chat/rooms/:roomId - get room details + * PATCH /chat/rooms/:roomId - update room (creator only) + * DELETE /chat/rooms/:roomId - delete room (creator only) + * + * GET /chat/rooms/:roomId/members - list members + * POST /chat/rooms/:roomId/members - join a room + * DELETE /chat/rooms/:roomId/members/me - leave a room + * + * GET /chat/rooms/:roomId/messages - paginated message history + * POST /chat/rooms/:roomId/messages - send a message (members only) + * DELETE /chat/rooms/:roomId/messages/:msgId - delete own message + */ +@ApiTags("Chat / Study Rooms") +@Controller("chat/rooms") +export class StudyRoomController { + private readonly logger = new Logger(StudyRoomController.name); + + constructor(private readonly service: StudyRoomService) {} + + // --------------------------------------------------------------------------- + // Helper – extract caller identity from the standard header + // --------------------------------------------------------------------------- + private callerKey(headers: Record): string { + const key = headers["x-public-key"]; + if (!key || Array.isArray(key)) { + return "anonymous"; + } + return key; + } + + // --------------------------------------------------------------------------- + // Rooms – CRUD + // --------------------------------------------------------------------------- + + @Get() + @ApiOperation({ summary: "List study rooms, optionally filtered by topic" }) + @ApiQuery({ name: "topic", required: false, description: "Filter by topic slug" }) + @ApiQuery({ name: "status", required: false, enum: ["active", "archived"] }) + @ApiQuery({ name: "page", required: false, type: Number, example: 1 }) + @ApiQuery({ name: "limit", required: false, type: Number, example: 20 }) + @ApiResponse({ status: 200, type: StudyRoomListResponseDto }) + async listRooms( + @Query("topic") topic?: string, + @Query("status") status?: "active" | "archived", + @Query("page", new DefaultValuePipe(1), ParseIntPipe) page = 1, + @Query("limit", new DefaultValuePipe(20), ParseIntPipe) limit = 20, + ): Promise { + const result = await this.service.listRooms({ topic, status, page, limit }); + + return { + items: result.items.map(this.toRoomResponse), + total: result.total, + page: result.page, + limit: result.limit, + }; + } + + @Post() + @ApiOperation({ summary: "Create a new topic-based study room" }) + @ApiQuery({ + name: "publicKey", + required: true, + description: "Creator Stellar public key (or pass via x-public-key header)", + }) + @ApiResponse({ status: 201, type: StudyRoomResponseDto }) + async createRoom( + @Body() dto: CreateStudyRoomDto, + @Query("publicKey") publicKeyQuery?: string, + ): Promise { + const createdByPublicKey = publicKeyQuery ?? "unknown"; + + this.logger.log( + `Create room request: topic="${dto.topic}" by ${createdByPublicKey.slice(0, 8)}...`, + ); + + const room = await this.service.createRoom( + { + topic: dto.topic, + name: dto.name, + description: dto.description, + tags: dto.tags, + maxParticipants: dto.maxParticipants, + }, + createdByPublicKey, + ); + + return this.toRoomResponse(room); + } + + @Get(":roomId") + @ApiOperation({ summary: "Get study room details" }) + @ApiParam({ name: "roomId", description: "UUID of the study room" }) + @ApiResponse({ status: 200, type: StudyRoomResponseDto }) + @ApiResponse({ status: 404, description: "Room not found" }) + async getRoom( + @Param("roomId", ParseUUIDPipe) roomId: string, + ): Promise { + const room = await this.service.getRoomById(roomId); + return this.toRoomResponse(room); + } + + @Patch(":roomId") + @ApiOperation({ summary: "Update a study room (creator only)" }) + @ApiParam({ name: "roomId", description: "UUID of the study room" }) + @ApiQuery({ name: "publicKey", required: true, description: "Caller's Stellar public key" }) + @ApiResponse({ status: 200, type: StudyRoomResponseDto }) + @ApiResponse({ status: 403, description: "Not room creator" }) + @ApiResponse({ status: 404, description: "Room not found" }) + async updateRoom( + @Param("roomId", ParseUUIDPipe) roomId: string, + @Body() dto: UpdateStudyRoomDto, + @Query("publicKey") publicKey: string, + ): Promise { + const room = await this.service.updateRoom(roomId, dto, publicKey); + return this.toRoomResponse(room); + } + + @Delete(":roomId") + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: "Delete a study room (creator only)" }) + @ApiParam({ name: "roomId", description: "UUID of the study room" }) + @ApiQuery({ name: "publicKey", required: true, description: "Caller's Stellar public key" }) + @ApiResponse({ status: 204, description: "Room deleted" }) + @ApiResponse({ status: 403, description: "Not room creator" }) + @ApiResponse({ status: 404, description: "Room not found" }) + async deleteRoom( + @Param("roomId", ParseUUIDPipe) roomId: string, + @Query("publicKey") publicKey: string, + ): Promise { + await this.service.deleteRoom(roomId, publicKey); + } + + // --------------------------------------------------------------------------- + // Members + // --------------------------------------------------------------------------- + + @Get(":roomId/members") + @ApiOperation({ summary: "List room members" }) + @ApiParam({ name: "roomId", description: "UUID of the study room" }) + @ApiResponse({ status: 200, type: [StudyRoomMemberResponseDto] }) + async listMembers( + @Param("roomId", ParseUUIDPipe) roomId: string, + ): Promise { + const members = await this.service.listMembers(roomId); + return members.map(this.toMemberResponse); + } + + @Post(":roomId/members") + @ApiOperation({ summary: "Join a study room" }) + @ApiParam({ name: "roomId", description: "UUID of the study room" }) + @ApiQuery({ name: "publicKey", required: true, description: "Caller's Stellar public key" }) + @ApiResponse({ status: 201, type: StudyRoomMemberResponseDto }) + @ApiResponse({ status: 409, description: "Already a member" }) + async joinRoom( + @Param("roomId", ParseUUIDPipe) roomId: string, + @Query("publicKey") publicKey: string, + ): Promise { + const member = await this.service.joinRoom(roomId, publicKey); + return this.toMemberResponse(member); + } + + @Delete(":roomId/members/me") + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: "Leave a study room" }) + @ApiParam({ name: "roomId", description: "UUID of the study room" }) + @ApiQuery({ name: "publicKey", required: true, description: "Caller's Stellar public key" }) + @ApiResponse({ status: 204, description: "Left the room" }) + @ApiResponse({ status: 404, description: "Not a member" }) + async leaveRoom( + @Param("roomId", ParseUUIDPipe) roomId: string, + @Query("publicKey") publicKey: string, + ): Promise { + await this.service.leaveRoom(roomId, publicKey); + } + + // --------------------------------------------------------------------------- + // Messages + // --------------------------------------------------------------------------- + + @Get(":roomId/messages") + @ApiOperation({ summary: "List messages in a study room (newest first)" }) + @ApiParam({ name: "roomId", description: "UUID of the study room" }) + @ApiQuery({ name: "page", required: false, type: Number, example: 1 }) + @ApiQuery({ name: "limit", required: false, type: Number, example: 50 }) + @ApiResponse({ status: 200, type: MessageListResponseDto }) + async listMessages( + @Param("roomId", ParseUUIDPipe) roomId: string, + @Query("page", new DefaultValuePipe(1), ParseIntPipe) page = 1, + @Query("limit", new DefaultValuePipe(50), ParseIntPipe) limit = 50, + ): Promise { + const result = await this.service.listMessages(roomId, page, limit); + + return { + items: result.items.map(this.toMessageResponse), + total: result.total, + page: result.page, + limit: result.limit, + }; + } + + @Post(":roomId/messages") + @ApiOperation({ summary: "Send a message to a study room (members only)" }) + @ApiParam({ name: "roomId", description: "UUID of the study room" }) + @ApiQuery({ name: "publicKey", required: true, description: "Caller's Stellar public key" }) + @ApiResponse({ status: 201, type: StudyRoomMessageResponseDto }) + @ApiResponse({ status: 403, description: "Not a member of the room" }) + async sendMessage( + @Param("roomId", ParseUUIDPipe) roomId: string, + @Body() dto: SendMessageDto, + @Query("publicKey") publicKey: string, + ): Promise { + const message = await this.service.sendMessage(roomId, publicKey, dto.content); + return this.toMessageResponse(message); + } + + @Delete(":roomId/messages/:messageId") + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: "Delete own message" }) + @ApiParam({ name: "roomId", description: "UUID of the study room" }) + @ApiParam({ name: "messageId", description: "UUID of the message" }) + @ApiQuery({ name: "publicKey", required: true, description: "Caller's Stellar public key" }) + @ApiResponse({ status: 204, description: "Message deleted" }) + @ApiResponse({ status: 403, description: "Not message author" }) + @ApiResponse({ status: 404, description: "Message not found" }) + async deleteMessage( + @Param("roomId", ParseUUIDPipe) roomId: string, + @Param("messageId", ParseUUIDPipe) messageId: string, + @Query("publicKey") publicKey: string, + ): Promise { + await this.service.deleteMessage(roomId, messageId, publicKey); + } + + // --------------------------------------------------------------------------- + // Private mappers + // --------------------------------------------------------------------------- + + private toRoomResponse(room: RoomWithMemberCount): StudyRoomResponseDto { + const dto = new StudyRoomResponseDto(); + dto.id = room.id; + dto.topic = room.topic; + dto.name = room.name; + dto.description = room.description; + dto.tags = room.tags; + dto.status = room.status; + dto.maxParticipants = room.maxParticipants; + dto.createdByPublicKey = room.createdByPublicKey; + dto.memberCount = room.memberCount; + dto.createdAt = room.createdAt; + dto.updatedAt = room.updatedAt; + return dto; + } + + private toMemberResponse(member: StudyRoomMember): StudyRoomMemberResponseDto { + const dto = new StudyRoomMemberResponseDto(); + dto.id = member.id; + dto.roomId = member.roomId; + dto.publicKey = member.publicKey; + dto.joinedAt = member.joinedAt; + return dto; + } + + private toMessageResponse(message: StudyRoomMessage): StudyRoomMessageResponseDto { + const dto = new StudyRoomMessageResponseDto(); + dto.id = message.id; + dto.roomId = message.roomId; + dto.senderPublicKey = message.senderPublicKey; + dto.content = message.content; + dto.createdAt = message.createdAt; + dto.editedAt = message.editedAt; + return dto; + } +} diff --git a/app/backend/src/chat/study-room.repository.ts b/app/backend/src/chat/study-room.repository.ts new file mode 100644 index 000000000..046540b84 --- /dev/null +++ b/app/backend/src/chat/study-room.repository.ts @@ -0,0 +1,381 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { SupabaseService } from "../supabase/supabase.service"; +import type { + StudyRoom, + StudyRoomMember, + StudyRoomMessage, + StudyRoomStatus, +} from "./entities/study-room.entity"; + +export interface CreateRoomData { + topic: string; + name: string; + description?: string; + tags?: string[]; + maxParticipants?: number; + createdByPublicKey: string; +} + +export interface UpdateRoomData { + name?: string; + description?: string; + tags?: string[]; + status?: StudyRoomStatus; + maxParticipants?: number; +} + +export interface RoomFilters { + topic?: string; + status?: StudyRoomStatus; + page?: number; + limit?: number; +} + +@Injectable() +export class StudyRoomRepository { + private readonly logger = new Logger(StudyRoomRepository.name); + + constructor(private readonly db: SupabaseService) {} + + // --------------------------------------------------------------------------- + // Rooms + // --------------------------------------------------------------------------- + + async createRoom(data: CreateRoomData): Promise { + const now = new Date().toISOString(); + + const { data: row, error } = await this.db + .getClient() + .from("study_rooms") + .insert({ + topic: data.topic, + name: data.name, + description: data.description ?? null, + tags: data.tags ?? [], + max_participants: data.maxParticipants ?? null, + created_by_public_key: data.createdByPublicKey, + status: "active", + created_at: now, + updated_at: now, + }) + .select() + .single(); + + if (error) { + this.logger.error("Failed to create study room", error); + throw error; + } + + return this.mapRoom(row); + } + + async findRooms(filters: RoomFilters): Promise<{ items: StudyRoom[]; total: number }> { + const page = filters.page ?? 1; + const limit = Math.min(filters.limit ?? 20, 100); + const from = (page - 1) * limit; + const to = from + limit - 1; + + let query = this.db + .getClient() + .from("study_rooms") + .select("*", { count: "exact" }); + + if (filters.topic) { + query = query.eq("topic", filters.topic); + } + + if (filters.status) { + query = query.eq("status", filters.status); + } else { + // Default: only active rooms + query = query.eq("status", "active"); + } + + const { data, error, count } = await query + .order("created_at", { ascending: false }) + .range(from, to); + + if (error) { + this.logger.error("Failed to list study rooms", error); + throw error; + } + + return { + items: (data ?? []).map((r) => this.mapRoom(r)), + total: count ?? 0, + }; + } + + async findRoomById(id: string): Promise { + const { data, error } = await this.db + .getClient() + .from("study_rooms") + .select("*") + .eq("id", id) + .single(); + + if (error) { + if (error.code === "PGRST116") return null; // row not found + this.logger.error(`Failed to fetch room ${id}`, error); + throw error; + } + + return data ? this.mapRoom(data) : null; + } + + async updateRoom(id: string, data: UpdateRoomData): Promise { + const patch: Record = { updated_at: new Date().toISOString() }; + + if (data.name !== undefined) patch["name"] = data.name; + if (data.description !== undefined) patch["description"] = data.description; + if (data.tags !== undefined) patch["tags"] = data.tags; + if (data.status !== undefined) patch["status"] = data.status; + if (data.maxParticipants !== undefined) patch["max_participants"] = data.maxParticipants; + + const { data: row, error } = await this.db + .getClient() + .from("study_rooms") + .update(patch) + .eq("id", id) + .select() + .single(); + + if (error) { + if (error.code === "PGRST116") return null; + this.logger.error(`Failed to update room ${id}`, error); + throw error; + } + + return row ? this.mapRoom(row) : null; + } + + async deleteRoom(id: string): Promise { + const { error } = await this.db + .getClient() + .from("study_rooms") + .delete() + .eq("id", id); + + if (error) { + this.logger.error(`Failed to delete room ${id}`, error); + throw error; + } + } + + async countMembers(roomId: string): Promise { + const { count, error } = await this.db + .getClient() + .from("study_room_members") + .select("*", { count: "exact", head: true }) + .eq("room_id", roomId); + + if (error) { + this.logger.warn(`Failed to count members for room ${roomId}`, error); + return 0; + } + + return count ?? 0; + } + + // --------------------------------------------------------------------------- + // Membership + // --------------------------------------------------------------------------- + + async findMember(roomId: string, publicKey: string): Promise { + const { data, error } = await this.db + .getClient() + .from("study_room_members") + .select("*") + .eq("room_id", roomId) + .eq("public_key", publicKey) + .single(); + + if (error) { + if (error.code === "PGRST116") return null; + throw error; + } + + return data ? this.mapMember(data) : null; + } + + async addMember(roomId: string, publicKey: string): Promise { + const { data, error } = await this.db + .getClient() + .from("study_room_members") + .insert({ + room_id: roomId, + public_key: publicKey, + joined_at: new Date().toISOString(), + }) + .select() + .single(); + + if (error) { + this.logger.error(`Failed to add member to room ${roomId}`, error); + throw error; + } + + return this.mapMember(data); + } + + async removeMember(roomId: string, publicKey: string): Promise { + const { error } = await this.db + .getClient() + .from("study_room_members") + .delete() + .eq("room_id", roomId) + .eq("public_key", publicKey); + + if (error) { + this.logger.error(`Failed to remove member from room ${roomId}`, error); + throw error; + } + } + + async listMembers(roomId: string): Promise { + const { data, error } = await this.db + .getClient() + .from("study_room_members") + .select("*") + .eq("room_id", roomId) + .order("joined_at", { ascending: true }); + + if (error) { + this.logger.error(`Failed to list members of room ${roomId}`, error); + throw error; + } + + return (data ?? []).map((m) => this.mapMember(m)); + } + + // --------------------------------------------------------------------------- + // Messages + // --------------------------------------------------------------------------- + + async createMessage( + roomId: string, + senderPublicKey: string, + content: string, + ): Promise { + const now = new Date().toISOString(); + + const { data, error } = await this.db + .getClient() + .from("study_room_messages") + .insert({ + room_id: roomId, + sender_public_key: senderPublicKey, + content, + created_at: now, + edited_at: null, + }) + .select() + .single(); + + if (error) { + this.logger.error(`Failed to send message in room ${roomId}`, error); + throw error; + } + + return this.mapMessage(data); + } + + async listMessages( + roomId: string, + page = 1, + limit = 50, + ): Promise<{ items: StudyRoomMessage[]; total: number }> { + const safeLimit = Math.min(limit, 200); + const from = (page - 1) * safeLimit; + const to = from + safeLimit - 1; + + const { data, error, count } = await this.db + .getClient() + .from("study_room_messages") + .select("*", { count: "exact" }) + .eq("room_id", roomId) + .order("created_at", { ascending: false }) + .range(from, to); + + if (error) { + this.logger.error(`Failed to list messages for room ${roomId}`, error); + throw error; + } + + return { + items: (data ?? []).map((m) => this.mapMessage(m)), + total: count ?? 0, + }; + } + + async deleteMessage(messageId: string): Promise { + const { error } = await this.db + .getClient() + .from("study_room_messages") + .delete() + .eq("id", messageId); + + if (error) { + this.logger.error(`Failed to delete message ${messageId}`, error); + throw error; + } + } + + async findMessageById(messageId: string): Promise { + const { data, error } = await this.db + .getClient() + .from("study_room_messages") + .select("*") + .eq("id", messageId) + .single(); + + if (error) { + if (error.code === "PGRST116") return null; + throw error; + } + + return data ? this.mapMessage(data) : null; + } + + // --------------------------------------------------------------------------- + // Row mappers + // --------------------------------------------------------------------------- + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private mapRoom(row: any): StudyRoom { + return { + id: row.id, + topic: row.topic, + name: row.name, + description: row.description ?? null, + tags: row.tags ?? [], + status: row.status, + maxParticipants: row.max_participants ?? null, + createdByPublicKey: row.created_by_public_key, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private mapMember(row: any): StudyRoomMember { + return { + id: row.id, + roomId: row.room_id, + publicKey: row.public_key, + joinedAt: row.joined_at, + }; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private mapMessage(row: any): StudyRoomMessage { + return { + id: row.id, + roomId: row.room_id, + senderPublicKey: row.sender_public_key, + content: row.content, + createdAt: row.created_at, + editedAt: row.edited_at ?? null, + }; + } +} diff --git a/app/backend/src/chat/study-room.service.ts b/app/backend/src/chat/study-room.service.ts new file mode 100644 index 000000000..313d5bd7f --- /dev/null +++ b/app/backend/src/chat/study-room.service.ts @@ -0,0 +1,235 @@ +import { + Injectable, + Logger, + NotFoundException, + ConflictException, + ForbiddenException, + BadRequestException, +} from "@nestjs/common"; + +import { StudyRoomRepository } from "./study-room.repository"; +import type { CreateRoomData, UpdateRoomData } from "./study-room.repository"; +import type { + StudyRoom, + StudyRoomMember, + StudyRoomMessage, +} from "./entities/study-room.entity"; + +export interface RoomWithMemberCount extends StudyRoom { + memberCount: number; +} + +@Injectable() +export class StudyRoomService { + private readonly logger = new Logger(StudyRoomService.name); + + constructor(private readonly roomRepo: StudyRoomRepository) {} + + // --------------------------------------------------------------------------- + // Rooms + // --------------------------------------------------------------------------- + + async createRoom( + data: Omit, + createdByPublicKey: string, + ): Promise { + this.logger.log( + `Creating room "${data.name}" for topic "${data.topic}" by ${createdByPublicKey.slice(0, 8)}...`, + ); + + const room = await this.roomRepo.createRoom({ ...data, createdByPublicKey }); + + // Auto-join the creator + await this.roomRepo.addMember(room.id, createdByPublicKey); + + return { ...room, memberCount: 1 }; + } + + async listRooms(filters: { + topic?: string; + status?: "active" | "archived"; + page?: number; + limit?: number; + }): Promise<{ items: RoomWithMemberCount[]; total: number; page: number; limit: number }> { + const page = filters.page ?? 1; + const limit = filters.limit ?? 20; + + const { items, total } = await this.roomRepo.findRooms({ ...filters, page, limit }); + + // Enrich each room with its member count + const itemsWithCount = await Promise.all( + items.map(async (room) => ({ + ...room, + memberCount: await this.roomRepo.countMembers(room.id), + })), + ); + + return { items: itemsWithCount, total, page, limit }; + } + + async getRoomById(id: string): Promise { + const room = await this.roomRepo.findRoomById(id); + + if (!room) { + throw new NotFoundException({ error: "ROOM_NOT_FOUND", message: `Study room ${id} not found` }); + } + + const memberCount = await this.roomRepo.countMembers(id); + return { ...room, memberCount }; + } + + async updateRoom( + id: string, + data: UpdateRoomData, + callerPublicKey: string, + ): Promise { + const room = await this.getRoomById(id); + + if (room.createdByPublicKey !== callerPublicKey) { + throw new ForbiddenException({ + error: "NOT_ROOM_OWNER", + message: "Only the room creator can update this room", + }); + } + + const updated = await this.roomRepo.updateRoom(id, data); + + if (!updated) { + throw new NotFoundException({ error: "ROOM_NOT_FOUND", message: `Study room ${id} not found` }); + } + + const memberCount = await this.roomRepo.countMembers(id); + return { ...updated, memberCount }; + } + + async deleteRoom(id: string, callerPublicKey: string): Promise { + const room = await this.getRoomById(id); + + if (room.createdByPublicKey !== callerPublicKey) { + throw new ForbiddenException({ + error: "NOT_ROOM_OWNER", + message: "Only the room creator can delete this room", + }); + } + + this.logger.log(`Deleting study room ${id} by ${callerPublicKey.slice(0, 8)}...`); + await this.roomRepo.deleteRoom(id); + } + + // --------------------------------------------------------------------------- + // Membership + // --------------------------------------------------------------------------- + + async joinRoom(roomId: string, publicKey: string): Promise { + const room = await this.getRoomById(roomId); + + if (room.status === "archived") { + throw new BadRequestException({ + error: "ROOM_ARCHIVED", + message: "Cannot join an archived room", + }); + } + + const existing = await this.roomRepo.findMember(roomId, publicKey); + if (existing) { + throw new ConflictException({ + error: "ALREADY_MEMBER", + message: "You are already a member of this room", + }); + } + + if (room.maxParticipants !== null && room.memberCount >= room.maxParticipants) { + throw new BadRequestException({ + error: "ROOM_FULL", + message: `This room has reached its maximum capacity of ${room.maxParticipants}`, + }); + } + + this.logger.log(`${publicKey.slice(0, 8)}... joined room ${roomId}`); + return this.roomRepo.addMember(roomId, publicKey); + } + + async leaveRoom(roomId: string, publicKey: string): Promise { + await this.getRoomById(roomId); // ensure room exists + + const existing = await this.roomRepo.findMember(roomId, publicKey); + if (!existing) { + throw new NotFoundException({ + error: "NOT_A_MEMBER", + message: "You are not a member of this room", + }); + } + + this.logger.log(`${publicKey.slice(0, 8)}... left room ${roomId}`); + await this.roomRepo.removeMember(roomId, publicKey); + } + + async listMembers(roomId: string): Promise { + await this.getRoomById(roomId); // ensure room exists + return this.roomRepo.listMembers(roomId); + } + + // --------------------------------------------------------------------------- + // Messages + // --------------------------------------------------------------------------- + + async sendMessage( + roomId: string, + senderPublicKey: string, + content: string, + ): Promise { + const room = await this.getRoomById(roomId); + + if (room.status === "archived") { + throw new BadRequestException({ + error: "ROOM_ARCHIVED", + message: "Cannot send messages to an archived room", + }); + } + + const member = await this.roomRepo.findMember(roomId, senderPublicKey); + if (!member) { + throw new ForbiddenException({ + error: "NOT_A_MEMBER", + message: "You must join the room before sending messages", + }); + } + + return this.roomRepo.createMessage(roomId, senderPublicKey, content); + } + + async listMessages( + roomId: string, + page = 1, + limit = 50, + ): Promise<{ items: StudyRoomMessage[]; total: number; page: number; limit: number }> { + await this.getRoomById(roomId); // ensure room exists + const { items, total } = await this.roomRepo.listMessages(roomId, page, limit); + return { items, total, page, limit }; + } + + async deleteMessage( + roomId: string, + messageId: string, + callerPublicKey: string, + ): Promise { + await this.getRoomById(roomId); + + const message = await this.roomRepo.findMessageById(messageId); + if (!message || message.roomId !== roomId) { + throw new NotFoundException({ + error: "MESSAGE_NOT_FOUND", + message: `Message ${messageId} not found in room ${roomId}`, + }); + } + + if (message.senderPublicKey !== callerPublicKey) { + throw new ForbiddenException({ + error: "NOT_MESSAGE_AUTHOR", + message: "You can only delete your own messages", + }); + } + + await this.roomRepo.deleteMessage(messageId); + } +}