| 4 |
4 |
|
|
| 5 |
5 |
|
use sqlx::{FromRow, PgPool};
|
| 6 |
6 |
|
|
| 7 |
|
- |
use super::enums::{AiTier, DiscoverSort, ItemType};
|
|
7 |
+ |
use super::enums::{AiTierFilter, DiscoverSort, ItemType};
|
| 8 |
8 |
|
use super::models::*;
|
| 9 |
9 |
|
use crate::error::Result;
|
| 10 |
10 |
|
|
| 20 |
20 |
|
pub min_price: Option<i32>,
|
| 21 |
21 |
|
pub max_price: Option<i32>,
|
| 22 |
22 |
|
pub sort_by: Option<DiscoverSort>,
|
| 23 |
|
- |
pub ai_tier: Option<AiTier>,
|
|
23 |
+ |
pub ai_tier: Option<AiTierFilter>,
|
| 24 |
24 |
|
}
|
| 25 |
25 |
|
|
| 26 |
26 |
|
// Shared SQL fragments for fuzzy search (trigram + ILIKE fallback).
|
| 113 |
113 |
|
)"#,
|
| 114 |
114 |
|
);
|
| 115 |
115 |
|
}
|
| 116 |
|
- |
if filters.ai_tier.is_some() {
|
| 117 |
|
- |
query.push_str(" AND i.ai_tier = $6");
|
|
116 |
+ |
// AI disclosure filter: `Handmade only` narrows to handmade; `Human-led`
|
|
117 |
+ |
// accepts handmade ∪ assisted. Values are enum-derived constants (not user
|
|
118 |
+ |
// input), so inlining the literals is safe and keeps the bind-position
|
|
119 |
+ |
// count stable across queries that use this fragment.
|
|
120 |
+ |
match filters.ai_tier {
|
|
121 |
+ |
Some(AiTierFilter::HandmadeOnly) => query.push_str(" AND i.ai_tier = 'handmade'"),
|
|
122 |
+ |
Some(AiTierFilter::HumanLed) => {
|
|
123 |
+ |
query.push_str(" AND i.ai_tier IN ('handmade', 'assisted')")
|
|
124 |
+ |
}
|
|
125 |
+ |
None => {}
|
| 118 |
126 |
|
}
|
| 119 |
127 |
|
}
|
| 120 |
128 |
|
|
| 121 |
|
- |
/// Bind the 6 discover-filter parameters ($1-$6) to a sqlx query.
|
|
129 |
+ |
/// Bind the 5 discover-filter parameters ($1-$5) to a sqlx query.
|
|
130 |
+ |
/// The AI-tier filter is appended to the WHERE as a literal SQL fragment,
|
|
131 |
+ |
/// so it occupies no bind position.
|
| 122 |
132 |
|
macro_rules! bind_item_discover_filters {
|
| 123 |
133 |
|
($q:expr, $filters:expr, $search_term:expr) => {
|
| 124 |
134 |
|
$q.bind($search_term.unwrap_or(""))
|
| 126 |
136 |
|
.bind($filters.min_price.unwrap_or(0))
|
| 127 |
137 |
|
.bind($filters.max_price.unwrap_or(i32::MAX))
|
| 128 |
138 |
|
.bind($filters.tag.unwrap_or(""))
|
| 129 |
|
- |
.bind($filters.ai_tier.map(|t| t.to_string()).unwrap_or_default())
|
| 130 |
139 |
|
};
|
| 131 |
140 |
|
}
|
| 132 |
141 |
|
|
| 173 |
182 |
|
pt.name as primary_tag_name,
|
| 174 |
183 |
|
i.pwyw_enabled,
|
| 175 |
184 |
|
i.pwyw_min_cents,
|
|
185 |
+ |
i.ai_tier,
|
| 176 |
186 |
|
GREATEST(
|
| 177 |
187 |
|
similarity(i.title, $1),
|
| 178 |
188 |
|
similarity(COALESCE(i.description, ''), $1) * 0.5
|
| 202 |
212 |
|
pt.name as primary_tag_name,
|
| 203 |
213 |
|
i.pwyw_enabled,
|
| 204 |
214 |
|
i.pwyw_min_cents,
|
|
215 |
+ |
i.ai_tier,
|
| 205 |
216 |
|
1.0::real as match_score
|
| 206 |
217 |
|
FROM items i
|
| 207 |
218 |
|
JOIN projects p ON i.project_id = p.id
|
| 227 |
238 |
|
pt.name as primary_tag_name,
|
| 228 |
239 |
|
i.pwyw_enabled,
|
| 229 |
240 |
|
i.pwyw_min_cents,
|
|
241 |
+ |
i.ai_tier,
|
| 230 |
242 |
|
NULL::real as match_score
|
| 231 |
243 |
|
FROM items i
|
| 232 |
244 |
|
JOIN projects p ON i.project_id = p.id
|
| 252 |
264 |
|
}
|
| 253 |
265 |
|
};
|
| 254 |
266 |
|
|
| 255 |
|
- |
query.push_str(&format!(" ORDER BY {} LIMIT $7 OFFSET $8", order));
|
|
267 |
+ |
query.push_str(&format!(" ORDER BY {} LIMIT $6 OFFSET $7", order));
|
| 256 |
268 |
|
|
| 257 |
269 |
|
let items = bind_item_discover_filters!(
|
| 258 |
270 |
|
sqlx::query_as::<_, DbDiscoverItemRow>(&query),
|
| 834 |
846 |
|
append_item_discover_filters(&mut q, &filters, true, false);
|
| 835 |
847 |
|
assert!(q.contains("i.title % $1"));
|
| 836 |
848 |
|
}
|
|
849 |
+ |
|
|
850 |
+ |
#[test]
|
|
851 |
+ |
fn append_filters_handmade_only_narrows_to_one_tier() {
|
|
852 |
+ |
let filters = DiscoverFilters {
|
|
853 |
+ |
search: None, item_type: None, tag: None,
|
|
854 |
+ |
min_price: None, max_price: None, sort_by: None,
|
|
855 |
+ |
ai_tier: Some(AiTierFilter::HandmadeOnly),
|
|
856 |
+ |
};
|
|
857 |
+ |
let mut q = String::new();
|
|
858 |
+ |
append_item_discover_filters(&mut q, &filters, false, false);
|
|
859 |
+ |
assert!(q.contains("i.ai_tier = 'handmade'"));
|
|
860 |
+ |
assert!(!q.contains("assisted"));
|
|
861 |
+ |
}
|
|
862 |
+ |
|
|
863 |
+ |
#[test]
|
|
864 |
+ |
fn append_filters_human_led_includes_handmade_and_assisted() {
|
|
865 |
+ |
// Locks the policy commitment that Human-led covers BOTH handmade
|
|
866 |
+ |
// and assisted. A future rename of the literals or a swap to a
|
|
867 |
+ |
// single-tier match would silently weaken the filter.
|
|
868 |
+ |
let filters = DiscoverFilters {
|
|
869 |
+ |
search: None, item_type: None, tag: None,
|
|
870 |
+ |
min_price: None, max_price: None, sort_by: None,
|
|
871 |
+ |
ai_tier: Some(AiTierFilter::HumanLed),
|
|
872 |
+ |
};
|
|
873 |
+ |
let mut q = String::new();
|
|
874 |
+ |
append_item_discover_filters(&mut q, &filters, false, false);
|
|
875 |
+ |
assert!(q.contains("i.ai_tier IN ('handmade', 'assisted')"));
|
|
876 |
+ |
assert!(!q.contains("generated"));
|
|
877 |
+ |
}
|
|
878 |
+ |
|
|
879 |
+ |
#[test]
|
|
880 |
+ |
fn ai_tier_filter_round_trip() {
|
|
881 |
+ |
// Parses the query-string value the route receives back into the
|
|
882 |
+ |
// typed enum the SQL builder expects.
|
|
883 |
+ |
assert_eq!("handmade_only".parse::<AiTierFilter>().unwrap(), AiTierFilter::HandmadeOnly);
|
|
884 |
+ |
assert_eq!("human_led".parse::<AiTierFilter>().unwrap(), AiTierFilter::HumanLed);
|
|
885 |
+ |
assert!("everything".parse::<AiTierFilter>().is_err());
|
|
886 |
+ |
assert!("assisted".parse::<AiTierFilter>().is_err());
|
|
887 |
+ |
assert_eq!(AiTierFilter::HumanLed.to_string(), "human_led");
|
|
888 |
+ |
assert_eq!(AiTierFilter::HandmadeOnly.label(), "Handmade only");
|
|
889 |
+ |
}
|
| 837 |
890 |
|
}
|