Skip to main content

max / makenotwork

4.4 KB · 117 lines History Blame Raw
1 //! Who exported their followers, and when.
2 //!
3 //! `POST /api/export/followers` streams a creator's whole follower and
4 //! project-subscriber set, addresses included.
5 //! `site-docs/public/legal/mailing-list-data-processing.md` promises a
6 //! subscriber that an erasure reaches an exported copy, so the export has to
7 //! leave a record of who holds one.
8 //!
9 //! # No addresses, and no list
10 //!
11 //! The endpoint is `AuthUser`-scoped and exports everything the creator has, so
12 //! there is no list to name. And holding a second copy of anyone's address to
13 //! answer a question *about* copies of addresses is the thing this exists to
14 //! avoid: [`creators_to_contact`] intersects relationships the database already
15 //! has against export timestamps, and reads no address at all.
16
17 use chrono::{DateTime, Utc};
18 use sqlx::PgPool;
19
20 use super::id_types::UserId;
21 use crate::error::Result;
22
23 /// Note that a creator asked for their followers.
24 ///
25 /// The request and not the completion. The export streams from a spawned task,
26 /// so a run can end partway -- and a logged request that produced a partial
27 /// file still means addresses left the building, which is the only thing the
28 /// erasure follow-up cares about. Recording the completion instead would miss
29 /// exactly the runs most likely to have gone wrong.
30 #[tracing::instrument(skip_all)]
31 /// `None` for a count that could not be read: the row still has to be written,
32 /// so the number says "we could not say" rather than claiming zero.
33 pub async fn record(
34 pool: &PgPool,
35 user_id: UserId,
36 follower_count: Option<i64>,
37 subscriber_count: Option<i64>,
38 ) -> Result<()> {
39 sqlx::query(
40 "INSERT INTO follower_exports (user_id, follower_count, subscriber_count) \
41 VALUES ($1, $2, $3)",
42 )
43 .bind(user_id)
44 .bind(follower_count)
45 .bind(subscriber_count)
46 .execute(pool)
47 .await?;
48 Ok(())
49 }
50
51 /// The creators an erasing subscriber's data may have reached, and when.
52 ///
53 /// The creators this person follows or subscribes to, intersected with the ones
54 /// who exported *after* that relationship began. An export that ran before the
55 /// follow could not have carried them, so it is not a creator to write to.
56 ///
57 /// `latest` is the most recent qualifying export, which is what an operator
58 /// puts in the mail. Ordered by it, newest first: the creator most likely to
59 /// still have the file open is the one to reach first.
60 ///
61 /// # What this cannot see
62 ///
63 /// [`super::follows`] hard-deletes on unfollow, so a subscriber who has already
64 /// unfollowed leaves no relationship row to intersect against. Answering that
65 /// case needs a stored set of exported addresses, hashed or otherwise, and that
66 /// second copy is declined. Reopen the decision if the case turns up in
67 /// practice; do not quietly add the copy.
68 #[tracing::instrument(skip_all)]
69 pub async fn creators_to_contact(pool: &PgPool, subscriber_id: UserId) -> Result<Vec<Contacted>> {
70 let rows = sqlx::query_as::<_, Contacted>(
71 r"
72 WITH related AS (
73 SELECT f.target_id AS creator_id, f.created_at AS since
74 FROM follows f
75 WHERE f.follower_id = $1 AND f.target_type = 'user'
76 UNION ALL
77 SELECT p.user_id AS creator_id, f.created_at AS since
78 FROM follows f
79 JOIN projects p ON p.id = f.target_id
80 WHERE f.follower_id = $1 AND f.target_type = 'project'
81 )
82 SELECT
83 r.creator_id AS creator_id,
84 u.username AS username,
85 u.email AS email,
86 MAX(e.requested_at) AS latest,
87 COUNT(e.id) AS exports
88 FROM related r
89 JOIN follower_exports e
90 ON e.user_id = r.creator_id AND e.requested_at >= r.since
91 JOIN users u ON u.id = r.creator_id
92 GROUP BY r.creator_id, u.username, u.email
93 ORDER BY latest DESC
94 ",
95 )
96 .bind(subscriber_id)
97 .fetch_all(pool)
98 .await?;
99 Ok(rows)
100 }
101
102 /// One creator an erasure has to reach.
103 #[derive(Debug, Clone, sqlx::FromRow)]
104 pub struct Contacted {
105 /// Who to write to.
106 pub creator_id: UserId,
107 /// Their handle, for the operator reading the list.
108 pub username: String,
109 /// Where to write.
110 pub email: String,
111 /// The most recent export that could have carried this subscriber.
112 pub latest: DateTime<Utc>,
113 /// How many qualifying exports they ran. A creator who exports weekly is a
114 /// different follow-up from one who exported once.
115 pub exports: i64,
116 }
117