Some checks are pending
NordaBiz Tests / Unit & Integration Tests (push) Waiting to run
NordaBiz Tests / E2E Tests (Playwright) (push) Blocked by required conditions
NordaBiz Tests / Smoke Tests (Production) (push) Blocked by required conditions
NordaBiz Tests / Send Failure Notification (push) Blocked by required conditions
Production moved from on-prem VM 249 (10.22.68.249) to OVH VPS (57.128.200.27, inpi-vps-waw01). Updated ALL documentation, slash commands, memory files, architecture docs, and deploy procedures. Added |local_time Jinja filter (UTC→Europe/Warsaw) and converted 155 .strftime() calls across 71 templates so timestamps display in Polish timezone regardless of server timezone. Also includes: created_by_id tracking, abort import fix, ICS calendar fix for missing end times, Pros Poland data cleanup. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1120 lines
37 KiB
Markdown
1120 lines
37 KiB
Markdown
# Messaging Redesign — Implementation Plan
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Przebudowa systemu wiadomości z email-like (Odebrane/Wysłane) na konwersacyjny (Messenger/WhatsApp) z ujednoliconym modelem danych, SSE real-time, i pełnym zestawem funkcji komunikacyjnych.
|
|
|
|
**Architecture:** Unified `Conversation` + `Message` model replacing separate PrivateMessage and MessageGroup systems. SSE via Redis pub/sub for real-time events. REST API for all operations. Conversation-based frontend with split-pane layout.
|
|
|
|
**Tech Stack:** Flask, SQLAlchemy, PostgreSQL, Redis (pub/sub + presence), SSE, Quill.js, vanilla JS
|
|
|
|
**Spec:** `docs/superpowers/specs/2026-03-27-messaging-redesign-design.md`
|
|
|
|
---
|
|
|
|
## File Structure
|
|
|
|
### New files
|
|
| File | Responsibility |
|
|
|------|---------------|
|
|
| `database/migrations/091_messaging_redesign.sql` | New tables DDL |
|
|
| `blueprints/messages/conversation_routes.py` | Conversation CRUD API |
|
|
| `blueprints/messages/message_routes.py` | Message send/edit/delete/forward API |
|
|
| `blueprints/messages/reaction_routes.py` | Reactions + pins API |
|
|
| `blueprints/messages/sse_routes.py` | SSE stream + typing + presence |
|
|
| `blueprints/messages/link_preview.py` | Link preview fetcher |
|
|
| `redis_service.py` | Redis client singleton + pub/sub helpers |
|
|
| `templates/messages/conversations.html` | Main conversation view (list + chat) |
|
|
| `static/js/conversations.js` | Frontend: conversation list, chat, SSE client |
|
|
| `static/css/conversations.css` | Conversation-specific styles |
|
|
| `scripts/migrate_messages.py` | Data migration from old to new tables |
|
|
| `tests/unit/test_conversation_models.py` | Model unit tests |
|
|
| `tests/unit/test_conversation_api.py` | API endpoint tests |
|
|
|
|
### Modified files
|
|
| File | Changes |
|
|
|------|---------|
|
|
| `database.py` | Add 5 new model classes (after line 5821) |
|
|
| `blueprints/messages/__init__.py` | Import new route modules |
|
|
| `email_service.py` | Add `build_conversation_notification_email` |
|
|
| `app.py` | Redis connection setup (extend existing, line ~245) |
|
|
|
|
### Preserved (read-only after migration)
|
|
| File | Status |
|
|
|------|--------|
|
|
| `blueprints/messages/routes.py` | Keep for `/wiadomosci/archiwum` redirect |
|
|
| `blueprints/messages/group_routes.py` | Keep for backward compat, redirect to new |
|
|
| `templates/messages/inbox.html` | Unused after switch, keep for rollback |
|
|
|
|
---
|
|
|
|
## Task 1: Database Models
|
|
|
|
**Files:**
|
|
- Modify: `database.py:5822` (insert before `# DATABASE INITIALIZATION`)
|
|
- Create: `database/migrations/091_messaging_redesign.sql`
|
|
|
|
- [ ] **Step 1: Add SQLAlchemy models to database.py**
|
|
|
|
Insert after line 5821 (after `InternalHealthLog`), before `# DATABASE INITIALIZATION`:
|
|
|
|
```python
|
|
# ============================================================
|
|
# CONVERSATIONS (Unified Messaging System)
|
|
# ============================================================
|
|
|
|
class Conversation(Base):
|
|
"""Unified conversation model — 1:1 and groups."""
|
|
__tablename__ = 'conversations'
|
|
|
|
id = Column(Integer, primary_key=True)
|
|
name = Column(String(255), nullable=True) # Null for 1:1
|
|
is_group = Column(Boolean, default=False, nullable=False)
|
|
owner_id = Column(Integer, ForeignKey('users.id'), nullable=False)
|
|
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
|
last_message_id = Column(Integer, ForeignKey('conv_messages.id', use_alter=True), nullable=True)
|
|
|
|
owner = relationship('User', foreign_keys=[owner_id])
|
|
members = relationship('ConversationMember', back_populates='conversation', cascade='all, delete-orphan')
|
|
messages = relationship('ConvMessage', back_populates='conversation', foreign_keys='ConvMessage.conversation_id')
|
|
last_message = relationship('ConvMessage', foreign_keys=[last_message_id], post_update=True)
|
|
pins = relationship('MessagePin', back_populates='conversation', cascade='all, delete-orphan')
|
|
|
|
@property
|
|
def display_name(self):
|
|
if self.name:
|
|
return self.name
|
|
member_names = [m.user.name for m in self.members if m.user]
|
|
return ', '.join(member_names[:4]) + (f' +{len(member_names)-4}' if len(member_names) > 4 else '')
|
|
|
|
@property
|
|
def member_count(self):
|
|
return len(self.members)
|
|
|
|
def __repr__(self):
|
|
return f'<Conversation {self.id} group={self.is_group}>'
|
|
|
|
|
|
class ConversationMember(Base):
|
|
"""Membership in a conversation with per-user settings."""
|
|
__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), default='member', nullable=False)
|
|
last_read_at = Column(DateTime, nullable=True)
|
|
is_muted = Column(Boolean, default=False, nullable=False)
|
|
is_archived = Column(Boolean, default=False, nullable=False)
|
|
joined_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
|
added_by_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
|
|
|
|
conversation = relationship('Conversation', back_populates='members')
|
|
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}>'
|
|
|
|
|
|
class ConvMessage(Base):
|
|
"""Message within a conversation."""
|
|
__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, default=False, nullable=False)
|
|
link_preview = Column(JSON, nullable=True) # {url, title, description, image}
|
|
created_at = Column(DateTime, default=datetime.utcnow, nullable=False, index=True)
|
|
|
|
conversation = relationship('Conversation', back_populates='messages', foreign_keys=[conversation_id])
|
|
sender = relationship('User', foreign_keys=[sender_id])
|
|
reply_to = relationship('ConvMessage', remote_side=[id], foreign_keys=[reply_to_id])
|
|
reactions = relationship('MessageReaction', back_populates='message', cascade='all, delete-orphan')
|
|
attachments = relationship('MessageAttachment', back_populates='conv_message', foreign_keys='MessageAttachment.conv_message_id')
|
|
|
|
def __repr__(self):
|
|
return f'<ConvMessage {self.id} conv={self.conversation_id}>'
|
|
|
|
|
|
class MessageReaction(Base):
|
|
"""Emoji reaction on a message."""
|
|
__tablename__ = 'message_reactions'
|
|
|
|
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.utcnow, nullable=False)
|
|
|
|
message = relationship('ConvMessage', back_populates='reactions')
|
|
user = relationship('User')
|
|
|
|
__table_args__ = (
|
|
UniqueConstraint('message_id', 'user_id', 'emoji', name='uq_reaction_per_user'),
|
|
)
|
|
|
|
def __repr__(self):
|
|
return f'<MessageReaction {self.emoji} msg={self.message_id}>'
|
|
|
|
|
|
class MessagePin(Base):
|
|
"""Pinned message in a conversation."""
|
|
__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.utcnow, nullable=False)
|
|
|
|
conversation = relationship('Conversation', back_populates='pins')
|
|
message = relationship('ConvMessage')
|
|
pinned_by = relationship('User')
|
|
|
|
def __repr__(self):
|
|
return f'<MessagePin msg={self.message_id} conv={self.conversation_id}>'
|
|
```
|
|
|
|
- [ ] **Step 2: Add conv_message_id FK to MessageAttachment**
|
|
|
|
In `database.py`, find class `MessageAttachment` (line ~2334) and add:
|
|
|
|
```python
|
|
conv_message_id = Column(Integer, ForeignKey('conv_messages.id', ondelete='CASCADE'), nullable=True)
|
|
conv_message = relationship('ConvMessage', back_populates='attachments', foreign_keys=[conv_message_id])
|
|
```
|
|
|
|
- [ ] **Step 3: Add missing import if needed**
|
|
|
|
Check that `UniqueConstraint` is imported at the top of `database.py`. If not, add to the `from sqlalchemy import ...` line:
|
|
|
|
```python
|
|
from sqlalchemy import UniqueConstraint
|
|
```
|
|
|
|
- [ ] **Step 4: Write migration SQL**
|
|
|
|
```sql
|
|
-- Migration 091: Messaging Redesign — Unified Conversation Model
|
|
-- Replaces separate private_messages + message_group systems
|
|
|
|
BEGIN;
|
|
|
|
CREATE TABLE conversations (
|
|
id SERIAL PRIMARY KEY,
|
|
name VARCHAR(255),
|
|
is_group BOOLEAN NOT NULL DEFAULT FALSE,
|
|
owner_id INTEGER NOT NULL REFERENCES users(id),
|
|
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
|
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
|
last_message_id INTEGER -- FK added after conv_messages exists
|
|
);
|
|
|
|
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 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, created_at DESC);
|
|
CREATE INDEX idx_conv_messages_sender ON conv_messages(sender_id);
|
|
|
|
-- Add FK from conversations to conv_messages (deferred)
|
|
ALTER TABLE conversations ADD CONSTRAINT fk_conversations_last_message
|
|
FOREIGN KEY (last_message_id) REFERENCES conv_messages(id) ON DELETE SET NULL;
|
|
|
|
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_reaction_per_user UNIQUE (message_id, user_id, emoji)
|
|
);
|
|
|
|
CREATE INDEX idx_message_reactions_message ON message_reactions(message_id);
|
|
|
|
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);
|
|
|
|
-- Add conv_message_id to existing message_attachments
|
|
ALTER TABLE message_attachments ADD COLUMN conv_message_id INTEGER
|
|
REFERENCES conv_messages(id) ON DELETE CASCADE;
|
|
|
|
-- 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;
|
|
```
|
|
|
|
- [ ] **Step 5: Verify models compile**
|
|
|
|
Run: `python3 -c "from database import Conversation, ConversationMember, ConvMessage, MessageReaction, MessagePin; print('OK')"`
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add database.py database/migrations/091_messaging_redesign.sql
|
|
git commit -m "feat(messages): add unified conversation models and migration SQL"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 2: Redis Service
|
|
|
|
**Files:**
|
|
- Create: `redis_service.py`
|
|
- Modify: `app.py:~245` (extend existing Redis setup)
|
|
|
|
- [ ] **Step 1: Create redis_service.py**
|
|
|
|
```python
|
|
"""
|
|
Redis Service
|
|
=============
|
|
|
|
Singleton Redis client for pub/sub (SSE events) and presence tracking.
|
|
Uses the same Redis instance as Flask-Limiter (localhost:6379/0).
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
import redis
|
|
import threading
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_redis_client = None
|
|
_redis_available = False
|
|
|
|
|
|
def init_redis(app=None):
|
|
"""Initialize Redis connection. Called from app.py after existing Redis check."""
|
|
global _redis_client, _redis_available
|
|
try:
|
|
_redis_client = redis.Redis(host='localhost', port=6379, db=0, decode_responses=True)
|
|
_redis_client.ping()
|
|
_redis_available = True
|
|
logger.info("Redis connected for messaging pub/sub")
|
|
except (redis.ConnectionError, redis.TimeoutError):
|
|
_redis_available = False
|
|
logger.warning("Redis unavailable — SSE real-time disabled, falling back to polling")
|
|
|
|
|
|
def get_redis():
|
|
"""Get Redis client. Returns None if unavailable."""
|
|
return _redis_client if _redis_available else None
|
|
|
|
|
|
def is_available():
|
|
return _redis_available
|
|
|
|
|
|
def publish_event(user_id, event_type, data):
|
|
"""Publish SSE event to a user's channel."""
|
|
r = get_redis()
|
|
if not r:
|
|
return
|
|
payload = json.dumps({'event': event_type, 'data': data})
|
|
r.publish(f'user:{user_id}:events', payload)
|
|
|
|
|
|
def publish_to_conversation(db, conversation_id, event_type, data, exclude_user_id=None):
|
|
"""Publish event to all members of a conversation."""
|
|
from database import ConversationMember
|
|
members = db.query(ConversationMember.user_id).filter_by(conversation_id=conversation_id).all()
|
|
for (uid,) in members:
|
|
if uid != exclude_user_id:
|
|
publish_event(uid, event_type, data)
|
|
|
|
|
|
def set_user_online(user_id):
|
|
"""Mark user as online with 60s TTL."""
|
|
r = get_redis()
|
|
if not r:
|
|
return
|
|
r.setex(f'user:{user_id}:online', 60, '1')
|
|
|
|
|
|
def is_user_online(user_id):
|
|
"""Check if user is currently online."""
|
|
r = get_redis()
|
|
if not r:
|
|
return False
|
|
return r.exists(f'user:{user_id}:online') > 0
|
|
|
|
|
|
def get_user_last_seen(user_id):
|
|
"""Get last seen timestamp from Redis."""
|
|
r = get_redis()
|
|
if not r:
|
|
return None
|
|
ts = r.get(f'user:{user_id}:last_seen')
|
|
return ts
|
|
|
|
|
|
def update_last_seen(user_id):
|
|
"""Update last seen timestamp."""
|
|
r = get_redis()
|
|
if not r:
|
|
return
|
|
from datetime import datetime
|
|
r.set(f'user:{user_id}:last_seen', datetime.utcnow().isoformat())
|
|
```
|
|
|
|
- [ ] **Step 2: Wire Redis init into app.py**
|
|
|
|
In `app.py`, after the existing Redis ping block (~line 260), add:
|
|
|
|
```python
|
|
from redis_service import init_redis
|
|
init_redis(app)
|
|
```
|
|
|
|
- [ ] **Step 3: Verify**
|
|
|
|
Run: `python3 -c "from redis_service import init_redis, is_available; init_redis(); print('Redis available:', is_available())"`
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add redis_service.py app.py
|
|
git commit -m "feat(messages): add Redis service for pub/sub and presence"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 3: SSE Stream + Presence + Typing
|
|
|
|
**Files:**
|
|
- Create: `blueprints/messages/sse_routes.py`
|
|
- Modify: `blueprints/messages/__init__.py`
|
|
|
|
- [ ] **Step 1: Create sse_routes.py**
|
|
|
|
```python
|
|
"""
|
|
SSE Routes
|
|
==========
|
|
|
|
Server-Sent Events stream, typing indicators, and presence.
|
|
"""
|
|
|
|
import json
|
|
from datetime import datetime
|
|
from flask import Response, request, jsonify, stream_with_context
|
|
from flask_login import login_required, current_user
|
|
from . import bp
|
|
from database import SessionLocal, ConversationMember
|
|
from utils.decorators import member_required
|
|
from redis_service import get_redis, is_available, publish_to_conversation, set_user_online, update_last_seen, publish_event
|
|
|
|
|
|
@bp.route('/api/messages/stream')
|
|
@login_required
|
|
def message_stream():
|
|
"""SSE stream — one connection per logged-in user."""
|
|
r = get_redis()
|
|
if not r:
|
|
return jsonify({'error': 'Real-time unavailable'}), 503
|
|
|
|
user_id = current_user.id
|
|
|
|
def generate():
|
|
pubsub = r.pubsub()
|
|
pubsub.subscribe(f'user:{user_id}:events')
|
|
# Initial heartbeat
|
|
yield f"retry: 30000\ndata: {json.dumps({'event': 'connected'})}\n\n"
|
|
try:
|
|
for msg in pubsub.listen():
|
|
if msg['type'] == 'message':
|
|
payload = json.loads(msg['data'])
|
|
yield f"event: {payload['event']}\ndata: {json.dumps(payload['data'])}\n\n"
|
|
except GeneratorExit:
|
|
pubsub.unsubscribe()
|
|
pubsub.close()
|
|
|
|
# Update presence on SSE connect
|
|
set_user_online(user_id)
|
|
update_last_seen(user_id)
|
|
|
|
response = Response(
|
|
stream_with_context(generate()),
|
|
mimetype='text/event-stream',
|
|
headers={
|
|
'Cache-Control': 'no-cache',
|
|
'X-Accel-Buffering': 'no', # nginx: disable buffering
|
|
'Connection': 'keep-alive',
|
|
}
|
|
)
|
|
return response
|
|
|
|
|
|
@bp.route('/api/conversations/<int:conversation_id>/typing', methods=['POST'])
|
|
@login_required
|
|
@member_required
|
|
def typing_indicator(conversation_id):
|
|
"""Signal that current user is typing."""
|
|
if not is_available():
|
|
return jsonify({'ok': True})
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
member = db.query(ConversationMember).filter_by(
|
|
conversation_id=conversation_id, user_id=current_user.id
|
|
).first()
|
|
if not member:
|
|
return jsonify({'error': 'Not a member'}), 403
|
|
|
|
publish_to_conversation(db, conversation_id, 'typing', {
|
|
'conversation_id': conversation_id,
|
|
'user_id': current_user.id,
|
|
'user_name': current_user.name or current_user.email.split('@')[0],
|
|
}, exclude_user_id=current_user.id)
|
|
|
|
return jsonify({'ok': True})
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@bp.route('/api/messages/heartbeat', methods=['POST'])
|
|
@login_required
|
|
def heartbeat():
|
|
"""Update presence — called every 30s from SSE client."""
|
|
set_user_online(current_user.id)
|
|
update_last_seen(current_user.id)
|
|
return jsonify({'ok': True})
|
|
|
|
|
|
@bp.route('/api/users/presence', methods=['GET'])
|
|
@login_required
|
|
@member_required
|
|
def users_presence():
|
|
"""Batch check online status for multiple users."""
|
|
from redis_service import is_user_online, get_user_last_seen
|
|
user_ids = request.args.getlist('ids', type=int)
|
|
if not user_ids or len(user_ids) > 50:
|
|
return jsonify({'error': 'Provide 1-50 user IDs'}), 400
|
|
|
|
result = {}
|
|
for uid in user_ids:
|
|
result[str(uid)] = {
|
|
'online': is_user_online(uid),
|
|
'last_seen': get_user_last_seen(uid),
|
|
}
|
|
return jsonify(result)
|
|
```
|
|
|
|
- [ ] **Step 2: Register in __init__.py**
|
|
|
|
Add to `blueprints/messages/__init__.py`:
|
|
|
|
```python
|
|
from . import sse_routes # noqa: F401
|
|
```
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add blueprints/messages/sse_routes.py blueprints/messages/__init__.py
|
|
git commit -m "feat(messages): add SSE stream, typing indicator, and presence endpoints"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 4: Conversation API
|
|
|
|
**Files:**
|
|
- Create: `blueprints/messages/conversation_routes.py`
|
|
- Modify: `blueprints/messages/__init__.py`
|
|
|
|
- [ ] **Step 1: Create conversation_routes.py**
|
|
|
|
Full CRUD for conversations: list, create (with 1:1 dedup), get details, update, delete, manage members, settings (mute/archive), mark read.
|
|
|
|
Key endpoints:
|
|
- `GET /api/conversations` — list user's conversations with last_message, unread count, sorted by updated_at
|
|
- `POST /api/conversations` — create new (1:1 dedup: if conversation with exact same 2 members exists, return it)
|
|
- `GET /api/conversations/<id>` — details + members
|
|
- `PATCH /api/conversations/<id>` — edit name/description (owner, groups only)
|
|
- `DELETE /api/conversations/<id>` — delete (owner only)
|
|
- `POST /api/conversations/<id>/members` — add member (owner, groups only)
|
|
- `DELETE /api/conversations/<id>/members/<uid>` — remove member
|
|
- `PATCH /api/conversations/<id>/settings` — mute/archive per user
|
|
- `POST /api/conversations/<id>/read` — mark all messages as read (update last_read_at)
|
|
|
|
Each endpoint: check membership, return JSON, publish SSE events where relevant.
|
|
|
|
- [ ] **Step 2: Create `GET /wiadomosci` HTML route**
|
|
|
|
Add route that renders the new `conversations.html` template. This replaces the old inbox. Pass conversations list as initial data to avoid extra API call on page load.
|
|
|
|
- [ ] **Step 3: Register in __init__.py**
|
|
|
|
```python
|
|
from . import conversation_routes # noqa: F401
|
|
```
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add blueprints/messages/conversation_routes.py blueprints/messages/__init__.py
|
|
git commit -m "feat(messages): add conversation CRUD API endpoints"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 5: Message API
|
|
|
|
**Files:**
|
|
- Create: `blueprints/messages/message_routes.py`
|
|
- Modify: `blueprints/messages/__init__.py`
|
|
|
|
- [ ] **Step 1: Create message_routes.py**
|
|
|
|
Endpoints:
|
|
- `GET /api/conversations/<id>/messages` — cursor-based pagination (before_id param), return messages with sender, reactions, reply_to, attachments
|
|
- `POST /api/conversations/<id>/messages` — send message. Check membership. Update conversation.updated_at and last_message_id. Publish SSE `new_message`. Send email notification (respecting mute). Handle attachments. Trigger link preview (async).
|
|
- `PATCH /api/messages/<id>` — edit own message (max 24h old). Set edited_at. Publish SSE `message_edited`.
|
|
- `DELETE /api/messages/<id>` — soft delete own message. Set is_deleted=True. Publish SSE `message_deleted`.
|
|
- `POST /api/messages/<id>/forward` — copy message content to another conversation. Create new message in target.
|
|
|
|
- [ ] **Step 2: Email notification logic**
|
|
|
|
In the send message handler, for each conversation member (except sender):
|
|
```python
|
|
member = db.query(ConversationMember).filter_by(conversation_id=cid, user_id=uid).first()
|
|
if member.is_muted:
|
|
continue
|
|
user = member.user
|
|
if user.notify_email_messages == False or not user.email:
|
|
continue
|
|
# Send email using build_message_notification_email
|
|
```
|
|
|
|
- [ ] **Step 3: Register in __init__.py**
|
|
|
|
```python
|
|
from . import message_routes # noqa: F401
|
|
```
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add blueprints/messages/message_routes.py blueprints/messages/__init__.py
|
|
git commit -m "feat(messages): add message send/edit/delete/forward API"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 6: Reactions + Pins API
|
|
|
|
**Files:**
|
|
- Create: `blueprints/messages/reaction_routes.py`
|
|
- Modify: `blueprints/messages/__init__.py`
|
|
|
|
- [ ] **Step 1: Create reaction_routes.py**
|
|
|
|
Endpoints:
|
|
- `POST /api/messages/<id>/reactions` — add reaction (emoji in body). Check membership. Upsert (unique constraint). Publish SSE `reaction`.
|
|
- `DELETE /api/messages/<id>/reactions/<emoji>` — remove own reaction. Publish SSE `reaction` with action=remove.
|
|
- `POST /api/messages/<id>/pin` — pin message. Check membership. Create MessagePin. Publish SSE `message_pinned`.
|
|
- `DELETE /api/messages/<id>/pin` — unpin. Delete MessagePin. Publish SSE.
|
|
- `GET /api/conversations/<id>/pins` — list pinned messages with content preview.
|
|
|
|
Allowed emoji set: `['👍', '❤️', '😂', '😮', '😢', '✅']`
|
|
|
|
- [ ] **Step 2: Register in __init__.py**
|
|
|
|
```python
|
|
from . import reaction_routes # noqa: F401
|
|
```
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add blueprints/messages/reaction_routes.py blueprints/messages/__init__.py
|
|
git commit -m "feat(messages): add reactions and pins API"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 7: Link Preview
|
|
|
|
**Files:**
|
|
- Create: `blueprints/messages/link_preview.py`
|
|
|
|
- [ ] **Step 1: Create link_preview.py**
|
|
|
|
```python
|
|
"""
|
|
Link Preview
|
|
============
|
|
|
|
Fetch Open Graph metadata for URLs in messages.
|
|
"""
|
|
|
|
import re
|
|
import logging
|
|
import requests
|
|
from html.parser import HTMLParser
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
URL_REGEX = re.compile(r'https?://[^\s<>"\']+')
|
|
INTERNAL_DOMAINS = ['nordabiznes.pl', 'staging.nordabiznes.pl', 'localhost']
|
|
|
|
|
|
class OGParser(HTMLParser):
|
|
"""Parse og: meta tags and title from HTML."""
|
|
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.og = {}
|
|
self.title = None
|
|
self._in_title = False
|
|
self._title_data = []
|
|
|
|
def handle_starttag(self, tag, attrs):
|
|
if tag == 'meta':
|
|
d = dict(attrs)
|
|
prop = d.get('property', '') or d.get('name', '')
|
|
content = d.get('content', '')
|
|
if prop in ('og:title', 'og:description', 'og:image'):
|
|
self.og[prop.replace('og:', '')] = content
|
|
elif prop == 'description' and 'description' not in self.og:
|
|
self.og['description'] = content
|
|
elif tag == 'title':
|
|
self._in_title = True
|
|
|
|
def handle_data(self, data):
|
|
if self._in_title:
|
|
self._title_data.append(data)
|
|
|
|
def handle_endtag(self, tag):
|
|
if tag == 'title':
|
|
self._in_title = False
|
|
self.title = ''.join(self._title_data).strip()
|
|
|
|
|
|
def fetch_link_preview(text):
|
|
"""Extract first URL from text and fetch OG metadata. Returns dict or None."""
|
|
# Strip HTML tags for URL detection
|
|
clean = re.sub(r'<[^>]+>', '', text)
|
|
urls = URL_REGEX.findall(clean)
|
|
if not urls:
|
|
return None
|
|
|
|
url = urls[0]
|
|
|
|
# Skip internal links
|
|
from urllib.parse import urlparse
|
|
parsed = urlparse(url)
|
|
if any(parsed.hostname and parsed.hostname.endswith(d) for d in INTERNAL_DOMAINS):
|
|
return None
|
|
|
|
try:
|
|
resp = requests.get(url, timeout=3, headers={
|
|
'User-Agent': 'NordaBiznes/1.0 (Link Preview)'
|
|
}, allow_redirects=True)
|
|
resp.raise_for_status()
|
|
|
|
if 'text/html' not in resp.headers.get('content-type', ''):
|
|
return None
|
|
|
|
# Parse only first 100KB
|
|
html = resp.text[:100_000]
|
|
parser = OGParser()
|
|
parser.feed(html)
|
|
|
|
title = parser.og.get('title') or parser.title
|
|
if not title:
|
|
return None
|
|
|
|
return {
|
|
'url': url,
|
|
'title': title[:200],
|
|
'description': (parser.og.get('description') or '')[:300],
|
|
'image': parser.og.get('image', ''),
|
|
}
|
|
except Exception as e:
|
|
logger.debug(f"Link preview failed for {url}: {e}")
|
|
return None
|
|
```
|
|
|
|
- [ ] **Step 2: Wire into message sending**
|
|
|
|
In `message_routes.py`, after saving the message:
|
|
|
|
```python
|
|
from blueprints.messages.link_preview import fetch_link_preview
|
|
preview = fetch_link_preview(content)
|
|
if preview:
|
|
message.link_preview = preview
|
|
db.commit()
|
|
```
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add blueprints/messages/link_preview.py
|
|
git commit -m "feat(messages): add link preview for URLs in messages"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 8: Frontend — CSS
|
|
|
|
**Files:**
|
|
- Create: `static/css/conversations.css`
|
|
|
|
- [ ] **Step 1: Create conversations.css**
|
|
|
|
Port and refine styles from the mockup (`mockups/messages_chat_view.html`). Use the existing CSS variable system (`var(--primary)`, `var(--surface)`, `var(--text-primary)`, etc.) from the portal's base styles rather than hardcoded colors.
|
|
|
|
Key sections:
|
|
- `.conversations-container` — flex layout, full viewport height
|
|
- `.conversations-panel` — left panel (380px), conversation list items, hover/active states, unread badges, avatars
|
|
- `.chat-panel` — right panel, header, messages area, input area
|
|
- `.message-bubble` — mine (accent color, right) / theirs (surface, left), consecutive grouping
|
|
- `.message-reactions` — pill badges under bubbles
|
|
- `.message-reply-quote` — cited message preview
|
|
- `.link-preview-card` — title + description + image card
|
|
- `.date-separator` — centered date between message groups
|
|
- `.typing-indicator` — animated dots
|
|
- `.pinned-bar` — pinned messages strip under header
|
|
- `.context-menu` — hover menu for message actions
|
|
- Mobile breakpoint (`max-width: 768px`) — single-panel mode, bottom sheet menus
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add static/css/conversations.css
|
|
git commit -m "feat(messages): add conversation view CSS"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 9: Frontend — HTML Template
|
|
|
|
**Files:**
|
|
- Create: `templates/messages/conversations.html`
|
|
|
|
- [ ] **Step 1: Create conversations.html**
|
|
|
|
Extends `base.html`. Single template for the entire messaging experience.
|
|
|
|
Structure:
|
|
```
|
|
{% block extra_css %} — link to conversations.css
|
|
{% block content %}
|
|
.conversations-container
|
|
.conversations-panel
|
|
.conversations-header (title + search + new message btn)
|
|
.conversation-list (rendered server-side for initial load, updated by JS)
|
|
.chat-panel
|
|
.chat-empty (shown when no conversation selected)
|
|
.chat-header (hidden until conversation selected)
|
|
.chat-messages (scrollable, filled by JS)
|
|
.chat-input-area (Quill editor + attachments + send)
|
|
.pinned-bar (hidden until pins exist)
|
|
{% block extra_js %} — link to conversations.js
|
|
```
|
|
|
|
Initial data: embed conversations list as JSON in a `<script>` tag for instant render without API call:
|
|
```html
|
|
<script>
|
|
window.__CONVERSATIONS__ = {{ conversations_json|safe }};
|
|
window.__CURRENT_USER__ = {{ current_user_json|safe }};
|
|
</script>
|
|
```
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add templates/messages/conversations.html
|
|
git commit -m "feat(messages): add conversation view template"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 10: Frontend — JavaScript
|
|
|
|
**Files:**
|
|
- Create: `static/js/conversations.js`
|
|
|
|
- [ ] **Step 1: Create conversations.js**
|
|
|
|
Main JS file handling all client-side logic. Modules (as functions/objects, no build step):
|
|
|
|
**ConversationList** — render conversation items, handle selection, search filtering, update on new messages, unread badges, muted/archived indicators
|
|
|
|
**ChatView** — render messages for selected conversation, cursor-based scroll-up loading, scroll to bottom on new message, date separators, read receipt indicators (ptaszki for 1:1, avatars for groups)
|
|
|
|
**MessageActions** — context menu on hover/long-press: reply, react, forward, pin, edit, delete. Reply-to quote UI. Edit mode. Delete confirmation.
|
|
|
|
**Reactions** — emoji picker (6 emoji), toggle reaction via API, render pill badges, update via SSE
|
|
|
|
**Composer** — Quill editor init, send on Enter (Shift+Enter = newline), attachment drag&drop (reuse existing pattern from compose.html), typing indicator (debounce 2s, POST to /api/conversations/<id>/typing)
|
|
|
|
**SSEClient** — connect to /api/messages/stream, handle events (new_message, message_read, typing, reaction, message_edited, message_deleted, message_pinned, presence), heartbeat every 30s, auto-reconnect on disconnect
|
|
|
|
**Presence** — batch fetch /api/users/presence for visible conversation members, update online dots and "last seen" text, refresh every 60s
|
|
|
|
**Search** — filter conversation list client-side by name, search within conversation via API
|
|
|
|
**Pins** — fetch/render pinned messages bar, pin/unpin actions
|
|
|
|
**LinkPreview** — render link_preview JSON as card under message content
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add static/js/conversations.js
|
|
git commit -m "feat(messages): add conversation view JavaScript"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 11: Old Routes — Redirect + Backward Compat
|
|
|
|
**Files:**
|
|
- Modify: `blueprints/messages/routes.py`
|
|
|
|
- [ ] **Step 1: Redirect old inbox/sent to new view**
|
|
|
|
At the top of `messages_inbox` and `messages_sent` functions, add redirect:
|
|
|
|
```python
|
|
@bp.route('/wiadomosci/stare')
|
|
@login_required
|
|
@member_required
|
|
def messages_inbox():
|
|
# Old inbox — keep for direct links but redirect
|
|
...existing code...
|
|
|
|
# New primary route
|
|
@bp.route('/wiadomosci')
|
|
@login_required
|
|
@member_required
|
|
def conversations_view():
|
|
return redirect(url_for('messages.conversations'))
|
|
```
|
|
|
|
Change old `/wiadomosci` route to `/wiadomosci/stare` so existing bookmarks still work but new links go to conversations view.
|
|
|
|
- [ ] **Step 2: Update nav links**
|
|
|
|
In `templates/base.html`, update the messages nav link to point to new route.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add blueprints/messages/routes.py templates/base.html
|
|
git commit -m "feat(messages): redirect old inbox to new conversation view"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 12: Data Migration Script
|
|
|
|
**Files:**
|
|
- Create: `scripts/migrate_messages.py`
|
|
|
|
- [ ] **Step 1: Create migration script**
|
|
|
|
Script that:
|
|
1. Groups private_messages by unique (sender_id, recipient_id) pairs → creates Conversation per pair (is_group=False)
|
|
2. Maps message_group → Conversation (is_group=True) with name, owner
|
|
3. Maps message_group_member → ConversationMember with role, last_read_at
|
|
4. Maps private_messages → ConvMessage preserving content, sender_id, created_at
|
|
5. Maps parent_id → reply_to_id using old→new ID mapping
|
|
6. Maps group_message → ConvMessage
|
|
7. Updates message_attachments.conv_message_id using old→new mapping
|
|
8. Sets conversation.last_message_id and updated_at
|
|
9. Computes last_read_at for 1:1 members from PrivateMessage.read_at
|
|
10. Validation: count old messages == count new messages, print summary
|
|
|
|
Usage: `DATABASE_URL=... python3 scripts/migrate_messages.py [--dry-run]`
|
|
|
|
- [ ] **Step 2: Test on dev database**
|
|
|
|
```bash
|
|
# Start Docker DB
|
|
docker compose up -d
|
|
|
|
# Run migration (dry run)
|
|
DATABASE_URL=postgresql://nordabiz:nordabiz@localhost:5433/nordabiz python3 scripts/migrate_messages.py --dry-run
|
|
|
|
# Run migration (real)
|
|
DATABASE_URL=postgresql://nordabiz:nordabiz@localhost:5433/nordabiz python3 scripts/migrate_messages.py
|
|
```
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add scripts/migrate_messages.py
|
|
git commit -m "feat(messages): add data migration script (old → unified model)"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 13: Nginx SSE Configuration
|
|
|
|
**Files:**
|
|
- No code files — server configuration via SSH
|
|
|
|
- [ ] **Step 1: Add SSE proxy config on staging**
|
|
|
|
SSH to R11-REVPROXY-01 and add custom nginx config for the SSE endpoint:
|
|
|
|
```nginx
|
|
location /api/messages/stream {
|
|
proxy_pass http://10.22.68.248:5000;
|
|
proxy_set_header Connection '';
|
|
proxy_http_version 1.1;
|
|
chunked_transfer_encoding off;
|
|
proxy_buffering off;
|
|
proxy_cache off;
|
|
proxy_read_timeout 86400s;
|
|
}
|
|
```
|
|
|
|
This can be added via NPM's "Advanced" tab for the staging proxy host (ID 44).
|
|
|
|
- [ ] **Step 2: Test SSE on staging**
|
|
|
|
```bash
|
|
curl -N -H "Cookie: session=..." https://staging.nordabiznes.pl/api/messages/stream
|
|
```
|
|
|
|
Should receive `data: {"event": "connected"}` immediately.
|
|
|
|
- [ ] **Step 3: Document**
|
|
|
|
Add SSE config note to `docs/architecture/08-critical-configurations.md`.
|
|
|
|
---
|
|
|
|
## Task 14: Deploy + Production Migration
|
|
|
|
- [ ] **Step 1: Install Redis on production VM**
|
|
|
|
```bash
|
|
ssh maciejpi@57.128.200.27 "sudo apt install redis-server -y && sudo systemctl enable redis-server"
|
|
```
|
|
|
|
- [ ] **Step 2: Deploy code to staging**
|
|
|
|
```bash
|
|
git push origin master && git push inpi master
|
|
ssh maciejpi@10.22.68.248 "cd /var/www/nordabiznes && sudo -u www-data git pull && sudo systemctl restart nordabiznes"
|
|
```
|
|
|
|
- [ ] **Step 3: Run migration on staging**
|
|
|
|
```bash
|
|
# Schema
|
|
ssh maciejpi@10.22.68.248 "cd /var/www/nordabiznes && DATABASE_URL=\$(grep DATABASE_URL .env | cut -d'=' -f2) /var/www/nordabiznes/venv/bin/python3 scripts/run_migration.py database/migrations/091_messaging_redesign.sql"
|
|
|
|
# Data migration
|
|
ssh maciejpi@10.22.68.248 "cd /var/www/nordabiznes && DATABASE_URL=\$(grep DATABASE_URL .env | cut -d'=' -f2) /var/www/nordabiznes/venv/bin/python3 scripts/migrate_messages.py"
|
|
```
|
|
|
|
- [ ] **Step 4: Test on staging**
|
|
|
|
Verify at `https://staging.nordabiznes.pl/wiadomosci`:
|
|
- Conversation list renders with migrated data
|
|
- Opening a conversation shows message history
|
|
- Sending a message works
|
|
- SSE events arrive in real-time
|
|
- Reactions, pins, typing indicator work
|
|
- Mobile view works
|
|
|
|
- [ ] **Step 5: Deploy to production (after staging verification)**
|
|
|
|
```bash
|
|
# Same steps as staging but on 57.128.200.27
|
|
# Plus: add SSE nginx config to production proxy host (ID 27)
|
|
```
|
|
|
|
- [ ] **Step 6: Update release notes**
|
|
|
|
Add to `_get_releases()` in `blueprints/public/routes.py`.
|
|
|
|
---
|
|
|
|
## Task 15: Unit Tests
|
|
|
|
**Files:**
|
|
- Create: `tests/unit/test_conversation_models.py`
|
|
- Create: `tests/unit/test_conversation_api.py`
|
|
- Create: `tests/unit/test_link_preview.py`
|
|
|
|
- [ ] **Step 1: Model tests**
|
|
|
|
Test Conversation, ConversationMember, ConvMessage creation, relationships, display_name property, member_count.
|
|
|
|
- [ ] **Step 2: Link preview tests**
|
|
|
|
Test URL extraction, OG parsing, internal domain skipping, timeout handling.
|
|
|
|
- [ ] **Step 3: API tests**
|
|
|
|
Test conversation creation (1:1 dedup), message sending, reaction toggle, pin/unpin, mute/archive settings, membership checks.
|
|
|
|
- [ ] **Step 4: Run all tests**
|
|
|
|
```bash
|
|
pytest tests/unit/test_conversation_models.py tests/unit/test_conversation_api.py tests/unit/test_link_preview.py -v
|
|
```
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add tests/unit/test_conversation_*.py tests/unit/test_link_preview.py
|
|
git commit -m "test(messages): add unit tests for conversation system"
|
|
```
|