messaging system uses userid straight from the DB isntead of tokens
This commit is contained in:
1 parent
6fdecaaff9
commit
4975fc71e3
6 files changed
+500
-3
No files matched your search
+119
-1
@@ -509,4 +509,122 @@ def update_user_settings(user_id, username, email, account_type=None):
|
||||
"UPDATE users SET username = ?, email = ? WHERE user_id = ?",
|
||||
(username, email, user_id)
|
||||
)
|
||||
return True, "Settings updated successfully"
|
||||
return True, "Settings updated successfully"
|
||||
|
||||
# ── messaging system ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
def send_message(sender_id, receiver_id, subject, body, parent_message_id=None):
|
||||
"""Send a new message"""
|
||||
with get_db() as conn:
|
||||
cursor = conn.execute("""
|
||||
INSERT INTO messages (sender_id, receiver_id, subject, body, parent_message_id)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""", (sender_id, receiver_id, subject, body, parent_message_id))
|
||||
return cursor.lastrowid
|
||||
|
||||
|
||||
def get_user_conversations(user_id):
|
||||
"""Get all conversations for a user (unique correspondents)"""
|
||||
with get_db() as conn:
|
||||
rows = conn.execute("""
|
||||
SELECT DISTINCT
|
||||
u.user_id,
|
||||
u.username,
|
||||
u.email,
|
||||
MAX(m.created_at) as last_message,
|
||||
COUNT(CASE WHEN m.is_read = 0 AND m.receiver_id = ? THEN 1 END) as unread_count
|
||||
FROM messages m
|
||||
JOIN users u ON (u.user_id = m.sender_id OR u.user_id = m.receiver_id)
|
||||
WHERE (m.sender_id = ? OR m.receiver_id = ?)
|
||||
AND u.user_id != ?
|
||||
AND ((m.sender_id = ? AND m.sender_deleted = 0) OR (m.receiver_id = ? AND m.receiver_deleted = 0))
|
||||
GROUP BY u.user_id
|
||||
ORDER BY last_message DESC
|
||||
""", (user_id, user_id, user_id, user_id, user_id, user_id))
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
|
||||
def get_messages_between_users(user1_id, user2_id, limit=50, offset=0):
|
||||
"""Get message history between two users"""
|
||||
with get_db() as conn:
|
||||
rows = conn.execute("""
|
||||
SELECT m.*,
|
||||
u1.username as sender_name,
|
||||
u2.username as receiver_name
|
||||
FROM messages m
|
||||
JOIN users u1 ON m.sender_id = u1.user_id
|
||||
JOIN users u2 ON m.receiver_id = u2.user_id
|
||||
WHERE ((m.sender_id = ? AND m.receiver_id = ?) OR (m.sender_id = ? AND m.receiver_id = ?))
|
||||
AND ((m.sender_id = ? AND m.sender_deleted = 0) OR (m.receiver_id = ? AND m.receiver_deleted = 0))
|
||||
ORDER BY m.created_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
""", (user1_id, user2_id, user2_id, user1_id, user1_id, user2_id, limit, offset))
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
|
||||
def mark_message_as_read(message_id, user_id):
|
||||
"""Mark a specific message as read"""
|
||||
with get_db() as conn:
|
||||
conn.execute("""
|
||||
UPDATE messages
|
||||
SET is_read = 1
|
||||
WHERE message_id = ? AND receiver_id = ?
|
||||
""", (message_id, user_id))
|
||||
|
||||
|
||||
def mark_conversation_as_read(user_id, other_user_id):
|
||||
"""Mark all messages from a specific user as read"""
|
||||
with get_db() as conn:
|
||||
conn.execute("""
|
||||
UPDATE messages
|
||||
SET is_read = 1
|
||||
WHERE sender_id = ? AND receiver_id = ? AND is_read = 0
|
||||
""", (other_user_id, user_id))
|
||||
|
||||
|
||||
def delete_message_for_user(message_id, user_id):
|
||||
"""Soft delete a message for a specific user"""
|
||||
with get_db() as conn:
|
||||
conn.execute("""
|
||||
UPDATE messages
|
||||
SET sender_deleted = CASE WHEN sender_id = ? THEN 1 ELSE sender_deleted END,
|
||||
receiver_deleted = CASE WHEN receiver_id = ? THEN 1 ELSE receiver_deleted END
|
||||
WHERE message_id = ?
|
||||
""", (user_id, user_id, message_id))
|
||||
|
||||
|
||||
def get_unread_message_count(user_id):
|
||||
"""Get total unread messages for a user"""
|
||||
with get_db() as conn:
|
||||
row = conn.execute("""
|
||||
SELECT COUNT(*) as count
|
||||
FROM messages
|
||||
WHERE receiver_id = ? AND is_read = 0 AND receiver_deleted = 0
|
||||
""", (user_id,)).fetchone()
|
||||
return row['count'] if row else 0
|
||||
|
||||
|
||||
def search_users(query, current_user_id, limit=10):
|
||||
"""Search for users to message"""
|
||||
with get_db() as conn:
|
||||
rows = conn.execute("""
|
||||
SELECT user_id, username, email, account_type
|
||||
FROM users
|
||||
WHERE (username LIKE ? OR email LIKE ?)
|
||||
AND user_id != ?
|
||||
LIMIT ?
|
||||
""", (f'%{query}%', f'%{query}%', current_user_id, limit))
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
|
||||
def get_user_by_id(user_id):
|
||||
"""Get user by ID - returns user info for messaging"""
|
||||
with get_db() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT user_id, username, email, account_type FROM users WHERE user_id = ? LIMIT 1",
|
||||
(user_id,)
|
||||
).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
from flask import render_template, Blueprint, request, redirect, url_for, flash, g, jsonify
|
||||
from database.db_commands import (
|
||||
send_message, get_user_conversations, get_messages_between_users,
|
||||
mark_conversation_as_read, delete_message_for_user,
|
||||
get_unread_message_count, search_users, get_user_by_id
|
||||
)
|
||||
|
||||
page = Blueprint("messages", __name__, url_prefix="/messages")
|
||||
|
||||
|
||||
@page.before_request
|
||||
def require_login():
|
||||
if not g.user:
|
||||
flash("You must be logged in to access messages.", "info")
|
||||
return redirect(url_for("login.login"))
|
||||
|
||||
|
||||
@page.route("/")
|
||||
def inbox():
|
||||
conversations = get_user_conversations(g.user["user_id"])
|
||||
unread_total = get_unread_message_count(g.user["user_id"])
|
||||
return render_template(
|
||||
"messages/inbox.jinja",
|
||||
conversations=conversations,
|
||||
unread_total=unread_total
|
||||
)
|
||||
|
||||
|
||||
@page.route("/conversation/<int:user_id>")
|
||||
def conversation(user_id):
|
||||
# Mark messages as read
|
||||
mark_conversation_as_read(g.user["user_id"], user_id)
|
||||
messages = get_messages_between_users(g.user["user_id"], user_id)
|
||||
|
||||
other_user = get_user_by_id(user_id)
|
||||
if not other_user:
|
||||
flash("User not found.", "info")
|
||||
return redirect(url_for("messages.inbox"))
|
||||
|
||||
return render_template(
|
||||
"messages/conversation.jinja",
|
||||
messages=messages,
|
||||
other_user=other_user,
|
||||
recipient_id=user_id
|
||||
)
|
||||
|
||||
|
||||
@page.route("/send", methods=["POST"])
|
||||
def send():
|
||||
recipient_id = request.form.get("recipient_id")
|
||||
subject = request.form.get("subject", "").strip()
|
||||
body = request.form.get("body", "").strip()
|
||||
parent_id = request.form.get("parent_message_id")
|
||||
|
||||
if not body:
|
||||
flash("Message cannot be empty.", "info")
|
||||
return redirect(request.referrer or url_for("messages.inbox"))
|
||||
|
||||
if not recipient_id:
|
||||
flash("user cannot be empty.", "info")
|
||||
return redirect(request.referrer or url_for("messages.inbox"))
|
||||
|
||||
|
||||
send_message(
|
||||
g.user["user_id"],
|
||||
int(recipient_id),
|
||||
subject[:200] if subject else None,
|
||||
body,
|
||||
int(parent_id) if parent_id else None
|
||||
)
|
||||
|
||||
flash("Message sent!", "info")
|
||||
|
||||
if parent_id:
|
||||
return redirect(url_for("messages.conversation", user_id=recipient_id))
|
||||
return redirect(url_for("messages.inbox"))
|
||||
|
||||
|
||||
@page.route("/delete/<int:message_id>", methods=["POST"])
|
||||
def delete(message_id):
|
||||
delete_message_for_user(message_id, g.user["user_id"])
|
||||
flash("Message deleted.", "info")
|
||||
return redirect(request.referrer or url_for("messages.inbox"))
|
||||
|
||||
|
||||
@page.route("/unread-count")
|
||||
def unread_count():
|
||||
if not g.user:
|
||||
return jsonify({"count": 0})
|
||||
count = get_unread_message_count(g.user["user_id"])
|
||||
return jsonify({"count": count})
|
||||
|
||||
|
||||
@page.route("/search")
|
||||
def search():
|
||||
query = request.args.get("q", "").strip()
|
||||
if len(query) < 2:
|
||||
return jsonify({"users": []})
|
||||
|
||||
users = search_users(query, g.user["user_id"])
|
||||
return jsonify({"users": users})
|
||||
|
||||
|
||||
@page.route("/compose")
|
||||
def compose():
|
||||
recipient_id = request.args.get("to")
|
||||
recipient_username = None
|
||||
|
||||
if recipient_id:
|
||||
user = get_user_by_id(recipient_id)
|
||||
if user:
|
||||
recipient_username = user["username"]
|
||||
|
||||
return render_template(
|
||||
"messages/compose.jinja",
|
||||
recipient_id=recipient_id,
|
||||
recipient_username=recipient_username
|
||||
)
|
||||
@@ -0,0 +1,101 @@
|
||||
{% extends "base.jinja" %}
|
||||
{% block title %}New Message{% endblock %}
|
||||
{% block content %}
|
||||
{% from 'button.jinja' import button %}
|
||||
|
||||
<section id="hero" style="padding: 40px 20px; text-align: center;">
|
||||
<h1>New Message</h1>
|
||||
</section>
|
||||
|
||||
<section style="padding: 0 20px 40px 20px; max-width: 600px; margin: 0 auto;">
|
||||
<div class="container-full">
|
||||
<form action="{{ url_for('messages.send') }}" method="POST" class="form">
|
||||
<div class="form-group" style="position: relative;">
|
||||
<label for="recipient_search">To:</label>
|
||||
<input type="text" id="recipient_search"
|
||||
placeholder="Search for a user by username..."
|
||||
value="{{ recipient_username if recipient_username else '' }}">
|
||||
<input type="hidden" name="recipient_id" id="recipient_id"
|
||||
value="{{ recipient_id if recipient_id else '' }}">
|
||||
<div id="search-results" style="
|
||||
position: absolute;
|
||||
background: var(--background);
|
||||
border: 1px solid var(--primary);
|
||||
border-radius: 8px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
width: calc(100% - 40px);
|
||||
display: none;
|
||||
z-index: 100;
|
||||
"></div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="subject">Subject (optional):</label>
|
||||
<input type="text" id="subject" name="subject" maxlength="200">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="body">Message:</label>
|
||||
<textarea id="body" name="body" rows="6" required></textarea>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="submit-button">Send Message</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 20px; text-align: center;">
|
||||
{{ button(url='/messages', button_text='Cancel', extra_classes='homepage-btn') }}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
const searchInput = document.getElementById('recipient_search');
|
||||
const recipientIdInput = document.getElementById('recipient_id');
|
||||
const resultsDiv = document.getElementById('search-results');
|
||||
let searchTimeout;
|
||||
|
||||
searchInput.addEventListener('input', function() {
|
||||
clearTimeout(searchTimeout);
|
||||
const query = this.value.trim();
|
||||
|
||||
if (query.length < 2) {
|
||||
resultsDiv.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
searchTimeout = setTimeout(() => {
|
||||
fetch(`/messages/search?q=${encodeURIComponent(query)}`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.users.length > 0) {
|
||||
resultsDiv.innerHTML = data.users.map(user => `
|
||||
<div style="padding: 10px; cursor: pointer; border-bottom: 1px solid var(--primary);"
|
||||
onclick="selectUser(${user.user_id}, '${user.username.replace(/'/g, "\\'")}')">
|
||||
<strong>${user.username}</strong><br>
|
||||
<small>${user.email}</small>
|
||||
</div>
|
||||
`).join('');
|
||||
resultsDiv.style.display = 'block';
|
||||
} else {
|
||||
resultsDiv.innerHTML = '<div style="padding: 10px;">No users found</div>';
|
||||
resultsDiv.style.display = 'block';
|
||||
}
|
||||
});
|
||||
}, 300);
|
||||
});
|
||||
|
||||
function selectUser(id, username) {
|
||||
searchInput.value = username;
|
||||
recipientIdInput.value = id;
|
||||
resultsDiv.style.display = 'none';
|
||||
}
|
||||
|
||||
// Close results when clicking outside
|
||||
document.addEventListener('click', function(e) {
|
||||
if (!searchInput.contains(e.target) && !resultsDiv.contains(e.target)) {
|
||||
resultsDiv.style.display = 'none';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,60 @@
|
||||
{% extends "base.jinja" %}
|
||||
{% block title %}Conversation with {{ other_user.username }}{% endblock %}
|
||||
{% block content %}
|
||||
{% from 'button.jinja' import button %}
|
||||
|
||||
<section id="hero" style="padding: 40px 20px; text-align: center;">
|
||||
<h1>Conversation with {{ other_user.username }}</h1>
|
||||
<p style="opacity: 0.7;">{{ other_user.email }}</p>
|
||||
</section>
|
||||
|
||||
<section style="padding: 0 20px 40px 20px; max-width: 700px; margin: 0 auto;">
|
||||
|
||||
{# Message thread #}
|
||||
<div class="container-full" style="margin-bottom: 20px; max-height: 500px; overflow-y: auto;">
|
||||
{% if messages %}
|
||||
{% for msg in messages|reverse %}
|
||||
<div style="
|
||||
margin-bottom: 20px;
|
||||
text-align: {% if msg.sender_id == user.user_id %}right{% else %}left{% endif %};
|
||||
">
|
||||
<div style="
|
||||
display: inline-block;
|
||||
max-width: 80%;
|
||||
background: {% if msg.sender_id == user.user_id %}var(--primary_darker){% else %}var(--background){% endif %};
|
||||
border: 1.5px solid var(--primary);
|
||||
border-radius: 12px;
|
||||
padding: 12px 16px;
|
||||
">
|
||||
{% if msg.subject %}
|
||||
<strong style="display: block; margin-bottom: 4px;">{{ msg.subject }}</strong>
|
||||
{% endif %}
|
||||
<p style="margin: 0; white-space: pre-wrap;">{{ msg.body }}</p>
|
||||
<small style="opacity: 0.5; display: block; margin-top: 6px;">
|
||||
{{ msg.created_at[:16] }}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<p style="text-align: center; opacity: 0.6;">No messages yet. Start the conversation!</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# Reply form #}
|
||||
<div class="container-full">
|
||||
<h3>Send a reply</h3>
|
||||
<form action="{{ url_for('messages.send') }}" method="POST" class="form">
|
||||
<input type="hidden" name="recipient_id" value="{{ other_user.user_id }}">
|
||||
<div class="form-group">
|
||||
<textarea name="body" rows="4" placeholder="Type your message..." required></textarea>
|
||||
</div>
|
||||
<button type="submit" class="submit-button">Send Message</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 20px; text-align: center;">
|
||||
{{ button(url='/messages', button_text='Back to Inbox', extra_classes='homepage-btn') }}
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,61 @@
|
||||
{% extends "base.jinja" %}
|
||||
{% block title %}Messages{% endblock %}
|
||||
{% block content %}
|
||||
{% from 'button.jinja' import button %}
|
||||
|
||||
<section id="hero" style="padding: 40px 20px; text-align: center;">
|
||||
<h1>Messages</h1>
|
||||
{% if unread_total > 0 %}
|
||||
<p style="color: var(--accent);">You have {{ unread_total }} unread message{{ 's' if unread_total != 1 }}</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<section style="padding: 0 20px 40px 20px; max-width: 800px; margin: 0 auto;">
|
||||
<div style="margin-bottom: 20px; text-align: right;">
|
||||
{{ button(url='/messages/compose', button_text='+ New Message', extra_classes='homepage-btn') }}
|
||||
</div>
|
||||
|
||||
{% if conversations %}
|
||||
<div class="container-full">
|
||||
{% for conv in conversations %}
|
||||
<a href="{{ url_for('messages.conversation', user_id=conv.user_id) }}"
|
||||
style="text-decoration: none; color: var(--text);">
|
||||
<div style="
|
||||
background: var(--background);
|
||||
border: 1.5px solid var(--primary);
|
||||
border-radius: 12px;
|
||||
padding: 15px 20px;
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
transition: border-color 0.2s;
|
||||
">
|
||||
<div>
|
||||
<strong>{{ conv.username }}</strong>
|
||||
{% if conv.unread_count > 0 %}
|
||||
<span style="
|
||||
background: var(--accent);
|
||||
border-radius: 10px;
|
||||
padding: 2px 8px;
|
||||
font-size: 0.75rem;
|
||||
margin-left: 8px;
|
||||
">{{ conv.unread_count }} new</span>
|
||||
{% endif %}
|
||||
<p style="opacity: 0.6; font-size: 0.85rem; margin-top: 4px;">
|
||||
{{ conv.last_message[:16] if conv.last_message else 'No messages' }}
|
||||
</p>
|
||||
</div>
|
||||
<span style="opacity: 0.5;">→</span>
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="container-full" style="text-align: center;">
|
||||
<p style="opacity: 0.6;">No messages yet.</p>
|
||||
{{ button(url='/messages/compose', button_text='Send message', extra_classes='homepage-btn') }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
+41
-2
@@ -24,6 +24,23 @@
|
||||
</div>
|
||||
<a href="/admin">Admin</a>
|
||||
{% endif %}
|
||||
|
||||
<!-- Messages link with unread badge -->
|
||||
<a href="/messages" class="messages-link" style="position: relative;">
|
||||
Messages
|
||||
<span id="unread-badge" style="
|
||||
background: #e74c3c;
|
||||
color: white;
|
||||
border-radius: 10px;
|
||||
padding: 2px 6px;
|
||||
font-size: 0.7rem;
|
||||
margin-left: 5px;
|
||||
display: none;
|
||||
">0</span>
|
||||
</a>
|
||||
|
||||
<a href="/about">About</a>
|
||||
|
||||
<div class="dropdown">
|
||||
<a class="dropbtn">{{user["username"]}} ▾</a>
|
||||
<div class="dropdown-content">
|
||||
@@ -32,10 +49,32 @@
|
||||
<a href="/logout">Logout</a>
|
||||
</div>
|
||||
</div>
|
||||
<a href="/about">About</a>
|
||||
{% else %}
|
||||
{{ button(url='/login', button_text='Login', extra_classes='') }}
|
||||
{{ button(url='/register', button_text='Register', extra_classes='') }}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function updateUnreadCount() {
|
||||
fetch('/messages/unread-count')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
const badge = document.getElementById('unread-badge');
|
||||
if (data.count > 0) {
|
||||
badge.textContent = data.count;
|
||||
badge.style.display = 'inline-block';
|
||||
} else {
|
||||
badge.style.display = 'none';
|
||||
}
|
||||
})
|
||||
.catch(error => console.error('Error fetching unread count:', error));
|
||||
}
|
||||
|
||||
// Update immediately on page load
|
||||
updateUnreadCount();
|
||||
|
||||
// Then update every 30 seconds
|
||||
setInterval(updateUnreadCount, 30000);
|
||||
</script>
|
||||
Reference in new issue
Block a user