Compare commits
23 Commits
498f3b1818
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 22b1dd3f56 | |||
| 813778dc7e | |||
| 063bab910b | |||
| 839cababe4 | |||
| fdf286ca94 | |||
| e30e785936 | |||
| 10be024bdf | |||
| 1e33c6d184 | |||
| 96168cfc7b | |||
| 95b839355f | |||
| 85285b2a52 | |||
| c3d8bcad29 | |||
| 8aa64ab174 | |||
| d84c54bb30 | |||
| 41bed3583d | |||
| 0a8b52514d | |||
| a0c0ba1c5d | |||
| 94cab1ea7c | |||
| 3dde0d13db | |||
| 1c81d7768c | |||
| 07df6fe207 | |||
| 1a7448fd3d | |||
| 587dcc04de |
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
@@ -57,8 +57,8 @@ jobs:
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GHCR_TOKEN || github.token }}
|
||||
|
||||
- name: Docker metadata
|
||||
id: meta
|
||||
|
||||
8
Cargo.lock
generated
8
Cargo.lock
generated
@@ -2892,9 +2892,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "k-ap"
|
||||
version = "0.4.2"
|
||||
version = "0.5.0"
|
||||
source = "sparse+https://git.gabrielkaszewski.dev/api/packages/GKaszewski/cargo/"
|
||||
checksum = "4291cac43b119cce0be6e2ba8d85339f3f4c69b266cb7c00fb4cb179302b97e4"
|
||||
checksum = "ab6066cccc6ae8aaa2f6262ac7d471e58930a04be3266a2e367d6bdd8aaaba29"
|
||||
dependencies = [
|
||||
"activitypub_federation",
|
||||
"anyhow",
|
||||
@@ -2903,9 +2903,11 @@ dependencies = [
|
||||
"chrono",
|
||||
"enum_delegate",
|
||||
"futures",
|
||||
"paste",
|
||||
"reqwest 0.13.3",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"url",
|
||||
@@ -6714,7 +6716,7 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -98,7 +98,6 @@ infra-wiring = { path = "crates/infra-wiring" }
|
||||
|
||||
[profile.dev]
|
||||
debug = 1 # line tables only — still debuggable, much faster linking
|
||||
split-debuginfo = "unpacked" # macOS: skip dsymutil on every link
|
||||
|
||||
[profile.dev.package."*"]
|
||||
opt-level = 2 # compile deps faster at runtime; paid once, cached after
|
||||
|
||||
12
README.md
12
README.md
@@ -47,7 +47,7 @@ Open `http://localhost:3000`. The HTTP server and background worker start togeth
|
||||
|
||||
## Features
|
||||
|
||||
- Log movies with a TMDB/OMDb ID or manual title/year/director, with a 0–5 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 1–5 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)
|
||||
- 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`
|
||||
@@ -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
|
||||
- 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`
|
||||
- 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)
|
||||
- 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
|
||||
- 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
|
||||
@@ -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
|
||||
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
|
||||
application — use cases (commands + queries), business logic orchestration; handlers delegate here for all domain logic
|
||||
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; 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
|
||||
worker — standalone worker binary (event consumer, poster sync, federation)
|
||||
adapters/
|
||||
@@ -101,7 +102,7 @@ adapters/
|
||||
postgres — PostgreSQL repository + connection factory
|
||||
metadata — TMDB / OMDb HTTP client
|
||||
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
|
||||
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
|
||||
@@ -114,6 +115,7 @@ adapters/
|
||||
event-payload — shared event serialization DTOs (used by all event bus adapters)
|
||||
sqlite-event-queue — durable polling event queue backed by SQLite
|
||||
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
|
||||
event-publisher — in-memory event channel (used in tests)
|
||||
activitypub — ActivityPub federation adapter (follow, inbox/outbox, actor); delegates to k-ap for protocol internals
|
||||
|
||||
@@ -24,6 +24,7 @@ graph TB
|
||||
UC_INTEGRATIONS["integrations<br/>webhooks, watch_queue,<br/>confirm, dismiss"]
|
||||
UC_SEARCH["search<br/>execute"]
|
||||
UC_PERSON["person<br/>get, get_credits"]
|
||||
UC_SOCIAL["social<br/>follow, unfollow,<br/>accept, reject, block"]
|
||||
end
|
||||
subgraph EventHandlers["Event Handlers"]
|
||||
EH_MOVIE["MovieEnrichmentHandler<br/><i>on MovieEnrichmentRequested</i>"]
|
||||
@@ -59,10 +60,10 @@ graph TB
|
||||
M_SEARCH["SearchQuery,<br/>SearchResults"]
|
||||
end
|
||||
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_SERVICES["AuthService<br/>MetadataClient<br/>PosterFetcherClient<br/>ImageFetcher<br/>ObjectStorage<br/>EventPublisher<br/>EventConsumer<br/>PasswordHasher<br/>DiaryExporter<br/>DocumentParser<br/>RssFeedRenderer"]
|
||||
P_SEARCH["SearchPort<br/>SearchCommand<br/>PersonQuery<br/>PersonCommand"]
|
||||
P_FEDERATION["SocialQueryPort<br/>LocalApContentQuery<br/>RemoteWatchlistRepository<br/>RemoteGoalRepository"]
|
||||
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 / MovieEnrichmentClient<br/>PersonEnrichmentClient<br/>PosterFetcherClient<br/>ImageFetcher / ObjectStorage<br/>EventPublisher / EventConsumer<br/>PasswordHasher<br/>DiaryExporter / DocumentParser<br/>RssFeedRenderer / MediaServerParser"]
|
||||
P_SEARCH["SearchPort / SearchCommand<br/>PersonQuery / PersonCommand<br/>FederatedProfileQuery"]
|
||||
P_FEDERATION["SocialCommand / SocialQuery<br/>FederationAdminQuery<br/>LocalApContentQuery<br/>RemoteWatchlistRepository<br/>RemoteGoalRepository"]
|
||||
end
|
||||
subgraph DomainServices["Services (pure, no I/O)"]
|
||||
DS_WRAPUP["WrapUpAnalyzer<br/><i>build_report, compute_*</i>"]
|
||||
@@ -92,7 +93,9 @@ graph TB
|
||||
end
|
||||
subgraph Messaging["Messaging"]
|
||||
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_EVT_PUB["event-publisher<br/><i>In-memory (tests)</i>"]
|
||||
A_PAYLOAD["event-payload<br/><i>Serde (de)serialization</i>"]
|
||||
end
|
||||
subgraph External["External Services"]
|
||||
|
||||
@@ -4,7 +4,7 @@ version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
k-ap = { version = "0.4.2", registry = "gitea" }
|
||||
k-ap = { version = "0.5.0", registry = "gitea" }
|
||||
domain = { workspace = true }
|
||||
axum = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use k_ap::{ApContentReader, ApObjectHandler};
|
||||
use k_ap::{ApContentReader, ApObjectHandler, LocalObject};
|
||||
use url::Url;
|
||||
|
||||
use crate::{
|
||||
@@ -21,10 +20,9 @@ impl ApContentReader for CompositeObjectHandler {
|
||||
async fn get_local_objects_page(
|
||||
&self,
|
||||
user_id: uuid::Uuid,
|
||||
before: Option<DateTime<Utc>>,
|
||||
before: Option<chrono::DateTime<chrono::Utc>>,
|
||||
limit: usize,
|
||||
) -> anyhow::Result<Vec<(Url, serde_json::Value, DateTime<Utc>)>> {
|
||||
// Fetch from all three sources (watchlist/goals return all, reviews use DB pagination)
|
||||
) -> anyhow::Result<Vec<LocalObject>> {
|
||||
let fetch_limit = limit * 3;
|
||||
let reviews = self
|
||||
.review
|
||||
@@ -39,16 +37,15 @@ impl ApContentReader for CompositeObjectHandler {
|
||||
.get_local_objects_page(user_id, None, usize::MAX)
|
||||
.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(watchlist);
|
||||
all.extend(goals);
|
||||
|
||||
// Apply before filter and sort descending by timestamp
|
||||
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);
|
||||
Ok(all)
|
||||
}
|
||||
|
||||
@@ -217,7 +217,7 @@ impl ActivityPubEventHandler {
|
||||
let json = serde_json::to_value(obj)?;
|
||||
|
||||
self.ap_service
|
||||
.broadcast_create_note(user_id.value(), json, ApVisibility::Public, vec![])
|
||||
.broadcast_create(user_id.value(), json, ApVisibility::Public, vec![])
|
||||
.await?;
|
||||
|
||||
let year = review.watched_at().year() as u16;
|
||||
@@ -283,7 +283,7 @@ impl ActivityPubEventHandler {
|
||||
let json = serde_json::to_value(obj)?;
|
||||
|
||||
self.ap_service
|
||||
.broadcast_update_note(user_id.value(), json, ApVisibility::Public, vec![])
|
||||
.broadcast_update(user_id.value(), json, ApVisibility::Public, vec![])
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
@@ -349,7 +349,7 @@ impl ActivityPubEventHandler {
|
||||
let json = serde_json::to_value(obj)?;
|
||||
|
||||
self.ap_service
|
||||
.broadcast_create_note(user_id.value(), json, ApVisibility::Public, vec![])
|
||||
.broadcast_create(user_id.value(), json, ApVisibility::Public, vec![])
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -416,7 +416,7 @@ impl ActivityPubEventHandler {
|
||||
let json = serde_json::to_value(obj)?;
|
||||
|
||||
self.ap_service
|
||||
.broadcast_update_note(user_id.value(), json, ApVisibility::Public, vec![])
|
||||
.broadcast_update(user_id.value(), json, ApVisibility::Public, vec![])
|
||||
.await?;
|
||||
}
|
||||
|
||||
@@ -462,7 +462,7 @@ impl ActivityPubEventHandler {
|
||||
);
|
||||
let json = serde_json::to_value(obj)?;
|
||||
self.ap_service
|
||||
.broadcast_update_note(user_id.value(), json, ApVisibility::Public, vec![])
|
||||
.broadcast_update(user_id.value(), json, ApVisibility::Public, vec![])
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -494,11 +494,11 @@ impl ActivityPubEventHandler {
|
||||
let json = serde_json::to_value(obj)?;
|
||||
if is_create {
|
||||
self.ap_service
|
||||
.broadcast_create_note(user_id.value(), json, ApVisibility::Public, vec![])
|
||||
.broadcast_create(user_id.value(), json, ApVisibility::Public, vec![])
|
||||
.await?;
|
||||
} else {
|
||||
self.ap_service
|
||||
.broadcast_update_note(user_id.value(), json, ApVisibility::Public, vec![])
|
||||
.broadcast_update(user_id.value(), json, ApVisibility::Public, vec![])
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -7,7 +7,7 @@ use domain::{
|
||||
ports::{GoalQuery, RemoteGoalRepository},
|
||||
value_objects::UserId,
|
||||
};
|
||||
use k_ap::{ApContentReader, ApObjectHandler};
|
||||
use k_ap::{AS_PUBLIC, ApContentReader, ApObjectHandler, LocalObject};
|
||||
use url::Url;
|
||||
|
||||
use crate::objects::{GoalObject, goal_to_ap_object};
|
||||
@@ -26,7 +26,7 @@ impl ApContentReader for GoalObjectHandler {
|
||||
user_id: uuid::Uuid,
|
||||
_before: Option<DateTime<chrono::Utc>>,
|
||||
_limit: usize,
|
||||
) -> anyhow::Result<Vec<(Url, serde_json::Value, DateTime<chrono::Utc>)>> {
|
||||
) -> anyhow::Result<Vec<LocalObject>> {
|
||||
let uid = UserId::from_uuid(user_id);
|
||||
let goals = self
|
||||
.goal_repo
|
||||
@@ -35,6 +35,7 @@ impl ApContentReader for GoalObjectHandler {
|
||||
.map_err(|e| anyhow::anyhow!(e.to_string()))?;
|
||||
|
||||
let actor = actor_url(&self.base_url, user_id);
|
||||
let follower_cc = format!("{}/followers", actor);
|
||||
let mut results = Vec::new();
|
||||
for goal in goals {
|
||||
let ap_id = goal_url(&self.base_url, user_id, goal.year());
|
||||
@@ -47,7 +48,15 @@ impl ApContentReader for GoalObjectHandler {
|
||||
0,
|
||||
&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)
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ pub const INSTANCE_ACTOR_ID: uuid::Uuid =
|
||||
pub use k_ap::{
|
||||
ActivityPubService, ActivityRepository, ActorRepository, ApContentReader, ApFederationConfig,
|
||||
ApObjectHandler, ApUser, ApUserRepository, BlocklistRepository, FederationData,
|
||||
FollowRepository, Follower, FollowerStatus, FollowingStatus, RemoteActor,
|
||||
FollowRepository, Follower, FollowerStatus, FollowingStatus, LocalObject, RemoteActor,
|
||||
};
|
||||
|
||||
pub use event_handler::ActivityPubEventHandler;
|
||||
@@ -28,15 +28,17 @@ pub use review_handler::ReviewObjectHandler;
|
||||
pub use social_adapter::CompositeSocialAdapter;
|
||||
pub use user_adapter::DomainUserRepoAdapter;
|
||||
|
||||
pub type FederationRepos = (
|
||||
std::sync::Arc<dyn ActivityRepository>,
|
||||
std::sync::Arc<dyn FollowRepository>,
|
||||
std::sync::Arc<dyn ActorRepository>,
|
||||
std::sync::Arc<dyn BlocklistRepository>,
|
||||
std::sync::Arc<dyn domain::ports::FederationAdminQuery>,
|
||||
std::sync::Arc<dyn RemoteReviewRepository>,
|
||||
std::sync::Arc<dyn domain::ports::RemoteWatchlistRepository>,
|
||||
);
|
||||
pub struct FederationRepos {
|
||||
pub activity: std::sync::Arc<dyn ActivityRepository>,
|
||||
pub follow: std::sync::Arc<dyn FollowRepository>,
|
||||
pub actor: std::sync::Arc<dyn ActorRepository>,
|
||||
pub blocklist: std::sync::Arc<dyn BlocklistRepository>,
|
||||
pub admin_query: std::sync::Arc<dyn domain::ports::FederationAdminQuery>,
|
||||
pub review_store: std::sync::Arc<dyn RemoteReviewRepository>,
|
||||
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 service: std::sync::Arc<dyn ActivityPubPort>,
|
||||
@@ -60,6 +62,8 @@ pub struct ActivityPubDeps {
|
||||
pub stats_repo: std::sync::Arc<dyn domain::ports::StatsRepository>,
|
||||
pub user_repo: std::sync::Arc<dyn domain::ports::UserRepository>,
|
||||
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 allow_registration: bool,
|
||||
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,
|
||||
user_repo,
|
||||
federation_settings,
|
||||
follow_command: _,
|
||||
follow_query: _,
|
||||
base_url,
|
||||
allow_registration,
|
||||
event_publisher,
|
||||
@@ -141,6 +147,10 @@ pub async fn wire(deps: ActivityPubDeps) -> anyhow::Result<ActivityPubWire> {
|
||||
.event_publisher(fed_event_bridge)
|
||||
.allow_registration(allow_registration)
|
||||
.software_name("movies-diary")
|
||||
.nodeinfo_metadata(serde_json::json!({
|
||||
"nodeName": "movies-diary",
|
||||
"nodeDescription": "A federated movie diary"
|
||||
}))
|
||||
.debug(federation_debug)
|
||||
.build()
|
||||
.await?,
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use k_ap::AS_PUBLIC;
|
||||
use k_ap::NoteType;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use url::Url;
|
||||
|
||||
use domain::models::Review;
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||
pub(crate) enum ActivityStreamsType {
|
||||
#[default]
|
||||
Note,
|
||||
Article,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ApAttachment {
|
||||
@@ -34,7 +40,7 @@ pub(crate) fn normalize_hashtag(title: &str) -> String {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ReviewObject {
|
||||
#[serde(rename = "type")]
|
||||
pub(crate) kind: NoteType,
|
||||
pub(crate) kind: ActivityStreamsType,
|
||||
pub(crate) id: Url,
|
||||
pub(crate) attributed_to: Url,
|
||||
pub(crate) content: String,
|
||||
@@ -125,7 +131,7 @@ pub fn review_to_ap_object(review: &Review, input: ReviewApInput) -> ReviewObjec
|
||||
};
|
||||
|
||||
ReviewObject {
|
||||
kind: NoteType::default(),
|
||||
kind: ActivityStreamsType::default(),
|
||||
id: ap_id,
|
||||
attributed_to: actor_url.clone(),
|
||||
content,
|
||||
@@ -150,7 +156,7 @@ pub fn review_to_ap_object(review: &Review, input: ReviewApInput) -> ReviewObjec
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WatchlistObject {
|
||||
#[serde(rename = "type")]
|
||||
pub(crate) kind: NoteType,
|
||||
pub(crate) kind: ActivityStreamsType,
|
||||
pub(crate) id: Url,
|
||||
pub(crate) attributed_to: Url,
|
||||
pub(crate) content: String,
|
||||
@@ -218,7 +224,7 @@ pub fn watchlist_to_ap_object(input: WatchlistApInput) -> WatchlistObject {
|
||||
];
|
||||
|
||||
WatchlistObject {
|
||||
kind: NoteType::default(),
|
||||
kind: ActivityStreamsType::default(),
|
||||
id: ap_id,
|
||||
attributed_to: actor_url.clone(),
|
||||
content,
|
||||
@@ -240,7 +246,7 @@ pub fn watchlist_to_ap_object(input: WatchlistApInput) -> WatchlistObject {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GoalObject {
|
||||
#[serde(rename = "type")]
|
||||
pub(crate) kind: NoteType,
|
||||
pub(crate) kind: ActivityStreamsType,
|
||||
pub(crate) id: Url,
|
||||
pub(crate) attributed_to: Url,
|
||||
pub(crate) content: String,
|
||||
@@ -277,7 +283,7 @@ pub fn goal_to_ap_object(
|
||||
}];
|
||||
|
||||
GoalObject {
|
||||
kind: NoteType::default(),
|
||||
kind: ActivityStreamsType::default(),
|
||||
id: ap_id,
|
||||
attributed_to: actor_url.clone(),
|
||||
content,
|
||||
|
||||
@@ -6,9 +6,6 @@ use k_ap::{ActivityPubService, BlockedDomain, RemoteActor};
|
||||
#[async_trait]
|
||||
pub trait ActivityPubPort: Send + Sync {
|
||||
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 unfollow(&self, local_user_id: Uuid, actor_url: &str) -> anyhow::Result<()>;
|
||||
async fn accept_follower(
|
||||
@@ -22,8 +19,6 @@ pub trait ActivityPubPort: Send + Sync {
|
||||
remote_actor_url: &str,
|
||||
) -> anyhow::Result<()>;
|
||||
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 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<()>;
|
||||
@@ -54,15 +49,6 @@ impl ActivityPubPort for ActivityPubService {
|
||||
async fn actor_json(&self, user_id: &str) -> anyhow::Result<String> {
|
||||
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<()> {
|
||||
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>> {
|
||||
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<()> {
|
||||
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> {
|
||||
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<()> {
|
||||
Ok(())
|
||||
}
|
||||
@@ -171,9 +142,6 @@ impl ActivityPubPort for NoopActivityPubService {
|
||||
async fn get_following(&self, _: Uuid) -> anyhow::Result<Vec<RemoteActor>> {
|
||||
Ok(vec![])
|
||||
}
|
||||
async fn get_accepted_followers(&self, _: Uuid) -> anyhow::Result<Vec<RemoteActor>> {
|
||||
Ok(vec![])
|
||||
}
|
||||
async fn remove_follower(&self, _: Uuid, _: &str) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ use domain::{
|
||||
ports::{DiaryQuery, EventPublisher, LocalApContentQuery, MovieQuery},
|
||||
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 crate::objects::{ReviewApInput, ReviewObject, review_to_ap_object};
|
||||
@@ -30,7 +30,7 @@ impl ApContentReader for ReviewObjectHandler {
|
||||
user_id: uuid::Uuid,
|
||||
before: Option<chrono::DateTime<chrono::Utc>>,
|
||||
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 before_naive = before.map(|dt| dt.naive_utc());
|
||||
let entries = self
|
||||
@@ -65,7 +65,16 @@ impl ApContentReader for ReviewObjectHandler {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -3,17 +3,17 @@ use std::sync::Arc;
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
ports::{SocialCommand, SocialQuery, UserRepository},
|
||||
value_objects::{FollowTarget, SocialActor, SocialIdentity, UserId},
|
||||
ports::{FollowCommand, FollowQuery, SocialCommand, SocialQuery, UserRepository},
|
||||
value_objects::{FollowStatus, FollowTarget, SocialActor, SocialIdentity, UserId, Username},
|
||||
};
|
||||
|
||||
use k_ap::RemoteActor;
|
||||
|
||||
use super::ActivityPubPort;
|
||||
|
||||
pub struct CompositeSocialAdapter {
|
||||
ap_service: Arc<dyn ActivityPubPort>,
|
||||
user_repo: Arc<dyn UserRepository>,
|
||||
follow_command: Arc<dyn FollowCommand>,
|
||||
follow_query: Arc<dyn FollowQuery>,
|
||||
base_url: String,
|
||||
}
|
||||
|
||||
@@ -21,11 +21,15 @@ impl CompositeSocialAdapter {
|
||||
pub fn new(
|
||||
ap_service: Arc<dyn ActivityPubPort>,
|
||||
user_repo: Arc<dyn UserRepository>,
|
||||
follow_command: Arc<dyn FollowCommand>,
|
||||
follow_query: Arc<dyn FollowQuery>,
|
||||
base_url: String,
|
||||
) -> Self {
|
||||
Self {
|
||||
ap_service,
|
||||
user_repo,
|
||||
follow_command,
|
||||
follow_query,
|
||||
base_url,
|
||||
}
|
||||
}
|
||||
@@ -41,42 +45,31 @@ impl CompositeSocialAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
fn identity_from_actor_url(&self, url: &str) -> SocialIdentity {
|
||||
let prefix = format!("{}/users/", self.base_url);
|
||||
if let Some(uuid_str) = url.strip_prefix(&prefix)
|
||||
&& let Ok(uuid) = uuid::Uuid::parse_str(uuid_str)
|
||||
{
|
||||
return SocialIdentity::Local(UserId::from_uuid(uuid));
|
||||
}
|
||||
SocialIdentity::Remote {
|
||||
actor_url: url.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn remote_actor_to_social_actor(&self, actor: RemoteActor) -> SocialActor {
|
||||
let identity = self.identity_from_actor_url(&actor.url);
|
||||
SocialActor {
|
||||
identity,
|
||||
handle: actor.handle,
|
||||
display_name: actor.display_name,
|
||||
avatar_url: actor.avatar_url,
|
||||
}
|
||||
}
|
||||
|
||||
async fn resolve_handle(&self, identity: &SocialIdentity) -> Result<String, DomainError> {
|
||||
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))
|
||||
async fn resolve_target_identity(
|
||||
&self,
|
||||
target: &FollowTarget,
|
||||
) -> Result<SocialIdentity, DomainError> {
|
||||
match target {
|
||||
FollowTarget::Identity(id) => Ok(id.clone()),
|
||||
FollowTarget::Handle(handle) => {
|
||||
let host = handle.rsplit_once('@').map(|(_, h)| h).unwrap_or("");
|
||||
let local_host = SocialIdentity::host_from_base_url(&self.base_url);
|
||||
if host == local_host {
|
||||
let username_str = handle
|
||||
.trim_start_matches('@')
|
||||
.split('@')
|
||||
.next()
|
||||
.unwrap_or("");
|
||||
if let Ok(username) = Username::new(username_str.to_string())
|
||||
&& let Some(user) = self.user_repo.find_by_username(&username).await?
|
||||
{
|
||||
return Ok(SocialIdentity::Local(user.id().clone()));
|
||||
}
|
||||
}
|
||||
Ok(SocialIdentity::Remote {
|
||||
actor_url: handle.clone(),
|
||||
})
|
||||
}
|
||||
SocialIdentity::Remote { actor_url } => Ok(actor_url.clone()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -88,16 +81,38 @@ fn ap_err(e: anyhow::Error) -> DomainError {
|
||||
#[async_trait]
|
||||
impl SocialCommand for CompositeSocialAdapter {
|
||||
async fn follow(&self, follower: &UserId, target: &FollowTarget) -> Result<(), DomainError> {
|
||||
if let FollowTarget::Identity(SocialIdentity::Local(target_id)) = target
|
||||
&& follower == target_id
|
||||
{
|
||||
return Err(DomainError::ValidationError(
|
||||
"Cannot follow yourself".into(),
|
||||
));
|
||||
let identity = self.resolve_target_identity(target).await?;
|
||||
|
||||
if let SocialIdentity::Local(ref target_id) = identity {
|
||||
if follower == target_id {
|
||||
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 {
|
||||
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
|
||||
.follow(follower.value(), &handle)
|
||||
@@ -111,10 +126,23 @@ impl SocialCommand for CompositeSocialAdapter {
|
||||
target: &SocialIdentity,
|
||||
) -> Result<(), DomainError> {
|
||||
let actor_url = self.actor_url_from_identity(target);
|
||||
self.ap_service
|
||||
.unfollow(follower.value(), &actor_url)
|
||||
.await
|
||||
.map_err(ap_err)
|
||||
match target {
|
||||
SocialIdentity::Local(target_id) => {
|
||||
let follower_url = self.local_actor_url(follower);
|
||||
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(
|
||||
@@ -123,10 +151,23 @@ impl SocialCommand for CompositeSocialAdapter {
|
||||
requester: &SocialIdentity,
|
||||
) -> Result<(), DomainError> {
|
||||
let actor_url = self.actor_url_from_identity(requester);
|
||||
self.ap_service
|
||||
.accept_follower(owner.value(), &actor_url)
|
||||
.await
|
||||
.map_err(ap_err)
|
||||
match requester {
|
||||
SocialIdentity::Local(requester_id) => {
|
||||
let owner_url = self.local_actor_url(owner);
|
||||
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(
|
||||
@@ -135,10 +176,23 @@ impl SocialCommand for CompositeSocialAdapter {
|
||||
requester: &SocialIdentity,
|
||||
) -> Result<(), DomainError> {
|
||||
let actor_url = self.actor_url_from_identity(requester);
|
||||
self.ap_service
|
||||
.reject_follower(owner.value(), &actor_url)
|
||||
.await
|
||||
.map_err(ap_err)
|
||||
match requester {
|
||||
SocialIdentity::Local(requester_id) => {
|
||||
let owner_url = self.local_actor_url(owner);
|
||||
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(
|
||||
@@ -147,10 +201,23 @@ impl SocialCommand for CompositeSocialAdapter {
|
||||
follower: &SocialIdentity,
|
||||
) -> Result<(), DomainError> {
|
||||
let actor_url = self.actor_url_from_identity(follower);
|
||||
self.ap_service
|
||||
.remove_follower(owner.value(), &actor_url)
|
||||
.await
|
||||
.map_err(ap_err)
|
||||
match follower {
|
||||
SocialIdentity::Local(follower_id) => {
|
||||
let owner_url = self.local_actor_url(owner);
|
||||
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> {
|
||||
@@ -173,53 +240,29 @@ impl SocialCommand for CompositeSocialAdapter {
|
||||
#[async_trait]
|
||||
impl SocialQuery for CompositeSocialAdapter {
|
||||
async fn get_following(&self, user: &UserId) -> Result<Vec<SocialActor>, DomainError> {
|
||||
let actors = self
|
||||
.ap_service
|
||||
.get_following(user.value())
|
||||
self.follow_query
|
||||
.get_following(user.value(), &self.base_url)
|
||||
.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> {
|
||||
let actors = self
|
||||
.ap_service
|
||||
.get_accepted_followers(user.value())
|
||||
self.follow_query
|
||||
.get_followers(user.value(), &self.base_url)
|
||||
.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> {
|
||||
let actors = self
|
||||
.ap_service
|
||||
.get_pending_followers(user.value())
|
||||
self.follow_query
|
||||
.get_pending_followers(user.value(), &self.base_url)
|
||||
.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> {
|
||||
self.ap_service
|
||||
.count_following(user.value())
|
||||
.await
|
||||
.map_err(ap_err)
|
||||
self.follow_query.count_following(user.value()).await
|
||||
}
|
||||
|
||||
async fn count_followers(&self, user: &UserId) -> Result<usize, DomainError> {
|
||||
self.ap_service
|
||||
.count_accepted_followers(user.value())
|
||||
.await
|
||||
.map_err(ap_err)
|
||||
self.follow_query.count_followers(user.value()).await
|
||||
}
|
||||
|
||||
async fn get_blocked(&self, user: &UserId) -> Result<Vec<SocialActor>, DomainError> {
|
||||
@@ -230,7 +273,15 @@ impl SocialQuery for CompositeSocialAdapter {
|
||||
.map_err(ap_err)?;
|
||||
Ok(actors
|
||||
.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())
|
||||
}
|
||||
|
||||
@@ -239,7 +290,9 @@ impl SocialQuery for CompositeSocialAdapter {
|
||||
follower: &UserId,
|
||||
target: &SocialIdentity,
|
||||
) -> Result<bool, DomainError> {
|
||||
let following = self.get_following(follower).await?;
|
||||
Ok(following.iter().any(|a| a.identity == *target))
|
||||
let actor_url = self.actor_url_from_identity(target);
|
||||
self.follow_query
|
||||
.is_following(follower.value(), &actor_url)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ use domain::{
|
||||
ports::{LocalApContentQuery, RemoteWatchlistRepository},
|
||||
value_objects::UserId,
|
||||
};
|
||||
use k_ap::{ApContentReader, ApObjectHandler};
|
||||
use k_ap::{AS_PUBLIC, ApContentReader, ApObjectHandler, LocalObject};
|
||||
use url::Url;
|
||||
|
||||
use crate::objects::{WatchlistApInput, WatchlistObject, watchlist_to_ap_object};
|
||||
@@ -26,7 +26,7 @@ impl ApContentReader for WatchlistObjectHandler {
|
||||
user_id: uuid::Uuid,
|
||||
_before: Option<DateTime<chrono::Utc>>,
|
||||
_limit: usize,
|
||||
) -> anyhow::Result<Vec<(Url, serde_json::Value, DateTime<chrono::Utc>)>> {
|
||||
) -> anyhow::Result<Vec<LocalObject>> {
|
||||
let uid = UserId::from_uuid(user_id);
|
||||
let entries = self
|
||||
.content_query
|
||||
@@ -35,6 +35,7 @@ impl ApContentReader for WatchlistObjectHandler {
|
||||
.map_err(|e| anyhow::anyhow!(e.to_string()))?;
|
||||
|
||||
let actor = actor_url(&self.base_url, user_id);
|
||||
let follower_cc = format!("{}/followers", actor);
|
||||
let mut results = Vec::new();
|
||||
for WatchlistWithMovie { entry, movie } in entries {
|
||||
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,
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ sqlx = { version = "0.8.6", features = [
|
||||
] }
|
||||
activitypub = { 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 }
|
||||
uuid = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
|
||||
@@ -1,43 +1,41 @@
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
use k_ap::{ActorRepository, RemoteActor};
|
||||
use k_ap::{AnnounceRepository, Keypair, KeypairRepository, RemoteActor, RemoteActorCache};
|
||||
use sqlx::Row;
|
||||
|
||||
use super::{PG_ACTOR_COLS, PostgresFederationRepository, pg_remote_actor};
|
||||
use adapter_common::datetime_to_str;
|
||||
|
||||
#[async_trait]
|
||||
impl ActorRepository for PostgresFederationRepository {
|
||||
async fn get_local_actor_keypair(
|
||||
&self,
|
||||
user_id: uuid::Uuid,
|
||||
) -> Result<Option<(String, String)>> {
|
||||
impl KeypairRepository for PostgresFederationRepository {
|
||||
async fn get_local_actor_keypair(&self, user_id: uuid::Uuid) -> Result<Option<Keypair>> {
|
||||
let uid = user_id.to_string();
|
||||
let row =
|
||||
sqlx::query("SELECT public_key, private_key FROM ap_local_actors WHERE user_id = $1")
|
||||
.bind(&uid)
|
||||
.fetch_optional(&self.pool)
|
||||
.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(
|
||||
&self,
|
||||
user_id: uuid::Uuid,
|
||||
public_key: String,
|
||||
private_key: String,
|
||||
) -> Result<()> {
|
||||
async fn save_local_actor_keypair(&self, user_id: uuid::Uuid, keypair: Keypair) -> Result<()> {
|
||||
let uid = user_id.to_string();
|
||||
let now = Utc::now().naive_utc();
|
||||
let created_at = datetime_to_str(&now);
|
||||
sqlx::query(
|
||||
"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",
|
||||
).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(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl RemoteActorCache for PostgresFederationRepository {
|
||||
async fn upsert_remote_actor(&self, actor: RemoteActor) -> Result<()> {
|
||||
let now = Utc::now().naive_utc();
|
||||
let fetched_at = datetime_to_str(&now);
|
||||
@@ -68,7 +66,10 @@ impl ActorRepository for PostgresFederationRepository {
|
||||
.await?;
|
||||
Ok(row.as_ref().map(|r| pg_remote_actor(r, "url")))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AnnounceRepository for PostgresFederationRepository {
|
||||
async fn add_announce(
|
||||
&self,
|
||||
activity_id: &str,
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
use k_ap::{BlockedDomain, BlocklistRepository};
|
||||
use k_ap::{ActorBlocklist, BlockedDomain, DomainBlocklist};
|
||||
use sqlx::Row;
|
||||
|
||||
use super::PostgresFederationRepository;
|
||||
use adapter_common::datetime_to_str;
|
||||
|
||||
#[async_trait]
|
||||
impl BlocklistRepository for PostgresFederationRepository {
|
||||
impl DomainBlocklist for PostgresFederationRepository {
|
||||
async fn add_blocked_domain(&self, domain: &str, reason: Option<&str>) -> Result<()> {
|
||||
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")
|
||||
@@ -48,7 +48,10 @@ impl BlocklistRepository for PostgresFederationRepository {
|
||||
.await?;
|
||||
Ok(count > 0)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ActorBlocklist for PostgresFederationRepository {
|
||||
async fn add_blocked_actor(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()> {
|
||||
let uid = local_user_id.to_string();
|
||||
let ts = datetime_to_str(&Utc::now().naive_utc());
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
use k_ap::{
|
||||
ActorRepository, FollowRepository, Follower, FollowerStatus, FollowingStatus, RemoteActor,
|
||||
};
|
||||
use k_ap::{Follower, FollowerReader, FollowerStatus, FollowerWriter, RemoteActor};
|
||||
use sqlx::Row;
|
||||
|
||||
use super::{
|
||||
use crate::{
|
||||
PG_ACTOR_COLS, PostgresFederationRepository, pg_remote_actor, status_to_str, str_to_status,
|
||||
};
|
||||
use adapter_common::datetime_to_str;
|
||||
|
||||
#[async_trait]
|
||||
impl FollowRepository for PostgresFederationRepository {
|
||||
impl FollowerWriter for PostgresFederationRepository {
|
||||
async fn add_follower(
|
||||
&self,
|
||||
local_user_id: uuid::Uuid,
|
||||
@@ -59,6 +57,25 @@ impl FollowRepository for PostgresFederationRepository {
|
||||
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>> {
|
||||
let uid = local_user_id.to_string();
|
||||
let q = format!(
|
||||
@@ -116,22 +133,6 @@ impl FollowRepository for PostgresFederationRepository {
|
||||
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>> {
|
||||
let uid = local_user_id.to_string();
|
||||
let q = format!(
|
||||
@@ -193,129 +194,4 @@ impl FollowRepository for PostgresFederationRepository {
|
||||
.map(|row| pg_remote_actor(row, "remote_actor_url"))
|
||||
.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()
|
||||
}
|
||||
}
|
||||
106
crates/adapters/postgres-federation/src/follow/following.rs
Normal file
106
crates/adapters/postgres-federation/src/follow/following.rs
Normal 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())
|
||||
}
|
||||
}
|
||||
27
crates/adapters/postgres-federation/src/follow/migration.rs
Normal file
27
crates/adapters/postgres-federation/src/follow/migration.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
3
crates/adapters/postgres-federation/src/follow/mod.rs
Normal file
3
crates/adapters/postgres-federation/src/follow/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
mod followers;
|
||||
mod following;
|
||||
mod migration;
|
||||
303
crates/adapters/postgres-federation/src/follow_repository.rs
Normal file
303
crates/adapters/postgres-federation/src/follow_repository.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ pub mod ap_content;
|
||||
mod blocklist;
|
||||
mod federated_profile;
|
||||
mod follow;
|
||||
mod follow_repository;
|
||||
pub mod remote_goals;
|
||||
mod review;
|
||||
mod social;
|
||||
@@ -79,13 +80,15 @@ pub fn create_federated_profile_query(
|
||||
|
||||
pub fn wire(pool: PgPool) -> activitypub::FederationRepos {
|
||||
let fed = std::sync::Arc::new(PostgresFederationRepository::new(pool));
|
||||
(
|
||||
std::sync::Arc::clone(&fed) as _,
|
||||
std::sync::Arc::clone(&fed) as _,
|
||||
std::sync::Arc::clone(&fed) as _,
|
||||
std::sync::Arc::clone(&fed) as _,
|
||||
std::sync::Arc::clone(&fed) as _,
|
||||
std::sync::Arc::clone(&fed) as _,
|
||||
fed as _,
|
||||
)
|
||||
activitypub::FederationRepos {
|
||||
activity: std::sync::Arc::clone(&fed) as _,
|
||||
follow: std::sync::Arc::clone(&fed) as _,
|
||||
actor: std::sync::Arc::clone(&fed) as _,
|
||||
blocklist: std::sync::Arc::clone(&fed) as _,
|
||||
admin_query: std::sync::Arc::clone(&fed) as _,
|
||||
review_store: std::sync::Arc::clone(&fed) as _,
|
||||
remote_watchlist: std::sync::Arc::clone(&fed) as _,
|
||||
follow_command: std::sync::Arc::clone(&fed) as _,
|
||||
follow_query: fed as _,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,3 +257,21 @@ pub(crate) struct MonthlyRatingRow {
|
||||
pub avg_rating: f64,
|
||||
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,
|
||||
}
|
||||
|
||||
@@ -7,7 +7,10 @@ use domain::{
|
||||
};
|
||||
use sqlx::PgPool;
|
||||
|
||||
use crate::models::{DirectorCountRow, MonthlyRatingRow, UserTotalsRow};
|
||||
use crate::models::{
|
||||
DirectorCountRow, GenreCountRow, MonthlyRatingRow, RatingDistRow, UserTotalsRow,
|
||||
WatchMediumCountRow,
|
||||
};
|
||||
use adapter_common::format_year_month;
|
||||
|
||||
pub struct PostgresStatsRepository {
|
||||
@@ -97,31 +100,61 @@ impl StatsRepository for PostgresStatsRepository {
|
||||
async fn get_user_trends(&self, user_id: &UserId) -> Result<UserTrends, DomainError> {
|
||||
let uid = user_id.value().to_string();
|
||||
|
||||
let (rating_rows, director_rows) = tokio::try_join!(
|
||||
sqlx::query_as::<_, MonthlyRatingRow>(
|
||||
"SELECT to_char(watched_at AT TIME ZONE 'UTC', 'YYYY-MM') AS month,
|
||||
let (rating_rows, director_rows, genre_rows, rating_dist_rows, medium_rows) =
|
||||
tokio::try_join!(
|
||||
sqlx::query_as::<_, MonthlyRatingRow>(
|
||||
"SELECT to_char(watched_at AT TIME ZONE 'UTC', 'YYYY-MM') AS month,
|
||||
AVG(rating::float) AS avg_rating,
|
||||
COUNT(*) AS count
|
||||
FROM reviews
|
||||
WHERE user_id = $1 AND watched_at >= NOW() - INTERVAL '12 months'
|
||||
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"
|
||||
)
|
||||
.bind(&uid)
|
||||
.fetch_all(&self.pool),
|
||||
sqlx::query_as::<_, DirectorCountRow>(
|
||||
"SELECT m.director AS director, COUNT(*) AS count
|
||||
)
|
||||
.bind(&uid)
|
||||
.fetch_all(&self.pool),
|
||||
sqlx::query_as::<_, DirectorCountRow>(
|
||||
"SELECT m.director AS director, COUNT(*) AS count
|
||||
FROM reviews r
|
||||
INNER JOIN movies m ON m.id = r.movie_id
|
||||
WHERE r.user_id = $1 AND m.director IS NOT NULL
|
||||
GROUP BY m.director
|
||||
ORDER BY COUNT(*) DESC
|
||||
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)
|
||||
.fetch_all(&self.pool)
|
||||
)
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
|
||||
let max_director_count = director_rows.iter().map(|d| d.count).max().unwrap_or(1);
|
||||
|
||||
@@ -143,10 +176,38 @@ impl StatsRepository for PostgresStatsRepository {
|
||||
})
|
||||
.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 {
|
||||
monthly_ratings,
|
||||
top_directors,
|
||||
max_director_count,
|
||||
top_genres,
|
||||
rating_distribution,
|
||||
watch_medium_distribution,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -289,7 +289,7 @@ impl WrapUpStatsQuery for PostgresWrapUpStatsQuery {
|
||||
"SELECT r.movie_id, m.title, m.release_year, m.director, m.poster_path, \
|
||||
r.rating, \
|
||||
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 \
|
||||
FROM reviews r \
|
||||
INNER JOIN movies m ON m.id = r.movie_id \
|
||||
@@ -367,6 +367,9 @@ impl WrapUpStatsQuery for PostgresWrapUpStatsQuery {
|
||||
let original_language: Option<String> = row
|
||||
.try_get("original_language")
|
||||
.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 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),
|
||||
budget_usd,
|
||||
original_language,
|
||||
watch_medium,
|
||||
genres,
|
||||
keywords,
|
||||
cast_names,
|
||||
|
||||
@@ -7,7 +7,7 @@ edition = "2024"
|
||||
sqlx = { workspace = true }
|
||||
activitypub = { 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 }
|
||||
anyhow = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
@@ -1,33 +1,28 @@
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
use k_ap::{ActorRepository, RemoteActor};
|
||||
use k_ap::{AnnounceRepository, Keypair, KeypairRepository, RemoteActor, RemoteActorCache};
|
||||
use sqlx::Row;
|
||||
|
||||
use super::{SqliteFederationRepository, remote_actor_from_row};
|
||||
use adapter_common::datetime_to_str;
|
||||
|
||||
#[async_trait]
|
||||
impl ActorRepository for SqliteFederationRepository {
|
||||
async fn get_local_actor_keypair(
|
||||
&self,
|
||||
user_id: uuid::Uuid,
|
||||
) -> Result<Option<(String, String)>> {
|
||||
impl KeypairRepository for SqliteFederationRepository {
|
||||
async fn get_local_actor_keypair(&self, user_id: uuid::Uuid) -> Result<Option<Keypair>> {
|
||||
let uid = user_id.to_string();
|
||||
let row =
|
||||
sqlx::query("SELECT public_key, private_key FROM ap_local_actors WHERE user_id = ?")
|
||||
.bind(&uid)
|
||||
.fetch_optional(&self.pool)
|
||||
.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(
|
||||
&self,
|
||||
user_id: uuid::Uuid,
|
||||
public_key: String,
|
||||
private_key: String,
|
||||
) -> Result<()> {
|
||||
async fn save_local_actor_keypair(&self, user_id: uuid::Uuid, keypair: Keypair) -> Result<()> {
|
||||
let uid = user_id.to_string();
|
||||
let now = Utc::now().naive_utc();
|
||||
let created_at = datetime_to_str(&now);
|
||||
@@ -39,14 +34,17 @@ impl ActorRepository for SqliteFederationRepository {
|
||||
private_key = excluded.private_key",
|
||||
)
|
||||
.bind(&uid)
|
||||
.bind(&public_key)
|
||||
.bind(&private_key)
|
||||
.bind(&keypair.public_key)
|
||||
.bind(&keypair.private_key)
|
||||
.bind(&created_at)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl RemoteActorCache for SqliteFederationRepository {
|
||||
async fn upsert_remote_actor(&self, actor: RemoteActor) -> Result<()> {
|
||||
let now = Utc::now().naive_utc();
|
||||
let fetched_at = datetime_to_str(&now);
|
||||
@@ -84,7 +82,10 @@ impl ActorRepository for SqliteFederationRepository {
|
||||
).bind(actor_url).fetch_optional(&self.pool).await?;
|
||||
Ok(row.as_ref().map(|r| remote_actor_from_row(r, "url")))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AnnounceRepository for SqliteFederationRepository {
|
||||
async fn add_announce(
|
||||
&self,
|
||||
activity_id: &str,
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
use k_ap::{BlockedDomain, BlocklistRepository};
|
||||
use k_ap::{ActorBlocklist, BlockedDomain, DomainBlocklist};
|
||||
use sqlx::Row;
|
||||
|
||||
use super::SqliteFederationRepository;
|
||||
use adapter_common::datetime_to_str;
|
||||
|
||||
#[async_trait]
|
||||
impl BlocklistRepository for SqliteFederationRepository {
|
||||
impl DomainBlocklist for SqliteFederationRepository {
|
||||
async fn add_blocked_domain(&self, domain: &str, reason: Option<&str>) -> Result<()> {
|
||||
let now = Utc::now().naive_utc();
|
||||
let ts = datetime_to_str(&now);
|
||||
@@ -56,7 +56,10 @@ impl BlocklistRepository for SqliteFederationRepository {
|
||||
.await?;
|
||||
Ok(count > 0)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ActorBlocklist for SqliteFederationRepository {
|
||||
async fn add_blocked_actor(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()> {
|
||||
let uid = local_user_id.to_string();
|
||||
let ts = datetime_to_str(&Utc::now().naive_utc());
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
use k_ap::{
|
||||
ActorRepository, FollowRepository, Follower, FollowerStatus, FollowingStatus, RemoteActor,
|
||||
};
|
||||
use k_ap::{Follower, FollowerReader, FollowerStatus, FollowerWriter, RemoteActor};
|
||||
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;
|
||||
|
||||
#[async_trait]
|
||||
impl FollowRepository for SqliteFederationRepository {
|
||||
impl FollowerWriter for SqliteFederationRepository {
|
||||
async fn add_follower(
|
||||
&self,
|
||||
local_user_id: uuid::Uuid,
|
||||
@@ -69,6 +67,31 @@ impl FollowRepository for SqliteFederationRepository {
|
||||
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>> {
|
||||
let uid = local_user_id.to_string();
|
||||
let rows = sqlx::query(
|
||||
@@ -138,28 +161,6 @@ impl FollowRepository for SqliteFederationRepository {
|
||||
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>> {
|
||||
let uid = local_user_id.to_string();
|
||||
let rows = sqlx::query(
|
||||
@@ -232,173 +233,4 @@ impl FollowRepository for SqliteFederationRepository {
|
||||
.map(|row| remote_actor_from_row(row, "remote_actor_url"))
|
||||
.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()
|
||||
}
|
||||
}
|
||||
124
crates/adapters/sqlite-federation/src/follow/following.rs
Normal file
124
crates/adapters/sqlite-federation/src/follow/following.rs
Normal 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())
|
||||
}
|
||||
}
|
||||
47
crates/adapters/sqlite-federation/src/follow/migration.rs
Normal file
47
crates/adapters/sqlite-federation/src/follow/migration.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
3
crates/adapters/sqlite-federation/src/follow/mod.rs
Normal file
3
crates/adapters/sqlite-federation/src/follow/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
mod followers;
|
||||
mod following;
|
||||
mod migration;
|
||||
303
crates/adapters/sqlite-federation/src/follow_repository.rs
Normal file
303
crates/adapters/sqlite-federation/src/follow_repository.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ mod actor;
|
||||
mod blocklist;
|
||||
mod federated_profile;
|
||||
mod follow;
|
||||
mod follow_repository;
|
||||
mod review;
|
||||
mod social;
|
||||
mod watchlist;
|
||||
@@ -91,21 +92,19 @@ pub fn create_federated_profile_query(
|
||||
|
||||
pub fn wire(pool: SqlitePool) -> activitypub::FederationRepos {
|
||||
let fed = std::sync::Arc::new(SqliteFederationRepository::new(pool));
|
||||
(
|
||||
std::sync::Arc::clone(&fed) as _,
|
||||
std::sync::Arc::clone(&fed) as _,
|
||||
std::sync::Arc::clone(&fed) as _,
|
||||
std::sync::Arc::clone(&fed) as _,
|
||||
std::sync::Arc::clone(&fed) as _,
|
||||
std::sync::Arc::clone(&fed) as _,
|
||||
fed as _,
|
||||
)
|
||||
activitypub::FederationRepos {
|
||||
activity: std::sync::Arc::clone(&fed) as _,
|
||||
follow: std::sync::Arc::clone(&fed) as _,
|
||||
actor: std::sync::Arc::clone(&fed) as _,
|
||||
blocklist: std::sync::Arc::clone(&fed) as _,
|
||||
admin_query: std::sync::Arc::clone(&fed) as _,
|
||||
review_store: std::sync::Arc::clone(&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)]
|
||||
#[path = "tests/actor_block_tests.rs"]
|
||||
mod actor_block_tests;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::*;
|
||||
use k_ap::BlocklistRepository;
|
||||
use k_ap::ActorBlocklist;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
async fn test_pool() -> SqlitePool {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::*;
|
||||
use k_ap::BlocklistRepository;
|
||||
use k_ap::DomainBlocklist;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
async fn test_pool() -> SqlitePool {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::*;
|
||||
use chrono::Utc;
|
||||
use domain::ports::FederationAdminQuery;
|
||||
use k_ap::ActorRepository;
|
||||
use k_ap::AnnounceRepository;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
async fn test_pool() -> SqlitePool {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -264,6 +264,24 @@ pub(crate) struct MonthlyRatingRow {
|
||||
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)]
|
||||
pub(crate) struct WatchlistRow {
|
||||
pub id: String,
|
||||
|
||||
@@ -7,7 +7,10 @@ use domain::{
|
||||
};
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use crate::models::{DirectorCountRow, MonthlyRatingRow, UserTotalsRow};
|
||||
use crate::models::{
|
||||
DirectorCountRow, GenreCountRow, MonthlyRatingRow, RatingDistRow, UserTotalsRow,
|
||||
WatchMediumCountRow,
|
||||
};
|
||||
|
||||
pub struct SqliteStatsRepository {
|
||||
pool: SqlitePool,
|
||||
@@ -98,20 +101,21 @@ impl StatsRepository for SqliteStatsRepository {
|
||||
async fn get_user_trends(&self, user_id: &UserId) -> Result<UserTrends, DomainError> {
|
||||
let uid = user_id.value().to_string();
|
||||
|
||||
let (rating_rows, director_rows) = tokio::try_join!(
|
||||
sqlx::query_as::<_, MonthlyRatingRow>(
|
||||
"SELECT strftime('%Y-%m', watched_at) AS month,
|
||||
let (rating_rows, director_rows, genre_rows, rating_dist_rows, medium_rows) =
|
||||
tokio::try_join!(
|
||||
sqlx::query_as::<_, MonthlyRatingRow>(
|
||||
"SELECT strftime('%Y-%m', watched_at) AS month,
|
||||
AVG(CAST(rating AS REAL)) AS avg_rating,
|
||||
COUNT(*) AS count
|
||||
FROM reviews
|
||||
WHERE user_id = ? AND watched_at >= datetime('now', '-12 months')
|
||||
GROUP BY month
|
||||
ORDER BY month ASC",
|
||||
)
|
||||
.bind(&uid)
|
||||
.fetch_all(&self.pool),
|
||||
sqlx::query_as::<_, DirectorCountRow>(
|
||||
"SELECT m.director,
|
||||
)
|
||||
.bind(&uid)
|
||||
.fetch_all(&self.pool),
|
||||
sqlx::query_as::<_, DirectorCountRow>(
|
||||
"SELECT m.director,
|
||||
COUNT(*) AS count
|
||||
FROM reviews r
|
||||
INNER JOIN movies m ON m.id = r.movie_id
|
||||
@@ -119,11 +123,40 @@ impl StatsRepository for SqliteStatsRepository {
|
||||
GROUP BY m.director
|
||||
ORDER BY COUNT(*) DESC
|
||||
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)
|
||||
.fetch_all(&self.pool)
|
||||
)
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
|
||||
let max_director_count = director_rows.iter().map(|d| d.count).max().unwrap_or(1);
|
||||
|
||||
@@ -145,10 +178,38 @@ impl StatsRepository for SqliteStatsRepository {
|
||||
})
|
||||
.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 {
|
||||
monthly_ratings,
|
||||
top_directors,
|
||||
max_director_count,
|
||||
top_genres,
|
||||
rating_distribution,
|
||||
watch_medium_distribution,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -301,7 +301,7 @@ impl WrapUpStatsQuery for SqliteWrapUpStatsQuery {
|
||||
|
||||
let sql = format!(
|
||||
"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 \
|
||||
FROM reviews r \
|
||||
INNER JOIN movies m ON m.id = r.movie_id \
|
||||
@@ -379,6 +379,9 @@ impl WrapUpStatsQuery for SqliteWrapUpStatsQuery {
|
||||
let original_language: Option<String> = row
|
||||
.try_get("original_language")
|
||||
.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 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),
|
||||
budget_usd,
|
||||
original_language,
|
||||
watch_medium,
|
||||
genres,
|
||||
keywords,
|
||||
cast_names,
|
||||
|
||||
@@ -60,11 +60,26 @@ pub struct DirectorStatDto {
|
||||
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)]
|
||||
pub struct UserTrendsDto {
|
||||
pub monthly_ratings: Vec<MonthlyRatingDto>,
|
||||
pub top_directors: Vec<DirectorStatDto>,
|
||||
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)]
|
||||
|
||||
@@ -27,6 +27,7 @@ fn make_row(title: &str, rating: u8, watched_at: &str) -> WrapUpMovieRow {
|
||||
keywords: vec!["heist".to_string()],
|
||||
cast_names: vec![("Actor A".to_string(), 1, 12345)],
|
||||
cast_profile_paths: vec![None],
|
||||
watch_medium: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ mod feed;
|
||||
mod movie;
|
||||
mod refresh_session;
|
||||
mod review;
|
||||
mod stats;
|
||||
pub mod stats;
|
||||
mod user;
|
||||
|
||||
pub mod collections;
|
||||
@@ -26,7 +26,7 @@ pub use federation::*;
|
||||
pub use feed::*;
|
||||
pub use movie::*;
|
||||
pub use review::*;
|
||||
pub use stats::*;
|
||||
pub use stats::{DirectorStat, MonthActivity, MonthlyRating, MovieStats, UserStats, UserTrends};
|
||||
pub use user::*;
|
||||
|
||||
pub use goal::{Goal, GoalWithProgress};
|
||||
|
||||
@@ -30,11 +30,26 @@ pub struct DirectorStat {
|
||||
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)]
|
||||
pub struct UserTrends {
|
||||
pub monthly_ratings: Vec<MonthlyRating>,
|
||||
pub top_directors: Vec<DirectorStat>,
|
||||
pub max_director_count: i64,
|
||||
pub top_genres: Vec<GenreStat>,
|
||||
pub rating_distribution: [i64; 5],
|
||||
pub watch_medium_distribution: Vec<WatchMediumStat>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
|
||||
@@ -17,6 +17,7 @@ pub struct WrapUpMovieRow {
|
||||
pub runtime_minutes: Option<u32>,
|
||||
pub budget_usd: Option<i64>,
|
||||
pub original_language: Option<String>,
|
||||
pub watch_medium: Option<String>,
|
||||
pub genres: Vec<String>,
|
||||
pub keywords: Vec<String>,
|
||||
pub cast_names: Vec<(String, u32, i64)>,
|
||||
@@ -95,6 +96,12 @@ pub struct LangStat {
|
||||
pub count: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct WatchMediumStat {
|
||||
pub medium: String,
|
||||
pub count: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct MonthCount {
|
||||
pub year_month: String,
|
||||
@@ -142,6 +149,7 @@ pub struct WrapUpReport {
|
||||
pub total_budget_watched: Option<i64>,
|
||||
pub avg_budget: Option<i64>,
|
||||
pub language_distribution: Vec<LangStat>,
|
||||
pub watch_medium_distribution: Vec<WatchMediumStat>,
|
||||
pub oldest_movie: Option<MovieRef>,
|
||||
pub newest_movie: Option<MovieRef>,
|
||||
|
||||
|
||||
80
crates/domain/src/ports/follow.rs
Normal file
80
crates/domain/src/ports/follow.rs
Normal 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>;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ pub mod auth;
|
||||
pub mod diary;
|
||||
pub mod events;
|
||||
pub mod federated_profile;
|
||||
pub mod follow;
|
||||
pub mod goals;
|
||||
pub mod image_fetcher;
|
||||
pub mod images;
|
||||
@@ -21,6 +22,7 @@ pub use auth::*;
|
||||
pub use diary::*;
|
||||
pub use events::*;
|
||||
pub use federated_profile::*;
|
||||
pub use follow::*;
|
||||
pub use goals::*;
|
||||
pub use image_fetcher::*;
|
||||
pub use images::*;
|
||||
|
||||
@@ -23,6 +23,7 @@ fn row(title: &str, rating: u8, ym: &str) -> WrapUpMovieRow {
|
||||
runtime_minutes: Some(100),
|
||||
budget_usd: None,
|
||||
original_language: Some("en".to_string()),
|
||||
watch_medium: None,
|
||||
genres: vec!["Action".to_string()],
|
||||
keywords: vec![],
|
||||
cast_names: vec![],
|
||||
|
||||
@@ -7,6 +7,8 @@ use crate::models::WrapUpMovieRow;
|
||||
use crate::models::wrapup::*;
|
||||
use crate::models::{ExternalPersonId, PersonId};
|
||||
|
||||
const MIN_PERSON_COUNT: u32 = 2;
|
||||
|
||||
pub fn build_report(
|
||||
scope: WrapUpScope,
|
||||
date_range: DateRange,
|
||||
@@ -53,6 +55,7 @@ pub fn build_report(
|
||||
|
||||
let (total_budget_watched, avg_budget) = compute_budget_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) =
|
||||
compute_rewatch_stats(rows);
|
||||
@@ -91,6 +94,7 @@ pub fn build_report(
|
||||
total_budget_watched,
|
||||
avg_budget,
|
||||
language_distribution,
|
||||
watch_medium_distribution,
|
||||
oldest_movie,
|
||||
newest_movie,
|
||||
total_rewatches,
|
||||
@@ -226,6 +230,7 @@ fn compute_director_stats(rows: &[WrapUpMovieRow]) -> (Vec<PersonStat>, u32) {
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
stats.retain(|s| s.count > MIN_PERSON_COUNT);
|
||||
stats.sort_by(|a, b| {
|
||||
b.count
|
||||
.cmp(&a.count)
|
||||
@@ -270,6 +275,7 @@ fn compute_actor_stats(rows: &[WrapUpMovieRow]) -> (Vec<PersonStat>, u32, Vec<St
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
stats.retain(|s| s.count > MIN_PERSON_COUNT);
|
||||
stats.sort_by(|a, b| {
|
||||
b.count
|
||||
.cmp(&a.count)
|
||||
@@ -315,7 +321,7 @@ fn compute_genre_stats(
|
||||
.map(|g| g.genre.clone());
|
||||
let lowest = stats
|
||||
.iter()
|
||||
.filter(|g| g.count >= 3)
|
||||
.filter(|g| g.count > 3)
|
||||
.min_by(|a, b| a.avg_rating.total_cmp(&b.avg_rating))
|
||||
.map(|g| g.genre.clone());
|
||||
stats.truncate(5);
|
||||
@@ -367,6 +373,21 @@ fn compute_language_stats(rows: &[WrapUpMovieRow]) -> Vec<LangStat> {
|
||||
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>) {
|
||||
let mut movie_reviews: HashMap<Uuid, Vec<&WrapUpMovieRow>> = HashMap::new();
|
||||
for r in rows {
|
||||
|
||||
@@ -226,6 +226,9 @@ impl StatsRepository for FakeStatsRepository {
|
||||
monthly_ratings: vec![],
|
||||
top_directors: vec![],
|
||||
max_director_count: 0,
|
||||
top_genres: vec![],
|
||||
rating_distribution: [0; 5],
|
||||
watch_medium_distribution: vec![],
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -904,12 +904,12 @@ impl SocialCommand for InMemorySocialRepository {
|
||||
actor_url: h.clone(),
|
||||
},
|
||||
};
|
||||
if let SocialIdentity::Local(target_id) = &identity {
|
||||
if follower == target_id {
|
||||
return Err(DomainError::ValidationError(
|
||||
"Cannot follow yourself".into(),
|
||||
));
|
||||
}
|
||||
if let SocialIdentity::Local(target_id) = &identity
|
||||
&& follower == target_id
|
||||
{
|
||||
return Err(DomainError::ValidationError(
|
||||
"Cannot follow yourself".into(),
|
||||
));
|
||||
}
|
||||
let mut store = self.follows.lock().unwrap();
|
||||
let already = store
|
||||
|
||||
@@ -7,6 +7,18 @@ pub enum 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 {
|
||||
matches!(self, Self::Local(_))
|
||||
}
|
||||
@@ -14,6 +26,26 @@ impl SocialIdentity {
|
||||
pub fn is_remote(&self) -> bool {
|
||||
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)]
|
||||
|
||||
@@ -19,8 +19,10 @@ impl AppConfig {
|
||||
let allow_registration = std::env::var("ALLOW_REGISTRATION")
|
||||
.map(|v| v == "true" || v == "1")
|
||||
.unwrap_or(false);
|
||||
let base_url =
|
||||
std::env::var("BASE_URL").unwrap_or_else(|_| "http://localhost:3000".to_string());
|
||||
let base_url = std::env::var("BASE_URL")
|
||||
.unwrap_or_else(|_| "http://localhost:3000".to_string())
|
||||
.trim_end_matches('/')
|
||||
.to_string();
|
||||
let rate_limit = std::env::var("RATE_LIMIT")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
|
||||
@@ -312,8 +312,7 @@ pub async fn get_activity_feed_html(
|
||||
let limit = params.limit.unwrap_or(20);
|
||||
let offset = params.offset.unwrap_or(0);
|
||||
|
||||
let filter_following =
|
||||
cfg!(feature = "federation") && params.filter == "following" && user_id.is_some();
|
||||
let filter_following = params.filter == "following" && user_id.is_some();
|
||||
let filter_str = if filter_following { "following" } else { "all" };
|
||||
|
||||
let sort_by_str = match params.sort_by.as_str() {
|
||||
|
||||
@@ -189,9 +189,7 @@ pub async fn block_actor_api(
|
||||
&deps,
|
||||
application::social::commands::SocialCmd::Block {
|
||||
blocker_id: user.0.value(),
|
||||
target: SocialIdentity::Remote {
|
||||
actor_url: body.actor_url,
|
||||
},
|
||||
target: SocialIdentity::from_actor_url(&body.actor_url, &state.app_ctx.config.base_url),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
@@ -217,9 +215,7 @@ pub async fn unblock_actor_api(
|
||||
&deps,
|
||||
application::social::commands::SocialCmd::Unblock {
|
||||
blocker_id: user.0.value(),
|
||||
target: SocialIdentity::Remote {
|
||||
actor_url: body.actor_url,
|
||||
},
|
||||
target: SocialIdentity::from_actor_url(&body.actor_url, &state.app_ctx.config.base_url),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
@@ -381,9 +377,7 @@ pub async fn unfollow(
|
||||
&deps,
|
||||
application::social::commands::SocialCmd::Unfollow {
|
||||
follower_id: user.0.value(),
|
||||
target: SocialIdentity::Remote {
|
||||
actor_url: body.actor_url,
|
||||
},
|
||||
target: SocialIdentity::from_actor_url(&body.actor_url, &state.app_ctx.config.base_url),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
@@ -409,9 +403,10 @@ pub async fn accept_follower(
|
||||
&deps,
|
||||
application::social::commands::SocialCmd::AcceptFollow {
|
||||
owner_id: user.0.value(),
|
||||
requester: SocialIdentity::Remote {
|
||||
actor_url: body.actor_url,
|
||||
},
|
||||
requester: SocialIdentity::from_actor_url(
|
||||
&body.actor_url,
|
||||
&state.app_ctx.config.base_url,
|
||||
),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
@@ -437,9 +432,10 @@ pub async fn reject_follower(
|
||||
&deps,
|
||||
application::social::commands::SocialCmd::RejectFollow {
|
||||
owner_id: user.0.value(),
|
||||
requester: SocialIdentity::Remote {
|
||||
actor_url: body.actor_url,
|
||||
},
|
||||
requester: SocialIdentity::from_actor_url(
|
||||
&body.actor_url,
|
||||
&state.app_ctx.config.base_url,
|
||||
),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
@@ -465,9 +461,10 @@ pub async fn remove_follower(
|
||||
&deps,
|
||||
application::social::commands::SocialCmd::RemoveFollower {
|
||||
owner_id: user.0.value(),
|
||||
follower: SocialIdentity::Remote {
|
||||
actor_url: body.actor_url,
|
||||
},
|
||||
follower: SocialIdentity::from_actor_url(
|
||||
&body.actor_url,
|
||||
&state.app_ctx.config.base_url,
|
||||
),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
@@ -563,9 +560,7 @@ pub async fn unfollow_remote_user(
|
||||
&deps,
|
||||
application::social::commands::SocialCmd::Unfollow {
|
||||
follower_id: user_id.value(),
|
||||
target: SocialIdentity::Remote {
|
||||
actor_url: form.actor_url,
|
||||
},
|
||||
target: SocialIdentity::from_actor_url(&form.actor_url, &state.app_ctx.config.base_url),
|
||||
},
|
||||
)
|
||||
.await
|
||||
@@ -602,9 +597,10 @@ pub async fn accept_follower_html(
|
||||
&deps,
|
||||
application::social::commands::SocialCmd::AcceptFollow {
|
||||
owner_id: user_id.value(),
|
||||
requester: SocialIdentity::Remote {
|
||||
actor_url: form.actor_url,
|
||||
},
|
||||
requester: SocialIdentity::from_actor_url(
|
||||
&form.actor_url,
|
||||
&state.app_ctx.config.base_url,
|
||||
),
|
||||
},
|
||||
)
|
||||
.await
|
||||
@@ -635,9 +631,10 @@ pub async fn reject_follower_html(
|
||||
&deps,
|
||||
application::social::commands::SocialCmd::RejectFollow {
|
||||
owner_id: user_id.value(),
|
||||
requester: SocialIdentity::Remote {
|
||||
actor_url: form.actor_url,
|
||||
},
|
||||
requester: SocialIdentity::from_actor_url(
|
||||
&form.actor_url,
|
||||
&state.app_ctx.config.base_url,
|
||||
),
|
||||
},
|
||||
)
|
||||
.await
|
||||
@@ -832,9 +829,10 @@ pub async fn remove_follower_html(
|
||||
&deps,
|
||||
application::social::commands::SocialCmd::RemoveFollower {
|
||||
owner_id: user_id.value(),
|
||||
follower: SocialIdentity::Remote {
|
||||
actor_url: form.actor_url,
|
||||
},
|
||||
follower: SocialIdentity::from_actor_url(
|
||||
&form.actor_url,
|
||||
&state.app_ctx.config.base_url,
|
||||
),
|
||||
},
|
||||
)
|
||||
.await
|
||||
@@ -1001,9 +999,7 @@ pub async fn post_block_actor_html(
|
||||
&deps,
|
||||
application::social::commands::SocialCmd::Block {
|
||||
blocker_id: user_id.value(),
|
||||
target: SocialIdentity::Remote {
|
||||
actor_url: form.actor_url,
|
||||
},
|
||||
target: SocialIdentity::from_actor_url(&form.actor_url, &state.app_ctx.config.base_url),
|
||||
},
|
||||
)
|
||||
.await
|
||||
@@ -1030,9 +1026,7 @@ pub async fn post_unblock_actor(
|
||||
&deps,
|
||||
application::social::commands::SocialCmd::Unblock {
|
||||
blocker_id: user_id.value(),
|
||||
target: SocialIdentity::Remote {
|
||||
actor_url: form.actor_url,
|
||||
},
|
||||
target: SocialIdentity::from_actor_url(&form.actor_url, &state.app_ctx.config.base_url),
|
||||
},
|
||||
)
|
||||
.await
|
||||
|
||||
@@ -24,9 +24,9 @@ use crate::{
|
||||
state::AppState,
|
||||
};
|
||||
use api_types::{
|
||||
DiaryResponse, DirectorStatDto, MonthActivityDto, MonthlyRatingDto, ProfileResponse,
|
||||
UserProfileQueryParams, UserProfileResponse, UserStatsDto, UserSummaryDto, UserTrendsDto,
|
||||
UsersResponse,
|
||||
DiaryResponse, DirectorStatDto, GenreStatDto, MonthActivityDto, MonthlyRatingDto,
|
||||
ProfileResponse, UserProfileQueryParams, UserProfileResponse, UserStatsDto, UserSummaryDto,
|
||||
UserTrendsDto, UsersResponse, WatchMediumStatDto,
|
||||
};
|
||||
use template_askama::{
|
||||
EmbedProfileTemplate, MonthlyRatingRow, ProfileSettingsTemplate, ProfileTemplate,
|
||||
@@ -297,32 +297,10 @@ pub async fn get_user_profile(
|
||||
})
|
||||
.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 {
|
||||
None
|
||||
profile.trends.map(|t| api_types::ProfileViewData::Trends {
|
||||
trends: trends_to_dto(t),
|
||||
})
|
||||
};
|
||||
|
||||
Json(UserProfileResponse {
|
||||
@@ -414,32 +392,10 @@ async fn build_federated_profile_response(
|
||||
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 {
|
||||
None
|
||||
profile.trends.map(|t| api_types::ProfileViewData::Trends {
|
||||
trends: trends_to_dto(t),
|
||||
})
|
||||
};
|
||||
|
||||
let username = fed
|
||||
@@ -473,6 +429,47 @@ async fn build_federated_profile_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 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
pub async fn get_users_list(
|
||||
|
||||
@@ -75,15 +75,7 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
|
||||
social_command_arc,
|
||||
social_query_unified_arc,
|
||||
) = {
|
||||
let (
|
||||
activity_repo,
|
||||
follow_repo,
|
||||
actor_repo,
|
||||
blocklist_repo,
|
||||
social_query_arc,
|
||||
review_store,
|
||||
remote_watchlist_repo,
|
||||
) = match &db_pool {
|
||||
let fed_repos = match &db_pool {
|
||||
#[cfg(feature = "postgres-federation")]
|
||||
factory::DbPool::Postgres(pool) => postgres_federation::wire(pool.clone()),
|
||||
#[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 ap = activitypub::wire(activitypub::ActivityPubDeps {
|
||||
activity_repo,
|
||||
follow_repo,
|
||||
actor_repo,
|
||||
blocklist_repo,
|
||||
review_store,
|
||||
remote_watchlist_repo: remote_watchlist_repo.clone(),
|
||||
activity_repo: fed_repos.activity,
|
||||
follow_repo: fed_repos.follow,
|
||||
actor_repo: fed_repos.actor,
|
||||
blocklist_repo: fed_repos.blocklist,
|
||||
review_store: fed_repos.review_store,
|
||||
remote_watchlist_repo: fed_repos.remote_watchlist.clone(),
|
||||
remote_goal_repo: Arc::clone(&db.remote_goal),
|
||||
local_ap_content: Arc::clone(&ap_content_repo),
|
||||
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),
|
||||
user_repo: Arc::clone(&db.user),
|
||||
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(),
|
||||
allow_registration: app_config.allow_registration,
|
||||
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(
|
||||
Arc::clone(&ap_service_arc),
|
||||
Arc::clone(&db.user),
|
||||
fed_repos.follow_command,
|
||||
fed_repos.follow_query,
|
||||
app_config.base_url.clone(),
|
||||
));
|
||||
|
||||
@@ -130,8 +126,8 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
|
||||
ep,
|
||||
ap_router,
|
||||
ap_service_arc,
|
||||
social_query_arc,
|
||||
remote_watchlist_repo,
|
||||
fed_repos.admin_query,
|
||||
fed_repos.remote_watchlist,
|
||||
composite_social.clone() as Arc<dyn domain::ports::SocialCommand>,
|
||||
composite_social as Arc<dyn domain::ports::SocialQuery>,
|
||||
)
|
||||
|
||||
@@ -61,15 +61,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
);
|
||||
// Wire federation repos early to get remote_watchlist_repo for AppContext.
|
||||
#[cfg(feature = "federation")]
|
||||
let (
|
||||
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 {
|
||||
let fed_repos = match &db.db_pool {
|
||||
#[cfg(feature = "sqlite-federation")]
|
||||
db::DbPool::Sqlite(pool) => sqlite_federation::wire(pool.clone()),
|
||||
#[cfg(feature = "postgres-federation")]
|
||||
@@ -244,12 +236,12 @@ async fn main() -> anyhow::Result<()> {
|
||||
#[cfg(feature = "federation")]
|
||||
{
|
||||
let ap_wire = activitypub::wire(activitypub::ActivityPubDeps {
|
||||
activity_repo: fed_activity_repo,
|
||||
follow_repo: fed_follow_repo,
|
||||
actor_repo: fed_actor_repo,
|
||||
blocklist_repo: fed_blocklist_repo,
|
||||
review_store: fed_review_store,
|
||||
remote_watchlist_repo: fed_remote_watchlist_repo,
|
||||
activity_repo: fed_repos.activity,
|
||||
follow_repo: fed_repos.follow,
|
||||
actor_repo: fed_repos.actor,
|
||||
blocklist_repo: fed_repos.blocklist,
|
||||
review_store: fed_repos.review_store,
|
||||
remote_watchlist_repo: fed_repos.remote_watchlist,
|
||||
remote_goal_repo: Arc::clone(&remote_goal),
|
||||
local_ap_content: fed_ap_content,
|
||||
movie_repo: fed_movie_repo,
|
||||
@@ -258,6 +250,8 @@ async fn main() -> anyhow::Result<()> {
|
||||
goal_repo: fed_goal_repo,
|
||||
stats_repo: fed_stats_repo,
|
||||
user_repo: fed_user_repo,
|
||||
follow_command: fed_repos.follow_command,
|
||||
follow_query: fed_repos.follow_query,
|
||||
base_url,
|
||||
allow_registration,
|
||||
event_publisher: Arc::clone(&event_publisher),
|
||||
|
||||
20
docs/adr/0003-local-follow-direct-db.md
Normal file
20
docs/adr/0003-local-follow-direct-db.md
Normal 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.
|
||||
@@ -327,3 +327,31 @@ body > #root {
|
||||
.aero-star-filled {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/com
|
||||
import { timeAgo } from "@/lib/date"
|
||||
import type { SocialReviewDto } from "@/features/movies"
|
||||
|
||||
export function CommunityReviews({ reviews }: { reviews: { items: SocialReviewDto[] } }) {
|
||||
export function CommunityReviews({ reviews, onShowDetail }: { reviews: { items: SocialReviewDto[] }; onShowDetail?: (review: SocialReviewDto) => void }) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
@@ -36,7 +36,13 @@ export function CommunityReviews({ reviews }: { reviews: { items: SocialReviewDt
|
||||
</CardHeader>
|
||||
{r.comment && (
|
||||
<CardContent>
|
||||
<p className="text-xs text-muted-foreground">{r.comment}</p>
|
||||
<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>
|
||||
|
||||
216
spa/src/components/diary-calendar.tsx
Normal file
216
spa/src/components/diary-calendar.tsx
Normal 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} · {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>
|
||||
)
|
||||
}
|
||||
@@ -11,9 +11,10 @@ type MovieCardProps = {
|
||||
subtitle?: React.ReactNode
|
||||
variant?: "compact" | "full"
|
||||
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") {
|
||||
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">
|
||||
@@ -23,7 +24,15 @@ export function MovieCard({ movie, rating, comment, subtitle, variant = "full",
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-semibold">{movie.title}</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>
|
||||
{rating != null && <StarDisplay rating={rating} size="xs" />}
|
||||
</Link>
|
||||
@@ -35,13 +44,21 @@ export function MovieCard({ movie, rating, comment, subtitle, variant = "full",
|
||||
<Card size="sm">
|
||||
<CardContent className="flex gap-3">
|
||||
<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 className="min-w-0 flex-1">
|
||||
<p className="font-semibold">{movie.title}</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>}
|
||||
{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>
|
||||
{action && <div className="flex items-center" onClick={(e) => e.preventDefault()}>{action}</div>}
|
||||
</CardContent>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Link } from "@tanstack/react-router"
|
||||
import { useCallback } from "react"
|
||||
import { useCallback, useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { Bar, BarChart, XAxis, YAxis } from "recharts"
|
||||
import { Globe, Search, User } from "lucide-react"
|
||||
@@ -9,11 +9,16 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { MovieCard } from "@/components/movie-card"
|
||||
import { RatingHistogram } from "@/components/rating-histogram"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import { SwipeTabs } from "@/components/swipe-tabs"
|
||||
import { VirtualList } from "@/components/virtual-list"
|
||||
import { useInfiniteDiary } from "@/features/diary"
|
||||
import { TimeAgo } from "@/components/time-ago"
|
||||
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 = {
|
||||
@@ -47,6 +52,7 @@ export function ProfileView({
|
||||
const profileTabs = [
|
||||
{ value: "recent", label: t("profile.recent") },
|
||||
{ value: "top_rated", label: t("profile.topRated") },
|
||||
{ value: "calendar", label: t("profile.calendar", { defaultValue: "Calendar" }) },
|
||||
{ value: "trends", label: t("profile.trends") },
|
||||
] as const
|
||||
|
||||
@@ -124,6 +130,7 @@ export function ProfileView({
|
||||
search={search}
|
||||
/>
|
||||
)}
|
||||
{tab === "calendar" && <CalendarTab userId={userId} />}
|
||||
{tab === "trends" && <TrendsView data={data} />}
|
||||
</>
|
||||
)}
|
||||
@@ -152,27 +159,66 @@ function DiaryTab({ sortBy, userId, search }: { sortBy: string; userId?: string;
|
||||
)
|
||||
: items
|
||||
const loadMore = useCallback(() => fetchNextPage(), [fetchNextPage])
|
||||
const [detailEntry, setDetailEntry] = useState<DiaryEntryDto | null>(null)
|
||||
|
||||
if (isPending) return <Skeleton className="h-40 w-full rounded-xl" />
|
||||
if (!filtered.length) return <EmptyState icon={User} title={t("profile.noEntries")} />
|
||||
|
||||
return (
|
||||
<VirtualList
|
||||
items={filtered}
|
||||
estimateSize={52}
|
||||
hasMore={!!hasNextPage}
|
||||
isFetching={isFetchingNextPage}
|
||||
onLoadMore={loadMore}
|
||||
renderItem={(e) => (
|
||||
<MovieCard
|
||||
movie={e.movie}
|
||||
rating={e.review.rating}
|
||||
comment={e.review.comment}
|
||||
subtitle={<><TimeAgo date={e.review.watched_at} /></>}
|
||||
variant="compact"
|
||||
<>
|
||||
<VirtualList
|
||||
items={filtered}
|
||||
estimateSize={52}
|
||||
hasMore={!!hasNextPage}
|
||||
isFetching={isFetchingNextPage}
|
||||
onLoadMore={loadMore}
|
||||
renderItem={(e) => (
|
||||
<MovieCard
|
||||
movie={e.movie}
|
||||
rating={e.review.rating}
|
||||
comment={e.review.comment}
|
||||
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
|
||||
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 (
|
||||
<div className="space-y-3">
|
||||
{data.trends.top_directors.length > 0 && (
|
||||
{data.trends.top_directors.some((d) => d.count >= 2) && (
|
||||
<Card size="sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">{t("profile.topDirectors")}</CardTitle>
|
||||
@@ -221,6 +270,56 @@ function TrendsView({
|
||||
</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 && (
|
||||
<Card size="sm">
|
||||
<CardHeader>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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()
|
||||
return (
|
||||
<div
|
||||
@@ -8,7 +8,8 @@ export function RevealCard({ children }: { children: React.ReactNode }) {
|
||||
className="transition-all duration-700 ease-out"
|
||||
style={{
|
||||
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}
|
||||
|
||||
@@ -25,7 +25,7 @@ export function ReviewCard({ movie, review, userName, userId, isFederated, actor
|
||||
<Card size="sm">
|
||||
<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">
|
||||
{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>
|
||||
<div className="min-w-0 flex-1">
|
||||
{userName && (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from "react"
|
||||
import { createPortal } from "react-dom"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { PenLine, Search, X } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
@@ -27,6 +28,8 @@ type SearchOverlayProps = {
|
||||
onSelect: (movie: MovieSelection) => void
|
||||
}
|
||||
|
||||
const IMDB_RE = /^tt\d{4,}$/i
|
||||
|
||||
export function SearchOverlay({ open, onClose, onSelect }: SearchOverlayProps) {
|
||||
const { t } = useTranslation()
|
||||
const [query, setQuery] = useState("")
|
||||
@@ -38,6 +41,15 @@ export function SearchOverlay({ open, onClose, onSelect }: SearchOverlayProps) {
|
||||
const debouncedQuery = useDebounce(query, 300)
|
||||
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
|
||||
|
||||
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 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="flex items-center gap-3 p-4">
|
||||
<div className="relative flex-1">
|
||||
<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 && (
|
||||
<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" />
|
||||
@@ -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"
|
||||
>
|
||||
<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" />}
|
||||
</div>
|
||||
<div>
|
||||
@@ -182,4 +190,6 @@ export function SearchOverlay({ open, onClose, onSelect }: SearchOverlayProps) {
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return createPortal(content, document.body)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useState } from "react"
|
||||
import { Star } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { hapticLight } from "@/lib/haptics"
|
||||
@@ -12,25 +13,35 @@ 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) {
|
||||
const [animKey, setAnimKey] = useState(0)
|
||||
|
||||
function handleClick(star: number) {
|
||||
hapticLight()
|
||||
setAnimKey((k) => k + 1)
|
||||
onChange(star)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex gap-0.5">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<button
|
||||
key={star}
|
||||
type="button"
|
||||
onClick={() => { hapticLight(); onChange(star) }}
|
||||
onClick={() => handleClick(star)}
|
||||
className={cn(
|
||||
"flex items-center justify-center rounded-md transition-transform active:scale-90",
|
||||
buttonSizes[size],
|
||||
)}
|
||||
>
|
||||
<Star
|
||||
key={`${star}-${animKey}`}
|
||||
className={cn(
|
||||
iconSizes[size],
|
||||
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",
|
||||
)}
|
||||
style={star <= value ? { animationDelay: `${(star - 1) * 50}ms` } : undefined}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
|
||||
@@ -47,7 +47,7 @@ export function SwipeTabs({
|
||||
}
|
||||
}
|
||||
},
|
||||
{ axis: "x", filterTaps: true },
|
||||
{ axis: "x", filterTaps: true, pointer: { touch: true } },
|
||||
)
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useRef, useState } from "react"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { createPortal } from "react-dom"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { Download, Share2, X } from "lucide-react"
|
||||
import html2canvas from "html2canvas-pro"
|
||||
@@ -18,6 +19,11 @@ export function WrapUpShareCard({ report, onClose }: Props) {
|
||||
const cardRef = useRef<HTMLDivElement>(null)
|
||||
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 topGenre = report.top_genres[0]?.genre
|
||||
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="mb-4 flex w-full max-w-sm items-center justify-between">
|
||||
<Button variant="ghost" size="icon" onClick={onClose} className="text-white">
|
||||
@@ -66,7 +72,7 @@ export function WrapUpShareCard({ report, onClose }: Props) {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[75vh] overflow-y-auto rounded-2xl">
|
||||
<div className="max-h-[75vh] rounded-2xl">
|
||||
<div
|
||||
ref={cardRef}
|
||||
className="relative w-[360px] overflow-hidden rounded-2xl"
|
||||
@@ -133,7 +139,8 @@ export function WrapUpShareCard({ report, onClose }: Props) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -48,10 +48,25 @@ export const directorStatDtoSchema = z.object({
|
||||
})
|
||||
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({
|
||||
monthly_ratings: z.array(monthlyRatingDtoSchema),
|
||||
top_directors: z.array(directorStatDtoSchema),
|
||||
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>
|
||||
|
||||
|
||||
@@ -99,6 +99,7 @@ export type WrapUpReport = {
|
||||
lowest_rated_movie?: MovieRef
|
||||
first_movie_of_period?: MovieRef
|
||||
last_movie_of_period?: MovieRef
|
||||
watch_medium_distribution: { medium: string; count: number }[]
|
||||
poster_paths: string[]
|
||||
top_cast_profile_paths: string[]
|
||||
}
|
||||
|
||||
@@ -116,7 +116,7 @@
|
||||
"yearInReview": "Year in Review",
|
||||
"recent": "Recent",
|
||||
"topRated": "Top Rated",
|
||||
"trends": "Trends",
|
||||
"trends": "Stats",
|
||||
"movies": "Movies",
|
||||
"avg": "Avg",
|
||||
"followingStat": "Following",
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
createFileRoute,
|
||||
Outlet,
|
||||
redirect,
|
||||
useMatches,
|
||||
} from "@tanstack/react-router"
|
||||
import { useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
@@ -36,11 +37,15 @@ function ErrorFallback({ error, reset }: { error: unknown; reset: () => void })
|
||||
|
||||
function AppLayout() {
|
||||
const [logOpen, setLogOpen] = useState(false)
|
||||
const matches = useMatches()
|
||||
const routeKey = matches.at(-1)?.id ?? ""
|
||||
|
||||
return (
|
||||
<div className="mx-auto min-h-svh max-w-lg">
|
||||
<main className="pb-20">
|
||||
<Outlet />
|
||||
<div key={routeKey} className="aero-page-in">
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
<BottomTabBar onLogTap={() => setLogOpen(true)} />
|
||||
<ReviewSheet mode="log" open={logOpen} onOpenChange={setLogOpen} />
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next"
|
||||
import { BookOpen, ChevronLeft, ChevronRight, Pencil } from "lucide-react"
|
||||
import { format, startOfMonth, subMonths } from "date-fns"
|
||||
import { ReviewSheet } from "@/components/review-sheet"
|
||||
import { ReviewDetailSheet } from "@/components/review-detail-sheet"
|
||||
import { EditableContextMenu } from "@/components/editable-context-menu"
|
||||
import { MovieCard } from "@/components/movie-card"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
@@ -39,6 +40,7 @@ function DiaryPage() {
|
||||
useInfiniteDiary({ sort_by: "desc", user_id: auth?.user_id })
|
||||
const deleteReview = useDeleteReview()
|
||||
const [editingEntry, setEditingEntry] = useState<DiaryEntryDto | null>(null)
|
||||
const [detailEntry, setDetailEntry] = useState<DiaryEntryDto | null>(null)
|
||||
|
||||
const monthLabel = format(month, "MMMM yyyy")
|
||||
const monthStr = format(month, "yyyy-MM")
|
||||
@@ -121,6 +123,7 @@ function DiaryPage() {
|
||||
rating={item.entry.review.rating}
|
||||
comment={item.entry.review.comment}
|
||||
variant="full"
|
||||
onShowDetail={item.entry.review.comment ? () => setDetailEntry(item.entry) : undefined}
|
||||
action={
|
||||
<div className="flex items-center gap-1">
|
||||
{item.entry.review.watch_medium && (
|
||||
@@ -154,6 +157,15 @@ function DiaryPage() {
|
||||
review={editingEntry.review}
|
||||
/>
|
||||
)}
|
||||
|
||||
{detailEntry && (
|
||||
<ReviewDetailSheet
|
||||
open={!!detailEntry}
|
||||
onOpenChange={(open) => !open && setDetailEntry(null)}
|
||||
movie={detailEntry.movie}
|
||||
review={detailEntry.review}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { createFileRoute, Link } from "@tanstack/react-router"
|
||||
import { useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { Bookmark, BookmarkCheck, Star, User } from "lucide-react"
|
||||
import { BackButton } from "@/components/back-button"
|
||||
import { CommunityReviews } from "@/components/community-reviews"
|
||||
import { ReviewDetailSheet } from "@/components/review-detail-sheet"
|
||||
import { ViewingHistory } from "@/components/viewing-history"
|
||||
import { StarDisplay } from "@/components/star-display"
|
||||
import { RatingHistogram } from "@/components/rating-histogram"
|
||||
import { HorizontalStrip } from "@/components/horizontal-strip"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
@@ -18,7 +19,7 @@ import {
|
||||
useAddToWatchlist,
|
||||
useRemoveFromWatchlist,
|
||||
} from "@/features/watchlist"
|
||||
import type { CastMemberDto, CrewMemberDto } from "@/features/movies"
|
||||
import type { CastMemberDto, CrewMemberDto, SocialReviewDto } from "@/features/movies"
|
||||
|
||||
export const Route = createFileRoute("/_app/movies/$id")({
|
||||
component: MovieDetailPage,
|
||||
@@ -31,6 +32,7 @@ function MovieDetailPage() {
|
||||
const { data: profile } = useMovieProfile(id)
|
||||
const { data: history } = useMovieHistory(id)
|
||||
useDocumentTitle(data?.movie.title)
|
||||
const [detailReview, setDetailReview] = useState<SocialReviewDto | null>(null)
|
||||
|
||||
if (isPending) return <DetailSkeleton />
|
||||
if (!data) return null
|
||||
@@ -103,9 +105,19 @@ function MovieDetailPage() {
|
||||
</section>
|
||||
)}
|
||||
|
||||
<CommunityReviews reviews={reviews} />
|
||||
<CommunityReviews reviews={reviews} onShowDetail={setDetailReview} />
|
||||
|
||||
{history && <ViewingHistory history={history} />}
|
||||
|
||||
{detailReview && (
|
||||
<ReviewDetailSheet
|
||||
open={!!detailReview}
|
||||
onOpenChange={(open) => !open && setDetailReview(null)}
|
||||
movie={movie}
|
||||
review={{ id: "", rating: detailReview.rating, comment: detailReview.comment, watched_at: detailReview.watched_at, watch_medium: detailReview.watch_medium }}
|
||||
userName={detailReview.user_display}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createFileRoute, Link } from "@tanstack/react-router"
|
||||
import { useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { ArrowLeft, ChevronRight, Sparkles, Trash2 } from "lucide-react"
|
||||
import { format, subMonths, startOfYear, endOfYear } from "date-fns"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
@@ -125,6 +126,7 @@ function WrapupPage() {
|
||||
<DrawerTitle>{t("wrapup.generateWrapUp")}</DrawerTitle>
|
||||
</DrawerHeader>
|
||||
<div className="space-y-3 p-4 pb-8">
|
||||
<PeriodPresets onSelect={(s, e) => { setStartDate(s); setEndDate(e) }} />
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("wrapup.startDate")}</Label>
|
||||
<Input
|
||||
@@ -173,3 +175,33 @@ function WrapupPage() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PeriodPresets({ onSelect }: { onSelect: (start: string, end: string) => void }) {
|
||||
const { t } = useTranslation()
|
||||
const now = new Date()
|
||||
const fmt = (d: Date) => format(d, "yyyy-MM-dd")
|
||||
|
||||
const currentYear = now.getFullYear()
|
||||
const presets = [
|
||||
{ label: String(currentYear), start: fmt(startOfYear(now)), end: fmt(endOfYear(now)) },
|
||||
{ label: String(currentYear - 1), start: fmt(startOfYear(new Date(currentYear - 1, 0))), end: fmt(endOfYear(new Date(currentYear - 1, 0))) },
|
||||
{ label: t("wrapup.last12Months", { defaultValue: "Last 12 months" }), start: fmt(subMonths(now, 12)), end: fmt(now) },
|
||||
{ label: t("wrapup.last6Months", { defaultValue: "Last 6 months" }), start: fmt(subMonths(now, 6)), end: fmt(now) },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{presets.map((p) => (
|
||||
<Button
|
||||
key={p.label}
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onSelect(p.start, p.end)}
|
||||
>
|
||||
{p.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { FunFacts } from "@/components/wrapup-fun-facts"
|
||||
import { RankCard } from "@/components/wrapup-rank-card"
|
||||
import { posterUrl } from "@/lib/api/client"
|
||||
import { fmtUsd } from "@/lib/format"
|
||||
import { WATCH_MEDIUMS } from "@/lib/watch-mediums"
|
||||
import { useWrapUpReport } from "@/features/wrapup"
|
||||
import { useDocumentTitle } from "@/hooks/use-document-title"
|
||||
import type { MovieRef } from "@/features/wrapup"
|
||||
@@ -144,6 +145,34 @@ function WrapUpReportPage() {
|
||||
</RevealCard>
|
||||
)}
|
||||
|
||||
{/* Watch Medium Distribution */}
|
||||
{report.watch_medium_distribution && report.watch_medium_distribution.length > 0 && (
|
||||
<RevealCard>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-sm">
|
||||
{t("wrapup.howYouWatched", { defaultValue: "How You Watched" })}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{report.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.5 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-muted-foreground">{t("common.films", { count: wm.count })}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</RevealCard>
|
||||
)}
|
||||
|
||||
{/* Monthly Activity */}
|
||||
{report.movies_per_month.length > 0 && (
|
||||
<RevealCard>
|
||||
|
||||
Reference in New Issue
Block a user