feat(tags): wire tags + list filters (T8.3, FR-SEARCH-2)

tags/meeting_tags existed since migration 0001, completely unused.
Adds upsert_tag/tags_for_meeting (mirroring upsert_participant's
find-or-create pattern) and real set_tags/list_tags implementations.

Extends list_meetings with tag/participant/date filters via a new
MeetingFilter struct, replacing the old query-only signature — the
participant filter matches either a speaker mapped to that participant
or an attendee of the meeting's linked calendar event, since neither
signal alone covers "who was in this meeting." Tags are populated on
Meeting/MeetingListItem/SearchHit via a per-row lookup (N+1, but
meeting counts are small enough that this is simpler than GROUP_CONCAT
string-splitting).
This commit is contained in:
iamdoubz
2026-07-02 10:38:26 -05:00
parent c2d282e5ba
commit fc6a2fa0aa
+169 -31
View File
@@ -38,6 +38,20 @@ pub struct NewMeeting {
pub calendar_event_id: Option<String>,
}
/// `list_meetings` filters (Phase 8, FR-SEARCH-2). All fields are ANDed
/// together; each is skipped when `None`.
#[derive(Debug, Default)]
pub struct MeetingFilter {
/// Title substring match — separate from full-text `search()`.
pub query: Option<String>,
pub tag: Option<String>,
/// Matches either a speaker mapped to this participant or an attendee of
/// the meeting's linked calendar event.
pub participant_id: Option<String>,
pub from: Option<i64>,
pub to: Option<i64>,
}
/// What actually happened during the session — richer than the doc's bare
/// `&MeetingId` because `finalize_meeting` has to persist what was captured.
pub struct FinalizeMeeting {
@@ -73,6 +87,8 @@ pub struct Meeting {
/// The linked calendar event, if any (T6.3/T6.6, FR-CAL-2/4) — set either
/// at `start_recording` time or later via `attach_meeting_to_event`.
pub calendar_event_id: Option<String>,
/// Tags (Phase 8, FR-SEARCH-2), sorted.
pub tags: Vec<String>,
}
/// A calendar event with its attendees (T6.3/T6.4, FR-CAL-1/3) — the
@@ -110,7 +126,7 @@ pub trait Store: Send + Sync {
async fn finalize_meeting(&self, id: &MeetingId, s: FinalizeMeeting) -> Result<(), StoreError>;
async fn list_meetings(
&self,
query: Option<String>,
filter: MeetingFilter,
) -> Result<Vec<MeetingListItem>, StoreError>;
async fn get_meeting(&self, id: &MeetingId) -> Result<Meeting, StoreError>;
async fn delete_meeting(&self, id: &MeetingId) -> Result<(), StoreError>;
@@ -177,6 +193,11 @@ pub trait Store: Send + Sync {
) -> Result<(), StoreError>;
/// Full-text search across transcripts + notes (Phase 8, FR-SEARCH-1).
async fn search(&self, query: &str) -> Result<Vec<SearchHit>, StoreError>;
/// Replaces this meeting's complete tag set (Phase 8, FR-SEARCH-2) —
/// not incremental add/remove. Unknown tag names are created.
async fn set_tags(&self, meeting_id: &MeetingId, tags: &[String]) -> Result<(), StoreError>;
/// All known tag names, sorted, for filter/autocomplete UI.
async fn list_tags(&self) -> Result<Vec<String>, StoreError>;
/// Startup reconcile: meetings with audio but no finalized transcript (FR-REL-1).
async fn recover_scan(&self) -> Result<Vec<MeetingId>, StoreError>;
/// Enforce retention; returns count removed. Skips in-progress meetings
@@ -296,6 +317,45 @@ impl SqliteStore {
Ok(id)
}
/// Finds-or-creates a `tags` row by name (Phase 8, FR-SEARCH-2). `name` is
/// `UNIQUE`, so — unlike `upsert_participant`'s nullable `email` — a plain
/// `INSERT OR IGNORE` conflict target is unambiguous here.
async fn upsert_tag(&self, name: &str) -> Result<String, StoreError> {
if let Some(id) = sqlx::query_scalar("SELECT id FROM tags WHERE name = ?")
.bind(name)
.fetch_optional(&self.pool)
.await?
{
return Ok(id);
}
let id = uuid::Uuid::new_v4().to_string();
sqlx::query("INSERT OR IGNORE INTO tags (id, name) VALUES (?, ?)")
.bind(&id)
.bind(name)
.execute(&self.pool)
.await?;
// Someone else (a concurrent set_tags call) may have won the
// INSERT OR IGNORE race; re-look-up rather than assume our id landed.
sqlx::query_scalar("SELECT id FROM tags WHERE name = ?")
.bind(name)
.fetch_one(&self.pool)
.await
.map_err(Into::into)
}
/// Tag names for one meeting, sorted (Phase 8, FR-SEARCH-2).
async fn tags_for_meeting(&self, meeting_id: &MeetingId) -> Result<Vec<String>, StoreError> {
Ok(sqlx::query_scalar(
"SELECT t.name FROM tags t
JOIN meeting_tags mt ON mt.tag_id = t.id
WHERE mt.meeting_id = ?
ORDER BY t.name",
)
.bind(meeting_id)
.fetch_all(&self.pool)
.await?)
}
/// (Re)builds this meeting's `meeting_fts` row from the current title and
/// whatever's on disk for transcript/notes (Phase 8, FR-SEARCH-1).
/// `meeting_fts` is a plain (non-`content=`) FTS5 table, so nothing keeps
@@ -395,6 +455,8 @@ struct TranscriptSpeaker {
display_name: Option<String>,
}
/// Builds everything but `tags` — the caller fills that in with an async
/// `tags_for_meeting` lookup, since sqlx rows can't be read across an await.
fn row_to_list_item(row: &sqlx::sqlite::SqliteRow) -> MeetingListItem {
MeetingListItem {
id: row.get("id"),
@@ -402,6 +464,7 @@ fn row_to_list_item(row: &sqlx::sqlite::SqliteRow) -> MeetingListItem {
started_at: row.get("started_at"),
duration_secs: row.get("duration_secs"),
status: MeetingStatus::parse(row.get::<String, _>("status").as_str()),
tags: Vec::new(),
}
}
@@ -495,23 +558,69 @@ impl Store for SqliteStore {
async fn list_meetings(
&self,
query: Option<String>,
filter: MeetingFilter,
) -> Result<Vec<MeetingListItem>, StoreError> {
let rows = match query.filter(|q| !q.trim().is_empty()) {
Some(q) => {
let like = format!("%{}%", q.trim());
sqlx::query("SELECT id, title, started_at, duration_secs, status FROM meetings WHERE title LIKE ? ORDER BY started_at DESC")
.bind(like)
.fetch_all(&self.pool)
.await?
}
None => {
sqlx::query("SELECT id, title, started_at, duration_secs, status FROM meetings ORDER BY started_at DESC")
.fetch_all(&self.pool)
.await?
}
};
Ok(rows.iter().map(row_to_list_item).collect())
// Conditions are pushed in a fixed order (tag, query, participant,
// from, to) and bound in that exact same order below — there's no
// named-parameter binding in this sqlx query style, so the two lists
// must stay in lockstep.
let mut sql = String::from("SELECT DISTINCT m.id, m.title, m.started_at, m.duration_secs, m.status FROM meetings m");
if filter.tag.is_some() {
sql.push_str(
" JOIN meeting_tags mt ON mt.meeting_id = m.id JOIN tags t ON t.id = mt.tag_id",
);
}
let mut conditions = Vec::new();
if filter.tag.is_some() {
conditions.push("t.name = ?");
}
let query = filter.query.filter(|q| !q.trim().is_empty());
if query.is_some() {
conditions.push("m.title LIKE ?");
}
if filter.participant_id.is_some() {
conditions.push(
"(EXISTS (SELECT 1 FROM speakers s WHERE s.meeting_id = m.id AND s.participant_id = ?)
OR EXISTS (SELECT 1 FROM calendar_event_participants cep WHERE cep.calendar_event_id = m.calendar_event_id AND cep.participant_id = ?))",
);
}
if filter.from.is_some() {
conditions.push("m.started_at >= ?");
}
if filter.to.is_some() {
conditions.push("m.started_at <= ?");
}
if !conditions.is_empty() {
sql.push_str(" WHERE ");
sql.push_str(&conditions.join(" AND "));
}
sql.push_str(" ORDER BY m.started_at DESC");
let mut q = sqlx::query(&sql);
if let Some(tag) = &filter.tag {
q = q.bind(tag);
}
if let Some(query) = &query {
q = q.bind(format!("%{}%", query.trim()));
}
if let Some(pid) = &filter.participant_id {
q = q.bind(pid).bind(pid);
}
if let Some(from) = filter.from {
q = q.bind(from);
}
if let Some(to) = filter.to {
q = q.bind(to);
}
let rows = q.fetch_all(&self.pool).await?;
let mut items = Vec::with_capacity(rows.len());
for row in &rows {
let mut item = row_to_list_item(row);
item.tags = self.tags_for_meeting(&item.id).await?;
items.push(item);
}
Ok(items)
}
async fn get_meeting(&self, id: &MeetingId) -> Result<Meeting, StoreError> {
@@ -568,6 +677,7 @@ impl Store for SqliteStore {
let summary = std::fs::read_to_string(folder.join("summary.json"))
.ok()
.and_then(|s| serde_json::from_str::<SummaryFile>(&s).ok());
let tags = self.tags_for_meeting(id).await?;
Ok(Meeting {
id: row.get("id"),
@@ -585,6 +695,7 @@ impl Store for SqliteStore {
notes_markdown,
summary,
calendar_event_id: row.get("calendar_event_id"),
tags,
})
}
@@ -890,20 +1001,47 @@ impl Store for SqliteStore {
.bind(&fts_query)
.fetch_all(&self.pool)
.await?;
Ok(rows
.iter()
.map(|row| {
let item = row_to_list_item(row);
SearchHit {
id: item.id,
title: item.title,
started_at: item.started_at,
duration_secs: item.duration_secs,
status: item.status,
snippet: row.get("snippet"),
}
})
.collect())
let mut hits = Vec::with_capacity(rows.len());
for row in &rows {
let item = row_to_list_item(row);
let tags = self.tags_for_meeting(&item.id).await?;
hits.push(SearchHit {
id: item.id,
title: item.title,
started_at: item.started_at,
duration_secs: item.duration_secs,
status: item.status,
tags,
snippet: row.get("snippet"),
});
}
Ok(hits)
}
async fn set_tags(&self, meeting_id: &MeetingId, tags: &[String]) -> Result<(), StoreError> {
sqlx::query("DELETE FROM meeting_tags WHERE meeting_id = ?")
.bind(meeting_id)
.execute(&self.pool)
.await?;
for name in tags {
let name = name.trim();
if name.is_empty() {
continue;
}
let tag_id = self.upsert_tag(name).await?;
sqlx::query("INSERT OR IGNORE INTO meeting_tags (meeting_id, tag_id) VALUES (?, ?)")
.bind(meeting_id)
.bind(&tag_id)
.execute(&self.pool)
.await?;
}
Ok(())
}
async fn list_tags(&self) -> Result<Vec<String>, StoreError> {
Ok(sqlx::query_scalar("SELECT name FROM tags ORDER BY name")
.fetch_all(&self.pool)
.await?)
}
async fn recover_scan(&self) -> Result<Vec<MeetingId>, StoreError> {