source: Klonkt/src/services/PrutterService.js@ 1ee5f65

main
Last change on this file since 1ee5f65 was 7bc636b, checked in by Robin <robin@…>, 4 months ago

Initial commit — PrutFolio v1 source (pulled from Hetzner /srv/prutfolio)

  • Property mode set to 100644
File size: 5.3 KB
Line 
1/**
2 * PrutterService — Real-time Direct Messaging
3 *
4 * Features:
5 * - Per-conversation (user A ↔ user B)
6 * - Optional site-specific (conversations tied to a community)
7 * - WebSocket real-time notifications
8 * - Message history in SQLite
9 * - Unread message tracking
10 */
11
12import { v4 as uuid } from 'uuid';
13
14class PrutterService {
15 constructor(db) {
16 this.db = db;
17 this.wsConnections = new Map(); // userId → Set<WebSocket>
18 }
19
20 /**
21 * Get or create conversation
22 */
23 getOrCreateConversation(userA, userB, siteId = null) {
24 if (!userA || !userB) throw new Error('Both users required');
25
26 // Normalize order: always smaller ID first
27 const [u1, u2] = userA < userB ? [userA, userB] : [userB, userA];
28
29 const existing = this.db.prepare(`
30 SELECT * FROM conversations
31 WHERE (user_a_id = ? AND user_b_id = ? AND site_id IS ?)
32 LIMIT 1
33 `).get(u1, u2, siteId);
34
35 if (existing) {
36 return existing;
37 }
38
39 const convId = uuid();
40 this.db.prepare(`
41 INSERT INTO conversations (id, user_a_id, user_b_id, site_id)
42 VALUES (?, ?, ?, ?)
43 `).run(convId, u1, u2, siteId);
44
45 return { id: convId, user_a_id: u1, user_b_id: u2, site_id: siteId };
46 }
47
48 /**
49 * Send message
50 */
51 sendMessage(conversationId, authorId, content) {
52 if (!conversationId || !authorId || !content) {
53 throw new Error('Missing required fields');
54 }
55
56 const msgId = uuid();
57 const now = new Date().toISOString();
58
59 this.db.prepare(`
60 INSERT INTO messages (id, conversation_id, author_id, content, created_at)
61 VALUES (?, ?, ?, ?, ?)
62 `).run(msgId, conversationId, authorId, content, now);
63
64 // Update last_message_at on conversation
65 this.db.prepare(`
66 UPDATE conversations SET last_message_at = ? WHERE id = ?
67 `).run(now, conversationId);
68
69 // Fetch full message for response
70 const message = this.db.prepare(`
71 SELECT m.*, u.username, u.avatar_url
72 FROM messages m
73 JOIN users u ON m.author_id = u.id
74 WHERE m.id = ?
75 `).get(msgId);
76
77 // Notify recipient via WebSocket (if online)
78 const conv = this.db.prepare('SELECT * FROM conversations WHERE id = ?').get(conversationId);
79 const recipientId = conv.user_a_id === authorId ? conv.user_b_id : conv.user_a_id;
80
81 this.notifyUser(recipientId, {
82 type: 'new_message',
83 conversationId,
84 message
85 });
86
87 return message;
88 }
89
90 /**
91 * Get conversation messages
92 */
93 getMessages(conversationId, limit = 50, offset = 0) {
94 return this.db.prepare(`
95 SELECT m.*, u.username, u.avatar_url
96 FROM messages m
97 JOIN users u ON m.author_id = u.id
98 WHERE m.conversation_id = ?
99 ORDER BY m.created_at DESC
100 LIMIT ? OFFSET ?
101 `).all(conversationId, limit, offset);
102 }
103
104 /**
105 * Get user's conversations (list)
106 */
107 getUserConversations(userId) {
108 return this.db.prepare(`
109 SELECT c.*,
110 CASE
111 WHEN c.user_a_id = ? THEN u2.id
112 ELSE u1.id
113 END as other_user_id,
114 CASE
115 WHEN c.user_a_id = ? THEN u2.username
116 ELSE u1.username
117 END as other_username,
118 CASE
119 WHEN c.user_a_id = ? THEN u2.avatar_url
120 ELSE u1.avatar_url
121 END as other_avatar,
122 (SELECT COUNT(*) FROM messages m
123 WHERE m.conversation_id = c.id
124 AND m.author_id != ?
125 AND m.read_at IS NULL) as unread_count,
126 (SELECT content FROM messages m
127 WHERE m.conversation_id = c.id
128 ORDER BY m.created_at DESC LIMIT 1) as last_message_preview
129 FROM conversations c
130 JOIN users u1 ON c.user_a_id = u1.id
131 JOIN users u2 ON c.user_b_id = u2.id
132 WHERE c.user_a_id = ? OR c.user_b_id = ?
133 ORDER BY c.last_message_at DESC
134 `).all(userId, userId, userId, userId, userId, userId);
135 }
136
137 /**
138 * Mark conversation messages as read
139 */
140 markAsRead(conversationId, userId) {
141 const now = new Date().toISOString();
142 this.db.prepare(`
143 UPDATE messages
144 SET read_at = ?
145 WHERE conversation_id = ? AND author_id != ? AND read_at IS NULL
146 `).run(now, conversationId, userId);
147 }
148
149 /**
150 * WebSocket connection management
151 */
152 addConnection(userId, ws) {
153 if (!this.wsConnections.has(userId)) {
154 this.wsConnections.set(userId, new Set());
155 }
156 this.wsConnections.get(userId).add(ws);
157 }
158
159 removeConnection(userId, ws) {
160 const conns = this.wsConnections.get(userId);
161 if (conns) {
162 conns.delete(ws);
163 if (conns.size === 0) {
164 this.wsConnections.delete(userId);
165 }
166 }
167 }
168
169 /**
170 * Notify user via WebSocket (if online)
171 */
172 notifyUser(userId, message) {
173 const conns = this.wsConnections.get(userId);
174 if (!conns) return;
175
176 const data = JSON.stringify(message);
177 for (const ws of conns) {
178 if (ws.readyState === 1) { // OPEN
179 ws.send(data);
180 }
181 }
182 }
183
184 /**
185 * Broadcast to all users in conversation (except sender)
186 */
187 broadcastToConversation(conversationId, senderUserId, message) {
188 const conv = this.db.prepare('SELECT * FROM conversations WHERE id = ?').get(conversationId);
189 if (!conv) return;
190
191 const otherUserId = conv.user_a_id === senderUserId ? conv.user_b_id : conv.user_a_id;
192 this.notifyUser(otherUserId, message);
193 }
194}
195
196export default PrutterService;
Note: See TracBrowser for help on using the repository browser.