@devchitchat/chat 0.0.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.
Files changed (131) hide show
  1. package/README.md +313 -0
  2. package/index.js +148 -0
  3. package/migrate/001-drop-channel-invites.js +3 -0
  4. package/migrate/002-invite-initial-roles.js +5 -0
  5. package/migrate/003-dm-channels.js +35 -0
  6. package/migrate/004-notifications.js +15 -0
  7. package/migrate/005-uploads.js +21 -0
  8. package/migrate/006-mention-priority.js +3 -0
  9. package/migrate/007-push-subscriptions.js +14 -0
  10. package/migrate/008-messages-channel-seq-index.js +3 -0
  11. package/migrate/009-message-reactions.js +15 -0
  12. package/migrate/010-edit-messages.js +7 -0
  13. package/package.json +51 -0
  14. package/pages/_error.html +12 -0
  15. package/pages/_layout.html +31 -0
  16. package/pages/_layout.js +13 -0
  17. package/pages/admin/_layout.html +52 -0
  18. package/pages/admin/_layout.js +8 -0
  19. package/pages/admin/bots/[userId].js +88 -0
  20. package/pages/admin/bots/[userId].phtml +89 -0
  21. package/pages/admin/bots/index.js +41 -0
  22. package/pages/admin/bots/index.phtml +58 -0
  23. package/pages/admin/index.js +8 -0
  24. package/pages/admin/invites/index.js +72 -0
  25. package/pages/admin/invites/index.phtml +88 -0
  26. package/pages/admin/users/[userId].js +60 -0
  27. package/pages/admin/users/[userId].phtml +57 -0
  28. package/pages/admin/users/index.js +20 -0
  29. package/pages/admin/users/index.phtml +37 -0
  30. package/pages/api/uploads/index.js +66 -0
  31. package/pages/api/user/settings.js +26 -0
  32. package/pages/auth/signout.js +14 -0
  33. package/pages/channels/[channelId].js +99 -0
  34. package/pages/channels/[channelId].phtml +173 -0
  35. package/pages/index.js +33 -0
  36. package/pages/invite/[token].js +10 -0
  37. package/pages/login/index.js +57 -0
  38. package/pages/login/index.phtml +29 -0
  39. package/pages/public/client/action-sheet.js +77 -0
  40. package/pages/public/client/app.js +38 -0
  41. package/pages/public/client/auth-tabs.js +13 -0
  42. package/pages/public/client/emoji-data.js +197 -0
  43. package/pages/public/client/islands/call.js +1770 -0
  44. package/pages/public/client/islands/sidebar.js +1197 -0
  45. package/pages/public/client/long-press.js +59 -0
  46. package/pages/public/client/modal.js +50 -0
  47. package/pages/public/client/router.js +87 -0
  48. package/pages/public/client/rtc-peer-manager.js +344 -0
  49. package/pages/public/client/settings-sync.js +76 -0
  50. package/pages/public/client/shared/messages.js +147 -0
  51. package/pages/public/client/swipe-nav.js +98 -0
  52. package/pages/public/client/theme.js +27 -0
  53. package/pages/public/client/ws.js +71 -0
  54. package/pages/public/favicon.ico +0 -0
  55. package/pages/public/favicon.png +0 -0
  56. package/pages/public/icon.png +0 -0
  57. package/pages/public/manifest.json +11 -0
  58. package/pages/public/sw.js +38 -0
  59. package/pages/public/themes/base.css +1786 -0
  60. package/pages/public/themes/dark.css +22 -0
  61. package/pages/public/themes/forest.css +22 -0
  62. package/pages/public/themes/light.css +23 -0
  63. package/pages/public/themes/ocean.css +22 -0
  64. package/pages/public/themes/rose.css +22 -0
  65. package/pages/registration/index.js +35 -0
  66. package/pages/registration/index.phtml +38 -0
  67. package/pages/uploads/[uploadId]/[filename].js +45 -0
  68. package/src/adapters/InMemoryAuthRepository.js +74 -0
  69. package/src/adapters/InMemoryChannelRepository.js +138 -0
  70. package/src/adapters/InMemoryDeliveryRepository.js +52 -0
  71. package/src/adapters/InMemoryFileStore.js +53 -0
  72. package/src/adapters/InMemoryHubRepository.js +85 -0
  73. package/src/adapters/InMemoryMessageRepository.js +35 -0
  74. package/src/adapters/InMemoryReactionRepository.js +45 -0
  75. package/src/adapters/InMemorySearchRepository.js +37 -0
  76. package/src/adapters/InMemorySignalingRepository.js +35 -0
  77. package/src/adapters/InMemoryUploadRepository.js +36 -0
  78. package/src/adapters/InMemoryUserSettingsRepository.js +15 -0
  79. package/src/adapters/LocalFileStore.js +40 -0
  80. package/src/adapters/SqliteAuthRepository.js +184 -0
  81. package/src/adapters/SqliteChannelRepository.js +149 -0
  82. package/src/adapters/SqliteDeliveryRepository.js +53 -0
  83. package/src/adapters/SqliteHubRepository.js +99 -0
  84. package/src/adapters/SqliteMessageRepository.js +90 -0
  85. package/src/adapters/SqlitePushRepository.js +39 -0
  86. package/src/adapters/SqliteReactionRepository.js +50 -0
  87. package/src/adapters/SqliteSearchRepository.js +42 -0
  88. package/src/adapters/SqliteSignalingRepository.js +50 -0
  89. package/src/adapters/SqliteUploadRepository.js +34 -0
  90. package/src/adapters/SqliteUserSettingsRepository.js +23 -0
  91. package/src/adminAuth.js +25 -0
  92. package/src/config.js +11 -0
  93. package/src/context.js +77 -0
  94. package/src/core/dm.js +10 -0
  95. package/src/core/mentions.js +27 -0
  96. package/src/core/messages.js +21 -0
  97. package/src/core/reactions.js +6 -0
  98. package/src/core/roles.js +5 -0
  99. package/src/core/uploads.js +107 -0
  100. package/src/db/initDb.js +225 -0
  101. package/src/db/openDb.js +18 -0
  102. package/src/db/runMigrations.js +45 -0
  103. package/src/db/transaction.js +11 -0
  104. package/src/ports/IFileStore.js +34 -0
  105. package/src/services/AuthService.js +208 -0
  106. package/src/services/BotService.js +148 -0
  107. package/src/services/ChannelService.js +176 -0
  108. package/src/services/DeliveryService.js +28 -0
  109. package/src/services/HubService.js +133 -0
  110. package/src/services/MessageService.js +122 -0
  111. package/src/services/NotificationService.js +45 -0
  112. package/src/services/PresenceService.js +55 -0
  113. package/src/services/ReactionService.js +57 -0
  114. package/src/services/SearchService.js +21 -0
  115. package/src/services/SignalingService.js +177 -0
  116. package/src/services/UploadService.js +111 -0
  117. package/src/services/UserSettingsService.js +30 -0
  118. package/src/services/WebPushService.js +217 -0
  119. package/src/util/crypto.js +21 -0
  120. package/src/util/errors.js +14 -0
  121. package/src/util/ids.js +3 -0
  122. package/src/util/logger.js +21 -0
  123. package/src/ws/ChatServer.js +478 -0
  124. package/src/ws/handlers/authHandlers.js +152 -0
  125. package/src/ws/handlers/channelHandlers.js +166 -0
  126. package/src/ws/handlers/hubHandlers.js +82 -0
  127. package/src/ws/handlers/messageHandlers.js +88 -0
  128. package/src/ws/handlers/pushHandlers.js +25 -0
  129. package/src/ws/handlers/reactionHandlers.js +27 -0
  130. package/src/ws/handlers/rtcHandlers.js +126 -0
  131. package/styles.css +22 -0
@@ -0,0 +1,45 @@
1
+ export class InMemoryReactionRepository {
2
+ constructor() {
3
+ this._reactions = [] // { reaction_id, msg_id, channel_id, user_id, emoji, ts }
4
+ }
5
+
6
+ upsertReaction({ reactionId, msgId, channelId, userId, emoji, ts }) {
7
+ const exists = this._reactions.find(r => r.msg_id === msgId && r.user_id === userId && r.emoji === emoji)
8
+ if (!exists) {
9
+ this._reactions.push({ reaction_id: reactionId, msg_id: msgId, channel_id: channelId, user_id: userId, emoji, ts })
10
+ }
11
+ }
12
+
13
+ removeReaction({ msgId, userId, emoji }) {
14
+ this._reactions = this._reactions.filter(r => !(r.msg_id === msgId && r.user_id === userId && r.emoji === emoji))
15
+ }
16
+
17
+ listReactionsForMsgs({ msgIds, requestingUserId }) {
18
+ const map = new Map()
19
+ if (!msgIds || msgIds.length === 0) return map
20
+
21
+ const msgIdSet = new Set(msgIds)
22
+ const relevant = this._reactions.filter(r => msgIdSet.has(r.msg_id))
23
+
24
+ // Group by msg_id + emoji, preserving insertion order
25
+ const grouped = new Map() // key: `${msgId}|${emoji}` → { msg_id, emoji, count, minTs, reacted }
26
+ for (const r of relevant) {
27
+ const key = `${r.msg_id}|${r.emoji}`
28
+ if (!grouped.has(key)) {
29
+ grouped.set(key, { msg_id: r.msg_id, emoji: r.emoji, count: 0, minTs: r.ts, reacted: false })
30
+ }
31
+ const entry = grouped.get(key)
32
+ entry.count++
33
+ if (r.ts < entry.minTs) entry.minTs = r.ts
34
+ if (r.user_id === requestingUserId) entry.reacted = true
35
+ }
36
+
37
+ // Sort by minTs and build the output map
38
+ const sorted = Array.from(grouped.values()).sort((a, b) => a.minTs - b.minTs)
39
+ for (const entry of sorted) {
40
+ if (!map.has(entry.msg_id)) map.set(entry.msg_id, [])
41
+ map.get(entry.msg_id).push({ emoji: entry.emoji, count: entry.count, reacted: entry.reacted })
42
+ }
43
+ return map
44
+ }
45
+ }
@@ -0,0 +1,37 @@
1
+ export class InMemorySearchRepository {
2
+ constructor() {
3
+ this._index = [] // { msg_id, channel_id, seq, user_id, ts, text }
4
+ this._ftsEnabled = true
5
+ }
6
+
7
+ isFtsEnabled() {
8
+ return this._ftsEnabled
9
+ }
10
+
11
+ indexMessage({ msg_id, channel_id, seq, user_id, ts, text }) {
12
+ const existing = this._index.findIndex(r => r.msg_id === msg_id)
13
+ if (existing >= 0) this._index.splice(existing, 1)
14
+ this._index.push({ msg_id, channel_id, seq, user_id, ts, text })
15
+ }
16
+
17
+ removeMessage({ msgId }) {
18
+ const i = this._index.findIndex(r => r.msg_id === msgId)
19
+ if (i >= 0) this._index.splice(i, 1)
20
+ }
21
+
22
+ searchFts({ channelId, query, limit }) {
23
+ const q = query.toLowerCase()
24
+ return this._index
25
+ .filter(r => r.channel_id === channelId && r.text.toLowerCase().includes(q))
26
+ .slice(0, limit)
27
+ .map(r => ({ ...r, snippet: r.text }))
28
+ }
29
+
30
+ searchLike({ channelId, query, limit }) {
31
+ const q = query.toLowerCase()
32
+ return this._index
33
+ .filter(r => r.channel_id === channelId && r.text.toLowerCase().includes(q))
34
+ .slice(0, limit)
35
+ .map(r => ({ ...r, snippet: r.text }))
36
+ }
37
+ }
@@ -0,0 +1,35 @@
1
+ export class InMemorySignalingRepository {
2
+ constructor() {
3
+ this._calls = new Map() // callId → { call_id, channel_id, created_by_user_id, topology, started_at, ended_at }
4
+ this._participants = [] // { call_id, user_id, peer_id, joined_at, left_at }
5
+ }
6
+
7
+ findActiveByChannel({ channelId }) {
8
+ for (const call of this._calls.values()) {
9
+ if (call.channel_id === channelId && call.ended_at == null) return call
10
+ }
11
+ return null
12
+ }
13
+
14
+ findActiveCalls() {
15
+ return [...this._calls.values()].filter(c => c.ended_at == null)
16
+ }
17
+
18
+ insertCall({ callId, channelId, createdByUserId, topology, startedAt }) {
19
+ this._calls.set(callId, { call_id: callId, channel_id: channelId, created_by_user_id: createdByUserId, topology, started_at: startedAt, ended_at: null })
20
+ }
21
+
22
+ endCall({ callId, endedAt }) {
23
+ const call = this._calls.get(callId)
24
+ if (call) call.ended_at = endedAt
25
+ }
26
+
27
+ insertParticipant({ callId, userId, peerId, joinedAt }) {
28
+ this._participants.push({ call_id: callId, user_id: userId, peer_id: peerId, joined_at: joinedAt, left_at: null })
29
+ }
30
+
31
+ leaveParticipant({ callId, peerId, leftAt }) {
32
+ const p = this._participants.find(p => p.call_id === callId && p.peer_id === peerId && p.left_at == null)
33
+ if (p) p.left_at = leftAt
34
+ }
35
+ }
@@ -0,0 +1,36 @@
1
+ export class InMemoryUploadRepository {
2
+ constructor() {
3
+ this._uploads = new Map()
4
+ }
5
+
6
+ insert({ uploadId, uploaderUserId, channelId, originalName, storedName, mimeType, sizeBytes, now }) {
7
+ this._uploads.set(uploadId, {
8
+ upload_id: uploadId,
9
+ uploader_user_id: uploaderUserId,
10
+ channel_id: channelId,
11
+ original_name: originalName,
12
+ stored_name: storedName,
13
+ mime_type: mimeType,
14
+ size_bytes: sizeBytes,
15
+ created_at: now,
16
+ msg_id: null,
17
+ })
18
+ }
19
+
20
+ findById({ uploadId }) {
21
+ return this._uploads.get(uploadId) ?? null
22
+ }
23
+
24
+ linkToMessage({ uploadId, msgId }) {
25
+ const row = this._uploads.get(uploadId)
26
+ if (row) row.msg_id = msgId
27
+ }
28
+
29
+ findOrphansOlderThan({ thresholdTs }) {
30
+ return [...this._uploads.values()].filter(r => r.msg_id === null && r.created_at < thresholdTs)
31
+ }
32
+
33
+ delete({ uploadId }) {
34
+ this._uploads.delete(uploadId)
35
+ }
36
+ }
@@ -0,0 +1,15 @@
1
+ export class InMemoryUserSettingsRepository {
2
+ constructor() {
3
+ this._store = new Map() // userId → { settings_json, updated_at }
4
+ }
5
+
6
+ findByUserId({ userId }) {
7
+ return this._store.get(userId) ?? null
8
+ }
9
+
10
+ upsert({ userId, settingsJson, updatedAt }) {
11
+ const existing = this._store.get(userId)
12
+ if (existing && existing.updated_at > updatedAt) return
13
+ this._store.set(userId, { settings_json: settingsJson, updated_at: updatedAt })
14
+ }
15
+ }
@@ -0,0 +1,40 @@
1
+ import { mkdir } from 'node:fs/promises'
2
+ import path from 'node:path'
3
+ import { ServiceError } from '../util/errors'
4
+ const DEFAULT_UPLOAD_DIR = 'data/uploads'
5
+
6
+ export class LocalFileStore {
7
+ constructor({ uploadDir = process.env.UPLOAD_DIR ?? DEFAULT_UPLOAD_DIR } = {}) {
8
+ this.uploadDir = uploadDir
9
+ }
10
+
11
+ #dir(uploadId) {
12
+ return path.join(this.uploadDir, uploadId)
13
+ }
14
+
15
+ async write({ uploadId, storedName, stream: data }) {
16
+ const dir = this.#dir(uploadId)
17
+ await mkdir(dir, { recursive: true })
18
+ const filePath = path.join(dir, storedName)
19
+ await Bun.write(filePath, data)
20
+ }
21
+
22
+ async read({ uploadId, storedName }) {
23
+ const filePath = path.join(this.#dir(uploadId), storedName)
24
+ const file = Bun.file(filePath)
25
+ const exists = await file.exists()
26
+ if (!exists) {
27
+ throw new ServiceError('NOT_FOUND', `File not found: ${filePath}`)
28
+ }
29
+ return file.stream()
30
+ }
31
+
32
+ async delete({ uploadId, storedName }) {
33
+ const filePath = path.join(this.#dir(uploadId), storedName)
34
+ try {
35
+ await Bun.file(filePath).unlink()
36
+ } catch {
37
+ // already gone — ignore
38
+ }
39
+ }
40
+ }
@@ -0,0 +1,184 @@
1
+ import { runTransaction } from '../db/transaction.js'
2
+
3
+ export class SqliteAuthRepository {
4
+ constructor({ db }) {
5
+ this.db = db
6
+ }
7
+
8
+ // ── Invites ────────────────────────────────────────────────────────────────
9
+
10
+ insertInvite({ inviteId, tokenHash, createdByUserId, now, expiresAt, maxUses, note, initialRolesJson }) {
11
+ this.db.prepare(
12
+ `INSERT INTO invites (invite_id, token_hash, created_by_user_id, created_at, expires_at, max_uses, uses, note, initial_roles_json)
13
+ VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?)`
14
+ ).run(inviteId, tokenHash, createdByUserId, now, expiresAt, maxUses, note, initialRolesJson)
15
+ }
16
+
17
+ findInviteByTokenHash({ tokenHash }) {
18
+ return this.db.prepare('SELECT * FROM invites WHERE token_hash = ?').get(tokenHash) ?? null
19
+ }
20
+
21
+ listInvites() {
22
+ return this.db.prepare(`
23
+ SELECT i.*, u.handle AS created_by_handle
24
+ FROM invites i
25
+ LEFT JOIN users u ON u.user_id = i.created_by_user_id
26
+ ORDER BY i.created_at DESC
27
+ `).all()
28
+ }
29
+
30
+ deleteInvite({ inviteId }) {
31
+ this.db.prepare('DELETE FROM invites WHERE invite_id = ?').run(inviteId)
32
+ }
33
+
34
+ /** Atomically: increment invite uses + insert user + insert session */
35
+ registerUser({ inviteId, userId, handle, displayName, rolesJson, passwordHash, now, sessionId, sessionTokenHash, sessionExpiresAt }) {
36
+ runTransaction(this.db, () => {
37
+ this.db.prepare(
38
+ `INSERT INTO users (user_id, handle, display_name, roles_json, password_hash, created_at)
39
+ VALUES (?, ?, ?, ?, ?, ?)`
40
+ ).run(userId, handle, displayName, rolesJson, passwordHash, now)
41
+ this.db.prepare(
42
+ `UPDATE invites SET uses = uses + 1, redeemed_by_user_id = ? WHERE invite_id = ?`
43
+ ).run(userId, inviteId)
44
+ this.db.prepare(
45
+ `INSERT INTO sessions (session_id, user_id, token_hash, created_at, expires_at, last_seen_at)
46
+ VALUES (?, ?, ?, ?, ?, ?)`
47
+ ).run(sessionId, userId, sessionTokenHash, now, sessionExpiresAt, now)
48
+ })
49
+ }
50
+
51
+ /** Atomically: insert user + insert session (bootstrap — no invite to redeem) */
52
+ registerBootstrapUser({ userId, handle, displayName, rolesJson, passwordHash, now, sessionId, sessionTokenHash, sessionExpiresAt }) {
53
+ runTransaction(this.db, () => {
54
+ this.db.prepare(
55
+ `INSERT INTO users (user_id, handle, display_name, roles_json, password_hash, created_at)
56
+ VALUES (?, ?, ?, ?, ?, ?)`
57
+ ).run(userId, handle, displayName, rolesJson, passwordHash, now)
58
+ this.db.prepare(
59
+ `INSERT INTO sessions (session_id, user_id, token_hash, created_at, expires_at, last_seen_at)
60
+ VALUES (?, ?, ?, ?, ?, ?)`
61
+ ).run(sessionId, userId, sessionTokenHash, now, sessionExpiresAt, now)
62
+ })
63
+ }
64
+
65
+ // ── Users ──────────────────────────────────────────────────────────────────
66
+
67
+ findUserByHandle({ handle }) {
68
+ return this.db.prepare(
69
+ 'SELECT user_id, handle, display_name, roles_json, password_hash FROM users WHERE handle = ?'
70
+ ).get(handle) ?? null
71
+ }
72
+
73
+ findUserById({ userId }) {
74
+ return this.db.prepare(
75
+ 'SELECT user_id, handle, display_name, roles_json FROM users WHERE user_id = ?'
76
+ ).get(userId) ?? null
77
+ }
78
+
79
+ listUsers() {
80
+ return this.db.prepare(
81
+ `SELECT user_id, handle, display_name, roles_json, created_at FROM users ORDER BY created_at ASC`
82
+ ).all()
83
+ }
84
+
85
+ updateUserRoles({ userId, rolesJson }) {
86
+ this.db.prepare('UPDATE users SET roles_json = ? WHERE user_id = ?').run(rolesJson, userId)
87
+ }
88
+
89
+ updateUserPassword({ userId, passwordHash }) {
90
+ this.db.prepare('UPDATE users SET password_hash = ? WHERE user_id = ?').run(passwordHash, userId)
91
+ }
92
+
93
+ updateUserDisplayName({ userId, displayName }) {
94
+ this.db.prepare('UPDATE users SET display_name = ? WHERE user_id = ?').run(displayName, userId)
95
+ }
96
+
97
+ getUserCount() {
98
+ return this.db.prepare('SELECT COUNT(*) AS count FROM users').get()?.count ?? 0
99
+ }
100
+
101
+ isHandleTaken({ handle }) {
102
+ return !!this.db.prepare('SELECT 1 FROM users WHERE handle = ?').get(handle)
103
+ }
104
+
105
+ // ── Sessions ───────────────────────────────────────────────────────────────
106
+
107
+ insertSession({ sessionId, userId, tokenHash, now, expiresAt }) {
108
+ this.db.prepare(
109
+ `INSERT INTO sessions (session_id, user_id, token_hash, created_at, expires_at, last_seen_at)
110
+ VALUES (?, ?, ?, ?, ?, ?)`
111
+ ).run(sessionId, userId, tokenHash, now, expiresAt, now)
112
+ }
113
+
114
+ findSessionWithUser({ tokenHash }) {
115
+ return this.db.prepare(
116
+ `SELECT s.session_id, s.user_id, s.expires_at, s.revoked_at, u.handle, u.display_name, u.roles_json
117
+ FROM sessions s JOIN users u ON u.user_id = s.user_id
118
+ WHERE s.token_hash = ?`
119
+ ).get(tokenHash) ?? null
120
+ }
121
+
122
+ touchSession({ sessionId, now }) {
123
+ this.db.prepare('UPDATE sessions SET last_seen_at = ? WHERE session_id = ?').run(now, sessionId)
124
+ }
125
+
126
+ revokeSession({ sessionId, now }) {
127
+ this.db.prepare('UPDATE sessions SET revoked_at = ? WHERE session_id = ?').run(now, sessionId)
128
+ }
129
+
130
+ revokeAllUserSessions({ userId, now }) {
131
+ this.db.prepare(
132
+ 'UPDATE sessions SET revoked_at = ? WHERE user_id = ? AND revoked_at IS NULL'
133
+ ).run(now, userId)
134
+ }
135
+
136
+ // ── Bot tokens ─────────────────────────────────────────────────────────────
137
+
138
+ insertBotUser({ userId, handle, displayName, now }) {
139
+ this.db.prepare(
140
+ `INSERT INTO users (user_id, handle, display_name, roles_json, password_hash, created_at)
141
+ VALUES (?, ?, ?, '["bot"]', NULL, ?)`
142
+ ).run(userId, handle, displayName, now)
143
+ }
144
+
145
+ insertBotToken({ tokenId, userId, tokenHash, label, now, expiresAt = null }) {
146
+ this.db.prepare(
147
+ `INSERT INTO bot_tokens (token_id, user_id, token_hash, label, created_at, expires_at)
148
+ VALUES (?, ?, ?, ?, ?, ?)`
149
+ ).run(tokenId, userId, tokenHash, label ?? null, now, expiresAt)
150
+ }
151
+
152
+ listBotTokens({ userId }) {
153
+ return this.db.prepare(
154
+ `SELECT token_id, label, created_at, expires_at, last_used_at, revoked_at
155
+ FROM bot_tokens WHERE user_id = ? ORDER BY created_at DESC`
156
+ ).all(userId)
157
+ }
158
+
159
+ revokeBotToken({ tokenId, now }) {
160
+ this.db.prepare('UPDATE bot_tokens SET revoked_at = ? WHERE token_id = ?').run(now, tokenId)
161
+ }
162
+
163
+ revokeAllBotTokens({ userId, now }) {
164
+ this.db.prepare(
165
+ 'UPDATE bot_tokens SET revoked_at = ? WHERE user_id = ? AND revoked_at IS NULL'
166
+ ).run(now, userId)
167
+ }
168
+
169
+ findBotTokenByHash({ tokenHash, now }) {
170
+ return this.db.prepare(
171
+ `SELECT bt.token_id, bt.user_id, bt.last_used_at,
172
+ u.handle, u.display_name, u.roles_json
173
+ FROM bot_tokens bt
174
+ JOIN users u ON u.user_id = bt.user_id
175
+ WHERE bt.token_hash = ?
176
+ AND bt.revoked_at IS NULL
177
+ AND (bt.expires_at IS NULL OR bt.expires_at > ?)`
178
+ ).get(tokenHash, now) ?? null
179
+ }
180
+
181
+ touchBotToken({ tokenId, now }) {
182
+ this.db.prepare('UPDATE bot_tokens SET last_used_at = ? WHERE token_id = ?').run(now, tokenId)
183
+ }
184
+ }
@@ -0,0 +1,149 @@
1
+ import { runTransaction } from '../db/transaction.js'
2
+
3
+ export class SqliteChannelRepository {
4
+ constructor({ db }) {
5
+ this.db = db
6
+ }
7
+
8
+ insertChannelWithOwner({ channelId, hubId, kind, name, topic, visibility, createdByUserId, now }) {
9
+ runTransaction(this.db, () => {
10
+ const nextOrder = (this.db.prepare(
11
+ `SELECT COALESCE(MAX(sort_order), -1) + 1 AS next FROM channels WHERE hub_id = ? AND deleted_at IS NULL`
12
+ ).get(hubId)?.next ?? 0)
13
+ this.db.prepare(
14
+ `INSERT INTO channels (channel_id, hub_id, kind, name, topic, visibility, sort_order, created_by_user_id, created_at)
15
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
16
+ ).run(channelId, hubId, kind, name, topic, visibility, nextOrder, createdByUserId, now)
17
+ this.db.prepare(
18
+ `INSERT INTO channel_members (channel_id, user_id, role, joined_at) VALUES (?, ?, 'owner', ?)`
19
+ ).run(channelId, createdByUserId, now)
20
+ })
21
+ }
22
+
23
+ listInHub({ hubId }) {
24
+ return this.db.prepare(
25
+ `SELECT c.channel_id, c.hub_id, c.name, c.kind, c.visibility, c.topic, c.sort_order
26
+ FROM channels c WHERE c.hub_id = ? AND c.deleted_at IS NULL
27
+ ORDER BY c.sort_order ASC, c.created_at ASC`
28
+ ).all(hubId)
29
+ }
30
+
31
+ listAccessibleInHub({ hubId, userId, isGuest = false }) {
32
+ return this.db.prepare(
33
+ `SELECT c.channel_id, c.hub_id, c.name, c.kind, c.visibility, c.topic, c.sort_order
34
+ FROM channels c WHERE c.hub_id = ? AND c.deleted_at IS NULL
35
+ AND (EXISTS (
36
+ SELECT 1 FROM channel_members cm WHERE cm.channel_id = c.channel_id
37
+ AND cm.user_id = ? AND cm.left_at IS NULL AND cm.banned_at IS NULL
38
+ ) OR (? = 0 AND c.visibility = 'public'))
39
+ ORDER BY c.sort_order ASC, c.created_at ASC`
40
+ ).all(hubId, userId, isGuest ? 1 : 0)
41
+ }
42
+
43
+ listAll() {
44
+ return this.db.prepare(
45
+ `SELECT c.channel_id, c.hub_id, c.name, c.kind, c.visibility, c.topic, c.sort_order, h.name AS hub_name
46
+ FROM channels c JOIN hubs h ON c.hub_id = h.hub_id
47
+ WHERE c.deleted_at IS NULL AND h.deleted_at IS NULL
48
+ ORDER BY h.name, c.sort_order ASC, c.created_at ASC`
49
+ ).all()
50
+ }
51
+
52
+ listAccessible({ userId, isGuest = false }) {
53
+ return this.db.prepare(
54
+ `SELECT c.channel_id, c.hub_id, c.name, c.kind, c.visibility, c.topic, c.sort_order, h.name AS hub_name
55
+ FROM channels c JOIN hubs h ON c.hub_id = h.hub_id
56
+ WHERE c.deleted_at IS NULL AND h.deleted_at IS NULL
57
+ AND (EXISTS (SELECT 1 FROM channel_members cm WHERE cm.channel_id = c.channel_id AND cm.user_id = ? AND cm.left_at IS NULL AND cm.banned_at IS NULL)
58
+ OR (? = 0 AND (
59
+ (h.visibility = 'public' AND c.visibility = 'public')
60
+ OR (c.visibility = 'public' AND EXISTS (SELECT 1 FROM hub_members hm WHERE hm.hub_id = h.hub_id AND hm.user_id = ? AND hm.left_at IS NULL))
61
+ )))
62
+ ORDER BY h.name, c.sort_order ASC, c.created_at ASC`
63
+ ).all(userId, isGuest ? 1 : 0, userId)
64
+ }
65
+
66
+ findById({ channelId }) {
67
+ return this.db.prepare('SELECT * FROM channels WHERE channel_id = ?').get(channelId) ?? null
68
+ }
69
+
70
+ findMembership({ channelId, userId }) {
71
+ return this.db.prepare('SELECT * FROM channel_members WHERE channel_id = ? AND user_id = ?').get(channelId, userId) ?? null
72
+ }
73
+
74
+ findByHubAndName({ hubId, name }) {
75
+ return this.db.prepare('SELECT * FROM channels WHERE hub_id = ? AND name = ? AND deleted_at IS NULL').get(hubId, name) ?? null
76
+ }
77
+
78
+ findDmByName({ name }) {
79
+ return this.db.prepare(`SELECT * FROM channels WHERE kind = 'dm' AND name = ? AND deleted_at IS NULL`).get(name) ?? null
80
+ }
81
+
82
+ insertDmChannel({ channelId, name, userIdA, userIdB, now }) {
83
+ runTransaction(this.db, () => {
84
+ this.db.prepare(
85
+ `INSERT INTO channels (channel_id, hub_id, kind, name, topic, visibility, sort_order, created_by_user_id, created_at)
86
+ VALUES (?, NULL, 'dm', ?, NULL, 'private', 0, ?, ?)`
87
+ ).run(channelId, name, userIdA, now)
88
+ this.db.prepare(
89
+ `INSERT INTO channel_members (channel_id, user_id, role, joined_at) VALUES (?, ?, 'member', ?)`
90
+ ).run(channelId, userIdA, now)
91
+ this.db.prepare(
92
+ `INSERT INTO channel_members (channel_id, user_id, role, joined_at) VALUES (?, ?, 'member', ?)`
93
+ ).run(channelId, userIdB, now)
94
+ })
95
+ }
96
+
97
+ listDmsByUser({ userId }) {
98
+ return this.db.prepare(
99
+ `SELECT c.channel_id, c.name, c.kind,
100
+ other.user_id AS other_user_id
101
+ FROM channels c
102
+ JOIN channel_members cm ON cm.channel_id = c.channel_id AND cm.user_id = ? AND cm.left_at IS NULL
103
+ LEFT JOIN channel_members other ON other.channel_id = c.channel_id AND other.user_id != ? AND other.left_at IS NULL
104
+ WHERE c.kind = 'dm' AND c.deleted_at IS NULL
105
+ ORDER BY c.created_at DESC`
106
+ ).all(userId, userId)
107
+ }
108
+
109
+ upsertMembership({ channelId, userId, role, now }) {
110
+ this.db.prepare(
111
+ `INSERT INTO channel_members (channel_id, user_id, role, joined_at) VALUES (?, ?, ?, ?)
112
+ ON CONFLICT(channel_id, user_id) DO UPDATE SET left_at = NULL, banned_at = NULL`
113
+ ).run(channelId, userId, role, now)
114
+ }
115
+
116
+ setMemberLeft({ channelId, userId, now }) {
117
+ this.db.prepare('UPDATE channel_members SET left_at = ? WHERE channel_id = ? AND user_id = ?').run(now, channelId, userId)
118
+ }
119
+
120
+ listActiveMembers({ channelId }) {
121
+ return this.db.prepare(
122
+ `SELECT user_id, role FROM channel_members WHERE channel_id = ? AND left_at IS NULL AND banned_at IS NULL`
123
+ ).all(channelId)
124
+ }
125
+
126
+ patchChannel({ channelId, name, topic, visibility }) {
127
+ const updates = []
128
+ const params = []
129
+ if (name !== undefined) { updates.push('name = ?'); params.push(name) }
130
+ if (topic !== undefined) { updates.push('topic = ?'); params.push(topic) }
131
+ if (visibility !== undefined) { updates.push('visibility = ?'); params.push(visibility) }
132
+ params.push(channelId)
133
+ this.db.prepare(`UPDATE channels SET ${updates.join(', ')} WHERE channel_id = ?`).run(...params)
134
+ }
135
+
136
+ softDeleteChannel({ channelId, now }) {
137
+ this.db.prepare('UPDATE channels SET deleted_at = ? WHERE channel_id = ?').run(now, channelId)
138
+ }
139
+
140
+ reorderChannels({ hubId, channelIds }) {
141
+ runTransaction(this.db, () => {
142
+ const stmt = this.db.prepare(
143
+ `UPDATE channels SET sort_order = ? WHERE channel_id = ? AND hub_id = ? AND deleted_at IS NULL`
144
+ )
145
+ channelIds.forEach((channelId, index) => stmt.run(index, channelId, hubId))
146
+ })
147
+ return this.listInHub({ hubId })
148
+ }
149
+ }
@@ -0,0 +1,53 @@
1
+ import { newId } from '../util/ids.js'
2
+
3
+ export class SqliteDeliveryRepository {
4
+ constructor({ db }) {
5
+ this.db = db
6
+ }
7
+
8
+ findByChannelAndUser({ channelId, userId }) {
9
+ return this.db.prepare('SELECT * FROM deliveries WHERE channel_id = ? AND user_id = ?').get(channelId, userId) ?? null
10
+ }
11
+
12
+ create({ channelId, userId, now }) {
13
+ const deliveryId = newId('del')
14
+ this.db.prepare(
15
+ `INSERT INTO deliveries (delivery_id, user_id, channel_id, after_seq, last_delivered_at, status) VALUES (?, ?, ?, 0, ?, 'active')`
16
+ ).run(deliveryId, userId, channelId, now)
17
+ return this.db.prepare('SELECT * FROM deliveries WHERE delivery_id = ?').get(deliveryId)
18
+ }
19
+
20
+ advance({ channelId, userId, afterSeq, now }) {
21
+ this.db.prepare(
22
+ `UPDATE deliveries
23
+ SET after_seq = ?, last_delivered_at = ?,
24
+ mention_seq = CASE WHEN mention_seq > 0 AND mention_seq <= ? THEN 0 ELSE mention_seq END
25
+ WHERE channel_id = ? AND user_id = ?`
26
+ ).run(afterSeq, now, afterSeq, channelId, userId)
27
+ }
28
+
29
+ advanceMention({ channelId, userId, mentionSeq, priority = 'normal' }) {
30
+ this.db.prepare(
31
+ `UPDATE deliveries SET mention_seq = ?, mention_priority = ?
32
+ WHERE channel_id = ? AND user_id = ? AND mention_seq < ?`
33
+ ).run(mentionSeq, priority, channelId, userId, mentionSeq)
34
+ }
35
+
36
+ buildDigestData({ userId }) {
37
+ return this.db.prepare(
38
+ `SELECT
39
+ d.channel_id, c.name, c.kind, d.after_seq, d.mention_seq, d.mention_priority,
40
+ COALESCE(
41
+ (SELECT MAX(seq) FROM messages WHERE channel_id = d.channel_id AND deleted_at IS NULL),
42
+ 0
43
+ ) AS max_seq,
44
+ (SELECT cm2.user_id FROM channel_members cm2
45
+ WHERE cm2.channel_id = c.channel_id AND cm2.user_id != d.user_id
46
+ AND cm2.left_at IS NULL AND c.kind = 'dm'
47
+ LIMIT 1) AS other_user_id
48
+ FROM deliveries d
49
+ JOIN channels c ON d.channel_id = c.channel_id AND c.deleted_at IS NULL
50
+ WHERE d.user_id = ?`
51
+ ).all(userId)
52
+ }
53
+ }