diff --git a/backend/api/README.md b/backend/api/README.md index 93912870..c4f36e08 100644 --- a/backend/api/README.md +++ b/backend/api/README.md @@ -534,6 +534,41 @@ gcloud scheduler jobs create http daily-saved-search-notifications \ View it [here](https://console.cloud.google.com/cloudscheduler). +Set up the two outreach email jobs. They partition the directory between them — members with enough +people nearby get the personalised city-number share email, members below that threshold get the +empty-room one — and both skip anyone already in a hand-written founder thread. Each member can only +ever receive each kind once; the `outreach_sends` ledger decides that, not the schedule, so running +either job twice is harmless. + +```bash +gcloud scheduler jobs create http weekly-city-number-emails \ + --schedule="0 15 * * *" \ + --uri="https://api.compassmeet.com/internal/send-city-number-emails" \ + --http-method=POST \ + --headers="x-api-key=,Content-Type=application/json" \ + --message-body='{"batchSize":10}' \ + --time-zone="UTC" \ + --location=us-west1 + +gcloud scheduler jobs create http weekly-empty-room-emails \ + --schedule="0 15 * * *" \ + --uri="https://api.compassmeet.com/internal/send-empty-room-emails" \ + --http-method=POST \ + --headers="x-api-key=,Content-Type=application/json" \ + --message-body='{"batchSize":10}' \ + --time-zone="UTC" \ + --location=us-west1 +``` + +Both accept `{"dryRun": true}`, which walks the full candidate list and returns the same counts +without sending or writing the ledger — worth running once by hand before the first real one: + +```bash +curl -X POST https://api.compassmeet.com/internal/send-city-number-emails \ + -H "x-api-key: " -H 'Content-Type: application/json' \ + -d '{"dryRun":true}' +``` + ##### API Deploy CD ```shell diff --git a/backend/api/src/app.ts b/backend/api/src/app.ts index 5c0a3c00..a7fa1e38 100644 --- a/backend/api/src/app.ts +++ b/backend/api/src/app.ts @@ -19,6 +19,8 @@ import {hideProfile} from 'api/hide-profile' import {reactToMessage} from 'api/react-to-message' import {saveSubscription} from 'api/save-subscription' import {saveSubscriptionMobile} from 'api/save-subscription-mobile' +import {sendCityNumberEmails} from 'api/send-city-number-emails' +import {sendEmptyRoomEmails} from 'api/send-empty-room-emails' import {sendSearchNotifications} from 'api/send-search-notifications' import {localSendTestEmail} from 'api/test' import {unhideProfile} from 'api/unhide-profile' @@ -54,6 +56,7 @@ import {createBookmarkedSearch} from './create-bookmarked-search' import {createComment} from './create-comment' import {createCompatibilityQuestion} from './create-compatibility-question' import {createEvent} from './create-event' +import {createOutreachSearch} from './create-outreach-search' import {createPrivateUserMessage} from './create-private-user-message' import {createPrivateUserMessageChannel} from './create-private-user-message-channel' import {createTestimonial} from './create-testimonial' @@ -67,10 +70,12 @@ import {getCurrentPrivateUser} from './get-current-private-user' import {getEvents} from './get-events' import {getLikesAndShips} from './get-likes-and-ships' import {getMe} from './get-me' +import {getMyReferrals} from './get-my-referrals' import {getNotifications} from './get-notifications' import {getOutreachQueue} from './get-outreach-queue' import {getProfileAnswers} from './get-profile-answers' import {getProfiles} from './get-profiles' +import {getSearchAlert} from './get-search-alert' import {getSupabaseToken} from './get-supabase-token' import {getTestimonials} from './get-testimonials' import {getTestimonialsMod} from './get-testimonials-mod' @@ -626,7 +631,10 @@ const handlers: {[k in APIPath]: APIHandler} = { 'get-notifications': getNotifications, 'get-options': getOptionsEndpoint, 'get-outreach-queue': getOutreachQueue, + 'get-my-referrals': getMyReferrals, 'update-outreach-contact': updateOutreachContact, + 'create-outreach-search': createOutreachSearch, + 'get-search-alert': getSearchAlert, 'get-testimonials': getTestimonials, 'get-testimonials-mod': getTestimonialsMod, 'create-testimonial': createTestimonial, @@ -739,6 +747,49 @@ app.post(pathWithPrefix('/internal/send-search-notifications'), async (req, res) } }) +/** + * The two automated outreach jobs. They partition the directory between them — above the nearby-count + * threshold gets the personalised share email, below it gets Contact #E — and both refuse to touch + * anyone already in a hand-written founder thread. Run them on separate schedules; running either + * twice is harmless, since the send ledger is what decides, not the cadence. + */ +const internalOutreachJob = ( + path: string, + run: (opts: {batchSize?: number; dryRun?: boolean}) => Promise, + failureMessage: string, +) => + // JSON parsing is per-route here rather than app-wide, so an internal endpoint that reads a body + // has to ask for it explicitly. Without this `req.body` is undefined and every run silently uses + // the default batch size. + app.post(pathWithPrefix(path), express.json(), async (req, res) => { + const apiKey = req.header('x-api-key') + if (!IS_LOCAL && apiKey !== process.env.COMPASS_API_KEY) { + return res.status(401).json({error: 'Unauthorized'}) + } + + try { + const batchSize = req.body?.batchSize ? Number(req.body.batchSize) : undefined + const result = await run({batchSize, dryRun: !!req.body?.dryRun}) + return res.status(200).json(result) + } catch (err) { + console.error(failureMessage, err) + await sendDiscordMessage(failureMessage, 'health') + return res.status(500).json({error: 'Internal server error'}) + } + }) + +internalOutreachJob( + '/internal/send-city-number-emails', + sendCityNumberEmails, + 'Failed to send city-number outreach emails...', +) + +internalOutreachJob( + '/internal/send-empty-room-emails', + sendEmptyRoomEmails, + 'Failed to send empty-room (Contact #E) outreach emails...', +) + const responses = { 200: { description: 'Request successful', diff --git a/backend/api/src/create-bookmarked-search.ts b/backend/api/src/create-bookmarked-search.ts index ea753710..047a46d6 100644 --- a/backend/api/src/create-bookmarked-search.ts +++ b/backend/api/src/create-bookmarked-search.ts @@ -1,6 +1,7 @@ +import {hasSearchCriteria} from 'common/filters' import {createSupabaseDirectClient} from 'shared/supabase/init' -import {APIHandler} from './helpers/endpoint' +import {APIErrors, APIHandler} from './helpers/endpoint' export const createBookmarkedSearch: APIHandler<'create-bookmarked-search'> = async ( props, @@ -9,6 +10,12 @@ export const createBookmarkedSearch: APIHandler<'create-bookmarked-search'> = as const creator_id = auth.uid const {search_filters, location = null, search_name = null} = props + // An unfiltered search matches every new member, so it would alert forever and tell them nothing. + // The button is disabled for this, but the rule belongs here too — the button is not the only caller. + if (!hasSearchCriteria(search_filters, location)) { + throw APIErrors.badRequest('Set at least one filter before saving a search alert') + } + const pg = createSupabaseDirectClient() const inserted = await pg.one( diff --git a/backend/api/src/create-outreach-search.ts b/backend/api/src/create-outreach-search.ts new file mode 100644 index 00000000..d0b6be8c --- /dev/null +++ b/backend/api/src/create-outreach-search.ts @@ -0,0 +1,62 @@ +import {APIErrors, APIHandler} from 'api/helpers/endpoint' +import {isAdminId} from 'common/envs/constants' +import {getLookingForSearchFilters, OUTREACH_SEARCH_NAME} from 'common/outreach/outreach' +import {createSupabaseDirectClient} from 'shared/supabase/init' + +/** + * Create the saved search a member never got around to creating, from the preferences they already + * put on their profile. + * + * Nothing here is invented on their behalf: age range, genders and connection goal come straight off + * their own profile, so the alerts they start getting are for the people they said they were looking + * for. Members who stated no preferences get nothing — see `getLookingForSearchFilters`. + * + * No location filter, on purpose. `city` is where they live, not the radius they would search, and + * guessing one would silently hide people they asked to see. + */ +export const createOutreachSearch: APIHandler<'create-outreach-search'> = async (props, auth) => { + if (!isAdminId(auth.uid)) throw APIErrors.forbidden('Admin only') + + const {userId} = props + const pg = createSupabaseDirectClient() + + const profile = await pg.oneOrNone<{ + pref_age_min: number | null + pref_age_max: number | null + pref_gender: string[] | null + pref_relation_styles: string[] | null + }>( + `select pref_age_min, pref_age_max, pref_gender, pref_relation_styles + from profiles + where user_id = $(userId)`, + {userId}, + ) + if (!profile) throw APIErrors.notFound('No profile for that member') + + const filters = getLookingForSearchFilters({ + prefAgeMin: profile.pref_age_min, + prefAgeMax: profile.pref_age_max, + prefGender: profile.pref_gender, + prefRelationStyles: profile.pref_relation_styles, + }) + if (!filters) { + throw APIErrors.badRequest('They have not said who they are looking for') + } + + // The dashboard only offers this for members with no search, but it reads a snapshot — checking + // again here keeps a stale page from stacking duplicate alerts on someone. + const existing = await pg.oneOrNone( + `select 1 from bookmarked_searches where creator_id = $(userId) limit 1`, + {userId}, + ) + if (existing) throw APIErrors.conflict('They already have a saved search') + + const {id} = await pg.one<{id: number}>( + `insert into bookmarked_searches (creator_id, search_filters, location, search_name) + values ($(userId), $(filters), null, $(name)) + returning id`, + {userId, filters, name: OUTREACH_SEARCH_NAME}, + ) + + return {searchId: Number(id)} +} diff --git a/backend/api/src/create-user-and-profile.ts b/backend/api/src/create-user-and-profile.ts index 8695f082..8f0cc4e5 100644 --- a/backend/api/src/create-user-and-profile.ts +++ b/backend/api/src/create-user-and-profile.ts @@ -16,6 +16,7 @@ import * as admin from 'firebase-admin' import {getIp, track} from 'shared/analytics' import {getBucket} from 'shared/firebase-utils' import {generateAvatarUrl} from 'shared/helpers/generate-and-update-avatar-urls' +import {notifyReferrerOfSignup} from 'shared/outreach/referrals' import {removePinnedUrlFromPhotoUrls} from 'shared/profiles/parse-photos' import {createSupabaseDirectClient} from 'shared/supabase/init' import {insert} from 'shared/supabase/utils' @@ -166,6 +167,19 @@ export const createUserAndProfile: APIHandler<'create-user-and-profile'> = async } catch (e) { console.error('Failed to send discord new profile', e) } + try { + // The one moment a sharer can be told their share worked. Miss it and `?referrer=` stays a + // number nobody ever sees. + if (newProfileRow.referred_by_username) { + await notifyReferrerOfSignup( + {name: user.name, username: user.username, avatarUrl: user.avatarUrl}, + newProfileRow.referred_by_username, + pg, + ) + } + } catch (e) { + console.error('Failed to notify referrer of signup', e) + } try { const nProfiles = await pg.one(`SELECT count(*) FROM profiles`, [], (r) => Number(r.count), diff --git a/backend/api/src/get-my-referrals.ts b/backend/api/src/get-my-referrals.ts new file mode 100644 index 00000000..eff26d11 --- /dev/null +++ b/backend/api/src/get-my-referrals.ts @@ -0,0 +1,22 @@ +import {APIErrors, APIHandler} from 'api/helpers/endpoint' +import {getReferredMembers} from 'shared/outreach/referrals' +import {createSupabaseDirectClient} from 'shared/supabase/init' + +/** + * Who this member has brought to Compass. + * + * Attribution is stored against the referrer's *username*, not their id, because that is what travels + * in the share link. A member who renames themselves therefore loses credit for earlier referrals — + * worth knowing, and not worth fixing by rewriting history on rename. + */ +export const getMyReferrals: APIHandler<'get-my-referrals'> = async (_props, auth) => { + const pg = createSupabaseDirectClient() + + const me = await pg.oneOrNone<{username: string}>(`select username from users where id = $1`, [ + auth.uid, + ]) + if (!me) throw APIErrors.notFound('User not found') + + const members = await getReferredMembers(me.username, pg) + return {count: members.length, members} +} diff --git a/backend/api/src/get-outreach-queue.ts b/backend/api/src/get-outreach-queue.ts index bff1fe37..06fba697 100644 --- a/backend/api/src/get-outreach-queue.ts +++ b/backend/api/src/get-outreach-queue.ts @@ -2,17 +2,31 @@ import {APIErrors, APIHandler} from 'api/helpers/endpoint' import {isAdminId} from 'common/envs/constants' import { DORMANT_AFTER_DAYS, + EMPTY_ROOM_INACTIVE_DAYS, + EMPTY_ROOM_MAX_NEARBY, getOutreachTier, getProfileCompleteness, + LocalDensity, + OUTREACH_RADIUS_KM, OutreachRow, OutreachStage, OutreachStatus, + OutreachTrigger, } from 'common/outreach/outreach' import {createSupabaseDirectClient} from 'shared/supabase/init' const DEFAULT_NEW_MEMBER_LIMIT = 20 const DEFAULT_MIN_SIGNUP_DAYS = 3 +/** + * How recently an event has to have happened to still be a reason to write today. + * + * The whole argument for triggering on events is that the ask lands while the moment is fresh. An + * alert that fired in March is not a moment, it is a fact — so past this window the badge goes away + * rather than sitting there implying the iron is still hot. + */ +const TRIGGER_RECENCY_DAYS = 14 + const MS_PER_DAY = 24 * 60 * 60 * 1000 const daysSince = (ts: string | null): number | null => @@ -46,6 +60,10 @@ type QueueQueryRow = { compatibility_answer_count: string saved_search_count: string referred_count: string + nearby_count: string | null + alert_fired: boolean + got_member_reply: boolean + sent_member_message: boolean } /** @@ -129,7 +147,47 @@ const QUEUE_SQL = ` (select count(*) from bookmarked_searches bs where bs.creator_id = u.id) as saved_search_count, (select count(*) from profiles rp where rp.referred_by_username = u.username) - as referred_count + as referred_count, + -- Members within the outreach radius of them. Null when they have no coordinates, which is + -- not the same as zero: one is a fact about the world, the other about their profile. + case + when p.city_latitude is null then null + else (select count(*) + from profiles np + join users nu on nu.id = np.user_id + where np.user_id != u.id + and np.looking_for_matches + and not coalesce(nu.is_banned_from_posting, false) + and not coalesce(np.disabled, false) + and np.city_latitude is not null + and calculate_earth_distance_km(p.city_latitude, p.city_longitude, + np.city_latitude, np.city_longitude) + < $(radiusKm)) + end as nearby_count, + -- A saved-search alert actually reached them recently. last_notified_at is only stamped on + -- searches that matched, so this is "the product visibly worked", not "they have a search". + exists (select 1 + from bookmarked_searches bs + where bs.creator_id = u.id + and bs.last_notified_at > now() - make_interval(days => $(triggerRecencyDays))) + as alert_fired, + -- Another member (not me) wrote to them. Admin messages are excluded deliberately: founder + -- outreach is not the platform working, and counting it would make every thread self-fulfilling. + exists (select 1 + from private_user_messages pm + join private_user_message_channel_members mem + on mem.channel_id = pm.channel_id and mem.user_id = u.id + where pm.user_id != u.id + and pm.user_id != $(adminId) + and pm.visibility != 'system_status') as got_member_reply, + -- They have written to someone who is not me, so they have committed to using it. + exists (select 1 + from private_user_messages pm + join private_user_message_channel_members other + on other.channel_id = pm.channel_id and other.user_id != u.id + where pm.user_id = u.id + and other.user_id != $(adminId) + and pm.visibility != 'system_status') as sent_member_message from candidates c join users u on u.id = c.user_id left join profiles p on p.user_id = u.id @@ -153,6 +211,8 @@ export const getOutreachQueue: APIHandler<'get-outreach-queue'> = async (props, adminId: auth.uid, minSignupDays: props.minSignupDays ?? DEFAULT_MIN_SIGNUP_DAYS, newMemberLimit: props.newMemberLimit ?? DEFAULT_NEW_MEMBER_LIMIT, + radiusKm: OUTREACH_RADIUS_KM, + triggerRecencyDays: TRIGGER_RECENCY_DAYS, }) return {rows: rows.map((row) => toOutreachRow(row, auth.uid))} @@ -182,6 +242,12 @@ const toOutreachRow = (row: QueueQueryRow, adminId: string): OutreachRow => { const savedSearchCount = Number(row.saved_search_count) + // `nearby` is left empty here on purpose: naming the nearest few costs a second distance query per + // member, and the queue renders a hundred rows. The dashboard only needs the number; the messages + // that actually quote names go through `getLocalDensity`. + const localDensity: LocalDensity | null = + row.nearby_count === null ? null : {count: Number(row.nearby_count), city: row.city, nearby: []} + return { user: { id: row.id, @@ -205,9 +271,36 @@ const toOutreachRow = (row: QueueQueryRow, adminId: string): OutreachRow => { channelId: row.channel_id === null ? null : Number(row.channel_id), savedSearchCount, referredCount: Number(row.referred_count), + localDensity, + triggers: getTriggers(row, localDensity, daysSinceLastOnline), } } +/** + * Which peak-willingness events have fired for this member. + * + * These are what the day numbers in the sequence were always standing in for. A calendar date says + * "value has probably landed by now"; these say it did, and name the moment — which is the difference + * between an ask that reads as timed and one that reads as automated. + */ +const getTriggers = ( + row: QueueQueryRow, + localDensity: LocalDensity | null, + daysSinceLastOnline: number | null, +): OutreachTrigger[] => { + const triggers: OutreachTrigger[] = [] + + if (row.alert_fired) triggers.push('search_alert_fired') + if (row.got_member_reply) triggers.push('first_reply_received') + if (row.sent_member_message) triggers.push('sent_first_message') + + const roomIsEmpty = localDensity !== null && localDensity.count < EMPTY_ROOM_MAX_NEARBY + const goneQuiet = daysSinceLastOnline === null || daysSinceLastOnline >= EMPTY_ROOM_INACTIVE_DAYS + if (roomIsEmpty || goneQuiet) triggers.push('empty_room') + + return triggers +} + const getStatus = ( channelId: string | null, repliedToUs: boolean, diff --git a/backend/api/src/get-search-alert.ts b/backend/api/src/get-search-alert.ts new file mode 100644 index 00000000..9fccd47a --- /dev/null +++ b/backend/api/src/get-search-alert.ts @@ -0,0 +1,60 @@ +import {loadProfiles} from 'api/get-profiles' +import {APIErrors, APIHandler} from 'api/helpers/endpoint' +import {createSupabaseDirectClient} from 'shared/supabase/init' + +/** + * The people one delivered saved-search alert was about. + * + * Reads the recorded match set rather than re-running the search, which is the point of recording it: + * the alert was a diff against the previous profile snapshot, and that diff is not reproducible from + * the filters alone. + */ +export const getSearchAlert: APIHandler<'get-search-alert'> = async (props, auth) => { + const pg = createSupabaseDirectClient() + + const send = await pg.oneOrNone<{ + search_ids: number[] + matched_user_ids: string[] + created_time: string + }>( + `select search_ids, matched_user_ids, created_time + from search_alert_sends + where id = $(id) and creator_id = $(uid)`, + {id: props.id, uid: auth.uid}, + ) + // Not-found rather than forbidden for someone else's alert: whether an id exists is itself a fact + // about another member. + if (!send) throw APIErrors.notFound('No such alert') + + const [{profiles}, searches] = await Promise.all([ + loadProfiles({ + userIds: send.matched_user_ids, + userId: auth.uid, + skipId: auth.uid, + skipCount: true, + limit: send.matched_user_ids.length, + }), + // A member may have deleted the search since; the alert it produced still stands, it just loses + // its description. + pg.manyOrNone<{id: number; search_name: string | null; search_filters: any; location: any}>( + `select id, search_name, search_filters, location + from bookmarked_searches + where id = any($(searchIds)) and creator_id = $(uid)`, + {searchIds: send.search_ids, uid: auth.uid}, + ), + ]) + + return { + profiles, + searches: searches.map((row) => ({ + id: Number(row.id), + name: row.search_name, + filters: row.search_filters, + location: row.location, + })), + createdTime: new Date(send.created_time).valueOf(), + // Named rather than silently shortened: a list that came up two people short with no explanation + // reads as the alert having lied. + goneCount: send.matched_user_ids.length - profiles.length, + } +} diff --git a/backend/api/src/send-city-number-emails.ts b/backend/api/src/send-city-number-emails.ts new file mode 100644 index 00000000..4edfe148 --- /dev/null +++ b/backend/api/src/send-city-number-emails.ts @@ -0,0 +1,126 @@ +import {debug} from 'common/logger' +import {EMPTY_ROOM_MAX_NEARBY, OUTREACH_RADIUS_KM} from 'common/outreach/outreach' +import {sleep} from 'common/util/time' +import {sendShareCompassEmail} from 'email/functions/helpers' +import {log} from 'shared/monitoring/log' +import {getLocalDensity} from 'shared/outreach/local-density' +import {recordOutreachSend} from 'shared/outreach/sends' +import {createSupabaseDirectClient} from 'shared/supabase/init' +import {getPrivateUser, getUser} from 'shared/utils' + +/** Emails per run. Small on purpose — see the note on cadence below. */ +const DEFAULT_BATCH_SIZE = 20 + +/** Provider courtesy, matching the pacing `createEmails` already uses for bulk sends. */ +const PAUSE_BETWEEN_SENDS_MS = 2000 + +/** + * Candidates for the personalised share email. + * + * Three exclusions, each load-bearing: + * + * - anyone already sent a `city_number` or `empty_room` message. The two are mutually exclusive and + * both are once-only, so the ledger is checked for either. + * - anyone in an active hand-written founder thread. A member being written to personally must not + * also receive a machine-written message making the same argument in the same week; it makes the + * personal one look automated, which is the one thing it can never look. + * - anyone without a city. There is no honest local number to quote them, and the generic fallback + * copy is not worth a once-only send. + */ +const CANDIDATES_SQL = ` + select u.id + from users u + join profiles p on p.user_id = u.id + where not coalesce(u.is_banned_from_posting, false) + and not coalesce(p.disabled, false) + and p.looking_for_matches + and p.city is not null + and p.city_latitude is not null + and not exists (select 1 + from outreach_sends os + where os.user_id = u.id + and os.kind in ('city_number', 'empty_room')) + and not exists (select 1 + from outreach_contacts oc + where oc.user_id = u.id + and coalesce(oc.stage, 'not_started') not in ('not_started', 'closed')) + order by u.created_time desc + limit $(batchSize) +` + +/** + * The automated half of Contact #3a: the honest local number, sent to everyone the founder sequence + * will never reach by hand. + * + * It quotes the same `OUTREACH_RADIUS_KM` figure the dashboard shows, not the wider historical radius, + * because a member who later hears the tighter number from Martin directly must not find that the two + * disagree. + * + * Members whose local number is *below* the threshold are skipped rather than sent a discouraging + * count — they are the population Contact #E exists for, and that job picks them up. The two jobs + * partition the directory between them; nobody gets both, and nobody gets neither. + */ +export const sendCityNumberEmails = async (opts?: {batchSize?: number; dryRun?: boolean}) => { + const pg = createSupabaseDirectClient() + const batchSize = opts?.batchSize ?? DEFAULT_BATCH_SIZE + const dryRun = opts?.dryRun ?? false + + const candidates = await pg.manyOrNone<{id: string}>(CANDIDATES_SQL, {batchSize}) + + let sent = 0 + let skippedThinRoom = 0 + let skippedNoDensity = 0 + let failed = 0 + + for (const {id} of candidates) { + try { + const density = await getLocalDensity(id, {pg, radiusKm: OUTREACH_RADIUS_KM}) + if (!density) { + skippedNoDensity++ + continue + } + if (density.count < EMPTY_ROOM_MAX_NEARBY) { + skippedThinRoom++ + continue + } + + if (dryRun) { + debug('[city-number] would send', id, density.count, density.city) + sent++ + continue + } + + // Claim the send before making it. If a concurrent run got here first the insert loses and we + // skip, which is the right way round: a missed email costs nothing, a duplicate costs the + // credibility of a message whose whole point is that a person is behind it. + const claimed = await recordOutreachSend( + id, + 'city_number', + {count: density.count, city: density.city, radiusKm: OUTREACH_RADIUS_KM}, + pg, + ) + if (!claimed) continue + + const [user, privateUser] = await Promise.all([getUser(id), getPrivateUser(id)]) + if (!user || !privateUser) continue + + await sendShareCompassEmail(user, privateUser, density) + sent++ + await sleep(PAUSE_BETWEEN_SENDS_MS) + } catch (e) { + failed++ + log.error('Failed to send city-number email', {userId: id, error: e}) + } + } + + const result = { + candidates: candidates.length, + sent, + skippedThinRoom, + skippedNoDensity, + failed, + dryRun, + } + log.info('city-number email run complete', result) + return result +} diff --git a/backend/api/src/send-empty-room-emails.ts b/backend/api/src/send-empty-room-emails.ts new file mode 100644 index 00000000..5c0cad0f --- /dev/null +++ b/backend/api/src/send-empty-room-emails.ts @@ -0,0 +1,165 @@ +import {debug} from 'common/logger' +import {Notification} from 'common/notifications' +import { + EMPTY_ROOM_INACTIVE_DAYS, + EMPTY_ROOM_MAX_NEARBY, + OUTREACH_RADIUS_KM, +} from 'common/outreach/outreach' +import {getNotificationDestinationsForUser} from 'common/user-notification-preferences' +import {sleep} from 'common/util/time' +import {sendEmptyRoomEmail} from 'email/functions/helpers' +import {log} from 'shared/monitoring/log' +import {getLocalDensity} from 'shared/outreach/local-density' +import {recordOutreachSend} from 'shared/outreach/sends' +import {createSupabaseDirectClient} from 'shared/supabase/init' +import {insertNotificationToSupabase} from 'shared/supabase/notifications' +import {getPrivateUser, getUser} from 'shared/utils' + +const DEFAULT_BATCH_SIZE = 50 +const PAUSE_BETWEEN_SENDS_MS = 2000 + +/** + * Everyone who might be in an empty room, before the count is known. + * + * The inactivity arm of the trigger is applied here; the density arm can only be applied per-member + * below, because it needs a distance query against their own coordinates. Members with no city are + * excluded — with no coordinates there is no honest number, and this message is nothing but the + * honest number. + */ +const CANDIDATES_SQL = ` + select u.id, + (ua.last_online_time is null + or ua.last_online_time < now() - make_interval(days => $(inactiveDays))) as was_inactive + from users u + join profiles p on p.user_id = u.id + left join user_activity ua on ua.user_id = u.id + where not coalesce(u.is_banned_from_posting, false) + and not coalesce(p.disabled, false) + and p.city is not null + and p.city_latitude is not null + and not exists (select 1 + from outreach_sends os + where os.user_id = u.id + and os.kind in ('city_number', 'empty_room')) + and not exists (select 1 + from outreach_contacts oc + where oc.user_id = u.id + and coalesce(oc.stage, 'not_started') not in ('not_started', 'closed')) + order by u.created_time desc + limit $(batchSize) +` + +/** + * Contact #E, automated. + * + * Sends only when the local number is genuinely low. The doc lists two weeks of silence as a second + * way in, and it is honoured — but only as a *reason to look*, never as a reason to send: telling + * someone with forty people near them that the room is empty would be false, and this is the one + * message in the system whose entire value is that it is not. + * + * `looking_for_matches` is deliberately not required here, unlike the city-number job. Someone who + * has switched it off in a city with four members has most likely switched it off *because* of that, + * and they are exactly who this is for. + */ +export const sendEmptyRoomEmails = async (opts?: {batchSize?: number; dryRun?: boolean}) => { + const pg = createSupabaseDirectClient() + const batchSize = opts?.batchSize ?? DEFAULT_BATCH_SIZE + const dryRun = opts?.dryRun ?? false + + const candidates = await pg.manyOrNone<{id: string; was_inactive: boolean}>(CANDIDATES_SQL, { + batchSize, + inactiveDays: EMPTY_ROOM_INACTIVE_DAYS, + }) + + let sent = 0 + let skippedRoomNotEmpty = 0 + let skippedNoDensity = 0 + let failed = 0 + + for (const {id, was_inactive: wasInactive} of candidates) { + try { + const density = await getLocalDensity(id, {pg, radiusKm: OUTREACH_RADIUS_KM}) + if (!density || !density.city) { + skippedNoDensity++ + continue + } + if (density.count >= EMPTY_ROOM_MAX_NEARBY) { + skippedRoomNotEmpty++ + continue + } + + if (dryRun) { + debug('[empty-room] would send', id, density.count, density.city) + sent++ + continue + } + + const claimed = await recordOutreachSend( + id, + 'empty_room', + {count: density.count, city: density.city, radiusKm: OUTREACH_RADIUS_KM, wasInactive}, + pg, + ) + if (!claimed) continue + + const [user, privateUser] = await Promise.all([getUser(id), getPrivateUser(id)]) + if (!user || !privateUser) continue + + await sendEmptyRoomEmail( + user, + privateUser, + {count: density.count, city: density.city}, + { + wasInactive, + }, + ) + await notifyInApp(id, privateUser, density.count, density.city) + + sent++ + await sleep(PAUSE_BETWEEN_SENDS_MS) + } catch (e) { + failed++ + log.error('Failed to send empty-room email', {userId: id, error: e}) + } + } + + const result = { + candidates: candidates.length, + sent, + skippedRoomNotEmpty, + skippedNoDensity, + failed, + dryRun, + } + log.info('empty-room run complete', result) + return result +} + +/** + * The in-app copy of the message, so it is waiting for them next time they open Compass rather than + * only sitting in an inbox they may already have stopped reading. + */ +const notifyInApp = async ( + userId: string, + privateUser: Awaited>, + count: number, + city: string, +) => { + if (!privateUser) return + const {sendToBrowser} = getNotificationDestinationsForUser(privateUser, 'platform_updates') + if (!sendToBrowser) return + + const notification: Notification = { + // One per member, matching the once-ever rule the ledger enforces on the email. + id: `empty-room-${userId}`, + userId, + reason: 'empty_room', + createdTime: Date.now(), + isSeen: false, + sourceType: 'outreach', + sourceUpdateType: 'created', + sourceText: `There are only ${count} members within reach of ${city} so far. The one thing that changes that is someone you bring.`, + isSeenOnHref: '/referrals', + } + await insertNotificationToSupabase(notification) +} diff --git a/backend/api/src/send-search-notifications.ts b/backend/api/src/send-search-notifications.ts index 8ee6e61f..f495edc9 100644 --- a/backend/api/src/send-search-notifications.ts +++ b/backend/api/src/send-search-notifications.ts @@ -11,14 +11,20 @@ import { withSchema, } from 'api/profile-snapshot' import {sendDiscordMessage} from 'common/discord/core' +import {FilterFields, hasSearchCriteria} from 'common/filters' import {debug} from 'common/logger' -import {MatchesType} from 'common/profiles/bookmarked_searches' +import {Notification} from 'common/notifications' +import {MatchesType, MatchUser} from 'common/profiles/bookmarked_searches' import {Row} from 'common/supabase/utils' +import {getNotificationDestinationsForUser} from 'common/user-notification-preferences' import {DAY_MS} from 'common/util/time' import {sendSearchAlertsEmail} from 'email/functions/helpers' -import {groupBy, keyBy, uniq} from 'lodash' +import {groupBy, keyBy, uniq, uniqBy} from 'lodash' +import {createT} from 'shared/locale' +import {sendMobileNotifications, sendWebNotifications} from 'shared/mobile' import {log} from 'shared/monitoring/log' import {createSupabaseDirectClient, SupabaseDirectClient} from 'shared/supabase/init' +import {insertNotificationToSupabase} from 'shared/supabase/notifications' /** * A staging snapshot is only promoted once every search has been processed, so a search whose email @@ -83,6 +89,126 @@ const findNewMatches = async ( return candidates.filter((profile: any) => !previously.has(profile.user_id)) } +/** Everyone this alert named, across all of the creator's searches that matched in this run. */ +const matchedUsersOf = (alert: CreatorAlert) => + uniqBy( + alert.matches.flatMap((match) => match.matches), + 'id', + ) + +/** + * Where the notification lands when it is opened. + * + * One match goes straight to their profile — an intermediate page listing one person is a click + * charged for nothing. Anything else goes to the send's own page, which is the only place the alert's + * people exist as a set: re-running the saved search would return them mixed into every older result, + * and a member who was *edited* into matching would not stand out at all. + */ +const alertUrl = (matched: MatchUser[], sendId: number) => + matched.length === 1 ? `/${matched[0].username}` : `/alerts/${sendId}` + +/** The row that gives the push and the bell entry somewhere to point. */ +const recordSearchAlertSend = async ( + pg: SupabaseDirectClient, + creatorId: string, + alert: CreatorAlert, +) => { + const {id} = await pg.one<{id: number}>( + `insert into search_alert_sends (creator_id, search_ids, matched_user_ids) + values ($(creatorId), $(searchIds), $(matchedUserIds)) + returning id`, + { + creatorId, + searchIds: alert.matchedSearchIds, + matchedUserIds: matchedUsersOf(alert).map((user) => user.id), + }, + ) + return Number(id) +} + +/** + * The in-app half of the alert: the bell entry and the push, both pointing at `alertUrl`. + * + * Deliberately best-effort. A member who cannot be pushed to has still had their email, and a failure + * here must not mark the run failed — that would pin the staging snapshot and re-send the email that + * already went out. + */ +const notifyInProduct = async ( + pg: SupabaseDirectClient, + creatorId: string, + alert: CreatorAlert, + sendId: number, +) => { + const {sendToBrowser, sendToMobile} = getNotificationDestinationsForUser( + alert.privateUser, + 'new_search_alerts', + ) + if (!sendToBrowser && !sendToMobile) return + + const matched = matchedUsersOf(alert) + if (!matched.length) return + + const t = createT(alert.privateUser?.locale) + const url = alertUrl(matched, sendId) + const first = matched[0] + + const title = + matched.length === 1 + ? t('notifications.search_alert.title_one', '{name} matches your saved search', { + name: first.name, + }) + : t('notifications.search_alert.title_many', '{count} people match your saved search', { + count: matched.length, + }) + + const body = + matched.length === 1 + ? t( + 'notifications.search_alert.body_one', + 'They just joined, or updated their profile into your search.', + ) + : matched.map((user) => user.name).join(', ') + + if (sendToBrowser) { + const notification: Notification = { + id: `search-alert-${sendId}`, + userId: creatorId, + reason: 'new_search_alerts', + sourceType: 'new_search_alerts', + sourceUpdateType: 'created', + createdTime: Date.now(), + isSeen: false, + // The bell shows one face and says how many there are; the page behind it shows them all. + sourceUserName: first.name, + sourceUserUsername: first.username, + sourceUserAvatarUrl: first.avatarUrl ?? undefined, + sourceText: body, + sourceSlug: url, + data: {sendId, count: matched.length}, + } + await insertNotificationToSupabase(notification, pg) + } + + // One collapse key for every search alert, so today's replaces yesterday's rather than stacking a + // tray of them for someone who was away for a week. + const payload = {title, body, url, collapseKey: 'search-alerts'} + + if (sendToBrowser) { + try { + await sendWebNotifications(pg, creatorId, JSON.stringify(payload)) + } catch (error) { + log.error(`Failed to web-push search alerts to ${creatorId}`, {error}) + } + } + if (sendToMobile) { + try { + await sendMobileNotifications(pg, creatorId, payload) + } catch (error) { + log.error(`Failed to push search alerts to ${creatorId}`, {error}) + } + } +} + /** Emails each creator, then stamps their searches so a crash never re-sends what already went out. */ export const notifyBookmarkedSearch = async ( pg: SupabaseDirectClient, @@ -96,6 +222,15 @@ export const notifyBookmarkedSearch = async ( if (alert.matches.length) { await sendSearchAlertsEmail(alert.user as any, alert.privateUser, alert.matches) notified++ + + // After the email, so a failed send never leaves a page nobody was told about — and outside + // the push's own error handling, so a dead push subscription cannot cost them the email. + const sendId = await recordSearchAlertSend(pg, creatorId, alert) + try { + await notifyInProduct(pg, creatorId, alert, sendId) + } catch (error) { + log.error(`Failed to notify ${creatorId} in-product about search alerts`, {error}) + } } await pg.none( `update bookmarked_searches @@ -175,6 +310,12 @@ export const sendSearchNotifications = async () => { for (const row of creatorSearches) { if (typeof row.search_filters !== 'object') continue + // Saving one of these is now rejected, but rows predating that rule are still in the table and + // would match every signup forever. Skipped rather than deleted: it is their row to remove. + if (!hasSearchCriteria(row.search_filters as Partial, row.location)) { + log.info(`Skipping unfiltered saved search ${row.id} — it would match every new member`) + continue + } const profiles = await findNewMatches(pg, row, changedUserIds) if (!profiles.length) continue log.info( diff --git a/backend/api/tests/unit/create-bookmarked-search.unit.test.ts b/backend/api/tests/unit/create-bookmarked-search.unit.test.ts index 8a2f45b2..524ce555 100644 --- a/backend/api/tests/unit/create-bookmarked-search.unit.test.ts +++ b/backend/api/tests/unit/create-bookmarked-search.unit.test.ts @@ -43,4 +43,20 @@ describe('createBookmarkedSearch', () => { ]) }) }) + + describe('when the search has no filters', () => { + it('should reject rather than save a search that matches every new member', async () => { + const mockAuth = {uid: '321'} as AuthedUser + const mockReq = {} as any + + await expect( + createBookmarkedSearch( + {search_filters: {orderBy: 'created_time', shortBio: true}, location: null}, + mockAuth, + mockReq, + ), + ).rejects.toThrow(/at least one filter/) + expect(mockPg.one).not.toBeCalled() + }) + }) }) diff --git a/backend/api/tests/unit/send-search-notifications.unit.test.ts b/backend/api/tests/unit/send-search-notifications.unit.test.ts index bc0f963c..60413da5 100644 --- a/backend/api/tests/unit/send-search-notifications.unit.test.ts +++ b/backend/api/tests/unit/send-search-notifications.unit.test.ts @@ -3,14 +3,19 @@ jest.mock('api/get-profiles') jest.mock('api/profile-snapshot') jest.mock('email/functions/helpers') jest.mock('common/discord/core') +jest.mock('shared/mobile') +jest.mock('shared/supabase/notifications') import * as profileModules from 'api/get-profiles' import * as snapshotModules from 'api/profile-snapshot' import * as searchNotificationModules from 'api/send-search-notifications' import * as helperModules from 'email/functions/helpers' +import * as mobileModules from 'shared/mobile' import * as supabaseInit from 'shared/supabase/init' +import * as notificationModules from 'shared/supabase/notifications' const CREATOR_ID = 'creator-1' +const SEND_ID = 42 const search = (id: number, creator_id = CREATOR_ID) => ({ id, @@ -40,6 +45,8 @@ describe('sendSearchNotifications', () => { mockPg = { manyOrNone: jest.fn(), none: jest.fn().mockResolvedValue(undefined), + // The only `one` in this path is the search_alert_sends insert. + one: jest.fn().mockResolvedValue({id: SEND_ID}), } ;(supabaseInit.createSupabaseDirectClient as jest.Mock).mockReturnValue(mockPg) @@ -54,6 +61,9 @@ describe('sendSearchNotifications', () => { (_pg: any, _schema: string, fn: any) => fn(mockPg), ) ;(helperModules.sendSearchAlertsEmail as jest.Mock).mockResolvedValue(null) + ;(mobileModules.sendWebNotifications as jest.Mock).mockResolvedValue(undefined) + ;(mobileModules.sendMobileNotifications as jest.Mock).mockResolvedValue(undefined) + ;(notificationModules.insertNotificationToSupabase as jest.Mock).mockResolvedValue(undefined) }) afterEach(() => { @@ -61,13 +71,22 @@ describe('sendSearchNotifications', () => { }) /** searches, then users, then private_users */ - const mockQueries = (searches: any[]) => { + const mockQueries = ( + searches: any[], + // An empty `notificationPreferences` is what a member who never touched their settings has, and + // it means every destination is on. + privateUserData: any = {email: 'creator@example.com', notificationPreferences: {}}, + ) => { mockPg.manyOrNone .mockResolvedValueOnce(searches) .mockResolvedValueOnce([{id: CREATOR_ID, name: 'Creator'}]) - .mockResolvedValueOnce([{id: CREATOR_ID, data: {email: 'creator@example.com'}}]) + .mockResolvedValueOnce([{id: CREATOR_ID, data: privateUserData}]) } + /** The payload handed to both push transports. */ + const pushPayload = () => + JSON.parse((mobileModules.sendWebNotifications as jest.Mock).mock.calls[0][2]) + it('notifies about a profile that did not match before it was modified', async () => { mockQueries([search(1)]) ;(profileModules.loadProfiles as jest.Mock) @@ -80,7 +99,7 @@ describe('sendSearchNotifications', () => { expect(helperModules.sendSearchAlertsEmail).toBeCalledTimes(1) expect(helperModules.sendSearchAlertsEmail).toBeCalledWith( {id: CREATOR_ID, name: 'Creator'}, - {email: 'creator@example.com'}, + {email: 'creator@example.com', notificationPreferences: {}}, [ { id: CREATOR_ID, @@ -197,4 +216,81 @@ describe('sendSearchNotifications', () => { expect(snapshotModules.promoteStagingSnapshot).toBeCalledTimes(1) }) + + it('records who the alert named, and pushes to the same person it emailed', async () => { + mockQueries([search(1)]) + ;(profileModules.loadProfiles as jest.Mock) + .mockResolvedValueOnce({profiles: [profile('woman-1')]}) + .mockResolvedValueOnce({profiles: []}) + + await searchNotificationModules.sendSearchNotifications() + + expect(mockPg.one).toBeCalledWith(expect.stringContaining('insert into search_alert_sends'), { + creatorId: CREATOR_ID, + searchIds: [1], + matchedUserIds: ['woman-1'], + }) + expect(mobileModules.sendWebNotifications).toBeCalledTimes(1) + expect(mobileModules.sendMobileNotifications).toBeCalledTimes(1) + expect(notificationModules.insertNotificationToSupabase).toBeCalledTimes(1) + }) + + it('sends a single match straight to their profile, not to the alert page', async () => { + mockQueries([search(1)]) + ;(profileModules.loadProfiles as jest.Mock) + .mockResolvedValueOnce({profiles: [profile('woman-1')]}) + .mockResolvedValueOnce({profiles: []}) + + await searchNotificationModules.sendSearchNotifications() + + expect(pushPayload().url).toEqual('/woman-1') + }) + + it('sends several matches to the alert page, which is the only place they exist as a set', async () => { + ;(snapshotModules.getChangedUserIds as jest.Mock).mockResolvedValue(['woman-1', 'woman-2']) + mockQueries([search(1)]) + ;(profileModules.loadProfiles as jest.Mock) + .mockResolvedValueOnce({profiles: [profile('woman-1'), profile('woman-2')]}) + .mockResolvedValueOnce({profiles: []}) + + await searchNotificationModules.sendSearchNotifications() + + expect(mockPg.one).toBeCalledWith(expect.anything(), { + creatorId: CREATOR_ID, + searchIds: [1], + matchedUserIds: ['woman-1', 'woman-2'], + }) + expect(pushPayload().url).toEqual(`/alerts/${SEND_ID}`) + }) + + it('does not push to someone who turned search alerts off, but still emails them', async () => { + // `email` is left on, so this isolates the push gate from the email one. + mockQueries([search(1)], { + email: 'creator@example.com', + notificationPreferences: {new_search_alerts: ['email']}, + }) + ;(profileModules.loadProfiles as jest.Mock) + .mockResolvedValueOnce({profiles: [profile('woman-1')]}) + .mockResolvedValueOnce({profiles: []}) + + await searchNotificationModules.sendSearchNotifications() + + expect(helperModules.sendSearchAlertsEmail).toBeCalledTimes(1) + expect(mobileModules.sendWebNotifications).not.toBeCalled() + expect(mobileModules.sendMobileNotifications).not.toBeCalled() + expect(notificationModules.insertNotificationToSupabase).not.toBeCalled() + }) + + it('does not fail the run when the push fails, so the email is never re-sent', async () => { + mockQueries([search(1)]) + ;(profileModules.loadProfiles as jest.Mock) + .mockResolvedValueOnce({profiles: [profile('woman-1')]}) + .mockResolvedValueOnce({profiles: []}) + ;(mobileModules.sendWebNotifications as jest.Mock).mockRejectedValue(new Error('gone')) + + const result = await searchNotificationModules.sendSearchNotifications() + + expect(result).toEqual({status: 'success', notified: 1, failed: 0}) + expect(snapshotModules.promoteStagingSnapshot).toBeCalledTimes(1) + }) }) diff --git a/backend/email/emails/empty-room.tsx b/backend/email/emails/empty-room.tsx new file mode 100644 index 00000000..9f8112d3 --- /dev/null +++ b/backend/email/emails/empty-room.tsx @@ -0,0 +1,121 @@ +import {Body, Container, Head, Html, Link, Preview, Section, Text} from '@react-email/components' +import {DEPLOYED_WEB_URL} from 'common/envs/constants' +import {formatDistance, kmToMiles} from 'common/measurement-utils' +import {OUTREACH_RADIUS_KM} from 'common/outreach/outreach' +import {type User} from 'common/user' +import {UNSUBSCRIBE_URL} from 'common/user-notification-preferences' +import {container, content, Footer, main, paragraph} from 'email/utils' +import React from 'react' +import {createT} from 'shared/locale' + +import {mockUser} from './functions/mock' + +/** + * Contact #E — the empty room. + * + * The largest group on the platform by a wide margin: far more people search, find nobody, and leave + * than ever reply to anything. Everything else Compass sends them is beside the point, because the + * point is that there is nobody there. + * + * It is also the only message that asks for something without having delivered something first. That + * is not an exception to the rule so much as the rule read properly — what it delivers is the honest + * account of the value *not* being there, which is the one thing nobody else will tell them. Hence no + * feature announcements, no encouragement, and no suggestion that trying harder would work. + */ +interface EmptyRoomEmailProps { + toUser: User + unsubscribeUrl: string + email?: string + locale?: string + /** Members within `radiusKm` of them. The number the message is built on. */ + nearbyCount: number + city: string + radiusKm?: number + /** True when they were picked up for having gone quiet rather than for the count alone. */ + wasInactive?: boolean +} + +export const EmptyRoomEmail = ({ + toUser, + unsubscribeUrl, + email, + locale, + nearbyCount, + city, + radiusKm = OUTREACH_RADIUS_KM, + wasInactive, +}: EmptyRoomEmailProps) => { + const name = toUser.name.split(' ')[0] + const t = createT(locale) + const radius = formatDistance(kmToMiles(radiusKm), locale === 'en' ? 'imperial' : 'metric') + + return ( + + + {t('email.empty_room.preview', 'The honest number for {city}', {city})} + + +
+ {t('email.empty_room.greeting', 'Hi {name},', {name})} + + {wasInactive && ( + + {t( + 'email.empty_room.away', + "You haven't been back in a couple of weeks, and I think I know why. Rather than guess, here is the number.", + )} + + )} + + + {t( + 'email.empty_room.number', + "There isn't really anyone here for you within {radius} of {city} yet — {count} people only.", + {radius, city, count: String(nearbyCount)}, + )} + + + + {t( + 'email.empty_room.no_feature', + "It's unlikely a new feature fixes that. Compass is a directory, and a directory of almost nobody is of almost no use, however good the search is. The only thing that changes it is people like you arriving.", + )} + + + + {t( + 'email.empty_room.ask', + "Which is why the one honest thing I can ask is whether there's someone you'd actually want in the room. Just one person. If nobody comes to mind, that's completely fine and I won't ask again!", + )} + + +
+ + {t('email.empty_room.link', 'compassmeet.com/referrals')} + +
+ + + Martin Braquet +
+ + {t('email.empty_room.signature_title', 'Founder, Compass')} + +
+
+