feat(messages): add unified conversation models and migration SQL

Add 5 new SQLAlchemy models (Conversation, ConversationMember, ConvMessage,
MessageReaction, MessagePin) and extend MessageAttachment with conv_message_id FK.
Migration 091 creates all tables with indexes, FKs, and grants.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maciej Pienczyn 2026-03-27 12:56:35 +01:00
parent 29bd3b2e39
commit d4fd1f3b06
2 changed files with 227 additions and 0 deletions

View File

@ -2344,7 +2344,10 @@ class MessageAttachment(Base):
mime_type = Column(String(100), nullable=False) mime_type = Column(String(100), nullable=False)
created_at = Column(DateTime, default=datetime.now) created_at = Column(DateTime, default=datetime.now)
conv_message_id = Column(Integer, ForeignKey('conv_messages.id', ondelete='CASCADE'), nullable=True)
message = relationship('PrivateMessage', backref=backref('attachments', cascade='all, delete-orphan')) message = relationship('PrivateMessage', backref=backref('attachments', cascade='all, delete-orphan'))
conv_message = relationship('ConvMessage', back_populates='attachments', foreign_keys=[conv_message_id])
# ============================================================ # ============================================================
@ -5820,6 +5823,134 @@ class InternalHealthLog(Base):
return f'<InternalHealthLog {self.checked_at} app={self.app_ok} db={self.db_ok}>' return f'<InternalHealthLog {self.checked_at} app={self.app_ok} db={self.db_ok}>'
# ============================================================
# UNIFIED CONVERSATIONS (messaging redesign)
# ============================================================
class Conversation(Base):
"""Zunifikowana konwersacja — 1:1 lub grupowa"""
__tablename__ = 'conversations'
id = Column(Integer, primary_key=True)
name = Column(String(255), nullable=True)
is_group = Column(Boolean, nullable=False, default=False)
owner_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
last_message_id = Column(Integer, ForeignKey('conv_messages.id', name='fk_conversations_last_message', use_alter=True, ondelete='SET NULL'), nullable=True)
owner = relationship('User', foreign_keys=[owner_id])
members = relationship('ConversationMember', backref='conversation', cascade='all, delete-orphan')
messages = relationship('ConvMessage', backref='conversation',
foreign_keys='ConvMessage.conversation_id',
cascade='all, delete-orphan',
order_by='ConvMessage.created_at')
last_message = relationship('ConvMessage', foreign_keys=[last_message_id],
post_update=True)
pins = relationship('MessagePin', backref='conversation', cascade='all, delete-orphan')
@property
def display_name(self):
"""Nazwa wyświetlana — nazwa grupy lub lista imion uczestników"""
if self.name:
return self.name
names = [m.user.name or m.user.email.split('@')[0] for m in self.members if m.user]
return ', '.join(sorted(names)[:4]) + (f' +{len(names)-4}' if len(names) > 4 else '')
@property
def member_count(self):
return len(self.members)
def __repr__(self):
return f'<Conversation {self.id} group={self.is_group} members={self.member_count}>'
class ConversationMember(Base):
"""Członkostwo w konwersacji"""
__tablename__ = 'conversation_members'
conversation_id = Column(Integer, ForeignKey('conversations.id', ondelete='CASCADE'), primary_key=True)
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), primary_key=True)
role = Column(String(20), nullable=False, default='member')
last_read_at = Column(DateTime, nullable=True)
is_muted = Column(Boolean, nullable=False, default=False)
is_archived = Column(Boolean, nullable=False, default=False)
joined_at = Column(DateTime, default=datetime.now)
added_by_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
user = relationship('User', foreign_keys=[user_id])
added_by = relationship('User', foreign_keys=[added_by_id])
@property
def is_owner(self):
return self.role == 'owner'
def __repr__(self):
return f'<ConversationMember conv={self.conversation_id} user={self.user_id} role={self.role}>'
class ConvMessage(Base):
"""Wiadomość w konwersacji"""
__tablename__ = 'conv_messages'
id = Column(Integer, primary_key=True)
conversation_id = Column(Integer, ForeignKey('conversations.id', ondelete='CASCADE'), nullable=False, index=True)
sender_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
content = Column(Text, nullable=False)
reply_to_id = Column(Integer, ForeignKey('conv_messages.id', ondelete='SET NULL'), nullable=True)
edited_at = Column(DateTime, nullable=True)
is_deleted = Column(Boolean, nullable=False, default=False)
link_preview = Column(PG_JSONB, nullable=True)
created_at = Column(DateTime, default=datetime.now, index=True)
sender = relationship('User', foreign_keys=[sender_id])
reply_to = relationship('ConvMessage', remote_side=[id], foreign_keys=[reply_to_id])
reactions = relationship('MessageReaction', backref='message', cascade='all, delete-orphan')
attachments = relationship('MessageAttachment',
foreign_keys='MessageAttachment.conv_message_id',
back_populates='conv_message',
cascade='all, delete-orphan')
def __repr__(self):
return f'<ConvMessage {self.id} conv={self.conversation_id} sender={self.sender_id}>'
class MessageReaction(Base):
"""Reakcja emoji na wiadomość"""
__tablename__ = 'message_reactions'
__table_args__ = (
UniqueConstraint('message_id', 'user_id', 'emoji', name='uq_message_reaction'),
)
id = Column(Integer, primary_key=True)
message_id = Column(Integer, ForeignKey('conv_messages.id', ondelete='CASCADE'), nullable=False, index=True)
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False)
emoji = Column(String(10), nullable=False)
created_at = Column(DateTime, default=datetime.now)
user = relationship('User', foreign_keys=[user_id])
def __repr__(self):
return f'<MessageReaction {self.emoji} msg={self.message_id} user={self.user_id}>'
class MessagePin(Base):
"""Przypięta wiadomość w konwersacji"""
__tablename__ = 'message_pins'
id = Column(Integer, primary_key=True)
conversation_id = Column(Integer, ForeignKey('conversations.id', ondelete='CASCADE'), nullable=False, index=True)
message_id = Column(Integer, ForeignKey('conv_messages.id', ondelete='CASCADE'), nullable=False)
pinned_by_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
created_at = Column(DateTime, default=datetime.now)
message = relationship('ConvMessage', foreign_keys=[message_id])
pinned_by = relationship('User', foreign_keys=[pinned_by_id])
def __repr__(self):
return f'<MessagePin msg={self.message_id} conv={self.conversation_id}>'
# ============================================================ # ============================================================
# DATABASE INITIALIZATION # DATABASE INITIALIZATION
# ============================================================ # ============================================================

View File

@ -0,0 +1,96 @@
-- 091_messaging_redesign.sql
-- Unified conversation model: replaces separate private_messages + message_group
-- Conversations (1:1 and group), messages, reactions, pins
BEGIN;
-- Unified conversations (1:1 or group)
CREATE TABLE conversations (
id SERIAL PRIMARY KEY,
name VARCHAR(255),
is_group BOOLEAN NOT NULL DEFAULT FALSE,
owner_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
last_message_id INTEGER -- FK added below after conv_messages exists
);
CREATE INDEX idx_conversations_owner ON conversations(owner_id);
CREATE INDEX idx_conversations_updated ON conversations(updated_at DESC);
-- Conversation membership
CREATE TABLE conversation_members (
conversation_id INTEGER NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role VARCHAR(20) NOT NULL DEFAULT 'member',
last_read_at TIMESTAMP,
is_muted BOOLEAN NOT NULL DEFAULT FALSE,
is_archived BOOLEAN NOT NULL DEFAULT FALSE,
joined_at TIMESTAMP NOT NULL DEFAULT NOW(),
added_by_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
PRIMARY KEY (conversation_id, user_id)
);
CREATE INDEX idx_conversation_members_user ON conversation_members(user_id);
-- Messages within a conversation
CREATE TABLE conv_messages (
id SERIAL PRIMARY KEY,
conversation_id INTEGER NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
sender_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
content TEXT NOT NULL,
reply_to_id INTEGER REFERENCES conv_messages(id) ON DELETE SET NULL,
edited_at TIMESTAMP,
is_deleted BOOLEAN NOT NULL DEFAULT FALSE,
link_preview JSONB,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_conv_messages_conversation ON conv_messages(conversation_id);
CREATE INDEX idx_conv_messages_created ON conv_messages(created_at);
CREATE INDEX idx_conv_messages_conversation_created ON conv_messages(conversation_id, created_at);
-- Now add the deferred FK from conversations.last_message_id -> conv_messages.id
ALTER TABLE conversations
ADD CONSTRAINT fk_conversations_last_message
FOREIGN KEY (last_message_id) REFERENCES conv_messages(id) ON DELETE SET NULL;
-- Emoji reactions on messages
CREATE TABLE message_reactions (
id SERIAL PRIMARY KEY,
message_id INTEGER NOT NULL REFERENCES conv_messages(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
emoji VARCHAR(10) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
CONSTRAINT uq_message_reaction UNIQUE (message_id, user_id, emoji)
);
CREATE INDEX idx_message_reactions_message ON message_reactions(message_id);
-- Pinned messages
CREATE TABLE message_pins (
id SERIAL PRIMARY KEY,
conversation_id INTEGER NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
message_id INTEGER NOT NULL REFERENCES conv_messages(id) ON DELETE CASCADE,
pinned_by_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_message_pins_conversation ON message_pins(conversation_id);
-- Extend message_attachments to support conv_messages
ALTER TABLE message_attachments ADD COLUMN conv_message_id INTEGER REFERENCES conv_messages(id) ON DELETE CASCADE;
CREATE INDEX idx_message_attachments_conv ON message_attachments(conv_message_id) WHERE conv_message_id IS NOT NULL;
-- Grants
GRANT ALL ON TABLE conversations TO nordabiz_app;
GRANT ALL ON TABLE conversation_members TO nordabiz_app;
GRANT ALL ON TABLE conv_messages TO nordabiz_app;
GRANT ALL ON TABLE message_reactions TO nordabiz_app;
GRANT ALL ON TABLE message_pins TO nordabiz_app;
GRANT USAGE, SELECT ON SEQUENCE conversations_id_seq TO nordabiz_app;
GRANT USAGE, SELECT ON SEQUENCE conv_messages_id_seq TO nordabiz_app;
GRANT USAGE, SELECT ON SEQUENCE message_reactions_id_seq TO nordabiz_app;
GRANT USAGE, SELECT ON SEQUENCE message_pins_id_seq TO nordabiz_app;
COMMIT;