@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,148 @@
1
+ /**
2
+ * BotService — manages bot user accounts and API tokens.
3
+ *
4
+ * Bots are regular users with the "bot" role. They authenticate to the
5
+ * WebSocket using a long-lived bot token instead of a session cookie.
6
+ * The token is hashed before storage; the plain token is returned once
7
+ * at creation time and never retrievable again.
8
+ */
9
+ import { newId } from '../util/ids.js'
10
+ import { randomToken, hashToken } from '../util/crypto.js'
11
+ import { ServiceError } from '../util/errors.js'
12
+
13
+ export class BotService {
14
+ constructor({ authService, authRepo, channelRepo, nowFn = () => Date.now() }) {
15
+ this.authService = authService
16
+ this.authRepo = authRepo
17
+ this.channelRepo = channelRepo
18
+ this.nowFn = nowFn
19
+ }
20
+
21
+ // ── Bot accounts ───────────────────────────────────────────────────────────
22
+
23
+ createBot({ handle, displayName, tokenLabel, requestingUserId }) {
24
+ this._requireAdmin(requestingUserId)
25
+ const h = handle?.trim()
26
+ const name = displayName?.trim() || h
27
+ if (!h) throw new ServiceError('BAD_REQUEST', 'Handle is required')
28
+ if (this.authRepo.isHandleTaken({ handle: h })) throw new ServiceError('CONFLICT', 'Handle already taken')
29
+
30
+ const userId = newId('u')
31
+ const now = this.nowFn()
32
+ this.authRepo.insertBotUser({ userId, handle: h, displayName: name, now })
33
+
34
+ const { tokenId, token } = this._insertToken({ userId, label: tokenLabel, now })
35
+ return { userId, handle: h, displayName: name, roles: ['bot'], tokenId, token }
36
+ }
37
+
38
+ listBots({ requestingUserId }) {
39
+ this._requireAdmin(requestingUserId)
40
+ return this.authRepo.listUsers()
41
+ .filter(row => {
42
+ try { return JSON.parse(row.roles_json).includes('bot') } catch { return false }
43
+ })
44
+ .map(row => ({
45
+ user_id: row.user_id,
46
+ handle: row.handle,
47
+ display_name: row.display_name,
48
+ roles: JSON.parse(row.roles_json),
49
+ created_at: row.created_at,
50
+ tokens: this.authRepo.listBotTokens({ userId: row.user_id }),
51
+ }))
52
+ }
53
+
54
+ getBot({ userId, requestingUserId }) {
55
+ this._requireAdmin(requestingUserId)
56
+ const row = this.authRepo.findUserById({ userId })
57
+ if (!row) throw new ServiceError('NOT_FOUND', 'Bot not found')
58
+ const roles = JSON.parse(row.roles_json)
59
+ if (!roles.includes('bot')) throw new ServiceError('NOT_FOUND', 'Bot not found')
60
+ return {
61
+ user_id: row.user_id,
62
+ handle: row.handle,
63
+ display_name: row.display_name,
64
+ roles,
65
+ tokens: this.authRepo.listBotTokens({ userId }),
66
+ channels: this._getBotChannels(userId),
67
+ }
68
+ }
69
+
70
+ // ── Bot tokens ─────────────────────────────────────────────────────────────
71
+
72
+ createToken({ userId, label, ttlMs = null, requestingUserId }) {
73
+ this._requireAdmin(requestingUserId)
74
+ const now = this.nowFn()
75
+ const expiresAt = ttlMs != null ? now + ttlMs : null
76
+ return this._insertToken({ userId, label, now, expiresAt })
77
+ }
78
+
79
+ revokeToken({ tokenId, requestingUserId }) {
80
+ this._requireAdmin(requestingUserId)
81
+ this.authRepo.revokeBotToken({ tokenId, now: this.nowFn() })
82
+ }
83
+
84
+ revokeAllTokens({ userId, requestingUserId }) {
85
+ this._requireAdmin(requestingUserId)
86
+ this.authRepo.revokeAllBotTokens({ userId, now: this.nowFn() })
87
+ }
88
+
89
+ /** Called by ChatServer hello handler to authenticate a bot WS connection. */
90
+ authenticateToken(plainToken) {
91
+ const tokenHash = hashToken(plainToken)
92
+ const row = this.authRepo.findBotTokenByHash({ tokenHash, now: this.nowFn() })
93
+ if (!row) throw new ServiceError('UNAUTHORIZED', 'Invalid bot token')
94
+ this.authRepo.touchBotToken({ tokenId: row.token_id, now: this.nowFn() })
95
+ return {
96
+ userId: row.user_id,
97
+ handle: row.handle,
98
+ displayName: row.display_name,
99
+ roles: JSON.parse(row.roles_json),
100
+ }
101
+ }
102
+
103
+ // ── Bot channel membership ─────────────────────────────────────────────────
104
+
105
+ setBotChannels({ userId, channelIds, requestingUserId }) {
106
+ this._requireAdmin(requestingUserId)
107
+ const now = this.nowFn()
108
+
109
+ // Current active memberships
110
+ const current = this._getBotChannels(userId).map(c => c.channel_id)
111
+ const next = Array.isArray(channelIds) ? channelIds : []
112
+
113
+ const toJoin = next.filter(id => !current.includes(id))
114
+ const toLeave = current.filter(id => !next.includes(id))
115
+
116
+ for (const channelId of toJoin) {
117
+ this.channelRepo.upsertMembership({ channelId, userId, role: 'member', now })
118
+ }
119
+ for (const channelId of toLeave) {
120
+ this.channelRepo.setMemberLeft({ channelId, userId, now })
121
+ }
122
+ }
123
+
124
+ _getBotChannels(userId) {
125
+ return this.channelRepo.listAccessible({ userId })
126
+ }
127
+
128
+ // ── Internals ──────────────────────────────────────────────────────────────
129
+
130
+ _insertToken({ userId, label, now, expiresAt = null }) {
131
+ const token = randomToken()
132
+ const tokenId = newId('bt')
133
+ this.authRepo.insertBotToken({
134
+ tokenId, userId,
135
+ tokenHash: hashToken(token),
136
+ label: label?.trim() || null,
137
+ now,
138
+ expiresAt,
139
+ })
140
+ return { tokenId, token, expiresAt }
141
+ }
142
+
143
+ _requireAdmin(userId) {
144
+ const user = this.authService.getUser(userId)
145
+ const roles = user?.roles ?? []
146
+ if (!roles.includes('admin')) throw new ServiceError('FORBIDDEN', 'Admin role required')
147
+ }
148
+ }
@@ -0,0 +1,176 @@
1
+ import { newId } from '../util/ids.js'
2
+ import { ServiceError } from '../util/errors.js'
3
+ import { buildDmChannelName } from '../core/dm.js'
4
+
5
+ export class ChannelService {
6
+ constructor({ channelRepo, hubService, nowFn = () => Date.now() }) {
7
+ this.channelRepo = channelRepo
8
+ this.hubService = hubService
9
+ this.nowFn = nowFn
10
+ }
11
+
12
+ createChannel({ hubId, kind, name, topic = null, visibility = 'public', createdByUserId, userRoles = [] }) {
13
+ if (!['text', 'voice'].includes(kind)) throw new ServiceError('BAD_REQUEST', 'Invalid channel kind')
14
+ if (!name?.trim()) throw new ServiceError('BAD_REQUEST', 'Channel name required')
15
+ if (!this.hubService.canAccessHub(hubId, createdByUserId, userRoles)) throw new ServiceError('FORBIDDEN', 'Cannot access hub')
16
+
17
+ const channelId = newId('c')
18
+ const now = this.nowFn()
19
+
20
+ this.channelRepo.insertChannelWithOwner({ channelId, hubId, kind, name: name.trim(), topic, visibility, createdByUserId, now })
21
+
22
+ return { channel_id: channelId, hub_id: hubId, kind, name: name.trim(), topic, visibility }
23
+ }
24
+
25
+ listChannels(userId, userRoles = [], hubId = null) {
26
+ const isAdmin = userRoles.includes('admin')
27
+ const isGuest = userRoles.includes('guest')
28
+ if (hubId) {
29
+ return isAdmin
30
+ ? this.channelRepo.listInHub({ hubId })
31
+ : this.channelRepo.listAccessibleInHub({ hubId, userId, isGuest })
32
+ }
33
+ return isAdmin
34
+ ? this.channelRepo.listAll()
35
+ : this.channelRepo.listAccessible({ userId, isGuest })
36
+ }
37
+
38
+ joinChannel({ channelId, userId, userRoles = [] }) {
39
+ const channel = this.getChannel(channelId)
40
+ if (!channel || channel.deleted_at) throw new ServiceError('NOT_FOUND', 'Channel not found')
41
+ if (channel.hub_id !== null && !this.hubService.canAccessHub(channel.hub_id, userId, userRoles)) throw new ServiceError('FORBIDDEN', 'Cannot access hub')
42
+
43
+ if (channel.visibility === 'private') {
44
+ const member = this.getMembership(channelId, userId)
45
+ if (!member || member.left_at || member.banned_at) throw new ServiceError('FORBIDDEN', 'Not a member of this channel')
46
+ return { channel_id: channelId, kind: channel.kind }
47
+ }
48
+
49
+ this.channelRepo.upsertMembership({ channelId, userId, role: 'member', now: this.nowFn() })
50
+ return { channel_id: channelId, kind: channel.kind }
51
+ }
52
+
53
+ leaveChannel({ channelId, userId }) {
54
+ const channel = this.getChannel(channelId)
55
+ // DM and private channels have permanent membership — the WS topic unsubscribes
56
+ // but the DB row is preserved. Private channel members cannot re-add themselves,
57
+ // so clearing left_at here would lock them out until an owner re-adds them.
58
+ const isPermanent = channel?.kind === 'dm' || channel?.visibility === 'private'
59
+ if (!isPermanent) {
60
+ this.channelRepo.setMemberLeft({ channelId, userId, now: this.nowFn() })
61
+ }
62
+ return { channel_id: channelId }
63
+ }
64
+
65
+ isMember(channelId, userId) {
66
+ const member = this.getMembership(channelId, userId)
67
+ return !!member && !member.left_at && !member.banned_at
68
+ }
69
+
70
+ canAccessChannel(channelId, userId, roles = []) {
71
+ if (roles.includes('admin')) return true
72
+ const channel = this.getChannel(channelId)
73
+ if (!channel || channel.deleted_at) return false
74
+ // DM channels (hub_id = null) skip the hub access check — membership is the sole gate
75
+ if (channel.hub_id !== null && !this.hubService.canAccessHub(channel.hub_id, userId, roles)) return false
76
+ if (channel.visibility === 'public' && !roles.includes('guest')) return true
77
+ return this.isMember(channelId, userId)
78
+ }
79
+
80
+ listChannelMembers(channelId) {
81
+ return this.channelRepo.listActiveMembers({ channelId })
82
+ }
83
+
84
+ addMember({ channelId, createdByUserId, targetUserId }) {
85
+ const adder = this.getMembership(channelId, createdByUserId)
86
+ if (!adder || !['owner', 'mod'].includes(adder.role)) throw new ServiceError('FORBIDDEN', 'Only owner or mod can add members')
87
+ const channel = this.getChannel(channelId)
88
+ if (!channel) throw new ServiceError('NOT_FOUND', 'Channel not found')
89
+ const existing = this.getMembership(channelId, targetUserId)
90
+ if (existing && !existing.left_at && !existing.banned_at) throw new ServiceError('BAD_REQUEST', 'User is already a member')
91
+
92
+ this.channelRepo.upsertMembership({ channelId, userId: targetUserId, role: 'member', now: this.nowFn() })
93
+
94
+ return { channel_id: channelId, user_id: targetUserId }
95
+ }
96
+
97
+ removeMember({ channelId, removedByUserId, targetUserId }) {
98
+ const remover = this.getMembership(channelId, removedByUserId)
99
+ if (!remover || !['owner', 'mod'].includes(remover.role)) throw new ServiceError('FORBIDDEN', 'Only owner or mod can remove members')
100
+ if (removedByUserId === targetUserId) throw new ServiceError('BAD_REQUEST', 'Use leaveChannel to leave a channel')
101
+ const target = this.getMembership(channelId, targetUserId)
102
+ if (!target || target.left_at || target.banned_at) throw new ServiceError('BAD_REQUEST', 'User is not a member')
103
+
104
+ this.channelRepo.setMemberLeft({ channelId, userId: targetUserId, now: this.nowFn() })
105
+
106
+ return { channel_id: channelId, user_id: targetUserId }
107
+ }
108
+
109
+ getChannel(channelId) {
110
+ return this.channelRepo.findById({ channelId })
111
+ }
112
+
113
+ getMembership(channelId, userId) {
114
+ return this.channelRepo.findMembership({ channelId, userId })
115
+ }
116
+
117
+ findOrCreateDm({ userId, targetUserId }) {
118
+ if (userId === targetUserId) throw new ServiceError('BAD_REQUEST', 'Cannot DM yourself')
119
+ const name = buildDmChannelName(userId, targetUserId)
120
+ const existing = this.channelRepo.findDmByName({ name })
121
+ if (existing) return { channel_id: existing.channel_id, is_new: false }
122
+ const channelId = newId('c')
123
+ this.channelRepo.insertDmChannel({ channelId, name, userIdA: userId, userIdB: targetUserId, now: this.nowFn() })
124
+ return { channel_id: channelId, is_new: true }
125
+ }
126
+
127
+ listDms({ userId }) {
128
+ return this.channelRepo.listDmsByUser({ userId })
129
+ }
130
+
131
+ ensureDefaultChannel(hubId, createdByUserId) {
132
+ const existing = this.channelRepo.findByHubAndName({ hubId, name: 'general' })
133
+ if (existing) return existing
134
+ return this.createChannel({ hubId, kind: 'text', name: 'general', topic: 'General discussions', visibility: 'public', createdByUserId, userRoles: ['admin'] })
135
+ }
136
+
137
+ updateChannel({ channelId, userId, roles = [], name = null, topic = null, visibility = null }) {
138
+ const channel = this.getChannel(channelId)
139
+ if (!channel || channel.deleted_at) throw new ServiceError('NOT_FOUND', 'Channel not found')
140
+ const membership = this.getMembership(channelId, userId)
141
+ const isOwner = membership && membership.role === 'owner' && !membership.left_at && !membership.banned_at
142
+ if (!roles.includes('admin') && channel.created_by_user_id !== userId && !isOwner) throw new ServiceError('FORBIDDEN', 'Cannot update channel')
143
+ if (name === null && topic === null && visibility === null) throw new ServiceError('BAD_REQUEST', 'No fields to update')
144
+
145
+ const patch = {}
146
+ if (name !== null) {
147
+ if (!name.trim()) throw new ServiceError('BAD_REQUEST', 'Channel name cannot be empty')
148
+ patch.name = name.trim()
149
+ }
150
+ if (topic !== null) patch.topic = topic
151
+ if (visibility !== null) {
152
+ if (!['public', 'private'].includes(visibility)) throw new ServiceError('BAD_REQUEST', 'Channel visibility must be public or private')
153
+ patch.visibility = visibility
154
+ }
155
+
156
+ this.channelRepo.patchChannel({ channelId, ...patch })
157
+ return this.getChannel(channelId)
158
+ }
159
+
160
+ deleteChannel({ channelId, userId, roles = [] }) {
161
+ const channel = this.getChannel(channelId)
162
+ if (!channel || channel.deleted_at) throw new ServiceError('NOT_FOUND', 'Channel not found')
163
+ const membership = this.getMembership(channelId, userId)
164
+ const isOwner = membership && membership.role === 'owner' && !membership.left_at && !membership.banned_at
165
+ if (!roles.includes('admin') && channel.created_by_user_id !== userId && !isOwner) throw new ServiceError('FORBIDDEN', 'Cannot delete channel')
166
+
167
+ this.channelRepo.softDeleteChannel({ channelId, now: this.nowFn() })
168
+ return { channel_id: channel.channel_id, hub_id: channel.hub_id }
169
+ }
170
+
171
+ reorderChannels({ hubId, channelIds, userId, userRoles = [] }) {
172
+ if (!this.hubService.canAccessHub(hubId, userId, userRoles)) throw new ServiceError('FORBIDDEN', 'Cannot access hub')
173
+ if (!Array.isArray(channelIds) || channelIds.length === 0) throw new ServiceError('BAD_REQUEST', 'channelIds must be a non-empty array')
174
+ return this.channelRepo.reorderChannels({ hubId, channelIds })
175
+ }
176
+ }
@@ -0,0 +1,28 @@
1
+ export class DeliveryService {
2
+ constructor({ deliveryRepo, nowFn = () => Date.now() }) {
3
+ this.deliveryRepo = deliveryRepo
4
+ this.nowFn = nowFn
5
+ }
6
+
7
+ getOrCreate({ channelId, userId }) {
8
+ const existing = this.deliveryRepo.findByChannelAndUser({ channelId, userId })
9
+ if (existing) return existing
10
+ return this.deliveryRepo.create({ channelId, userId, now: this.nowFn() })
11
+ }
12
+
13
+ advance({ channelId, userId, afterSeq }) {
14
+ this.deliveryRepo.advance({ channelId, userId, afterSeq, now: this.nowFn() })
15
+ }
16
+
17
+ advanceMention({ channelId, userId, mentionSeq, priority = 'normal' }) {
18
+ // Ensure a delivery row exists before updating mention_seq.
19
+ // If Linda has never navigated to this channel, there is no row yet and
20
+ // the UPDATE in advanceMention would silently affect 0 rows, losing the mention.
21
+ this.getOrCreate({ channelId, userId })
22
+ this.deliveryRepo.advanceMention({ channelId, userId, mentionSeq, priority })
23
+ }
24
+
25
+ buildDigestData({ userId }) {
26
+ return this.deliveryRepo.buildDigestData({ userId })
27
+ }
28
+ }
@@ -0,0 +1,133 @@
1
+ import { newId } from '../util/ids.js'
2
+ import { ServiceError } from '../util/errors.js'
3
+
4
+ export class HubService {
5
+ constructor({ hubRepo, nowFn = () => Date.now() }) {
6
+ this.hubRepo = hubRepo
7
+ this.nowFn = nowFn
8
+ }
9
+
10
+ createHub({ name, description = null, visibility = 'public', createdByUserId }) {
11
+ if (!['public', 'restricted'].includes(visibility)) throw new ServiceError('BAD_REQUEST', 'Invalid hub visibility')
12
+ if (!name?.trim()) throw new ServiceError('BAD_REQUEST', 'Hub name required')
13
+ const hubId = newId('h')
14
+ const now = this.nowFn()
15
+
16
+ this.hubRepo.insertHubWithOwner({ hubId, name: name.trim(), description, visibility, createdByUserId, now })
17
+
18
+ return { hub_id: hubId, name: name.trim(), description, visibility }
19
+ }
20
+
21
+ listHubs(userId, roles = []) {
22
+ if (roles.includes('admin')) return this.hubRepo.listAllHubs()
23
+ return this.hubRepo.listAccessibleHubs({ userId })
24
+ }
25
+
26
+ canAccessHub(hubId, userId, roles = []) {
27
+ if (roles.includes('admin')) return true
28
+ const hub = this.getHub(hubId)
29
+ if (!hub || hub.deleted_at) return false
30
+ if (hub.visibility === 'public' && !roles.includes('guest')) return true
31
+ const member = this.getHubMembership(hubId, userId)
32
+ return !!member && !member.left_at
33
+ }
34
+
35
+ getHub(hubId) {
36
+ return this.hubRepo.findById({ hubId })
37
+ }
38
+
39
+ getHubMembership(hubId, userId) {
40
+ return this.hubRepo.findMembership({ hubId, userId })
41
+ }
42
+
43
+ joinHub(hubId, userId) {
44
+ const hub = this.getHub(hubId)
45
+ if (!hub || hub.deleted_at) throw new ServiceError('NOT_FOUND', 'Hub not found')
46
+ this.hubRepo.upsertMembership({ hubId, userId, now: this.nowFn() })
47
+ return { hub_id: hubId }
48
+ }
49
+
50
+ leaveHub(hubId, userId) {
51
+ this.hubRepo.setMemberLeft({ hubId, userId, now: this.nowFn() })
52
+ return { hub_id: hubId }
53
+ }
54
+
55
+ ensureDefaultHub(createdByUserId) {
56
+ const existing = this.hubRepo.findByName({ name: 'Lobby' })
57
+ if (existing) return existing
58
+ return this.createHub({ name: 'Lobby', description: 'Main hub for general discussions', visibility: 'public', createdByUserId })
59
+ }
60
+
61
+ updateHub({ hubId, userId, roles = [], name = null, description = null, visibility = null }) {
62
+ const hub = this.getHub(hubId)
63
+ if (!hub || hub.deleted_at) throw new ServiceError('NOT_FOUND', 'Hub not found')
64
+ if (!roles.includes('admin') && hub.created_by_user_id !== userId) throw new ServiceError('FORBIDDEN', 'Cannot update hub')
65
+ if (name === null && description === null && visibility === null) throw new ServiceError('BAD_REQUEST', 'No fields to update')
66
+
67
+ const patch = {}
68
+ if (name !== null) {
69
+ if (!name.trim()) throw new ServiceError('BAD_REQUEST', 'Hub name cannot be empty')
70
+ patch.name = name.trim()
71
+ }
72
+ if (description !== null) patch.description = description
73
+ if (visibility !== null) {
74
+ if (!['public', 'restricted'].includes(visibility)) throw new ServiceError('BAD_REQUEST', 'Hub visibility must be public or restricted')
75
+ patch.visibility = visibility
76
+ }
77
+
78
+ this.hubRepo.patchHub({ hubId, ...patch })
79
+ return this.getHub(hubId)
80
+ }
81
+
82
+ addHubMember({ hubId, targetUserId, requestingUserId, requestingRoles = [] }) {
83
+ const hub = this.getHub(hubId)
84
+ if (!hub || hub.deleted_at) throw new ServiceError('NOT_FOUND', 'Hub not found')
85
+ const isAdmin = requestingRoles.includes('admin')
86
+ const isCreator = hub.created_by_user_id === requestingUserId
87
+ if (!isAdmin && !isCreator) throw new ServiceError('FORBIDDEN', 'Admin or hub creator required')
88
+ this.hubRepo.upsertMembership({ hubId, userId: targetUserId, now: this.nowFn() })
89
+ return { hub_id: hubId, user_id: targetUserId }
90
+ }
91
+
92
+ removeHubMember({ hubId, targetUserId, requestingUserId, requestingRoles = [] }) {
93
+ const hub = this.getHub(hubId)
94
+ if (!hub || hub.deleted_at) throw new ServiceError('NOT_FOUND', 'Hub not found')
95
+ const isAdmin = requestingRoles.includes('admin')
96
+ const isCreator = hub.created_by_user_id === requestingUserId
97
+ if (!isAdmin && !isCreator) throw new ServiceError('FORBIDDEN', 'Admin or hub creator required')
98
+ this.hubRepo.setMemberLeft({ hubId, userId: targetUserId, now: this.nowFn() })
99
+ return { hub_id: hubId, user_id: targetUserId }
100
+ }
101
+
102
+ listHubMembers({ hubId, requestingUserId, requestingRoles = [] }) {
103
+ const hub = this.getHub(hubId)
104
+ if (!hub || hub.deleted_at) throw new ServiceError('NOT_FOUND', 'Hub not found')
105
+ const isAdmin = requestingRoles.includes('admin')
106
+ const membership = this.getHubMembership(hubId, requestingUserId)
107
+ const isMember = membership && !membership.left_at
108
+ if (!isAdmin && !isMember) throw new ServiceError('FORBIDDEN', 'Hub membership required')
109
+ return this.hubRepo.listMembers({ hubId })
110
+ }
111
+
112
+ reorderHubs({ hubIds, userId, userRoles = [] }) {
113
+ if (!Array.isArray(hubIds) || hubIds.length === 0) throw new ServiceError('BAD_REQUEST', 'hub_ids required')
114
+ if (!userRoles.includes('admin')) {
115
+ // Non-admins can only reorder hubs they own or are a member of — the list query
116
+ // already scopes to accessible hubs, so any hub_id not in that set is silently ignored.
117
+ }
118
+ this.hubRepo.reorderHubs({ hubIds })
119
+ return this.listHubs(userId, userRoles)
120
+ }
121
+
122
+ deleteHub({ hubId, userId, roles = [] }) {
123
+ const hub = this.getHub(hubId)
124
+ if (!hub || hub.deleted_at) throw new ServiceError('NOT_FOUND', 'Hub not found')
125
+ if (!roles.includes('admin') && hub.created_by_user_id !== userId) throw new ServiceError('FORBIDDEN', 'Cannot delete hub')
126
+
127
+ const channelIds = this.hubRepo.listActiveChannelIds({ hubId })
128
+ const now = this.nowFn()
129
+ this.hubRepo.softDeleteHub({ hubId, now })
130
+
131
+ return { hub_id: hubId, channel_ids: channelIds }
132
+ }
133
+ }
@@ -0,0 +1,122 @@
1
+ import { newId } from '../util/ids.js'
2
+ import { ServiceError } from '../util/errors.js'
3
+ import { validateEditPermission, validateEditText, assertMessageEditable, validateDeletePermission } from '../core/messages.js'
4
+
5
+ export class MessageService {
6
+ constructor({ messageRepo, nowFn = () => Date.now(), channelService, searchService, uploadService = null, reactionService = null }) {
7
+ this.messageRepo = messageRepo
8
+ this.nowFn = nowFn
9
+ this.channelService = channelService
10
+ this.searchService = searchService
11
+ this.uploadService = uploadService
12
+ this.reactionService = reactionService
13
+ }
14
+
15
+ setUploadService(uploadService) {
16
+ this.uploadService = uploadService
17
+ }
18
+
19
+ sendMessage({ channelId, userId, text, clientMsgId = null, priority = 'normal', attachments = [] }) {
20
+ if (!this.channelService.isMember(channelId, userId)) throw new ServiceError('FORBIDDEN', 'Not a member of channel')
21
+ if (!text?.trim() && attachments.length === 0) throw new ServiceError('BAD_REQUEST', 'Message text or attachment required')
22
+ if (!['normal', 'async', 'now'].includes(priority)) throw new ServiceError('BAD_REQUEST', 'Invalid priority')
23
+
24
+ const msgId = newId('m')
25
+ const now = this.nowFn()
26
+ const trimmed = text?.trim() ?? ''
27
+
28
+ const attachmentsJson = attachments.length > 0 ? JSON.stringify(attachments) : null
29
+
30
+ const { seq } = this.messageRepo.insertMessage({
31
+ msgId, channelId, userId, now, text: trimmed, clientMsgId, priority, attachmentsJson
32
+ })
33
+
34
+ if (trimmed) {
35
+ this.searchService.indexMessage({ msg_id: msgId, channel_id: channelId, seq, user_id: userId, ts: now, text: trimmed })
36
+ }
37
+
38
+ let enrichedAttachments = attachments
39
+ if (this.uploadService && attachments.length > 0) {
40
+ const uploadIds = attachments.map(a => a.upload_id).filter(Boolean)
41
+ if (uploadIds.length > 0) {
42
+ this.uploadService.linkToMessage({ uploadIds, msgId, userId, channelId })
43
+ // Enrich with full upload details so consumers get url, mime_type, original_name.
44
+ enrichedAttachments = attachments.map(a => {
45
+ if (!a.upload_id) return a
46
+ const row = this.uploadService.getUpload({ uploadId: a.upload_id })
47
+ if (!row) return a
48
+ return {
49
+ upload_id: row.upload_id,
50
+ url: `/uploads/${row.upload_id}/${encodeURIComponent(row.original_name)}`,
51
+ original_name: row.original_name,
52
+ mime_type: row.mime_type,
53
+ size_bytes: row.size_bytes,
54
+ }
55
+ })
56
+ }
57
+ }
58
+
59
+ return { msg_id: msgId, seq, ts: now, priority, attachments: enrichedAttachments }
60
+ }
61
+
62
+ editMessage({ msgId, channelId, userId, newText }) {
63
+ const msg = this.messageRepo.getById(msgId)
64
+ if (!msg) throw new ServiceError('NOT_FOUND', 'Message not found')
65
+ if (msg.channel_id !== channelId) throw new ServiceError('BAD_REQUEST', 'Message does not belong to this channel')
66
+
67
+ assertMessageEditable(msg.deleted_at)
68
+ validateEditPermission(userId, msg.user_id)
69
+ const trimmed = validateEditText(newText)
70
+
71
+ const editedAt = this.nowFn()
72
+ this.messageRepo.updateMessage({ msgId, text: trimmed, editedAt })
73
+
74
+ this.searchService.indexMessage({ msg_id: msgId, channel_id: channelId, seq: msg.seq, user_id: msg.user_id, ts: msg.ts, text: trimmed })
75
+
76
+ return { msgId, channelId, text: trimmed, editedAt }
77
+ }
78
+
79
+ deleteMessage({ msgId, channelId, userId }) {
80
+ const msg = this.messageRepo.getById(msgId)
81
+ if (!msg) throw new ServiceError('NOT_FOUND', 'Message not found')
82
+ if (msg.channel_id !== channelId) throw new ServiceError('BAD_REQUEST', 'Message does not belong to this channel')
83
+ if (msg.deleted_at != null) throw new ServiceError('BAD_REQUEST', 'Message already deleted')
84
+
85
+ validateDeletePermission(userId, msg.user_id)
86
+
87
+ this.messageRepo.deleteMessage({ msgId, deletedAt: this.nowFn() })
88
+ this.searchService.removeMessage({ msgId })
89
+
90
+ return { msgId, channelId, seq: msg.seq }
91
+ }
92
+
93
+ listMessages({ channelId, userId, afterSeq = 0, limit = 50 }) {
94
+ if (!this.channelService.isMember(channelId, userId)) throw new ServiceError('FORBIDDEN', 'Not a member of channel')
95
+
96
+ const rows = this.messageRepo.listMessages({ channelId, afterSeq, limit })
97
+ const lastSeq = rows.length ? rows[rows.length - 1].seq : afterSeq
98
+ const messages = this.reactionService
99
+ ? this.reactionService.enrichWithReactions({ messages: rows, requestingUserId: userId })
100
+ : rows
101
+ return { messages, next_after_seq: lastSeq }
102
+ }
103
+
104
+ listLatestMessages({ channelId, userId, limit = 50 }) {
105
+ if (!this.channelService.isMember(channelId, userId)) throw new ServiceError('FORBIDDEN', 'Not a member of channel')
106
+ const rows = this.messageRepo.listLatestMessages({ channelId, limit })
107
+ const messages = this.reactionService
108
+ ? this.reactionService.enrichWithReactions({ messages: rows, requestingUserId: userId })
109
+ : rows
110
+ return { messages }
111
+ }
112
+
113
+ listMessagesBefore({ channelId, userId, beforeSeq, limit = 50 }) {
114
+ if (!this.channelService.isMember(channelId, userId)) throw new ServiceError('FORBIDDEN', 'Not a member of channel')
115
+ const rows = this.messageRepo.listMessagesBefore({ channelId, beforeSeq, limit })
116
+ const hasMore = rows.length === limit
117
+ const messages = this.reactionService
118
+ ? this.reactionService.enrichWithReactions({ messages: rows, requestingUserId: userId })
119
+ : rows
120
+ return { messages, has_more: hasMore }
121
+ }
122
+ }
@@ -0,0 +1,45 @@
1
+ export class NotificationService {
2
+ constructor({ deliveryService, authService }) {
3
+ this.deliveryService = deliveryService
4
+ this.authService = authService
5
+ }
6
+
7
+ /**
8
+ * Build the reconnect digest for a user.
9
+ * Returns { channels, dms } — only entries with unread > 0 or a pending mention.
10
+ *
11
+ * @param {string} userId
12
+ * @param {number} [lastSeenAt] — epoch ms of last session activity; used for away_duration_ms
13
+ */
14
+ buildDigest(userId, lastSeenAt = null) {
15
+ const rows = this.deliveryService.buildDigestData({ userId })
16
+ const channels = []
17
+ const dms = []
18
+
19
+ for (const row of rows) {
20
+ const unread = Math.max(0, (row.max_seq ?? 0) - (row.after_seq ?? 0))
21
+ const hasMention = (row.mention_seq ?? 0) > (row.after_seq ?? 0)
22
+ if (unread === 0 && !hasMention) continue
23
+
24
+ if (row.kind === 'dm') {
25
+ const other = row.other_user_id ? this.authService.getUser(row.other_user_id) : null
26
+ dms.push({
27
+ channel_id: row.channel_id,
28
+ with_user: { user_id: row.other_user_id, display_name: other?.display_name ?? row.other_user_id },
29
+ unread,
30
+ })
31
+ } else {
32
+ channels.push({
33
+ channel_id: row.channel_id,
34
+ name: row.name,
35
+ unread,
36
+ mentions: hasMention ? 1 : 0,
37
+ urgent: hasMention && row.mention_priority === 'now',
38
+ })
39
+ }
40
+ }
41
+
42
+ const away_duration_ms = lastSeenAt ? Math.max(0, Date.now() - lastSeenAt) : null
43
+ return { channels, dms, away_duration_ms }
44
+ }
45
+ }