Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions app/backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<unknown>
Expand Down Expand Up @@ -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
Expand Down
24 changes: 24 additions & 0 deletions app/backend/src/chat/chat.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
166 changes: 166 additions & 0 deletions app/backend/src/chat/dto/study-room.dto.ts
Original file line number Diff line number Diff line change
@@ -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;
}
30 changes: 30 additions & 0 deletions app/backend/src/chat/entities/study-room.entity.ts
Original file line number Diff line number Diff line change
@@ -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;
}
61 changes: 61 additions & 0 deletions app/backend/src/chat/migrations/001_create_study_rooms.sql
Original file line number Diff line number Diff line change
@@ -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);
Loading
Loading