Skip to content

Commit 20c2e4f

Browse files
VisenPclaudeAntony1060
authored
Replace contest Q&A with threaded message system (#127)
* Replace simple Q&A with threaded message system for contest questions Contest questions now support multi-message threads between contestants and management instead of a single question/response pair. Each thread has its own dedicated page with message history and reply form. Backend: new contest_chat_messages table, GET/POST message endpoints, author name resolution on responses, last_message_member_id for status tracking. Existing questions migrated to chat messages in migration 53. Frontend: member-side thread list with dedicated thread page, management-side table with status/member columns and thread detail page. Messages color-coded by role (contestant vs management). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Address PR review feedback - Fix "waiting" indicator to use last_message_member_id instead of last_message_at (which is always set after migration) - Copy arrays before sorting to avoid mutating react-query cache - Use BigInt comparison for message sorting instead of Number() cast - Batch member lookup with eqIn instead of N+1 selectOneFrom queries - Derive contest from thread.contest_id instead of route param to prevent wrong contest name in notifications - Use getSnowflakeTime() in migration instead of hardcoded epoch - Remove legacy PATCH endpoint (no old clients to support) - Remove unused selfMemberId prop from ContestChatSection Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Address second round of PR review feedback - Remove hardcoded snowflake bit-shift fallback in frontend sorting, use BigInt comparison instead - Use generateSnowflake() for response message IDs in migration instead of question.id + 1n which risks primary key collision - Allow admins (VIEW_CONTEST) to access GET messages endpoint without being a contest member, mirroring the list endpoint behavior - Allow admins (EDIT_CONTEST) to send messages via POST endpoint without contest membership - Add minLength: 1 to MessageSchema to prevent empty messages - Convert users array to Map for O(1) lookups in name resolution Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Address third round of PR review feedback - Remove admin-without-membership path in POST messages — admins must be contest members to send messages (management UI already requires VIEW_PRIVATE membership). This avoids storing user.id as author_member_id which breaks name resolution. - Add fallback in name resolution: if author_member_id not found in contest_members, try looking it up directly in users table. - Drop showAllUsers: true from useAllContestMembers calls in management pages to avoid query key cache collisions with other pages using the same hook with default options. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * thing --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: Antonio F. Trstenjak <antoniostignjedec@gmail.com>
1 parent 41c07c4 commit 20c2e4f

18 files changed

Lines changed: 867 additions & 290 deletions

File tree

apps/backend/src/database/Database.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {
22
Cluster,
33
ContestAnnouncement,
4+
ContestChatMessage,
45
ContestMember,
56
ContestQuestion,
67
EduUser,
@@ -76,6 +77,7 @@ import { migration_contest_show_leaderboard } from "./migrations/0049_contest_sh
7677
import { migration_improve_generators } from "./migrations/0050_improve_generators";
7778
import { migration_add_sample_clusters } from "./migrations/0051_add_sample_clusters";
7879
import { migration_generator_id_index } from "./migrations/0052_generator_id_index";
80+
import { migration_contest_chat_messages } from "./migrations/0053_contest_chat_messages";
7981

8082
export const Database = new ScylloClient<{
8183
users: User;
@@ -88,6 +90,7 @@ export const Database = new ScylloClient<{
8890
testcase_submissions: TestcaseSubmission;
8991
contest_members: ContestMember;
9092
contest_questions: ContestQuestion;
93+
contest_chat_messages: ContestChatMessage;
9194
contest_announcements: ContestAnnouncement;
9295
organisations: Organisation;
9396
organisation_members: OrganisationMember;
@@ -163,6 +166,7 @@ const migrations: Migration<any>[] = [
163166
migration_improve_generators,
164167
migration_add_sample_clusters,
165168
migration_generator_id_index,
169+
migration_contest_chat_messages,
166170
];
167171

168172
export const initDatabase = async () => {
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { ContestChatMessageV1, ContestQuestionV2 } from "@kontestis/models";
2+
import { Migration } from "scyllo";
3+
4+
import { generateSnowflake, getSnowflakeTime } from "../../lib/snowflake";
5+
6+
type MigrationType = {
7+
contest_chat_messages: ContestChatMessageV1;
8+
contest_questions: ContestQuestionV2;
9+
};
10+
11+
export const migration_contest_chat_messages: Migration<MigrationType> = async (database, log) => {
12+
// Create chat messages table
13+
await database.createTable(
14+
"contest_chat_messages",
15+
true,
16+
{
17+
id: { type: "bigint" },
18+
thread_id: { type: "bigint" },
19+
contest_id: { type: "bigint" },
20+
author_member_id: { type: "bigint" },
21+
content: { type: "text" },
22+
created_at: { type: "timestamp" },
23+
},
24+
"id"
25+
);
26+
27+
await database.createIndex(
28+
"contest_chat_messages",
29+
"contest_chat_messages_by_thread_id",
30+
"thread_id"
31+
);
32+
33+
await database.createIndex(
34+
"contest_chat_messages",
35+
"contest_chat_messages_by_contest_id",
36+
"contest_id"
37+
);
38+
39+
// Add new columns to contest_questions
40+
await database.raw("ALTER TABLE contest_questions ADD last_message_at timestamp");
41+
await database.raw("ALTER TABLE contest_questions ADD last_message_member_id bigint");
42+
43+
// Migrate existing questions into chat messages
44+
const questions = await database.selectFrom("contest_questions", "*", {});
45+
46+
for (const question of questions) {
47+
const questionTime = getSnowflakeTime(question.id);
48+
49+
// Insert the question text as the first message
50+
await database.insertInto("contest_chat_messages", {
51+
id: question.id,
52+
thread_id: question.id,
53+
contest_id: question.contest_id,
54+
author_member_id: question.contest_member_id,
55+
content: question.question,
56+
created_at: questionTime,
57+
});
58+
59+
let lastMessageAt = questionTime;
60+
let lastMessageMemberId = question.contest_member_id;
61+
62+
// Insert the response as a second message if it exists
63+
if (question.response && question.response_author_id) {
64+
const responseTime = new Date(questionTime.getTime() + 1000);
65+
66+
await database.insertInto("contest_chat_messages", {
67+
id: generateSnowflake(),
68+
thread_id: question.id,
69+
contest_id: question.contest_id,
70+
author_member_id: question.response_author_id,
71+
content: question.response,
72+
created_at: responseTime,
73+
});
74+
75+
lastMessageAt = responseTime;
76+
lastMessageMemberId = question.response_author_id;
77+
}
78+
79+
await database.update(
80+
"contest_questions",
81+
{ last_message_at: lastMessageAt, last_message_member_id: lastMessageMemberId },
82+
{ id: question.id }
83+
);
84+
}
85+
86+
log(`Migrated ${questions.length} existing questions to chat messages`);
87+
log("Done");
88+
};

0 commit comments

Comments
 (0)