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
73 changes: 73 additions & 0 deletions server/src/routes/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -556,6 +556,79 @@ app.get("/:channelId/stats", (c) => {
});
});

// GET /channels/:channelId/threads — list all threads (messages with replies)
app.get("/:channelId/threads", (c) => {
const auth = c.get("auth");
const channelId = c.req.param("channelId");
const limit = Math.min(parseInt(c.req.query("limit") ?? "20", 10) || 20, 100);

const channel = getChannelInWorkspace(channelId, auth.workspaceId);
if (!channel) {
return c.json({ detail: "Channel not found" }, 404);
}

const db = getDb();

// Find messages that have replies (are parents)
const threads = db
.select({
parentId: messages.parentId,
replyCount: sql<number>`count(*)`.as("reply_count"),
lastReplyAt: sql<string>`max(${messages.createdAt})`.as("last_reply_at"),
})
.from(messages)
.where(
and(
eq(messages.channelId, channelId),
sql`${messages.parentId} IS NOT NULL`
)
)
.groupBy(messages.parentId)
.orderBy(sql`max(${messages.createdAt}) DESC`)
.limit(limit)
.all();

// Get the parent messages
const parentIds = threads.map((t) => t.parentId).filter(Boolean) as string[];
if (parentIds.length === 0) {
return c.json({ threads: [], count: 0 });
}

const parents = db
.select({
id: messages.id,
content: messages.content,
senderId: messages.senderId,
senderName: sql<string>`coalesce(${users.displayName}, ${users.name})`,
senderType: users.type,
createdAt: messages.createdAt,
})
.from(messages)
.innerJoin(users, eq(messages.senderId, users.id))
.where(sql`${messages.id} IN (${sql.join(parentIds.map((id) => sql`${id}`), sql`, `)})`)
.all();

const parentMap = new Map(parents.map((p) => [p.id, p]));

const result = threads
.map((t) => {
const parent = parentMap.get(t.parentId!);
if (!parent) return null;
return {
parent_id: parent.id,
parent_content: parent.content.slice(0, 200),
parent_sender_name: parent.senderName,
parent_sender_type: parent.senderType,
parent_created_at: parent.createdAt,
reply_count: t.replyCount,
last_reply_at: t.lastReplyAt,
};
})
.filter(Boolean);

return c.json({ threads: result, count: result.length });
});

// GET /channels/:channelId/mentionable
app.get("/:channelId/mentionable", (c) => {
const auth = c.get("auth");
Expand Down
95 changes: 95 additions & 0 deletions server/tests/channel-threads.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/**
* Tests for channel threads listing endpoint.
*/
import { describe, expect, it, beforeAll } from "bun:test";
import { Hono } from "hono";
import "./test-env";

let app: Hono;
let channelId: string;
let parentMsgId: string;

function req(method: string, path: string, body?: unknown) {
const opts: RequestInit = {
method,
headers: { "Content-Type": "application/json" },
};
if (body) opts.body = JSON.stringify(body);
return new Request(`http://localhost${path}`, opts);
}

beforeAll(async () => {
process.env.TALKTO_PORT = "0";
const mod = await import("../src/index");
app = mod.app;

await app.fetch(req("POST", "/api/users/onboard", {
name: "threads-test-user",
display_name: "Threads Tester",
}));

const chRes = await app.fetch(req("GET", "/api/channels"));
const channels = await chRes.json();
channelId = channels.find((c: any) => c.name === "#general")?.id;

// Create a parent message
const msgRes = await app.fetch(req("POST", `/api/channels/${channelId}/messages`, {
content: "parent message for thread test unique88",
}));
const msg = await msgRes.json();
parentMsgId = msg.id;

// Create replies
await app.fetch(req("POST", `/api/channels/${channelId}/messages`, {
content: "reply one to thread test",
parent_id: parentMsgId,
}));
await app.fetch(req("POST", `/api/channels/${channelId}/messages`, {
content: "reply two to thread test",
parent_id: parentMsgId,
}));
});

describe("Channel Threads", () => {
it("lists threads with reply counts", async () => {
const res = await app.fetch(req("GET", `/api/channels/${channelId}/threads`));
expect(res.status).toBe(200);
const data = await res.json();
expect(data.threads.length).toBeGreaterThanOrEqual(1);

const thread = data.threads.find((t: any) => t.parent_id === parentMsgId);
expect(thread).toBeDefined();
expect(thread.reply_count).toBe(2);
expect(thread.parent_sender_name).toBeDefined();
expect(thread.last_reply_at).toBeDefined();
});

it("returns empty for channel with no threads", async () => {
const chRes = await app.fetch(req("POST", "/api/channels", { name: "no-threads-chan" }));
const ch = await chRes.json();

const res = await app.fetch(req("GET", `/api/channels/${ch.id}/threads`));
const data = await res.json();
expect(data.threads).toBeArrayOfSize(0);
expect(data.count).toBe(0);
});

it("returns 404 for nonexistent channel", async () => {
const res = await app.fetch(req("GET", "/api/channels/nonexistent-id/threads"));
expect(res.status).toBe(404);
});

it("respects limit parameter", async () => {
const res = await app.fetch(req("GET", `/api/channels/${channelId}/threads?limit=1`));
const data = await res.json();
expect(data.threads.length).toBeLessThanOrEqual(1);
});

it("truncates long parent content", async () => {
const res = await app.fetch(req("GET", `/api/channels/${channelId}/threads`));
const data = await res.json();
for (const thread of data.threads) {
expect(thread.parent_content.length).toBeLessThanOrEqual(200);
}
});
});
Loading