1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
//! Repository for discuss-channel writes (hand-written; user-owned).
//!
//! Holds the SQL the [`crate::application::service::ChannelWriteService`]
//! orchestrates: channel creation with the creator membership injected in the
//! SAME transaction, the 1:1-chat get-or-create dedup (source-verified
//! `_get_or_create_chat`: member-set equality via a NOT EXISTS anti-join, not
//! a name lookup), sub-channel creator-only ops, and message pinning.
//! Runtime queries (no compile-time macros) — hence no `.sqlx` cache.
use sqlx::{PgConnection, Row};
use uuid::Uuid;
use crate::application::service::chatter_acl::MessagingIdentity;
/// The channel fields a create may set (MAIL-M37).
pub struct NewChannelRow<'a> {
pub id: Uuid,
pub name: Option<&'a str>,
pub channel_type: &'a str,
pub uuid: Option<&'a str>,
pub default_access_mode: Option<&'a str>,
pub email_send: bool,
}
/// Hand-written channel SQL. Services orchestrate; this holds SQL.
pub struct ChannelRepository;
impl ChannelRepository {
pub fn new() -> Self {
Self
}
/// Insert the channel row (soft-delete-aware reads elsewhere; the write is
/// a plain INSERT — new rows are live by construction).
pub async fn insert_channel(
conn: &mut PgConnection,
row: &NewChannelRow<'_>,
) -> Result<(), sqlx::Error> {
sqlx::query(r#"
INSERT INTO messaging.discuss_channels
(id, name, channel_type, uuid, default_access_mode, email_send)
VALUES ($1, $2, $3::discuss_channel_type, $4, $5, $6)
"#)
.bind(row.id)
.bind(row.name)
.bind(row.channel_type)
.bind(row.uuid)
.bind(row.default_access_mode)
.bind(row.email_send)
.execute(&mut *conn)
.await?;
Ok(())
}
/// Insert the creator's membership on the caller's open transaction — the
/// M37 rule "create injects the creator as first member" is one tx with
/// the channel insert, never two writes.
pub async fn insert_creator_member(
conn: &mut PgConnection,
channel_id: Uuid,
identity: &MessagingIdentity,
) -> Result<Uuid, sqlx::Error> {
let member_id = Uuid::new_v4();
sqlx::query(r#"
INSERT INTO messaging.discuss_channel_members
(id, channel_id, partner_id, guest_id, is_pinned)
VALUES ($1, $2, $3, $4, TRUE)
"#)
.bind(member_id)
.bind(channel_id)
.bind(identity.partner_id())
.bind(match identity {
MessagingIdentity::Guest { guest_id } => Some(*guest_id),
_ => None,
})
.execute(&mut *conn)
.await?;
Ok(member_id)
}
/// The 1:1-chat dedup lookup (source-verified `_get_or_create_chat`
/// :1340-1360): a chat channel matches iff its live partner-member set is
/// EXACTLY the two given partners — no member outside the set (anti-join)
/// and no missing member (count). This is deliberately NOT a name lookup:
/// partner renames would silently fork chats.
pub async fn find_chat_by_member_set(
conn: &mut PgConnection,
partner_ids: &[Uuid],
) -> Result<Option<Uuid>, sqlx::Error> {
let row = sqlx::query(r#"
SELECT c.id
FROM messaging.discuss_channels c
WHERE c.channel_type = 'chat'
AND (c.metadata->>'deleted_at') IS NULL
AND NOT EXISTS (
SELECT 1 FROM messaging.discuss_channel_members m
WHERE m.channel_id = c.id
AND (m.metadata->>'deleted_at') IS NULL
AND m.partner_id IS DISTINCT FROM NULL
AND NOT (m.partner_id = ANY($1))
)
AND (
SELECT COUNT(*) FROM messaging.discuss_channel_members m
WHERE m.channel_id = c.id
AND (m.metadata->>'deleted_at') IS NULL
AND m.partner_id IS NOT NULL
) = $2
ORDER BY c.metadata->>'created_at'
LIMIT 1
"#)
.bind(partner_ids)
.bind(partner_ids.len() as i64)
.fetch_optional(&mut *conn)
.await?;
Ok(row.map(|r| r.get::<Uuid, _>("id")))
}
/// Load one channel's channel_type (guard for immutable-type + moderation
/// checks). `None` = no such live channel.
pub async fn channel_type(
conn: &mut PgConnection,
channel_id: Uuid,
) -> Result<Option<String>, sqlx::Error> {
sqlx::query_scalar::<_, String>(r#"
SELECT channel_type::text FROM messaging.discuss_channels
WHERE id = $1 AND (metadata->>'deleted_at') IS NULL
"#)
.bind(channel_id)
.fetch_optional(&mut *conn)
.await
}
/// Update name/description/email_send (guarded writes — channel_type is
/// immutable post-create and has no UPDATE path at all).
pub async fn update_channel_fields(
conn: &mut PgConnection,
channel_id: Uuid,
name: Option<&str>,
description: Option<&str>,
email_send: Option<bool>,
) -> Result<(), sqlx::Error> {
sqlx::query(r#"
UPDATE messaging.discuss_channels
SET name = COALESCE($2, name),
description = COALESCE($3, description),
email_send = COALESCE($4, email_send)
WHERE id = $1
"#)
.bind(channel_id)
.bind(name)
.bind(description)
.bind(email_send)
.execute(&mut *conn)
.await?;
Ok(())
}
/// Advance `last_message_id` after a post (channel-list ordering pointer).
pub async fn set_last_message(
conn: &mut PgConnection,
channel_id: Uuid,
message_id: Uuid,
) -> Result<(), sqlx::Error> {
sqlx::query(r#"
UPDATE messaging.discuss_channels SET last_message_id = $2
WHERE id = $1
"#)
.bind(channel_id)
.bind(message_id)
.execute(&mut *conn)
.await?;
Ok(())
}
/// Pin/unpin a message (MAIL-M37.1, source-verified shape: pinned_at is a
/// column ON mail.message). Minimal UPDATE touching only pinned_at.
///
/// PORT DELTA (documented): Odoo's raw UPDATE deliberately skips
/// write_date; the port's generated audit trigger advances
/// metadata->updated_at on every UPDATE, and pin writes are no exception.
/// Recording the delta here beats weakening the audit trigger.
pub async fn set_message_pinned(
conn: &mut PgConnection,
message_id: Uuid,
pinned: bool,
) -> Result<(), sqlx::Error> {
sqlx::query(r#"
UPDATE messaging.mail_messages
SET pinned_at = CASE WHEN $2 THEN NOW() ELSE NULL END
WHERE id = $1
"#)
.bind(message_id)
.bind(pinned)
.execute(&mut *conn)
.await?;
Ok(())
}
/// Soft-delete a channel (archive semantics; members keep their rows).
pub async fn soft_delete_channel(
conn: &mut PgConnection,
channel_id: Uuid,
) -> Result<(), sqlx::Error> {
sqlx::query(r#"
UPDATE messaging.discuss_channels
SET metadata = jsonb_set(metadata, '{deleted_at}', to_jsonb(NOW()))
WHERE id = $1 AND (metadata->>'deleted_at') IS NULL
"#)
.bind(channel_id)
.execute(&mut *conn)
.await?;
Ok(())
}
// =====================================================================
// Query side (ChannelQueryService) — pre-gated by the service (MAIL-B1:
// membership is checked in code before these run).
// =====================================================================
/// Live members of a channel: (member_id, partner_id?, guest_id?).
pub async fn list_members(
conn: &mut PgConnection,
channel_id: Uuid,
) -> Result<Vec<(Uuid, Option<Uuid>, Option<Uuid>)>, sqlx::Error> {
let rows = sqlx::query(
r#"SELECT id, partner_id, guest_id FROM messaging.discuss_channel_members
WHERE channel_id = $1 AND (metadata->>'deleted_at') IS NULL
ORDER BY id"#,
)
.bind(channel_id)
.fetch_all(&mut *conn)
.await?;
Ok(rows
.into_iter()
.map(|r| (r.get("id"), r.get("partner_id"), r.get("guest_id")))
.collect())
}
/// The channel's pinned message ids (MAIL-M37 adjunct shape: pinned_at
/// lives ON mail.message over the (model='discuss.channel', res_id) edge).
pub async fn pinned_message_ids(
conn: &mut PgConnection,
channel_id: Uuid,
) -> Result<Vec<Uuid>, sqlx::Error> {
sqlx::query_scalar(
r#"SELECT id FROM messaging.mail_messages
WHERE model = 'discuss.channel' AND res_id = $1
AND pinned_at IS NOT NULL
AND (metadata->>'deleted_at') IS NULL
ORDER BY pinned_at DESC"#,
)
.bind(channel_id)
.fetch_all(&mut *conn)
.await
}
/// Channel search (Odoo's `/discuss/search` — name ILIKE over the
/// channels the identity may see): their own member channels plus public
/// channels (joinable), with `is_member` distinguishing them.
pub async fn search_channels(
executor: impl sqlx::Executor<'_, Database = sqlx::Postgres>,
partner_id: Option<Uuid>,
guest_id: Option<Uuid>,
term: &str,
limit: i64,
) -> Result<Vec<(Uuid, Option<String>, String, bool)>, sqlx::Error> {
let rows = sqlx::query(
r#"
SELECT c.id, c.name, c.channel_type::text AS channel_type,
(m.id IS NOT NULL) AS is_member
FROM messaging.discuss_channels c
LEFT JOIN messaging.discuss_channel_members m
ON m.channel_id = c.id
AND m.partner_id IS NOT DISTINCT FROM $1
AND m.guest_id IS NOT DISTINCT FROM $2
AND (m.metadata->>'deleted_at') IS NULL
WHERE (c.metadata->>'deleted_at') IS NULL
AND c.channel_type IN ('channel', 'group')
AND ($3::text IS NULL OR c.name ILIKE '%' || $3 || '%')
AND (
m.id IS NOT NULL
OR (c.default_access_mode = 'public' AND $1 IS NOT NULL)
)
ORDER BY is_member DESC, c.name
LIMIT $4
"#,
)
.bind(partner_id)
.bind(guest_id)
.bind(if term.trim().is_empty() { None } else { Some(term.trim()) })
.bind(limit)
.fetch_all(executor)
.await?;
Ok(rows
.into_iter()
.map(|r| (r.get("id"), r.get("name"), r.get("channel_type"), r.get("is_member")))
.collect())
}
/// The LIVE channels an identity is a member of (the SSE allowlist's
/// membership leg — `discuss.channel_<id>` keys are built from these ids
/// by the caller; key CONSTRUCTION stays server-side per BUS-B2).
/// Soft-deleted memberships and soft-deleted channels both excluded.
pub async fn membership_channel_ids(
executor: impl sqlx::Executor<'_, Database = sqlx::Postgres>,
partner_id: Option<Uuid>,
guest_id: Option<Uuid>,
) -> Result<Vec<Uuid>, sqlx::Error> {
sqlx::query_scalar(
r#"
SELECT c.id
FROM messaging.discuss_channels c
JOIN messaging.discuss_channel_members m
ON m.channel_id = c.id
AND m.partner_id IS NOT DISTINCT FROM $1
AND m.guest_id IS NOT DISTINCT FROM $2
AND (m.metadata->>'deleted_at') IS NULL
WHERE (c.metadata->>'deleted_at') IS NULL
"#,
)
.bind(partner_id)
.bind(guest_id)
.fetch_all(executor)
.await
}
/// Public-channel lookup for the unauthenticated bootstrap: (id, name,
/// channel_type) when the invitation uuid matches a LIVE channel whose
/// `default_access_mode` is 'public'. Non-public and soft-deleted
/// channels are indistinguishable from "no such channel" — the bootstrap
/// must not leak private channel existence.
pub async fn find_public_by_uuid(
executor: impl sqlx::Executor<'_, Database = sqlx::Postgres>,
uuid: &str,
) -> Result<Option<(Uuid, Option<String>, String)>, sqlx::Error> {
let row = sqlx::query(
r#"
SELECT id, name, channel_type::text AS channel_type
FROM messaging.discuss_channels
WHERE uuid = $1
AND default_access_mode = 'public'
AND (metadata->>'deleted_at') IS NULL
"#,
)
.bind(uuid)
.fetch_optional(executor)
.await?;
Ok(row.map(|r| (r.get("id"), r.get("name"), r.get("channel_type"))))
}
}
impl Default for ChannelRepository {
fn default() -> Self {
Self::new()
}
}