From d4fd1f3b068d82eee9434cee7de5d2472473efd5 Mon Sep 17 00:00:00 2001 From: Maciej Pienczyn Date: Fri, 27 Mar 2026 12:56:35 +0100 Subject: [PATCH] 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) --- database.py | 131 ++++++++++++++++++ .../migrations/091_messaging_redesign.sql | 96 +++++++++++++ 2 files changed, 227 insertions(+) create mode 100644 database/migrations/091_messaging_redesign.sql diff --git a/database.py b/database.py index fc0a9eb..b6356c5 100644 --- a/database.py +++ b/database.py @@ -2344,7 +2344,10 @@ class MessageAttachment(Base): mime_type = Column(String(100), nullable=False) 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')) + conv_message = relationship('ConvMessage', back_populates='attachments', foreign_keys=[conv_message_id]) # ============================================================ @@ -5820,6 +5823,134 @@ class InternalHealthLog(Base): return f'' +# ============================================================ +# 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'' + + +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'' + + +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'' + + +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'' + + +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'' + + # ============================================================ # DATABASE INITIALIZATION # ============================================================ diff --git a/database/migrations/091_messaging_redesign.sql b/database/migrations/091_messaging_redesign.sql new file mode 100644 index 0000000..2a80711 --- /dev/null +++ b/database/migrations/091_messaging_redesign.sql @@ -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;