Skip to main content

archivist_core/
model.rs

1//! Typed response models matching the FicHub REST API.
2//!
3//! Shapes mirror `frontend/src/lib/api/types.ts` (the frontend is the
4//! authoritative contract). Fields are `Option` where the API can omit them.
5
6use serde::{Deserialize, Serialize};
7
8/// A single export's download URLs.
9/// Keys: epub, html, txt, md, mobi, pdf, azw3, docx, fb2, kepub.
10#[derive(Debug, Clone, Default, Deserialize, Serialize)]
11pub struct ExportUrls {
12    /// EPUB download URL, if the API produced one.
13    #[serde(default)]
14    pub epub: Option<String>,
15    /// HTML download URL, if the API produced one.
16    #[serde(default)]
17    pub html: Option<String>,
18    /// Plain-text download URL, if the API produced one.
19    #[serde(default)]
20    pub txt: Option<String>,
21    /// Markdown download URL, if the API produced one.
22    #[serde(default)]
23    pub md: Option<String>,
24    /// MOBI download URL, if the API produced one.
25    #[serde(default)]
26    pub mobi: Option<String>,
27    /// PDF download URL, if the API produced one.
28    #[serde(default)]
29    pub pdf: Option<String>,
30    /// AZW3 download URL, if the API produced one.
31    #[serde(default)]
32    pub azw3: Option<String>,
33    /// DOCX download URL, if the API produced one.
34    #[serde(default)]
35    pub docx: Option<String>,
36    /// FB2 download URL, if the API produced one.
37    #[serde(default)]
38    pub fb2: Option<String>,
39    /// KEPUB download URL, if the API produced one.
40    #[serde(default)]
41    pub kepub: Option<String>,
42}
43
44impl ExportUrls {
45    /// The preferred download URL for a human: EPUB, else first available.
46    pub fn preferred(&self) -> Option<(String, String)> {
47        if let Some(e) = &self.epub {
48            return Some(("epub".to_string(), e.clone()));
49        }
50        self.first()
51    }
52
53    /// First available (format, url) pair.
54    pub fn first(&self) -> Option<(String, String)> {
55        macro_rules! try_fmt {
56            ($name:literal, $field:ident) => {
57                if let Some(u) = &self.$field {
58                    return Some(($name.to_string(), u.clone()));
59                }
60            };
61        }
62        try_fmt!("html", html);
63        try_fmt!("txt", txt);
64        try_fmt!("md", md);
65        try_fmt!("mobi", mobi);
66        try_fmt!("pdf", pdf);
67        try_fmt!("azw3", azw3);
68        try_fmt!("docx", docx);
69        try_fmt!("fb2", fb2);
70        try_fmt!("kepub", kepub);
71        None
72    }
73
74    /// List of (format, url) pairs currently available.
75    pub fn available(&self) -> Vec<(String, String)> {
76        let mut out = Vec::new();
77        macro_rules! push {
78            ($name:literal, $field:ident) => {
79                if let Some(u) = &self.$field {
80                    out.push(($name.to_string(), u.clone()));
81                }
82            };
83        }
84        push!("epub", epub);
85        push!("html", html);
86        push!("txt", txt);
87        push!("md", md);
88        push!("mobi", mobi);
89        push!("pdf", pdf);
90        push!("azw3", azw3);
91        push!("docx", docx);
92        push!("fb2", fb2);
93        push!("kepub", kepub);
94        out
95    }
96}
97
98/// Fic metadata object returned by `/api/epub` and `/api/meta`.
99#[derive(Debug, Clone, Deserialize, Serialize)]
100pub struct FicMeta {
101    /// FicHub url id (string form).
102    pub id: String,
103    /// Numeric work id, when known.
104    #[serde(default)]
105    pub work_id: Option<i64>,
106    /// Work title.
107    pub title: String,
108    /// Author display name.
109    pub author: String,
110    /// Number of chapters.
111    pub chapters: i64,
112    /// Word count.
113    pub words: i64,
114    /// Work description/summary.
115    pub description: String,
116    /// Status string (e.g. `complete`, `in-progress`).
117    pub status: String,
118    /// Source site domain.
119    pub source: String,
120    /// ISO timestamp of original publication.
121    pub created: String,
122    /// ISO timestamp of last update.
123    pub updated: String,
124    /// Site-specific extra metadata, when the API provides it.
125    #[serde(default)]
126    pub extra_meta: Option<serde_json::Value>,
127    /// Raw extended metadata object, when the API provides it.
128    #[serde(default)]
129    pub raw_extended_meta: Option<serde_json::Value>,
130    /// Author profile URL on the source site.
131    pub author_url: String,
132    /// Author id within the source site.
133    pub author_local_id: String,
134    /// FicHub source id.
135    pub source_id: i64,
136    /// FicHub author id.
137    pub author_id: i64,
138}
139
140/// Response from `GET /api/epub?q=` and `GET /api/meta?q=`.
141#[derive(Debug, Clone, Deserialize, Serialize)]
142pub struct ExportResponse {
143    /// Error code (0 = success).
144    pub err: i32,
145    /// Echoed query, when present.
146    #[serde(default)]
147    pub q: Option<String>,
148    /// Error/notice message, when present.
149    #[serde(default)]
150    pub msg: Option<String>,
151    /// FicHub url id of the work.
152    #[serde(default)]
153    pub url_id: Option<String>,
154    /// URL slug, when present.
155    #[serde(default)]
156    pub slug: Option<String>,
157    /// Fic metadata, when the API returned it.
158    #[serde(default)]
159    pub meta: Option<FicMeta>,
160    /// Per-format content hashes, when present.
161    #[serde(default)]
162    pub hashes: Option<serde_json::Map<String, serde_json::Value>>,
163    /// Structured download URLs (newer API shape).
164    #[serde(default)]
165    pub urls: Option<ExportUrls>,
166    /// Legacy flat EPUB URL (older API shape).
167    #[serde(default)]
168    pub epub_url: Option<String>,
169    /// Legacy flat HTML URL (older API shape).
170    #[serde(default)]
171    pub html_url: Option<String>,
172    /// Legacy flat TXT URL (older API shape).
173    #[serde(default)]
174    pub txt_url: Option<String>,
175    /// Legacy flat MD URL (older API shape).
176    #[serde(default)]
177    pub md_url: Option<String>,
178    /// Legacy flat MOBI URL (older API shape).
179    #[serde(default)]
180    pub mobi_url: Option<String>,
181    /// Legacy flat PDF URL (older API shape).
182    #[serde(default)]
183    pub pdf_url: Option<String>,
184    /// Legacy flat AZW3 URL (older API shape).
185    #[serde(default)]
186    pub azw3_url: Option<String>,
187    /// Legacy flat DOCX URL (older API shape).
188    #[serde(default)]
189    pub docx_url: Option<String>,
190    /// Legacy flat FB2 URL (older API shape).
191    #[serde(default)]
192    pub fb2_url: Option<String>,
193    /// Legacy flat KEPUB URL (older API shape).
194    #[serde(default)]
195    pub kepub_url: Option<String>,
196    /// Download notes, when present.
197    #[serde(default)]
198    pub notes: Option<Vec<String>>,
199}
200
201impl ExportResponse {
202    /// Build a flat ExportUrls from the `*_url` fields (older API shape).
203    pub fn flat_urls(&self) -> ExportUrls {
204        ExportUrls {
205            epub: self.epub_url.clone(),
206            html: self.html_url.clone(),
207            txt: self.txt_url.clone(),
208            md: self.md_url.clone(),
209            mobi: self.mobi_url.clone(),
210            pdf: self.pdf_url.clone(),
211            azw3: self.azw3_url.clone(),
212            docx: self.docx_url.clone(),
213            fb2: self.fb2_url.clone(),
214            kepub: self.kepub_url.clone(),
215        }
216    }
217}
218
219/// Response from `GET /api/epub/convert?format=mobi|pdf|azw3`.
220#[derive(Debug, Clone, Deserialize, Serialize)]
221pub struct ConvertResponse {
222    /// Error code (0 = success).
223    pub err: i32,
224    /// Echoed query, when present.
225    #[serde(default)]
226    pub q: Option<String>,
227    /// Error/notice message, when present.
228    #[serde(default)]
229    pub msg: Option<String>,
230    /// FicHub url id of the work.
231    #[serde(default)]
232    pub url_id: Option<String>,
233    /// Requested output format.
234    #[serde(default)]
235    pub format: Option<String>,
236    /// Hash of the produced file, when present.
237    #[serde(default)]
238    pub hash: Option<String>,
239    /// Download URL for the converted file.
240    #[serde(default)]
241    pub url: Option<String>,
242    /// Whether the conversion was served from cache.
243    #[serde(default)]
244    pub cached: Option<bool>,
245    /// Conversion duration in milliseconds.
246    #[serde(default)]
247    pub elapsed_ms: Option<i64>,
248}
249
250/// A single recommendation result.
251#[derive(Debug, Clone, Default, Deserialize, Serialize)]
252pub struct RecResult {
253    /// FicHub url id.
254    pub url_id: String,
255    /// Work title.
256    pub title: String,
257    /// Author display name.
258    pub author: String,
259    /// Word count.
260    #[serde(default)]
261    pub words: i64,
262    /// Chapter count.
263    #[serde(default)]
264    pub chapters: i64,
265    /// Status string (e.g. `complete`).
266    #[serde(default)]
267    pub status: String,
268    /// Source site domain.
269    #[serde(default)]
270    pub site_domain: String,
271    /// Short summary/description.
272    #[serde(default)]
273    pub summary: String,
274    /// Recommendation score (0..1).
275    #[serde(default)]
276    pub score: f64,
277    /// Community-driven score.
278    #[serde(default)]
279    pub community_score: f64,
280    /// Per-format download URLs, when present.
281    #[serde(default)]
282    pub download_urls: serde_json::Map<String, serde_json::Value>,
283}
284
285/// Response from `GET /api/recommendations?q=&n=`.
286#[derive(Debug, Clone, Deserialize, Serialize)]
287pub struct RecommendationsResponse {
288    /// Error code (0 = success).
289    pub err: i32,
290    /// Source fic url id the recs were computed for.
291    #[serde(default)]
292    pub url_id: String,
293    /// Source site domain, when present.
294    #[serde(default)]
295    pub site_domain: Option<String>,
296    /// Recommended works.
297    #[serde(default)]
298    pub recommendations: Vec<RecResult>,
299    /// ISO timestamp when the recs were generated.
300    #[serde(default)]
301    pub generated_at: String,
302}
303
304/// Personal recommendations response (`GET /api/recommendations/personal`).
305#[derive(Debug, Clone, Deserialize, Serialize)]
306pub struct PersonalRecommendationsResponse {
307    /// Error code (0 = success).
308    pub err: i32,
309    /// Whether the user's history was enough to compute recs.
310    #[serde(default)]
311    pub enough_data: bool,
312    /// Recommended works.
313    #[serde(default)]
314    pub recs: Vec<RecResult>,
315    /// `[{ "title": ..., "url_id": ... }, ...]` — what the recs were based on.
316    #[serde(default)]
317    pub based_on: Vec<serde_json::Value>,
318    /// Pluggable-mode diagnostics (`strategies`, `curator_alpha`, ...).
319    #[serde(default)]
320    pub strategies: Vec<serde_json::Value>,
321}
322
323/// A community suggestion for a fic (`GET /api/recommendations/votes`).
324#[derive(Debug, Clone, Deserialize, Serialize)]
325pub struct Suggestion {
326    /// Suggestion id.
327    pub id: i64,
328    /// Suggested work's url id.
329    pub suggested_url_id: String,
330    /// Optional comment explaining the suggestion.
331    #[serde(default)]
332    pub comment: Option<String>,
333    /// Net vote count.
334    pub net_votes: i64,
335    /// ISO timestamp, when present.
336    #[serde(default)]
337    pub created: Option<String>,
338}
339
340/// Response from `GET /api/recommendations/votes?url_id=`.
341#[derive(Debug, Clone, Deserialize, Serialize)]
342pub struct VotesResponse {
343    /// Error code (0 = success).
344    pub err: i32,
345    /// Source fic url id.
346    pub url_id: String,
347    /// Community suggestions.
348    #[serde(default)]
349    pub suggestions: Vec<Suggestion>,
350}
351
352/// A search result row (`GET /api/search`, `POST /api/search/ask`).
353#[derive(Debug, Clone, Deserialize, Serialize)]
354pub struct SearchResult {
355    /// FicHub url id.
356    pub url_id: String,
357    /// Work title.
358    pub title: String,
359    /// Author display name.
360    pub author: String,
361    /// Source site domain.
362    pub source: String,
363    /// Word count.
364    pub words: i64,
365    /// Chapter count.
366    pub chapters: i64,
367    /// Status string (e.g. `complete`).
368    pub status: String,
369    /// Work description.
370    pub description: String,
371    /// ISO timestamp of last update, when present.
372    #[serde(default)]
373    pub updated: Option<String>,
374    /// Relevance rank, when present.
375    #[serde(default)]
376    pub rank: Option<f64>,
377    /// Highlighted snippet, when present.
378    #[serde(default)]
379    pub snippet: Option<String>,
380    /// Tags attached to the work.
381    #[serde(default)]
382    pub tags: Vec<serde_json::Value>,
383    /// Freeform tag count on the work.
384    #[serde(default)]
385    pub total_freeform: usize,
386    /// Comment count.
387    #[serde(default)]
388    pub comment_count: i64,
389    /// Kudos count.
390    #[serde(default)]
391    pub kudos_count: i64,
392}
393
394/// Search facets (`GET /api/search`).
395#[derive(Debug, Clone, Default, Deserialize, Serialize)]
396pub struct SearchFacets {
397    /// Available fandom facets.
398    #[serde(default)]
399    pub fandoms: Vec<serde_json::Value>,
400    /// Available tag facets.
401    #[serde(default)]
402    pub tags: Vec<serde_json::Value>,
403}
404
405/// Response envelope from `GET /api/search`.
406#[derive(Debug, Clone, Deserialize, Serialize)]
407pub struct SearchResponse {
408    /// Total matching works.
409    pub total: i64,
410    /// Current page (1-based).
411    pub page: usize,
412    /// Results per page.
413    pub per_page: usize,
414    /// Matching works.
415    pub results: Vec<SearchResult>,
416    /// Facet counts, when present.
417    #[serde(default)]
418    pub facets: SearchFacets,
419}
420
421/// Response from `POST /api/search/ask` — search envelope plus ask metadata.
422#[derive(Debug, Clone, Deserialize, Serialize)]
423pub struct AskResponse {
424    /// The underlying search envelope.
425    #[serde(flatten)]
426    pub search: SearchResponse,
427    /// Whether the natural-language ask path was used.
428    #[serde(default)]
429    pub used_ask: bool,
430    /// The original natural-language query, when ask was used.
431    #[serde(default)]
432    pub ask_query: Option<String>,
433    /// Free-text translation of the parsed filters, when present.
434    #[serde(default)]
435    pub translation: Option<serde_json::Value>,
436}
437
438/// Response from `GET /api/search/body?q=`.
439#[derive(Debug, Clone, Deserialize, Serialize)]
440pub struct BodySearchResponse {
441    /// Error code (0 = success).
442    pub err: i32,
443    /// Total matching works.
444    #[serde(default)]
445    pub total: i64,
446    /// Current page (1-based).
447    #[serde(default)]
448    pub page: usize,
449    /// Results per page.
450    #[serde(default)]
451    pub per_page: usize,
452    /// Matching works.
453    #[serde(default)]
454    pub results: Vec<BodySearchHit>,
455}
456
457/// A body-search hit with `<mark>`-highlighted snippet.
458#[derive(Debug, Clone, Deserialize, Serialize)]
459pub struct BodySearchHit {
460    /// FicHub url id.
461    pub url_id: String,
462    /// Numeric work id, when known.
463    #[serde(default)]
464    pub work_id: Option<i64>,
465    /// Work title.
466    pub title: String,
467    /// Author display name.
468    pub author: String,
469    /// Source site domain.
470    #[serde(default)]
471    pub source: String,
472    /// Word count.
473    #[serde(default)]
474    pub words: i64,
475    /// Chapter count.
476    #[serde(default)]
477    pub chapters: i64,
478    /// Status string.
479    #[serde(default)]
480    pub status: String,
481    /// Work description.
482    #[serde(default)]
483    pub description: String,
484    /// `<mark>`-highlighted matching passage.
485    #[serde(default)]
486    pub body_snippet: Option<String>,
487}
488
489/// A feed item (`GET /api/v1/feed`).
490#[derive(Debug, Clone, Deserialize, Serialize)]
491pub struct FeedItem {
492    /// Numeric work id.
493    #[serde(default)]
494    pub work_id: i64,
495    /// FicHub url id.
496    #[serde(default)]
497    pub url_id: String,
498    /// Work title.
499    #[serde(default)]
500    pub title: String,
501    /// Author display name.
502    #[serde(default)]
503    pub author: String,
504    /// ISO timestamp of last update.
505    #[serde(default)]
506    pub updated: String,
507    /// Feed entry format (e.g. `epub`).
508    #[serde(default)]
509    pub format: String,
510    /// Optional feed note.
511    #[serde(default)]
512    pub note: Option<String>,
513}
514
515/// Response from `GET /api/v1/feed`.
516#[derive(Debug, Clone, Deserialize, Serialize)]
517pub struct FeedResponse {
518    /// Error code (0 = success).
519    pub err: i32,
520    /// Feed items.
521    #[serde(default)]
522    pub items: Vec<FeedItem>,
523    /// Current page.
524    #[serde(default)]
525    pub page: i64,
526    /// Results per page.
527    #[serde(default)]
528    pub per_page: i64,
529    /// Total items.
530    #[serde(default)]
531    pub total: i64,
532}
533
534/// A Fic Request board item (`GET /api/requests`).
535#[derive(Debug, Clone, Deserialize, Serialize)]
536pub struct RequestItem {
537    /// Request id.
538    pub id: i64,
539    /// Request title.
540    pub title: String,
541    /// Request body text.
542    pub body: String,
543    /// Seed work (fic the request is about), when known.
544    #[serde(default)]
545    pub seed_work_id: Option<i64>,
546    /// Request status.
547    pub status: String,
548    /// ISO timestamp of creation.
549    #[serde(default)]
550    pub created_at: String,
551    /// Number of answers.
552    #[serde(default)]
553    pub answer_count: i64,
554    /// Upvote count.
555    #[serde(default)]
556    pub upvotes: i64,
557}
558
559/// Response from `GET /api/requests?status=`.
560#[derive(Debug, Clone, Deserialize, Serialize)]
561pub struct RequestsResponse {
562    /// Error code (0 = success).
563    pub err: i32,
564    /// Request items.
565    #[serde(default)]
566    pub items: Vec<RequestItem>,
567    /// Current page.
568    #[serde(default)]
569    pub page: i64,
570    /// Request status filter echoed back.
571    #[serde(default)]
572    pub status: String,
573}
574
575/// A roadmap consensus cluster row.
576#[derive(Debug, Clone, Deserialize, Serialize)]
577pub struct ClusterRow {
578    /// Cluster id.
579    pub id: i64,
580    /// Cluster text (the roadmap item).
581    pub text: String,
582    /// Elo rating in the consensus tournament.
583    #[serde(default)]
584    pub elo_rating: f64,
585    /// Matches played.
586    #[serde(default)]
587    pub matches_played: i64,
588    /// Times picked as best.
589    #[serde(default)]
590    pub times_picked_best: i64,
591    /// Times picked as worst.
592    #[serde(default)]
593    pub times_picked_worst: i64,
594    /// Number of suggestions.
595    #[serde(default)]
596    pub suggestions: i64,
597    /// Cluster status.
598    #[serde(default)]
599    pub status: String,
600    /// Controversy score.
601    #[serde(default)]
602    pub controversy: i64,
603}
604
605/// Response from `GET /api/roadmap/consensus`.
606#[derive(Debug, Clone, Deserialize, Serialize)]
607pub struct ConsensusResponse {
608    /// Error code (0 = success).
609    pub err: i32,
610    /// Leaderboard rows.
611    #[serde(default)]
612    pub leaderboard: Vec<ClusterRow>,
613    /// Most controversial rows.
614    #[serde(default)]
615    pub controversy: Vec<ClusterRow>,
616}
617
618/// Auth response from `POST /api/auth/login`.
619#[derive(Debug, Clone, Deserialize, Serialize)]
620pub struct AuthResponse {
621    /// JWT to use for authenticated endpoints.
622    pub token: String,
623    /// The authenticated user.
624    pub user: AuthUserModel,
625}
626
627/// The authenticated user model.
628#[derive(Debug, Clone, Deserialize, Serialize)]
629pub struct AuthUserModel {
630    /// FicHub user id.
631    pub id: i64,
632    /// Username.
633    pub username: String,
634    /// Role code.
635    pub role: i16,
636    /// Reputation score.
637    pub reputation: i64,
638    /// Email address, when present.
639    #[serde(default)]
640    pub email: Option<String>,
641    /// Level.
642    #[serde(default)]
643    pub level: i16,
644    /// Experience points.
645    #[serde(default)]
646    pub exp: i64,
647}
648
649/// Me response from `GET /api/auth/me`.
650#[derive(Debug, Clone, Deserialize, Serialize)]
651pub struct MeResponse {
652    /// Error code (0 = success).
653    pub err: i32,
654    /// The authenticated user, when the token was valid.
655    #[serde(default)]
656    pub user: Option<AuthUserModel>,
657}
658
659// ── Forum (F2–F6: categories/topics/posts/follow/read/search/moderation) ──
660//
661// Shapes mirror `src/routes/forum.rs` in the FicHub web repo (the
662// authoritative server implementation of FORUM-API-CONTRACT.md). All forum
663// endpoints are auth-gated; the bot always sends the bearer token.
664
665/// A forum category (`GET /api/forum/categories` item).
666#[derive(Debug, Clone, Deserialize, Serialize)]
667pub struct ForumCategory {
668    /// Category id.
669    pub id: i64,
670    /// URL slug (used as `category=` in topic lists).
671    pub slug: String,
672    /// Display title.
673    pub title: String,
674    /// Category description.
675    #[serde(default)]
676    pub description: String,
677    /// Sort position (ascending).
678    #[serde(default)]
679    pub position: i32,
680    /// Mod-only board (hidden from anonymous readers).
681    #[serde(default)]
682    pub is_mod_only: bool,
683    /// ISO timestamp of creation.
684    #[serde(default)]
685    pub created_at: String,
686    /// Live topic count (excludes deleted/hidden).
687    #[serde(default)]
688    pub topic_count: i64,
689    /// ISO timestamp of the most recent topic activity, when present.
690    #[serde(default)]
691    pub last_activity_at: Option<String>,
692}
693
694/// Response envelope from `GET /api/forum/categories`.
695#[derive(Debug, Clone, Deserialize, Serialize)]
696pub struct ForumCategoriesResponse {
697    /// Error code (0 = success).
698    pub err: i32,
699    /// Visible categories.
700    #[serde(default)]
701    pub items: Vec<ForumCategory>,
702}
703
704/// A topic row in the category listing (`GET /api/forum/topics` item).
705#[derive(Debug, Clone, Deserialize, Serialize)]
706pub struct ForumTopic {
707    /// Topic id.
708    pub id: i64,
709    /// Topic title.
710    pub title: String,
711    /// Canonical slug `{slugified-title}-{id}`, when assigned.
712    #[serde(default)]
713    pub topic_slug: Option<String>,
714    /// Author user id.
715    pub author_id: i64,
716    /// Author display name.
717    #[serde(default)]
718    pub author_username: String,
719    /// Number of replies (visible posts − OP).
720    #[serde(default)]
721    pub reply_count: i64,
722    /// Legacy vote score (v2 keeps this at 0; mod points are the score system).
723    #[serde(default)]
724    pub vote_score: i64,
725    /// View count.
726    #[serde(default)]
727    pub view_count: i64,
728    /// Id of the newest post, when present.
729    #[serde(default)]
730    pub last_post_id: Option<i64>,
731    /// Topic status: `open` | `locked` | `pinned` | `removed`.
732    #[serde(default)]
733    pub status: String,
734    /// ISO timestamp of last activity.
735    #[serde(default)]
736    pub last_activity_at: String,
737    /// ISO timestamp of creation.
738    #[serde(default)]
739    pub created_at: String,
740    /// Whether the current user has unread posts in this topic.
741    #[serde(default)]
742    pub unread: bool,
743}
744
745/// Response envelope from `GET /api/forum/topics`.
746#[derive(Debug, Clone, Deserialize, Serialize)]
747pub struct ForumTopicList {
748    /// Error code (0 = success).
749    pub err: i32,
750    /// Topic rows (pinned first, then last activity desc).
751    #[serde(default)]
752    pub items: Vec<ForumTopic>,
753    /// Cursor for the next page (last topic id), when more pages exist.
754    #[serde(default)]
755    pub next_cursor: Option<i64>,
756    /// Category slug echoed back.
757    #[serde(default)]
758    pub category: String,
759    /// Page size actually applied.
760    #[serde(default)]
761    pub limit: i64,
762}
763
764/// A quoted-post preview attached to a forum post (best-effort server-side).
765#[derive(Debug, Clone, Deserialize, Serialize)]
766pub struct ForumQuote {
767    /// Author of the quoted post.
768    #[serde(default)]
769    pub author_username: String,
770    /// First ~200 chars of the quoted post.
771    #[serde(default)]
772    pub preview: String,
773}
774
775/// A single post in a topic detail (`GET /api/forum/topics/{id}` item).
776#[derive(Debug, Clone, Deserialize, Serialize)]
777pub struct ForumPost {
778    /// Post id.
779    pub id: i64,
780    /// Author user id.
781    pub author_id: i64,
782    /// Author display name.
783    #[serde(default)]
784    pub author_username: String,
785    /// Post body (markdown).
786    #[serde(default)]
787    pub body: String,
788    /// Id of the quoted post, when this post quotes one.
789    #[serde(default)]
790    pub quote_of: Option<i64>,
791    /// Resolved quote preview, when available.
792    #[serde(default)]
793    pub quote: Option<ForumQuote>,
794    /// ISO timestamp of last edit, when edited.
795    #[serde(default)]
796    pub edited_at: Option<String>,
797    /// ISO timestamp of deletion, when soft-deleted.
798    #[serde(default)]
799    pub deleted_at: Option<String>,
800    /// ISO timestamp of creation.
801    #[serde(default)]
802    pub created_at: String,
803    /// Moderation score (0 baseline; adjusted by mod actions).
804    #[serde(default)]
805    pub score: i32,
806    /// Whether this post is the topic OP (lowest-id post).
807    #[serde(default)]
808    pub is_op: bool,
809}
810
811/// Topic detail + posts (`GET /api/forum/topics/{id}` and `/by-slug/{slug}`).
812#[derive(Debug, Clone, Deserialize, Serialize)]
813pub struct ForumTopicDetail {
814    /// Error code (0 = success).
815    pub err: i32,
816    /// Topic id.
817    pub id: i64,
818    /// Topic title.
819    pub title: String,
820    /// Canonical slug, when assigned.
821    #[serde(default)]
822    pub topic_slug: Option<String>,
823    /// Author user id.
824    pub author_id: i64,
825    /// Author display name.
826    #[serde(default)]
827    pub author_username: String,
828    /// Category slug.
829    #[serde(default)]
830    pub category_slug: String,
831    /// Category display title.
832    #[serde(default)]
833    pub category_title: String,
834    /// Topic status.
835    #[serde(default)]
836    pub status: String,
837    /// OP body (markdown).
838    #[serde(default)]
839    pub body: String,
840    /// Free-form payload JSONB, when set.
841    #[serde(default)]
842    pub payload: serde_json::Value,
843    /// View count AFTER this fetch incremented it.
844    #[serde(default)]
845    pub view_count: i64,
846    /// ISO timestamp of creation.
847    #[serde(default)]
848    pub created_at: String,
849    /// ISO timestamp of last update, when present.
850    #[serde(default)]
851    pub updated_at: Option<String>,
852    /// Posts (OP first, then replies ascending; `after=` cursor skips earlier).
853    #[serde(default)]
854    pub items: Vec<ForumPost>,
855    /// Cursor for the next page of posts, when more exist.
856    #[serde(default)]
857    pub next_cursor: Option<i64>,
858    /// Page size actually applied.
859    #[serde(default)]
860    pub limit: i64,
861    /// View count before this fetch incremented it.
862    #[serde(default)]
863    pub view_count_before: i64,
864}
865
866/// A forum search hit (`GET /api/forum/search` result).
867#[derive(Debug, Clone, Deserialize, Serialize)]
868pub struct ForumSearchHit {
869    /// `topic` (OP match) or `post` (reply match).
870    #[serde(default)]
871    pub r#type: String,
872    /// Topic id the hit belongs to.
873    pub topic_id: i64,
874    /// Topic slug, when assigned.
875    #[serde(default)]
876    pub topic_slug: Option<String>,
877    /// Post id, when the hit is a reply.
878    #[serde(default)]
879    pub post_id: Option<i64>,
880    /// Author user id.
881    pub author_id: i64,
882    /// Author display name.
883    #[serde(default)]
884    pub author_username: String,
885    /// Topic title.
886    #[serde(default)]
887    pub title: String,
888    /// Matched body text.
889    #[serde(default)]
890    pub body: String,
891    /// `<mark>`-highlighted snippet, when present.
892    #[serde(default)]
893    pub snippet: Option<String>,
894    /// Category slug.
895    #[serde(default)]
896    pub category_slug: String,
897    /// Category display title.
898    #[serde(default)]
899    pub category_title: String,
900    /// ISO timestamp of the matching post/topic.
901    #[serde(default)]
902    pub created_at: String,
903}
904
905/// Response envelope from `GET /api/forum/search`.
906#[derive(Debug, Clone, Deserialize, Serialize)]
907pub struct ForumSearchResponse {
908    /// Error code (0 = success).
909    pub err: i32,
910    /// Query echoed back.
911    #[serde(default)]
912    pub q: String,
913    /// Matches (ranked).
914    #[serde(default)]
915    pub results: Vec<ForumSearchHit>,
916    /// Always null (search is limit/offset, not cursor-paginated).
917    #[serde(default)]
918    pub next_cursor: Option<i64>,
919    /// Page size actually applied.
920    #[serde(default)]
921    pub limit: i64,
922    /// Number of merged matches (pre-truncation).
923    #[serde(default)]
924    pub total: i64,
925}
926
927/// Response from `POST /api/forum/topics` (create topic).
928#[derive(Debug, Clone, Deserialize, Serialize)]
929pub struct ForumCreateTopicResponse {
930    /// Error code (0 = success).
931    pub err: i32,
932    /// New topic id.
933    #[serde(default)]
934    pub id: i64,
935    /// New OP post id.
936    #[serde(default)]
937    pub post_id: i64,
938    /// Generated canonical slug `{slugified-title}-{id}`.
939    #[serde(default)]
940    pub topic_slug: Option<String>,
941    /// Human message.
942    #[serde(default)]
943    pub msg: String,
944}
945
946/// Response from `POST /api/forum/topics/{id}/posts` (reply).
947#[derive(Debug, Clone, Deserialize, Serialize)]
948pub struct ForumPostCreated {
949    /// Error code (0 = success).
950    pub err: i32,
951    /// New post id.
952    #[serde(default)]
953    pub id: i64,
954    /// Human message.
955    #[serde(default)]
956    pub msg: String,
957}
958
959/// Follow state (`POST` toggle and `GET` read on `/api/forum/topics/{id}/follow`).
960#[derive(Debug, Clone, Deserialize, Serialize)]
961pub struct ForumFollowState {
962    /// Error code (0 = success).
963    pub err: i32,
964    /// Whether the current user is following the topic (post-toggle value).
965    #[serde(default)]
966    pub following: bool,
967    /// Total follower count.
968    #[serde(default)]
969    pub follower_count: i64,
970}
971
972/// Response from `POST /api/forum/topics/{id}/read` (mark read).
973#[derive(Debug, Clone, Deserialize, Serialize)]
974pub struct ForumMarkRead {
975    /// Error code (0 = success).
976    pub err: i32,
977    /// The read marker actually stored (monotonic).
978    #[serde(default)]
979    pub last_read_post_id: i64,
980    /// ISO timestamp of the upsert.
981    #[serde(default)]
982    pub updated_at: String,
983}
984
985/// Response from `GET /api/forum/moderation/status` (F5).
986#[derive(Debug, Clone, Deserialize, Serialize)]
987pub struct ForumModerationStatus {
988    /// Error code (0 = success).
989    pub err: i32,
990    /// Points left in the current grant window.
991    #[serde(default)]
992    pub points_left: i16,
993    /// ISO timestamp of grant expiry, when eligible.
994    #[serde(default)]
995    pub expires_at: Option<String>,
996    /// Whether the user may moderate.
997    #[serde(default)]
998    pub eligible: bool,
999    /// Why the user is ineligible, when present.
1000    #[serde(default)]
1001    pub reason: Option<String>,
1002}
1003
1004/// A moderation-queue row (`GET /api/forum/moderation/queue` item, F5).
1005#[derive(Debug, Clone, Deserialize, Serialize)]
1006pub struct ForumModQueueItem {
1007    /// Post id.
1008    pub post_id: i64,
1009    /// Topic id the post belongs to.
1010    pub topic_id: i64,
1011    /// Author display name.
1012    #[serde(default)]
1013    pub author_username: String,
1014    /// Post excerpt (first ~300 chars).
1015    #[serde(default)]
1016    pub body: String,
1017    /// Current moderation score.
1018    #[serde(default)]
1019    pub score: i32,
1020    /// Times already moderated.
1021    #[serde(default)]
1022    pub mod_count: i32,
1023    /// Always null in v2 (reason is chosen at moderate time).
1024    #[serde(default)]
1025    pub reason: Option<String>,
1026    /// ISO timestamp of creation.
1027    #[serde(default)]
1028    pub created_at: String,
1029}
1030
1031/// Response envelope from `GET /api/forum/moderation/queue` (F5).
1032#[derive(Debug, Clone, Deserialize, Serialize)]
1033pub struct ForumModerationQueue {
1034    /// Error code (0 = success).
1035    pub err: i32,
1036    /// Posts needing moderation (lowest score first).
1037    #[serde(default)]
1038    pub items: Vec<ForumModQueueItem>,
1039    /// Item count.
1040    #[serde(default)]
1041    pub count: i64,
1042    /// Cold-start gate: eligible-moderator pool below minimum.
1043    #[serde(default)]
1044    pub pool_too_small: bool,
1045}
1046
1047/// Response from `POST /api/forum/posts/{id}/moderate` (F5).
1048#[derive(Debug, Clone, Deserialize, Serialize)]
1049pub struct ForumModerateResponse {
1050    /// Error code (0 = success).
1051    pub err: i32,
1052    /// Score delta applied for the reason.
1053    #[serde(default)]
1054    pub delta: i16,
1055    /// Post score after the action.
1056    #[serde(default)]
1057    pub score_after: i32,
1058    /// Always null in v2 (auto-collapse is client-side).
1059    #[serde(default)]
1060    pub hidden_until: Option<String>,
1061}
1062
1063/// A metamoderation-queue row (`GET /api/forum/metamod/queue` item, F6).
1064#[derive(Debug, Clone, Deserialize, Serialize)]
1065pub struct ForumMetamodItem {
1066    /// Moderation action id (used for the vote).
1067    pub action_id: i64,
1068    /// Post id the action targeted.
1069    pub post_id: i64,
1070    /// Topic id the post belongs to.
1071    pub topic_id: i64,
1072    /// Post excerpt (first ~200 chars).
1073    #[serde(default)]
1074    pub excerpt: String,
1075    /// Reason the moderator used.
1076    #[serde(default)]
1077    pub reason: String,
1078    /// Score delta the moderator applied.
1079    #[serde(default)]
1080    pub delta: i16,
1081    /// Post score after the action.
1082    #[serde(default)]
1083    pub score_after: i32,
1084    /// ISO timestamp of the action.
1085    #[serde(default)]
1086    pub created_at: String,
1087}
1088
1089/// Response envelope from `GET /api/forum/metamod/queue` (F6).
1090#[derive(Debug, Clone, Deserialize, Serialize)]
1091pub struct ForumMetamodQueue {
1092    /// Error code (0 = success).
1093    pub err: i32,
1094    /// Anonymized moderation actions to rate.
1095    #[serde(default)]
1096    pub items: Vec<ForumMetamodItem>,
1097    /// Item count.
1098    #[serde(default)]
1099    pub count: i64,
1100    /// Cold-start gate: eligible-metamod pool below minimum.
1101    #[serde(default)]
1102    pub pool_too_small: bool,
1103}
1104
1105/// Response from `POST /api/forum/metamod/{actionId}/vote` (F6).
1106#[derive(Debug, Clone, Deserialize, Serialize)]
1107pub struct ForumMetamodVoteResponse {
1108    /// Error code (0 = success).
1109    pub err: i32,
1110}