2626from app .blueprints .rest .endpoints import response_api_success
2727from app .blueprints .rest .v2 .war_rooms .access import require_war_room_read
2828from app .blueprints .rest .v2 .war_rooms .access import require_war_room_write
29+ from app .business .war_room_chat import archive_topic
2930from app .business .war_room_chat import close_poll
3031from app .business .war_room_chat import create_message
3132from app .business .war_room_chat import create_poll
3233from app .business .war_room_chat import create_reply
34+ from app .business .war_room_chat import create_topic
3335from app .business .war_room_chat import delete_message
3436from app .business .war_room_chat import follow_thread
3537from app .business .war_room_chat import get_poll_by_message_id
4042from app .business .war_room_chat import list_reactions
4143from app .business .war_room_chat import list_replies
4244from app .business .war_room_chat import list_thread_roots
45+ from app .business .war_room_chat import list_topics
4346from app .business .war_room_chat import parse_slash
4447from app .business .war_room_chat import set_message_pin
4548from app .business .war_room_chat import set_thread_title
4649from app .business .war_room_chat import toggle_reaction
50+ from app .business .war_room_chat import unarchive_topic
4751from app .business .war_room_chat import unfollow_thread
4852from app .business .war_room_chat import update_message
4953from 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+
120141def _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