Skip to content

Commit 61ad232

Browse files
author
whitekernel
committed
[ADD] war room teams (@-mention groups) + chat message attachments — WarRoomTeam models, /war-rooms/{id}/teams REST, team-mention notification fan-out, WarRoomChatMessage.attachments JSONB with datastore file refs
1 parent 09168fe commit 61ad232

9 files changed

Lines changed: 855 additions & 8 deletions

File tree

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
"""Add per-war-room teams and chat message attachments.
2+
3+
Feature 1: `war_room_team` + `war_room_team_member` support @team
4+
mentions in chat, threads, notes and tasks. Teams are per-war-room and
5+
cascade with the room.
6+
7+
Feature 2: `war_room_chat_message.attachments` is a JSONB column that
8+
holds inline file references (list of `{file_id, filename, mime_type,
9+
size_bytes}`) pointing at rows in `war_room_datastore_file`.
10+
11+
Idempotent via `_has_table` / `_table_has_column` so re-running is safe
12+
across environments that already applied the migration.
13+
14+
Revision ID: f7a3b9c1d02e
15+
Revises: d5e6f7a8b9c0
16+
Create Date: 2026-07-13 10:00:00.000000
17+
"""
18+
import sqlalchemy as sa
19+
from alembic import op
20+
from sqlalchemy.dialects import postgresql
21+
22+
from app.alembic.alembic_utils import _has_table, _table_has_column
23+
24+
25+
revision = 'f7a3b9c1d02e'
26+
down_revision = 'd5e6f7a8b9c0'
27+
branch_labels = None
28+
depends_on = None
29+
30+
31+
def upgrade():
32+
if not _has_table('war_room_team'):
33+
op.create_table(
34+
'war_room_team',
35+
sa.Column('team_id', sa.BigInteger(), primary_key=True),
36+
sa.Column(
37+
'war_room_id', sa.BigInteger(),
38+
sa.ForeignKey('war_room.war_room_id', ondelete='CASCADE'),
39+
nullable=False,
40+
),
41+
sa.Column('name', sa.String(length=80), nullable=False),
42+
sa.Column('description', sa.Text(), nullable=True),
43+
sa.Column('color', sa.String(length=7), nullable=True),
44+
sa.Column(
45+
'created_at', sa.DateTime(), nullable=False,
46+
server_default=sa.text('now()'),
47+
),
48+
sa.Column(
49+
'created_by_id', sa.BigInteger(),
50+
sa.ForeignKey('user.id'), nullable=True,
51+
),
52+
sa.UniqueConstraint('war_room_id', 'name',
53+
name='uq_war_room_team_name'),
54+
)
55+
op.create_index(
56+
'ix_war_room_team_war_room_id',
57+
'war_room_team',
58+
['war_room_id'],
59+
)
60+
61+
if not _has_table('war_room_team_member'):
62+
op.create_table(
63+
'war_room_team_member',
64+
sa.Column(
65+
'team_id', sa.BigInteger(),
66+
sa.ForeignKey('war_room_team.team_id', ondelete='CASCADE'),
67+
primary_key=True, nullable=False,
68+
),
69+
sa.Column(
70+
'user_id', sa.BigInteger(),
71+
sa.ForeignKey('user.id', ondelete='CASCADE'),
72+
primary_key=True, nullable=False,
73+
),
74+
sa.Column(
75+
'added_at', sa.DateTime(), nullable=False,
76+
server_default=sa.text('now()'),
77+
),
78+
sa.Column(
79+
'added_by_id', sa.BigInteger(),
80+
sa.ForeignKey('user.id'), nullable=True,
81+
),
82+
sa.UniqueConstraint('team_id', 'user_id',
83+
name='uq_war_room_team_member'),
84+
)
85+
86+
if _has_table('war_room_chat_message') \
87+
and not _table_has_column('war_room_chat_message', 'attachments'):
88+
op.add_column(
89+
'war_room_chat_message',
90+
sa.Column('attachments', postgresql.JSONB(), nullable=True),
91+
)
92+
93+
94+
def downgrade():
95+
if _has_table('war_room_chat_message') \
96+
and _table_has_column('war_room_chat_message', 'attachments'):
97+
op.drop_column('war_room_chat_message', 'attachments')
98+
99+
if _has_table('war_room_team_member'):
100+
op.drop_table('war_room_team_member')
101+
102+
if _has_table('war_room_team'):
103+
op.drop_index(
104+
'ix_war_room_team_war_room_id',
105+
table_name='war_room_team',
106+
)
107+
op.drop_table('war_room_team')

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

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,10 @@ def _serialize(row, reactions=None, viewer_id=None):
102102
'edited_at': row.edited_at.isoformat() if row.edited_at else None,
103103
'deleted_at': row.deleted_at.isoformat() if row.deleted_at else None,
104104
'reactions': reactions or [],
105+
# Inline file attachments. May be missing on databases predating
106+
# the migration or on virtual UserActivity rows — default to an
107+
# empty list so the SPA can always iterate.
108+
'attachments': list(getattr(row, 'attachments', None) or []),
105109
}
106110

107111
# Inline the poll payload on poll-kind messages so the stream
@@ -493,8 +497,15 @@ def post_chat(war_room_id):
493497
if not isinstance(raw, dict):
494498
return response_api_error('Invalid request')
495499
body = raw.get('body')
500+
file_ids = raw.get('file_ids') or []
496501
if not isinstance(body, str):
497-
return response_api_error('body is required')
502+
# An attachment-only post is legal — coerce a missing body to
503+
# empty string; `create_message` will accept it when
504+
# `file_ids` is non-empty.
505+
if file_ids:
506+
body = ''
507+
else:
508+
return response_api_error('body is required')
498509
# Optional `topic_id` — the currently-selected topic in the SPA.
499510
# NULL means Main. The business layer validates ownership /
500511
# archived state and raises `BusinessProcessingError` on mismatch.
@@ -601,7 +612,8 @@ def post_chat(war_room_id):
601612

602613
try:
603614
msg = create_message(war_room_id, iris_current_user.id, body,
604-
topic_id=posted_topic_id)
615+
topic_id=posted_topic_id,
616+
file_ids=file_ids)
605617
except BusinessProcessingError as e:
606618
return response_api_error(e.get_message())
607619

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@
7474
from app.blueprints.rest.v2.war_rooms.notes_folders import war_rooms_notes_folders_blueprint
7575
from app.blueprints.rest.v2.war_rooms.sitreps import war_rooms_sitreps_blueprint
7676
from app.blueprints.rest.v2.war_rooms.tasks import war_rooms_tasks_blueprint
77+
from app.blueprints.rest.v2.war_rooms.teams import war_rooms_teams_blueprint
7778
from app.blueprints.rest.v2.war_rooms.timelines import war_rooms_timelines_blueprint
7879

7980

@@ -88,6 +89,7 @@
8889
war_rooms_blueprint.register_blueprint(war_rooms_timelines_blueprint)
8990
war_rooms_blueprint.register_blueprint(war_rooms_sitreps_blueprint)
9091
war_rooms_blueprint.register_blueprint(war_rooms_datastore_blueprint)
92+
war_rooms_blueprint.register_blueprint(war_rooms_teams_blueprint)
9193

9294

9395
def _is_admin():
Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
# IRIS Source Code
2+
# Copyright (C) 2026 - DFIR-IRIS
3+
# contact@dfir-iris.org
4+
#
5+
# This program is free software; you can redistribute it and/or
6+
# modify it under the terms of the GNU Lesser General Public
7+
# License as published by the Free Software Foundation; either
8+
# version 3 of the License, or (at your option) any later version.
9+
10+
"""v2 REST routes for war-room teams.
11+
12+
Teams are per-war-room groupings used as @-mention targets. The URL
13+
layout mirrors members:
14+
15+
GET /<war_room_id>/teams list teams
16+
POST /<war_room_id>/teams create a team
17+
GET /<war_room_id>/teams/<tid> get a team + its members
18+
PATCH /<war_room_id>/teams/<tid> rename / recolor
19+
DELETE /<war_room_id>/teams/<tid> remove
20+
POST /<war_room_id>/teams/<tid>/members add a user
21+
DELETE /<war_room_id>/teams/<tid>/members/<uid> remove
22+
"""
23+
24+
from flask import Blueprint, request
25+
26+
from app.blueprints.access_controls import ac_api_requires
27+
from app.blueprints.iris_user import iris_current_user
28+
from app.blueprints.rest.endpoints import response_api_created
29+
from app.blueprints.rest.endpoints import response_api_deleted
30+
from app.blueprints.rest.endpoints import response_api_error
31+
from app.blueprints.rest.endpoints import response_api_not_found
32+
from app.blueprints.rest.endpoints import response_api_success
33+
from app.blueprints.rest.v2.war_rooms.access import require_war_room_read
34+
from app.blueprints.rest.v2.war_rooms.access import require_war_room_write
35+
from app.business.war_room_teams import war_room_team_create
36+
from app.business.war_room_teams import war_room_team_delete
37+
from app.business.war_room_teams import war_room_team_get
38+
from app.business.war_room_teams import war_room_team_list
39+
from app.business.war_room_teams import war_room_team_member_add
40+
from app.business.war_room_teams import war_room_team_member_remove
41+
from app.business.war_room_teams import war_room_team_members_list
42+
from app.business.war_room_teams import war_room_team_update
43+
from app.models.errors import BusinessProcessingError
44+
from app.models.errors import ObjectNotFoundError
45+
46+
47+
war_rooms_teams_blueprint = Blueprint(
48+
'war_rooms_teams_rest_v2', __name__, url_prefix='/<int:war_room_id>/teams'
49+
)
50+
51+
52+
def _serialize_team(team, member_ids=None):
53+
return {
54+
'team_id': team.team_id,
55+
'war_room_id': team.war_room_id,
56+
'name': team.name,
57+
'description': team.description,
58+
'color': team.color,
59+
'created_at': team.created_at.isoformat() if team.created_at else None,
60+
'created_by_id': team.created_by_id,
61+
'member_ids': sorted(member_ids) if member_ids is not None else None,
62+
}
63+
64+
65+
def _serialize_member(row):
66+
return {
67+
'team_id': row.team_id,
68+
'user_id': row.user_id,
69+
'added_at': row.added_at.isoformat() if row.added_at else None,
70+
'added_by_id': row.added_by_id,
71+
}
72+
73+
74+
def _team_member_ids(team_id):
75+
"""User IDs for a team — skips the war-room scope check because the
76+
caller has already resolved the team belongs to this war room."""
77+
from app.db import db
78+
from app.models.war_rooms import WarRoomTeamMember
79+
rows = (
80+
db.session.query(WarRoomTeamMember.user_id)
81+
.filter(WarRoomTeamMember.team_id == team_id)
82+
.all()
83+
)
84+
return {r.user_id for r in rows}
85+
86+
87+
@war_rooms_teams_blueprint.get('')
88+
@ac_api_requires()
89+
def list_teams(war_room_id):
90+
err = require_war_room_read(war_room_id)
91+
if err is not None:
92+
return err
93+
teams = war_room_team_list(war_room_id)
94+
data = [
95+
_serialize_team(t, _team_member_ids(t.team_id))
96+
for t in teams
97+
]
98+
return response_api_success(data=data)
99+
100+
101+
@war_rooms_teams_blueprint.post('')
102+
@ac_api_requires()
103+
def create_team(war_room_id):
104+
err = require_war_room_write(war_room_id)
105+
if err is not None:
106+
return err
107+
raw = request.get_json()
108+
if not isinstance(raw, dict):
109+
return response_api_error('Invalid request')
110+
try:
111+
team = war_room_team_create(
112+
war_room_id,
113+
name=raw.get('name'),
114+
description=raw.get('description'),
115+
color=raw.get('color'),
116+
created_by_id=iris_current_user.id,
117+
)
118+
except BusinessProcessingError as e:
119+
return response_api_error(e.get_message())
120+
return response_api_created(_serialize_team(team, set()))
121+
122+
123+
@war_rooms_teams_blueprint.get('/<int:team_id>')
124+
@ac_api_requires()
125+
def get_team(war_room_id, team_id):
126+
err = require_war_room_read(war_room_id)
127+
if err is not None:
128+
return err
129+
try:
130+
team = war_room_team_get(war_room_id, team_id)
131+
except ObjectNotFoundError:
132+
return response_api_not_found()
133+
return response_api_success(_serialize_team(team, _team_member_ids(team_id)))
134+
135+
136+
@war_rooms_teams_blueprint.patch('/<int:team_id>')
137+
@ac_api_requires()
138+
def update_team(war_room_id, team_id):
139+
err = require_war_room_write(war_room_id)
140+
if err is not None:
141+
return err
142+
raw = request.get_json()
143+
if not isinstance(raw, dict):
144+
return response_api_error('Invalid request')
145+
try:
146+
team = war_room_team_update(
147+
war_room_id, team_id,
148+
name=raw.get('name'),
149+
description=raw.get('description'),
150+
color=raw.get('color'),
151+
)
152+
except ObjectNotFoundError:
153+
return response_api_not_found()
154+
except BusinessProcessingError as e:
155+
return response_api_error(e.get_message())
156+
return response_api_success(_serialize_team(team, _team_member_ids(team_id)))
157+
158+
159+
@war_rooms_teams_blueprint.delete('/<int:team_id>')
160+
@ac_api_requires()
161+
def delete_team(war_room_id, team_id):
162+
err = require_war_room_write(war_room_id)
163+
if err is not None:
164+
return err
165+
try:
166+
war_room_team_delete(war_room_id, team_id)
167+
except ObjectNotFoundError:
168+
return response_api_not_found()
169+
return response_api_deleted()
170+
171+
172+
# --- Team members ----------------------------------------------------------
173+
174+
@war_rooms_teams_blueprint.get('/<int:team_id>/members')
175+
@ac_api_requires()
176+
def list_team_members(war_room_id, team_id):
177+
err = require_war_room_read(war_room_id)
178+
if err is not None:
179+
return err
180+
try:
181+
rows = war_room_team_members_list(war_room_id, team_id)
182+
except ObjectNotFoundError:
183+
return response_api_not_found()
184+
return response_api_success(data=[_serialize_member(r) for r in rows])
185+
186+
187+
@war_rooms_teams_blueprint.post('/<int:team_id>/members')
188+
@ac_api_requires()
189+
def add_team_member(war_room_id, team_id):
190+
err = require_war_room_write(war_room_id)
191+
if err is not None:
192+
return err
193+
raw = request.get_json()
194+
if not isinstance(raw, dict):
195+
return response_api_error('Invalid request')
196+
try:
197+
row, auto_added_room_member = war_room_team_member_add(
198+
war_room_id, team_id,
199+
user_id=raw.get('user_id'),
200+
added_by_id=iris_current_user.id,
201+
)
202+
except ObjectNotFoundError:
203+
return response_api_not_found()
204+
except BusinessProcessingError as e:
205+
return response_api_error(e.get_message())
206+
payload = _serialize_member(row)
207+
# Flag surfaced so the SPA can show "also added to the war room as
208+
# responder" without a follow-up GET on the members list.
209+
payload['auto_added_room_member'] = bool(auto_added_room_member)
210+
return response_api_created(payload)
211+
212+
213+
@war_rooms_teams_blueprint.delete('/<int:team_id>/members/<int:user_id>')
214+
@ac_api_requires()
215+
def remove_team_member(war_room_id, team_id, user_id):
216+
err = require_war_room_write(war_room_id)
217+
if err is not None:
218+
return err
219+
try:
220+
war_room_team_member_remove(war_room_id, team_id, user_id)
221+
except ObjectNotFoundError:
222+
return response_api_not_found()
223+
return response_api_deleted()

0 commit comments

Comments
 (0)