Skip to content

Commit 588f29e

Browse files
author
whitekernel
committed
[ADD] War room chat topics — model, migration, /topic slash, REST endpoints
1 parent 77ce197 commit 588f29e

4 files changed

Lines changed: 533 additions & 7 deletions

File tree

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
"""Add war_room_topic table + topic_id column on war_room_chat_message.
2+
3+
Topics are top-level partitions of the chat stream — each war room
4+
has one non-archivable "Main" topic (created lazily by the business
5+
layer on first read) plus any number of operator-created ones. A
6+
message with `topic_id IS NULL` is on Main; the read path treats
7+
NULL and the Main topic row as equivalent so pre-migration rows keep
8+
working without a backfill.
9+
10+
Archive is soft (`archived_at`). No hard delete — archived topics
11+
still render (read-only) in the sidebar.
12+
13+
Idempotent via `_has_table` / `_table_has_column` so re-running on
14+
environments that already applied the migration is a no-op.
15+
16+
Revision ID: c3d4e5f6a9b0
17+
Revises: f3c8d2a1b47e
18+
Create Date: 2026-07-09 10:00:00.000000
19+
"""
20+
import sqlalchemy as sa
21+
from alembic import op
22+
23+
from app.alembic.alembic_utils import _has_table, _table_has_column
24+
25+
26+
revision = 'c3d4e5f6a9b0'
27+
down_revision = 'f3c8d2a1b47e'
28+
branch_labels = None
29+
depends_on = None
30+
31+
32+
def upgrade():
33+
if not _has_table('war_room_topic'):
34+
op.create_table(
35+
'war_room_topic',
36+
sa.Column('topic_id', sa.BigInteger(), primary_key=True),
37+
sa.Column(
38+
'war_room_id', sa.BigInteger(),
39+
sa.ForeignKey('war_room.war_room_id', ondelete='CASCADE'),
40+
nullable=False,
41+
),
42+
sa.Column('name', sa.String(length=80), nullable=False),
43+
sa.Column(
44+
'is_main', sa.Boolean(), nullable=False,
45+
server_default=sa.text('false'),
46+
),
47+
sa.Column(
48+
'created_by_id', sa.BigInteger(),
49+
sa.ForeignKey('user.id'), nullable=True,
50+
),
51+
sa.Column(
52+
'created_at', sa.DateTime(), nullable=False,
53+
server_default=sa.text('now()'),
54+
),
55+
sa.Column('archived_at', sa.DateTime(), nullable=True),
56+
sa.UniqueConstraint('war_room_id', 'name',
57+
name='uq_war_room_topic_name'),
58+
)
59+
op.create_index(
60+
'ix_war_room_topic_war_room_id',
61+
'war_room_topic',
62+
['war_room_id'],
63+
)
64+
65+
if _has_table('war_room_chat_message'):
66+
if not _table_has_column('war_room_chat_message', 'topic_id'):
67+
op.add_column(
68+
'war_room_chat_message',
69+
sa.Column(
70+
'topic_id', sa.BigInteger(),
71+
sa.ForeignKey(
72+
'war_room_topic.topic_id', ondelete='SET NULL'
73+
),
74+
nullable=True,
75+
),
76+
)
77+
op.create_index(
78+
'ix_war_room_chat_message_topic_id',
79+
'war_room_chat_message',
80+
['topic_id'],
81+
)
82+
83+
84+
def downgrade():
85+
if _has_table('war_room_chat_message'):
86+
if _table_has_column('war_room_chat_message', 'topic_id'):
87+
op.drop_index(
88+
'ix_war_room_chat_message_topic_id',
89+
table_name='war_room_chat_message',
90+
)
91+
op.drop_column('war_room_chat_message', 'topic_id')
92+
93+
if _has_table('war_room_topic'):
94+
op.drop_index(
95+
'ix_war_room_topic_war_room_id',
96+
table_name='war_room_topic',
97+
)
98+
op.drop_table('war_room_topic')

source/app/blueprints/rest/v2/war_rooms/chat.py

Lines changed: 160 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,12 @@
2626
from app.blueprints.rest.endpoints import response_api_success
2727
from app.blueprints.rest.v2.war_rooms.access import require_war_room_read
2828
from app.blueprints.rest.v2.war_rooms.access import require_war_room_write
29+
from app.business.war_room_chat import archive_topic
2930
from app.business.war_room_chat import close_poll
3031
from app.business.war_room_chat import create_message
3132
from app.business.war_room_chat import create_poll
3233
from app.business.war_room_chat import create_reply
34+
from app.business.war_room_chat import create_topic
3335
from app.business.war_room_chat import delete_message
3436
from app.business.war_room_chat import follow_thread
3537
from app.business.war_room_chat import get_poll_by_message_id
@@ -40,10 +42,12 @@
4042
from app.business.war_room_chat import list_reactions
4143
from app.business.war_room_chat import list_replies
4244
from app.business.war_room_chat import list_thread_roots
45+
from app.business.war_room_chat import list_topics
4346
from app.business.war_room_chat import parse_slash
4447
from app.business.war_room_chat import set_message_pin
4548
from app.business.war_room_chat import set_thread_title
4649
from app.business.war_room_chat import toggle_reaction
50+
from app.business.war_room_chat import unarchive_topic
4751
from app.business.war_room_chat import unfollow_thread
4852
from app.business.war_room_chat import update_message
4953
from app.business.war_room_chat import vote_on_poll
@@ -91,6 +95,9 @@ def _serialize(row, reactions=None, viewer_id=None):
9195
# `getattr` fallback lets the route survive a boot where the
9296
# migration hasn't been applied yet.
9397
'is_pinned': bool(getattr(row, 'is_pinned', False)),
98+
# `topic_id` mirrors the same pre-migration guard as `is_pinned`.
99+
# NULL is normal — it means the message is on Main.
100+
'topic_id': getattr(row, 'topic_id', None),
94101
'created_at': row.created_at.isoformat() if row.created_at else None,
95102
'edited_at': row.edited_at.isoformat() if row.edited_at else None,
96103
'deleted_at': row.deleted_at.isoformat() if row.deleted_at else None,
@@ -117,6 +124,20 @@ def _serialize(row, reactions=None, viewer_id=None):
117124
return payload
118125

119126

127+
def _serialize_topic(row):
128+
return {
129+
'topic_id': row.topic_id,
130+
'war_room_id': row.war_room_id,
131+
'name': row.name,
132+
'is_main': bool(row.is_main),
133+
'created_by_id': row.created_by_id,
134+
'created_at': row.created_at.isoformat() if row.created_at else None,
135+
'archived_at': (
136+
row.archived_at.isoformat() if row.archived_at else None
137+
),
138+
}
139+
140+
120141
def _emit_socket(war_room_id, event_name, payload):
121142
"""Best-effort fan-out to the war-room socket channel.
122143
@@ -160,8 +181,17 @@ def list_chat(war_room_id):
160181
except ValueError:
161182
return response_api_error('Invalid case_ids')
162183

184+
topic_ids_raw = request.args.get('topic_ids', type=str)
185+
topic_ids = None
186+
if topic_ids_raw is not None:
187+
try:
188+
topic_ids = [int(x) for x in topic_ids_raw.split(',') if x.strip()]
189+
except ValueError:
190+
return response_api_error('Invalid topic_ids')
191+
163192
rows = list_messages(war_room_id, before=before, limit=limit,
164-
kinds=kinds, case_ids=case_ids, search=search)
193+
kinds=kinds, case_ids=case_ids, search=search,
194+
topic_ids=topic_ids)
165195
reactions = list_reactions([r.message_id for r in rows])
166196
viewer_id = iris_current_user.id
167197
return response_api_success(
@@ -364,6 +394,29 @@ def _resolve_slash(war_room_id, cmd, rest):
364394
'sitrep', sit.sitrep_id, None,
365395
)
366396

397+
if cmd == 'topic':
398+
# `/topic <name>` — create (or switch to) a top-level topic.
399+
# Rides through the normal system-message pipeline with a
400+
# sentinel `ref_type` so the post handler can create the topic
401+
# in the same request and echo its id back to the caller.
402+
from app.business.war_room_chat import _topics_supported
403+
if not _topics_supported():
404+
raise BusinessProcessingError(
405+
'Topics are not enabled on this server yet — '
406+
'apply the latest migrations.'
407+
)
408+
name = rest.strip()
409+
if not name:
410+
raise BusinessProcessingError('Usage: /topic <name>')
411+
if len(name) > 80:
412+
raise BusinessProcessingError(
413+
'Topic name must be at most 80 characters'
414+
)
415+
# Sentinel `ref_type` is consumed by post_chat which calls
416+
# `create_topic` and appends the topic id onto the response.
417+
return ('system', f'Opened topic #{name}',
418+
'__create_topic__', None, None)
419+
367420
if cmd == 'thread':
368421
# `/thread <title>` — open a named topic the team can rally
369422
# replies under. The resolved row becomes a normal `message` with
@@ -391,7 +444,7 @@ def _resolve_slash(war_room_id, cmd, rest):
391444
if cmd in ('help', '?'):
392445
body = (
393446
'Commands: /note /pin /decision /attach /detach /task /assign '
394-
'/sitrep /summary /state /priority /thread'
447+
'/sitrep /summary /state /priority /thread /topic'
395448
)
396449
return ('system', body, None, None, None)
397450

@@ -442,6 +495,15 @@ def post_chat(war_room_id):
442495
body = raw.get('body')
443496
if not isinstance(body, str):
444497
return response_api_error('body is required')
498+
# Optional `topic_id` — the currently-selected topic in the SPA.
499+
# NULL means Main. The business layer validates ownership /
500+
# archived state and raises `BusinessProcessingError` on mismatch.
501+
posted_topic_id = raw.get('topic_id')
502+
if posted_topic_id is not None:
503+
try:
504+
posted_topic_id = int(posted_topic_id)
505+
except (TypeError, ValueError):
506+
return response_api_error('Invalid topic_id')
445507

446508
slash = parse_slash(body)
447509
if slash is not None:
@@ -484,18 +546,48 @@ def post_chat(war_room_id):
484546
wants_thread = ref_type == '__set_thread_title__'
485547
if wants_thread:
486548
ref_type = None
549+
# `/topic <name>` uses a symmetrical sentinel — we create
550+
# the topic first, drop the message on it (so the "Opened
551+
# topic #X" system row is anchored under it), and echo the
552+
# new topic id back so the SPA can auto-switch its view.
553+
wants_topic = ref_type == '__create_topic__'
554+
created_topic = None
555+
if wants_topic:
556+
ref_type = None
557+
# The topic name is the tail of the resolved body — we
558+
# parsed it into the "Opened topic #<name>" template.
559+
topic_name = body.split('#', 1)[1] if '#' in body else body
560+
try:
561+
created_topic = create_topic(
562+
war_room_id, topic_name, iris_current_user.id
563+
)
564+
except BusinessProcessingError as e:
565+
return response_api_error(e.get_message())
566+
# Slash-command system rows normally sit on the posted topic
567+
# (defaulting to the current view). `/topic` anchors its
568+
# system row on the newly-created topic instead so the
569+
# "Opened topic #X" line is the first row in the new view.
570+
slash_topic_id = (
571+
created_topic.topic_id if created_topic is not None
572+
else posted_topic_id
573+
)
487574
try:
488575
msg = create_message(
489576
war_room_id, iris_current_user.id, body,
490577
kind=kind, ref_type=ref_type, ref_id=ref_id,
491-
ref_case_id=ref_case_id,
578+
ref_case_id=ref_case_id, topic_id=slash_topic_id,
492579
)
493580
if wants_thread:
494581
set_thread_title(war_room_id, msg.message_id, body)
495582
except BusinessProcessingError as e:
496583
return response_api_error(e.get_message())
497584
_emit_socket(war_room_id, 'message:new', {'message_id': msg.message_id})
498-
return response_api_created({'message_id': msg.message_id, 'kind': msg.kind})
585+
payload = {'message_id': msg.message_id, 'kind': msg.kind}
586+
if created_topic is not None:
587+
payload['topic'] = _serialize_topic(created_topic)
588+
_emit_socket(war_room_id, 'topic:new',
589+
{'topic_id': created_topic.topic_id})
590+
return response_api_created(payload)
499591

500592
# Unknown command. Returning an explicit 400 — instead of
501593
# falling through to `create_message(body)` and storing the
@@ -508,7 +600,8 @@ def post_chat(war_room_id):
508600
)
509601

510602
try:
511-
msg = create_message(war_room_id, iris_current_user.id, body)
603+
msg = create_message(war_room_id, iris_current_user.id, body,
604+
topic_id=posted_topic_id)
512605
except BusinessProcessingError as e:
513606
return response_api_error(e.get_message())
514607

@@ -584,6 +677,68 @@ def pin_chat(war_room_id, message_id):
584677
'is_pinned': bool(raw['is_pinned'])})
585678

586679

680+
# ----- Topics --------------------------------------------------------------
681+
682+
683+
@war_rooms_chat_blueprint.get('/topics')
684+
@ac_api_requires()
685+
def list_topics_route(war_room_id):
686+
err = require_war_room_read(war_room_id)
687+
if err is not None:
688+
return err
689+
rows = list_topics(war_room_id, include_archived=True)
690+
return response_api_success(data=[_serialize_topic(r) for r in rows])
691+
692+
693+
@war_rooms_chat_blueprint.post('/topics')
694+
@ac_api_requires()
695+
def create_topic_route(war_room_id):
696+
err = require_war_room_write(war_room_id)
697+
if err is not None:
698+
return err
699+
raw = request.get_json()
700+
if not isinstance(raw, dict):
701+
return response_api_error('Invalid request')
702+
try:
703+
row = create_topic(war_room_id, raw.get('name'), iris_current_user.id)
704+
except BusinessProcessingError as e:
705+
return response_api_error(e.get_message())
706+
_emit_socket(war_room_id, 'topic:new', {'topic_id': row.topic_id})
707+
return response_api_created(_serialize_topic(row))
708+
709+
710+
@war_rooms_chat_blueprint.post('/topics/<int:topic_id>/archive')
711+
@ac_api_requires()
712+
def archive_topic_route(war_room_id, topic_id):
713+
err = require_war_room_write(war_room_id)
714+
if err is not None:
715+
return err
716+
try:
717+
row = archive_topic(war_room_id, topic_id)
718+
except ObjectNotFoundError:
719+
return response_api_not_found()
720+
except BusinessProcessingError as e:
721+
return response_api_error(e.get_message())
722+
_emit_socket(war_room_id, 'topic:archive', {'topic_id': topic_id})
723+
return response_api_success(_serialize_topic(row))
724+
725+
726+
@war_rooms_chat_blueprint.post('/topics/<int:topic_id>/unarchive')
727+
@ac_api_requires()
728+
def unarchive_topic_route(war_room_id, topic_id):
729+
err = require_war_room_write(war_room_id)
730+
if err is not None:
731+
return err
732+
try:
733+
row = unarchive_topic(war_room_id, topic_id)
734+
except ObjectNotFoundError:
735+
return response_api_not_found()
736+
except BusinessProcessingError as e:
737+
return response_api_error(e.get_message())
738+
_emit_socket(war_room_id, 'topic:unarchive', {'topic_id': topic_id})
739+
return response_api_success(_serialize_topic(row))
740+
741+
587742
# ----- Threads -------------------------------------------------------------
588743

589744

0 commit comments

Comments
 (0)