Compare commits

...

25 Commits

Author SHA1 Message Date
22b1dd3f56 fix: portal wrapup share card to escape transform stacking context
All checks were successful
CI / Check / Test (push) Successful in 1h5m29s
2026-07-27 14:07:36 +02:00
813778dc7e fix: users table has avatar_path not avatar_url, resolve to full URL
Some checks failed
CI / Check / Test (push) Has been cancelled
2026-07-27 13:08:45 +02:00
063bab910b ADR-0003: local follows bypass AP, unified FollowCommand/FollowQuery ports, FederationRepos struct, base_url normalization
Some checks failed
CI / Check / Test (push) Has been cancelled
2026-07-27 12:56:30 +02:00
839cababe4 bump k-ap 0.5.0, decompose repo traits, LocalObject, ActivityStreamsType enum, split follow modules
Some checks failed
CI / Check / Test (push) Has been cancelled
2026-07-27 11:46:24 +02:00
fdf286ca94 fix: remove split-debuginfo option from dev profile in Cargo.toml
Some checks failed
CI / Check / Test (push) Has been cancelled
2026-07-27 11:19:54 +02:00
e30e785936 bump k-ap to 0.4.6, add nodeinfo metadata 2026-07-16 09:56:54 +02:00
10be024bdf fix: update k-ap dependency version to 0.4.4
All checks were successful
CI / Check / Test (push) Successful in 1h5m51s
2026-07-11 10:45:43 +02:00
1e33c6d184 fix: update k-ap dependency version to 0.4.4
Some checks failed
CI / Check / Test (push) Has been cancelled
2026-07-11 10:40:53 +02:00
96168cfc7b fix: update k-ap dependency version to 0.4.3
Some checks failed
CI / Check / Test (push) Has been cancelled
2026-07-11 10:21:28 +02:00
95b839355f fix: portal search overlay to escape transform stacking context, auto-fill IMDB from ttXXXX input
All checks were successful
CI / Check / Test (push) Successful in 1h12m50s
2026-07-11 01:44:08 +02:00
85285b2a52 github ci fix
All checks were successful
CI / Check / Test (push) Successful in 1h9m41s
2026-07-10 23:29:17 +02:00
c3d8bcad29 fix: collapse nested if for clippy
Some checks failed
CI / Check / Test (push) Has been cancelled
2026-07-10 23:20:49 +02:00
8aa64ab174 fix: restrict swipe-tabs drag to touch-only, prevent ghost swipe after drawer close
Some checks failed
CI / Check / Test (push) Failing after 8m25s
2026-07-10 22:52:50 +02:00
d84c54bb30 feat: page transitions, poster hover, star bounce, reveal scale-up 2026-07-10 22:51:09 +02:00
41bed3583d feat: calendar diary view on profile (#15)
Some checks failed
CI / Check / Test (push) Failing after 8m29s
2026-07-10 22:32:47 +02:00
0a8b52514d feat: calendar diary view on profile (#15) 2026-07-10 22:22:13 +02:00
a0c0ba1c5d rename profile Trends tab to Stats
Some checks failed
CI / Check / Test (push) Failing after 8m35s
2026-07-10 22:01:56 +02:00
94cab1ea7c fix: lock body scroll + remove scrollbar on wrapup share card
Some checks failed
CI / Check / Test (push) Has been cancelled
2026-07-10 21:58:38 +02:00
3dde0d13db docs: sync README + architecture diagram with current codebase 2026-07-10 21:56:16 +02:00
1c81d7768c fix: duplicate count in wrapup watch medium display 2026-07-10 21:53:21 +02:00
07df6fe207 review detail sheet on diary/profile/community, wrapup period presets 2026-07-10 21:51:42 +02:00
1a7448fd3d fix: use strict > for min-count filters in wrapup stats
Some checks failed
CI / Check / Test (push) Failing after 8m38s
2026-07-10 21:42:29 +02:00
587dcc04de feat: richer profile stats, wrapup min-count filter, watch medium distribution
Some checks failed
CI / Check / Test (push) Has been cancelled
- filter wrapup top directors/actors with count < 2
- add watch_medium to wrapup report + adapters
- profile trends: genre breakdown, rating histogram, watch medium dist
- fix unused StarDisplay import in movie detail
2026-07-10 21:38:08 +02:00
498f3b1818 edit sheet delete btn, fix medium icons, 44px touch targets, fix drag leak on drawer close
Some checks failed
CI / Check / Test (push) Failing after 8m44s
2026-07-10 17:46:56 +02:00
0ee2e04fe5 refactor(spa): collapse api/hooks telescope, merge review sheets, extract tab+list modules
- merge lib/api/* + hooks/use-* → features/* domain modules
- log-sheet + edit-review-sheet → review-sheet with mode discriminant
- index.tsx → feed-tab, watchlist-tab, queue-tab
- social actor-list pattern → ActorList component
- movie detail inline sections → community-reviews, viewing-history
2026-07-10 17:38:58 +02:00
134 changed files with 4181 additions and 2732 deletions

View File

@@ -57,8 +57,8 @@ jobs:
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GHCR_TOKEN || github.token }}
- name: Docker metadata - name: Docker metadata
id: meta id: meta

8
Cargo.lock generated
View File

@@ -2892,9 +2892,9 @@ dependencies = [
[[package]] [[package]]
name = "k-ap" name = "k-ap"
version = "0.4.2" version = "0.5.0"
source = "sparse+https://git.gabrielkaszewski.dev/api/packages/GKaszewski/cargo/" source = "sparse+https://git.gabrielkaszewski.dev/api/packages/GKaszewski/cargo/"
checksum = "4291cac43b119cce0be6e2ba8d85339f3f4c69b266cb7c00fb4cb179302b97e4" checksum = "ab6066cccc6ae8aaa2f6262ac7d471e58930a04be3266a2e367d6bdd8aaaba29"
dependencies = [ dependencies = [
"activitypub_federation", "activitypub_federation",
"anyhow", "anyhow",
@@ -2903,9 +2903,11 @@ dependencies = [
"chrono", "chrono",
"enum_delegate", "enum_delegate",
"futures", "futures",
"paste",
"reqwest 0.13.3", "reqwest 0.13.3",
"serde", "serde",
"serde_json", "serde_json",
"thiserror 2.0.18",
"tokio", "tokio",
"tracing", "tracing",
"url", "url",
@@ -6714,7 +6716,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.48.0",
] ]
[[package]] [[package]]

View File

@@ -98,7 +98,6 @@ infra-wiring = { path = "crates/infra-wiring" }
[profile.dev] [profile.dev]
debug = 1 # line tables only — still debuggable, much faster linking debug = 1 # line tables only — still debuggable, much faster linking
split-debuginfo = "unpacked" # macOS: skip dsymutil on every link
[profile.dev.package."*"] [profile.dev.package."*"]
opt-level = 2 # compile deps faster at runtime; paid once, cached after opt-level = 2 # compile deps faster at runtime; paid once, cached after

View File

@@ -47,7 +47,7 @@ Open `http://localhost:3000`. The HTTP server and background worker start togeth
## Features ## Features
- Log movies with a TMDB/OMDb ID or manual title/year/director, with a 05 rating and optional watch medium (cinema, streaming, TV, physical media, download, media server) - Log movies with a TMDB/OMDb ID or manual title/year/director, with a 15 rating and optional watch medium (cinema, streaming, TV, physical media, download, media server)
- Edit reviews after the fact — update rating, comment, date, or watch medium via partial PATCH; each watch is still a separate record (re-watches tracked) - Edit reviews after the fact — update rating, comment, date, or watch medium via partial PATCH; each watch is still a separate record (re-watches tracked)
- Background poster fetching and storage (local filesystem or S3-compatible) - Background poster fetching and storage (local filesystem or S3-compatible)
- Movie enrichment via TMDb — full cast, crew, genres, keywords, runtime, budget/revenue, ratings; fetched automatically on movie discovery and refreshed every 30 days; exposed via `GET /api/v1/movies/{id}/profile` - Movie enrichment via TMDb — full cast, crew, genres, keywords, runtime, budget/revenue, ratings; fetched automatically on movie discovery and refreshed every 30 days; exposed via `GET /api/v1/movies/{id}/profile`
@@ -60,8 +60,9 @@ Open `http://localhost:3000`. The HTTP server and background worker start togeth
- Watchlist — add movies to watch later, per-user; federated watchlist entries visible for remote actors - Watchlist — add movies to watch later, per-user; federated watchlist entries visible for remote actors
- User profiles — display name, bio, avatar, banner, custom profile fields; editable via HTML settings page or REST API; account deletion broadcasts AP `Delete` actor activity; `alsoKnownAs` change triggers AP `Move` for account migration - User profiles — display name, bio, avatar, banner, custom profile fields; editable via HTML settings page or REST API; account deletion broadcasts AP `Delete` actor activity; `alsoKnownAs` change triggers AP `Move` for account migration
- Jellyfin/Plex auto-import — media server sends a webhook on playback stop, movies land in a watch queue; review and confirm with a rating to create diary entries; per-user webhook tokens with SHA-256 auth; setup UI at `/settings/integrations` - Jellyfin/Plex auto-import — media server sends a webhook on playback stop, movies land in a watch queue; review and confirm with a rating to create diary entries; per-user webhook tokens with SHA-256 auth; setup UI at `/settings/integrations`
- Annual Wrap-Up — Spotify Wrapped for movies: per-user and instance-wide year-in-review with stats (top directors, actors, genres, rating distribution, watch time, rewatches, budget analysis), shareable HTML page at `/wrapups/{user_id}/{year}`; admin-triggered or auto-generated in January - Annual Wrap-Up — Spotify Wrapped for movies: per-user and instance-wide year-in-review with stats (top directors, actors, genres, rating distribution, watch time, watch medium breakdown, rewatches, budget analysis); directors/actors filtered by minimum watch count for statistical relevance; shareable HTML page at `/wrapups/{user_id}/{year}`; admin-triggered or auto-generated in January
- Goals — set a "watch N movies in YEAR" target with a progress bar; progress computed from existing reviews (backwards compatible); per-user federation toggle in settings; displayed on profile (SPA: interactive with create/edit/delete, classic HTML: read-only glassmorphic card) - Goals — set a "watch N movies in YEAR" target with a progress bar; progress computed from existing reviews (backwards compatible); per-user federation toggle in settings; displayed on profile (SPA: interactive with create/edit/delete, classic HTML: read-only glassmorphic card)
- Profile trends — top directors, genre breakdown, rating distribution histogram, watch medium breakdown, monthly activity chart; all computed from the user's review history
- CSV and JSON diary export - CSV and JSON diary export
- File importer: upload CSV, TSV, JSON, or XLSX from any source (Letterboxd, IMDb, etc.), map columns to domain fields via a step-by-step wizard or REST API, save mapping profiles for repeat imports - File importer: upload CSV, TSV, JSON, or XLSX from any source (Letterboxd, IMDb, etc.), map columns to domain fields via a step-by-step wizard or REST API, save mapping profiles for repeat imports
- REST API v1 (`/api/v1/`) with full feature parity with the HTML interface - REST API v1 (`/api/v1/`) with full feature parity with the HTML interface
@@ -90,8 +91,8 @@ Hexagonal (Ports & Adapters) with Domain-Driven Design:
``` ```
api-types — shared REST API request/response DTOs (Serialize/Deserialize + utoipa schemas) + HtmlPageContext; used by presentation, tui, and template adapters api-types — shared REST API request/response DTOs (Serialize/Deserialize + utoipa schemas) + HtmlPageContext; used by presentation, tui, and template adapters
infra-wiring — shared infrastructure types (DbPool, EventBusBackend, AppConfig) used by both presentation and worker binaries infra-wiring — shared infrastructure types (DbPool, EventBusBackend, AppConfig) used by both presentation and worker binaries
domain — pure types and CQRS port traits (MovieCommand/MovieQuery, WatchEventCommand/WatchEventQuery, GoalCommand/GoalQuery, DiaryQuery, PersonCommand/PersonQuery, SearchCommand/SearchPort, ImageFetcher, RssFeedRenderer), no external deps except serde domain — pure types and CQRS port traits (MovieCommand/MovieQuery, WatchEventCommand/WatchEventQuery, GoalCommand/GoalQuery, DiaryQuery, PersonCommand/PersonQuery, SearchCommand/SearchPort, SocialCommand/SocialQuery, ImageFetcher, RssFeedRenderer), no external deps except serde
application — use cases (commands + queries), business logic orchestration; handlers delegate here for all domain logic application — use cases (commands + queries), business logic orchestration; handlers delegate here for all domain logic; modules: auth, diary, goals, import, integrations, movies, person, search, social, users, watchlist, wrapup
presentation — Axum HTTP router, OpenAPI spec assembly, Swagger UI + Scalar serving, composition root for the HTTP process presentation — Axum HTTP router, OpenAPI spec assembly, Swagger UI + Scalar serving, composition root for the HTTP process
worker — standalone worker binary (event consumer, poster sync, federation) worker — standalone worker binary (event consumer, poster sync, federation)
adapters/ adapters/
@@ -101,7 +102,7 @@ adapters/
postgres — PostgreSQL repository + connection factory postgres — PostgreSQL repository + connection factory
metadata — TMDB / OMDb HTTP client metadata — TMDB / OMDb HTTP client
poster-fetcher — downloads poster images poster-fetcher — downloads poster images
image-storage — stores images (posters + user avatars) on local filesystem or S3-compatible storage object-storage — stores images (posters + user avatars) on local filesystem or S3-compatible storage
poster-sync — event handler: triggers poster fetch+store on MovieDiscovered poster-sync — event handler: triggers poster fetch+store on MovieDiscovered
image-converter — optional background worker: converts stored images to AVIF or WebP; backfills existing images via a 24h periodic job image-converter — optional background worker: converts stored images to AVIF or WebP; backfills existing images via a 24h periodic job
tmdb-enrichment — TMDb HTTP client implementing MovieEnrichmentClient and PersonEnrichmentClient; event handlers (MovieEnrichmentHandler, PersonEnrichmentHandler) live in the application layer tmdb-enrichment — TMDb HTTP client implementing MovieEnrichmentClient and PersonEnrichmentClient; event handlers (MovieEnrichmentHandler, PersonEnrichmentHandler) live in the application layer
@@ -114,6 +115,7 @@ adapters/
event-payload — shared event serialization DTOs (used by all event bus adapters) event-payload — shared event serialization DTOs (used by all event bus adapters)
sqlite-event-queue — durable polling event queue backed by SQLite sqlite-event-queue — durable polling event queue backed by SQLite
postgres-event-queue — durable polling event queue backed by PostgreSQL postgres-event-queue — durable polling event queue backed by PostgreSQL
event-publisher — in-memory event channel (used in tests)
nats — NATS Core / JetStream event publisher and consumer nats — NATS Core / JetStream event publisher and consumer
event-publisher — in-memory event channel (used in tests) event-publisher — in-memory event channel (used in tests)
activitypub — ActivityPub federation adapter (follow, inbox/outbox, actor); delegates to k-ap for protocol internals activitypub — ActivityPub federation adapter (follow, inbox/outbox, actor); delegates to k-ap for protocol internals

View File

@@ -24,6 +24,7 @@ graph TB
UC_INTEGRATIONS["integrations<br/>webhooks, watch_queue,<br/>confirm, dismiss"] UC_INTEGRATIONS["integrations<br/>webhooks, watch_queue,<br/>confirm, dismiss"]
UC_SEARCH["search<br/>execute"] UC_SEARCH["search<br/>execute"]
UC_PERSON["person<br/>get, get_credits"] UC_PERSON["person<br/>get, get_credits"]
UC_SOCIAL["social<br/>follow, unfollow,<br/>accept, reject, block"]
end end
subgraph EventHandlers["Event Handlers"] subgraph EventHandlers["Event Handlers"]
EH_MOVIE["MovieEnrichmentHandler<br/><i>on MovieEnrichmentRequested</i>"] EH_MOVIE["MovieEnrichmentHandler<br/><i>on MovieEnrichmentRequested</i>"]
@@ -59,10 +60,10 @@ graph TB
M_SEARCH["SearchQuery,<br/>SearchResults"] M_SEARCH["SearchQuery,<br/>SearchResults"]
end end
subgraph Ports["Port Traits (Interfaces)"] subgraph Ports["Port Traits (Interfaces)"]
P_REPOS["MovieCommand / MovieQuery<br/>ReviewRepository<br/>DiaryQuery / StatsRepository<br/>UserRepository<br/>WatchlistRepository<br/>WatchEventCommand / WatchEventQuery<br/>WebhookTokenRepository<br/>ImportSessionRepository<br/>MovieProfileRepository<br/>WrapUpRepository<br/>GoalCommand / GoalQuery<br/>UserSettingsRepository<br/>MovieDeduplicator"] P_REPOS["MovieCommand / MovieQuery<br/>ReviewRepository<br/>DiaryQuery / StatsRepository<br/>UserRepository / UserProfileFieldsRepository<br/>WatchlistRepository<br/>WatchEventCommand / WatchEventQuery<br/>WebhookTokenRepository<br/>ImportSessionRepository / ImportProfileRepository<br/>MovieProfileRepository<br/>WrapUpRepository / WrapUpStatsQuery<br/>GoalCommand / GoalQuery<br/>UserSettingsRepository / RefreshSessionRepository<br/>MovieDeduplicator"]
P_SERVICES["AuthService<br/>MetadataClient<br/>PosterFetcherClient<br/>ImageFetcher<br/>ObjectStorage<br/>EventPublisher<br/>EventConsumer<br/>PasswordHasher<br/>DiaryExporter<br/>DocumentParser<br/>RssFeedRenderer"] P_SERVICES["AuthService<br/>MetadataClient / MovieEnrichmentClient<br/>PersonEnrichmentClient<br/>PosterFetcherClient<br/>ImageFetcher / ObjectStorage<br/>EventPublisher / EventConsumer<br/>PasswordHasher<br/>DiaryExporter / DocumentParser<br/>RssFeedRenderer / MediaServerParser"]
P_SEARCH["SearchPort<br/>SearchCommand<br/>PersonQuery<br/>PersonCommand"] P_SEARCH["SearchPort / SearchCommand<br/>PersonQuery / PersonCommand<br/>FederatedProfileQuery"]
P_FEDERATION["SocialQueryPort<br/>LocalApContentQuery<br/>RemoteWatchlistRepository<br/>RemoteGoalRepository"] P_FEDERATION["SocialCommand / SocialQuery<br/>FederationAdminQuery<br/>LocalApContentQuery<br/>RemoteWatchlistRepository<br/>RemoteGoalRepository"]
end end
subgraph DomainServices["Services (pure, no I/O)"] subgraph DomainServices["Services (pure, no I/O)"]
DS_WRAPUP["WrapUpAnalyzer<br/><i>build_report, compute_*</i>"] DS_WRAPUP["WrapUpAnalyzer<br/><i>build_report, compute_*</i>"]
@@ -92,7 +93,9 @@ graph TB
end end
subgraph Messaging["Messaging"] subgraph Messaging["Messaging"]
A_NATS["nats<br/><i>JetStream / Core</i>"] A_NATS["nats<br/><i>JetStream / Core</i>"]
A_SQLITE_QUEUE["sqlite-event-queue<br/><i>Polling, dead-letter</i>"]
A_PG_QUEUE["postgres-event-queue<br/><i>Polling, dead-letter</i>"] A_PG_QUEUE["postgres-event-queue<br/><i>Polling, dead-letter</i>"]
A_EVT_PUB["event-publisher<br/><i>In-memory (tests)</i>"]
A_PAYLOAD["event-payload<br/><i>Serde (de)serialization</i>"] A_PAYLOAD["event-payload<br/><i>Serde (de)serialization</i>"]
end end
subgraph External["External Services"] subgraph External["External Services"]

View File

@@ -4,7 +4,7 @@ version = "0.1.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
k-ap = { version = "0.4.2", registry = "gitea" } k-ap = { version = "0.5.0", registry = "gitea" }
domain = { workspace = true } domain = { workspace = true }
axum = { workspace = true } axum = { workspace = true }
serde = { workspace = true } serde = { workspace = true }

View File

@@ -1,8 +1,7 @@
use std::sync::Arc; use std::sync::Arc;
use async_trait::async_trait; use async_trait::async_trait;
use chrono::{DateTime, Utc}; use k_ap::{ApContentReader, ApObjectHandler, LocalObject};
use k_ap::{ApContentReader, ApObjectHandler};
use url::Url; use url::Url;
use crate::{ use crate::{
@@ -21,10 +20,9 @@ impl ApContentReader for CompositeObjectHandler {
async fn get_local_objects_page( async fn get_local_objects_page(
&self, &self,
user_id: uuid::Uuid, user_id: uuid::Uuid,
before: Option<DateTime<Utc>>, before: Option<chrono::DateTime<chrono::Utc>>,
limit: usize, limit: usize,
) -> anyhow::Result<Vec<(Url, serde_json::Value, DateTime<Utc>)>> { ) -> anyhow::Result<Vec<LocalObject>> {
// Fetch from all three sources (watchlist/goals return all, reviews use DB pagination)
let fetch_limit = limit * 3; let fetch_limit = limit * 3;
let reviews = self let reviews = self
.review .review
@@ -39,16 +37,15 @@ impl ApContentReader for CompositeObjectHandler {
.get_local_objects_page(user_id, None, usize::MAX) .get_local_objects_page(user_id, None, usize::MAX)
.await?; .await?;
let mut all: Vec<(Url, serde_json::Value, DateTime<Utc>)> = Vec::new(); let mut all: Vec<LocalObject> = Vec::new();
all.extend(reviews); all.extend(reviews);
all.extend(watchlist); all.extend(watchlist);
all.extend(goals); all.extend(goals);
// Apply before filter and sort descending by timestamp
if let Some(before_ts) = before { if let Some(before_ts) = before {
all.retain(|(_, _, ts)| *ts < before_ts); all.retain(|obj| obj.published_at < before_ts);
} }
all.sort_by_key(|b| std::cmp::Reverse(b.2)); all.sort_by_key(|obj| std::cmp::Reverse(obj.published_at));
all.truncate(limit); all.truncate(limit);
Ok(all) Ok(all)
} }

View File

@@ -217,7 +217,7 @@ impl ActivityPubEventHandler {
let json = serde_json::to_value(obj)?; let json = serde_json::to_value(obj)?;
self.ap_service self.ap_service
.broadcast_create_note(user_id.value(), json, ApVisibility::Public, vec![]) .broadcast_create(user_id.value(), json, ApVisibility::Public, vec![])
.await?; .await?;
let year = review.watched_at().year() as u16; let year = review.watched_at().year() as u16;
@@ -283,7 +283,7 @@ impl ActivityPubEventHandler {
let json = serde_json::to_value(obj)?; let json = serde_json::to_value(obj)?;
self.ap_service self.ap_service
.broadcast_update_note(user_id.value(), json, ApVisibility::Public, vec![]) .broadcast_update(user_id.value(), json, ApVisibility::Public, vec![])
.await?; .await?;
Ok(()) Ok(())
@@ -349,7 +349,7 @@ impl ActivityPubEventHandler {
let json = serde_json::to_value(obj)?; let json = serde_json::to_value(obj)?;
self.ap_service self.ap_service
.broadcast_create_note(user_id.value(), json, ApVisibility::Public, vec![]) .broadcast_create(user_id.value(), json, ApVisibility::Public, vec![])
.await?; .await?;
Ok(()) Ok(())
} }
@@ -416,7 +416,7 @@ impl ActivityPubEventHandler {
let json = serde_json::to_value(obj)?; let json = serde_json::to_value(obj)?;
self.ap_service self.ap_service
.broadcast_update_note(user_id.value(), json, ApVisibility::Public, vec![]) .broadcast_update(user_id.value(), json, ApVisibility::Public, vec![])
.await?; .await?;
} }
@@ -462,7 +462,7 @@ impl ActivityPubEventHandler {
); );
let json = serde_json::to_value(obj)?; let json = serde_json::to_value(obj)?;
self.ap_service self.ap_service
.broadcast_update_note(user_id.value(), json, ApVisibility::Public, vec![]) .broadcast_update(user_id.value(), json, ApVisibility::Public, vec![])
.await?; .await?;
Ok(()) Ok(())
} }
@@ -494,11 +494,11 @@ impl ActivityPubEventHandler {
let json = serde_json::to_value(obj)?; let json = serde_json::to_value(obj)?;
if is_create { if is_create {
self.ap_service self.ap_service
.broadcast_create_note(user_id.value(), json, ApVisibility::Public, vec![]) .broadcast_create(user_id.value(), json, ApVisibility::Public, vec![])
.await?; .await?;
} else { } else {
self.ap_service self.ap_service
.broadcast_update_note(user_id.value(), json, ApVisibility::Public, vec![]) .broadcast_update(user_id.value(), json, ApVisibility::Public, vec![])
.await?; .await?;
} }
Ok(()) Ok(())

View File

@@ -7,7 +7,7 @@ use domain::{
ports::{GoalQuery, RemoteGoalRepository}, ports::{GoalQuery, RemoteGoalRepository},
value_objects::UserId, value_objects::UserId,
}; };
use k_ap::{ApContentReader, ApObjectHandler}; use k_ap::{AS_PUBLIC, ApContentReader, ApObjectHandler, LocalObject};
use url::Url; use url::Url;
use crate::objects::{GoalObject, goal_to_ap_object}; use crate::objects::{GoalObject, goal_to_ap_object};
@@ -26,7 +26,7 @@ impl ApContentReader for GoalObjectHandler {
user_id: uuid::Uuid, user_id: uuid::Uuid,
_before: Option<DateTime<chrono::Utc>>, _before: Option<DateTime<chrono::Utc>>,
_limit: usize, _limit: usize,
) -> anyhow::Result<Vec<(Url, serde_json::Value, DateTime<chrono::Utc>)>> { ) -> anyhow::Result<Vec<LocalObject>> {
let uid = UserId::from_uuid(user_id); let uid = UserId::from_uuid(user_id);
let goals = self let goals = self
.goal_repo .goal_repo
@@ -35,6 +35,7 @@ impl ApContentReader for GoalObjectHandler {
.map_err(|e| anyhow::anyhow!(e.to_string()))?; .map_err(|e| anyhow::anyhow!(e.to_string()))?;
let actor = actor_url(&self.base_url, user_id); let actor = actor_url(&self.base_url, user_id);
let follower_cc = format!("{}/followers", actor);
let mut results = Vec::new(); let mut results = Vec::new();
for goal in goals { for goal in goals {
let ap_id = goal_url(&self.base_url, user_id, goal.year()); let ap_id = goal_url(&self.base_url, user_id, goal.year());
@@ -47,7 +48,15 @@ impl ApContentReader for GoalObjectHandler {
0, 0,
&self.base_url, &self.base_url,
); );
results.push((ap_id, serde_json::to_value(obj)?, published)); results.push(LocalObject {
ap_id,
object: serde_json::to_value(obj)?,
published_at: published,
to: vec![AS_PUBLIC.to_string()],
cc: vec![follower_cc.clone()],
bto: vec![],
bcc: vec![],
});
} }
Ok(results) Ok(results)
} }

View File

@@ -18,7 +18,7 @@ pub const INSTANCE_ACTOR_ID: uuid::Uuid =
pub use k_ap::{ pub use k_ap::{
ActivityPubService, ActivityRepository, ActorRepository, ApContentReader, ApFederationConfig, ActivityPubService, ActivityRepository, ActorRepository, ApContentReader, ApFederationConfig,
ApObjectHandler, ApUser, ApUserRepository, BlocklistRepository, FederationData, ApObjectHandler, ApUser, ApUserRepository, BlocklistRepository, FederationData,
FollowRepository, Follower, FollowerStatus, FollowingStatus, RemoteActor, FollowRepository, Follower, FollowerStatus, FollowingStatus, LocalObject, RemoteActor,
}; };
pub use event_handler::ActivityPubEventHandler; pub use event_handler::ActivityPubEventHandler;
@@ -28,15 +28,17 @@ pub use review_handler::ReviewObjectHandler;
pub use social_adapter::CompositeSocialAdapter; pub use social_adapter::CompositeSocialAdapter;
pub use user_adapter::DomainUserRepoAdapter; pub use user_adapter::DomainUserRepoAdapter;
pub type FederationRepos = ( pub struct FederationRepos {
std::sync::Arc<dyn ActivityRepository>, pub activity: std::sync::Arc<dyn ActivityRepository>,
std::sync::Arc<dyn FollowRepository>, pub follow: std::sync::Arc<dyn FollowRepository>,
std::sync::Arc<dyn ActorRepository>, pub actor: std::sync::Arc<dyn ActorRepository>,
std::sync::Arc<dyn BlocklistRepository>, pub blocklist: std::sync::Arc<dyn BlocklistRepository>,
std::sync::Arc<dyn domain::ports::FederationAdminQuery>, pub admin_query: std::sync::Arc<dyn domain::ports::FederationAdminQuery>,
std::sync::Arc<dyn RemoteReviewRepository>, pub review_store: std::sync::Arc<dyn RemoteReviewRepository>,
std::sync::Arc<dyn domain::ports::RemoteWatchlistRepository>, pub remote_watchlist: std::sync::Arc<dyn domain::ports::RemoteWatchlistRepository>,
); pub follow_command: std::sync::Arc<dyn domain::ports::FollowCommand>,
pub follow_query: std::sync::Arc<dyn domain::ports::FollowQuery>,
}
pub struct ActivityPubWire { pub struct ActivityPubWire {
pub service: std::sync::Arc<dyn ActivityPubPort>, pub service: std::sync::Arc<dyn ActivityPubPort>,
@@ -60,6 +62,8 @@ pub struct ActivityPubDeps {
pub stats_repo: std::sync::Arc<dyn domain::ports::StatsRepository>, pub stats_repo: std::sync::Arc<dyn domain::ports::StatsRepository>,
pub user_repo: std::sync::Arc<dyn domain::ports::UserRepository>, pub user_repo: std::sync::Arc<dyn domain::ports::UserRepository>,
pub federation_settings: std::sync::Arc<dyn domain::ports::UserFederationSettingsQuery>, pub federation_settings: std::sync::Arc<dyn domain::ports::UserFederationSettingsQuery>,
pub follow_command: std::sync::Arc<dyn domain::ports::FollowCommand>,
pub follow_query: std::sync::Arc<dyn domain::ports::FollowQuery>,
pub base_url: String, pub base_url: String,
pub allow_registration: bool, pub allow_registration: bool,
pub event_publisher: std::sync::Arc<dyn domain::ports::EventPublisher>, pub event_publisher: std::sync::Arc<dyn domain::ports::EventPublisher>,
@@ -82,6 +86,8 @@ pub async fn wire(deps: ActivityPubDeps) -> anyhow::Result<ActivityPubWire> {
stats_repo, stats_repo,
user_repo, user_repo,
federation_settings, federation_settings,
follow_command: _,
follow_query: _,
base_url, base_url,
allow_registration, allow_registration,
event_publisher, event_publisher,
@@ -141,6 +147,10 @@ pub async fn wire(deps: ActivityPubDeps) -> anyhow::Result<ActivityPubWire> {
.event_publisher(fed_event_bridge) .event_publisher(fed_event_bridge)
.allow_registration(allow_registration) .allow_registration(allow_registration)
.software_name("movies-diary") .software_name("movies-diary")
.nodeinfo_metadata(serde_json::json!({
"nodeName": "movies-diary",
"nodeDescription": "A federated movie diary"
}))
.debug(federation_debug) .debug(federation_debug)
.build() .build()
.await?, .await?,

View File

@@ -1,11 +1,17 @@
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use k_ap::AS_PUBLIC; use k_ap::AS_PUBLIC;
use k_ap::NoteType;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use url::Url; use url::Url;
use domain::models::Review; use domain::models::Review;
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub(crate) enum ActivityStreamsType {
#[default]
Note,
Article,
}
#[derive(Debug, Clone, Deserialize, Serialize)] #[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct ApAttachment { pub struct ApAttachment {
@@ -34,7 +40,7 @@ pub(crate) fn normalize_hashtag(title: &str) -> String {
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct ReviewObject { pub struct ReviewObject {
#[serde(rename = "type")] #[serde(rename = "type")]
pub(crate) kind: NoteType, pub(crate) kind: ActivityStreamsType,
pub(crate) id: Url, pub(crate) id: Url,
pub(crate) attributed_to: Url, pub(crate) attributed_to: Url,
pub(crate) content: String, pub(crate) content: String,
@@ -125,7 +131,7 @@ pub fn review_to_ap_object(review: &Review, input: ReviewApInput) -> ReviewObjec
}; };
ReviewObject { ReviewObject {
kind: NoteType::default(), kind: ActivityStreamsType::default(),
id: ap_id, id: ap_id,
attributed_to: actor_url.clone(), attributed_to: actor_url.clone(),
content, content,
@@ -150,7 +156,7 @@ pub fn review_to_ap_object(review: &Review, input: ReviewApInput) -> ReviewObjec
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct WatchlistObject { pub struct WatchlistObject {
#[serde(rename = "type")] #[serde(rename = "type")]
pub(crate) kind: NoteType, pub(crate) kind: ActivityStreamsType,
pub(crate) id: Url, pub(crate) id: Url,
pub(crate) attributed_to: Url, pub(crate) attributed_to: Url,
pub(crate) content: String, pub(crate) content: String,
@@ -218,7 +224,7 @@ pub fn watchlist_to_ap_object(input: WatchlistApInput) -> WatchlistObject {
]; ];
WatchlistObject { WatchlistObject {
kind: NoteType::default(), kind: ActivityStreamsType::default(),
id: ap_id, id: ap_id,
attributed_to: actor_url.clone(), attributed_to: actor_url.clone(),
content, content,
@@ -240,7 +246,7 @@ pub fn watchlist_to_ap_object(input: WatchlistApInput) -> WatchlistObject {
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct GoalObject { pub struct GoalObject {
#[serde(rename = "type")] #[serde(rename = "type")]
pub(crate) kind: NoteType, pub(crate) kind: ActivityStreamsType,
pub(crate) id: Url, pub(crate) id: Url,
pub(crate) attributed_to: Url, pub(crate) attributed_to: Url,
pub(crate) content: String, pub(crate) content: String,
@@ -277,7 +283,7 @@ pub fn goal_to_ap_object(
}]; }];
GoalObject { GoalObject {
kind: NoteType::default(), kind: ActivityStreamsType::default(),
id: ap_id, id: ap_id,
attributed_to: actor_url.clone(), attributed_to: actor_url.clone(),
content, content,

View File

@@ -6,9 +6,6 @@ use k_ap::{ActivityPubService, BlockedDomain, RemoteActor};
#[async_trait] #[async_trait]
pub trait ActivityPubPort: Send + Sync { pub trait ActivityPubPort: Send + Sync {
async fn actor_json(&self, user_id: &str) -> anyhow::Result<String>; async fn actor_json(&self, user_id: &str) -> anyhow::Result<String>;
async fn count_following(&self, local_user_id: Uuid) -> anyhow::Result<usize>;
async fn count_accepted_followers(&self, local_user_id: Uuid) -> anyhow::Result<usize>;
async fn get_pending_followers(&self, local_user_id: Uuid) -> anyhow::Result<Vec<RemoteActor>>;
async fn follow(&self, local_user_id: Uuid, handle: &str) -> anyhow::Result<()>; async fn follow(&self, local_user_id: Uuid, handle: &str) -> anyhow::Result<()>;
async fn unfollow(&self, local_user_id: Uuid, actor_url: &str) -> anyhow::Result<()>; async fn unfollow(&self, local_user_id: Uuid, actor_url: &str) -> anyhow::Result<()>;
async fn accept_follower( async fn accept_follower(
@@ -22,8 +19,6 @@ pub trait ActivityPubPort: Send + Sync {
remote_actor_url: &str, remote_actor_url: &str,
) -> anyhow::Result<()>; ) -> anyhow::Result<()>;
async fn get_following(&self, local_user_id: Uuid) -> anyhow::Result<Vec<RemoteActor>>; async fn get_following(&self, local_user_id: Uuid) -> anyhow::Result<Vec<RemoteActor>>;
async fn get_accepted_followers(&self, local_user_id: Uuid)
-> anyhow::Result<Vec<RemoteActor>>;
async fn remove_follower(&self, local_user_id: Uuid, actor_url: &str) -> anyhow::Result<()>; async fn remove_follower(&self, local_user_id: Uuid, actor_url: &str) -> anyhow::Result<()>;
async fn block_actor(&self, local_user_id: Uuid, actor_url: &str) -> anyhow::Result<()>; async fn block_actor(&self, local_user_id: Uuid, actor_url: &str) -> anyhow::Result<()>;
async fn unblock_actor(&self, local_user_id: Uuid, actor_url: &str) -> anyhow::Result<()>; async fn unblock_actor(&self, local_user_id: Uuid, actor_url: &str) -> anyhow::Result<()>;
@@ -54,15 +49,6 @@ impl ActivityPubPort for ActivityPubService {
async fn actor_json(&self, user_id: &str) -> anyhow::Result<String> { async fn actor_json(&self, user_id: &str) -> anyhow::Result<String> {
self.actor_json(user_id).await self.actor_json(user_id).await
} }
async fn count_following(&self, local_user_id: Uuid) -> anyhow::Result<usize> {
self.count_following(local_user_id).await
}
async fn count_accepted_followers(&self, local_user_id: Uuid) -> anyhow::Result<usize> {
self.count_accepted_followers(local_user_id).await
}
async fn get_pending_followers(&self, local_user_id: Uuid) -> anyhow::Result<Vec<RemoteActor>> {
self.get_pending_followers(local_user_id).await
}
async fn follow(&self, local_user_id: Uuid, handle: &str) -> anyhow::Result<()> { async fn follow(&self, local_user_id: Uuid, handle: &str) -> anyhow::Result<()> {
self.follow(local_user_id, handle).await self.follow(local_user_id, handle).await
} }
@@ -86,12 +72,6 @@ impl ActivityPubPort for ActivityPubService {
async fn get_following(&self, local_user_id: Uuid) -> anyhow::Result<Vec<RemoteActor>> { async fn get_following(&self, local_user_id: Uuid) -> anyhow::Result<Vec<RemoteActor>> {
self.get_following(local_user_id).await self.get_following(local_user_id).await
} }
async fn get_accepted_followers(
&self,
local_user_id: Uuid,
) -> anyhow::Result<Vec<RemoteActor>> {
self.get_accepted_followers(local_user_id).await
}
async fn remove_follower(&self, local_user_id: Uuid, actor_url: &str) -> anyhow::Result<()> { async fn remove_follower(&self, local_user_id: Uuid, actor_url: &str) -> anyhow::Result<()> {
self.remove_follower(local_user_id, actor_url).await self.remove_follower(local_user_id, actor_url).await
} }
@@ -147,15 +127,6 @@ impl ActivityPubPort for NoopActivityPubService {
async fn actor_json(&self, _: &str) -> anyhow::Result<String> { async fn actor_json(&self, _: &str) -> anyhow::Result<String> {
Ok(String::new()) Ok(String::new())
} }
async fn count_following(&self, _: Uuid) -> anyhow::Result<usize> {
Ok(0)
}
async fn count_accepted_followers(&self, _: Uuid) -> anyhow::Result<usize> {
Ok(0)
}
async fn get_pending_followers(&self, _: Uuid) -> anyhow::Result<Vec<RemoteActor>> {
Ok(vec![])
}
async fn follow(&self, _: Uuid, _: &str) -> anyhow::Result<()> { async fn follow(&self, _: Uuid, _: &str) -> anyhow::Result<()> {
Ok(()) Ok(())
} }
@@ -171,9 +142,6 @@ impl ActivityPubPort for NoopActivityPubService {
async fn get_following(&self, _: Uuid) -> anyhow::Result<Vec<RemoteActor>> { async fn get_following(&self, _: Uuid) -> anyhow::Result<Vec<RemoteActor>> {
Ok(vec![]) Ok(vec![])
} }
async fn get_accepted_followers(&self, _: Uuid) -> anyhow::Result<Vec<RemoteActor>> {
Ok(vec![])
}
async fn remove_follower(&self, _: Uuid, _: &str) -> anyhow::Result<()> { async fn remove_follower(&self, _: Uuid, _: &str) -> anyhow::Result<()> {
Ok(()) Ok(())
} }

View File

@@ -7,7 +7,7 @@ use domain::{
ports::{DiaryQuery, EventPublisher, LocalApContentQuery, MovieQuery}, ports::{DiaryQuery, EventPublisher, LocalApContentQuery, MovieQuery},
value_objects::{Comment, ExternalMetadataId, MovieId, Rating, ReviewId, UserId}, value_objects::{Comment, ExternalMetadataId, MovieId, Rating, ReviewId, UserId},
}; };
use k_ap::{ApContentReader, ApObjectHandler}; use k_ap::{AS_PUBLIC, ApContentReader, ApObjectHandler, LocalObject};
use url::Url; use url::Url;
use crate::objects::{ReviewApInput, ReviewObject, review_to_ap_object}; use crate::objects::{ReviewApInput, ReviewObject, review_to_ap_object};
@@ -30,7 +30,7 @@ impl ApContentReader for ReviewObjectHandler {
user_id: uuid::Uuid, user_id: uuid::Uuid,
before: Option<chrono::DateTime<chrono::Utc>>, before: Option<chrono::DateTime<chrono::Utc>>,
limit: usize, limit: usize,
) -> anyhow::Result<Vec<(url::Url, serde_json::Value, chrono::DateTime<chrono::Utc>)>> { ) -> anyhow::Result<Vec<LocalObject>> {
let domain_user_id = UserId::from_uuid(user_id); let domain_user_id = UserId::from_uuid(user_id);
let before_naive = before.map(|dt| dt.naive_utc()); let before_naive = before.map(|dt| dt.naive_utc());
let entries = self let entries = self
@@ -65,7 +65,16 @@ impl ApContentReader for ReviewObjectHandler {
base_url: self.base_url.clone(), base_url: self.base_url.clone(),
}, },
); );
results.push((ap_id, serde_json::to_value(obj)?, published)); let follower_cc = format!("{}/followers", actor);
results.push(LocalObject {
ap_id,
object: serde_json::to_value(obj)?,
published_at: published,
to: vec![AS_PUBLIC.to_string()],
cc: vec![follower_cc],
bto: vec![],
bcc: vec![],
});
} }
Ok(results) Ok(results)
} }

View File

@@ -3,17 +3,17 @@ use std::sync::Arc;
use async_trait::async_trait; use async_trait::async_trait;
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
ports::{SocialCommand, SocialQuery, UserRepository}, ports::{FollowCommand, FollowQuery, SocialCommand, SocialQuery, UserRepository},
value_objects::{FollowTarget, SocialActor, SocialIdentity, UserId}, value_objects::{FollowStatus, FollowTarget, SocialActor, SocialIdentity, UserId, Username},
}; };
use k_ap::RemoteActor;
use super::ActivityPubPort; use super::ActivityPubPort;
pub struct CompositeSocialAdapter { pub struct CompositeSocialAdapter {
ap_service: Arc<dyn ActivityPubPort>, ap_service: Arc<dyn ActivityPubPort>,
user_repo: Arc<dyn UserRepository>, user_repo: Arc<dyn UserRepository>,
follow_command: Arc<dyn FollowCommand>,
follow_query: Arc<dyn FollowQuery>,
base_url: String, base_url: String,
} }
@@ -21,11 +21,15 @@ impl CompositeSocialAdapter {
pub fn new( pub fn new(
ap_service: Arc<dyn ActivityPubPort>, ap_service: Arc<dyn ActivityPubPort>,
user_repo: Arc<dyn UserRepository>, user_repo: Arc<dyn UserRepository>,
follow_command: Arc<dyn FollowCommand>,
follow_query: Arc<dyn FollowQuery>,
base_url: String, base_url: String,
) -> Self { ) -> Self {
Self { Self {
ap_service, ap_service,
user_repo, user_repo,
follow_command,
follow_query,
base_url, base_url,
} }
} }
@@ -41,42 +45,31 @@ impl CompositeSocialAdapter {
} }
} }
fn identity_from_actor_url(&self, url: &str) -> SocialIdentity { async fn resolve_target_identity(
let prefix = format!("{}/users/", self.base_url); &self,
if let Some(uuid_str) = url.strip_prefix(&prefix) target: &FollowTarget,
&& let Ok(uuid) = uuid::Uuid::parse_str(uuid_str) ) -> Result<SocialIdentity, DomainError> {
{ match target {
return SocialIdentity::Local(UserId::from_uuid(uuid)); FollowTarget::Identity(id) => Ok(id.clone()),
} FollowTarget::Handle(handle) => {
SocialIdentity::Remote { let host = handle.rsplit_once('@').map(|(_, h)| h).unwrap_or("");
actor_url: url.to_string(), let local_host = SocialIdentity::host_from_base_url(&self.base_url);
} if host == local_host {
} let username_str = handle
.trim_start_matches('@')
fn remote_actor_to_social_actor(&self, actor: RemoteActor) -> SocialActor { .split('@')
let identity = self.identity_from_actor_url(&actor.url); .next()
SocialActor { .unwrap_or("");
identity, if let Ok(username) = Username::new(username_str.to_string())
handle: actor.handle, && let Some(user) = self.user_repo.find_by_username(&username).await?
display_name: actor.display_name, {
avatar_url: actor.avatar_url, return Ok(SocialIdentity::Local(user.id().clone()));
} }
} }
Ok(SocialIdentity::Remote {
async fn resolve_handle(&self, identity: &SocialIdentity) -> Result<String, DomainError> { actor_url: handle.clone(),
match identity { })
SocialIdentity::Local(uid) => {
let user = self
.user_repo
.find_by_id(uid)
.await?
.ok_or_else(|| DomainError::NotFound("User not found".into()))?;
let host = url::Url::parse(&self.base_url)
.map(|u| u.host_str().unwrap_or("localhost").to_string())
.unwrap_or_else(|_| "localhost".to_string());
Ok(format!("@{}@{}", user.username().value(), host))
} }
SocialIdentity::Remote { actor_url } => Ok(actor_url.clone()),
} }
} }
} }
@@ -88,16 +81,38 @@ fn ap_err(e: anyhow::Error) -> DomainError {
#[async_trait] #[async_trait]
impl SocialCommand for CompositeSocialAdapter { impl SocialCommand for CompositeSocialAdapter {
async fn follow(&self, follower: &UserId, target: &FollowTarget) -> Result<(), DomainError> { async fn follow(&self, follower: &UserId, target: &FollowTarget) -> Result<(), DomainError> {
if let FollowTarget::Identity(SocialIdentity::Local(target_id)) = target let identity = self.resolve_target_identity(target).await?;
&& follower == target_id
{ if let SocialIdentity::Local(ref target_id) = identity {
return Err(DomainError::ValidationError( if follower == target_id {
"Cannot follow yourself".into(), return Err(DomainError::ValidationError(
)); "Cannot follow yourself".into(),
));
}
let follower_url = self.local_actor_url(follower);
let target_url = self.local_actor_url(target_id);
self.follow_command
.add_follower(target_id.value(), &follower_url, FollowStatus::Pending)
.await?;
self.follow_command
.add_follow(follower.value(), &target_url, FollowStatus::Pending)
.await?;
return Ok(());
} }
let handle = match target { let handle = match target {
FollowTarget::Handle(h) => h.clone(), FollowTarget::Handle(h) => h.clone(),
FollowTarget::Identity(id) => self.resolve_handle(id).await?, FollowTarget::Identity(id) => match id {
SocialIdentity::Local(uid) => {
let user = self
.user_repo
.find_by_id(uid)
.await?
.ok_or_else(|| DomainError::NotFound("User not found".into()))?;
SocialIdentity::format_local_handle(user.username().value(), &self.base_url)
}
SocialIdentity::Remote { actor_url } => actor_url.clone(),
},
}; };
self.ap_service self.ap_service
.follow(follower.value(), &handle) .follow(follower.value(), &handle)
@@ -111,10 +126,23 @@ impl SocialCommand for CompositeSocialAdapter {
target: &SocialIdentity, target: &SocialIdentity,
) -> Result<(), DomainError> { ) -> Result<(), DomainError> {
let actor_url = self.actor_url_from_identity(target); let actor_url = self.actor_url_from_identity(target);
self.ap_service match target {
.unfollow(follower.value(), &actor_url) SocialIdentity::Local(target_id) => {
.await let follower_url = self.local_actor_url(follower);
.map_err(ap_err) self.follow_command
.remove_follow(follower.value(), &actor_url)
.await?;
self.follow_command
.remove_follower_record(target_id.value(), &follower_url)
.await?;
Ok(())
}
SocialIdentity::Remote { .. } => self
.ap_service
.unfollow(follower.value(), &actor_url)
.await
.map_err(ap_err),
}
} }
async fn accept_follow( async fn accept_follow(
@@ -123,10 +151,23 @@ impl SocialCommand for CompositeSocialAdapter {
requester: &SocialIdentity, requester: &SocialIdentity,
) -> Result<(), DomainError> { ) -> Result<(), DomainError> {
let actor_url = self.actor_url_from_identity(requester); let actor_url = self.actor_url_from_identity(requester);
self.ap_service match requester {
.accept_follower(owner.value(), &actor_url) SocialIdentity::Local(requester_id) => {
.await let owner_url = self.local_actor_url(owner);
.map_err(ap_err) self.follow_command
.update_follower_status(owner.value(), &actor_url, FollowStatus::Accepted)
.await?;
self.follow_command
.update_follow_status(requester_id.value(), &owner_url, FollowStatus::Accepted)
.await?;
Ok(())
}
SocialIdentity::Remote { .. } => self
.ap_service
.accept_follower(owner.value(), &actor_url)
.await
.map_err(ap_err),
}
} }
async fn reject_follow( async fn reject_follow(
@@ -135,10 +176,23 @@ impl SocialCommand for CompositeSocialAdapter {
requester: &SocialIdentity, requester: &SocialIdentity,
) -> Result<(), DomainError> { ) -> Result<(), DomainError> {
let actor_url = self.actor_url_from_identity(requester); let actor_url = self.actor_url_from_identity(requester);
self.ap_service match requester {
.reject_follower(owner.value(), &actor_url) SocialIdentity::Local(requester_id) => {
.await let owner_url = self.local_actor_url(owner);
.map_err(ap_err) self.follow_command
.update_follower_status(owner.value(), &actor_url, FollowStatus::Rejected)
.await?;
self.follow_command
.remove_follow(requester_id.value(), &owner_url)
.await?;
Ok(())
}
SocialIdentity::Remote { .. } => self
.ap_service
.reject_follower(owner.value(), &actor_url)
.await
.map_err(ap_err),
}
} }
async fn remove_follower( async fn remove_follower(
@@ -147,10 +201,23 @@ impl SocialCommand for CompositeSocialAdapter {
follower: &SocialIdentity, follower: &SocialIdentity,
) -> Result<(), DomainError> { ) -> Result<(), DomainError> {
let actor_url = self.actor_url_from_identity(follower); let actor_url = self.actor_url_from_identity(follower);
self.ap_service match follower {
.remove_follower(owner.value(), &actor_url) SocialIdentity::Local(follower_id) => {
.await let owner_url = self.local_actor_url(owner);
.map_err(ap_err) self.follow_command
.remove_follower_record(owner.value(), &actor_url)
.await?;
self.follow_command
.remove_follow(follower_id.value(), &owner_url)
.await?;
Ok(())
}
SocialIdentity::Remote { .. } => self
.ap_service
.remove_follower(owner.value(), &actor_url)
.await
.map_err(ap_err),
}
} }
async fn block(&self, blocker: &UserId, target: &SocialIdentity) -> Result<(), DomainError> { async fn block(&self, blocker: &UserId, target: &SocialIdentity) -> Result<(), DomainError> {
@@ -173,53 +240,29 @@ impl SocialCommand for CompositeSocialAdapter {
#[async_trait] #[async_trait]
impl SocialQuery for CompositeSocialAdapter { impl SocialQuery for CompositeSocialAdapter {
async fn get_following(&self, user: &UserId) -> Result<Vec<SocialActor>, DomainError> { async fn get_following(&self, user: &UserId) -> Result<Vec<SocialActor>, DomainError> {
let actors = self self.follow_query
.ap_service .get_following(user.value(), &self.base_url)
.get_following(user.value())
.await .await
.map_err(ap_err)?;
Ok(actors
.into_iter()
.map(|a| self.remote_actor_to_social_actor(a))
.collect())
} }
async fn get_followers(&self, user: &UserId) -> Result<Vec<SocialActor>, DomainError> { async fn get_followers(&self, user: &UserId) -> Result<Vec<SocialActor>, DomainError> {
let actors = self self.follow_query
.ap_service .get_followers(user.value(), &self.base_url)
.get_accepted_followers(user.value())
.await .await
.map_err(ap_err)?;
Ok(actors
.into_iter()
.map(|a| self.remote_actor_to_social_actor(a))
.collect())
} }
async fn get_pending_followers(&self, user: &UserId) -> Result<Vec<SocialActor>, DomainError> { async fn get_pending_followers(&self, user: &UserId) -> Result<Vec<SocialActor>, DomainError> {
let actors = self self.follow_query
.ap_service .get_pending_followers(user.value(), &self.base_url)
.get_pending_followers(user.value())
.await .await
.map_err(ap_err)?;
Ok(actors
.into_iter()
.map(|a| self.remote_actor_to_social_actor(a))
.collect())
} }
async fn count_following(&self, user: &UserId) -> Result<usize, DomainError> { async fn count_following(&self, user: &UserId) -> Result<usize, DomainError> {
self.ap_service self.follow_query.count_following(user.value()).await
.count_following(user.value())
.await
.map_err(ap_err)
} }
async fn count_followers(&self, user: &UserId) -> Result<usize, DomainError> { async fn count_followers(&self, user: &UserId) -> Result<usize, DomainError> {
self.ap_service self.follow_query.count_followers(user.value()).await
.count_accepted_followers(user.value())
.await
.map_err(ap_err)
} }
async fn get_blocked(&self, user: &UserId) -> Result<Vec<SocialActor>, DomainError> { async fn get_blocked(&self, user: &UserId) -> Result<Vec<SocialActor>, DomainError> {
@@ -230,7 +273,15 @@ impl SocialQuery for CompositeSocialAdapter {
.map_err(ap_err)?; .map_err(ap_err)?;
Ok(actors Ok(actors
.into_iter() .into_iter()
.map(|a| self.remote_actor_to_social_actor(a)) .map(|a| {
let identity = SocialIdentity::from_actor_url(&a.url, &self.base_url);
SocialActor {
identity,
handle: a.handle,
display_name: a.display_name,
avatar_url: a.avatar_url,
}
})
.collect()) .collect())
} }
@@ -239,7 +290,9 @@ impl SocialQuery for CompositeSocialAdapter {
follower: &UserId, follower: &UserId,
target: &SocialIdentity, target: &SocialIdentity,
) -> Result<bool, DomainError> { ) -> Result<bool, DomainError> {
let following = self.get_following(follower).await?; let actor_url = self.actor_url_from_identity(target);
Ok(following.iter().any(|a| a.identity == *target)) self.follow_query
.is_following(follower.value(), &actor_url)
.await
} }
} }

View File

@@ -7,7 +7,7 @@ use domain::{
ports::{LocalApContentQuery, RemoteWatchlistRepository}, ports::{LocalApContentQuery, RemoteWatchlistRepository},
value_objects::UserId, value_objects::UserId,
}; };
use k_ap::{ApContentReader, ApObjectHandler}; use k_ap::{AS_PUBLIC, ApContentReader, ApObjectHandler, LocalObject};
use url::Url; use url::Url;
use crate::objects::{WatchlistApInput, WatchlistObject, watchlist_to_ap_object}; use crate::objects::{WatchlistApInput, WatchlistObject, watchlist_to_ap_object};
@@ -26,7 +26,7 @@ impl ApContentReader for WatchlistObjectHandler {
user_id: uuid::Uuid, user_id: uuid::Uuid,
_before: Option<DateTime<chrono::Utc>>, _before: Option<DateTime<chrono::Utc>>,
_limit: usize, _limit: usize,
) -> anyhow::Result<Vec<(Url, serde_json::Value, DateTime<chrono::Utc>)>> { ) -> anyhow::Result<Vec<LocalObject>> {
let uid = UserId::from_uuid(user_id); let uid = UserId::from_uuid(user_id);
let entries = self let entries = self
.content_query .content_query
@@ -35,6 +35,7 @@ impl ApContentReader for WatchlistObjectHandler {
.map_err(|e| anyhow::anyhow!(e.to_string()))?; .map_err(|e| anyhow::anyhow!(e.to_string()))?;
let actor = actor_url(&self.base_url, user_id); let actor = actor_url(&self.base_url, user_id);
let follower_cc = format!("{}/followers", actor);
let mut results = Vec::new(); let mut results = Vec::new();
for WatchlistWithMovie { entry, movie } in entries { for WatchlistWithMovie { entry, movie } in entries {
let ap_id = watchlist_entry_url(&self.base_url, user_id, entry.movie_id.value()); let ap_id = watchlist_entry_url(&self.base_url, user_id, entry.movie_id.value());
@@ -54,7 +55,15 @@ impl ApContentReader for WatchlistObjectHandler {
added_at: published, added_at: published,
base_url: self.base_url.clone(), base_url: self.base_url.clone(),
}); });
results.push((ap_id, serde_json::to_value(obj)?, published)); results.push(LocalObject {
ap_id,
object: serde_json::to_value(obj)?,
published_at: published,
to: vec![AS_PUBLIC.to_string()],
cc: vec![follower_cc.clone()],
bto: vec![],
bcc: vec![],
});
} }
Ok(results) Ok(results)
} }

View File

@@ -13,7 +13,7 @@ sqlx = { version = "0.8.6", features = [
] } ] }
activitypub = { workspace = true } activitypub = { workspace = true }
adapter-common = { workspace = true } adapter-common = { workspace = true }
k-ap = { version = "0.4.1", registry = "gitea" } k-ap = { version = "0.5.0", registry = "gitea" }
domain = { workspace = true } domain = { workspace = true }
uuid = { workspace = true } uuid = { workspace = true }
chrono = { workspace = true } chrono = { workspace = true }

View File

@@ -1,43 +1,41 @@
use anyhow::Result; use anyhow::Result;
use async_trait::async_trait; use async_trait::async_trait;
use chrono::Utc; use chrono::Utc;
use k_ap::{ActorRepository, RemoteActor}; use k_ap::{AnnounceRepository, Keypair, KeypairRepository, RemoteActor, RemoteActorCache};
use sqlx::Row; use sqlx::Row;
use super::{PG_ACTOR_COLS, PostgresFederationRepository, pg_remote_actor}; use super::{PG_ACTOR_COLS, PostgresFederationRepository, pg_remote_actor};
use adapter_common::datetime_to_str; use adapter_common::datetime_to_str;
#[async_trait] #[async_trait]
impl ActorRepository for PostgresFederationRepository { impl KeypairRepository for PostgresFederationRepository {
async fn get_local_actor_keypair( async fn get_local_actor_keypair(&self, user_id: uuid::Uuid) -> Result<Option<Keypair>> {
&self,
user_id: uuid::Uuid,
) -> Result<Option<(String, String)>> {
let uid = user_id.to_string(); let uid = user_id.to_string();
let row = let row =
sqlx::query("SELECT public_key, private_key FROM ap_local_actors WHERE user_id = $1") sqlx::query("SELECT public_key, private_key FROM ap_local_actors WHERE user_id = $1")
.bind(&uid) .bind(&uid)
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await?; .await?;
Ok(row.map(|r| (r.get("public_key"), r.get("private_key")))) Ok(row.map(|r| Keypair {
public_key: r.get("public_key"),
private_key: r.get("private_key"),
}))
} }
async fn save_local_actor_keypair( async fn save_local_actor_keypair(&self, user_id: uuid::Uuid, keypair: Keypair) -> Result<()> {
&self,
user_id: uuid::Uuid,
public_key: String,
private_key: String,
) -> Result<()> {
let uid = user_id.to_string(); let uid = user_id.to_string();
let now = Utc::now().naive_utc(); let now = Utc::now().naive_utc();
let created_at = datetime_to_str(&now); let created_at = datetime_to_str(&now);
sqlx::query( sqlx::query(
"INSERT INTO ap_local_actors (user_id, public_key, private_key, created_at) VALUES ($1, $2, $3, $4::timestamptz) "INSERT INTO ap_local_actors (user_id, public_key, private_key, created_at) VALUES ($1, $2, $3, $4::timestamptz)
ON CONFLICT(user_id) DO UPDATE SET public_key = EXCLUDED.public_key, private_key = EXCLUDED.private_key", ON CONFLICT(user_id) DO UPDATE SET public_key = EXCLUDED.public_key, private_key = EXCLUDED.private_key",
).bind(&uid).bind(&public_key).bind(&private_key).bind(&created_at).execute(&self.pool).await?; ).bind(&uid).bind(&keypair.public_key).bind(&keypair.private_key).bind(&created_at).execute(&self.pool).await?;
Ok(()) Ok(())
} }
}
#[async_trait]
impl RemoteActorCache for PostgresFederationRepository {
async fn upsert_remote_actor(&self, actor: RemoteActor) -> Result<()> { async fn upsert_remote_actor(&self, actor: RemoteActor) -> Result<()> {
let now = Utc::now().naive_utc(); let now = Utc::now().naive_utc();
let fetched_at = datetime_to_str(&now); let fetched_at = datetime_to_str(&now);
@@ -68,7 +66,10 @@ impl ActorRepository for PostgresFederationRepository {
.await?; .await?;
Ok(row.as_ref().map(|r| pg_remote_actor(r, "url"))) Ok(row.as_ref().map(|r| pg_remote_actor(r, "url")))
} }
}
#[async_trait]
impl AnnounceRepository for PostgresFederationRepository {
async fn add_announce( async fn add_announce(
&self, &self,
activity_id: &str, activity_id: &str,

View File

@@ -1,14 +1,14 @@
use anyhow::Result; use anyhow::Result;
use async_trait::async_trait; use async_trait::async_trait;
use chrono::Utc; use chrono::Utc;
use k_ap::{BlockedDomain, BlocklistRepository}; use k_ap::{ActorBlocklist, BlockedDomain, DomainBlocklist};
use sqlx::Row; use sqlx::Row;
use super::PostgresFederationRepository; use super::PostgresFederationRepository;
use adapter_common::datetime_to_str; use adapter_common::datetime_to_str;
#[async_trait] #[async_trait]
impl BlocklistRepository for PostgresFederationRepository { impl DomainBlocklist for PostgresFederationRepository {
async fn add_blocked_domain(&self, domain: &str, reason: Option<&str>) -> Result<()> { async fn add_blocked_domain(&self, domain: &str, reason: Option<&str>) -> Result<()> {
let ts = datetime_to_str(&Utc::now().naive_utc()); let ts = datetime_to_str(&Utc::now().naive_utc());
sqlx::query("INSERT INTO blocked_domains (domain, reason, blocked_at) VALUES ($1, $2, $3) ON CONFLICT(domain) DO UPDATE SET reason = EXCLUDED.reason") sqlx::query("INSERT INTO blocked_domains (domain, reason, blocked_at) VALUES ($1, $2, $3) ON CONFLICT(domain) DO UPDATE SET reason = EXCLUDED.reason")
@@ -48,7 +48,10 @@ impl BlocklistRepository for PostgresFederationRepository {
.await?; .await?;
Ok(count > 0) Ok(count > 0)
} }
}
#[async_trait]
impl ActorBlocklist for PostgresFederationRepository {
async fn add_blocked_actor(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()> { async fn add_blocked_actor(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()> {
let uid = local_user_id.to_string(); let uid = local_user_id.to_string();
let ts = datetime_to_str(&Utc::now().naive_utc()); let ts = datetime_to_str(&Utc::now().naive_utc());

View File

@@ -1,18 +1,16 @@
use anyhow::Result; use anyhow::Result;
use async_trait::async_trait; use async_trait::async_trait;
use chrono::Utc; use chrono::Utc;
use k_ap::{ use k_ap::{Follower, FollowerReader, FollowerStatus, FollowerWriter, RemoteActor};
ActorRepository, FollowRepository, Follower, FollowerStatus, FollowingStatus, RemoteActor,
};
use sqlx::Row; use sqlx::Row;
use super::{ use crate::{
PG_ACTOR_COLS, PostgresFederationRepository, pg_remote_actor, status_to_str, str_to_status, PG_ACTOR_COLS, PostgresFederationRepository, pg_remote_actor, status_to_str, str_to_status,
}; };
use adapter_common::datetime_to_str; use adapter_common::datetime_to_str;
#[async_trait] #[async_trait]
impl FollowRepository for PostgresFederationRepository { impl FollowerWriter for PostgresFederationRepository {
async fn add_follower( async fn add_follower(
&self, &self,
local_user_id: uuid::Uuid, local_user_id: uuid::Uuid,
@@ -59,6 +57,25 @@ impl FollowRepository for PostgresFederationRepository {
Ok(()) Ok(())
} }
async fn update_follower_status(
&self,
local_user_id: uuid::Uuid,
remote_actor_url: &str,
status: FollowerStatus,
) -> Result<()> {
let uid = local_user_id.to_string();
let status_str = status_to_str(&status);
let result = sqlx::query("UPDATE ap_followers SET status = $1 WHERE local_user_id = $2 AND remote_actor_url = $3")
.bind(status_str).bind(&uid).bind(remote_actor_url).execute(&self.pool).await?;
if result.rows_affected() == 0 {
tracing::warn!(local_user_id = %local_user_id, remote_actor_url, "update_follower_status: no row found");
}
Ok(())
}
}
#[async_trait]
impl FollowerReader for PostgresFederationRepository {
async fn get_followers(&self, local_user_id: uuid::Uuid) -> Result<Vec<Follower>> { async fn get_followers(&self, local_user_id: uuid::Uuid) -> Result<Vec<Follower>> {
let uid = local_user_id.to_string(); let uid = local_user_id.to_string();
let q = format!( let q = format!(
@@ -116,22 +133,6 @@ impl FollowRepository for PostgresFederationRepository {
Ok(count as usize) Ok(count as usize)
} }
async fn update_follower_status(
&self,
local_user_id: uuid::Uuid,
remote_actor_url: &str,
status: FollowerStatus,
) -> Result<()> {
let uid = local_user_id.to_string();
let status_str = status_to_str(&status);
let result = sqlx::query("UPDATE ap_followers SET status = $1 WHERE local_user_id = $2 AND remote_actor_url = $3")
.bind(status_str).bind(&uid).bind(remote_actor_url).execute(&self.pool).await?;
if result.rows_affected() == 0 {
tracing::warn!(local_user_id = %local_user_id, remote_actor_url, "update_follower_status: no row found");
}
Ok(())
}
async fn get_pending_followers(&self, local_user_id: uuid::Uuid) -> Result<Vec<RemoteActor>> { async fn get_pending_followers(&self, local_user_id: uuid::Uuid) -> Result<Vec<RemoteActor>> {
let uid = local_user_id.to_string(); let uid = local_user_id.to_string();
let q = format!( let q = format!(
@@ -193,129 +194,4 @@ impl FollowRepository for PostgresFederationRepository {
.map(|row| pg_remote_actor(row, "remote_actor_url")) .map(|row| pg_remote_actor(row, "remote_actor_url"))
.collect()) .collect())
} }
async fn add_following(
&self,
local_user_id: uuid::Uuid,
actor: RemoteActor,
follow_activity_id: &str,
) -> Result<()> {
let uid = local_user_id.to_string();
let now = Utc::now().naive_utc();
let created_at = datetime_to_str(&now);
ActorRepository::upsert_remote_actor(self, actor.clone()).await?;
sqlx::query("INSERT INTO ap_following (local_user_id, remote_actor_url, follow_activity_id, created_at) VALUES ($1, $2, $3, $4::timestamptz) ON CONFLICT DO NOTHING")
.bind(&uid).bind(&actor.url).bind(follow_activity_id).bind(&created_at).execute(&self.pool).await?;
Ok(())
}
async fn get_follow_activity_id(
&self,
local_user_id: uuid::Uuid,
remote_actor_url: &str,
) -> Result<Option<String>> {
let uid = local_user_id.to_string();
let row: Option<String> = sqlx::query_scalar("SELECT follow_activity_id FROM ap_following WHERE local_user_id = $1 AND remote_actor_url = $2")
.bind(&uid).bind(remote_actor_url).fetch_optional(&self.pool).await?;
Ok(row)
}
async fn remove_following(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()> {
let uid = local_user_id.to_string();
sqlx::query("DELETE FROM ap_following WHERE local_user_id = $1 AND remote_actor_url = $2")
.bind(&uid)
.bind(actor_url)
.execute(&self.pool)
.await?;
Ok(())
}
async fn get_following(&self, local_user_id: uuid::Uuid) -> Result<Vec<RemoteActor>> {
let uid = local_user_id.to_string();
let q = format!(
"SELECT a.url, {PG_ACTOR_COLS} FROM ap_following f INNER JOIN ap_remote_actors a ON a.url = f.remote_actor_url WHERE f.local_user_id = $1 AND f.status = 'accepted'"
);
let rows = sqlx::query(&q).bind(&uid).fetch_all(&self.pool).await?;
Ok(rows.iter().map(|row| pg_remote_actor(row, "url")).collect())
}
async fn count_following(&self, local_user_id: uuid::Uuid) -> Result<usize> {
let uid = local_user_id.to_string();
let count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM ap_following WHERE local_user_id = $1 AND status = 'accepted'",
)
.bind(&uid)
.fetch_one(&self.pool)
.await?;
Ok(count as usize)
}
async fn get_following_page(
&self,
local_user_id: uuid::Uuid,
offset: u32,
limit: usize,
) -> Result<Vec<RemoteActor>> {
let uid = local_user_id.to_string();
let q = format!(
"SELECT a.url, {PG_ACTOR_COLS} FROM ap_following f INNER JOIN ap_remote_actors a ON a.url = f.remote_actor_url WHERE f.local_user_id = $1 AND f.status = 'accepted' ORDER BY f.created_at ASC LIMIT $2 OFFSET $3"
);
let rows = sqlx::query(&q)
.bind(&uid)
.bind(limit as i64)
.bind(offset as i64)
.fetch_all(&self.pool)
.await?;
Ok(rows.iter().map(|row| pg_remote_actor(row, "url")).collect())
}
async fn update_following_status(
&self,
local_user_id: uuid::Uuid,
remote_actor_url: &str,
status: FollowingStatus,
) -> Result<()> {
let uid = local_user_id.to_string();
let status_str = match status {
FollowingStatus::Pending => "pending",
FollowingStatus::Accepted => "accepted",
};
let result = sqlx::query("UPDATE ap_following SET status = $1 WHERE local_user_id = $2 AND remote_actor_url = $3")
.bind(status_str).bind(&uid).bind(remote_actor_url).execute(&self.pool).await?;
if result.rows_affected() == 0 {
tracing::warn!(local_user_id = %local_user_id, remote_actor_url, "update_following_status: no row found");
}
Ok(())
}
async fn get_following_outbox_url(
&self,
local_user_id: uuid::Uuid,
remote_actor_url: &str,
) -> Result<Option<String>> {
let uid = local_user_id.to_string();
let row: Option<Option<String>> = sqlx::query_scalar(
"SELECT a.outbox_url FROM ap_following f INNER JOIN ap_remote_actors a ON a.url = f.remote_actor_url WHERE f.local_user_id = $1 AND f.remote_actor_url = $2",
).bind(&uid).bind(remote_actor_url).fetch_optional(&self.pool).await?;
Ok(row.flatten())
}
async fn migrate_follower_actor(
&self,
old_actor_url: &str,
new_actor_url: &str,
) -> Result<Vec<uuid::Uuid>> {
let candidates: Vec<String> = sqlx::query_scalar(
"SELECT local_user_id FROM ap_following WHERE remote_actor_url = $1 AND local_user_id NOT IN (SELECT local_user_id FROM ap_following WHERE remote_actor_url = $2)",
).bind(old_actor_url).bind(new_actor_url).fetch_all(&self.pool).await?;
if candidates.is_empty() {
return Ok(vec![]);
}
sqlx::query("UPDATE ap_following SET remote_actor_url = $1 WHERE remote_actor_url = $2 AND local_user_id NOT IN (SELECT local_user_id FROM ap_following WHERE remote_actor_url = $1)")
.bind(new_actor_url).bind(old_actor_url).execute(&self.pool).await?;
candidates
.into_iter()
.map(|s| uuid::Uuid::parse_str(&s).map_err(|e| anyhow::anyhow!(e)))
.collect()
}
} }

View File

@@ -0,0 +1,106 @@
use crate::{PG_ACTOR_COLS, PostgresFederationRepository, pg_remote_actor};
use adapter_common::datetime_to_str;
use anyhow::Result;
use async_trait::async_trait;
use chrono::Utc;
use k_ap::{FollowingReader, FollowingStatus, FollowingWriter, RemoteActor, RemoteActorCache};
#[async_trait]
impl FollowingWriter for PostgresFederationRepository {
async fn add_following(
&self,
local_user_id: uuid::Uuid,
actor: RemoteActor,
follow_activity_id: &str,
) -> Result<()> {
let uid = local_user_id.to_string();
let now = Utc::now().naive_utc();
let created_at = datetime_to_str(&now);
RemoteActorCache::upsert_remote_actor(self, actor.clone()).await?;
sqlx::query("INSERT INTO ap_following (local_user_id, remote_actor_url, follow_activity_id, created_at) VALUES ($1, $2, $3, $4::timestamptz) ON CONFLICT DO NOTHING")
.bind(&uid).bind(&actor.url).bind(follow_activity_id).bind(&created_at).execute(&self.pool).await?;
Ok(())
}
async fn get_follow_activity_id(
&self,
local_user_id: uuid::Uuid,
remote_actor_url: &str,
) -> Result<Option<String>> {
let uid = local_user_id.to_string();
let row: Option<String> = sqlx::query_scalar("SELECT follow_activity_id FROM ap_following WHERE local_user_id = $1 AND remote_actor_url = $2")
.bind(&uid).bind(remote_actor_url).fetch_optional(&self.pool).await?;
Ok(row)
}
async fn remove_following(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()> {
let uid = local_user_id.to_string();
sqlx::query("DELETE FROM ap_following WHERE local_user_id = $1 AND remote_actor_url = $2")
.bind(&uid)
.bind(actor_url)
.execute(&self.pool)
.await?;
Ok(())
}
async fn update_following_status(
&self,
local_user_id: uuid::Uuid,
remote_actor_url: &str,
status: FollowingStatus,
) -> Result<()> {
let uid = local_user_id.to_string();
let status_str = match status {
FollowingStatus::Pending => "pending",
FollowingStatus::Accepted => "accepted",
};
let result = sqlx::query("UPDATE ap_following SET status = $1 WHERE local_user_id = $2 AND remote_actor_url = $3")
.bind(status_str).bind(&uid).bind(remote_actor_url).execute(&self.pool).await?;
if result.rows_affected() == 0 {
tracing::warn!(local_user_id = %local_user_id, remote_actor_url, "update_following_status: no row found");
}
Ok(())
}
}
#[async_trait]
impl FollowingReader for PostgresFederationRepository {
async fn get_following(&self, local_user_id: uuid::Uuid) -> Result<Vec<RemoteActor>> {
let uid = local_user_id.to_string();
let q = format!(
"SELECT a.url, {PG_ACTOR_COLS} FROM ap_following f INNER JOIN ap_remote_actors a ON a.url = f.remote_actor_url WHERE f.local_user_id = $1 AND f.status = 'accepted'"
);
let rows = sqlx::query(&q).bind(&uid).fetch_all(&self.pool).await?;
Ok(rows.iter().map(|row| pg_remote_actor(row, "url")).collect())
}
async fn count_following(&self, local_user_id: uuid::Uuid) -> Result<usize> {
let uid = local_user_id.to_string();
let count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM ap_following WHERE local_user_id = $1 AND status = 'accepted'",
)
.bind(&uid)
.fetch_one(&self.pool)
.await?;
Ok(count as usize)
}
async fn get_following_page(
&self,
local_user_id: uuid::Uuid,
offset: u32,
limit: usize,
) -> Result<Vec<RemoteActor>> {
let uid = local_user_id.to_string();
let q = format!(
"SELECT a.url, {PG_ACTOR_COLS} FROM ap_following f INNER JOIN ap_remote_actors a ON a.url = f.remote_actor_url WHERE f.local_user_id = $1 AND f.status = 'accepted' ORDER BY f.created_at ASC LIMIT $2 OFFSET $3"
);
let rows = sqlx::query(&q)
.bind(&uid)
.bind(limit as i64)
.bind(offset as i64)
.fetch_all(&self.pool)
.await?;
Ok(rows.iter().map(|row| pg_remote_actor(row, "url")).collect())
}
}

View File

@@ -0,0 +1,27 @@
use anyhow::Result;
use async_trait::async_trait;
use k_ap::FollowMigration;
use crate::PostgresFederationRepository;
#[async_trait]
impl FollowMigration for PostgresFederationRepository {
async fn migrate_follower_actor(
&self,
old_actor_url: &str,
new_actor_url: &str,
) -> Result<Vec<uuid::Uuid>> {
let candidates: Vec<String> = sqlx::query_scalar(
"SELECT local_user_id FROM ap_following WHERE remote_actor_url = $1 AND local_user_id NOT IN (SELECT local_user_id FROM ap_following WHERE remote_actor_url = $2)",
).bind(old_actor_url).bind(new_actor_url).fetch_all(&self.pool).await?;
if candidates.is_empty() {
return Ok(vec![]);
}
sqlx::query("UPDATE ap_following SET remote_actor_url = $1 WHERE remote_actor_url = $2 AND local_user_id NOT IN (SELECT local_user_id FROM ap_following WHERE remote_actor_url = $1)")
.bind(new_actor_url).bind(old_actor_url).execute(&self.pool).await?;
candidates
.into_iter()
.map(|s| uuid::Uuid::parse_str(&s).map_err(|e| anyhow::anyhow!(e)))
.collect()
}
}

View File

@@ -0,0 +1,3 @@
mod followers;
mod following;
mod migration;

View File

@@ -0,0 +1,303 @@
use async_trait::async_trait;
use chrono::Utc;
use domain::{
errors::DomainError,
value_objects::{FollowStatus, SocialActor, SocialIdentity},
};
use sqlx::Row;
use crate::PostgresFederationRepository;
use adapter_common::datetime_to_str;
fn follow_status_to_str(status: &FollowStatus) -> &'static str {
match status {
FollowStatus::Pending => "pending",
FollowStatus::Accepted => "accepted",
FollowStatus::Rejected => "rejected",
}
}
fn infra_err(e: impl std::fmt::Display) -> DomainError {
DomainError::InfrastructureError(e.to_string())
}
#[async_trait]
impl domain::ports::FollowCommand for PostgresFederationRepository {
async fn add_follow(
&self,
follower_id: uuid::Uuid,
target_actor_url: &str,
status: FollowStatus,
) -> Result<(), DomainError> {
let uid = follower_id.to_string();
let status_str = follow_status_to_str(&status);
let now = datetime_to_str(&Utc::now().naive_utc());
sqlx::query(
"INSERT INTO ap_following (local_user_id, remote_actor_url, follow_activity_id, created_at, status)
VALUES ($1, $2, '', $3::timestamptz, $4)
ON CONFLICT(local_user_id, remote_actor_url) DO UPDATE SET status = EXCLUDED.status",
)
.bind(&uid)
.bind(target_actor_url)
.bind(&now)
.bind(status_str)
.execute(&self.pool)
.await
.map_err(infra_err)?;
Ok(())
}
async fn update_follow_status(
&self,
follower_id: uuid::Uuid,
target_actor_url: &str,
status: FollowStatus,
) -> Result<(), DomainError> {
let uid = follower_id.to_string();
let status_str = follow_status_to_str(&status);
sqlx::query(
"UPDATE ap_following SET status = $1 WHERE local_user_id = $2 AND remote_actor_url = $3",
)
.bind(status_str)
.bind(&uid)
.bind(target_actor_url)
.execute(&self.pool)
.await
.map_err(infra_err)?;
Ok(())
}
async fn remove_follow(
&self,
follower_id: uuid::Uuid,
target_actor_url: &str,
) -> Result<(), DomainError> {
let uid = follower_id.to_string();
sqlx::query("DELETE FROM ap_following WHERE local_user_id = $1 AND remote_actor_url = $2")
.bind(&uid)
.bind(target_actor_url)
.execute(&self.pool)
.await
.map_err(infra_err)?;
Ok(())
}
async fn add_follower(
&self,
local_user_id: uuid::Uuid,
follower_actor_url: &str,
status: FollowStatus,
) -> Result<(), DomainError> {
let uid = local_user_id.to_string();
let status_str = follow_status_to_str(&status);
let now = datetime_to_str(&Utc::now().naive_utc());
sqlx::query(
"INSERT INTO ap_followers (local_user_id, remote_actor_url, status, created_at, follow_activity_id)
VALUES ($1, $2, $3, $4::timestamptz, '')
ON CONFLICT(local_user_id, remote_actor_url) DO UPDATE SET status = EXCLUDED.status",
)
.bind(&uid)
.bind(follower_actor_url)
.bind(status_str)
.bind(&now)
.execute(&self.pool)
.await
.map_err(infra_err)?;
Ok(())
}
async fn update_follower_status(
&self,
local_user_id: uuid::Uuid,
follower_actor_url: &str,
status: FollowStatus,
) -> Result<(), DomainError> {
let uid = local_user_id.to_string();
let status_str = follow_status_to_str(&status);
sqlx::query(
"UPDATE ap_followers SET status = $1 WHERE local_user_id = $2 AND remote_actor_url = $3",
)
.bind(status_str)
.bind(&uid)
.bind(follower_actor_url)
.execute(&self.pool)
.await
.map_err(infra_err)?;
Ok(())
}
async fn remove_follower_record(
&self,
local_user_id: uuid::Uuid,
follower_actor_url: &str,
) -> Result<(), DomainError> {
let uid = local_user_id.to_string();
sqlx::query("DELETE FROM ap_followers WHERE local_user_id = $1 AND remote_actor_url = $2")
.bind(&uid)
.bind(follower_actor_url)
.execute(&self.pool)
.await
.map_err(infra_err)?;
Ok(())
}
}
fn social_actor_from_row(row: &sqlx::postgres::PgRow, base_url: &str) -> SocialActor {
let actor_url: String = row.get("remote_actor_url");
let identity = SocialIdentity::from_actor_url(&actor_url, base_url);
let (handle, display_name, avatar_url) = match &identity {
SocialIdentity::Local(_) => {
let username: Option<String> = row.try_get("local_username").ok().flatten();
let display: Option<String> = row.try_get("local_display").ok().flatten();
let avatar: Option<String> = row
.try_get::<Option<String>, _>("local_avatar_path")
.ok()
.flatten()
.map(|p| format!("{}/images/{}", base_url, p));
let handle = username
.as_deref()
.map(|u| SocialIdentity::format_local_handle(u, base_url))
.unwrap_or_else(|| actor_url.clone());
(handle, display, avatar)
}
SocialIdentity::Remote { .. } => {
let handle: String = row
.try_get("remote_handle")
.ok()
.unwrap_or_else(|| actor_url.clone());
let display: Option<String> = row.try_get("remote_display").ok().flatten();
let avatar: Option<String> = row.try_get("remote_avatar").ok().flatten();
(handle, display, avatar)
}
};
SocialActor {
identity,
handle,
display_name,
avatar_url,
}
}
#[async_trait]
impl domain::ports::FollowQuery for PostgresFederationRepository {
async fn get_following(
&self,
user_id: uuid::Uuid,
base_url: &str,
) -> Result<Vec<SocialActor>, DomainError> {
let uid = user_id.to_string();
let rows = sqlx::query(
"SELECT f.remote_actor_url,
u.username AS local_username, u.display_name AS local_display, u.avatar_path AS local_avatar_path,
a.handle AS remote_handle, a.display_name AS remote_display, a.avatar_url AS remote_avatar
FROM ap_following f
LEFT JOIN users u ON f.remote_actor_url = $1 || '/users/' || u.id
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
WHERE f.local_user_id = $2 AND f.status = 'accepted'",
)
.bind(base_url)
.bind(&uid)
.fetch_all(&self.pool)
.await
.map_err(infra_err)?;
Ok(rows
.iter()
.map(|r| social_actor_from_row(r, base_url))
.collect())
}
async fn get_followers(
&self,
user_id: uuid::Uuid,
base_url: &str,
) -> Result<Vec<SocialActor>, DomainError> {
let uid = user_id.to_string();
let rows = sqlx::query(
"SELECT f.remote_actor_url,
u.username AS local_username, u.display_name AS local_display, u.avatar_path AS local_avatar_path,
a.handle AS remote_handle, a.display_name AS remote_display, a.avatar_url AS remote_avatar
FROM ap_followers f
LEFT JOIN users u ON f.remote_actor_url = $1 || '/users/' || u.id
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
WHERE f.local_user_id = $2 AND f.status = 'accepted'",
)
.bind(base_url)
.bind(&uid)
.fetch_all(&self.pool)
.await
.map_err(infra_err)?;
Ok(rows
.iter()
.map(|r| social_actor_from_row(r, base_url))
.collect())
}
async fn get_pending_followers(
&self,
user_id: uuid::Uuid,
base_url: &str,
) -> Result<Vec<SocialActor>, DomainError> {
let uid = user_id.to_string();
let rows = sqlx::query(
"SELECT f.remote_actor_url,
u.username AS local_username, u.display_name AS local_display, u.avatar_path AS local_avatar_path,
a.handle AS remote_handle, a.display_name AS remote_display, a.avatar_url AS remote_avatar
FROM ap_followers f
LEFT JOIN users u ON f.remote_actor_url = $1 || '/users/' || u.id
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
WHERE f.local_user_id = $2 AND f.status = 'pending'",
)
.bind(base_url)
.bind(&uid)
.fetch_all(&self.pool)
.await
.map_err(infra_err)?;
Ok(rows
.iter()
.map(|r| social_actor_from_row(r, base_url))
.collect())
}
async fn count_following(&self, user_id: uuid::Uuid) -> Result<usize, DomainError> {
let uid = user_id.to_string();
let count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM ap_following WHERE local_user_id = $1 AND status = 'accepted'",
)
.bind(&uid)
.fetch_one(&self.pool)
.await
.map_err(infra_err)?;
Ok(count as usize)
}
async fn count_followers(&self, user_id: uuid::Uuid) -> Result<usize, DomainError> {
let uid = user_id.to_string();
let count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM ap_followers WHERE local_user_id = $1 AND status = 'accepted'",
)
.bind(&uid)
.fetch_one(&self.pool)
.await
.map_err(infra_err)?;
Ok(count as usize)
}
async fn is_following(
&self,
follower_id: uuid::Uuid,
target_actor_url: &str,
) -> Result<bool, DomainError> {
let uid = follower_id.to_string();
let count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM ap_following WHERE local_user_id = $1 AND remote_actor_url = $2 AND status = 'accepted'",
)
.bind(&uid)
.bind(target_actor_url)
.fetch_one(&self.pool)
.await
.map_err(infra_err)?;
Ok(count > 0)
}
}

View File

@@ -4,6 +4,7 @@ pub mod ap_content;
mod blocklist; mod blocklist;
mod federated_profile; mod federated_profile;
mod follow; mod follow;
mod follow_repository;
pub mod remote_goals; pub mod remote_goals;
mod review; mod review;
mod social; mod social;
@@ -79,13 +80,15 @@ pub fn create_federated_profile_query(
pub fn wire(pool: PgPool) -> activitypub::FederationRepos { pub fn wire(pool: PgPool) -> activitypub::FederationRepos {
let fed = std::sync::Arc::new(PostgresFederationRepository::new(pool)); let fed = std::sync::Arc::new(PostgresFederationRepository::new(pool));
( activitypub::FederationRepos {
std::sync::Arc::clone(&fed) as _, activity: std::sync::Arc::clone(&fed) as _,
std::sync::Arc::clone(&fed) as _, follow: std::sync::Arc::clone(&fed) as _,
std::sync::Arc::clone(&fed) as _, actor: std::sync::Arc::clone(&fed) as _,
std::sync::Arc::clone(&fed) as _, blocklist: std::sync::Arc::clone(&fed) as _,
std::sync::Arc::clone(&fed) as _, admin_query: std::sync::Arc::clone(&fed) as _,
std::sync::Arc::clone(&fed) as _, review_store: std::sync::Arc::clone(&fed) as _,
fed as _, remote_watchlist: std::sync::Arc::clone(&fed) as _,
) follow_command: std::sync::Arc::clone(&fed) as _,
follow_query: fed as _,
}
} }

View File

@@ -257,3 +257,21 @@ pub(crate) struct MonthlyRatingRow {
pub avg_rating: f64, pub avg_rating: f64,
pub count: i64, pub count: i64,
} }
#[derive(sqlx::FromRow)]
pub(crate) struct GenreCountRow {
pub genre: String,
pub count: i64,
}
#[derive(sqlx::FromRow)]
pub(crate) struct RatingDistRow {
pub rating: i64,
pub count: i64,
}
#[derive(sqlx::FromRow)]
pub(crate) struct WatchMediumCountRow {
pub watch_medium: String,
pub count: i64,
}

View File

@@ -7,7 +7,10 @@ use domain::{
}; };
use sqlx::PgPool; use sqlx::PgPool;
use crate::models::{DirectorCountRow, MonthlyRatingRow, UserTotalsRow}; use crate::models::{
DirectorCountRow, GenreCountRow, MonthlyRatingRow, RatingDistRow, UserTotalsRow,
WatchMediumCountRow,
};
use adapter_common::format_year_month; use adapter_common::format_year_month;
pub struct PostgresStatsRepository { pub struct PostgresStatsRepository {
@@ -97,31 +100,61 @@ impl StatsRepository for PostgresStatsRepository {
async fn get_user_trends(&self, user_id: &UserId) -> Result<UserTrends, DomainError> { async fn get_user_trends(&self, user_id: &UserId) -> Result<UserTrends, DomainError> {
let uid = user_id.value().to_string(); let uid = user_id.value().to_string();
let (rating_rows, director_rows) = tokio::try_join!( let (rating_rows, director_rows, genre_rows, rating_dist_rows, medium_rows) =
sqlx::query_as::<_, MonthlyRatingRow>( tokio::try_join!(
"SELECT to_char(watched_at AT TIME ZONE 'UTC', 'YYYY-MM') AS month, sqlx::query_as::<_, MonthlyRatingRow>(
"SELECT to_char(watched_at AT TIME ZONE 'UTC', 'YYYY-MM') AS month,
AVG(rating::float) AS avg_rating, AVG(rating::float) AS avg_rating,
COUNT(*) AS count COUNT(*) AS count
FROM reviews FROM reviews
WHERE user_id = $1 AND watched_at >= NOW() - INTERVAL '12 months' WHERE user_id = $1 AND watched_at >= NOW() - INTERVAL '12 months'
GROUP BY to_char(watched_at AT TIME ZONE 'UTC', 'YYYY-MM') GROUP BY to_char(watched_at AT TIME ZONE 'UTC', 'YYYY-MM')
ORDER BY to_char(watched_at AT TIME ZONE 'UTC', 'YYYY-MM') ASC" ORDER BY to_char(watched_at AT TIME ZONE 'UTC', 'YYYY-MM') ASC"
) )
.bind(&uid) .bind(&uid)
.fetch_all(&self.pool), .fetch_all(&self.pool),
sqlx::query_as::<_, DirectorCountRow>( sqlx::query_as::<_, DirectorCountRow>(
"SELECT m.director AS director, COUNT(*) AS count "SELECT m.director AS director, COUNT(*) AS count
FROM reviews r FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id INNER JOIN movies m ON m.id = r.movie_id
WHERE r.user_id = $1 AND m.director IS NOT NULL WHERE r.user_id = $1 AND m.director IS NOT NULL
GROUP BY m.director GROUP BY m.director
ORDER BY COUNT(*) DESC ORDER BY COUNT(*) DESC
LIMIT 5" LIMIT 5"
)
.bind(&uid)
.fetch_all(&self.pool),
sqlx::query_as::<_, GenreCountRow>(
"SELECT mg.name AS genre, COUNT(*) AS count
FROM reviews r
INNER JOIN movie_genres mg ON mg.movie_id = r.movie_id
WHERE r.user_id = $1
GROUP BY mg.name
ORDER BY COUNT(*) DESC
LIMIT 5"
)
.bind(&uid)
.fetch_all(&self.pool),
sqlx::query_as::<_, RatingDistRow>(
"SELECT rating, COUNT(*) AS count
FROM reviews
WHERE user_id = $1
GROUP BY rating
ORDER BY rating ASC"
)
.bind(&uid)
.fetch_all(&self.pool),
sqlx::query_as::<_, WatchMediumCountRow>(
"SELECT watch_medium, COUNT(*) AS count
FROM reviews
WHERE user_id = $1 AND watch_medium IS NOT NULL
GROUP BY watch_medium
ORDER BY COUNT(*) DESC"
)
.bind(&uid)
.fetch_all(&self.pool)
) )
.bind(&uid) .map_err(adapter_common::map_sqlx_error)?;
.fetch_all(&self.pool)
)
.map_err(adapter_common::map_sqlx_error)?;
let max_director_count = director_rows.iter().map(|d| d.count).max().unwrap_or(1); let max_director_count = director_rows.iter().map(|d| d.count).max().unwrap_or(1);
@@ -143,10 +176,38 @@ impl StatsRepository for PostgresStatsRepository {
}) })
.collect(); .collect();
let top_genres = genre_rows
.into_iter()
.map(|g| domain::models::stats::GenreStat {
genre: g.genre,
count: g.count,
})
.collect();
let rating_distribution = {
let mut dist = [0i64; 5];
for r in &rating_dist_rows {
let idx = (r.rating as usize).saturating_sub(1).min(4);
dist[idx] = r.count;
}
dist
};
let watch_medium_distribution = medium_rows
.into_iter()
.map(|m| domain::models::stats::WatchMediumStat {
medium: m.watch_medium,
count: m.count,
})
.collect();
Ok(UserTrends { Ok(UserTrends {
monthly_ratings, monthly_ratings,
top_directors, top_directors,
max_director_count, max_director_count,
top_genres,
rating_distribution,
watch_medium_distribution,
}) })
} }
} }

View File

@@ -289,7 +289,7 @@ impl WrapUpStatsQuery for PostgresWrapUpStatsQuery {
"SELECT r.movie_id, m.title, m.release_year, m.director, m.poster_path, \ "SELECT r.movie_id, m.title, m.release_year, m.director, m.poster_path, \
r.rating, \ r.rating, \
to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at, \ to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at, \
r.user_id, \ r.user_id, r.watch_medium, \
p.runtime_minutes, p.budget_usd, p.original_language \ p.runtime_minutes, p.budget_usd, p.original_language \
FROM reviews r \ FROM reviews r \
INNER JOIN movies m ON m.id = r.movie_id \ INNER JOIN movies m ON m.id = r.movie_id \
@@ -367,6 +367,9 @@ impl WrapUpStatsQuery for PostgresWrapUpStatsQuery {
let original_language: Option<String> = row let original_language: Option<String> = row
.try_get("original_language") .try_get("original_language")
.map_err(adapter_common::map_sqlx_error)?; .map_err(adapter_common::map_sqlx_error)?;
let watch_medium: Option<String> = row
.try_get("watch_medium")
.map_err(adapter_common::map_sqlx_error)?;
let genres = genres_map.get(&movie_id_str).cloned().unwrap_or_default(); let genres = genres_map.get(&movie_id_str).cloned().unwrap_or_default();
let keywords = keywords_map.get(&movie_id_str).cloned().unwrap_or_default(); let keywords = keywords_map.get(&movie_id_str).cloned().unwrap_or_default();
@@ -391,6 +394,7 @@ impl WrapUpStatsQuery for PostgresWrapUpStatsQuery {
runtime_minutes: runtime_minutes.map(|v| v as u32), runtime_minutes: runtime_minutes.map(|v| v as u32),
budget_usd, budget_usd,
original_language, original_language,
watch_medium,
genres, genres,
keywords, keywords,
cast_names, cast_names,

View File

@@ -7,7 +7,7 @@ edition = "2024"
sqlx = { workspace = true } sqlx = { workspace = true }
activitypub = { workspace = true } activitypub = { workspace = true }
adapter-common = { workspace = true } adapter-common = { workspace = true }
k-ap = { version = "0.4.1", registry = "gitea" } k-ap = { version = "0.5.0", registry = "gitea" }
domain = { workspace = true } domain = { workspace = true }
anyhow = { workspace = true } anyhow = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }

View File

@@ -1,33 +1,28 @@
use anyhow::Result; use anyhow::Result;
use async_trait::async_trait; use async_trait::async_trait;
use chrono::Utc; use chrono::Utc;
use k_ap::{ActorRepository, RemoteActor}; use k_ap::{AnnounceRepository, Keypair, KeypairRepository, RemoteActor, RemoteActorCache};
use sqlx::Row; use sqlx::Row;
use super::{SqliteFederationRepository, remote_actor_from_row}; use super::{SqliteFederationRepository, remote_actor_from_row};
use adapter_common::datetime_to_str; use adapter_common::datetime_to_str;
#[async_trait] #[async_trait]
impl ActorRepository for SqliteFederationRepository { impl KeypairRepository for SqliteFederationRepository {
async fn get_local_actor_keypair( async fn get_local_actor_keypair(&self, user_id: uuid::Uuid) -> Result<Option<Keypair>> {
&self,
user_id: uuid::Uuid,
) -> Result<Option<(String, String)>> {
let uid = user_id.to_string(); let uid = user_id.to_string();
let row = let row =
sqlx::query("SELECT public_key, private_key FROM ap_local_actors WHERE user_id = ?") sqlx::query("SELECT public_key, private_key FROM ap_local_actors WHERE user_id = ?")
.bind(&uid) .bind(&uid)
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await?; .await?;
Ok(row.map(|r| (r.get("public_key"), r.get("private_key")))) Ok(row.map(|r| Keypair {
public_key: r.get("public_key"),
private_key: r.get("private_key"),
}))
} }
async fn save_local_actor_keypair( async fn save_local_actor_keypair(&self, user_id: uuid::Uuid, keypair: Keypair) -> Result<()> {
&self,
user_id: uuid::Uuid,
public_key: String,
private_key: String,
) -> Result<()> {
let uid = user_id.to_string(); let uid = user_id.to_string();
let now = Utc::now().naive_utc(); let now = Utc::now().naive_utc();
let created_at = datetime_to_str(&now); let created_at = datetime_to_str(&now);
@@ -39,14 +34,17 @@ impl ActorRepository for SqliteFederationRepository {
private_key = excluded.private_key", private_key = excluded.private_key",
) )
.bind(&uid) .bind(&uid)
.bind(&public_key) .bind(&keypair.public_key)
.bind(&private_key) .bind(&keypair.private_key)
.bind(&created_at) .bind(&created_at)
.execute(&self.pool) .execute(&self.pool)
.await?; .await?;
Ok(()) Ok(())
} }
}
#[async_trait]
impl RemoteActorCache for SqliteFederationRepository {
async fn upsert_remote_actor(&self, actor: RemoteActor) -> Result<()> { async fn upsert_remote_actor(&self, actor: RemoteActor) -> Result<()> {
let now = Utc::now().naive_utc(); let now = Utc::now().naive_utc();
let fetched_at = datetime_to_str(&now); let fetched_at = datetime_to_str(&now);
@@ -84,7 +82,10 @@ impl ActorRepository for SqliteFederationRepository {
).bind(actor_url).fetch_optional(&self.pool).await?; ).bind(actor_url).fetch_optional(&self.pool).await?;
Ok(row.as_ref().map(|r| remote_actor_from_row(r, "url"))) Ok(row.as_ref().map(|r| remote_actor_from_row(r, "url")))
} }
}
#[async_trait]
impl AnnounceRepository for SqliteFederationRepository {
async fn add_announce( async fn add_announce(
&self, &self,
activity_id: &str, activity_id: &str,

View File

@@ -1,14 +1,14 @@
use anyhow::Result; use anyhow::Result;
use async_trait::async_trait; use async_trait::async_trait;
use chrono::Utc; use chrono::Utc;
use k_ap::{BlockedDomain, BlocklistRepository}; use k_ap::{ActorBlocklist, BlockedDomain, DomainBlocklist};
use sqlx::Row; use sqlx::Row;
use super::SqliteFederationRepository; use super::SqliteFederationRepository;
use adapter_common::datetime_to_str; use adapter_common::datetime_to_str;
#[async_trait] #[async_trait]
impl BlocklistRepository for SqliteFederationRepository { impl DomainBlocklist for SqliteFederationRepository {
async fn add_blocked_domain(&self, domain: &str, reason: Option<&str>) -> Result<()> { async fn add_blocked_domain(&self, domain: &str, reason: Option<&str>) -> Result<()> {
let now = Utc::now().naive_utc(); let now = Utc::now().naive_utc();
let ts = datetime_to_str(&now); let ts = datetime_to_str(&now);
@@ -56,7 +56,10 @@ impl BlocklistRepository for SqliteFederationRepository {
.await?; .await?;
Ok(count > 0) Ok(count > 0)
} }
}
#[async_trait]
impl ActorBlocklist for SqliteFederationRepository {
async fn add_blocked_actor(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()> { async fn add_blocked_actor(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()> {
let uid = local_user_id.to_string(); let uid = local_user_id.to_string();
let ts = datetime_to_str(&Utc::now().naive_utc()); let ts = datetime_to_str(&Utc::now().naive_utc());

View File

@@ -1,16 +1,14 @@
use anyhow::Result; use anyhow::Result;
use async_trait::async_trait; use async_trait::async_trait;
use chrono::Utc; use chrono::Utc;
use k_ap::{ use k_ap::{Follower, FollowerReader, FollowerStatus, FollowerWriter, RemoteActor};
ActorRepository, FollowRepository, Follower, FollowerStatus, FollowingStatus, RemoteActor,
};
use sqlx::Row; use sqlx::Row;
use super::{SqliteFederationRepository, remote_actor_from_row, status_to_str, str_to_status}; use crate::{SqliteFederationRepository, remote_actor_from_row, status_to_str, str_to_status};
use adapter_common::datetime_to_str; use adapter_common::datetime_to_str;
#[async_trait] #[async_trait]
impl FollowRepository for SqliteFederationRepository { impl FollowerWriter for SqliteFederationRepository {
async fn add_follower( async fn add_follower(
&self, &self,
local_user_id: uuid::Uuid, local_user_id: uuid::Uuid,
@@ -69,6 +67,31 @@ impl FollowRepository for SqliteFederationRepository {
Ok(()) Ok(())
} }
async fn update_follower_status(
&self,
local_user_id: uuid::Uuid,
remote_actor_url: &str,
status: FollowerStatus,
) -> Result<()> {
let uid = local_user_id.to_string();
let status_str = status_to_str(&status);
let result = sqlx::query(
"UPDATE ap_followers SET status = ? WHERE local_user_id = ? AND remote_actor_url = ?",
)
.bind(status_str)
.bind(&uid)
.bind(remote_actor_url)
.execute(&self.pool)
.await?;
if result.rows_affected() == 0 {
tracing::warn!(local_user_id = %local_user_id, remote_actor_url, "update_follower_status: no row found");
}
Ok(())
}
}
#[async_trait]
impl FollowerReader for SqliteFederationRepository {
async fn get_followers(&self, local_user_id: uuid::Uuid) -> Result<Vec<Follower>> { async fn get_followers(&self, local_user_id: uuid::Uuid) -> Result<Vec<Follower>> {
let uid = local_user_id.to_string(); let uid = local_user_id.to_string();
let rows = sqlx::query( let rows = sqlx::query(
@@ -138,28 +161,6 @@ impl FollowRepository for SqliteFederationRepository {
Ok(count as usize) Ok(count as usize)
} }
async fn update_follower_status(
&self,
local_user_id: uuid::Uuid,
remote_actor_url: &str,
status: FollowerStatus,
) -> Result<()> {
let uid = local_user_id.to_string();
let status_str = status_to_str(&status);
let result = sqlx::query(
"UPDATE ap_followers SET status = ? WHERE local_user_id = ? AND remote_actor_url = ?",
)
.bind(status_str)
.bind(&uid)
.bind(remote_actor_url)
.execute(&self.pool)
.await?;
if result.rows_affected() == 0 {
tracing::warn!(local_user_id = %local_user_id, remote_actor_url, "update_follower_status: no row found");
}
Ok(())
}
async fn get_pending_followers(&self, local_user_id: uuid::Uuid) -> Result<Vec<RemoteActor>> { async fn get_pending_followers(&self, local_user_id: uuid::Uuid) -> Result<Vec<RemoteActor>> {
let uid = local_user_id.to_string(); let uid = local_user_id.to_string();
let rows = sqlx::query( let rows = sqlx::query(
@@ -232,173 +233,4 @@ impl FollowRepository for SqliteFederationRepository {
.map(|row| remote_actor_from_row(row, "remote_actor_url")) .map(|row| remote_actor_from_row(row, "remote_actor_url"))
.collect()) .collect())
} }
async fn add_following(
&self,
local_user_id: uuid::Uuid,
actor: RemoteActor,
follow_activity_id: &str,
) -> Result<()> {
let uid = local_user_id.to_string();
let now = Utc::now().naive_utc();
let created_at = datetime_to_str(&now);
ActorRepository::upsert_remote_actor(self, actor.clone()).await?;
sqlx::query(
"INSERT OR IGNORE INTO ap_following (local_user_id, remote_actor_url, follow_activity_id, created_at)
VALUES (?, ?, ?, ?)",
).bind(&uid).bind(&actor.url).bind(follow_activity_id).bind(&created_at).execute(&self.pool).await?;
Ok(())
}
async fn get_follow_activity_id(
&self,
local_user_id: uuid::Uuid,
remote_actor_url: &str,
) -> Result<Option<String>> {
let uid = local_user_id.to_string();
let row: Option<Option<String>> = sqlx::query_scalar(
"SELECT follow_activity_id FROM ap_following WHERE local_user_id = ? AND remote_actor_url = ?",
).bind(&uid).bind(remote_actor_url).fetch_optional(&self.pool).await?;
Ok(row.flatten())
}
async fn remove_following(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()> {
let uid = local_user_id.to_string();
sqlx::query("DELETE FROM ap_following WHERE local_user_id = ? AND remote_actor_url = ?")
.bind(&uid)
.bind(actor_url)
.execute(&self.pool)
.await?;
Ok(())
}
async fn get_following(&self, local_user_id: uuid::Uuid) -> Result<Vec<RemoteActor>> {
let uid = local_user_id.to_string();
let rows = sqlx::query(
"SELECT a.url, a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.avatar_url,
a.outbox_url, a.bio, a.banner_url, a.followers_url, a.following_url, a.also_known_as, a.fetched_at
FROM ap_following f
INNER JOIN ap_remote_actors a ON a.url = f.remote_actor_url
WHERE f.local_user_id = ? AND f.status = 'accepted'",
).bind(&uid).fetch_all(&self.pool).await?;
Ok(rows
.iter()
.map(|row| remote_actor_from_row(row, "url"))
.collect())
}
async fn count_following(&self, local_user_id: uuid::Uuid) -> Result<usize> {
let uid = local_user_id.to_string();
let count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM ap_following WHERE local_user_id = ? AND status = 'accepted'",
)
.bind(&uid)
.fetch_one(&self.pool)
.await?;
Ok(count as usize)
}
async fn get_following_page(
&self,
local_user_id: uuid::Uuid,
offset: u32,
limit: usize,
) -> Result<Vec<RemoteActor>> {
let uid = local_user_id.to_string();
let rows = sqlx::query(
"SELECT a.url, a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.avatar_url,
a.outbox_url, a.bio, a.banner_url, a.followers_url, a.following_url, a.also_known_as, a.fetched_at
FROM ap_following f
INNER JOIN ap_remote_actors a ON a.url = f.remote_actor_url
WHERE f.local_user_id = ? AND f.status = 'accepted'
ORDER BY f.created_at ASC LIMIT ? OFFSET ?",
).bind(&uid).bind(limit as i64).bind(offset as i64).fetch_all(&self.pool).await?;
Ok(rows
.iter()
.map(|row| remote_actor_from_row(row, "url"))
.collect())
}
async fn update_following_status(
&self,
local_user_id: uuid::Uuid,
remote_actor_url: &str,
status: FollowingStatus,
) -> Result<()> {
let uid = local_user_id.to_string();
let status_str = match status {
FollowingStatus::Pending => "pending",
FollowingStatus::Accepted => "accepted",
};
let result = sqlx::query(
"UPDATE ap_following SET status = ? WHERE local_user_id = ? AND remote_actor_url = ?",
)
.bind(status_str)
.bind(&uid)
.bind(remote_actor_url)
.execute(&self.pool)
.await?;
if result.rows_affected() == 0 {
tracing::warn!(local_user_id = %local_user_id, remote_actor_url, "update_following_status: no row found");
}
Ok(())
}
async fn get_following_outbox_url(
&self,
local_user_id: uuid::Uuid,
remote_actor_url: &str,
) -> Result<Option<String>> {
let uid = local_user_id.to_string();
let row: Option<Option<String>> = sqlx::query_scalar(
"SELECT a.outbox_url
FROM ap_following f
INNER JOIN ap_remote_actors a ON a.url = f.remote_actor_url
WHERE f.local_user_id = ? AND f.remote_actor_url = ?",
)
.bind(&uid)
.bind(remote_actor_url)
.fetch_optional(&self.pool)
.await?;
Ok(row.flatten())
}
async fn migrate_follower_actor(
&self,
old_actor_url: &str,
new_actor_url: &str,
) -> Result<Vec<uuid::Uuid>> {
let candidates: Vec<String> = sqlx::query_scalar(
"SELECT local_user_id FROM ap_following
WHERE remote_actor_url = ?1
AND local_user_id NOT IN (
SELECT local_user_id FROM ap_following WHERE remote_actor_url = ?2
)",
)
.bind(old_actor_url)
.bind(new_actor_url)
.fetch_all(&self.pool)
.await?;
if candidates.is_empty() {
return Ok(vec![]);
}
sqlx::query(
"UPDATE ap_following SET remote_actor_url = ?1
WHERE remote_actor_url = ?2
AND local_user_id NOT IN (
SELECT local_user_id FROM ap_following WHERE remote_actor_url = ?1
)",
)
.bind(new_actor_url)
.bind(old_actor_url)
.execute(&self.pool)
.await?;
candidates
.into_iter()
.map(|s| uuid::Uuid::parse_str(&s).map_err(|e| anyhow::anyhow!(e)))
.collect()
}
} }

View File

@@ -0,0 +1,124 @@
use anyhow::Result;
use async_trait::async_trait;
use chrono::Utc;
use k_ap::{FollowingReader, FollowingStatus, FollowingWriter, RemoteActor, RemoteActorCache};
use crate::{SqliteFederationRepository, remote_actor_from_row};
use adapter_common::datetime_to_str;
#[async_trait]
impl FollowingWriter for SqliteFederationRepository {
async fn add_following(
&self,
local_user_id: uuid::Uuid,
actor: RemoteActor,
follow_activity_id: &str,
) -> Result<()> {
let uid = local_user_id.to_string();
let now = Utc::now().naive_utc();
let created_at = datetime_to_str(&now);
RemoteActorCache::upsert_remote_actor(self, actor.clone()).await?;
sqlx::query(
"INSERT OR IGNORE INTO ap_following (local_user_id, remote_actor_url, follow_activity_id, created_at)
VALUES (?, ?, ?, ?)",
).bind(&uid).bind(&actor.url).bind(follow_activity_id).bind(&created_at).execute(&self.pool).await?;
Ok(())
}
async fn get_follow_activity_id(
&self,
local_user_id: uuid::Uuid,
remote_actor_url: &str,
) -> Result<Option<String>> {
let uid = local_user_id.to_string();
let row: Option<Option<String>> = sqlx::query_scalar(
"SELECT follow_activity_id FROM ap_following WHERE local_user_id = ? AND remote_actor_url = ?",
).bind(&uid).bind(remote_actor_url).fetch_optional(&self.pool).await?;
Ok(row.flatten())
}
async fn remove_following(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()> {
let uid = local_user_id.to_string();
sqlx::query("DELETE FROM ap_following WHERE local_user_id = ? AND remote_actor_url = ?")
.bind(&uid)
.bind(actor_url)
.execute(&self.pool)
.await?;
Ok(())
}
async fn update_following_status(
&self,
local_user_id: uuid::Uuid,
remote_actor_url: &str,
status: FollowingStatus,
) -> Result<()> {
let uid = local_user_id.to_string();
let status_str = match status {
FollowingStatus::Pending => "pending",
FollowingStatus::Accepted => "accepted",
};
let result = sqlx::query(
"UPDATE ap_following SET status = ? WHERE local_user_id = ? AND remote_actor_url = ?",
)
.bind(status_str)
.bind(&uid)
.bind(remote_actor_url)
.execute(&self.pool)
.await?;
if result.rows_affected() == 0 {
tracing::warn!(local_user_id = %local_user_id, remote_actor_url, "update_following_status: no row found");
}
Ok(())
}
}
#[async_trait]
impl FollowingReader for SqliteFederationRepository {
async fn get_following(&self, local_user_id: uuid::Uuid) -> Result<Vec<RemoteActor>> {
let uid = local_user_id.to_string();
let rows = sqlx::query(
"SELECT a.url, a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.avatar_url,
a.outbox_url, a.bio, a.banner_url, a.followers_url, a.following_url, a.also_known_as, a.fetched_at
FROM ap_following f
INNER JOIN ap_remote_actors a ON a.url = f.remote_actor_url
WHERE f.local_user_id = ? AND f.status = 'accepted'",
).bind(&uid).fetch_all(&self.pool).await?;
Ok(rows
.iter()
.map(|row| remote_actor_from_row(row, "url"))
.collect())
}
async fn count_following(&self, local_user_id: uuid::Uuid) -> Result<usize> {
let uid = local_user_id.to_string();
let count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM ap_following WHERE local_user_id = ? AND status = 'accepted'",
)
.bind(&uid)
.fetch_one(&self.pool)
.await?;
Ok(count as usize)
}
async fn get_following_page(
&self,
local_user_id: uuid::Uuid,
offset: u32,
limit: usize,
) -> Result<Vec<RemoteActor>> {
let uid = local_user_id.to_string();
let rows = sqlx::query(
"SELECT a.url, a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.avatar_url,
a.outbox_url, a.bio, a.banner_url, a.followers_url, a.following_url, a.also_known_as, a.fetched_at
FROM ap_following f
INNER JOIN ap_remote_actors a ON a.url = f.remote_actor_url
WHERE f.local_user_id = ? AND f.status = 'accepted'
ORDER BY f.created_at ASC LIMIT ? OFFSET ?",
).bind(&uid).bind(limit as i64).bind(offset as i64).fetch_all(&self.pool).await?;
Ok(rows
.iter()
.map(|row| remote_actor_from_row(row, "url"))
.collect())
}
}

View File

@@ -0,0 +1,47 @@
use anyhow::Result;
use async_trait::async_trait;
use k_ap::FollowMigration;
use crate::SqliteFederationRepository;
#[async_trait]
impl FollowMigration for SqliteFederationRepository {
async fn migrate_follower_actor(
&self,
old_actor_url: &str,
new_actor_url: &str,
) -> Result<Vec<uuid::Uuid>> {
let candidates: Vec<String> = sqlx::query_scalar(
"SELECT local_user_id FROM ap_following
WHERE remote_actor_url = ?1
AND local_user_id NOT IN (
SELECT local_user_id FROM ap_following WHERE remote_actor_url = ?2
)",
)
.bind(old_actor_url)
.bind(new_actor_url)
.fetch_all(&self.pool)
.await?;
if candidates.is_empty() {
return Ok(vec![]);
}
sqlx::query(
"UPDATE ap_following SET remote_actor_url = ?1
WHERE remote_actor_url = ?2
AND local_user_id NOT IN (
SELECT local_user_id FROM ap_following WHERE remote_actor_url = ?1
)",
)
.bind(new_actor_url)
.bind(old_actor_url)
.execute(&self.pool)
.await?;
candidates
.into_iter()
.map(|s| uuid::Uuid::parse_str(&s).map_err(|e| anyhow::anyhow!(e)))
.collect()
}
}

View File

@@ -0,0 +1,3 @@
mod followers;
mod following;
mod migration;

View File

@@ -0,0 +1,303 @@
use async_trait::async_trait;
use chrono::Utc;
use domain::{
errors::DomainError,
value_objects::{FollowStatus, SocialActor, SocialIdentity},
};
use sqlx::Row;
use crate::SqliteFederationRepository;
use adapter_common::datetime_to_str;
fn follow_status_to_str(status: &FollowStatus) -> &'static str {
match status {
FollowStatus::Pending => "pending",
FollowStatus::Accepted => "accepted",
FollowStatus::Rejected => "rejected",
}
}
fn infra_err(e: impl std::fmt::Display) -> DomainError {
DomainError::InfrastructureError(e.to_string())
}
#[async_trait]
impl domain::ports::FollowCommand for SqliteFederationRepository {
async fn add_follow(
&self,
follower_id: uuid::Uuid,
target_actor_url: &str,
status: FollowStatus,
) -> Result<(), DomainError> {
let uid = follower_id.to_string();
let status_str = follow_status_to_str(&status);
let now = datetime_to_str(&Utc::now().naive_utc());
sqlx::query(
"INSERT INTO ap_following (local_user_id, remote_actor_url, follow_activity_id, created_at, status)
VALUES (?1, ?2, '', ?3, ?4)
ON CONFLICT(local_user_id, remote_actor_url) DO UPDATE SET status = excluded.status",
)
.bind(&uid)
.bind(target_actor_url)
.bind(&now)
.bind(status_str)
.execute(&self.pool)
.await
.map_err(infra_err)?;
Ok(())
}
async fn update_follow_status(
&self,
follower_id: uuid::Uuid,
target_actor_url: &str,
status: FollowStatus,
) -> Result<(), DomainError> {
let uid = follower_id.to_string();
let status_str = follow_status_to_str(&status);
sqlx::query(
"UPDATE ap_following SET status = ?1 WHERE local_user_id = ?2 AND remote_actor_url = ?3",
)
.bind(status_str)
.bind(&uid)
.bind(target_actor_url)
.execute(&self.pool)
.await
.map_err(infra_err)?;
Ok(())
}
async fn remove_follow(
&self,
follower_id: uuid::Uuid,
target_actor_url: &str,
) -> Result<(), DomainError> {
let uid = follower_id.to_string();
sqlx::query("DELETE FROM ap_following WHERE local_user_id = ?1 AND remote_actor_url = ?2")
.bind(&uid)
.bind(target_actor_url)
.execute(&self.pool)
.await
.map_err(infra_err)?;
Ok(())
}
async fn add_follower(
&self,
local_user_id: uuid::Uuid,
follower_actor_url: &str,
status: FollowStatus,
) -> Result<(), DomainError> {
let uid = local_user_id.to_string();
let status_str = follow_status_to_str(&status);
let now = datetime_to_str(&Utc::now().naive_utc());
sqlx::query(
"INSERT INTO ap_followers (local_user_id, remote_actor_url, status, created_at, follow_activity_id)
VALUES (?1, ?2, ?3, ?4, '')
ON CONFLICT(local_user_id, remote_actor_url) DO UPDATE SET status = excluded.status",
)
.bind(&uid)
.bind(follower_actor_url)
.bind(status_str)
.bind(&now)
.execute(&self.pool)
.await
.map_err(infra_err)?;
Ok(())
}
async fn update_follower_status(
&self,
local_user_id: uuid::Uuid,
follower_actor_url: &str,
status: FollowStatus,
) -> Result<(), DomainError> {
let uid = local_user_id.to_string();
let status_str = follow_status_to_str(&status);
sqlx::query(
"UPDATE ap_followers SET status = ?1 WHERE local_user_id = ?2 AND remote_actor_url = ?3",
)
.bind(status_str)
.bind(&uid)
.bind(follower_actor_url)
.execute(&self.pool)
.await
.map_err(infra_err)?;
Ok(())
}
async fn remove_follower_record(
&self,
local_user_id: uuid::Uuid,
follower_actor_url: &str,
) -> Result<(), DomainError> {
let uid = local_user_id.to_string();
sqlx::query("DELETE FROM ap_followers WHERE local_user_id = ?1 AND remote_actor_url = ?2")
.bind(&uid)
.bind(follower_actor_url)
.execute(&self.pool)
.await
.map_err(infra_err)?;
Ok(())
}
}
fn social_actor_from_row(row: &sqlx::sqlite::SqliteRow, base_url: &str) -> SocialActor {
let actor_url: String = row.get("remote_actor_url");
let identity = SocialIdentity::from_actor_url(&actor_url, base_url);
let (handle, display_name, avatar_url) = match &identity {
SocialIdentity::Local(_) => {
let username: Option<String> = row.try_get("local_username").ok().flatten();
let display: Option<String> = row.try_get("local_display").ok().flatten();
let avatar: Option<String> = row
.try_get::<Option<String>, _>("local_avatar_path")
.ok()
.flatten()
.map(|p| format!("{}/images/{}", base_url, p));
let handle = username
.as_deref()
.map(|u| SocialIdentity::format_local_handle(u, base_url))
.unwrap_or_else(|| actor_url.clone());
(handle, display, avatar)
}
SocialIdentity::Remote { .. } => {
let handle: String = row
.try_get("remote_handle")
.ok()
.unwrap_or_else(|| actor_url.clone());
let display: Option<String> = row.try_get("remote_display").ok().flatten();
let avatar: Option<String> = row.try_get("remote_avatar").ok().flatten();
(handle, display, avatar)
}
};
SocialActor {
identity,
handle,
display_name,
avatar_url,
}
}
#[async_trait]
impl domain::ports::FollowQuery for SqliteFederationRepository {
async fn get_following(
&self,
user_id: uuid::Uuid,
base_url: &str,
) -> Result<Vec<SocialActor>, DomainError> {
let uid = user_id.to_string();
let rows = sqlx::query(
"SELECT f.remote_actor_url,
u.username AS local_username, u.display_name AS local_display, u.avatar_path AS local_avatar_path,
a.handle AS remote_handle, a.display_name AS remote_display, a.avatar_url AS remote_avatar
FROM ap_following f
LEFT JOIN users u ON f.remote_actor_url = ?1 || '/users/' || u.id
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
WHERE f.local_user_id = ?2 AND f.status = 'accepted'",
)
.bind(base_url)
.bind(&uid)
.fetch_all(&self.pool)
.await
.map_err(infra_err)?;
Ok(rows
.iter()
.map(|r| social_actor_from_row(r, base_url))
.collect())
}
async fn get_followers(
&self,
user_id: uuid::Uuid,
base_url: &str,
) -> Result<Vec<SocialActor>, DomainError> {
let uid = user_id.to_string();
let rows = sqlx::query(
"SELECT f.remote_actor_url,
u.username AS local_username, u.display_name AS local_display, u.avatar_path AS local_avatar_path,
a.handle AS remote_handle, a.display_name AS remote_display, a.avatar_url AS remote_avatar
FROM ap_followers f
LEFT JOIN users u ON f.remote_actor_url = ?1 || '/users/' || u.id
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
WHERE f.local_user_id = ?2 AND f.status = 'accepted'",
)
.bind(base_url)
.bind(&uid)
.fetch_all(&self.pool)
.await
.map_err(infra_err)?;
Ok(rows
.iter()
.map(|r| social_actor_from_row(r, base_url))
.collect())
}
async fn get_pending_followers(
&self,
user_id: uuid::Uuid,
base_url: &str,
) -> Result<Vec<SocialActor>, DomainError> {
let uid = user_id.to_string();
let rows = sqlx::query(
"SELECT f.remote_actor_url,
u.username AS local_username, u.display_name AS local_display, u.avatar_path AS local_avatar_path,
a.handle AS remote_handle, a.display_name AS remote_display, a.avatar_url AS remote_avatar
FROM ap_followers f
LEFT JOIN users u ON f.remote_actor_url = ?1 || '/users/' || u.id
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
WHERE f.local_user_id = ?2 AND f.status = 'pending'",
)
.bind(base_url)
.bind(&uid)
.fetch_all(&self.pool)
.await
.map_err(infra_err)?;
Ok(rows
.iter()
.map(|r| social_actor_from_row(r, base_url))
.collect())
}
async fn count_following(&self, user_id: uuid::Uuid) -> Result<usize, DomainError> {
let uid = user_id.to_string();
let count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM ap_following WHERE local_user_id = ? AND status = 'accepted'",
)
.bind(&uid)
.fetch_one(&self.pool)
.await
.map_err(infra_err)?;
Ok(count as usize)
}
async fn count_followers(&self, user_id: uuid::Uuid) -> Result<usize, DomainError> {
let uid = user_id.to_string();
let count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM ap_followers WHERE local_user_id = ? AND status = 'accepted'",
)
.bind(&uid)
.fetch_one(&self.pool)
.await
.map_err(infra_err)?;
Ok(count as usize)
}
async fn is_following(
&self,
follower_id: uuid::Uuid,
target_actor_url: &str,
) -> Result<bool, DomainError> {
let uid = follower_id.to_string();
let count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM ap_following WHERE local_user_id = ? AND remote_actor_url = ? AND status = 'accepted'",
)
.bind(&uid)
.bind(target_actor_url)
.fetch_one(&self.pool)
.await
.map_err(infra_err)?;
Ok(count > 0)
}
}

View File

@@ -3,6 +3,7 @@ mod actor;
mod blocklist; mod blocklist;
mod federated_profile; mod federated_profile;
mod follow; mod follow;
mod follow_repository;
mod review; mod review;
mod social; mod social;
mod watchlist; mod watchlist;
@@ -91,21 +92,19 @@ pub fn create_federated_profile_query(
pub fn wire(pool: SqlitePool) -> activitypub::FederationRepos { pub fn wire(pool: SqlitePool) -> activitypub::FederationRepos {
let fed = std::sync::Arc::new(SqliteFederationRepository::new(pool)); let fed = std::sync::Arc::new(SqliteFederationRepository::new(pool));
( activitypub::FederationRepos {
std::sync::Arc::clone(&fed) as _, activity: std::sync::Arc::clone(&fed) as _,
std::sync::Arc::clone(&fed) as _, follow: std::sync::Arc::clone(&fed) as _,
std::sync::Arc::clone(&fed) as _, actor: std::sync::Arc::clone(&fed) as _,
std::sync::Arc::clone(&fed) as _, blocklist: std::sync::Arc::clone(&fed) as _,
std::sync::Arc::clone(&fed) as _, admin_query: std::sync::Arc::clone(&fed) as _,
std::sync::Arc::clone(&fed) as _, review_store: std::sync::Arc::clone(&fed) as _,
fed as _, remote_watchlist: std::sync::Arc::clone(&fed) as _,
) follow_command: std::sync::Arc::clone(&fed) as _,
follow_query: fed as _,
}
} }
#[cfg(test)]
#[path = "tests/outbox_url.rs"]
mod outbox_url_tests;
#[cfg(test)] #[cfg(test)]
#[path = "tests/actor_block_tests.rs"] #[path = "tests/actor_block_tests.rs"]
mod actor_block_tests; mod actor_block_tests;

View File

@@ -1,5 +1,5 @@
use super::*; use super::*;
use k_ap::BlocklistRepository; use k_ap::ActorBlocklist;
use sqlx::SqlitePool; use sqlx::SqlitePool;
async fn test_pool() -> SqlitePool { async fn test_pool() -> SqlitePool {

View File

@@ -1,5 +1,5 @@
use super::*; use super::*;
use k_ap::BlocklistRepository; use k_ap::DomainBlocklist;
use sqlx::SqlitePool; use sqlx::SqlitePool;
async fn test_pool() -> SqlitePool { async fn test_pool() -> SqlitePool {

View File

@@ -1,7 +1,7 @@
use super::*; use super::*;
use chrono::Utc; use chrono::Utc;
use domain::ports::FederationAdminQuery; use domain::ports::FederationAdminQuery;
use k_ap::ActorRepository; use k_ap::AnnounceRepository;
use sqlx::SqlitePool; use sqlx::SqlitePool;
async fn test_pool() -> SqlitePool { async fn test_pool() -> SqlitePool {

View File

@@ -1,76 +0,0 @@
use super::*;
use k_ap::{FollowRepository, FollowingStatus, RemoteActor};
async fn setup_pool() -> SqlitePool {
let pool = SqlitePool::connect(":memory:").await.unwrap();
sqlx::query(
"CREATE TABLE ap_remote_actors (
url TEXT PRIMARY KEY, handle TEXT NOT NULL, inbox_url TEXT NOT NULL,
shared_inbox_url TEXT, display_name TEXT, avatar_url TEXT,
outbox_url TEXT, bio TEXT, banner_url TEXT, followers_url TEXT,
following_url TEXT, also_known_as TEXT, fetched_at TEXT NOT NULL
);
CREATE TABLE ap_following (
local_user_id TEXT NOT NULL, remote_actor_url TEXT NOT NULL,
follow_activity_id TEXT, created_at TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
PRIMARY KEY (local_user_id, remote_actor_url)
);",
)
.execute(&pool)
.await
.unwrap();
pool
}
#[tokio::test]
async fn get_following_outbox_url_returns_stored_url() {
let pool = setup_pool().await;
let repo = SqliteFederationRepository::new(pool);
let local_user = uuid::Uuid::new_v4();
let actor = RemoteActor {
url: "https://remote.example/users/alice".to_string(),
handle: "alice@remote.example".to_string(),
inbox_url: "https://remote.example/users/alice/inbox".to_string(),
shared_inbox_url: None,
display_name: None,
avatar_url: None,
outbox_url: Some("https://remote.example/users/alice/outbox".to_string()),
bio: None,
banner_url: None,
followers_url: None,
following_url: None,
also_known_as: vec![],
fetched_at: None,
};
repo.add_following(local_user, actor, "https://local/activities/1")
.await
.unwrap();
repo.update_following_status(
local_user,
"https://remote.example/users/alice",
FollowingStatus::Accepted,
)
.await
.unwrap();
let result = repo
.get_following_outbox_url(local_user, "https://remote.example/users/alice")
.await
.unwrap();
assert_eq!(
result,
Some("https://remote.example/users/alice/outbox".to_string())
);
}
#[tokio::test]
async fn get_following_outbox_url_returns_none_when_not_following() {
let pool = setup_pool().await;
let repo = SqliteFederationRepository::new(pool);
let result = repo
.get_following_outbox_url(uuid::Uuid::new_v4(), "https://remote.example/users/alice")
.await
.unwrap();
assert_eq!(result, None);
}

View File

@@ -264,6 +264,24 @@ pub(crate) struct MonthlyRatingRow {
pub count: i64, pub count: i64,
} }
#[derive(sqlx::FromRow)]
pub(crate) struct GenreCountRow {
pub genre: String,
pub count: i64,
}
#[derive(sqlx::FromRow)]
pub(crate) struct RatingDistRow {
pub rating: i64,
pub count: i64,
}
#[derive(sqlx::FromRow)]
pub(crate) struct WatchMediumCountRow {
pub watch_medium: String,
pub count: i64,
}
#[derive(sqlx::FromRow)] #[derive(sqlx::FromRow)]
pub(crate) struct WatchlistRow { pub(crate) struct WatchlistRow {
pub id: String, pub id: String,

View File

@@ -7,7 +7,10 @@ use domain::{
}; };
use sqlx::SqlitePool; use sqlx::SqlitePool;
use crate::models::{DirectorCountRow, MonthlyRatingRow, UserTotalsRow}; use crate::models::{
DirectorCountRow, GenreCountRow, MonthlyRatingRow, RatingDistRow, UserTotalsRow,
WatchMediumCountRow,
};
pub struct SqliteStatsRepository { pub struct SqliteStatsRepository {
pool: SqlitePool, pool: SqlitePool,
@@ -98,20 +101,21 @@ impl StatsRepository for SqliteStatsRepository {
async fn get_user_trends(&self, user_id: &UserId) -> Result<UserTrends, DomainError> { async fn get_user_trends(&self, user_id: &UserId) -> Result<UserTrends, DomainError> {
let uid = user_id.value().to_string(); let uid = user_id.value().to_string();
let (rating_rows, director_rows) = tokio::try_join!( let (rating_rows, director_rows, genre_rows, rating_dist_rows, medium_rows) =
sqlx::query_as::<_, MonthlyRatingRow>( tokio::try_join!(
"SELECT strftime('%Y-%m', watched_at) AS month, sqlx::query_as::<_, MonthlyRatingRow>(
"SELECT strftime('%Y-%m', watched_at) AS month,
AVG(CAST(rating AS REAL)) AS avg_rating, AVG(CAST(rating AS REAL)) AS avg_rating,
COUNT(*) AS count COUNT(*) AS count
FROM reviews FROM reviews
WHERE user_id = ? AND watched_at >= datetime('now', '-12 months') WHERE user_id = ? AND watched_at >= datetime('now', '-12 months')
GROUP BY month GROUP BY month
ORDER BY month ASC", ORDER BY month ASC",
) )
.bind(&uid) .bind(&uid)
.fetch_all(&self.pool), .fetch_all(&self.pool),
sqlx::query_as::<_, DirectorCountRow>( sqlx::query_as::<_, DirectorCountRow>(
"SELECT m.director, "SELECT m.director,
COUNT(*) AS count COUNT(*) AS count
FROM reviews r FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id INNER JOIN movies m ON m.id = r.movie_id
@@ -119,11 +123,40 @@ impl StatsRepository for SqliteStatsRepository {
GROUP BY m.director GROUP BY m.director
ORDER BY COUNT(*) DESC ORDER BY COUNT(*) DESC
LIMIT 5", LIMIT 5",
)
.bind(&uid)
.fetch_all(&self.pool),
sqlx::query_as::<_, GenreCountRow>(
"SELECT mg.name AS genre, COUNT(*) AS count
FROM reviews r
INNER JOIN movie_genres mg ON mg.movie_id = r.movie_id
WHERE r.user_id = ?
GROUP BY mg.name
ORDER BY COUNT(*) DESC
LIMIT 5",
)
.bind(&uid)
.fetch_all(&self.pool),
sqlx::query_as::<_, RatingDistRow>(
"SELECT rating, COUNT(*) AS count
FROM reviews
WHERE user_id = ?
GROUP BY rating
ORDER BY rating ASC",
)
.bind(&uid)
.fetch_all(&self.pool),
sqlx::query_as::<_, WatchMediumCountRow>(
"SELECT watch_medium, COUNT(*) AS count
FROM reviews
WHERE user_id = ? AND watch_medium IS NOT NULL
GROUP BY watch_medium
ORDER BY COUNT(*) DESC",
)
.bind(&uid)
.fetch_all(&self.pool)
) )
.bind(&uid) .map_err(adapter_common::map_sqlx_error)?;
.fetch_all(&self.pool)
)
.map_err(adapter_common::map_sqlx_error)?;
let max_director_count = director_rows.iter().map(|d| d.count).max().unwrap_or(1); let max_director_count = director_rows.iter().map(|d| d.count).max().unwrap_or(1);
@@ -145,10 +178,38 @@ impl StatsRepository for SqliteStatsRepository {
}) })
.collect(); .collect();
let top_genres = genre_rows
.into_iter()
.map(|g| domain::models::stats::GenreStat {
genre: g.genre,
count: g.count,
})
.collect();
let rating_distribution = {
let mut dist = [0i64; 5];
for r in &rating_dist_rows {
let idx = (r.rating as usize).saturating_sub(1).min(4);
dist[idx] = r.count;
}
dist
};
let watch_medium_distribution = medium_rows
.into_iter()
.map(|m| domain::models::stats::WatchMediumStat {
medium: m.watch_medium,
count: m.count,
})
.collect();
Ok(UserTrends { Ok(UserTrends {
monthly_ratings, monthly_ratings,
top_directors, top_directors,
max_director_count, max_director_count,
top_genres,
rating_distribution,
watch_medium_distribution,
}) })
} }
} }

View File

@@ -301,7 +301,7 @@ impl WrapUpStatsQuery for SqliteWrapUpStatsQuery {
let sql = format!( let sql = format!(
"SELECT r.movie_id, m.title, m.release_year, m.director, m.poster_path, \ "SELECT r.movie_id, m.title, m.release_year, m.director, m.poster_path, \
r.rating, r.watched_at, r.user_id, \ r.rating, r.watched_at, r.user_id, r.watch_medium, \
p.runtime_minutes, p.budget_usd, p.original_language \ p.runtime_minutes, p.budget_usd, p.original_language \
FROM reviews r \ FROM reviews r \
INNER JOIN movies m ON m.id = r.movie_id \ INNER JOIN movies m ON m.id = r.movie_id \
@@ -379,6 +379,9 @@ impl WrapUpStatsQuery for SqliteWrapUpStatsQuery {
let original_language: Option<String> = row let original_language: Option<String> = row
.try_get("original_language") .try_get("original_language")
.map_err(adapter_common::map_sqlx_error)?; .map_err(adapter_common::map_sqlx_error)?;
let watch_medium: Option<String> = row
.try_get("watch_medium")
.map_err(adapter_common::map_sqlx_error)?;
let genres = genres_map.get(&movie_id_str).cloned().unwrap_or_default(); let genres = genres_map.get(&movie_id_str).cloned().unwrap_or_default();
let keywords = keywords_map.get(&movie_id_str).cloned().unwrap_or_default(); let keywords = keywords_map.get(&movie_id_str).cloned().unwrap_or_default();
@@ -403,6 +406,7 @@ impl WrapUpStatsQuery for SqliteWrapUpStatsQuery {
runtime_minutes: runtime_minutes.map(|v| v as u32), runtime_minutes: runtime_minutes.map(|v| v as u32),
budget_usd, budget_usd,
original_language, original_language,
watch_medium,
genres, genres,
keywords, keywords,
cast_names, cast_names,

View File

@@ -60,11 +60,26 @@ pub struct DirectorStatDto {
pub count: i64, pub count: i64,
} }
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct GenreStatDto {
pub genre: String,
pub count: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct WatchMediumStatDto {
pub medium: String,
pub count: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct UserTrendsDto { pub struct UserTrendsDto {
pub monthly_ratings: Vec<MonthlyRatingDto>, pub monthly_ratings: Vec<MonthlyRatingDto>,
pub top_directors: Vec<DirectorStatDto>, pub top_directors: Vec<DirectorStatDto>,
pub max_director_count: i64, pub max_director_count: i64,
pub top_genres: Vec<GenreStatDto>,
pub rating_distribution: [i64; 5],
pub watch_medium_distribution: Vec<WatchMediumStatDto>,
} }
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]

View File

@@ -27,6 +27,7 @@ fn make_row(title: &str, rating: u8, watched_at: &str) -> WrapUpMovieRow {
keywords: vec!["heist".to_string()], keywords: vec!["heist".to_string()],
cast_names: vec![("Actor A".to_string(), 1, 12345)], cast_names: vec![("Actor A".to_string(), 1, 12345)],
cast_profile_paths: vec![None], cast_profile_paths: vec![None],
watch_medium: None,
} }
} }

View File

@@ -4,7 +4,7 @@ mod feed;
mod movie; mod movie;
mod refresh_session; mod refresh_session;
mod review; mod review;
mod stats; pub mod stats;
mod user; mod user;
pub mod collections; pub mod collections;
@@ -26,7 +26,7 @@ pub use federation::*;
pub use feed::*; pub use feed::*;
pub use movie::*; pub use movie::*;
pub use review::*; pub use review::*;
pub use stats::*; pub use stats::{DirectorStat, MonthActivity, MonthlyRating, MovieStats, UserStats, UserTrends};
pub use user::*; pub use user::*;
pub use goal::{Goal, GoalWithProgress}; pub use goal::{Goal, GoalWithProgress};

View File

@@ -30,11 +30,26 @@ pub struct DirectorStat {
pub count: i64, pub count: i64,
} }
#[derive(Clone, Debug)]
pub struct GenreStat {
pub genre: String,
pub count: i64,
}
#[derive(Clone, Debug)]
pub struct WatchMediumStat {
pub medium: String,
pub count: i64,
}
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct UserTrends { pub struct UserTrends {
pub monthly_ratings: Vec<MonthlyRating>, pub monthly_ratings: Vec<MonthlyRating>,
pub top_directors: Vec<DirectorStat>, pub top_directors: Vec<DirectorStat>,
pub max_director_count: i64, pub max_director_count: i64,
pub top_genres: Vec<GenreStat>,
pub rating_distribution: [i64; 5],
pub watch_medium_distribution: Vec<WatchMediumStat>,
} }
#[derive(Clone, Debug)] #[derive(Clone, Debug)]

View File

@@ -17,6 +17,7 @@ pub struct WrapUpMovieRow {
pub runtime_minutes: Option<u32>, pub runtime_minutes: Option<u32>,
pub budget_usd: Option<i64>, pub budget_usd: Option<i64>,
pub original_language: Option<String>, pub original_language: Option<String>,
pub watch_medium: Option<String>,
pub genres: Vec<String>, pub genres: Vec<String>,
pub keywords: Vec<String>, pub keywords: Vec<String>,
pub cast_names: Vec<(String, u32, i64)>, pub cast_names: Vec<(String, u32, i64)>,
@@ -95,6 +96,12 @@ pub struct LangStat {
pub count: u32, pub count: u32,
} }
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct WatchMediumStat {
pub medium: String,
pub count: u32,
}
#[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize)]
pub struct MonthCount { pub struct MonthCount {
pub year_month: String, pub year_month: String,
@@ -142,6 +149,7 @@ pub struct WrapUpReport {
pub total_budget_watched: Option<i64>, pub total_budget_watched: Option<i64>,
pub avg_budget: Option<i64>, pub avg_budget: Option<i64>,
pub language_distribution: Vec<LangStat>, pub language_distribution: Vec<LangStat>,
pub watch_medium_distribution: Vec<WatchMediumStat>,
pub oldest_movie: Option<MovieRef>, pub oldest_movie: Option<MovieRef>,
pub newest_movie: Option<MovieRef>, pub newest_movie: Option<MovieRef>,

View File

@@ -0,0 +1,80 @@
use async_trait::async_trait;
use crate::{
errors::DomainError,
value_objects::{FollowStatus, SocialActor},
};
#[async_trait]
pub trait FollowCommand: Send + Sync {
async fn add_follow(
&self,
follower_id: uuid::Uuid,
target_actor_url: &str,
status: FollowStatus,
) -> Result<(), DomainError>;
async fn update_follow_status(
&self,
follower_id: uuid::Uuid,
target_actor_url: &str,
status: FollowStatus,
) -> Result<(), DomainError>;
async fn remove_follow(
&self,
follower_id: uuid::Uuid,
target_actor_url: &str,
) -> Result<(), DomainError>;
async fn add_follower(
&self,
local_user_id: uuid::Uuid,
follower_actor_url: &str,
status: FollowStatus,
) -> Result<(), DomainError>;
async fn update_follower_status(
&self,
local_user_id: uuid::Uuid,
follower_actor_url: &str,
status: FollowStatus,
) -> Result<(), DomainError>;
async fn remove_follower_record(
&self,
local_user_id: uuid::Uuid,
follower_actor_url: &str,
) -> Result<(), DomainError>;
}
#[async_trait]
pub trait FollowQuery: Send + Sync {
async fn get_following(
&self,
user_id: uuid::Uuid,
base_url: &str,
) -> Result<Vec<SocialActor>, DomainError>;
async fn get_followers(
&self,
user_id: uuid::Uuid,
base_url: &str,
) -> Result<Vec<SocialActor>, DomainError>;
async fn get_pending_followers(
&self,
user_id: uuid::Uuid,
base_url: &str,
) -> Result<Vec<SocialActor>, DomainError>;
async fn count_following(&self, user_id: uuid::Uuid) -> Result<usize, DomainError>;
async fn count_followers(&self, user_id: uuid::Uuid) -> Result<usize, DomainError>;
async fn is_following(
&self,
follower_id: uuid::Uuid,
target_actor_url: &str,
) -> Result<bool, DomainError>;
}

View File

@@ -2,6 +2,7 @@ pub mod auth;
pub mod diary; pub mod diary;
pub mod events; pub mod events;
pub mod federated_profile; pub mod federated_profile;
pub mod follow;
pub mod goals; pub mod goals;
pub mod image_fetcher; pub mod image_fetcher;
pub mod images; pub mod images;
@@ -21,6 +22,7 @@ pub use auth::*;
pub use diary::*; pub use diary::*;
pub use events::*; pub use events::*;
pub use federated_profile::*; pub use federated_profile::*;
pub use follow::*;
pub use goals::*; pub use goals::*;
pub use image_fetcher::*; pub use image_fetcher::*;
pub use images::*; pub use images::*;

View File

@@ -23,6 +23,7 @@ fn row(title: &str, rating: u8, ym: &str) -> WrapUpMovieRow {
runtime_minutes: Some(100), runtime_minutes: Some(100),
budget_usd: None, budget_usd: None,
original_language: Some("en".to_string()), original_language: Some("en".to_string()),
watch_medium: None,
genres: vec!["Action".to_string()], genres: vec!["Action".to_string()],
keywords: vec![], keywords: vec![],
cast_names: vec![], cast_names: vec![],

View File

@@ -7,6 +7,8 @@ use crate::models::WrapUpMovieRow;
use crate::models::wrapup::*; use crate::models::wrapup::*;
use crate::models::{ExternalPersonId, PersonId}; use crate::models::{ExternalPersonId, PersonId};
const MIN_PERSON_COUNT: u32 = 2;
pub fn build_report( pub fn build_report(
scope: WrapUpScope, scope: WrapUpScope,
date_range: DateRange, date_range: DateRange,
@@ -53,6 +55,7 @@ pub fn build_report(
let (total_budget_watched, avg_budget) = compute_budget_stats(rows); let (total_budget_watched, avg_budget) = compute_budget_stats(rows);
let language_distribution = compute_language_stats(rows); let language_distribution = compute_language_stats(rows);
let watch_medium_distribution = compute_watch_medium_stats(rows);
let (total_rewatches, most_rewatched_movie, avg_rating_change_on_rewatch) = let (total_rewatches, most_rewatched_movie, avg_rating_change_on_rewatch) =
compute_rewatch_stats(rows); compute_rewatch_stats(rows);
@@ -91,6 +94,7 @@ pub fn build_report(
total_budget_watched, total_budget_watched,
avg_budget, avg_budget,
language_distribution, language_distribution,
watch_medium_distribution,
oldest_movie, oldest_movie,
newest_movie, newest_movie,
total_rewatches, total_rewatches,
@@ -226,6 +230,7 @@ fn compute_director_stats(rows: &[WrapUpMovieRow]) -> (Vec<PersonStat>, u32) {
} }
}) })
.collect(); .collect();
stats.retain(|s| s.count > MIN_PERSON_COUNT);
stats.sort_by(|a, b| { stats.sort_by(|a, b| {
b.count b.count
.cmp(&a.count) .cmp(&a.count)
@@ -270,6 +275,7 @@ fn compute_actor_stats(rows: &[WrapUpMovieRow]) -> (Vec<PersonStat>, u32, Vec<St
} }
}) })
.collect(); .collect();
stats.retain(|s| s.count > MIN_PERSON_COUNT);
stats.sort_by(|a, b| { stats.sort_by(|a, b| {
b.count b.count
.cmp(&a.count) .cmp(&a.count)
@@ -315,7 +321,7 @@ fn compute_genre_stats(
.map(|g| g.genre.clone()); .map(|g| g.genre.clone());
let lowest = stats let lowest = stats
.iter() .iter()
.filter(|g| g.count >= 3) .filter(|g| g.count > 3)
.min_by(|a, b| a.avg_rating.total_cmp(&b.avg_rating)) .min_by(|a, b| a.avg_rating.total_cmp(&b.avg_rating))
.map(|g| g.genre.clone()); .map(|g| g.genre.clone());
stats.truncate(5); stats.truncate(5);
@@ -367,6 +373,21 @@ fn compute_language_stats(rows: &[WrapUpMovieRow]) -> Vec<LangStat> {
stats stats
} }
fn compute_watch_medium_stats(rows: &[WrapUpMovieRow]) -> Vec<WatchMediumStat> {
let mut counts: HashMap<String, u32> = HashMap::new();
for r in rows {
if let Some(ref medium) = r.watch_medium {
*counts.entry(medium.clone()).or_default() += 1;
}
}
let mut stats: Vec<WatchMediumStat> = counts
.into_iter()
.map(|(medium, count)| WatchMediumStat { medium, count })
.collect();
stats.sort_by_key(|s| std::cmp::Reverse(s.count));
stats
}
fn compute_rewatch_stats(rows: &[WrapUpMovieRow]) -> (u32, Option<MovieRef>, Option<f64>) { fn compute_rewatch_stats(rows: &[WrapUpMovieRow]) -> (u32, Option<MovieRef>, Option<f64>) {
let mut movie_reviews: HashMap<Uuid, Vec<&WrapUpMovieRow>> = HashMap::new(); let mut movie_reviews: HashMap<Uuid, Vec<&WrapUpMovieRow>> = HashMap::new();
for r in rows { for r in rows {

View File

@@ -226,6 +226,9 @@ impl StatsRepository for FakeStatsRepository {
monthly_ratings: vec![], monthly_ratings: vec![],
top_directors: vec![], top_directors: vec![],
max_director_count: 0, max_director_count: 0,
top_genres: vec![],
rating_distribution: [0; 5],
watch_medium_distribution: vec![],
}) })
} }

View File

@@ -904,12 +904,12 @@ impl SocialCommand for InMemorySocialRepository {
actor_url: h.clone(), actor_url: h.clone(),
}, },
}; };
if let SocialIdentity::Local(target_id) = &identity { if let SocialIdentity::Local(target_id) = &identity
if follower == target_id { && follower == target_id
return Err(DomainError::ValidationError( {
"Cannot follow yourself".into(), return Err(DomainError::ValidationError(
)); "Cannot follow yourself".into(),
} ));
} }
let mut store = self.follows.lock().unwrap(); let mut store = self.follows.lock().unwrap();
let already = store let already = store

View File

@@ -7,6 +7,18 @@ pub enum SocialIdentity {
} }
impl SocialIdentity { impl SocialIdentity {
pub fn from_actor_url(actor_url: &str, base_url: &str) -> Self {
let prefix = format!("{}/users/", base_url);
if let Some(uuid_str) = actor_url.strip_prefix(&prefix)
&& let Ok(uuid) = uuid::Uuid::parse_str(uuid_str)
{
return Self::Local(UserId::from_uuid(uuid));
}
Self::Remote {
actor_url: actor_url.to_string(),
}
}
pub fn is_local(&self) -> bool { pub fn is_local(&self) -> bool {
matches!(self, Self::Local(_)) matches!(self, Self::Local(_))
} }
@@ -14,6 +26,26 @@ impl SocialIdentity {
pub fn is_remote(&self) -> bool { pub fn is_remote(&self) -> bool {
matches!(self, Self::Remote { .. }) matches!(self, Self::Remote { .. })
} }
pub fn format_local_handle(username: &str, base_url: &str) -> String {
let host = Self::host_from_base_url(base_url);
format!("@{}@{}", username, host)
}
pub fn host_from_base_url(base_url: &str) -> &str {
base_url
.split("://")
.nth(1)
.and_then(|s| s.split('/').next())
.unwrap_or("localhost")
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FollowStatus {
Pending,
Accepted,
Rejected,
} }
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]

View File

@@ -19,8 +19,10 @@ impl AppConfig {
let allow_registration = std::env::var("ALLOW_REGISTRATION") let allow_registration = std::env::var("ALLOW_REGISTRATION")
.map(|v| v == "true" || v == "1") .map(|v| v == "true" || v == "1")
.unwrap_or(false); .unwrap_or(false);
let base_url = let base_url = std::env::var("BASE_URL")
std::env::var("BASE_URL").unwrap_or_else(|_| "http://localhost:3000".to_string()); .unwrap_or_else(|_| "http://localhost:3000".to_string())
.trim_end_matches('/')
.to_string();
let rate_limit = std::env::var("RATE_LIMIT") let rate_limit = std::env::var("RATE_LIMIT")
.ok() .ok()
.and_then(|v| v.parse().ok()) .and_then(|v| v.parse().ok())

View File

@@ -312,8 +312,7 @@ pub async fn get_activity_feed_html(
let limit = params.limit.unwrap_or(20); let limit = params.limit.unwrap_or(20);
let offset = params.offset.unwrap_or(0); let offset = params.offset.unwrap_or(0);
let filter_following = let filter_following = params.filter == "following" && user_id.is_some();
cfg!(feature = "federation") && params.filter == "following" && user_id.is_some();
let filter_str = if filter_following { "following" } else { "all" }; let filter_str = if filter_following { "following" } else { "all" };
let sort_by_str = match params.sort_by.as_str() { let sort_by_str = match params.sort_by.as_str() {

View File

@@ -189,9 +189,7 @@ pub async fn block_actor_api(
&deps, &deps,
application::social::commands::SocialCmd::Block { application::social::commands::SocialCmd::Block {
blocker_id: user.0.value(), blocker_id: user.0.value(),
target: SocialIdentity::Remote { target: SocialIdentity::from_actor_url(&body.actor_url, &state.app_ctx.config.base_url),
actor_url: body.actor_url,
},
}, },
) )
.await?; .await?;
@@ -217,9 +215,7 @@ pub async fn unblock_actor_api(
&deps, &deps,
application::social::commands::SocialCmd::Unblock { application::social::commands::SocialCmd::Unblock {
blocker_id: user.0.value(), blocker_id: user.0.value(),
target: SocialIdentity::Remote { target: SocialIdentity::from_actor_url(&body.actor_url, &state.app_ctx.config.base_url),
actor_url: body.actor_url,
},
}, },
) )
.await?; .await?;
@@ -381,9 +377,7 @@ pub async fn unfollow(
&deps, &deps,
application::social::commands::SocialCmd::Unfollow { application::social::commands::SocialCmd::Unfollow {
follower_id: user.0.value(), follower_id: user.0.value(),
target: SocialIdentity::Remote { target: SocialIdentity::from_actor_url(&body.actor_url, &state.app_ctx.config.base_url),
actor_url: body.actor_url,
},
}, },
) )
.await?; .await?;
@@ -409,9 +403,10 @@ pub async fn accept_follower(
&deps, &deps,
application::social::commands::SocialCmd::AcceptFollow { application::social::commands::SocialCmd::AcceptFollow {
owner_id: user.0.value(), owner_id: user.0.value(),
requester: SocialIdentity::Remote { requester: SocialIdentity::from_actor_url(
actor_url: body.actor_url, &body.actor_url,
}, &state.app_ctx.config.base_url,
),
}, },
) )
.await?; .await?;
@@ -437,9 +432,10 @@ pub async fn reject_follower(
&deps, &deps,
application::social::commands::SocialCmd::RejectFollow { application::social::commands::SocialCmd::RejectFollow {
owner_id: user.0.value(), owner_id: user.0.value(),
requester: SocialIdentity::Remote { requester: SocialIdentity::from_actor_url(
actor_url: body.actor_url, &body.actor_url,
}, &state.app_ctx.config.base_url,
),
}, },
) )
.await?; .await?;
@@ -465,9 +461,10 @@ pub async fn remove_follower(
&deps, &deps,
application::social::commands::SocialCmd::RemoveFollower { application::social::commands::SocialCmd::RemoveFollower {
owner_id: user.0.value(), owner_id: user.0.value(),
follower: SocialIdentity::Remote { follower: SocialIdentity::from_actor_url(
actor_url: body.actor_url, &body.actor_url,
}, &state.app_ctx.config.base_url,
),
}, },
) )
.await?; .await?;
@@ -563,9 +560,7 @@ pub async fn unfollow_remote_user(
&deps, &deps,
application::social::commands::SocialCmd::Unfollow { application::social::commands::SocialCmd::Unfollow {
follower_id: user_id.value(), follower_id: user_id.value(),
target: SocialIdentity::Remote { target: SocialIdentity::from_actor_url(&form.actor_url, &state.app_ctx.config.base_url),
actor_url: form.actor_url,
},
}, },
) )
.await .await
@@ -602,9 +597,10 @@ pub async fn accept_follower_html(
&deps, &deps,
application::social::commands::SocialCmd::AcceptFollow { application::social::commands::SocialCmd::AcceptFollow {
owner_id: user_id.value(), owner_id: user_id.value(),
requester: SocialIdentity::Remote { requester: SocialIdentity::from_actor_url(
actor_url: form.actor_url, &form.actor_url,
}, &state.app_ctx.config.base_url,
),
}, },
) )
.await .await
@@ -635,9 +631,10 @@ pub async fn reject_follower_html(
&deps, &deps,
application::social::commands::SocialCmd::RejectFollow { application::social::commands::SocialCmd::RejectFollow {
owner_id: user_id.value(), owner_id: user_id.value(),
requester: SocialIdentity::Remote { requester: SocialIdentity::from_actor_url(
actor_url: form.actor_url, &form.actor_url,
}, &state.app_ctx.config.base_url,
),
}, },
) )
.await .await
@@ -832,9 +829,10 @@ pub async fn remove_follower_html(
&deps, &deps,
application::social::commands::SocialCmd::RemoveFollower { application::social::commands::SocialCmd::RemoveFollower {
owner_id: user_id.value(), owner_id: user_id.value(),
follower: SocialIdentity::Remote { follower: SocialIdentity::from_actor_url(
actor_url: form.actor_url, &form.actor_url,
}, &state.app_ctx.config.base_url,
),
}, },
) )
.await .await
@@ -1001,9 +999,7 @@ pub async fn post_block_actor_html(
&deps, &deps,
application::social::commands::SocialCmd::Block { application::social::commands::SocialCmd::Block {
blocker_id: user_id.value(), blocker_id: user_id.value(),
target: SocialIdentity::Remote { target: SocialIdentity::from_actor_url(&form.actor_url, &state.app_ctx.config.base_url),
actor_url: form.actor_url,
},
}, },
) )
.await .await
@@ -1030,9 +1026,7 @@ pub async fn post_unblock_actor(
&deps, &deps,
application::social::commands::SocialCmd::Unblock { application::social::commands::SocialCmd::Unblock {
blocker_id: user_id.value(), blocker_id: user_id.value(),
target: SocialIdentity::Remote { target: SocialIdentity::from_actor_url(&form.actor_url, &state.app_ctx.config.base_url),
actor_url: form.actor_url,
},
}, },
) )
.await .await

View File

@@ -24,9 +24,9 @@ use crate::{
state::AppState, state::AppState,
}; };
use api_types::{ use api_types::{
DiaryResponse, DirectorStatDto, MonthActivityDto, MonthlyRatingDto, ProfileResponse, DiaryResponse, DirectorStatDto, GenreStatDto, MonthActivityDto, MonthlyRatingDto,
UserProfileQueryParams, UserProfileResponse, UserStatsDto, UserSummaryDto, UserTrendsDto, ProfileResponse, UserProfileQueryParams, UserProfileResponse, UserStatsDto, UserSummaryDto,
UsersResponse, UserTrendsDto, UsersResponse, WatchMediumStatDto,
}; };
use template_askama::{ use template_askama::{
EmbedProfileTemplate, MonthlyRatingRow, ProfileSettingsTemplate, ProfileTemplate, EmbedProfileTemplate, MonthlyRatingRow, ProfileSettingsTemplate, ProfileTemplate,
@@ -297,32 +297,10 @@ pub async fn get_user_profile(
}) })
.collect(), .collect(),
}) })
} else if let Some(t) = profile.trends {
Some(api_types::ProfileViewData::Trends {
trends: UserTrendsDto {
monthly_ratings: t
.monthly_ratings
.into_iter()
.map(|r| MonthlyRatingDto {
year_month: r.year_month,
month_label: r.month_label,
avg_rating: r.avg_rating,
count: r.count,
})
.collect(),
top_directors: t
.top_directors
.into_iter()
.map(|d| DirectorStatDto {
director: d.director,
count: d.count,
})
.collect(),
max_director_count: t.max_director_count,
},
})
} else { } else {
None profile.trends.map(|t| api_types::ProfileViewData::Trends {
trends: trends_to_dto(t),
})
}; };
Json(UserProfileResponse { Json(UserProfileResponse {
@@ -414,32 +392,10 @@ async fn build_federated_profile_response(
offset: p.offset, offset: p.offset,
}, },
}) })
} else if let Some(t) = profile.trends {
Some(api_types::ProfileViewData::Trends {
trends: UserTrendsDto {
monthly_ratings: t
.monthly_ratings
.into_iter()
.map(|r| MonthlyRatingDto {
year_month: r.year_month,
month_label: r.month_label,
avg_rating: r.avg_rating,
count: r.count,
})
.collect(),
top_directors: t
.top_directors
.into_iter()
.map(|d| DirectorStatDto {
director: d.director,
count: d.count,
})
.collect(),
max_director_count: t.max_director_count,
},
})
} else { } else {
None profile.trends.map(|t| api_types::ProfileViewData::Trends {
trends: trends_to_dto(t),
})
}; };
let username = fed let username = fed
@@ -473,6 +429,47 @@ async fn build_federated_profile_response(
.into_response() .into_response()
} }
fn trends_to_dto(t: domain::models::UserTrends) -> UserTrendsDto {
UserTrendsDto {
monthly_ratings: t
.monthly_ratings
.into_iter()
.map(|r| MonthlyRatingDto {
year_month: r.year_month,
month_label: r.month_label,
avg_rating: r.avg_rating,
count: r.count,
})
.collect(),
top_directors: t
.top_directors
.into_iter()
.map(|d| DirectorStatDto {
director: d.director,
count: d.count,
})
.collect(),
max_director_count: t.max_director_count,
top_genres: t
.top_genres
.into_iter()
.map(|g| GenreStatDto {
genre: g.genre,
count: g.count,
})
.collect(),
rating_distribution: t.rating_distribution,
watch_medium_distribution: t
.watch_medium_distribution
.into_iter()
.map(|m| WatchMediumStatDto {
medium: m.medium,
count: m.count,
})
.collect(),
}
}
// ── HTML ───────────────────────────────────────────────────────────────────── // ── HTML ─────────────────────────────────────────────────────────────────────
pub async fn get_users_list( pub async fn get_users_list(

View File

@@ -75,15 +75,7 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
social_command_arc, social_command_arc,
social_query_unified_arc, social_query_unified_arc,
) = { ) = {
let ( let fed_repos = match &db_pool {
activity_repo,
follow_repo,
actor_repo,
blocklist_repo,
social_query_arc,
review_store,
remote_watchlist_repo,
) = match &db_pool {
#[cfg(feature = "postgres-federation")] #[cfg(feature = "postgres-federation")]
factory::DbPool::Postgres(pool) => postgres_federation::wire(pool.clone()), factory::DbPool::Postgres(pool) => postgres_federation::wire(pool.clone()),
#[cfg(feature = "sqlite-federation")] #[cfg(feature = "sqlite-federation")]
@@ -97,12 +89,12 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
let ep = create_event_publisher(event_bus, &db_pool).await?; let ep = create_event_publisher(event_bus, &db_pool).await?;
let ap = activitypub::wire(activitypub::ActivityPubDeps { let ap = activitypub::wire(activitypub::ActivityPubDeps {
activity_repo, activity_repo: fed_repos.activity,
follow_repo, follow_repo: fed_repos.follow,
actor_repo, actor_repo: fed_repos.actor,
blocklist_repo, blocklist_repo: fed_repos.blocklist,
review_store, review_store: fed_repos.review_store,
remote_watchlist_repo: remote_watchlist_repo.clone(), remote_watchlist_repo: fed_repos.remote_watchlist.clone(),
remote_goal_repo: Arc::clone(&db.remote_goal), remote_goal_repo: Arc::clone(&db.remote_goal),
local_ap_content: Arc::clone(&ap_content_repo), local_ap_content: Arc::clone(&ap_content_repo),
movie_repo: Arc::clone(&db.movie_query), movie_repo: Arc::clone(&db.movie_query),
@@ -112,6 +104,8 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
stats_repo: Arc::clone(&db.stats), stats_repo: Arc::clone(&db.stats),
user_repo: Arc::clone(&db.user), user_repo: Arc::clone(&db.user),
federation_settings: std::sync::Arc::clone(&db.federation_settings), federation_settings: std::sync::Arc::clone(&db.federation_settings),
follow_command: Arc::clone(&fed_repos.follow_command),
follow_query: Arc::clone(&fed_repos.follow_query),
base_url: app_config.base_url.clone(), base_url: app_config.base_url.clone(),
allow_registration: app_config.allow_registration, allow_registration: app_config.allow_registration,
event_publisher: Arc::clone(&ep), event_publisher: Arc::clone(&ep),
@@ -123,6 +117,8 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
let composite_social = Arc::new(activitypub::CompositeSocialAdapter::new( let composite_social = Arc::new(activitypub::CompositeSocialAdapter::new(
Arc::clone(&ap_service_arc), Arc::clone(&ap_service_arc),
Arc::clone(&db.user), Arc::clone(&db.user),
fed_repos.follow_command,
fed_repos.follow_query,
app_config.base_url.clone(), app_config.base_url.clone(),
)); ));
@@ -130,8 +126,8 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
ep, ep,
ap_router, ap_router,
ap_service_arc, ap_service_arc,
social_query_arc, fed_repos.admin_query,
remote_watchlist_repo, fed_repos.remote_watchlist,
composite_social.clone() as Arc<dyn domain::ports::SocialCommand>, composite_social.clone() as Arc<dyn domain::ports::SocialCommand>,
composite_social as Arc<dyn domain::ports::SocialQuery>, composite_social as Arc<dyn domain::ports::SocialQuery>,
) )

View File

@@ -61,15 +61,7 @@ async fn main() -> anyhow::Result<()> {
); );
// Wire federation repos early to get remote_watchlist_repo for AppContext. // Wire federation repos early to get remote_watchlist_repo for AppContext.
#[cfg(feature = "federation")] #[cfg(feature = "federation")]
let ( let fed_repos = match &db.db_pool {
fed_activity_repo,
fed_follow_repo,
fed_actor_repo,
fed_blocklist_repo,
_fed_social_query,
fed_review_store,
fed_remote_watchlist_repo,
) = match &db.db_pool {
#[cfg(feature = "sqlite-federation")] #[cfg(feature = "sqlite-federation")]
db::DbPool::Sqlite(pool) => sqlite_federation::wire(pool.clone()), db::DbPool::Sqlite(pool) => sqlite_federation::wire(pool.clone()),
#[cfg(feature = "postgres-federation")] #[cfg(feature = "postgres-federation")]
@@ -244,12 +236,12 @@ async fn main() -> anyhow::Result<()> {
#[cfg(feature = "federation")] #[cfg(feature = "federation")]
{ {
let ap_wire = activitypub::wire(activitypub::ActivityPubDeps { let ap_wire = activitypub::wire(activitypub::ActivityPubDeps {
activity_repo: fed_activity_repo, activity_repo: fed_repos.activity,
follow_repo: fed_follow_repo, follow_repo: fed_repos.follow,
actor_repo: fed_actor_repo, actor_repo: fed_repos.actor,
blocklist_repo: fed_blocklist_repo, blocklist_repo: fed_repos.blocklist,
review_store: fed_review_store, review_store: fed_repos.review_store,
remote_watchlist_repo: fed_remote_watchlist_repo, remote_watchlist_repo: fed_repos.remote_watchlist,
remote_goal_repo: Arc::clone(&remote_goal), remote_goal_repo: Arc::clone(&remote_goal),
local_ap_content: fed_ap_content, local_ap_content: fed_ap_content,
movie_repo: fed_movie_repo, movie_repo: fed_movie_repo,
@@ -258,6 +250,8 @@ async fn main() -> anyhow::Result<()> {
goal_repo: fed_goal_repo, goal_repo: fed_goal_repo,
stats_repo: fed_stats_repo, stats_repo: fed_stats_repo,
user_repo: fed_user_repo, user_repo: fed_user_repo,
follow_command: fed_repos.follow_command,
follow_query: fed_repos.follow_query,
base_url, base_url,
allow_registration, allow_registration,
event_publisher: Arc::clone(&event_publisher), event_publisher: Arc::clone(&event_publisher),

View File

@@ -0,0 +1,20 @@
# Local follows bypass ActivityPub, share storage
ADR-0002 introduced `SocialIdentity` so the domain never branches on local vs remote, but the adapter (`CompositeSocialAdapter`) still routed everything through k_ap — meaning a local user following another local user triggered WebFinger resolution, HTTP signature verification, and inbox delivery to the same instance. Wasteful on any hardware, unacceptable on an N100.
## Decision
**Commands branch in the adapter, queries don't.**
- `SocialCommand` methods in `CompositeSocialAdapter` check the `SocialIdentity` variant. Local targets get direct SQL writes to the `ap_followers`/`ap_following` tables (via a domain `FollowRepository` port). Remote targets delegate to `k_ap::ActivityPubService` as before.
- `SocialQuery` methods go through the domain port only — a single SQL query that left-joins `ap_followers`/`ap_following` against both `users` (local) and `ap_remote_actors` (remote), returning `SocialActor` directly.
- Local follows store the full actor URL (`https://instance.example/users/{uuid}`) in `remote_actor_url`, same format as remote follows. k_ap's AP collection endpoints read from these tables unchanged, so local relationships are visible to the fediverse automatically.
- Local user metadata (display name, avatar) is resolved from the `users` table at query time — no duplication into `ap_remote_actors`.
- Follow acceptance is required for both local and remote — no behavioral divergence.
- Local follow events (`FollowRequested`, `FollowAccepted`) are not broadcast as AP activities. The fediverse discovers local relationships passively via collection endpoints.
## Considered Options
- **Keep routing local through k_ap** — rejected because it wastes CPU/network on self-delivery and creates an unnecessary runtime dependency on federation for local social features.
- **Separate `local_follows` table** — rejected because it creates two sources of truth for the same concept and requires merging in collection endpoints.
- **Insert local users into `ap_remote_actors`** — rejected because it duplicates profile data and requires sync when local users update their profile.

View File

@@ -327,3 +327,31 @@ body > #root {
.aero-star-filled { .aero-star-filled {
filter: drop-shadow(0 0 4px var(--aero-primary-glow)) drop-shadow(0 0 1px var(--aero-primary)); filter: drop-shadow(0 0 4px var(--aero-primary-glow)) drop-shadow(0 0 1px var(--aero-primary));
} }
/* ── Keyframe animations ────────────────────────────────────── */
@media (prefers-reduced-motion: no-preference) {
@keyframes aero-page-in {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.aero-page-in {
animation: aero-page-in 0.25s ease-out both;
}
@keyframes aero-star-pop {
0% { transform: scale(1); }
40% { transform: scale(1.35); }
100% { transform: scale(1); }
}
.aero-star-pop {
animation: aero-star-pop 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) both;
}
.aero-poster-hover {
transition: transform 0.2s ease-out;
}
.aero-poster-hover:hover {
transform: scale(1.04);
}
}

View File

@@ -0,0 +1,66 @@
import type { LucideIcon } from "lucide-react"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Card, CardContent } from "@/components/ui/card"
import { Skeleton } from "@/components/ui/skeleton"
import { EmptyState } from "@/components/empty-state"
import type { ActorListResponse, RemoteActorDto } from "@/features/social"
type ActorListProps = {
data: ActorListResponse | undefined
isPending: boolean
emptyIcon: LucideIcon
emptyTitle: string
emptyDescription?: string
renderAction?: (actor: RemoteActorDto) => React.ReactNode
}
export function ActorList({ data, isPending, emptyIcon, emptyTitle, emptyDescription, renderAction }: ActorListProps) {
if (isPending) return <ListSkeleton />
if (!data?.actors.length) return <EmptyState icon={emptyIcon} title={emptyTitle} description={emptyDescription} />
return (
<div className="space-y-2">
{data.actors.map((actor) => (
<ActorCard key={actor.url} actor={actor} action={renderAction?.(actor)} />
))}
</div>
)
}
function actorHandle(actor: RemoteActorDto): string {
try {
const host = new URL(actor.url).host
return `@${actor.handle}@${host}`
} catch {
return `@${actor.handle}`
}
}
function ActorCard({ actor, action }: { actor: RemoteActorDto; action?: React.ReactNode }) {
const initial = (actor.display_name || actor.handle)[0]?.toUpperCase() ?? "?"
return (
<Card size="sm">
<CardContent className="flex items-center gap-3">
<Avatar>
<AvatarFallback>{initial}</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold">{actor.display_name || actor.handle}</p>
<p className="truncate text-xs text-muted-foreground">{actorHandle(actor)}</p>
</div>
{action}
</CardContent>
</Card>
)
}
function ListSkeleton() {
return (
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-16 w-full rounded-xl" />
))}
</div>
)
}

View File

@@ -0,0 +1,54 @@
import { useTranslation } from "react-i18next"
import { Globe, Users } from "lucide-react"
import { StarDisplay } from "@/components/star-display"
import { WatchMediumBadge } from "@/components/watch-medium-badge"
import { EmptyState } from "@/components/empty-state"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
import { timeAgo } from "@/lib/date"
import type { SocialReviewDto } from "@/features/movies"
export function CommunityReviews({ reviews, onShowDetail }: { reviews: { items: SocialReviewDto[] }; onShowDetail?: (review: SocialReviewDto) => void }) {
const { t } = useTranslation()
return (
<section>
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">{t("movie.community")}</h3>
{!reviews.items.length ? (
<EmptyState icon={Users} title={t("movie.noReviews")} description={t("movie.beFirst")} />
) : (
<div className="space-y-2">
{reviews.items.map((r, i) => (
<Card key={i} size="sm">
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-1.5 text-sm">
{r.user_display}
{r.is_federated && <Globe className="size-3 text-muted-foreground/60" />}
</CardTitle>
<CardDescription className="text-[10px]">{timeAgo(r.watched_at)}</CardDescription>
</div>
<div className="flex items-center gap-1.5">
<StarDisplay rating={r.rating} size="xs" />
{r.watch_medium && <WatchMediumBadge medium={r.watch_medium} />}
</div>
</div>
</CardHeader>
{r.comment && (
<CardContent>
<p
className="text-xs text-muted-foreground"
role={onShowDetail ? "button" : undefined}
tabIndex={onShowDetail ? 0 : undefined}
onClick={onShowDetail ? () => onShowDetail(r) : undefined}
onKeyDown={onShowDetail ? (e) => e.key === "Enter" && onShowDetail(r) : undefined}
>{r.comment}</p>
</CardContent>
)}
</Card>
))}
</div>
)}
</section>
)
}

View File

@@ -0,0 +1,216 @@
import { useMemo, useState } from "react"
import { useTranslation } from "react-i18next"
import { ChevronLeft, ChevronRight } from "lucide-react"
import { VisuallyHidden } from "radix-ui"
import {
startOfMonth,
endOfMonth,
startOfWeek,
endOfWeek,
addMonths,
subMonths,
eachDayOfInterval,
isSameMonth,
isToday,
format,
} from "date-fns"
import { Button } from "@/components/ui/button"
import { Drawer, DrawerContent, DrawerTitle } from "@/components/ui/drawer"
import { StarDisplay } from "@/components/star-display"
import { WATCH_MEDIUMS } from "@/lib/watch-mediums"
import { posterUrl } from "@/lib/api/client"
import { shortDate } from "@/lib/date"
import type { DiaryEntryDto } from "@/lib/api/common"
type DiaryCalendarProps = {
entries: DiaryEntryDto[]
onSelectEntry?: (entry: DiaryEntryDto) => void
}
const WEEKDAYS = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"]
export function DiaryCalendar({ entries, onSelectEntry }: DiaryCalendarProps) {
const [month, setMonth] = useState(() => startOfMonth(new Date()))
const [dayEntries, setDayEntries] = useState<DiaryEntryDto[] | null>(null)
const byDate = useMemo(() => {
const map = new Map<string, DiaryEntryDto[]>()
for (const e of entries) {
const key = e.review.watched_at.slice(0, 10)
const list = map.get(key)
if (list) list.push(e)
else map.set(key, [e])
}
return map
}, [entries])
const days = useMemo(() => {
const start = startOfWeek(startOfMonth(month))
const end = endOfWeek(endOfMonth(month))
return eachDayOfInterval({ start, end })
}, [month])
function handleDayClick(clicked: DiaryEntryDto[]) {
if (clicked.length === 1) {
onSelectEntry?.(clicked[0]!)
} else {
setDayEntries(clicked)
}
}
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Button
variant="ghost"
size="icon"
onClick={() => setMonth((m) => subMonths(m, 1))}
>
<ChevronLeft className="size-4" />
</Button>
<span className="text-sm font-medium">
{format(month, "MMMM yyyy")}
</span>
<Button
variant="ghost"
size="icon"
onClick={() => setMonth((m) => addMonths(m, 1))}
>
<ChevronRight className="size-4" />
</Button>
</div>
<div className="grid grid-cols-7 gap-px">
{WEEKDAYS.map((d) => (
<div
key={d}
className="py-1 text-center text-xs font-medium text-muted-foreground"
>
{d}
</div>
))}
{days.map((day) => {
const key = format(day, "yyyy-MM-dd")
const de = byDate.get(key)
const inMonth = isSameMonth(day, month)
const today = isToday(day)
const poster = de?.[0]?.movie.poster_path
return (
<Button
key={key}
variant="ghost"
disabled={!de?.length}
onClick={() => de?.length && handleDayClick(de)}
className={`relative flex h-auto min-h-12 flex-col items-center gap-0.5 rounded-lg p-1 text-xs ${
!inMonth ? "opacity-30" : ""
} ${today ? "ring-1 ring-primary/50" : ""}`}
>
<span
className={
de?.length
? "font-semibold text-primary"
: "text-muted-foreground"
}
>
{day.getDate()}
</span>
{poster && (
<img
src={posterUrl(poster)}
alt=""
className="aspect-2/3 w-7 rounded-sm object-cover"
/>
)}
{de && de.length > 1 && (
<span className="absolute top-0.5 right-0.5 flex size-4 items-center justify-center rounded-full bg-primary text-[9px] font-bold text-primary-foreground">
{de.length}
</span>
)}
{de && de.length === 1 && !poster && (
<span className="size-1.5 rounded-full bg-primary" />
)}
</Button>
)
})}
</div>
<DayDrawer
entries={dayEntries}
onClose={() => setDayEntries(null)}
onSelect={(e) => {
setDayEntries(null)
onSelectEntry?.(e)
}}
/>
</div>
)
}
function DayDrawer({
entries,
onClose,
onSelect,
}: {
entries: DiaryEntryDto[] | null
onClose: () => void
onSelect: (entry: DiaryEntryDto) => void
}) {
const { t } = useTranslation()
if (!entries) return null
const dateLabel = shortDate(entries[0]!.review.watched_at)
return (
<Drawer open onOpenChange={(open) => !open && onClose()}>
<DrawerContent className="mx-auto max-w-lg">
<VisuallyHidden.Root>
<DrawerTitle>{dateLabel}</DrawerTitle>
</VisuallyHidden.Root>
<div className="p-4 pb-8">
<p className="mb-3 text-sm font-semibold">
{dateLabel} &middot; {t("common.films", { count: entries.length })}
</p>
<div className="space-y-2">
{entries.map((e) => (
<Button
key={e.review.id}
variant="ghost"
onClick={() => onSelect(e)}
className="flex h-auto w-full items-center justify-start gap-3 rounded-xl p-2"
>
<div className="h-16 w-11 shrink-0 overflow-hidden rounded-lg bg-muted">
{e.movie.poster_path && (
<img
src={posterUrl(e.movie.poster_path)}
alt=""
className="size-full object-cover"
/>
)}
</div>
<div className="min-w-0 flex-1 text-left">
<p className="truncate text-sm font-semibold">
{e.movie.title}
</p>
<div className="mt-0.5 flex items-center gap-1.5">
<StarDisplay rating={e.review.rating} size="xs" />
{e.review.watch_medium && (() => {
const Icon = WATCH_MEDIUMS.find((d) => d.value === e.review.watch_medium)?.icon
return Icon ? <Icon className="size-3.5 text-muted-foreground" /> : null
})()}
</div>
{e.review.comment && (
<p className="mt-0.5 truncate text-xs text-muted-foreground">
{e.review.comment}
</p>
)}
</div>
</Button>
))}
</div>
</div>
</DrawerContent>
</Drawer>
)
}

View File

@@ -1,112 +0,0 @@
import { useState } from "react"
import { useTranslation } from "react-i18next"
import { VisuallyHidden } from "radix-ui"
import { Drawer, DrawerContent, DrawerTitle } from "@/components/ui/drawer"
import { Button } from "@/components/ui/button"
import { ReviewFormFields } from "@/components/review-form-fields"
import { useEditReview } from "@/hooks/use-diary"
import { toast } from "sonner"
import { posterUrl } from "@/lib/api/client"
import { hapticMedium } from "@/lib/haptics"
import type { EditReviewRequest } from "@/lib/api/diary"
import type { MovieDto, ReviewDto } from "@/lib/api/common"
type EditReviewSheetProps = {
open: boolean
onOpenChange: (open: boolean) => void
movie: MovieDto
review: ReviewDto
}
function parseLocalDate(s: string): Date {
const [datePart, timePart] = s.split("T")
if (!datePart) return new Date()
const [y, m, d] = datePart.split("-").map(Number)
if (timePart) {
const [h, min, sec] = timePart.split(":").map(Number)
return new Date(y!, m! - 1, d!, h, min, sec)
}
return new Date(y!, m! - 1, d!)
}
function formatLocalDateTime(d: Date): string {
const pad = (n: number) => n.toString().padStart(2, "0")
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
}
export function EditReviewSheet({ open, onOpenChange, movie, review }: EditReviewSheetProps) {
const { t } = useTranslation()
const [rating, setRating] = useState(review.rating)
const [comment, setComment] = useState(review.comment ?? "")
const [watchedAt, setWatchedAt] = useState<Date>(() => parseLocalDate(review.watched_at))
const [dateChanged, setDateChanged] = useState(false)
const [watchMedium, setWatchMedium] = useState<string | undefined>(review.watch_medium)
const editMutation = useEditReview()
function handleDateChange(d: Date) {
setWatchedAt(d)
setDateChanged(true)
}
function handleSubmit() {
if (!rating) return
const data: Partial<EditReviewRequest> = {}
if (rating !== review.rating) data.rating = rating
const newComment = comment || null
if (newComment !== (review.comment ?? null)) data.comment = newComment
if (dateChanged) data.watched_at = formatLocalDateTime(watchedAt)
if (watchMedium !== review.watch_medium) data.watch_medium = watchMedium ?? null
if (Object.keys(data).length === 0) {
toast.info(t("editReview.noChanges"))
onOpenChange(false)
return
}
editMutation.mutate(
{ id: review.id, data },
{
onSuccess: () => {
hapticMedium()
toast.success(t("editReview.saved", { title: movie.title }))
onOpenChange(false)
},
},
)
}
return (
<Drawer open={open} onOpenChange={onOpenChange}>
<DrawerContent className="mx-auto max-w-lg">
<VisuallyHidden.Root><DrawerTitle>{t("editReview.title")}</DrawerTitle></VisuallyHidden.Root>
<div className="p-5 pb-8">
<div className="mb-5 flex gap-3">
<div className="h-24 w-16 flex-shrink-0 overflow-hidden rounded-lg bg-muted">
{movie.poster_path && <img src={posterUrl(movie.poster_path)} alt="" className="size-full object-cover" />}
</div>
<div>
<p className="text-lg font-bold">{movie.title}</p>
<p className="text-sm text-muted-foreground">{movie.release_year}{movie.director && ` · ${movie.director}`}</p>
</div>
</div>
<ReviewFormFields
rating={rating}
onRatingChange={setRating}
comment={comment}
onCommentChange={setComment}
watchedAt={watchedAt}
onWatchedAtChange={handleDateChange}
watchMedium={watchMedium}
onWatchMediumChange={setWatchMedium}
/>
<Button onClick={handleSubmit} disabled={!rating || editMutation.isPending} className="w-full" size="lg">
{editMutation.isPending ? t("editReview.saving") : t("editReview.save")}
</Button>
</div>
</DrawerContent>
</Drawer>
)
}

View File

@@ -0,0 +1,146 @@
import { useCallback, useState } from "react"
import { useTranslation } from "react-i18next"
import { Film, RefreshCw } from "lucide-react"
import { ReviewCard } from "@/components/review-card"
import { EmptyState } from "@/components/empty-state"
import { SwipeToDelete } from "@/components/swipe-to-delete"
import { VirtualList } from "@/components/virtual-list"
import { Button } from "@/components/ui/button"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { useAuth } from "@/components/auth-provider"
import { useQueryClient } from "@tanstack/react-query"
import { ReviewSheet } from "@/components/review-sheet"
import { ReviewDetailSheet } from "@/components/review-detail-sheet"
import { useInfiniteActivityFeed, useDeleteReview } from "@/features/diary"
import type { FeedEntryDto } from "@/features/diary"
export function FeedSkeleton() {
return (
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<div key={i} className="flex gap-3 rounded-xl bg-card p-3">
<Skeleton className="h-[84px] w-14 rounded-lg" />
<div className="flex-1 space-y-2">
<Skeleton className="h-3 w-20" />
<Skeleton className="h-4 w-32" />
<Skeleton className="h-3 w-24" />
</div>
</div>
))}
</div>
)
}
export function FeedTab() {
const { t } = useTranslation()
const { auth } = useAuth()
const qc = useQueryClient()
const [refreshing, setRefreshing] = useState(false)
const [sortBy, setSortBy] = useState("date")
const feedSortOptions = [
{ value: "date", label: t("feed.sortLatest") },
{ value: "date_asc", label: t("feed.sortOldest") },
{ value: "rating", label: t("feed.sortTopRated") },
{ value: "rating_asc", label: t("feed.sortLowestRated") },
] as const
const { data, isPending, hasNextPage, isFetchingNextPage, fetchNextPage } =
useInfiniteActivityFeed({ sort_by: sortBy })
const deleteReview = useDeleteReview()
const [editingEntry, setEditingEntry] = useState<FeedEntryDto | null>(null)
const [detailEntry, setDetailEntry] = useState<FeedEntryDto | null>(null)
const items = data?.pages.flatMap((p) => p.items) ?? []
const loadMore = useCallback(() => fetchNextPage(), [fetchNextPage])
return (
<div className="space-y-2">
<div className="flex items-center justify-end gap-2">
<Button
variant="ghost"
size="icon"
className="size-8"
onClick={async () => {
setRefreshing(true)
await qc.refetchQueries({ queryKey: ["activity-feed"] })
setRefreshing(false)
}}
>
<RefreshCw className={`size-4 ${refreshing ? "animate-spin" : ""}`} />
</Button>
<Select value={sortBy} onValueChange={setSortBy}>
<SelectTrigger className="w-36">
<SelectValue />
</SelectTrigger>
<SelectContent>
{feedSortOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{isPending && <FeedSkeleton />}
{!isPending && !items.length && (
<EmptyState icon={Film} title={t("feed.noActivity")} description={t("feed.noActivityDesc")} />
)}
{items.length > 0 && (
<VirtualList
items={items}
estimateSize={120}
hasMore={!!hasNextPage}
isFetching={isFetchingNextPage}
onLoadMore={loadMore}
renderItem={(entry) => {
const isOwn = entry.user_id === auth?.user_id
const card = (
<ReviewCard
movie={entry.movie}
review={entry.review}
userName={entry.user_display_name}
userId={entry.user_id}
isFederated={entry.is_federated}
actorUrl={entry.actor_url}
onEdit={isOwn ? () => setEditingEntry(entry) : undefined}
onShowDetail={entry.review.comment ? () => setDetailEntry(entry) : undefined}
/>
)
return isOwn ? (
<SwipeToDelete
onDelete={() => deleteReview.mutate(entry.review.id)}
confirmTitle={t("feed.deleteReview")}
confirmDescription={entry.movie.title}
>
{card}
</SwipeToDelete>
) : (
card
)
}}
/>
)}
{editingEntry && (
<ReviewSheet
key={editingEntry.review.id}
mode="edit"
open={!!editingEntry}
onOpenChange={(open) => !open && setEditingEntry(null)}
movie={editingEntry.movie}
review={editingEntry.review}
/>
)}
{detailEntry && (
<ReviewDetailSheet
open={!!detailEntry}
onOpenChange={(open) => !open && setDetailEntry(null)}
movie={detailEntry.movie}
review={detailEntry.review}
userName={detailEntry.user_display_name}
/>
)}
</div>
)
}

View File

@@ -9,7 +9,7 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu" } from "@/components/ui/dropdown-menu"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import type { GoalDto } from "@/lib/api/users" import type { GoalDto } from "@/features/users"
type GoalCardProps = { type GoalCardProps = {
goal: GoalDto goal: GoalDto

View File

@@ -5,7 +5,7 @@ import { Drawer, DrawerContent, DrawerTitle } from "@/components/ui/drawer"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { useCreateGoal, useUpdateGoal } from "@/hooks/use-goals" import { useCreateGoal, useUpdateGoal } from "@/features/goals"
import { toast } from "sonner" import { toast } from "sonner"
type GoalSheetProps = { type GoalSheetProps = {

View File

@@ -1,106 +0,0 @@
import { useState } from "react"
import { useTranslation } from "react-i18next"
import { VisuallyHidden } from "radix-ui"
import { Drawer, DrawerContent, DrawerTitle } from "@/components/ui/drawer"
import { Button } from "@/components/ui/button"
import { ReviewFormFields } from "@/components/review-form-fields"
import { SearchOverlay } from "@/components/search-overlay"
import type { MovieSelection } from "@/components/search-overlay"
import { useLogReview } from "@/hooks/use-diary"
import { toast } from "sonner"
import { posterUrl } from "@/lib/api/client"
import { hapticMedium } from "@/lib/haptics"
type LogSheetProps = {
open: boolean
onOpenChange: (open: boolean) => void
}
export function LogSheet({ open, onOpenChange }: LogSheetProps) {
const { t } = useTranslation()
const [movie, setMovie] = useState<MovieSelection | null>(null)
const [rating, setRating] = useState(0)
const [comment, setComment] = useState("")
const [watchedAt, setWatchedAt] = useState<Date>(new Date())
const [watchMedium, setWatchMedium] = useState<string | undefined>()
const logMutation = useLogReview()
function reset() {
setMovie(null)
setRating(0)
setComment("")
setWatchedAt(new Date())
setWatchMedium(undefined)
}
function handleClose() {
onOpenChange(false)
reset()
}
function handleSubmit() {
if (!movie || !rating) return
logMutation.mutate(
{
external_metadata_id: movie.external_metadata_id,
manual_title: movie.title,
manual_release_year: movie.release_year,
manual_director: movie.director,
rating,
comment: comment || undefined,
watched_at: watchedAt.toISOString().replace("Z", "").split(".")[0]!,
watch_medium: watchMedium,
},
{
onSuccess: () => {
hapticMedium()
toast.success(t("logReview.logged", { title: movie.title }))
handleClose()
},
},
)
}
if (open && !movie) {
return <SearchOverlay open onClose={handleClose} onSelect={(m) => setMovie(m)} />
}
return (
<Drawer open={open && !!movie} onOpenChange={(o) => !o && handleClose()}>
<DrawerContent className="mx-auto max-w-lg">
<VisuallyHidden.Root><DrawerTitle>{t("logReview.title")}</DrawerTitle></VisuallyHidden.Root>
<div className="p-5 pb-8">
{movie && (
<>
<div className="mb-5 flex gap-3">
<div className="h-24 w-16 flex-shrink-0 overflow-hidden rounded-lg bg-muted">
{movie.poster_path && <img src={posterUrl(movie.poster_path)} alt="" className="size-full object-cover" />}
</div>
<div>
<p className="text-lg font-bold">{movie.title}</p>
<p className="text-sm text-muted-foreground">{movie.release_year}{movie.director && ` · ${movie.director}`}</p>
{movie.genres.length > 0 && <p className="mt-1 text-xs text-muted-foreground">{movie.genres.join(", ")}</p>}
</div>
</div>
<ReviewFormFields
rating={rating}
onRatingChange={setRating}
comment={comment}
onCommentChange={setComment}
watchedAt={watchedAt}
onWatchedAtChange={setWatchedAt}
watchMedium={watchMedium}
onWatchMediumChange={setWatchMedium}
/>
<Button onClick={handleSubmit} disabled={!rating || logMutation.isPending} className="w-full" size="lg">
{logMutation.isPending ? t("logReview.logging") : t("logReview.logReview")}
</Button>
</>
)}
</div>
</DrawerContent>
</Drawer>
)
}

View File

@@ -11,9 +11,10 @@ type MovieCardProps = {
subtitle?: React.ReactNode subtitle?: React.ReactNode
variant?: "compact" | "full" variant?: "compact" | "full"
action?: React.ReactNode action?: React.ReactNode
onShowDetail?: () => void
} }
export function MovieCard({ movie, rating, comment, subtitle, variant = "full", action }: MovieCardProps) { export function MovieCard({ movie, rating, comment, subtitle, variant = "full", action, onShowDetail }: MovieCardProps) {
if (variant === "compact") { if (variant === "compact") {
return ( return (
<Link to="/movies/$id" params={{ id: movie.id }} className="glass flex items-center gap-3 rounded-xl px-3 py-2.5 transition-colors active:bg-muted/50"> <Link to="/movies/$id" params={{ id: movie.id }} className="glass flex items-center gap-3 rounded-xl px-3 py-2.5 transition-colors active:bg-muted/50">
@@ -23,7 +24,15 @@ export function MovieCard({ movie, rating, comment, subtitle, variant = "full",
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold">{movie.title}</p> <p className="truncate text-sm font-semibold">{movie.title}</p>
{subtitle && <p className="text-xs text-muted-foreground">{subtitle}</p>} {subtitle && <p className="text-xs text-muted-foreground">{subtitle}</p>}
{comment && <p className="truncate text-xs text-muted-foreground/70">{comment}</p>} {comment && (
<p
className="truncate text-xs text-muted-foreground/70"
role={onShowDetail ? "button" : undefined}
tabIndex={onShowDetail ? 0 : undefined}
onClick={onShowDetail ? (e) => { e.preventDefault(); onShowDetail() } : undefined}
onKeyDown={onShowDetail ? (e) => e.key === "Enter" && onShowDetail() : undefined}
>{comment}</p>
)}
</div> </div>
{rating != null && <StarDisplay rating={rating} size="xs" />} {rating != null && <StarDisplay rating={rating} size="xs" />}
</Link> </Link>
@@ -35,13 +44,21 @@ export function MovieCard({ movie, rating, comment, subtitle, variant = "full",
<Card size="sm"> <Card size="sm">
<CardContent className="flex gap-3"> <CardContent className="flex gap-3">
<div className="h-[84px] w-14 flex-shrink-0 overflow-hidden rounded-lg bg-muted"> <div className="h-[84px] w-14 flex-shrink-0 overflow-hidden rounded-lg bg-muted">
{movie.poster_path && <img src={posterUrl(movie.poster_path)} alt="" className="size-full object-cover" />} {movie.poster_path && <img src={posterUrl(movie.poster_path)} alt="" className="aero-poster-hover size-full object-cover" />}
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="font-semibold">{movie.title}</p> <p className="font-semibold">{movie.title}</p>
<p className="text-xs text-muted-foreground">{movie.release_year}{movie.director && ` · ${movie.director}`}</p> <p className="text-xs text-muted-foreground">{movie.release_year}{movie.director && ` · ${movie.director}`}</p>
{rating != null && <div className="mt-1"><StarDisplay rating={rating} /></div>} {rating != null && <div className="mt-1"><StarDisplay rating={rating} /></div>}
{comment && <p className="mt-1 line-clamp-2 text-xs text-muted-foreground">{comment}</p>} {comment && (
<p
className="mt-1 line-clamp-2 text-xs text-muted-foreground"
role={onShowDetail ? "button" : undefined}
tabIndex={onShowDetail ? 0 : undefined}
onClick={onShowDetail ? (e) => { e.preventDefault(); onShowDetail() } : undefined}
onKeyDown={onShowDetail ? (e) => e.key === "Enter" && onShowDetail() : undefined}
>{comment}</p>
)}
</div> </div>
{action && <div className="flex items-center" onClick={(e) => e.preventDefault()}>{action}</div>} {action && <div className="flex items-center" onClick={(e) => e.preventDefault()}>{action}</div>}
</CardContent> </CardContent>

View File

@@ -1,5 +1,5 @@
import { Link } from "@tanstack/react-router" import { Link } from "@tanstack/react-router"
import { useCallback } from "react" import { useCallback, useState } from "react"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { Bar, BarChart, XAxis, YAxis } from "recharts" import { Bar, BarChart, XAxis, YAxis } from "recharts"
import { Globe, Search, User } from "lucide-react" import { Globe, Search, User } from "lucide-react"
@@ -9,12 +9,17 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { MovieCard } from "@/components/movie-card" import { MovieCard } from "@/components/movie-card"
import { RatingHistogram } from "@/components/rating-histogram"
import { EmptyState } from "@/components/empty-state" import { EmptyState } from "@/components/empty-state"
import { SwipeTabs } from "@/components/swipe-tabs" import { SwipeTabs } from "@/components/swipe-tabs"
import { VirtualList } from "@/components/virtual-list" import { VirtualList } from "@/components/virtual-list"
import { useInfiniteDiary } from "@/hooks/use-diary" import { useInfiniteDiary } from "@/features/diary"
import { TimeAgo } from "@/components/time-ago" import { TimeAgo } from "@/components/time-ago"
import type { UserProfileResponse } from "@/lib/api/users" import { WATCH_MEDIUMS } from "@/lib/watch-mediums"
import { ReviewDetailSheet } from "@/components/review-detail-sheet"
import { DiaryCalendar } from "@/components/diary-calendar"
import type { DiaryEntryDto } from "@/lib/api/common"
import type { UserProfileResponse } from "@/features/users"
type ProfileViewProps = { type ProfileViewProps = {
data: UserProfileResponse data: UserProfileResponse
@@ -47,6 +52,7 @@ export function ProfileView({
const profileTabs = [ const profileTabs = [
{ value: "recent", label: t("profile.recent") }, { value: "recent", label: t("profile.recent") },
{ value: "top_rated", label: t("profile.topRated") }, { value: "top_rated", label: t("profile.topRated") },
{ value: "calendar", label: t("profile.calendar", { defaultValue: "Calendar" }) },
{ value: "trends", label: t("profile.trends") }, { value: "trends", label: t("profile.trends") },
] as const ] as const
@@ -124,6 +130,7 @@ export function ProfileView({
search={search} search={search}
/> />
)} )}
{tab === "calendar" && <CalendarTab userId={userId} />}
{tab === "trends" && <TrendsView data={data} />} {tab === "trends" && <TrendsView data={data} />}
</> </>
)} )}
@@ -152,27 +159,66 @@ function DiaryTab({ sortBy, userId, search }: { sortBy: string; userId?: string;
) )
: items : items
const loadMore = useCallback(() => fetchNextPage(), [fetchNextPage]) const loadMore = useCallback(() => fetchNextPage(), [fetchNextPage])
const [detailEntry, setDetailEntry] = useState<DiaryEntryDto | null>(null)
if (isPending) return <Skeleton className="h-40 w-full rounded-xl" /> if (isPending) return <Skeleton className="h-40 w-full rounded-xl" />
if (!filtered.length) return <EmptyState icon={User} title={t("profile.noEntries")} /> if (!filtered.length) return <EmptyState icon={User} title={t("profile.noEntries")} />
return ( return (
<VirtualList <>
items={filtered} <VirtualList
estimateSize={52} items={filtered}
hasMore={!!hasNextPage} estimateSize={52}
isFetching={isFetchingNextPage} hasMore={!!hasNextPage}
onLoadMore={loadMore} isFetching={isFetchingNextPage}
renderItem={(e) => ( onLoadMore={loadMore}
<MovieCard renderItem={(e) => (
movie={e.movie} <MovieCard
rating={e.review.rating} movie={e.movie}
comment={e.review.comment} rating={e.review.rating}
subtitle={<><TimeAgo date={e.review.watched_at} /></>} comment={e.review.comment}
variant="compact" subtitle={<><TimeAgo date={e.review.watched_at} /></>}
variant="compact"
onShowDetail={e.review.comment ? () => setDetailEntry(e) : undefined}
/>
)}
/>
{detailEntry && (
<ReviewDetailSheet
open={!!detailEntry}
onOpenChange={(open) => !open && setDetailEntry(null)}
movie={detailEntry.movie}
review={detailEntry.review}
/> />
)} )}
/> </>
)
}
function CalendarTab({ userId }: { userId?: string }) {
const { data, isPending, hasNextPage, fetchNextPage } =
useInfiniteDiary({ sort_by: "date_desc", user_id: userId })
const items = data?.pages.flatMap((p) => p.items) ?? []
const [detailEntry, setDetailEntry] = useState<DiaryEntryDto | null>(null)
if (isPending) return <Skeleton className="h-80 w-full rounded-xl" />
if (hasNextPage) fetchNextPage()
return (
<>
<DiaryCalendar
entries={items}
onSelectEntry={setDetailEntry}
/>
{detailEntry && (
<ReviewDetailSheet
open={!!detailEntry}
onOpenChange={(open) => !open && setDetailEntry(null)}
movie={detailEntry.movie}
review={detailEntry.review}
/>
)}
</>
) )
} }
@@ -191,6 +237,9 @@ function TrendsView({
avg_rating: number avg_rating: number
count: number count: number
}[] }[]
top_genres: { genre: string; count: number }[]
rating_distribution: number[]
watch_medium_distribution: { medium: string; count: number }[]
} }
} }
}) { }) {
@@ -200,7 +249,7 @@ function TrendsView({
return ( return (
<div className="space-y-3"> <div className="space-y-3">
{data.trends.top_directors.length > 0 && ( {data.trends.top_directors.some((d) => d.count >= 2) && (
<Card size="sm"> <Card size="sm">
<CardHeader> <CardHeader>
<CardTitle className="text-sm">{t("profile.topDirectors")}</CardTitle> <CardTitle className="text-sm">{t("profile.topDirectors")}</CardTitle>
@@ -221,6 +270,56 @@ function TrendsView({
</Card> </Card>
)} )}
{data.trends.top_genres.length > 0 && (
<Card size="sm">
<CardHeader>
<CardTitle className="text-sm">{t("profile.topGenres", { defaultValue: "Top Genres" })}</CardTitle>
</CardHeader>
<CardContent>
{data.trends.top_genres.map((g) => (
<div key={g.genre} className="flex items-center justify-between py-1 text-sm">
<span>{g.genre}</span>
<span className="text-xs text-muted-foreground">{t("common.films", { count: g.count })}</span>
</div>
))}
</CardContent>
</Card>
)}
{data.trends.rating_distribution.length > 0 && (
<Card size="sm">
<CardHeader>
<CardTitle className="text-sm">{t("profile.ratingDistribution", { defaultValue: "Rating Distribution" })}</CardTitle>
</CardHeader>
<CardContent>
<RatingHistogram histogram={data.trends.rating_distribution} />
</CardContent>
</Card>
)}
{data.trends.watch_medium_distribution.length > 0 && (
<Card size="sm">
<CardHeader>
<CardTitle className="text-sm">{t("profile.howYouWatch", { defaultValue: "How You Watch" })}</CardTitle>
</CardHeader>
<CardContent>
{data.trends.watch_medium_distribution.map((wm) => {
const def = WATCH_MEDIUMS.find((d) => d.value === wm.medium)
const Icon = def?.icon
return (
<div key={wm.medium} className="flex items-center justify-between py-1 text-sm">
<span className="flex items-center gap-2">
{Icon && <Icon className="size-4 text-muted-foreground" />}
{def ? t(def.labelKey) : wm.medium}
</span>
<span className="text-xs text-muted-foreground">{t("common.films", { count: wm.count })}</span>
</div>
)
})}
</CardContent>
</Card>
)}
{data.trends.monthly_ratings.length > 0 && ( {data.trends.monthly_ratings.length > 0 && (
<Card size="sm"> <Card size="sm">
<CardHeader> <CardHeader>

View File

@@ -0,0 +1,73 @@
import { useState } from "react"
import { useTranslation } from "react-i18next"
import { Inbox } from "lucide-react"
import { EmptyState } from "@/components/empty-state"
import { Button } from "@/components/ui/button"
import { Textarea } from "@/components/ui/textarea"
import { StarRating } from "@/components/star-rating"
import { useWatchQueue, useConfirmWatch, useDismissWatch } from "@/features/webhooks"
import { FeedSkeleton } from "@/components/feed-tab"
export function QueueTab() {
const { t } = useTranslation()
const { data, isPending } = useWatchQueue()
const confirmMutation = useConfirmWatch()
const dismissMutation = useDismissWatch()
const [ratings, setRatings] = useState<Record<string, number>>({})
const [comments, setComments] = useState<Record<string, string>>({})
if (isPending) return <FeedSkeleton />
if (!data?.length)
return <EmptyState icon={Inbox} title={t("feed.queueEmpty")} description={t("feed.queueEmptyDesc")} />
return (
<div className="space-y-3">
{data.map((entry) => (
<div key={entry.id} className="rounded-xl bg-card p-3">
<p className="font-semibold">{entry.title}</p>
<p className="text-xs text-muted-foreground">
{entry.year && `${entry.year} · `}{entry.source} · {entry.watched_at}
</p>
<div className="mt-2">
<StarRating
value={ratings[entry.id] ?? 0}
onChange={(v) => setRatings((p) => ({ ...p, [entry.id]: v }))}
size="sm"
/>
</div>
<Textarea
className="mt-2"
placeholder={t("logReview.commentPlaceholder")}
value={comments[entry.id] ?? ""}
onChange={(e) => setComments((p) => ({ ...p, [entry.id]: e.target.value }))}
rows={2}
/>
<div className="mt-2 flex gap-2">
<Button
size="sm"
disabled={!ratings[entry.id]}
onClick={() =>
confirmMutation.mutate({
confirmations: [{
watch_event_id: entry.id,
rating: ratings[entry.id]!,
comment: comments[entry.id] || undefined,
}],
})
}
>
{t("common.confirm")}
</Button>
<Button
size="sm"
variant="outline"
onClick={() => dismissMutation.mutate({ event_ids: [entry.id] })}
>
{t("common.dismiss")}
</Button>
</div>
</div>
))}
</div>
)
}

View File

@@ -1,6 +1,6 @@
import { useScrollReveal } from "@/hooks/use-animate" import { useScrollReveal } from "@/hooks/use-animate"
export function RevealCard({ children }: { children: React.ReactNode }) { export function RevealCard({ children, delay = 0 }: { children: React.ReactNode; delay?: number }) {
const { ref, visible } = useScrollReveal() const { ref, visible } = useScrollReveal()
return ( return (
<div <div
@@ -8,7 +8,8 @@ export function RevealCard({ children }: { children: React.ReactNode }) {
className="transition-all duration-700 ease-out" className="transition-all duration-700 ease-out"
style={{ style={{
opacity: visible ? 1 : 0, opacity: visible ? 1 : 0,
transform: visible ? "translateY(0)" : "translateY(24px)", transform: visible ? "translateY(0) scale(1)" : "translateY(24px) scale(0.97)",
transitionDelay: visible ? `${delay}ms` : "0ms",
}} }}
> >
{children} {children}

View File

@@ -25,7 +25,7 @@ export function ReviewCard({ movie, review, userName, userId, isFederated, actor
<Card size="sm"> <Card size="sm">
<CardContent className="flex gap-3"> <CardContent className="flex gap-3">
<Link to="/movies/$id" params={{ id: movie.id }} className="h-[84px] w-14 flex-shrink-0 overflow-hidden rounded-lg bg-muted"> <Link to="/movies/$id" params={{ id: movie.id }} className="h-[84px] w-14 flex-shrink-0 overflow-hidden rounded-lg bg-muted">
{movie.poster_path && <img src={posterUrl(movie.poster_path)} alt="" className="size-full object-cover" />} {movie.poster_path && <img src={posterUrl(movie.poster_path)} alt="" className="aero-poster-hover size-full object-cover" />}
</Link> </Link>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
{userName && ( {userName && (

View File

@@ -0,0 +1,271 @@
import { useState } from "react"
import { useTranslation } from "react-i18next"
import { VisuallyHidden } from "radix-ui"
import { Drawer, DrawerContent, DrawerTitle } from "@/components/ui/drawer"
import { Button } from "@/components/ui/button"
import { ReviewFormFields } from "@/components/review-form-fields"
import { SearchOverlay } from "@/components/search-overlay"
import type { MovieSelection } from "@/components/search-overlay"
import { useLogReview, useEditReview, useDeleteReview } from "@/features/diary"
import { ConfirmDialog } from "@/components/confirm-dialog"
import { Trash2 } from "lucide-react"
import { toast } from "sonner"
import { posterUrl } from "@/lib/api/client"
import { hapticMedium } from "@/lib/haptics"
import { parseLocalDate, formatLocalDateTime } from "@/lib/date"
import type { EditReviewRequest } from "@/features/diary"
import type { MovieDto, ReviewDto } from "@/lib/api/common"
type LogMode = {
mode: "log"
}
type EditMode = {
mode: "edit"
movie: MovieDto
review: ReviewDto
}
type ReviewSheetProps = {
open: boolean
onOpenChange: (open: boolean) => void
} & (LogMode | EditMode)
export function ReviewSheet(props: ReviewSheetProps) {
if (props.mode === "log") {
return <LogMode open={props.open} onOpenChange={props.onOpenChange} />
}
return (
<EditMode
open={props.open}
onOpenChange={props.onOpenChange}
movie={props.movie}
review={props.review}
/>
)
}
function LogMode({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) {
const { t } = useTranslation()
const [movie, setMovie] = useState<MovieSelection | null>(null)
const [rating, setRating] = useState(0)
const [comment, setComment] = useState("")
const [watchedAt, setWatchedAt] = useState<Date>(new Date())
const [watchMedium, setWatchMedium] = useState<string | undefined>()
const logMutation = useLogReview()
function reset() {
setMovie(null)
setRating(0)
setComment("")
setWatchedAt(new Date())
setWatchMedium(undefined)
}
function handleClose() {
onOpenChange(false)
reset()
}
function handleSubmit() {
if (!movie || !rating) return
logMutation.mutate(
{
external_metadata_id: movie.external_metadata_id,
manual_title: movie.title,
manual_release_year: movie.release_year,
manual_director: movie.director,
rating,
comment: comment || undefined,
watched_at: watchedAt.toISOString().replace("Z", "").split(".")[0]!,
watch_medium: watchMedium,
},
{
onSuccess: () => {
hapticMedium()
toast.success(t("logReview.logged", { title: movie.title }))
handleClose()
},
},
)
}
if (open && !movie) {
return <SearchOverlay open onClose={handleClose} onSelect={(m) => setMovie(m)} />
}
return (
<Drawer open={open && !!movie} onOpenChange={(o) => !o && handleClose()}>
<DrawerContent className="mx-auto max-w-lg">
<VisuallyHidden.Root><DrawerTitle>{t("logReview.title")}</DrawerTitle></VisuallyHidden.Root>
<div className="p-5 pb-8">
{movie && (
<>
<MovieHeader
title={movie.title}
releaseYear={movie.release_year}
director={movie.director}
posterPath={movie.poster_path}
genres={movie.genres}
/>
<ReviewFormFields
rating={rating}
onRatingChange={setRating}
comment={comment}
onCommentChange={setComment}
watchedAt={watchedAt}
onWatchedAtChange={setWatchedAt}
watchMedium={watchMedium}
onWatchMediumChange={setWatchMedium}
/>
<Button onClick={handleSubmit} disabled={!rating || logMutation.isPending} className="w-full" size="lg">
{logMutation.isPending ? t("logReview.logging") : t("logReview.logReview")}
</Button>
</>
)}
</div>
</DrawerContent>
</Drawer>
)
}
function EditMode({
open,
onOpenChange,
movie,
review,
}: {
open: boolean
onOpenChange: (open: boolean) => void
movie: MovieDto
review: ReviewDto
}) {
const { t } = useTranslation()
const [rating, setRating] = useState(review.rating)
const [comment, setComment] = useState(review.comment ?? "")
const [watchedAt, setWatchedAt] = useState<Date>(() => parseLocalDate(review.watched_at))
const [dateChanged, setDateChanged] = useState(false)
const [watchMedium, setWatchMedium] = useState<string | undefined>(review.watch_medium)
const [confirmDelete, setConfirmDelete] = useState(false)
const editMutation = useEditReview()
const deleteMutation = useDeleteReview()
function handleDateChange(d: Date) {
setWatchedAt(d)
setDateChanged(true)
}
function handleSubmit() {
if (!rating) return
const data: Partial<EditReviewRequest> = {}
if (rating !== review.rating) data.rating = rating
const newComment = comment || null
if (newComment !== (review.comment ?? null)) data.comment = newComment
if (dateChanged) data.watched_at = formatLocalDateTime(watchedAt)
if (watchMedium !== review.watch_medium) data.watch_medium = watchMedium ?? null
if (Object.keys(data).length === 0) {
toast.info(t("editReview.noChanges"))
onOpenChange(false)
return
}
editMutation.mutate(
{ id: review.id, data },
{
onSuccess: () => {
hapticMedium()
toast.success(t("editReview.saved", { title: movie.title }))
onOpenChange(false)
},
},
)
}
return (
<Drawer open={open} onOpenChange={onOpenChange}>
<DrawerContent className="mx-auto max-w-lg">
<VisuallyHidden.Root><DrawerTitle>{t("editReview.title")}</DrawerTitle></VisuallyHidden.Root>
<div className="p-5 pb-8">
<MovieHeader
title={movie.title}
releaseYear={movie.release_year}
director={movie.director}
posterPath={movie.poster_path}
/>
<ReviewFormFields
rating={rating}
onRatingChange={setRating}
comment={comment}
onCommentChange={setComment}
watchedAt={watchedAt}
onWatchedAtChange={handleDateChange}
watchMedium={watchMedium}
onWatchMediumChange={setWatchMedium}
/>
<Button onClick={handleSubmit} disabled={!rating || editMutation.isPending} className="w-full" size="lg">
{editMutation.isPending ? t("editReview.saving") : t("editReview.save")}
</Button>
<Button
variant="ghost"
className="mt-2 w-full text-destructive hover:text-destructive"
onClick={() => setConfirmDelete(true)}
disabled={deleteMutation.isPending}
>
<Trash2 className="mr-1.5 size-4" />
{t("editReview.delete", { defaultValue: "Delete review" })}
</Button>
<ConfirmDialog
open={confirmDelete}
onOpenChange={setConfirmDelete}
title={t("diary.deleteReview", { defaultValue: "Delete review?" })}
description={`${movie.title}${review.watched_at.slice(0, 10)}`}
onConfirm={() =>
deleteMutation.mutate(review.id, {
onSuccess: () => {
hapticMedium()
toast.success(t("editReview.deleted", { defaultValue: "Review deleted", title: movie.title }))
onOpenChange(false)
},
})
}
/>
</div>
</DrawerContent>
</Drawer>
)
}
function MovieHeader({
title,
releaseYear,
director,
posterPath,
genres,
}: {
title: string
releaseYear?: number
director?: string | null
posterPath?: string | null
genres?: string[]
}) {
return (
<div className="mb-5 flex gap-3">
<div className="h-24 w-16 flex-shrink-0 overflow-hidden rounded-lg bg-muted">
{posterPath && <img src={posterUrl(posterPath)} alt="" className="size-full object-cover" />}
</div>
<div>
<p className="text-lg font-bold">{title}</p>
<p className="text-sm text-muted-foreground">{releaseYear}{director && ` · ${director}`}</p>
{genres && genres.length > 0 && <p className="mt-1 text-xs text-muted-foreground">{genres.join(", ")}</p>}
</div>
</div>
)
}

View File

@@ -1,4 +1,5 @@
import { useState } from "react" import { useState } from "react"
import { createPortal } from "react-dom"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { PenLine, Search, X } from "lucide-react" import { PenLine, Search, X } from "lucide-react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
@@ -7,7 +8,7 @@ import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { Separator } from "@/components/ui/separator" import { Separator } from "@/components/ui/separator"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { useSearch } from "@/hooks/use-search" import { useSearch } from "@/features/search"
import { useDebounce } from "@/hooks/use-debounce" import { useDebounce } from "@/hooks/use-debounce"
import { posterUrl } from "@/lib/api/client" import { posterUrl } from "@/lib/api/client"
@@ -27,6 +28,8 @@ type SearchOverlayProps = {
onSelect: (movie: MovieSelection) => void onSelect: (movie: MovieSelection) => void
} }
const IMDB_RE = /^tt\d{4,}$/i
export function SearchOverlay({ open, onClose, onSelect }: SearchOverlayProps) { export function SearchOverlay({ open, onClose, onSelect }: SearchOverlayProps) {
const { t } = useTranslation() const { t } = useTranslation()
const [query, setQuery] = useState("") const [query, setQuery] = useState("")
@@ -38,6 +41,15 @@ export function SearchOverlay({ open, onClose, onSelect }: SearchOverlayProps) {
const debouncedQuery = useDebounce(query, 300) const debouncedQuery = useDebounce(query, 300)
const { data, isPending } = useSearch({ q: debouncedQuery || undefined }) const { data, isPending } = useSearch({ q: debouncedQuery || undefined })
function handleQueryChange(value: string) {
setQuery(value)
if (IMDB_RE.test(value.trim())) {
setManualImdbId(value.trim())
setManual(true)
setQuery("")
}
}
if (!open) return null if (!open) return null
const hasImdbId = manualImdbId.trim().length > 0 const hasImdbId = manualImdbId.trim().length > 0
@@ -56,62 +68,58 @@ export function SearchOverlay({ open, onClose, onSelect }: SearchOverlayProps) {
}) })
} }
if (manual) {
return (
<div className="fixed inset-0 z-50 flex flex-col glass-heavy">
<div className="flex items-center justify-between p-4">
<Button variant="ghost" size="sm" onClick={() => setManual(false)}>
{t("searchOverlay.backToSearch")}
</Button>
<Button variant="ghost" size="sm" onClick={onClose}>
{t("common.cancel")}
</Button>
</div>
<div className="flex-1 overflow-auto px-4">
<Card>
<CardHeader>
<CardTitle>{t("searchOverlay.addManuallyTitle")}</CardTitle>
<CardDescription>{t("searchOverlay.addManuallyDesc")}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="manual-imdb">{t("searchOverlay.imdbId")}</Label>
<Input id="manual-imdb" value={manualImdbId} onChange={(e) => setManualImdbId(e.target.value)} placeholder={t("searchOverlay.imdbPlaceholder")} autoFocus />
<p className="text-xs text-muted-foreground">{t("searchOverlay.imdbHelp")}</p>
</div>
<Separator />
<p className="text-xs text-muted-foreground">{t("searchOverlay.orSearchByTitle")}</p>
<div className="space-y-1.5">
<Label htmlFor="manual-title">{t("searchOverlay.titleLabel")} {!hasImdbId && "*"}</Label>
<Input id="manual-title" value={manualTitle} onChange={(e) => setManualTitle(e.target.value)} placeholder={t("searchOverlay.titlePlaceholder")} />
</div>
<div className="space-y-1.5">
<Label htmlFor="manual-year">{t("searchOverlay.releaseYear")} {!hasImdbId && "*"}</Label>
<Input id="manual-year" type="number" value={manualYear} onChange={(e) => setManualYear(e.target.value)} placeholder={t("searchOverlay.yearPlaceholder")} />
</div>
<div className="space-y-1.5">
<Label htmlFor="manual-director">{t("searchOverlay.director")}</Label>
<Input id="manual-director" value={manualDirector} onChange={(e) => setManualDirector(e.target.value)} placeholder={t("searchOverlay.directorPlaceholder")} />
</div>
<Button onClick={handleManualSubmit} disabled={!canSubmitManual} className="w-full">
{t("common.continue")}
</Button>
</CardContent>
</Card>
</div>
</div>
)
}
const hasResults = (data?.movies?.items?.length ?? 0) > 0 const hasResults = (data?.movies?.items?.length ?? 0) > 0
const searched = debouncedQuery.length > 0 && !isPending const searched = debouncedQuery.length > 0 && !isPending
return ( const content = manual ? (
<div className="fixed inset-0 z-50 flex flex-col glass-heavy">
<div className="flex items-center justify-between p-4">
<Button variant="ghost" size="sm" onClick={() => setManual(false)}>
{t("searchOverlay.backToSearch")}
</Button>
<Button variant="ghost" size="sm" onClick={onClose}>
{t("common.cancel")}
</Button>
</div>
<div className="flex-1 overflow-auto px-4">
<Card>
<CardHeader>
<CardTitle>{t("searchOverlay.addManuallyTitle")}</CardTitle>
<CardDescription>{t("searchOverlay.addManuallyDesc")}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="manual-imdb">{t("searchOverlay.imdbId")}</Label>
<Input id="manual-imdb" value={manualImdbId} onChange={(e) => setManualImdbId(e.target.value)} placeholder={t("searchOverlay.imdbPlaceholder")} autoFocus />
<p className="text-xs text-muted-foreground">{t("searchOverlay.imdbHelp")}</p>
</div>
<Separator />
<p className="text-xs text-muted-foreground">{t("searchOverlay.orSearchByTitle")}</p>
<div className="space-y-1.5">
<Label htmlFor="manual-title">{t("searchOverlay.titleLabel")} {!hasImdbId && "*"}</Label>
<Input id="manual-title" value={manualTitle} onChange={(e) => setManualTitle(e.target.value)} placeholder={t("searchOverlay.titlePlaceholder")} />
</div>
<div className="space-y-1.5">
<Label htmlFor="manual-year">{t("searchOverlay.releaseYear")} {!hasImdbId && "*"}</Label>
<Input id="manual-year" type="number" value={manualYear} onChange={(e) => setManualYear(e.target.value)} placeholder={t("searchOverlay.yearPlaceholder")} />
</div>
<div className="space-y-1.5">
<Label htmlFor="manual-director">{t("searchOverlay.director")}</Label>
<Input id="manual-director" value={manualDirector} onChange={(e) => setManualDirector(e.target.value)} placeholder={t("searchOverlay.directorPlaceholder")} />
</div>
<Button onClick={handleManualSubmit} disabled={!canSubmitManual} className="w-full">
{t("common.continue")}
</Button>
</CardContent>
</Card>
</div>
</div>
) : (
<div className="fixed inset-0 z-50 flex flex-col glass-heavy"> <div className="fixed inset-0 z-50 flex flex-col glass-heavy">
<div className="flex items-center gap-3 p-4"> <div className="flex items-center gap-3 p-4">
<div className="relative flex-1"> <div className="relative flex-1">
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" /> <Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input value={query} onChange={(e) => setQuery(e.target.value)} placeholder={t("searchOverlay.searchPlaceholder")} className="pl-9" autoFocus /> <Input value={query} onChange={(e) => handleQueryChange(e.target.value)} placeholder={t("searchOverlay.searchPlaceholder")} className="pl-9" autoFocus />
{query && ( {query && (
<Button variant="ghost" size="icon" onClick={() => setQuery("")} className="absolute right-3 top-1/2 size-6 -translate-y-1/2"> <Button variant="ghost" size="icon" onClick={() => setQuery("")} className="absolute right-3 top-1/2 size-6 -translate-y-1/2">
<X className="size-4 text-muted-foreground" /> <X className="size-4 text-muted-foreground" />
@@ -149,7 +157,7 @@ export function SearchOverlay({ open, onClose, onSelect }: SearchOverlayProps) {
}} }}
className="flex w-full items-center gap-3 rounded-lg p-2 text-left transition-colors active:bg-muted" className="flex w-full items-center gap-3 rounded-lg p-2 text-left transition-colors active:bg-muted"
> >
<div className="h-14 w-10 flex-shrink-0 overflow-hidden rounded-md bg-muted"> <div className="h-14 w-10 shrink-0 overflow-hidden rounded-md bg-muted">
{hit.poster_path && <img src={posterUrl(hit.poster_path)} alt="" className="size-full object-cover" />} {hit.poster_path && <img src={posterUrl(hit.poster_path)} alt="" className="size-full object-cover" />}
</div> </div>
<div> <div>
@@ -182,4 +190,6 @@ export function SearchOverlay({ open, onClose, onSelect }: SearchOverlayProps) {
</div> </div>
</div> </div>
) )
return createPortal(content, document.body)
} }

View File

@@ -1,3 +1,4 @@
import { useState } from "react"
import { Star } from "lucide-react" import { Star } from "lucide-react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { hapticLight } from "@/lib/haptics" import { hapticLight } from "@/lib/haptics"
@@ -8,25 +9,39 @@ type StarRatingProps = {
size?: "sm" | "md" | "lg" size?: "sm" | "md" | "lg"
} }
const sizes = { sm: "size-5", md: "size-8", lg: "size-10" } const iconSizes = { sm: "size-5", md: "size-7", lg: "size-9" }
const buttonSizes = { sm: "size-8", md: "size-10", lg: "size-11" }
export function StarRating({ value, onChange, size = "lg" }: StarRatingProps) { export function StarRating({ value, onChange, size = "lg" }: StarRatingProps) {
const [animKey, setAnimKey] = useState(0)
function handleClick(star: number) {
hapticLight()
setAnimKey((k) => k + 1)
onChange(star)
}
return ( return (
<div className="flex gap-1"> <div className="flex gap-0.5">
{[1, 2, 3, 4, 5].map((star) => ( {[1, 2, 3, 4, 5].map((star) => (
<button <button
key={star} key={star}
type="button" type="button"
onClick={() => { hapticLight(); onChange(star) }} onClick={() => handleClick(star)}
className="transition-transform active:scale-90" className={cn(
"flex items-center justify-center rounded-md transition-transform active:scale-90",
buttonSizes[size],
)}
> >
<Star <Star
key={`${star}-${animKey}`}
className={cn( className={cn(
sizes[size], iconSizes[size],
star <= value star <= value
? "fill-amber-500 text-amber-500 aero-star-filled" ? "fill-amber-500 text-amber-500 aero-star-filled aero-star-pop"
: "text-muted-foreground/30", : "text-muted-foreground/30",
)} )}
style={star <= value ? { animationDelay: `${(star - 1) * 50}ms` } : undefined}
/> />
</button> </button>
))} ))}

View File

@@ -47,7 +47,7 @@ export function SwipeTabs({
} }
} }
}, },
{ axis: "x", filterTaps: true }, { axis: "x", filterTaps: true, pointer: { touch: true } },
) )
return ( return (

View File

@@ -41,7 +41,7 @@ export function SwipeToDelete({
} }
} }
}, },
{ axis: "x", filterTaps: true, pointer: { capture: true } }, { axis: "x", filterTaps: true },
) )
function handleDeleteTap() { function handleDeleteTap() {

View File

@@ -0,0 +1,36 @@
import { useTranslation } from "react-i18next"
import { TrendingUp } from "lucide-react"
import { StarDisplay } from "@/components/star-display"
import { shortDate } from "@/lib/date"
import type { ReviewHistoryResponse } from "@/features/movies"
export function ViewingHistory({ history }: { history: ReviewHistoryResponse }) {
const { t } = useTranslation()
if (history.viewings.length === 0) return null
return (
<section>
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">{t("movie.yourHistory")}</h3>
<div className="space-y-2">
{history.trend && (
<div className="flex items-center gap-2 rounded-xl bg-card p-3 text-xs text-muted-foreground">
<TrendingUp className="size-3.5" />
{t("movie.trend", { trend: history.trend })}
</div>
)}
{history.viewings.map((v) => (
<div key={v.id} className="flex items-center justify-between rounded-xl bg-card p-3">
<div>
<p className="text-sm font-medium">{shortDate(v.watched_at)}</p>
{v.comment && (
<p className="mt-0.5 text-xs text-muted-foreground line-clamp-1">{v.comment}</p>
)}
</div>
<StarDisplay rating={v.rating} size="xs" />
</div>
))}
</div>
</section>
)
}

View File

@@ -17,7 +17,7 @@ export function WatchMediumPicker({ value, onChange }: WatchMediumPickerProps) {
<p className="mb-2 text-xs uppercase tracking-wide text-muted-foreground"> <p className="mb-2 text-xs uppercase tracking-wide text-muted-foreground">
{t("watchMedium.label")} {t("watchMedium.label")}
</p> </p>
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-2">
{WATCH_MEDIUMS.map(({ value: val, icon: Icon, labelKey }) => { {WATCH_MEDIUMS.map(({ value: val, icon: Icon, labelKey }) => {
const selected = value === val const selected = value === val
return ( return (
@@ -28,14 +28,14 @@ export function WatchMediumPicker({ value, onChange }: WatchMediumPickerProps) {
variant="outline" variant="outline"
size="icon" size="icon"
className={cn( className={cn(
"size-8", "size-11",
selected && "border-[var(--aero-primary)] bg-[var(--aero-primary)] text-white shadow-[0_0_8px_var(--aero-primary-glow)]", selected && "border-[var(--aero-primary)] bg-[var(--aero-primary)] text-white shadow-[0_0_8px_var(--aero-primary-glow)]",
)} )}
aria-label={t(labelKey)} aria-label={t(labelKey)}
aria-pressed={selected} aria-pressed={selected}
onClick={() => onChange(selected ? undefined : val)} onClick={() => onChange(selected ? undefined : val)}
> >
<Icon className="size-4" /> <Icon className="size-5" />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent sideOffset={4}>{t(labelKey)}</TooltipContent> <TooltipContent sideOffset={4}>{t(labelKey)}</TooltipContent>

View File

@@ -0,0 +1,74 @@
import { useCallback, useState } from "react"
import { useTranslation } from "react-i18next"
import { Clapperboard, Plus } from "lucide-react"
import { MovieCard } from "@/components/movie-card"
import { EmptyState } from "@/components/empty-state"
import { SwipeToDelete } from "@/components/swipe-to-delete"
import { VirtualList } from "@/components/virtual-list"
import { Button } from "@/components/ui/button"
import { SearchOverlay } from "@/components/search-overlay"
import type { MovieSelection } from "@/components/search-overlay"
import { useInfiniteWatchlist, useAddToWatchlist, useRemoveFromWatchlist } from "@/features/watchlist"
import { FeedSkeleton } from "@/components/feed-tab"
export function WatchlistTab() {
const { t } = useTranslation()
const { data, isPending, hasNextPage, isFetchingNextPage, fetchNextPage } =
useInfiniteWatchlist()
const items = data?.pages.flatMap((p) => p.items) ?? []
const addMutation = useAddToWatchlist()
const removeMutation = useRemoveFromWatchlist()
const loadMore = useCallback(() => fetchNextPage(), [fetchNextPage])
const [searchOpen, setSearchOpen] = useState(false)
function handleAdd(movie: MovieSelection) {
setSearchOpen(false)
addMutation.mutate(
movie.id
? { movie_id: movie.id }
: {
external_metadata_id: movie.external_metadata_id,
manual_title: movie.title,
manual_release_year: movie.release_year,
},
)
}
return (
<div className="space-y-2">
<Button variant="outline" size="sm" className="w-full" onClick={() => setSearchOpen(true)}>
<Plus className="mr-1 size-4" />
{t("feed.addToWatchlist")}
</Button>
{searchOpen && (
<SearchOverlay open onClose={() => setSearchOpen(false)} onSelect={handleAdd} />
)}
{isPending && <FeedSkeleton />}
{!isPending && !items.length && (
<EmptyState icon={Clapperboard} title={t("feed.watchlistEmpty")} description={t("feed.watchlistEmptyDesc")} />
)}
{items.length > 0 && (
<VirtualList
items={items}
estimateSize={110}
hasMore={!!hasNextPage}
isFetching={isFetchingNextPage}
onLoadMore={loadMore}
renderItem={(entry) => (
<SwipeToDelete
onDelete={() => removeMutation.mutate(entry.movie.id)}
confirmTitle={t("feed.removeFromWatchlist")}
confirmDescription={entry.movie.title}
>
<MovieCard movie={entry.movie} variant="full" />
</SwipeToDelete>
)}
/>
)}
</div>
)
}

View File

@@ -3,7 +3,7 @@ import { Lightbulb } from "lucide-react"
import { fmtUsd } from "@/lib/format" import { fmtUsd } from "@/lib/format"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { RevealCard } from "@/components/reveal-card" import { RevealCard } from "@/components/reveal-card"
import type { WrapUpReport } from "@/lib/api/wrapup" import type { WrapUpReport } from "@/features/wrapup"
export function FunFacts({ report, watchHours }: { report: WrapUpReport; watchHours: number }) { export function FunFacts({ report, watchHours }: { report: WrapUpReport; watchHours: number }) {
const { t } = useTranslation() const { t } = useTranslation()

View File

@@ -2,7 +2,7 @@ import { useTranslation } from "react-i18next"
import { Card, CardContent } from "@/components/ui/card" import { Card, CardContent } from "@/components/ui/card"
import { RevealCard } from "@/components/reveal-card" import { RevealCard } from "@/components/reveal-card"
import { useCountUp } from "@/hooks/use-animate" import { useCountUp } from "@/hooks/use-animate"
import type { WrapUpReport } from "@/lib/api/wrapup" import type { WrapUpReport } from "@/features/wrapup"
export function HeroCard({ report, watchHours }: { report: WrapUpReport; watchHours: number }) { export function HeroCard({ report, watchHours }: { report: WrapUpReport; watchHours: number }) {
const { t } = useTranslation() const { t } = useTranslation()

View File

@@ -4,7 +4,7 @@ import { Users } from "lucide-react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { tmdbProfileUrl } from "@/lib/api/client" import { tmdbProfileUrl } from "@/lib/api/client"
import type { PersonStat } from "@/lib/api/wrapup" import type { PersonStat } from "@/features/wrapup"
export function RankCard({ title, subtitle, items, profilePaths }: { title: string; subtitle: string; items: PersonStat[]; profilePaths?: string[] }) { export function RankCard({ title, subtitle, items, profilePaths }: { title: string; subtitle: string; items: PersonStat[]; profilePaths?: string[] }) {
const { t } = useTranslation() const { t } = useTranslation()

View File

@@ -1,10 +1,11 @@
import { useRef, useState } from "react" import { useEffect, useRef, useState } from "react"
import { createPortal } from "react-dom"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { Download, Share2, X } from "lucide-react" import { Download, Share2, X } from "lucide-react"
import html2canvas from "html2canvas-pro" import html2canvas from "html2canvas-pro"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { posterUrl } from "@/lib/api/client" import { posterUrl } from "@/lib/api/client"
import type { WrapUpReport } from "@/lib/api/wrapup" import type { WrapUpReport } from "@/features/wrapup"
const logoSrc = `${import.meta.env.BASE_URL}logo.webp` const logoSrc = `${import.meta.env.BASE_URL}logo.webp`
const bgSrc = `${import.meta.env.BASE_URL}shareable_bg.jpg` const bgSrc = `${import.meta.env.BASE_URL}shareable_bg.jpg`
@@ -18,6 +19,11 @@ export function WrapUpShareCard({ report, onClose }: Props) {
const cardRef = useRef<HTMLDivElement>(null) const cardRef = useRef<HTMLDivElement>(null)
const [exporting, setExporting] = useState(false) const [exporting, setExporting] = useState(false)
useEffect(() => {
document.body.style.overflow = "hidden"
return () => { document.body.style.overflow = "" }
}, [])
const watchHours = Math.round(report.total_watch_time_minutes / 60) const watchHours = Math.round(report.total_watch_time_minutes / 60)
const topGenre = report.top_genres[0]?.genre const topGenre = report.top_genres[0]?.genre
const topDirector = report.top_directors[0]?.name const topDirector = report.top_directors[0]?.name
@@ -54,7 +60,7 @@ export function WrapUpShareCard({ report, onClose }: Props) {
} }
} }
return ( return createPortal(
<div className="fixed inset-0 z-50 flex flex-col items-center justify-center bg-black/80 p-4"> <div className="fixed inset-0 z-50 flex flex-col items-center justify-center bg-black/80 p-4">
<div className="mb-4 flex w-full max-w-sm items-center justify-between"> <div className="mb-4 flex w-full max-w-sm items-center justify-between">
<Button variant="ghost" size="icon" onClick={onClose} className="text-white"> <Button variant="ghost" size="icon" onClick={onClose} className="text-white">
@@ -66,7 +72,7 @@ export function WrapUpShareCard({ report, onClose }: Props) {
</Button> </Button>
</div> </div>
<div className="max-h-[75vh] overflow-y-auto rounded-2xl"> <div className="max-h-[75vh] rounded-2xl">
<div <div
ref={cardRef} ref={cardRef}
className="relative w-[360px] overflow-hidden rounded-2xl" className="relative w-[360px] overflow-hidden rounded-2xl"
@@ -133,7 +139,8 @@ export function WrapUpShareCard({ report, onClose }: Props) {
</div> </div>
</div> </div>
</div> </div>
</div> </div>,
document.body,
) )
} }

View File

@@ -1,5 +1,8 @@
import { z } from "zod" import { z } from "zod"
import { API_URL, post } from "./client" import { useMutation, useQueryClient } from "@tanstack/react-query"
import { useAuth } from "@/components/auth-provider"
import { API_URL, post } from "@/lib/api/client"
import { getRefreshToken } from "@/lib/auth"
export const loginRequestSchema = z.object({ export const loginRequestSchema = z.object({
email: z.string(), email: z.string(),
@@ -24,20 +27,20 @@ export const registerRequestSchema = z.object({
}) })
export type RegisterRequest = z.infer<typeof registerRequestSchema> export type RegisterRequest = z.infer<typeof registerRequestSchema>
export function login(data: LoginRequest) {
return post<LoginResponse>("/auth/login", data)
}
export function register(data: RegisterRequest) {
return post("/auth/register", data)
}
export type RefreshResponse = { export type RefreshResponse = {
token: string token: string
refresh_token: string refresh_token: string
expires_at: string expires_at: string
} }
function login(data: LoginRequest) {
return post<LoginResponse>("/auth/login", data)
}
function register(data: RegisterRequest) {
return post("/auth/register", data)
}
export async function refreshToken( export async function refreshToken(
refresh_token: string, refresh_token: string,
): Promise<RefreshResponse> { ): Promise<RefreshResponse> {
@@ -50,6 +53,48 @@ export async function refreshToken(
return res.json() return res.json()
} }
export function apiLogout(refresh_token: string) { function apiLogout(refresh_token: string) {
return post("/auth/logout", { refresh_token }) return post("/auth/logout", { refresh_token })
} }
export function useLogin() {
const { login: setAuth } = useAuth()
const qc = useQueryClient()
return useMutation({
mutationFn: (data: LoginRequest) => login(data),
onSuccess: (res) => {
setAuth({
token: res.token,
refresh_token: res.refresh_token,
user_id: res.user_id,
email: res.email,
role: res.role,
expires_at: res.expires_at,
})
qc.clear()
},
})
}
export function useRegister() {
return useMutation({
mutationFn: (data: RegisterRequest) => register(data),
})
}
export function useLogout() {
const { logout } = useAuth()
const qc = useQueryClient()
return useMutation({
mutationFn: async () => {
const rt = getRefreshToken()
if (rt) {
try {
await apiLogout(rt)
} catch {}
}
logout()
qc.clear()
},
})
}

177
spa/src/features/diary.ts Normal file
View File

@@ -0,0 +1,177 @@
import { z } from "zod"
import {
useInfiniteQuery,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query"
import type { DiaryEntryDto, Paginated } from "@/lib/api/common"
import { diaryEntryDtoSchema, movieDtoSchema, paginatedSchema, reviewDtoSchema } from "@/lib/api/common"
import { del, get, patch, post } from "@/lib/api/client"
const PAGE_SIZE = 20
export const diaryQueryParamsSchema = z.object({
limit: z.number().optional(),
offset: z.number().optional(),
sort_by: z.string().optional(),
movie_id: z.string().uuid().optional(),
user_id: z.string().uuid().optional(),
})
export type DiaryQueryParams = z.infer<typeof diaryQueryParamsSchema>
export const diaryResponseSchema = paginatedSchema(diaryEntryDtoSchema)
export type DiaryResponse = Paginated<DiaryEntryDto>
export const logReviewRequestSchema = z.object({
external_metadata_id: z.string().optional(),
manual_title: z.string().optional(),
manual_release_year: z.number().optional(),
manual_director: z.string().optional(),
rating: z.number(),
comment: z.string().optional(),
watched_at: z.string(),
watch_medium: z.string().optional(),
})
export type LogReviewRequest = z.infer<typeof logReviewRequestSchema>
export const editReviewRequestSchema = z.object({
rating: z.number().optional(),
comment: z.string().nullable().optional(),
watched_at: z.string().optional(),
watch_medium: z.string().nullable().optional(),
})
export type EditReviewRequest = z.infer<typeof editReviewRequestSchema>
export const feedEntryDtoSchema = z.object({
movie: movieDtoSchema,
review: reviewDtoSchema,
user_id: z.string().uuid(),
user_display_name: z.string(),
is_federated: z.boolean(),
actor_url: z.string().optional(),
})
export type FeedEntryDto = z.infer<typeof feedEntryDtoSchema>
export const activityFeedQueryParamsSchema = z.object({
limit: z.number().optional(),
offset: z.number().optional(),
sort_by: z.string().optional(),
})
export type ActivityFeedQueryParams = z.infer<typeof activityFeedQueryParamsSchema>
export const activityFeedResponseSchema = paginatedSchema(feedEntryDtoSchema)
export type ActivityFeedResponse = Paginated<FeedEntryDto>
export const exportQueryParamsSchema = z.object({
format: z.string().optional(),
})
export type ExportQueryParams = z.infer<typeof exportQueryParamsSchema>
function getDiary(params?: DiaryQueryParams) {
return get<DiaryResponse>("/diary", params)
}
export function logReview(data: LogReviewRequest) {
return post("/reviews", data)
}
export function editReview(id: string, data: EditReviewRequest) {
return patch(`/reviews/${id}`, data)
}
function deleteReview(id: string) {
return del(`/reviews/${id}`)
}
function getActivityFeed(params?: ActivityFeedQueryParams) {
return get<ActivityFeedResponse>("/activity-feed", params)
}
export function exportDiary(params?: ExportQueryParams) {
return get<Blob>("/diary/export", params)
}
export const diaryKeys = {
all: ["diary"] as const,
list: (params?: Partial<DiaryQueryParams>) => [...diaryKeys.all, "list", params] as const,
infinite: (params?: Partial<DiaryQueryParams>) => [...diaryKeys.all, "infinite", params] as const,
feed: (params?: ActivityFeedQueryParams) =>
["activity-feed", params] as const,
}
export function useDiary(params?: DiaryQueryParams) {
return useQuery({
queryKey: diaryKeys.list(params),
queryFn: () => getDiary(params),
})
}
export function useInfiniteDiary(params?: Omit<DiaryQueryParams, "limit" | "offset">) {
return useInfiniteQuery({
queryKey: diaryKeys.infinite(params),
queryFn: ({ pageParam = 0 }) =>
getDiary({ ...params, limit: PAGE_SIZE, offset: pageParam }),
initialPageParam: 0,
getNextPageParam: (last) => {
const next = last.offset + last.limit
return next < last.total_count ? next : undefined
},
})
}
export function useActivityFeed(params?: ActivityFeedQueryParams) {
return useQuery({
queryKey: diaryKeys.feed(params),
queryFn: () => getActivityFeed(params),
})
}
export function useInfiniteActivityFeed(
params?: Omit<ActivityFeedQueryParams, "limit" | "offset">,
) {
return useInfiniteQuery({
queryKey: diaryKeys.feed(params),
queryFn: ({ pageParam = 0 }) =>
getActivityFeed({ ...params, limit: PAGE_SIZE, offset: pageParam }),
initialPageParam: 0,
getNextPageParam: (last) => {
const next = last.offset + last.limit
return next < last.total_count ? next : undefined
},
})
}
export function useLogReview() {
const qc = useQueryClient()
return useMutation({
mutationFn: (data: LogReviewRequest) => logReview(data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: diaryKeys.all })
qc.invalidateQueries({ queryKey: ["activity-feed"] })
},
})
}
export function useEditReview() {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ id, data }: { id: string; data: EditReviewRequest }) =>
editReview(id, data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: diaryKeys.all })
qc.invalidateQueries({ queryKey: ["activity-feed"] })
},
})
}
export function useDeleteReview() {
const qc = useQueryClient()
return useMutation({
mutationFn: (id: string) => deleteReview(id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: diaryKeys.all })
qc.invalidateQueries({ queryKey: ["activity-feed"] })
},
})
}

View File

@@ -1,19 +1,62 @@
import { z } from "zod"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { import { get, post, put, del } from "@/lib/api/client"
getGoals, import { goalDtoSchema, userKeys } from "@/features/users"
getUserGoals,
createGoal, export const goalsResponseSchema = z.object({
updateGoal, goals: z.array(goalDtoSchema),
deleteGoal, })
getSettings, export type GoalsResponse = z.infer<typeof goalsResponseSchema>
updateSettings,
} from "@/lib/api/goals" export type CreateGoalRequest = {
import type { year: number
CreateGoalRequest, target_count: number
UpdateGoalRequest, }
UpdateUserSettingsRequest,
} from "@/lib/api/goals" export type UpdateGoalRequest = {
import { userKeys } from "@/hooks/use-users" target_count: number
}
export const userSettingsDtoSchema = z.object({
federate_goals: z.boolean(),
federate_reviews: z.boolean(),
federate_watchlist: z.boolean(),
})
export type UserSettingsDto = z.infer<typeof userSettingsDtoSchema>
export type UpdateUserSettingsRequest = {
federate_goals: boolean
federate_reviews: boolean
federate_watchlist: boolean
}
function getGoals() {
return get<GoalsResponse>("/goals")
}
function getUserGoals(userId: string) {
return get<GoalsResponse>(`/users/${userId}/goals`)
}
function createGoal(data: CreateGoalRequest) {
return post<z.infer<typeof goalDtoSchema>>("/goals", data)
}
function updateGoal(year: number, data: UpdateGoalRequest) {
return put<z.infer<typeof goalDtoSchema>>(`/goals/${year}`, data)
}
function deleteGoal(year: number) {
return del(`/goals/${year}`)
}
function getSettings() {
return get<UserSettingsDto>("/settings")
}
function updateSettings(data: UpdateUserSettingsRequest) {
return put("/settings", data)
}
export const goalKeys = { export const goalKeys = {
all: ["goals"] as const, all: ["goals"] as const,

188
spa/src/features/imports.ts Normal file
View File

@@ -0,0 +1,188 @@
import { z } from "zod"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { del, get, post, put, uploadWithFields } from "@/lib/api/client"
export const sessionCreatedResponseSchema = z.object({
session_id: z.string(),
columns: z.array(z.string()),
sample_rows: z.array(z.array(z.string())),
})
export type SessionCreatedResponse = z.infer<typeof sessionCreatedResponseSchema>
export const sessionStateResponseSchema = z.object({
session_id: z.string(),
columns: z.array(z.string()),
has_mappings: z.boolean(),
row_count: z.number(),
})
export type SessionStateResponse = z.infer<typeof sessionStateResponseSchema>
export const apiFieldMappingSchema = z.object({
source_column: z.string(),
domain_field: z.string(),
rating_scale: z.number().optional(),
date_format: z.string().optional(),
})
export type ApiFieldMapping = z.infer<typeof apiFieldMappingSchema>
export const applyMappingRequestSchema = z.object({
mappings: z.array(apiFieldMappingSchema),
})
export type ApplyMappingRequest = z.infer<typeof applyMappingRequestSchema>
export const confirmRequestSchema = z.object({
confirmed_indices: z.array(z.number()),
})
export type ConfirmRequest = z.infer<typeof confirmRequestSchema>
export const saveProfileRequestSchema = z.object({
session_id: z.string(),
name: z.string(),
})
export type SaveProfileRequest = z.infer<typeof saveProfileRequestSchema>
export type PreviewRow = {
index: number
status: string
title?: string
release_year?: string
director?: string
rating?: string
watched_at?: string
comment?: string
errors?: string[]
}
export type PreviewResponse = {
rows: PreviewRow[]
}
export type ImportProfile = {
id: string
name: string
created_at: string
}
function createImportSession(file: File) {
const ext = file.name.split(".").pop()?.toLowerCase()
const format = ext === "json" ? "json" : "csv"
return uploadWithFields<SessionCreatedResponse>("/import/sessions", file, { format })
}
function getImportSession(id: string) {
return get<SessionStateResponse>(`/import/sessions/${id}`)
}
function getImportPreview(id: string) {
return get<PreviewResponse>(`/import/sessions/${id}/preview`)
}
function applyMapping(sessionId: string, data: ApplyMappingRequest) {
return put(`/import/sessions/${sessionId}/mapping`, data)
}
function confirmImport(sessionId: string, data: ConfirmRequest) {
return post(`/import/sessions/${sessionId}/confirm`, data)
}
function getImportProfiles() {
return get<ImportProfile[]>("/import/profiles")
}
function saveImportProfile(data: SaveProfileRequest) {
return post<{ id: string }>("/import/profiles", data)
}
function deleteImportProfile(id: string) {
return del(`/import/profiles/${id}`)
}
function applyImportProfile(sessionId: string, profileId: string) {
return put<{ row_count: number }>(`/import/sessions/${sessionId}/profile/${profileId}`)
}
export const importKeys = {
session: (id: string) => ["import-session", id] as const,
preview: (id: string) => ["import-preview", id] as const,
profiles: ["import-profiles"] as const,
}
export function useImportPreview(id: string) {
return useQuery({
queryKey: importKeys.preview(id),
queryFn: () => getImportPreview(id),
enabled: !!id,
})
}
export function useCreateImportSession() {
return useMutation({
mutationFn: (file: File) => createImportSession(file),
})
}
export function useImportSession(id: string) {
return useQuery({
queryKey: importKeys.session(id),
queryFn: () => getImportSession(id),
enabled: !!id,
})
}
export function useApplyMapping() {
return useMutation({
mutationFn: ({
sessionId,
data,
}: {
sessionId: string
data: ApplyMappingRequest
}) => applyMapping(sessionId, data),
})
}
export function useConfirmImport() {
return useMutation({
mutationFn: ({
sessionId,
data,
}: {
sessionId: string
data: ConfirmRequest
}) => confirmImport(sessionId, data),
})
}
export function useImportProfiles() {
return useQuery({
queryKey: importKeys.profiles,
queryFn: getImportProfiles,
})
}
export function useSaveImportProfile() {
const qc = useQueryClient()
return useMutation({
mutationFn: (data: SaveProfileRequest) => saveImportProfile(data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: importKeys.profiles })
},
})
}
export function useDeleteImportProfile() {
const qc = useQueryClient()
return useMutation({
mutationFn: (id: string) => deleteImportProfile(id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: importKeys.profiles })
},
})
}
export function useApplyImportProfile() {
return useMutation({
mutationFn: ({ sessionId, profileId }: { sessionId: string; profileId: string }) =>
applyImportProfile(sessionId, profileId),
})
}

View File

@@ -1,7 +1,8 @@
import { z } from "zod" import { z } from "zod"
import type { Paginated } from "./common" import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { movieDtoSchema, paginatedSchema, reviewDtoSchema } from "./common" import type { Paginated } from "@/lib/api/common"
import { get, post } from "./client" import { movieDtoSchema, paginatedSchema, reviewDtoSchema } from "@/lib/api/common"
import { get, post } from "@/lib/api/client"
export const moviesQueryParamsSchema = z.object({ export const moviesQueryParamsSchema = z.object({
limit: z.number().optional(), limit: z.number().optional(),
@@ -100,22 +101,71 @@ export const movieProfileResponseSchema = z.object({
}) })
export type MovieProfileResponse = z.infer<typeof movieProfileResponseSchema> export type MovieProfileResponse = z.infer<typeof movieProfileResponseSchema>
export function getMovies(params?: MoviesQueryParams) { function getMovies(params?: MoviesQueryParams) {
return get<MoviesResponse>("/movies", params) return get<MoviesResponse>("/movies", params)
} }
export function getMovie(id: string) { function getMovie(id: string) {
return get<MovieDetailResponse>(`/movies/${id}`) return get<MovieDetailResponse>(`/movies/${id}`)
} }
export function getMovieHistory(id: string) { function getMovieHistory(id: string) {
return get<ReviewHistoryResponse>(`/movies/${id}/history`) return get<ReviewHistoryResponse>(`/movies/${id}/history`)
} }
export function getMovieProfile(id: string) { function getMovieProfile(id: string) {
return get<MovieProfileResponse>(`/movies/${id}/profile`) return get<MovieProfileResponse>(`/movies/${id}/profile`)
} }
export function syncPoster(id: string) { function syncPoster(id: string) {
return post(`/movies/${id}/sync-poster`) return post(`/movies/${id}/sync-poster`)
} }
export const movieKeys = {
all: ["movies"] as const,
list: (params?: MoviesQueryParams) => [...movieKeys.all, params] as const,
detail: (id: string) => [...movieKeys.all, id] as const,
history: (id: string) => [...movieKeys.all, id, "history"] as const,
profile: (id: string) => [...movieKeys.all, id, "profile"] as const,
}
export function useMovies(params?: MoviesQueryParams) {
return useQuery({
queryKey: movieKeys.list(params),
queryFn: () => getMovies(params),
})
}
export function useMovie(id: string) {
return useQuery({
queryKey: movieKeys.detail(id),
queryFn: () => getMovie(id),
enabled: !!id,
})
}
export function useMovieHistory(id: string) {
return useQuery({
queryKey: movieKeys.history(id),
queryFn: () => getMovieHistory(id),
enabled: !!id,
})
}
export function useMovieProfile(id: string) {
return useQuery({
queryKey: movieKeys.profile(id),
queryFn: () => getMovieProfile(id),
enabled: !!id,
})
}
export function useSyncPoster() {
const qc = useQueryClient()
return useMutation({
mutationFn: (id: string) => syncPoster(id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: movieKeys.all })
},
})
}

View File

@@ -1,6 +1,9 @@
import { z } from "zod" import { z } from "zod"
import { paginatedSchema } from "./common" import { useInfiniteQuery, useQuery } from "@tanstack/react-query"
import { get } from "./client" import { paginatedSchema } from "@/lib/api/common"
import { get } from "@/lib/api/client"
const PAGE_SIZE = 20
export const searchQueryParamsSchema = z.object({ export const searchQueryParamsSchema = z.object({
q: z.string().optional(), q: z.string().optional(),
@@ -82,14 +85,61 @@ export const personCreditsDtoSchema = z.object({
}) })
export type PersonCreditsDto = z.infer<typeof personCreditsDtoSchema> export type PersonCreditsDto = z.infer<typeof personCreditsDtoSchema>
export function search(params: SearchQueryParams) { function search(params: SearchQueryParams) {
return get<SearchResponse>("/search", params) return get<SearchResponse>("/search", params)
} }
export function getPerson(id: string) { function getPerson(id: string) {
return get<PersonDto>(`/people/${id}`) return get<PersonDto>(`/people/${id}`)
} }
export function getPersonCredits(id: string) { function getPersonCredits(id: string) {
return get<PersonCreditsDto>(`/people/${id}/credits`) return get<PersonCreditsDto>(`/people/${id}/credits`)
} }
export const searchKeys = {
all: ["search"] as const,
query: (params: SearchQueryParams) => [...searchKeys.all, params] as const,
person: (id: string) => ["people", id] as const,
personCredits: (id: string) => ["people", id, "credits"] as const,
}
export function useSearch(params: SearchQueryParams) {
return useQuery({
queryKey: searchKeys.query(params),
queryFn: () => search(params),
enabled: !!params.q || !!params.genre || !!params.person_id,
})
}
export function useInfiniteSearch(
params: Omit<SearchQueryParams, "limit" | "offset">,
) {
return useInfiniteQuery({
queryKey: searchKeys.query(params),
queryFn: ({ pageParam = 0 }) =>
search({ ...params, limit: PAGE_SIZE, offset: pageParam }),
initialPageParam: 0,
getNextPageParam: (last) => {
const next = last.movies.offset + last.movies.limit
return next < last.movies.total_count ? next : undefined
},
enabled: !!params.q || !!params.genre || !!params.person_id,
})
}
export function usePerson(id: string) {
return useQuery({
queryKey: searchKeys.person(id),
queryFn: () => getPerson(id),
enabled: !!id,
})
}
export function usePersonCredits(id: string) {
return useQuery({
queryKey: searchKeys.personCredits(id),
queryFn: () => getPersonCredits(id),
enabled: !!id,
})
}

View File

@@ -1,27 +1,113 @@
import { z } from "zod"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { import { del, get, post } from "@/lib/api/client"
acceptFollower,
addBlockedDomain, export const remoteActorDtoSchema = z.object({
blockActor, handle: z.string(),
follow, display_name: z.string().optional(),
getBlockedActors, url: z.string(),
getBlockedDomains, })
getFollowers, export type RemoteActorDto = z.infer<typeof remoteActorDtoSchema>
getFollowing,
getPendingFollowers, export const actorListResponseSchema = z.object({
getUserFollowers, actors: z.array(remoteActorDtoSchema),
getUserFollowing, })
rejectFollower, export type ActorListResponse = z.infer<typeof actorListResponseSchema>
removeBlockedDomain,
removeFollower, export const followRequestSchema = z.object({
unblockActor, handle: z.string(),
unfollow, })
} from "@/lib/api/social" export type FollowRequest = z.infer<typeof followRequestSchema>
import type {
ActorUrlRequest, export const actorUrlRequestSchema = z.object({
AddBlockedDomainRequest, actor_url: z.string(),
FollowRequest, })
} from "@/lib/api/social" export type ActorUrlRequest = z.infer<typeof actorUrlRequestSchema>
export const blockedDomainResponseSchema = z.object({
domain: z.string(),
reason: z.string().optional(),
blocked_at: z.string(),
})
export type BlockedDomainResponse = z.infer<typeof blockedDomainResponseSchema>
export const addBlockedDomainRequestSchema = z.object({
domain: z.string(),
reason: z.string().optional(),
})
export type AddBlockedDomainRequest = z.infer<typeof addBlockedDomainRequestSchema>
export const blockedActorResponseSchema = z.object({
url: z.string(),
handle: z.string(),
display_name: z.string().optional(),
avatar_url: z.string().optional(),
})
export type BlockedActorResponse = z.infer<typeof blockedActorResponseSchema>
function getFollowing() {
return get<ActorListResponse>("/social/following")
}
function getFollowers() {
return get<ActorListResponse>("/social/followers")
}
function getUserFollowing(userId: string) {
return get<ActorListResponse>(`/users/${userId}/following`)
}
function getUserFollowers(userId: string) {
return get<ActorListResponse>(`/users/${userId}/followers`)
}
function getPendingFollowers() {
return get<ActorListResponse>("/social/followers/pending")
}
function follow(data: FollowRequest) {
return post("/social/follow", data)
}
function unfollow(data: ActorUrlRequest) {
return post("/social/unfollow", data)
}
function acceptFollower(data: ActorUrlRequest) {
return post("/social/followers/accept", data)
}
function rejectFollower(data: ActorUrlRequest) {
return post("/social/followers/reject", data)
}
function removeFollower(data: ActorUrlRequest) {
return post("/social/followers/remove", data)
}
function getBlockedDomains() {
return get<BlockedDomainResponse[]>("/admin/blocked-domains")
}
function addBlockedDomain(data: AddBlockedDomainRequest) {
return post("/admin/blocked-domains", data)
}
function removeBlockedDomain(domain: string) {
return del(`/admin/blocked-domains/${domain}`)
}
function blockActor(data: ActorUrlRequest) {
return post("/social/block", data)
}
function unblockActor(data: ActorUrlRequest) {
return post("/social/unblock", data)
}
function getBlockedActors() {
return get<BlockedActorResponse[]>("/social/blocked")
}
export const socialKeys = { export const socialKeys = {
following: ["following"] as const, following: ["following"] as const,

View File

@@ -1,6 +1,7 @@
import { z } from "zod" import { z } from "zod"
import { diaryEntryDtoSchema, paginatedSchema } from "./common" import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { get, post, put, putForm } from "./client" import { diaryEntryDtoSchema, paginatedSchema } from "@/lib/api/common"
import { get, post, put, putForm } from "@/lib/api/client"
export const userSummaryDtoSchema = z.object({ export const userSummaryDtoSchema = z.object({
id: z.string().uuid(), id: z.string().uuid(),
@@ -47,10 +48,25 @@ export const directorStatDtoSchema = z.object({
}) })
export type DirectorStatDto = z.infer<typeof directorStatDtoSchema> export type DirectorStatDto = z.infer<typeof directorStatDtoSchema>
export const genreStatDtoSchema = z.object({
genre: z.string(),
count: z.number(),
})
export type GenreStatDto = z.infer<typeof genreStatDtoSchema>
export const watchMediumStatDtoSchema = z.object({
medium: z.string(),
count: z.number(),
})
export type WatchMediumStatDto = z.infer<typeof watchMediumStatDtoSchema>
export const userTrendsDtoSchema = z.object({ export const userTrendsDtoSchema = z.object({
monthly_ratings: z.array(monthlyRatingDtoSchema), monthly_ratings: z.array(monthlyRatingDtoSchema),
top_directors: z.array(directorStatDtoSchema), top_directors: z.array(directorStatDtoSchema),
max_director_count: z.number(), max_director_count: z.number(),
top_genres: z.array(genreStatDtoSchema).default([]),
rating_distribution: z.array(z.number()).default([]),
watch_medium_distribution: z.array(watchMediumStatDtoSchema).default([]),
}) })
export type UserTrendsDto = z.infer<typeof userTrendsDtoSchema> export type UserTrendsDto = z.infer<typeof userTrendsDtoSchema>
@@ -62,8 +78,6 @@ export const monthActivityDtoSchema = z.object({
}) })
export type MonthActivityDto = z.infer<typeof monthActivityDtoSchema> export type MonthActivityDto = z.infer<typeof monthActivityDtoSchema>
const userDiaryResponseSchema = paginatedSchema(diaryEntryDtoSchema)
export const goalDtoSchema = z.object({ export const goalDtoSchema = z.object({
year: z.number(), year: z.number(),
target_count: z.number(), target_count: z.number(),
@@ -74,6 +88,8 @@ export const goalDtoSchema = z.object({
}) })
export type GoalDto = z.infer<typeof goalDtoSchema> export type GoalDto = z.infer<typeof goalDtoSchema>
const userDiaryResponseSchema = paginatedSchema(diaryEntryDtoSchema)
export const userProfileResponseSchema = z.object({ export const userProfileResponseSchema = z.object({
user_id: z.string().uuid(), user_id: z.string().uuid(),
username: z.string(), username: z.string(),
@@ -116,18 +132,6 @@ export const updateProfileFieldsRequestSchema = z.object({
}) })
export type UpdateProfileFieldsRequest = z.infer<typeof updateProfileFieldsRequestSchema> export type UpdateProfileFieldsRequest = z.infer<typeof updateProfileFieldsRequestSchema>
export function getUsers() {
return get<UsersResponse>("/users")
}
export function getUserProfile(id: string, params?: UserProfileQueryParams) {
return get<UserProfileResponse>(`/users/${id}`, params)
}
export function getProfile() {
return get<ProfileResponse>("/profile")
}
export type UpdateProfileData = { export type UpdateProfileData = {
display_name?: string display_name?: string
bio?: string bio?: string
@@ -136,7 +140,19 @@ export type UpdateProfileData = {
banner?: File banner?: File
} }
export function updateProfile(data: UpdateProfileData) { function getUsers() {
return get<UsersResponse>("/users")
}
function getUserProfile(id: string, params?: UserProfileQueryParams) {
return get<UserProfileResponse>(`/users/${id}`, params)
}
function getProfile() {
return get<ProfileResponse>("/profile")
}
function updateProfile(data: UpdateProfileData) {
const form = new FormData() const form = new FormData()
if (data.display_name != null) form.append("display_name", data.display_name) if (data.display_name != null) form.append("display_name", data.display_name)
if (data.also_known_as != null) form.append("also_known_as", data.also_known_as) if (data.also_known_as != null) form.append("also_known_as", data.also_known_as)
@@ -146,10 +162,60 @@ export function updateProfile(data: UpdateProfileData) {
return putForm("/profile", form) return putForm("/profile", form)
} }
export function updateProfileFields(data: UpdateProfileFieldsRequest) { function updateProfileFields(data: UpdateProfileFieldsRequest) {
return put("/profile/fields", data) return put("/profile/fields", data)
} }
export function reindexSearch() { export function reindexSearch() {
return post("/admin/reindex-search") return post("/admin/reindex-search")
} }
export const userKeys = {
all: ["users"] as const,
list: () => [...userKeys.all, "list"] as const,
profile: (id: string, params?: UserProfileQueryParams) =>
[...userKeys.all, id, params] as const,
me: ["profile"] as const,
}
export function useUsers() {
return useQuery({
queryKey: userKeys.list(),
queryFn: getUsers,
})
}
export function useUserProfile(id: string, params?: UserProfileQueryParams) {
return useQuery({
queryKey: userKeys.profile(id, params),
queryFn: () => getUserProfile(id, params),
enabled: !!id,
})
}
export function useProfile() {
return useQuery({
queryKey: userKeys.me,
queryFn: getProfile,
})
}
export function useUpdateProfile() {
const qc = useQueryClient()
return useMutation({
mutationFn: (data: UpdateProfileData) => updateProfile(data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: userKeys.me })
},
})
}
export function useUpdateProfileFields() {
const qc = useQueryClient()
return useMutation({
mutationFn: (data: UpdateProfileFieldsRequest) => updateProfileFields(data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: userKeys.me })
},
})
}

View File

@@ -1,19 +1,55 @@
import { z } from "zod"
import { import {
useInfiniteQuery, useInfiniteQuery,
useMutation, useMutation,
useQuery, useQuery,
useQueryClient, useQueryClient,
} from "@tanstack/react-query" } from "@tanstack/react-query"
import { import type { Paginated } from "@/lib/api/common"
addToWatchlist, import { movieDtoSchema, paginatedSchema } from "@/lib/api/common"
getWatchlist, import { del, get, post } from "@/lib/api/client"
getWatchlistStatus,
removeFromWatchlist,
} from "@/lib/api/watchlist"
import type { AddToWatchlistRequest } from "@/lib/api/watchlist"
const PAGE_SIZE = 20 const PAGE_SIZE = 20
export const watchlistEntryDtoSchema = z.object({
id: z.string().uuid(),
movie: movieDtoSchema,
added_at: z.string(),
})
export type WatchlistEntryDto = z.infer<typeof watchlistEntryDtoSchema>
export const watchlistResponseSchema = paginatedSchema(watchlistEntryDtoSchema)
export type WatchlistResponse = Paginated<WatchlistEntryDto>
export const addToWatchlistRequestSchema = z.object({
movie_id: z.string().uuid().optional(),
external_metadata_id: z.string().optional(),
manual_title: z.string().optional(),
manual_release_year: z.number().optional(),
})
export type AddToWatchlistRequest = z.infer<typeof addToWatchlistRequestSchema>
export const watchlistStatusResponseSchema = z.object({
on_watchlist: z.boolean(),
})
export type WatchlistStatusResponse = z.infer<typeof watchlistStatusResponseSchema>
function getWatchlist(params?: { limit?: number; offset?: number }) {
return get<WatchlistResponse>("/watchlist", params)
}
function getWatchlistStatus(movieId: string) {
return get<WatchlistStatusResponse>(`/watchlist/${movieId}`)
}
function addToWatchlist(data: AddToWatchlistRequest) {
return post("/watchlist", data)
}
function removeFromWatchlist(movieId: string) {
return del(`/watchlist/${movieId}`)
}
export const watchlistKeys = { export const watchlistKeys = {
all: ["watchlist"] as const, all: ["watchlist"] as const,
list: () => [...watchlistKeys.all, "list"] as const, list: () => [...watchlistKeys.all, "list"] as const,

View File

@@ -1,5 +1,6 @@
import { z } from "zod" import { z } from "zod"
import { del, get, post } from "./client" import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { del, get, post } from "@/lib/api/client"
export const webhookTokenDtoSchema = z.object({ export const webhookTokenDtoSchema = z.object({
id: z.string(), id: z.string(),
@@ -59,26 +60,85 @@ export const dismissWatchResponseSchema = z.object({
}) })
export type DismissWatchResponse = z.infer<typeof dismissWatchResponseSchema> export type DismissWatchResponse = z.infer<typeof dismissWatchResponseSchema>
export function getWebhookTokens() { function getWebhookTokens() {
return get<WebhookTokenDto[]>("/settings/webhook-tokens") return get<WebhookTokenDto[]>("/settings/webhook-tokens")
} }
export function generateToken(data: GenerateTokenRequest) { function generateToken(data: GenerateTokenRequest) {
return post<GenerateTokenResponse>("/settings/webhook-tokens", data) return post<GenerateTokenResponse>("/settings/webhook-tokens", data)
} }
export function deleteToken(id: string) { function deleteToken(id: string) {
return del(`/settings/webhook-tokens/${id}`) return del(`/settings/webhook-tokens/${id}`)
} }
export function getWatchQueue() { function getWatchQueue() {
return get<WatchQueueEntryDto[]>("/watch-queue") return get<WatchQueueEntryDto[]>("/watch-queue")
} }
export function confirmWatch(data: ConfirmWatchRequest) { function confirmWatch(data: ConfirmWatchRequest) {
return post<ConfirmWatchResponse>("/watch-queue/confirm", data) return post<ConfirmWatchResponse>("/watch-queue/confirm", data)
} }
export function dismissWatch(data: DismissWatchRequest) { function dismissWatch(data: DismissWatchRequest) {
return post<DismissWatchResponse>("/watch-queue/dismiss", data) return post<DismissWatchResponse>("/watch-queue/dismiss", data)
} }
export const webhookKeys = {
tokens: ["webhook-tokens"] as const,
queue: ["watch-queue"] as const,
}
export function useWebhookTokens() {
return useQuery({
queryKey: webhookKeys.tokens,
queryFn: getWebhookTokens,
})
}
export function useGenerateToken() {
const qc = useQueryClient()
return useMutation({
mutationFn: (data: GenerateTokenRequest) => generateToken(data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: webhookKeys.tokens })
},
})
}
export function useDeleteToken() {
const qc = useQueryClient()
return useMutation({
mutationFn: (id: string) => deleteToken(id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: webhookKeys.tokens })
},
})
}
export function useWatchQueue() {
return useQuery({
queryKey: webhookKeys.queue,
queryFn: getWatchQueue,
})
}
export function useConfirmWatch() {
const qc = useQueryClient()
return useMutation({
mutationFn: (data: ConfirmWatchRequest) => confirmWatch(data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: webhookKeys.queue })
},
})
}
export function useDismissWatch() {
const qc = useQueryClient()
return useMutation({
mutationFn: (data: DismissWatchRequest) => dismissWatch(data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: webhookKeys.queue })
},
})
}

View File

@@ -1,5 +1,6 @@
import { z } from "zod" import { z } from "zod"
import { del, get, post } from "./client" import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { del, get, post } from "@/lib/api/client"
export const generateWrapUpRequestSchema = z.object({ export const generateWrapUpRequestSchema = z.object({
start_date: z.string(), start_date: z.string(),
@@ -30,22 +31,6 @@ export const wrapUpListResponseSchema = z.object({
}) })
export type WrapUpListResponse = z.infer<typeof wrapUpListResponseSchema> export type WrapUpListResponse = z.infer<typeof wrapUpListResponseSchema>
export function generateWrapUp(data: GenerateWrapUpRequest) {
return post<WrapUpGeneratedResponse>("/wrapups/generate", data)
}
export function getWrapUps() {
return get<WrapUpListResponse>("/wrapups")
}
export function getWrapUp(id: string) {
return get<WrapUpStatusResponse>(`/wrapups/${id}`)
}
export function deleteWrapUp(id: string) {
return del(`/wrapups/${id}`)
}
export type MovieRef = { export type MovieRef = {
movie_id?: string movie_id?: string
title: string title: string
@@ -114,10 +99,77 @@ export type WrapUpReport = {
lowest_rated_movie?: MovieRef lowest_rated_movie?: MovieRef
first_movie_of_period?: MovieRef first_movie_of_period?: MovieRef
last_movie_of_period?: MovieRef last_movie_of_period?: MovieRef
watch_medium_distribution: { medium: string; count: number }[]
poster_paths: string[] poster_paths: string[]
top_cast_profile_paths: string[] top_cast_profile_paths: string[]
} }
export function getWrapUpReport(id: string) { function generateWrapUp(data: GenerateWrapUpRequest) {
return post<WrapUpGeneratedResponse>("/wrapups/generate", data)
}
function getWrapUps() {
return get<WrapUpListResponse>("/wrapups")
}
function getWrapUp(id: string) {
return get<WrapUpStatusResponse>(`/wrapups/${id}`)
}
function deleteWrapUp(id: string) {
return del(`/wrapups/${id}`)
}
function getWrapUpReport(id: string) {
return get<WrapUpReport>(`/wrapups/${id}/report`) return get<WrapUpReport>(`/wrapups/${id}/report`)
} }
export const wrapupKeys = {
all: ["wrapups"] as const,
list: () => [...wrapupKeys.all, "list"] as const,
detail: (id: string) => [...wrapupKeys.all, id] as const,
report: (id: string) => [...wrapupKeys.all, id, "report"] as const,
}
export function useWrapUpReport(id: string) {
return useQuery({
queryKey: wrapupKeys.report(id),
queryFn: () => getWrapUpReport(id),
enabled: !!id,
})
}
export function useWrapUps() {
return useQuery({
queryKey: wrapupKeys.list(),
queryFn: getWrapUps,
})
}
export function useWrapUp(id: string) {
return useQuery({
queryKey: wrapupKeys.detail(id),
queryFn: () => getWrapUp(id),
enabled: !!id,
})
}
export function useGenerateWrapUp() {
const qc = useQueryClient()
return useMutation({
mutationFn: (data: GenerateWrapUpRequest) => generateWrapUp(data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: wrapupKeys.all })
},
})
}
export function useDeleteWrapUp() {
const qc = useQueryClient()
return useMutation({
mutationFn: (id: string) => deleteWrapUp(id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: wrapupKeys.all })
},
})
}

View File

@@ -1,47 +0,0 @@
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { useAuth } from "@/components/auth-provider"
import { apiLogout, login, register } from "@/lib/api/auth"
import type { LoginRequest, RegisterRequest } from "@/lib/api/auth"
import { getRefreshToken } from "@/lib/auth"
export function useLogin() {
const { login: setAuth } = useAuth()
const qc = useQueryClient()
return useMutation({
mutationFn: (data: LoginRequest) => login(data),
onSuccess: (res) => {
setAuth({
token: res.token,
refresh_token: res.refresh_token,
user_id: res.user_id,
email: res.email,
role: res.role,
expires_at: res.expires_at,
})
qc.clear()
},
})
}
export function useRegister() {
return useMutation({
mutationFn: (data: RegisterRequest) => register(data),
})
}
export function useLogout() {
const { logout } = useAuth()
const qc = useQueryClient()
return useMutation({
mutationFn: async () => {
const rt = getRefreshToken()
if (rt) {
try {
await apiLogout(rt)
} catch {}
}
logout()
qc.clear()
},
})
}

Some files were not shown because too many files have changed in this diff Show More