//! Who exported their followers, and when. //! //! `POST /api/export/followers` streams a creator's whole follower and //! project-subscriber set, addresses included. //! `site-docs/public/legal/mailing-list-data-processing.md` promises a //! subscriber that an erasure reaches an exported copy, so the export has to //! leave a record of who holds one. //! //! # No addresses, and no list //! //! The endpoint is `AuthUser`-scoped and exports everything the creator has, so //! there is no list to name. And holding a second copy of anyone's address to //! answer a question *about* copies of addresses is the thing this exists to //! avoid: [`creators_to_contact`] intersects relationships the database already //! has against export timestamps, and reads no address at all. use chrono::{DateTime, Utc}; use sqlx::PgPool; use super::id_types::UserId; use crate::error::Result; /// Note that a creator asked for their followers. /// /// The request and not the completion. The export streams from a spawned task, /// so a run can end partway -- and a logged request that produced a partial /// file still means addresses left the building, which is the only thing the /// erasure follow-up cares about. Recording the completion instead would miss /// exactly the runs most likely to have gone wrong. #[tracing::instrument(skip_all)] /// `None` for a count that could not be read: the row still has to be written, /// so the number says "we could not say" rather than claiming zero. pub async fn record( pool: &PgPool, user_id: UserId, follower_count: Option, subscriber_count: Option, ) -> Result<()> { sqlx::query( "INSERT INTO follower_exports (user_id, follower_count, subscriber_count) \ VALUES ($1, $2, $3)", ) .bind(user_id) .bind(follower_count) .bind(subscriber_count) .execute(pool) .await?; Ok(()) } /// The creators an erasing subscriber's data may have reached, and when. /// /// The creators this person follows or subscribes to, intersected with the ones /// who exported *after* that relationship began. An export that ran before the /// follow could not have carried them, so it is not a creator to write to. /// /// `latest` is the most recent qualifying export, which is what an operator /// puts in the mail. Ordered by it, newest first: the creator most likely to /// still have the file open is the one to reach first. /// /// # What this cannot see /// /// [`super::follows`] hard-deletes on unfollow, so a subscriber who has already /// unfollowed leaves no relationship row to intersect against. Answering that /// case needs a stored set of exported addresses, hashed or otherwise, and that /// second copy is declined. Reopen the decision if the case turns up in /// practice; do not quietly add the copy. #[tracing::instrument(skip_all)] pub async fn creators_to_contact(pool: &PgPool, subscriber_id: UserId) -> Result> { let rows = sqlx::query_as::<_, Contacted>( r" WITH related AS ( SELECT f.target_id AS creator_id, f.created_at AS since FROM follows f WHERE f.follower_id = $1 AND f.target_type = 'user' UNION ALL SELECT p.user_id AS creator_id, f.created_at AS since FROM follows f JOIN projects p ON p.id = f.target_id WHERE f.follower_id = $1 AND f.target_type = 'project' ) SELECT r.creator_id AS creator_id, u.username AS username, u.email AS email, MAX(e.requested_at) AS latest, COUNT(e.id) AS exports FROM related r JOIN follower_exports e ON e.user_id = r.creator_id AND e.requested_at >= r.since JOIN users u ON u.id = r.creator_id GROUP BY r.creator_id, u.username, u.email ORDER BY latest DESC ", ) .bind(subscriber_id) .fetch_all(pool) .await?; Ok(rows) } /// One creator an erasure has to reach. #[derive(Debug, Clone, sqlx::FromRow)] pub struct Contacted { /// Who to write to. pub creator_id: UserId, /// Their handle, for the operator reading the list. pub username: String, /// Where to write. pub email: String, /// The most recent export that could have carried this subscriber. pub latest: DateTime, /// How many qualifying exports they ran. A creator who exports weekly is a /// different follow-up from one who exported once. pub exports: i64, }