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:
+169
-31
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user