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:
parent
29bd3b2e39
commit
d4fd1f3b06
131
database.py
131
database.py
@ -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
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|||||||
96
database/migrations/091_messaging_redesign.sql
Normal file
96
database/migrations/091_messaging_redesign.sql
Normal 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;
|
||||||
Loading…
Reference in New Issue
Block a user