tacel-chat 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/chat-api.js ADDED
@@ -0,0 +1,436 @@
1
+ /**
2
+ * tacel-chat — Backend IPC handler factory
3
+ * Registers all chat IPC handlers using app-provided database functions.
4
+ * This is optional — apps can implement their own handlers instead.
5
+ */
6
+
7
+ function normalizeUsername(username) {
8
+ return (username || '').trim().toLowerCase();
9
+ }
10
+
11
+ /**
12
+ * Initialize chat IPC handlers
13
+ * @param {object} ipcMain - Electron ipcMain
14
+ * @param {object} options
15
+ * @param {string} options.channelPrefix - Optional prefix for IPC channels
16
+ * @param {Function} options.dbQuery - (sql, params) => Promise<Array>
17
+ * @param {Function} options.dbGetOne - (sql, params) => Promise<object|null>
18
+ * @param {Function} options.socketEmit - (event, payload) => void
19
+ * @param {Function} options.broadcastRenderer - (eventName, payload) => void (fallback)
20
+ */
21
+ function initChatAPI(ipcMain, options = {}) {
22
+ const prefix = options.channelPrefix || '';
23
+ const dbQuery = options.dbQuery;
24
+ const dbGetOne = options.dbGetOne;
25
+ const socketEmit = options.socketEmit || (() => {});
26
+ const broadcastRenderer = options.broadcastRenderer || (() => {});
27
+
28
+ function ch(name) { return prefix + name; }
29
+
30
+ function emit(event, payload) {
31
+ try {
32
+ socketEmit(event, payload);
33
+ } catch {
34
+ broadcastRenderer(event, payload);
35
+ }
36
+ }
37
+
38
+ // ── List Users ──────────────────────────────────────────────
39
+ ipcMain.handle(ch('chat-list-users'), async () => {
40
+ try {
41
+ const rows = await dbQuery(
42
+ 'SELECT id, username, app, is_online, last_seen, global_key FROM chat_users ORDER BY username'
43
+ );
44
+ return { success: true, data: rows };
45
+ } catch (err) {
46
+ console.error('[TacelChatAPI] chat-list-users error:', err);
47
+ return { success: false, error: err.message };
48
+ }
49
+ });
50
+
51
+ // ── Presence Heartbeat ──────────────────────────────────────
52
+ ipcMain.handle(ch('chat-presence-heartbeat'), async (event, { current_username }) => {
53
+ try {
54
+ const gk = normalizeUsername(current_username);
55
+ await dbQuery(
56
+ 'UPDATE chat_users SET is_online = 1, last_seen = CURRENT_TIMESTAMP WHERE global_key = ?',
57
+ [gk]
58
+ );
59
+ return { success: true };
60
+ } catch (err) {
61
+ console.error('[TacelChatAPI] chat-presence-heartbeat error:', err);
62
+ return { success: false, error: err.message };
63
+ }
64
+ });
65
+
66
+ // ── Presence Offline ────────────────────────────────────────
67
+ ipcMain.handle(ch('chat-presence-offline'), async (event, { current_username }) => {
68
+ try {
69
+ const gk = normalizeUsername(current_username);
70
+ await dbQuery(
71
+ 'UPDATE chat_users SET is_online = 0, last_seen = CURRENT_TIMESTAMP WHERE global_key = ?',
72
+ [gk]
73
+ );
74
+ return { success: true };
75
+ } catch (err) {
76
+ console.error('[TacelChatAPI] chat-presence-offline error:', err);
77
+ return { success: false, error: err.message };
78
+ }
79
+ });
80
+
81
+ // ── Get Direct Peers ────────────────────────────────────────
82
+ ipcMain.handle(ch('chat-get-direct-peers'), async (event, { current_username }) => {
83
+ try {
84
+ const gk = normalizeUsername(current_username);
85
+ const me = await dbGetOne('SELECT id FROM chat_users WHERE global_key = ?', [gk]);
86
+ if (!me) return { success: true, data: [] };
87
+
88
+ const rows = await dbQuery(`
89
+ SELECT DISTINCT p2.user_id
90
+ FROM chat_participants p1
91
+ JOIN chat_participants p2 ON p1.conversation_id = p2.conversation_id
92
+ JOIN chat_conversations c ON c.id = p1.conversation_id
93
+ WHERE p1.user_id = ? AND p2.user_id != ? AND c.type = 'direct'
94
+ AND p1.status = 'active' AND p2.status = 'active'
95
+ `, [me.id, me.id]);
96
+
97
+ return { success: true, data: rows.map(r => r.user_id) };
98
+ } catch (err) {
99
+ console.error('[TacelChatAPI] chat-get-direct-peers error:', err);
100
+ return { success: false, error: err.message };
101
+ }
102
+ });
103
+
104
+ // ── Get Conversations ───────────────────────────────────────
105
+ ipcMain.handle(ch('chat-get-conversations'), async (event, { current_username }) => {
106
+ try {
107
+ const gk = normalizeUsername(current_username);
108
+ const me = await dbGetOne('SELECT id FROM chat_users WHERE global_key = ?', [gk]);
109
+ if (!me) return { success: true, data: [] };
110
+
111
+ const rows = await dbQuery(`
112
+ SELECT c.id, c.type, c.name,
113
+ CASE
114
+ WHEN c.type = 'direct' THEN (
115
+ SELECT u.username FROM chat_participants p2
116
+ JOIN chat_users u ON u.id = p2.user_id
117
+ WHERE p2.conversation_id = c.id AND p2.user_id != ? AND p2.status = 'active'
118
+ LIMIT 1
119
+ )
120
+ ELSE c.name
121
+ END AS display_name,
122
+ (SELECT m.content FROM chat_messages m WHERE m.conversation_id = c.id ORDER BY m.timestamp DESC LIMIT 1) AS last_message,
123
+ (SELECT m.timestamp FROM chat_messages m WHERE m.conversation_id = c.id ORDER BY m.timestamp DESC LIMIT 1) AS last_timestamp
124
+ FROM chat_conversations c
125
+ JOIN chat_participants p ON p.conversation_id = c.id
126
+ WHERE p.user_id = ? AND p.status = 'active'
127
+ ORDER BY last_timestamp DESC
128
+ `, [me.id, me.id]);
129
+
130
+ return { success: true, data: rows };
131
+ } catch (err) {
132
+ console.error('[TacelChatAPI] chat-get-conversations error:', err);
133
+ return { success: false, error: err.message };
134
+ }
135
+ });
136
+
137
+ // ── Get Messages ────────────────────────────────────────────
138
+ ipcMain.handle(ch('chat-get-messages'), async (event, { conversation_id }) => {
139
+ try {
140
+ const rows = await dbQuery(`
141
+ SELECT m.id, m.content, m.timestamp, u.username AS sender, m.sender_id
142
+ FROM chat_messages m
143
+ JOIN chat_users u ON u.id = m.sender_id
144
+ WHERE m.conversation_id = ?
145
+ ORDER BY m.timestamp ASC
146
+ `, [conversation_id]);
147
+ return { success: true, data: rows };
148
+ } catch (err) {
149
+ console.error('[TacelChatAPI] chat-get-messages error:', err);
150
+ return { success: false, error: err.message };
151
+ }
152
+ });
153
+
154
+ // ── Start Direct Conversation ───────────────────────────────
155
+ ipcMain.handle(ch('chat-start-direct'), async (event, { current_username, other_user_id }) => {
156
+ try {
157
+ const gk = normalizeUsername(current_username);
158
+ const me = await dbGetOne('SELECT id FROM chat_users WHERE global_key = ?', [gk]);
159
+ if (!me) return { success: false, error: 'User not found' };
160
+
161
+ // Check for existing direct conversation
162
+ const existing = await dbGetOne(`
163
+ SELECT p1.conversation_id
164
+ FROM chat_participants p1
165
+ JOIN chat_participants p2 ON p1.conversation_id = p2.conversation_id
166
+ JOIN chat_conversations c ON c.id = p1.conversation_id
167
+ WHERE p1.user_id = ? AND p2.user_id = ? AND c.type = 'direct'
168
+ AND p1.status = 'active' AND p2.status = 'active'
169
+ `, [me.id, other_user_id]);
170
+
171
+ if (existing) {
172
+ return { success: true, conversation_id: existing.conversation_id };
173
+ }
174
+
175
+ // Create new conversation
176
+ const convResult = await dbQuery(
177
+ "INSERT INTO chat_conversations (type, created_by, created_at, updated_at) VALUES ('direct', ?, NOW(), NOW())",
178
+ [me.id]
179
+ );
180
+ const convId = convResult.insertId;
181
+
182
+ // Add participants
183
+ await dbQuery(
184
+ 'INSERT INTO chat_participants (conversation_id, user_id, status, joined_at) VALUES (?, ?, \'active\', NOW())',
185
+ [convId, me.id]
186
+ );
187
+ await dbQuery(
188
+ 'INSERT INTO chat_participants (conversation_id, user_id, status, joined_at) VALUES (?, ?, \'active\', NOW())',
189
+ [convId, other_user_id]
190
+ );
191
+
192
+ emit('chat:new_conversation', { conversation_id: convId });
193
+ return { success: true, conversation_id: convId };
194
+ } catch (err) {
195
+ console.error('[TacelChatAPI] chat-start-direct error:', err);
196
+ return { success: false, error: err.message };
197
+ }
198
+ });
199
+
200
+ // ── Send Message ────────────────────────────────────────────
201
+ ipcMain.handle(ch('chat-send-message'), async (event, { conversation_id, current_username, content }) => {
202
+ try {
203
+ const gk = normalizeUsername(current_username);
204
+ const me = await dbGetOne('SELECT id, username FROM chat_users WHERE global_key = ?', [gk]);
205
+ if (!me) return { success: false, error: 'User not found' };
206
+
207
+ const result = await dbQuery(
208
+ 'INSERT INTO chat_messages (conversation_id, sender_id, content, timestamp) VALUES (?, ?, ?, NOW())',
209
+ [conversation_id, me.id, content]
210
+ );
211
+
212
+ // Touch conversation updated_at
213
+ await dbQuery('UPDATE chat_conversations SET updated_at = NOW() WHERE id = ?', [conversation_id]);
214
+
215
+ // Insert sender's own read receipt
216
+ await dbQuery(
217
+ 'INSERT INTO chat_message_reads (message_id, user_id, read_at) VALUES (?, ?, NOW())',
218
+ [result.insertId, me.id]
219
+ );
220
+
221
+ emit('chat:new_message', {
222
+ conversation_id,
223
+ message_id: result.insertId,
224
+ sender: me.username,
225
+ sender_id: me.id,
226
+ content,
227
+ timestamp: new Date().toISOString()
228
+ });
229
+
230
+ return { success: true, message_id: result.insertId };
231
+ } catch (err) {
232
+ console.error('[TacelChatAPI] chat-send-message error:', err);
233
+ return { success: false, error: err.message };
234
+ }
235
+ });
236
+
237
+ // ── Get Unread Counts ───────────────────────────────────────
238
+ ipcMain.handle(ch('chat-get-unread-counts'), async (event, { current_username }) => {
239
+ try {
240
+ const gk = normalizeUsername(current_username);
241
+ const me = await dbGetOne('SELECT id FROM chat_users WHERE global_key = ?', [gk]);
242
+ if (!me) return { success: true, data: [] };
243
+
244
+ const rows = await dbQuery(`
245
+ SELECT m.conversation_id, COUNT(*) AS unread_count
246
+ FROM chat_messages m
247
+ JOIN chat_participants p ON p.conversation_id = m.conversation_id AND p.user_id = ? AND p.status = 'active'
248
+ WHERE m.sender_id != ?
249
+ AND NOT EXISTS (
250
+ SELECT 1 FROM chat_message_reads r WHERE r.message_id = m.id AND r.user_id = ?
251
+ )
252
+ GROUP BY m.conversation_id
253
+ `, [me.id, me.id, me.id]);
254
+
255
+ return { success: true, data: rows };
256
+ } catch (err) {
257
+ console.error('[TacelChatAPI] chat-get-unread-counts error:', err);
258
+ return { success: false, error: err.message };
259
+ }
260
+ });
261
+
262
+ // ── Mark Read ───────────────────────────────────────────────
263
+ ipcMain.handle(ch('chat-mark-read'), async (event, { conversation_id, current_username }) => {
264
+ try {
265
+ const gk = normalizeUsername(current_username);
266
+ const me = await dbGetOne('SELECT id FROM chat_users WHERE global_key = ?', [gk]);
267
+ if (!me) return { success: true, marked: 0 };
268
+
269
+ // Find unread messages from others
270
+ const unread = await dbQuery(`
271
+ SELECT m.id FROM chat_messages m
272
+ WHERE m.conversation_id = ? AND m.sender_id != ?
273
+ AND NOT EXISTS (
274
+ SELECT 1 FROM chat_message_reads r WHERE r.message_id = m.id AND r.user_id = ?
275
+ )
276
+ `, [conversation_id, me.id, me.id]);
277
+
278
+ for (const msg of unread) {
279
+ await dbQuery(
280
+ 'INSERT INTO chat_message_reads (message_id, user_id, read_at) VALUES (?, ?, NOW())',
281
+ [msg.id, me.id]
282
+ );
283
+ }
284
+
285
+ if (unread.length > 0) {
286
+ emit('chat:read', { conversation_id, user_id: me.id, username: current_username });
287
+ }
288
+
289
+ return { success: true, marked: unread.length };
290
+ } catch (err) {
291
+ console.error('[TacelChatAPI] chat-mark-read error:', err);
292
+ return { success: false, error: err.message };
293
+ }
294
+ });
295
+
296
+ // ── Get Message Reads ───────────────────────────────────────
297
+ ipcMain.handle(ch('chat-get-message-reads'), async (event, { conversation_id }) => {
298
+ try {
299
+ const rows = await dbQuery(`
300
+ SELECT r.message_id, r.user_id, u.username, r.read_at
301
+ FROM chat_message_reads r
302
+ JOIN chat_users u ON u.id = r.user_id
303
+ JOIN chat_messages m ON m.id = r.message_id
304
+ WHERE m.conversation_id = ?
305
+ ORDER BY r.read_at ASC
306
+ `, [conversation_id]);
307
+ return { success: true, data: rows };
308
+ } catch (err) {
309
+ console.error('[TacelChatAPI] chat-get-message-reads error:', err);
310
+ return { success: false, error: err.message };
311
+ }
312
+ });
313
+
314
+ // ── Create Team ─────────────────────────────────────────────
315
+ ipcMain.handle(ch('chat-create-team'), async (event, { current_username, name, member_ids }) => {
316
+ try {
317
+ const gk = normalizeUsername(current_username);
318
+ const me = await dbGetOne('SELECT id FROM chat_users WHERE global_key = ?', [gk]);
319
+ if (!me) return { success: false, error: 'User not found' };
320
+ if (!member_ids || member_ids.length < 2) return { success: false, error: 'Need at least 2 members' };
321
+
322
+ // Ensure creator is in member list
323
+ const allMembers = new Set(member_ids.map(Number));
324
+ allMembers.add(me.id);
325
+
326
+ const convResult = await dbQuery(
327
+ "INSERT INTO chat_conversations (type, name, created_by, created_at, updated_at) VALUES ('team', ?, ?, NOW(), NOW())",
328
+ [name, me.id]
329
+ );
330
+ const convId = convResult.insertId;
331
+
332
+ for (const uid of allMembers) {
333
+ await dbQuery(
334
+ 'INSERT INTO chat_participants (conversation_id, user_id, status, joined_at) VALUES (?, ?, \'active\', NOW())',
335
+ [convId, uid]
336
+ );
337
+ }
338
+
339
+ emit('chat:new_conversation', { conversation_id: convId });
340
+ return { success: true, conversation_id: convId };
341
+ } catch (err) {
342
+ console.error('[TacelChatAPI] chat-create-team error:', err);
343
+ return { success: false, error: err.message };
344
+ }
345
+ });
346
+
347
+ // ── Update Team ─────────────────────────────────────────────
348
+ ipcMain.handle(ch('chat-update-team'), async (event, { conversation_id, name, member_ids, current_username }) => {
349
+ try {
350
+ if (name) {
351
+ await dbQuery('UPDATE chat_conversations SET name = ?, updated_at = NOW() WHERE id = ?', [name, conversation_id]);
352
+ }
353
+
354
+ if (member_ids && member_ids.length > 0) {
355
+ const gk = normalizeUsername(current_username);
356
+ const me = await dbGetOne('SELECT id FROM chat_users WHERE global_key = ?', [gk]);
357
+
358
+ // Get current active members
359
+ const current = await dbQuery(
360
+ "SELECT user_id FROM chat_participants WHERE conversation_id = ? AND status = 'active'",
361
+ [conversation_id]
362
+ );
363
+ const currentIds = new Set(current.map(r => r.user_id));
364
+ const newIds = new Set(member_ids.map(Number));
365
+
366
+ // Remove members not in new list (except self)
367
+ for (const uid of currentIds) {
368
+ if (!newIds.has(uid) && me && uid !== me.id) {
369
+ await dbQuery(
370
+ "UPDATE chat_participants SET status = 'removed', removed_at = NOW(), removed_by = ? WHERE conversation_id = ? AND user_id = ?",
371
+ [me ? me.id : null, conversation_id, uid]
372
+ );
373
+ }
374
+ }
375
+
376
+ // Add new members
377
+ for (const uid of newIds) {
378
+ if (!currentIds.has(uid)) {
379
+ await dbQuery(
380
+ 'INSERT INTO chat_participants (conversation_id, user_id, status, joined_at) VALUES (?, ?, \'active\', NOW())',
381
+ [conversation_id, uid]
382
+ );
383
+ }
384
+ }
385
+ }
386
+
387
+ return { success: true };
388
+ } catch (err) {
389
+ console.error('[TacelChatAPI] chat-update-team error:', err);
390
+ return { success: false, error: err.message };
391
+ }
392
+ });
393
+
394
+ // ── Delete Team ─────────────────────────────────────────────
395
+ ipcMain.handle(ch('chat-delete-team'), async (event, { conversation_id }) => {
396
+ try {
397
+ // Delete reads for messages in this conversation
398
+ await dbQuery(`
399
+ DELETE r FROM chat_message_reads r
400
+ JOIN chat_messages m ON m.id = r.message_id
401
+ WHERE m.conversation_id = ?
402
+ `, [conversation_id]);
403
+ await dbQuery('DELETE FROM chat_messages WHERE conversation_id = ?', [conversation_id]);
404
+ await dbQuery('DELETE FROM chat_participants WHERE conversation_id = ?', [conversation_id]);
405
+ await dbQuery('DELETE FROM chat_conversations WHERE id = ?', [conversation_id]);
406
+ return { success: true };
407
+ } catch (err) {
408
+ console.error('[TacelChatAPI] chat-delete-team error:', err);
409
+ return { success: false, error: err.message };
410
+ }
411
+ });
412
+
413
+ // ── List Teams ──────────────────────────────────────────────
414
+ ipcMain.handle(ch('chat-list-teams'), async () => {
415
+ try {
416
+ const rows = await dbQuery(`
417
+ SELECT c.id, c.name,
418
+ GROUP_CONCAT(p.user_id) AS member_ids,
419
+ GROUP_CONCAT(u.username) AS member_usernames
420
+ FROM chat_conversations c
421
+ JOIN chat_participants p ON p.conversation_id = c.id AND p.status = 'active'
422
+ JOIN chat_users u ON u.id = p.user_id
423
+ WHERE c.type = 'team'
424
+ GROUP BY c.id, c.name
425
+ `);
426
+ return { success: true, data: rows };
427
+ } catch (err) {
428
+ console.error('[TacelChatAPI] chat-list-teams error:', err);
429
+ return { success: false, error: err.message };
430
+ }
431
+ });
432
+
433
+ console.log(`[TacelChatAPI] Registered chat IPC handlers${prefix ? ` with prefix "${prefix}"` : ''}`);
434
+ }
435
+
436
+ module.exports = { initChatAPI };
package/chat-sync.js ADDED
@@ -0,0 +1,65 @@
1
+ /**
2
+ * tacel-chat — User sync utility
3
+ * Registers/updates a user in the shared APP-CHATS database.
4
+ */
5
+
6
+ function normalizeUsername(username) {
7
+ return (username || '').trim().toLowerCase();
8
+ }
9
+
10
+ /**
11
+ * Sync a user into the chat_users table
12
+ * @param {object} options
13
+ * @param {Function} options.dbQuery - (sql, params) => Promise<Array>
14
+ * @param {Function} options.dbGetOne - (sql, params) => Promise<object|null>
15
+ * @param {number|string} options.original_id - User's ID in the app's own DB
16
+ * @param {string} options.app - App identifier (e.g., 'Office-HQ')
17
+ * @param {string} options.username - Display name
18
+ * @returns {Promise<object>} The synced user record
19
+ */
20
+ async function syncUser({ dbQuery, dbGetOne, original_id, app, username }) {
21
+ if (!original_id || !app || !username) {
22
+ throw new Error('syncUser requires original_id, app, username');
23
+ }
24
+
25
+ const global_key = normalizeUsername(username);
26
+
27
+ try {
28
+ // Try to find by global_key first (cross-app unification)
29
+ let existing = await dbGetOne('SELECT * FROM chat_users WHERE global_key = ?', [global_key]);
30
+
31
+ // Fallback to (original_id, app) pair
32
+ if (!existing) {
33
+ existing = await dbGetOne(
34
+ 'SELECT * FROM chat_users WHERE original_id = ? AND app = ?',
35
+ [original_id, app]
36
+ );
37
+ }
38
+
39
+ if (existing) {
40
+ // Update existing user
41
+ await dbQuery(
42
+ 'UPDATE chat_users SET username = ?, global_key = ?, app = ?, original_id = ?, is_online = 1, last_seen = CURRENT_TIMESTAMP, last_active = CURRENT_TIMESTAMP WHERE id = ?',
43
+ [username, global_key, app, original_id, existing.id]
44
+ );
45
+ const updated = await dbGetOne('SELECT * FROM chat_users WHERE id = ?', [existing.id]);
46
+ console.log(`[ChatSync] Updated existing chat user id=${existing.id}`);
47
+ return updated || { ...existing, username, global_key, app, original_id };
48
+ }
49
+
50
+ // Create new user
51
+ const result = await dbQuery(
52
+ 'INSERT INTO chat_users (original_id, app, username, global_key, is_online, last_seen, last_active) VALUES (?, ?, ?, ?, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)',
53
+ [original_id, app, username, global_key]
54
+ );
55
+ const id = result.insertId;
56
+ const created = await dbGetOne('SELECT * FROM chat_users WHERE id = ?', [id]);
57
+ console.log(`[ChatSync] Created new chat user id=${id}`);
58
+ return created || { id, original_id, app, username, global_key };
59
+ } catch (err) {
60
+ console.error('[ChatSync] Failed to sync user:', { original_id, app, username, error: err.message });
61
+ throw err;
62
+ }
63
+ }
64
+
65
+ module.exports = { syncUser, normalizeUsername };