Skip to main content

max / goingson

8.8 KB · 273 lines History Blame Raw
1 //! Read paths for contacts.
2 //!
3 //! Every function takes an already-checked-out connection, so the parent trait
4 //! impl owns the pool checkout. `promote_contact` writes a single flag but lives
5 //! here: it is the terminal step of implicit-contact resolution and shares its
6 //! query shape with `find_by_email`.
7
8 use goingson_core::{Contact, ContactEmailEntry, ContactId, Result, UserId};
9 use std::collections::HashSet;
10
11 use rusqlite::{Connection, params, params_from_iter};
12
13 use crate::utils::{
14 bind_placeholders, escape_like, execute, format_datetime_now, query_all, query_opt,
15 };
16
17 use super::hydrate;
18 use super::row::ContactRow;
19
20 pub(super) fn list_all(conn: &Connection, user_id: UserId) -> Result<Vec<Contact>> {
21 let rows = query_all(
22 conn,
23 r"
24 SELECT id, display_name, nickname, company, title, notes, tags, birthday, timezone, external_source, external_id, is_implicit, created_at, updated_at
25 FROM contacts
26 WHERE user_id = ? AND is_implicit = 0
27 ORDER BY display_name ASC
28 ",
29 params![user_id.to_string()],
30 ContactRow::from_row,
31 )?;
32
33 hydrate::hydrate_contacts(conn, rows)
34 }
35
36 pub(super) fn list_all_for_backup(conn: &Connection, user_id: UserId) -> Result<Vec<Contact>> {
37 let rows = query_all(
38 conn,
39 r"
40 SELECT id, display_name, nickname, company, title, notes, tags, birthday, timezone, external_source, external_id, is_implicit, created_at, updated_at
41 FROM contacts
42 WHERE user_id = ?
43 ORDER BY display_name ASC
44 ",
45 params![user_id.to_string()],
46 ContactRow::from_row,
47 )?;
48
49 hydrate::hydrate_contacts(conn, rows)
50 }
51
52 pub(super) fn get_by_id(
53 conn: &Connection,
54 id: ContactId,
55 user_id: UserId,
56 ) -> Result<Option<Contact>> {
57 let row = query_opt(
58 conn,
59 r"
60 SELECT id, display_name, nickname, company, title, notes, tags, birthday, timezone, external_source, external_id, is_implicit, created_at, updated_at
61 FROM contacts
62 WHERE id = ? AND user_id = ?
63 ",
64 params![id.to_string(), user_id.to_string()],
65 ContactRow::from_row,
66 )?;
67
68 match row {
69 Some(r) => {
70 let contacts = hydrate::hydrate_contacts(conn, vec![r])?;
71 Ok(contacts.into_iter().next())
72 }
73 None => Ok(None),
74 }
75 }
76
77 pub(super) fn list_by_tag(conn: &Connection, user_id: UserId, tag: &str) -> Result<Vec<Contact>> {
78 // Tags stored as JSON array, use LIKE for matching
79 let pattern = format!("%\"{}\"%", escape_like(tag));
80 let rows = query_all(
81 conn,
82 r"
83 SELECT id, display_name, nickname, company, title, notes, tags, birthday, timezone, external_source, external_id, is_implicit, created_at, updated_at
84 FROM contacts
85 WHERE user_id = ? AND tags LIKE ? ESCAPE '\'
86 ORDER BY display_name ASC
87 ",
88 params![user_id.to_string(), &pattern],
89 ContactRow::from_row,
90 )?;
91
92 hydrate::hydrate_contacts(conn, rows)
93 }
94
95 pub(super) fn list_filtered(
96 conn: &Connection,
97 user_id: UserId,
98 search: Option<&str>,
99 tag: Option<&str>,
100 include_implicit: bool,
101 ) -> Result<Vec<Contact>> {
102 let has_search = search.is_some_and(|s| !s.is_empty());
103 let has_tag = tag.is_some_and(|t| !t.is_empty());
104
105 if !has_search && !has_tag && !include_implicit {
106 return list_all(conn, user_id);
107 }
108
109 let mut conditions = vec!["c.user_id = ?".to_string()];
110 if !include_implicit {
111 conditions.push("c.is_implicit = 0".to_string());
112 }
113 let mut binds: Vec<String> = vec![user_id.to_string()];
114
115 if let Some(t) = tag.filter(|t| !t.is_empty()) {
116 conditions.push("c.tags LIKE ? ESCAPE '\\'".to_string());
117 binds.push(format!("%\"{}\"%", escape_like(t)));
118 }
119
120 if let Some(s) = search.filter(|s| !s.is_empty()) {
121 let search_pattern = format!("%{}%", escape_like(&s.to_lowercase()));
122 // Search across contact fields and email addresses using a subquery
123 conditions.push(
124 "(LOWER(c.display_name) LIKE ? ESCAPE '\\' OR LOWER(COALESCE(c.nickname, '')) LIKE ? ESCAPE '\\' OR LOWER(COALESCE(c.company, '')) LIKE ? ESCAPE '\\' OR LOWER(COALESCE(c.title, '')) LIKE ? ESCAPE '\\' OR LOWER(c.notes) LIKE ? ESCAPE '\\' OR EXISTS (SELECT 1 FROM contact_emails ce WHERE ce.contact_id = c.id AND LOWER(ce.address) LIKE ? ESCAPE '\\'))".to_string()
125 );
126 // 6 binds for the search pattern
127 for _ in 0..6 {
128 binds.push(search_pattern.clone());
129 }
130 }
131
132 let sql = format!(
133 "SELECT c.id, c.display_name, c.nickname, c.company, c.title, c.notes, c.tags, c.birthday, c.timezone, c.external_source, c.external_id, c.is_implicit, c.created_at, c.updated_at FROM contacts c WHERE {} ORDER BY c.display_name ASC",
134 conditions.join(" AND ")
135 );
136
137 let rows = query_all(
138 conn,
139 &sql,
140 params_from_iter(binds.iter()),
141 ContactRow::from_row,
142 )?;
143 hydrate::hydrate_contacts(conn, rows)
144 }
145
146 pub(super) fn list_email_directory(
147 conn: &Connection,
148 user_id: UserId,
149 include_implicit: bool,
150 ) -> Result<Vec<ContactEmailEntry>> {
151 // One JOIN, one row per email address, no per-contact sub-collection
152 // hydration (the compose autocomplete only needs name + address).
153 let implicit_filter = if include_implicit {
154 ""
155 } else {
156 "AND c.is_implicit = 0"
157 };
158 let sql = format!(
159 "SELECT c.display_name, ce.address, c.is_implicit \
160 FROM contacts c JOIN contact_emails ce ON ce.contact_id = c.id \
161 WHERE c.user_id = ? {implicit_filter} \
162 ORDER BY c.display_name ASC, ce.is_primary DESC"
163 );
164 let rows: Vec<(String, String, i64)> =
165 query_all(conn, &sql, params![user_id.to_string()], |row| {
166 Ok((row.get(0)?, row.get(1)?, row.get(2)?))
167 })?;
168 Ok(rows
169 .into_iter()
170 .map(|(name, email, is_implicit)| ContactEmailEntry {
171 name,
172 email,
173 is_implicit: is_implicit != 0,
174 })
175 .collect())
176 }
177
178 pub(super) fn find_by_email(
179 conn: &Connection,
180 user_id: UserId,
181 email: &str,
182 ) -> Result<Option<Contact>> {
183 let row = query_opt(
184 conn,
185 r"
186 SELECT c.id, c.display_name, c.nickname, c.company, c.title, c.notes, c.tags, c.birthday, c.timezone, c.external_source, c.external_id, c.is_implicit, c.created_at, c.updated_at
187 FROM contacts c
188 JOIN contact_emails ce ON ce.contact_id = c.id
189 WHERE c.user_id = ? AND LOWER(ce.address) = LOWER(?)
190 LIMIT 1
191 ",
192 params![user_id.to_string(), email],
193 ContactRow::from_row,
194 )?;
195
196 match row {
197 Some(r) => {
198 let contacts = hydrate::hydrate_contacts(conn, vec![r])?;
199 Ok(contacts.into_iter().next())
200 }
201 None => Ok(None),
202 }
203 }
204
205 pub(super) fn find_emails_in_contacts(
206 conn: &Connection,
207 user_id: UserId,
208 addresses: &[&str],
209 ) -> Result<HashSet<String>> {
210 if addresses.is_empty() {
211 return Ok(HashSet::new());
212 }
213
214 let placeholders = bind_placeholders(addresses.len());
215 let query = format!(
216 "SELECT DISTINCT LOWER(ce.address) FROM contact_emails ce JOIN contacts c ON ce.contact_id = c.id WHERE c.user_id = ? AND LOWER(ce.address) IN ({placeholders})"
217 );
218
219 let mut binds: Vec<String> = Vec::with_capacity(addresses.len() + 1);
220 binds.push(user_id.to_string());
221 binds.extend(addresses.iter().map(|a| a.to_lowercase()));
222
223 let rows: Vec<String> = query_all(conn, &query, params_from_iter(binds), |row| row.get(0))?;
224
225 Ok(rows.into_iter().collect())
226 }
227
228 pub(super) fn promote_contact(
229 conn: &Connection,
230 id: ContactId,
231 user_id: UserId,
232 ) -> Result<Option<Contact>> {
233 let now = format_datetime_now();
234 let result = execute(
235 conn,
236 "UPDATE contacts SET is_implicit = 0, updated_at = ? WHERE id = ? AND user_id = ?",
237 params![&now, id.to_string(), user_id.to_string()],
238 )?;
239
240 if result > 0 {
241 get_by_id(conn, id, user_id)
242 } else {
243 Ok(None)
244 }
245 }
246
247 pub(super) fn find_by_external_id(
248 conn: &Connection,
249 source: &str,
250 ext_id: &str,
251 user_id: UserId,
252 ) -> Result<Option<Contact>> {
253 let row = query_opt(
254 conn,
255 r"
256 SELECT id, display_name, nickname, company, title, notes, tags, birthday, timezone, external_source, external_id, is_implicit, created_at, updated_at
257 FROM contacts
258 WHERE user_id = ? AND external_source = ? AND external_id = ?
259 LIMIT 1
260 ",
261 params![user_id.to_string(), source, ext_id],
262 ContactRow::from_row,
263 )?;
264
265 match row {
266 Some(r) => {
267 let contacts = hydrate::hydrate_contacts(conn, vec![r])?;
268 Ok(contacts.into_iter().next())
269 }
270 None => Ok(None),
271 }
272 }
273