Skip to content
Open
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
56 changes: 56 additions & 0 deletions BackendAcademy/database/migrations/003_tutor_escrow.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
-- Migration 003: Time-locked tutor escrow (BE-050)
--
-- Durable representation of the in-memory EscrowPayoutService. The NestJS
-- services are currently in-memory; this schema is what a future repository
-- layer targets.
--
-- Design notes
-- ─────────────
-- • Amounts are BIGINT in stroops (1 XLM = 10^7 stroops) — the indivisible
-- unit, mirroring the in-memory bigint handling.
-- • accrual_id is the client-supplied idempotency key; UNIQUE per tutor so a
-- replayed accrual is rejected by the database too.
-- • unlocks_at is frozen at accrual time (computed from the tutor's schedule
-- in effect then), so later schedule changes cannot shift existing rows.
-- • state is enforced by CHECK; the transition locked → released is one-way.

-- ── Escrow entries ──────────────────────────────────────────────────────────

CREATE TABLE IF NOT EXISTS tutor_escrow_entries (
escrow_id TEXT PRIMARY KEY,
tutor_id TEXT NOT NULL,
amount_stroops BIGINT NOT NULL CHECK (amount_stroops > 0),
accrued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
unlocks_at TIMESTAMPTZ NOT NULL,
state TEXT NOT NULL DEFAULT 'locked' CHECK (state IN ('locked', 'released')),
released_at TIMESTAMPTZ,
transaction_hash TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (tutor_id, escrow_id),
CHECK (released_at IS NULL OR state = 'released')
);

CREATE INDEX IF NOT EXISTS tutor_escrow_entries_tutor_state_idx ON tutor_escrow_entries (tutor_id, state);
-- Drives the unlock job's "what is due?" scan.
CREATE INDEX IF NOT EXISTS tutor_escrow_entries_state_unlocks_at_idx ON tutor_escrow_entries (state, unlocks_at);

-- ── Withdrawals ─────────────────────────────────────────────────────────────

CREATE TABLE IF NOT EXISTS tutor_escrow_withdrawals (
withdrawal_id TEXT PRIMARY KEY,
tutor_id TEXT NOT NULL,
amount_stroops BIGINT NOT NULL CHECK (amount_stroops > 0),
withdrawn_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
transaction_hash TEXT
);

CREATE INDEX IF NOT EXISTS tutor_escrow_withdrawals_tutor_idx ON tutor_escrow_withdrawals (tutor_id);

-- ── Line items: which escrow entries a withdrawal consumed ─────────────────

CREATE TABLE IF NOT EXISTS tutor_escrow_withdrawal_entries (
withdrawal_id TEXT NOT NULL REFERENCES tutor_escrow_withdrawals(withdrawal_id) ON DELETE CASCADE,
escrow_id TEXT NOT NULL REFERENCES tutor_escrow_entries(escrow_id),
amount_stroops BIGINT NOT NULL CHECK (amount_stroops > 0),
PRIMARY KEY (withdrawal_id, escrow_id)
);
1 change: 1 addition & 0 deletions BackendAcademy/scripts/db-migrate.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ if (!databaseUrl) {
const migrations = [
'database/migrations/001_gamification_chat.sql',
'database/migrations/002_courses_prerequisites.sql',
'database/migrations/003_tutor_escrow.sql',
];

for (const migration of migrations) {
Expand Down
2 changes: 2 additions & 0 deletions BackendAcademy/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { SandboxModule } from './sandbox/sandbox.module';
import { ProgressModule } from './progress/progress.module';
import { CoursesModule } from './courses/courses.module';
import { CertificatesModule } from './certificates/certificates.module';
import { UsersModule } from './users/users.module';

// Root application module: wires together global configuration/guards,
// every feature module, and the top-level controllers/providers.
Expand Down Expand Up @@ -46,6 +47,7 @@ import { CertificatesModule } from './certificates/certificates.module';
ProgressModule,
CoursesModule,
CertificatesModule,
UsersModule,
],
// Top-level controllers not owned by a specific feature module:
// AppController (root/basic routes) and HealthController
Expand Down
135 changes: 135 additions & 0 deletions BackendAcademy/src/chat/chat-room.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';

/**
* Chat rooms with membership and paginated history (BE-094).
*
* In-memory like the other services so a repository can be swapped in later.
* The service is the source of truth for room membership: a user must join a
* room before its history is meaningful to them, and leaving stops their
* membership but keeps the message history intact for everyone else.
*/

export interface ChatRoom {
id: string;
name: string;
topic: string;
description: string;
createdAt: string;
}

export interface RoomMessage {
id: string;
roomId: string;
userId: string;
content: string;
createdAt: string;
}

export interface Paginated<T> {
items: T[];
page: number;
limit: number;
total: number;
}

interface PaginationOptions {
page?: number;
limit?: number;
}

const DEFAULT_PAGE_SIZE = 20;

@Injectable()
export class ChatRoomService {
private readonly rooms = new Map<string, ChatRoom>();
private readonly members = new Map<string, Set<string>>();
private readonly messages = new Map<string, RoomMessage[]>();

private nextRoomId = 1;
private nextMessageId = 1;

createRoom(input: { name: string; topic: string; description: string }): ChatRoom {
if (!input.name || !input.name.startsWith('#')) {
throw new BadRequestException('Room name must start with "#"');
}

const room: ChatRoom = {
id: `room_${this.nextRoomId++}`,
name: input.name,
topic: input.topic,
description: input.description,
createdAt: new Date().toISOString(),
};
this.rooms.set(room.id, room);
this.members.set(room.id, new Set());
this.messages.set(room.id, []);
return room;
}

getRoom(roomId: string): ChatRoom {
const room = this.rooms.get(roomId);
if (!room) throw new NotFoundException(`Room ${roomId} not found`);
return room;
}

listRooms(options: PaginationOptions = {}): Paginated<ChatRoom & { memberCount: number }> {
const { page = 1, limit = DEFAULT_PAGE_SIZE } = options;
const allRooms = [...this.rooms.values()].map((room) => ({
...room,
memberCount: this.members.get(room.id)?.size ?? 0,
}));

return paginate(allRooms, page, limit);
}

joinRoom(roomId: string, userId: string): void {
this.getRoom(roomId);
this.members.get(roomId)!.add(userId);
}

leaveRoom(roomId: string, userId: string): boolean {
this.getRoom(roomId);
return this.members.get(roomId)!.delete(userId);
}

getRoomMembers(roomId: string): string[] {
this.getRoom(roomId);
return [...(this.members.get(roomId) ?? [])];
}

addMessage(roomId: string, input: { userId: string; content: string }): RoomMessage {
this.getRoom(roomId);

const message: RoomMessage = {
id: `msg_${this.nextMessageId++}`,
roomId,
userId: input.userId,
content: input.content,
createdAt: new Date().toISOString(),
};
this.messages.get(roomId)!.push(message);
return message;
}

/** Newest page first, matching how chat history is read back in the UI. */
getRoomHistory(roomId: string, options: PaginationOptions = {}): Paginated<RoomMessage> {
this.getRoom(roomId);
const { page = 1, limit = DEFAULT_PAGE_SIZE } = options;
const history = [...(this.messages.get(roomId) ?? [])].reverse();

return paginate(history, page, limit);
}
}

function paginate<T>(items: T[], page: number, limit: number): Paginated<T> {
const safePage = Math.max(1, Math.trunc(page));
const safeLimit = Math.min(Math.max(1, Math.trunc(limit)), 100);
const start = (safePage - 1) * safeLimit;

return {
items: items.slice(start, start + safeLimit),
page: safePage,
limit: safeLimit,
total: items.length,
};
}
12 changes: 11 additions & 1 deletion BackendAcademy/src/chat/moderation.service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,14 @@
import { ForbiddenException, Injectable, TooManyRequestsException } from '@nestjs/common';
import { ForbiddenException, HttpException, HttpStatus, Injectable } from '@nestjs/common';

/**
* NestJS 10 ships no built-in 429 exception class, so the chat module defines
* the one it needs (BE-094 chat rate limiting).
*/
export class TooManyRequestsException extends HttpException {
constructor(message = 'Too many requests') {
super(message, HttpStatus.TOO_MANY_REQUESTS);
}
}

export interface ChatMessage {
id: string;
Expand Down
55 changes: 0 additions & 55 deletions BackendAcademy/src/chat/registration.ts

This file was deleted.

Loading