Add migrations, APIs, and frontend for outreach and search alerts: implement saved alert displays, outreach email logic, and referral tracking.

This commit is contained in:
MartinBraquet
2026-08-04 01:41:50 +02:00
parent 1a275b1a6c
commit afd5dd3600
38 changed files with 2210 additions and 45 deletions

View File

@@ -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=<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=<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: <API_KEY>" -H 'Content-Type: application/json' \
-d '{"dryRun":true}'
```
##### API Deploy CD
```shell

View File

@@ -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<k>} = {
'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<unknown>,
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',

View File

@@ -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(

View File

@@ -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)}
}

View File

@@ -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<number>(`SELECT count(*) FROM profiles`, [], (r) =>
Number(r.count),

View File

@@ -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}
}

View File

@@ -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,

View File

@@ -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,
}
}

View File

@@ -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
}

View File

@@ -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<ReturnType<typeof getPrivateUser>>,
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)
}

View File

@@ -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<FilterFields>, 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(

View File

@@ -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()
})
})
})

View File

@@ -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)
})
})

View File

@@ -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 (
<Html>
<Head />
<Preview>{t('email.empty_room.preview', 'The honest number for {city}', {city})}</Preview>
<Body style={main}>
<Container style={container}>
<Section style={content}>
<Text style={paragraph}>{t('email.empty_room.greeting', 'Hi {name},', {name})}</Text>
{wasInactive && (
<Text style={paragraph}>
{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.",
)}
</Text>
)}
<Text style={paragraph}>
{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)},
)}
</Text>
<Text style={paragraph}>
{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.",
)}
</Text>
<Text style={paragraph}>
{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!",
)}
</Text>
<Section style={{marginTop: '20px'}}>
<Link href={`${DEPLOYED_WEB_URL}/referrals`}>
{t('email.empty_room.link', 'compassmeet.com/referrals')}
</Link>
</Section>
<Text style={{...paragraph, marginTop: '28px'}}>
Martin Braquet
<br />
<span style={{fontSize: '12px', color: '#888'}}>
{t('email.empty_room.signature_title', 'Founder, Compass')}
</span>
</Text>
</Section>
<Footer unsubscribeUrl={unsubscribeUrl} email={email ?? name} locale={locale} />
</Container>
</Body>
</Html>
)
}
EmptyRoomEmail.PreviewProps = {
toUser: mockUser,
email: 'someone@gmail.com',
unsubscribeUrl: UNSUBSCRIBE_URL,
nearbyCount: 3,
city: 'Groningen',
} as EmptyRoomEmailProps
export default EmptyRoomEmail

View File

@@ -2,12 +2,14 @@ import {render} from '@react-email/render'
import {defaultLocale} from 'common/constants'
import {debug} from 'common/logger'
import {milesToKm} from 'common/measurement-utils'
import {LocalDensity, OUTREACH_RADIUS_KM} from 'common/outreach/outreach'
import {MatchesType} from 'common/profiles/bookmarked_searches'
import {PrivateUser, User} from 'common/user'
import {
getNotificationDestinationsForUser,
UNSUBSCRIBE_URL,
} from 'common/user-notification-preferences'
import EmptyRoomEmail from 'email/empty-room'
import NewSearchAlertsEmail from 'email/new-search-alerts'
import ShareCompassEmail, {hasNearbyCount, NEARBY_RADIUS_MILES} from 'email/share-compass'
import WelcomeEmail from 'email/welcome'
@@ -201,7 +203,16 @@ export const sendNewEndorsementEmail = async (
})
}
export const sendShareCompassEmail = async (toUser: User, privateUser: PrivateUser) => {
/**
* @param density Precomputed local numbers, quoted verbatim instead of the historical 200-mile count.
* The outreach job passes this so the figure a member is emailed is the same one the dashboard shows
* next to their name; called without it (the ad-hoc script path) nothing changes.
*/
export const sendShareCompassEmail = async (
toUser: User,
privateUser: PrivateUser,
density?: LocalDensity,
) => {
const notificationType = 'platform_updates'
const {sendToEmail, unsubscribeUrl} = getNotificationDestinationsForUser(
privateUser,
@@ -217,15 +228,17 @@ export const sendShareCompassEmail = async (toUser: User, privateUser: PrivateUs
const t = createT(locale)
console.log(`Sending email to ${privateUser.email} in ${locale ?? defaultLocale} (${toUser.id})`)
const profile = await getProfile(toUser.id)
const city = profile?.city ?? undefined
const nearbyCount = profile
? await getNearbyMemberCount(profile, milesToKm(NEARBY_RADIUS_MILES)).catch((e) => {
// A failed count must not block the send — fall back to the generic copy.
debug('Failed to count nearby members', toUser.id, e)
return undefined
})
: undefined
const profile = density ? undefined : await getProfile(toUser.id)
const city = density ? (density.city ?? undefined) : (profile?.city ?? undefined)
const nearbyCount = density
? density.count
: profile
? await getNearbyMemberCount(profile, milesToKm(NEARBY_RADIUS_MILES)).catch((e) => {
// A failed count must not block the send — fall back to the generic copy.
debug('Failed to count nearby members', toUser.id, e)
return undefined
})
: undefined
const personalised = hasNearbyCount(nearbyCount, city)
@@ -252,6 +265,61 @@ export const sendShareCompassEmail = async (toUser: User, privateUser: PrivateUs
locale={locale}
nearbyCount={nearbyCount}
city={city}
nearbyRadiusKm={density ? OUTREACH_RADIUS_KM : undefined}
nearbyProfiles={density?.nearby}
/>,
),
headers: {
'List-Unsubscribe': `<mailto:unsubscribe@compassmeet.com?subject=${token}>, <${unsubscribeUrlOneClick}>`,
'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click',
'List-ID': 'Compass <compassmeet.com>',
},
})
}
/**
* Contact #E. Sent once per member, ever — the caller claims the send through `outreach_sends` before
* calling this, so nothing here re-checks it.
*/
export const sendEmptyRoomEmail = async (
toUser: User,
privateUser: PrivateUser,
density: {count: number; city: string},
opts?: {wasInactive?: boolean},
) => {
const notificationType = 'platform_updates'
const {sendToEmail, unsubscribeUrl} = getNotificationDestinationsForUser(
privateUser,
notificationType,
)
const email = privateUser.email
if (!email || !sendToEmail) {
debug('No email or user turned off emails', toUser.username, toUser.id)
return
}
const locale = privateUser?.locale
const t = createT(locale)
const token = await createUnsubscribeToken(toUser.id, notificationType)
const unsubscribeUrlOneClick = getUnsubscribeUrlOneClick(token)
return await sendEmail({
// From Martin rather than from Compass: it is a message admitting the product does not work for
// them yet, and that is not a thing a platform says about itself.
from: 'Martin from Compass <martin@compassmeet.com>',
replyTo: 'martin@compassmeet.com',
subject: t('email.empty_room.subject', 'The honest number for {city}', {city: density.city}),
to: email,
html: await render(
<EmptyRoomEmail
toUser={toUser}
unsubscribeUrl={unsubscribeUrl}
email={email}
locale={locale}
nearbyCount={density.count}
city={density.city}
wasInactive={opts?.wasInactive}
/>,
),
headers: {

View File

@@ -73,6 +73,7 @@ const SAVED_SEARCH = {
*/
const MATCHES = [
{
id: 'showcase-julien',
name: 'Julien Sarr',
username: 'juliensarr',
avatarUrl: '/images/showcase/juliensarr-1.jpg',

View File

@@ -1,6 +1,6 @@
import {Body, Container, Head, Html, Link, Preview, Section, Text} from '@react-email/components'
import {DEPLOYED_WEB_URL} from 'common/envs/constants'
import {formatDistance} from 'common/measurement-utils'
import {DEPLOYED_WEB_URL, ENV_CONFIG} from 'common/envs/constants'
import {formatDistance, kmToMiles} from 'common/measurement-utils'
import {getXShareProfileUrl} from 'common/socials'
import {type User} from 'common/user'
import {UNSUBSCRIBE_URL} from 'common/user-notification-preferences'
@@ -27,10 +27,25 @@ interface ShareCompassEmailProps {
unsubscribeUrl: string
email?: string
locale?: string
/** Members within NEARBY_RADIUS_MILES of this user's city. Undefined when unknown. */
/** Members within `nearbyRadiusKm` of this user's city. Undefined when unknown. */
nearbyCount?: number
/** This user's city, e.g. "Brussels". Undefined when unknown. */
city?: string
/**
* The radius `nearbyCount` was measured at. Defaults to the historical 200 miles; the outreach job
* passes the much tighter `OUTREACH_RADIUS_KM` so the number here is the same one the dashboard and
* Contact #E quote, rather than a second, friendlier figure for the same member.
*/
nearbyRadiusKm?: number
/**
* A few members near them, rendered as plain profile links.
*
* This is the lowest-friction version of the ask and the one worth putting in a mass email: "I
* joined a platform to meet someone, you should too" is a confession, "these three people are
* interesting" is a recommendation, and only the second one gets pasted into a group chat. Profile
* links already carry `?referrer=`, so it is credited exactly like the signup link.
*/
nearbyProfiles?: {name: string; username: string}[]
}
export const ShareCompassEmail = ({
@@ -40,13 +55,21 @@ export const ShareCompassEmail = ({
locale,
nearbyCount,
city,
nearbyRadiusKm,
nearbyProfiles,
}: ShareCompassEmailProps) => {
const name = toUser.name.split(' ')[0]
const t = createT(locale)
const profileShareUrl = getXShareProfileUrl(t, toUser.username)
const personalised = hasNearbyCount(nearbyCount, city)
const radius = formatDistance(NEARBY_RADIUS_MILES, locale === 'en' ? 'imperial' : 'metric')
const radius = formatDistance(
nearbyRadiusKm === undefined ? NEARBY_RADIUS_MILES : kmToMiles(nearbyRadiusKm),
!locale || locale === 'en' ? 'imperial' : 'metric',
)
// Tagged so anyone who follows a link from this email is credited to the member who was sent it.
const profileUrl = (username: string) =>
`https://${ENV_CONFIG.domain}/${username}?referrer=${toUser.username}`
return (
<Html>
@@ -59,7 +82,7 @@ export const ShareCompassEmail = ({
})
: t(
'email.share.preview',
"600 people in 6 months — here's how you help write what's next",
"700 people in 6 months — here's how you help write what's next",
)}
</Preview>
<Body style={main}>
@@ -79,7 +102,7 @@ export const ShareCompassEmail = ({
<Text style={paragraph}>
{t(
'email.share.growth_nearby',
'Right now, {count} members are within {radius} of {city}. Not 600 scattered across the world — {count} people in reach of you, who chose depth over algorithms and values over vanity metrics.',
'Right now, {count} members are within {radius} of {city}. People in reach of you, who chose depth over algorithms and values over vanity metrics.',
{
count: String(nearbyCount),
radius,
@@ -88,18 +111,18 @@ export const ShareCompassEmail = ({
)}
</Text>
<Text style={paragraph}>
{t(
'email.share.growth_nearby_context',
"That's what 6 months and 600 members across the platform look like where you live. It's a real signal — and it's only the beginning.",
)}
</Text>
{/*<Text style={paragraph}>*/}
{/* {t(*/}
{/* 'email.share.growth_nearby_context',*/}
{/* "That's what 6 months and 700 members across the platform look like where you live. It's a real signal — and it's only the beginning.",*/}
{/* )}*/}
{/*</Text>*/}
</>
) : (
<Text style={paragraph}>
{t(
'email.share.growth',
"In just 6 months, over 600 people have found their way here. That's 600 people who chose depth over algorithms, values over vanity metrics. It's a real signal — and it's only the beginning.",
"In just 6 months, over 700 people have found their way here. That's 700 people who chose depth over algorithms, values over vanity metrics. It's a real signal — and it's only the beginning.",
)}
</Text>
)}
@@ -122,6 +145,24 @@ export const ShareCompassEmail = ({
)}
</Text>
{!!nearbyProfiles?.length && (
<Section style={{marginTop: '24px'}}>
<Text style={paragraph}>
{t(
'email.share.link_profiles',
"The easiest version, and the one I'd do myself: don't recommend Compass to anyone. Just link a few profiles you found interesting and let people read them. Here are three near you to start with —",
)}
</Text>
{nearbyProfiles.map((p) => (
<Text key={p.username} style={{...paragraph, margin: '4px 0'}}>
<Link href={profileUrl(p.username)}>
{p.name} compassmeet.com/{p.username}
</Link>
</Text>
))}
</Section>
)}
<Text style={{...paragraph, fontWeight: 'bold', fontSize: '16px'}}>
{t('email.share.cta_heading', 'How to share:')}
</Text>

View File

@@ -78,6 +78,7 @@ export async function sendMobileNotifications(
payload: PushPayload,
) {
const subscriptions = await getMobileSubscriptionsFromDB(pg, userId)
console.log('Sending mobile notifications to:', subscriptions.length, 'devices')
for (const subscription of subscriptions) {
await sendPushToToken(pg, userId, subscription.token, payload)
}

View File

@@ -0,0 +1,66 @@
import {LocalDensity, OUTREACH_RADIUS_KM} from 'common/outreach/outreach'
import {createSupabaseDirectClient, SupabaseDirectClient} from 'shared/supabase/init'
/** How many nearby members to name. Three is what the "link three profiles" ask asks for. */
const NEARBY_SAMPLE_SIZE = 3
type DensityRow = {
city: string | null
count: string
nearby: {name: string; username: string}[] | null
}
/**
* The honest local number for one member, plus a few of the people it is made of.
*
* This is the figure Contact #3a and Contact #E are both built on, and the reason it is one helper
* rather than a count in each caller: the number a member is quoted must be the same number the
* dashboard shows next to their name, or the founder ends up contradicting his own email.
*
* Returns null when the member has no city coordinates. That is not zero — zero is a claim about the
* world, and "we don't know where you are" is a claim about the profile — and quoting a nearby count
* of zero to someone who simply never set a city is the kind of wrong number that costs the candour
* the whole approach runs on.
*/
export const getLocalDensity = async (
userId: string,
opts?: {radiusKm?: number; pg?: SupabaseDirectClient},
): Promise<LocalDensity | null> => {
const pg = opts?.pg ?? createSupabaseDirectClient()
const radiusKm = opts?.radiusKm ?? OUTREACH_RADIUS_KM
const row = await pg.oneOrNone<DensityRow>(
`
with me as (select city, city_latitude as lat, city_longitude as lon
from profiles
where user_id = $(userId))
, near as (select u.name, u.username
from profiles p
join users u on u.id = p.user_id
cross join me
where p.user_id != $(userId)
and p.looking_for_matches
and not coalesce(u.is_banned_from_posting, false)
and not coalesce(p.disabled, false)
and p.city_latitude is not null
and p.city_longitude is not null
and calculate_earth_distance_km(me.lat, me.lon, p.city_latitude,
p.city_longitude) < $(radiusKm)
-- Nearest first, so the handful we name are the ones actually worth writing to.
order by calculate_earth_distance_km(me.lat, me.lon, p.city_latitude,
p.city_longitude))
select me.city,
(select count(*) from near) as count,
(select coalesce(jsonb_agg(to_jsonb(s)), '[]'::jsonb)
from (select * from near limit $(sampleSize)) s) as nearby
from me
where me.lat is not null
and me.lon is not null
`,
{userId, radiusKm, sampleSize: NEARBY_SAMPLE_SIZE},
)
if (!row) return null
return {count: Number(row.count), city: row.city, nearby: row.nearby ?? []}
}

View File

@@ -0,0 +1,115 @@
import {Notification} from 'common/notifications'
import {getNotificationDestinationsForUser} from 'common/user-notification-preferences'
import {sendMobileNotifications} from 'shared/mobile'
import {log} from 'shared/monitoring/log'
import {createSupabaseDirectClient, SupabaseDirectClient} from 'shared/supabase/init'
import {insertNotificationToSupabase} from 'shared/supabase/notifications'
import {getPrivateUser} from 'shared/utils'
export type ReferredMember = {
id: string
name: string
username: string
avatarUrl: string | null
joinedTime: string
}
/**
* Everyone who signed up carrying this member's username as their referrer.
*
* Disabled and banned members are still counted. Credit is for the introduction, which happened; what
* the person did afterwards is not something the referrer should be silently docked for.
*/
export const getReferredMembers = async (
username: string,
pg?: SupabaseDirectClient,
): Promise<ReferredMember[]> => {
pg = pg ?? createSupabaseDirectClient()
return await pg.manyOrNone<ReferredMember>(
`select u.id, u.name, u.username, u.avatar_url as "avatarUrl", u.created_time as "joinedTime"
from profiles p
join users u on u.id = p.user_id
where p.referred_by_username = $1
order by u.created_time desc`,
[username],
)
}
/**
* Tell a member that someone they brought has arrived.
*
* The gap this closes: `?referrer=` has always been recorded and never surfaced, so bringing someone
* produced no visible result of any kind. A sharer who is never told it worked has no reason to
* believe it did, and no reason to do it again — which is most of the difference between a one-time
* share and a repeat one.
*
* Best-effort by design: it is called from the signup continuation, and nothing about a new member's
* account creation should fail because a notification to a third party could not be written.
*/
export const notifyReferrerOfSignup = async (
newMember: {name: string; username: string; avatarUrl?: string},
referrerUsername: string,
pg?: SupabaseDirectClient,
) => {
pg = pg ?? createSupabaseDirectClient()
const referrer = await pg.oneOrNone<{id: string}>(`select id from users where username = $1`, [
referrerUsername,
])
if (!referrer) return
const privateUser = await getPrivateUser(referrer.id)
if (!privateUser) return
const {sendToBrowser} = getNotificationDestinationsForUser(privateUser, 'platform_updates')
if (!sendToBrowser) return
// Need to fix the platform_updates notif setting stuck at ["browser", "email"]
const sendToMobile = sendToBrowser
const body = `${newMember.name} joined Compass from your link.`
if (sendToBrowser) {
// How many they have brought in total, counted after this signup so it includes them. The bell
// shows it as "your 3rd introduction" — a single arrival is easy to shrug off, a tally is what
// makes it read as something they are good at.
const {count} = await pg.one<{count: string}>(
`select count(*) from profiles where referred_by_username = $1`,
[referrerUsername],
)
const notification: Notification = {
id: `referred-joined-${newMember.username}`,
userId: referrer.id,
reason: 'referred_member_joined',
createdTime: Date.now(),
isSeen: false,
sourceType: 'referral',
sourceUpdateType: 'created',
sourceUserName: newMember.name,
sourceUserUsername: newMember.username,
sourceUserAvatarUrl: newMember.avatarUrl,
sourceText: body,
isSeenOnHref: '/referrals',
data: {referredCount: Number(count)},
}
await insertNotificationToSupabase(notification, pg)
}
console.log({sendToMobile, referrer, privateUser})
// No collapse key: each arrival is a separate person, and merging them would turn the one moment
// worth celebrating into a running total. Failure is swallowed — the bell entry is the record, the
// push is only the nudge to go look at it.
if (sendToMobile) {
try {
await sendMobileNotifications(pg, referrer.id, {
title: 'Someone joined from your link',
body,
url: '/referrals',
})
} catch (error) {
log.error(`Failed to push referral signup to ${referrer.id}`, {error})
}
}
}

View File

@@ -0,0 +1,43 @@
import {createSupabaseDirectClient, SupabaseDirectClient} from 'shared/supabase/init'
/** Mirrors the CHECK constraint in `20260803_add_outreach_sends.sql`. */
export const OUTREACH_SEND_KINDS = ['city_number', 'empty_room'] as const
export type OutreachSendKind = (typeof OUTREACH_SEND_KINDS)[number]
/**
* Record that an automated message reached a member.
*
* Relies on the unique index rather than a prior read, so two concurrent runs of the same job cannot
* both decide a member has not been written to yet. Returns whether this call is the one that actually
* inserted — callers should send only when it did.
*/
export const recordOutreachSend = async (
userId: string,
kind: OutreachSendKind,
context: Record<string, unknown> = {},
pg?: SupabaseDirectClient,
): Promise<boolean> => {
pg = pg ?? createSupabaseDirectClient()
const row = await pg.oneOrNone<{id: string}>(
`insert into outreach_sends (user_id, kind, context)
values ($1, $2, $3)
on conflict do nothing
returning id`,
[userId, kind, context],
)
return row !== null
}
export const hasOutreachSend = async (
userId: string,
kind: OutreachSendKind,
pg?: SupabaseDirectClient,
): Promise<boolean> => {
pg = pg ?? createSupabaseDirectClient()
const row = await pg.oneOrNone<{exists: boolean}>(
`select true as exists from outreach_sends where user_id = $1 and kind = $2 limit 1`,
[userId, kind],
)
return row !== null
}

View File

@@ -64,4 +64,6 @@ BEGIN;
\i backend/supabase/migrations/20260731_lock_activity_stars_compat.sql
\i backend/supabase/migrations/20260801_add_outreach_contacts.sql
\i backend/supabase/migrations/20260803_add_testimonials.sql
\i backend/supabase/migrations/20260803_add_outreach_sends.sql
\i backend/supabase/migrations/20260804_add_search_alert_sends.sql
COMMIT;

View File

@@ -0,0 +1,53 @@
-- Migration: add_outreach_sends
-- Created: 2026-08-03
--
-- What automated outreach has already gone out, per member. `outreach_contacts` holds the state of a
-- hand-written founder thread; this holds the machine-sent messages, and the two are deliberately
-- separate tables because only one of them is a conversation.
--
-- It exists for three reasons, in order of importance:
--
-- 1. Contact #E ("the empty room") is specified as one message, ever. Without a record of the send
-- there is nothing to check before sending it again, and a second copy of "there is nobody here
-- for you" is worse than never having sent the first.
-- 2. The city-number email and Contact #E are mutually exclusive — one says how many people are
-- near you, the other says almost nobody is. A member must never receive both.
-- 3. Nothing currently records that an automated message was sent at all, so the conversion of the
-- automated city-number email against the hand-written ask cannot be measured. That comparison
-- is the number that decides how much founder time the sequence deserves.
CREATE TABLE IF NOT EXISTS outreach_sends
(
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
-- 'city_number' the personalised share email — how many members are near them
-- 'empty_room' Contact #E — the honest account of there being nobody near them
--
-- Saved-search alerts are deliberately not logged here: `bookmarked_searches.last_notified_at`
-- already records that one reached a member, and a second copy of the same fact is a second thing
-- that can disagree with the first.
kind TEXT NOT NULL CHECK (kind IN ('city_number', 'empty_room')),
sent_at TIMESTAMPTZ NOT NULL DEFAULT now(),
-- Whatever the message asserted: the count it quoted, the city it named. Kept so a number that
-- turned out to be wrong can be traced back to the message that quoted it.
context JSONB NOT NULL DEFAULT '{}'::jsonb
);
-- Send-once, enforced rather than remembered: both kinds are one-shot, and a duplicate of either is
-- worse than never having sent it.
CREATE UNIQUE INDEX IF NOT EXISTS outreach_sends_once_per_kind
ON outreach_sends (user_id, kind);
CREATE INDEX IF NOT EXISTS outreach_sends_user_kind_time
ON outreach_sends (user_id, kind, sent_at DESC);
ALTER TABLE outreach_sends
ENABLE ROW LEVEL SECURITY;
-- No policies: this is admin/server-side only, read through the service role. Members have no reason
-- to read the log of what was sent to them, and an anon-readable copy would expose the per-city member
-- counts the emails quote.

View File

@@ -0,0 +1,42 @@
-- Migration: add_search_alert_sends
-- Created: 2026-08-04
--
-- The people one delivered saved-search alert was about, kept so the notification has somewhere to
-- land.
--
-- The matched set cannot be recomputed at click time, which is the whole reason this table exists.
-- It is a diff of the profile snapshot against the previous one, so it contains members whose
-- profile was *edited* into matching as well as members who just joined. Re-running the saved search
-- when the notification is opened returns neither group distinguishably: the new member sits
-- wherever the sort puts them among every older result, and the edited one is indistinguishable from
-- someone who has matched for months.
--
-- One row is one delivery — one email, one push, one bell entry — so a member whose three saved
-- searches all matched in the same run gets a single row listing all three.
CREATE TABLE IF NOT EXISTS search_alert_sends
(
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
creator_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
-- Which of their saved searches matched. Not a foreign key: a member may delete a saved search
-- afterwards, and the alert they already received still happened.
search_ids BIGINT[] NOT NULL,
-- The members the alert named. Kept as ids rather than a snapshot of the profiles, so an opened
-- alert shows people as they are now — including someone who has since deleted their account,
-- who then simply drops out of the list rather than being shown from a stale copy.
matched_user_ids TEXT[] NOT NULL,
created_time TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS search_alert_sends_creator_time
ON search_alert_sends (creator_id, created_time DESC);
ALTER TABLE search_alert_sends
ENABLE ROW LEVEL SECURITY;
-- No policies: read through an owner-checked API endpoint only. Who someone is being shown is a
-- statement about who they are looking for, which is not public even though each matched profile is.

View File

@@ -114,6 +114,11 @@
"add_photos.add_description": "Beschreibung hinzufügen",
"add_photos.profile_picture_center_face_hint": "achten Sie darauf, dass Ihr Gesicht darauf zentriert ist, da dieses Bild auf Ihrer Profilkarte angezeigt wird",
"add_photos.profile_picture_hint": "Das hervorgehobene Bild ist Ihr Profilbild",
"alerts.all_gone": "Die Profile aus dieser Benachrichtigung sind nicht mehr verfügbar. Sie wurden seit dem Versand möglicherweise entfernt.",
"alerts.gone_count": "{count} weitere waren in dieser Benachrichtigung, sind aber nicht mehr verfügbar.",
"alerts.not_found_body": "Sie gehört möglicherweise zu einem anderen Konto oder wurde entfernt.",
"alerts.not_found_title": "Diese Benachrichtigung ist nicht verfügbar",
"alerts.title": "Neu für deine gespeicherte Suche",
"answers.add.error_create": "Fehler beim Erstellen der Kompatibilitätsfrage. Erneut versuchen?",
"answers.add.submit_own": "schlagen Sie Ihre eigene vor!",
"answers.answer.answer_skipped": "{n} übersprungene Fragen beantworten",
@@ -191,6 +196,7 @@
"common.no": "Nein",
"common.notified": "Benachrichtigen, wenn diese Suche jemanden findet",
"common.notified_any": "Über neue Profile benachrichtigt werden",
"common.notified_needs_filter": "Zuerst einen Filter setzen — eine leere Suche würde dich über jede Person benachrichtigen",
"common.or": "Oder",
"common.people": "Personen",
"common.per_month": "/ Monat",
@@ -706,6 +712,14 @@
"notifications.question.opt_out_all": "Von allen Benachrichtigungen abmelden? (Sie können dies später noch ändern)",
"notifications.question.platform_updates": "Plattform-Updates (Teilen, Wachstum, neue Funktionen usw.)?",
"notifications.question.tagged_user": "... Sie erwähnt?",
"notifications.referral.first": "Die erste Person, die über deinen Link dazugekommen ist.",
"notifications.referral.joined": "ist über deinen Link zu Compass gekommen",
"notifications.referral.total": "{count} Personen sind über deinen Link dazugekommen",
"notifications.search_alert.body_one": "Sie sind neu dabei oder haben ihr Profil passend zu deiner Suche geändert.",
"notifications.search_alert.item_many": "{count} Personen passen zu deiner gespeicherten Suche",
"notifications.search_alert.item_one": "passt zu deiner gespeicherten Suche",
"notifications.search_alert.title_many": "{count} Personen passen zu deiner gespeicherten Suche",
"notifications.search_alert.title_one": "{name} passt zu deiner gespeicherten Suche",
"notifications.section.other": "Weitere Updates",
"notifications.tabs.notifications": "Benachrichtigungen",
"notifications.tabs.settings": "Einstellungen",
@@ -1629,6 +1643,16 @@
"stats.with_bio": "Mit Bio",
"sticky_format_menu.add_embed": "Embed hinzufügen",
"sticky_format_menu.add_emoji": "Emoji hinzufügen",
"sticky_format_menu.apply_link": "Link anwenden",
"sticky_format_menu.bold": "Fett",
"sticky_format_menu.bullet_list": "Aufzählung",
"sticky_format_menu.cancel_link": "Abbrechen",
"sticky_format_menu.heading": "Überschrift",
"sticky_format_menu.italic": "Kursiv",
"sticky_format_menu.link": "Link",
"sticky_format_menu.link_placeholder": "Link eingeben oder einfügen",
"sticky_format_menu.ordered_list": "Nummerierte Liste",
"sticky_format_menu.quote": "Zitat",
"sticky_format_menu.upload_image": "Foto oder Video hochladen",
"terms.changes.text": "Wir können diese Bedingungen gelegentlich aktualisieren. Die weitere Nutzung von Compass nach Änderungen gilt als Zustimmung zu den neuen Bedingungen.",
"terms.changes.title": "6. Änderungen",

View File

@@ -114,6 +114,11 @@
"add_photos.add_description": "Ajouter une description",
"add_photos.profile_picture_center_face_hint": "veillez à centrer votre visage sur cette photo, car c'est elle qui apparaît sur votre carte de profil",
"add_photos.profile_picture_hint": "L'image mise en surbrillance est votre photo de profil",
"alerts.all_gone": "Les profils de cette alerte ne sont plus disponibles. Ils ont peut-être été supprimés depuis son envoi.",
"alerts.gone_count": "{count} autres figuraient dans cette alerte mais ne sont plus disponibles.",
"alerts.not_found_body": "Elle appartient peut-être à un autre compte, ou elle a été supprimée.",
"alerts.not_found_title": "Cette alerte n'est pas disponible",
"alerts.title": "Nouveau pour votre recherche enregistrée",
"answers.add.error_create": "Erreur lors de la création de la question de compatibilité. Réessayez ?",
"answers.add.submit_own": "proposez la vôtre !",
"answers.answer.answer_skipped": "Répondre à {n} questions ignorées",
@@ -191,6 +196,7 @@
"common.no": "Non",
"common.notified": "Être notifié quand cette recherche trouve quelqu'un",
"common.notified_any": "Recevoir notifs pour tout nouveau profil",
"common.notified_needs_filter": "Ajoutez d'abord un filtre — une recherche vide vous notifierait pour tout le monde",
"common.or": "Ou",
"common.people": "personnes",
"common.per_month": "/ mois",
@@ -705,6 +711,14 @@
"notifications.question.opt_out_all": "Se désabonner de toutes les notifications ? (Vous pouvez toujours modifier cela plus tard)",
"notifications.question.platform_updates": "Mises à jour de la plateforme (partage, croissance, nouvelles fonctionnalités, etc.) ?",
"notifications.question.tagged_user": "... vous mentionne ?",
"notifications.referral.first": "La première personne à rejoindre via votre lien.",
"notifications.referral.joined": "a rejoint Compass via votre lien",
"notifications.referral.total": "{count} personnes ont rejoint via votre lien",
"notifications.search_alert.body_one": "Cette personne vient de s'inscrire, ou a modifié son profil pour correspondre à votre recherche.",
"notifications.search_alert.item_many": "{count} personnes correspondent à votre recherche enregistrée",
"notifications.search_alert.item_one": "correspond à votre recherche enregistrée",
"notifications.search_alert.title_many": "{count} personnes correspondent à votre recherche enregistrée",
"notifications.search_alert.title_one": "{name} correspond à votre recherche enregistrée",
"notifications.section.other": "Autres mises à jour",
"notifications.tabs.notifications": "Notifications",
"notifications.tabs.settings": "Paramètres",
@@ -1628,6 +1642,16 @@
"stats.with_bio": "Complétés",
"sticky_format_menu.add_embed": "Ajouter un embed",
"sticky_format_menu.add_emoji": "Ajouter un emoji",
"sticky_format_menu.apply_link": "Appliquer le lien",
"sticky_format_menu.bold": "Gras",
"sticky_format_menu.bullet_list": "Liste à puces",
"sticky_format_menu.cancel_link": "Annuler",
"sticky_format_menu.heading": "Titre",
"sticky_format_menu.italic": "Italique",
"sticky_format_menu.link": "Lien",
"sticky_format_menu.link_placeholder": "Saisir ou coller un lien",
"sticky_format_menu.ordered_list": "Liste numérotée",
"sticky_format_menu.quote": "Citation",
"sticky_format_menu.upload_image": "Ajouter une photo ou vidéo",
"terms.changes.text": "Nous pouvons mettre à jour ces Conditions périodiquement. La poursuite de l'utilisation de Compass après les mises à jour vaut acceptation des nouvelles Conditions.",
"terms.changes.title": "6. Modifications",

View File

@@ -1397,6 +1397,24 @@ export const API = (_apiTypeCheck = {
summary: 'Get the member outreach queue. Admin only.',
tag: 'Admin',
},
'get-my-referrals': {
method: 'GET',
authed: true,
rateLimited: false,
props: z.object({}).strict(),
returns: {} as {
count: number
members: {
id: string
name: string
username: string
avatarUrl: string | null
joinedTime: string
}[]
},
summary: 'The members who joined from your referral link.',
tag: 'Users',
},
'update-outreach-contact': {
method: 'POST',
authed: true,
@@ -1411,6 +1429,32 @@ export const API = (_apiTypeCheck = {
summary: 'Set the outreach stage or next action for a member. Admin only.',
tag: 'Admin',
},
'get-search-alert': {
method: 'GET',
authed: true,
rateLimited: true,
props: z.object({id: z.coerce.number()}).strict(),
returns: {} as {
profiles: Profile[]
/** The saved searches that matched, for naming what this alert was. Deleted ones are dropped. */
searches: {id: number; name: string | null; filters: any; location: any}[]
createdTime: number
/** Members the alert named who are no longer visible — deleted, disabled or banned since. */
goneCount: number
},
summary: 'The people one saved-search alert was about. Owner only.',
tag: 'Search',
},
'create-outreach-search': {
method: 'POST',
authed: true,
rateLimited: false,
props: z.object({userId: z.string()}).strict(),
returns: {} as {searchId: number},
summary:
'Save a search on a members behalf, built from the preferences already on their profile. Admin only.',
tag: 'Admin',
},
'get-testimonials': {
method: 'GET',
authed: false,

View File

@@ -133,4 +133,46 @@ export const initialFilters: Partial<FilterFields> = {
export const FilterKeys = Object.keys(initialFilters) as (keyof FilterFields)[]
/**
* Filters that are set on every search and narrow nobody: sort order, and the flag that *widens* the
* results to incomplete profiles.
*/
const NON_NARROWING_FILTER_KEYS: string[] = ['orderBy', 'shortBio']
/**
* The language filter is pre-set to the signup locale rather than chosen, so for the English majority
* it is furniture, not a decision — and English is the language most of the directory speaks anyway,
* so it narrows almost nobody. Picking a *different* language is a real choice and still counts.
*/
const DEFAULT_LANGUAGE_FILTER = 'english'
/**
* Whether a search actually asks for someone in particular.
*
* A saved search with nothing set matches every new member, so it fires on every signup forever —
* which is not an alert, it is a subscription to the whole directory. Worse, it makes the signal
* useless: "their saved search matched" stops meaning anything once it matches everyone.
*
* Shared by the save button, the endpoint behind it, and the alert job, so all three agree on what
* counts as a search rather than each deciding separately.
*/
export const hasSearchCriteria = (
filters: Partial<FilterFields> | null | undefined,
location?: unknown,
): boolean => {
// A location filter is a constraint in its own right, and is stored outside search_filters.
if (location) return true
if (!filters || typeof filters !== 'object') return false
return Object.entries(filters).some(([key, value]) => {
if (NON_NARROWING_FILTER_KEYS.includes(key)) return false
if (value === undefined || value === null || value === '') return false
if (key === 'languages' && Array.isArray(value)) {
return value.some((language) => language !== DEFAULT_LANGUAGE_FILTER)
}
if (Array.isArray(value)) return value.length > 0
return true
})
}
export type OriginLocation = {id: string; name: string | null; lat: number; lon: number}

View File

@@ -40,6 +40,71 @@ export const DORMANT_AFTER_DAYS = 30
/** Tier A is worth founder time first, C is a profile too thin to act on yet. */
export type OutreachTier = 'A' | 'B' | 'C'
/**
* The radius every honest local number is quoted at.
*
*/
export const OUTREACH_RADIUS_KM = 322
/**
* Fewer than this many members within `OUTREACH_RADIUS_KM` and the room is empty enough to say so.
*
* Strictly below, so that this and the share email's `MIN_NEARBY_COUNT` partition the population
* exactly: nobody is eligible for both "there are N people near you" and "there is nobody near you".
*/
export const EMPTY_ROOM_MAX_NEARBY = 5
/** No sign of them for this long is the other way into Contact #E. */
export const EMPTY_ROOM_INACTIVE_DAYS = 14
/** Members within `OUTREACH_RADIUS_KM` of this member, and who they are. */
export type LocalDensity = {
count: number
city: string | null
/** A handful of the nearest, for the "link three profiles" version of the ask. */
nearby: {name: string; username: string}[]
}
/**
* The moments the doc says willingness peaks — each one a reason the ask is credible *now* rather
* than on whatever day the calendar reached.
*
* Deriving these is the whole point: a date is a proxy for "value has probably landed by now", and
* these are the events the proxy was standing in for. The profile-view trigger from the doc is absent
* because profile views are not recorded anywhere yet.
*/
export const OUTREACH_TRIGGERS = [
'search_alert_fired',
'first_reply_received',
'sent_first_message',
'empty_room',
] as const
export type OutreachTrigger = (typeof OUTREACH_TRIGGERS)[number]
export const OUTREACH_TRIGGER_LABELS: Record<OutreachTrigger, string> = {
search_alert_fired: 'Alert fired',
first_reply_received: 'Got a reply',
sent_first_message: 'Wrote to someone',
empty_room: 'Empty room',
}
/** The longer version, shown on hover — what happened, and what it licenses. */
export const OUTREACH_TRIGGER_DESCRIPTIONS: Record<OutreachTrigger, string> = {
search_alert_fired: 'A saved-search alert reached them — the product visibly worked. Ask now.',
first_reply_received:
'Another member wrote back to them. Highest-emotion moment on the platform. Ask now.',
sent_first_message: "They've written to someone, so they're committed. #2 or #3.",
empty_room: 'Under 5 members near them, or two weeks quiet. This is Contact #E, not the ask.',
}
/**
* `empty_room` is the one trigger that argues *against* the normal ask: there is nothing to be
* enthusiastic about, and #3's "bringing two people improves your odds" reads as a deflection when
* the honest number is two. It gets #E instead, which says the same thing without pretending.
*/
export const isAskReadyTrigger = (trigger: OutreachTrigger) => trigger !== 'empty_room'
/**
* The fields that decide whether a member is findable by someone searching. Deliberately not every
* column on `profiles` — a missing height says nothing, a missing bio says the free-text search has
@@ -100,6 +165,46 @@ export const getProfileCompleteness = (p: ProfileCompletenessInput): ProfileComp
return {score: filled / checks.length, filled, total: checks.length, missing}
}
/** The "who I'm looking for" fields of a member's own profile. */
export type LookingForPrefs = {
prefAgeMin: number | null
prefAgeMax: number | null
/** Genders they want to meet. */
prefGender: string[] | null
/** Connection goal — collaboration / friendship / relationship. */
prefRelationStyles: string[] | null
}
/** What a search created on a member's behalf is called in their saved-searches list. */
export const OUTREACH_SEARCH_NAME = 'Who Im looking for'
/**
* The saved search a member would have written for themselves, from the preferences they already
* stated on their profile.
*
* The three filter keys are not the three profile columns, and the mismatch is deliberate — the
* search query reads them from opposite sides:
*
* - `genders` matches the candidate's *own* gender, so their `pref_gender` goes here.
* - `pref_age_min/max` bound the candidate's *age* (see `numericRangeClause('age', ...)` in
* `get-profiles`), so their age range maps across unchanged despite the shared name.
* - `pref_relation_styles` is the one true overlap check: candidates wanting the same kind of
* connection, plus everyone who left the field blank.
*
* Returns null when they stated nothing at all — a search with no filters is the whole directory,
* which is not an alert anyone wants.
*/
export const getLookingForSearchFilters = (p: LookingForPrefs) => {
const filters: Record<string, unknown> = {}
if (p.prefGender?.length) filters.genders = p.prefGender
if (p.prefRelationStyles?.length) filters.pref_relation_styles = p.prefRelationStyles
if (p.prefAgeMin !== null) filters.pref_age_min = p.prefAgeMin
if (p.prefAgeMax !== null) filters.pref_age_max = p.prefAgeMax
return Object.keys(filters).length ? filters : null
}
export type OutreachTierInput = {
completeness: number
daysSinceLastOnline: number | null
@@ -143,4 +248,8 @@ export type OutreachRow = {
savedSearchCount: number
/** How many members joined with this member's username as their referrer. */
referredCount: number
/** Null when they have no city set, so no honest local number can be quoted at them. */
localDensity: LocalDensity | null
/** Which of the peak-willingness events have fired for them. */
triggers: OutreachTrigger[]
}

View File

@@ -4,6 +4,8 @@ export interface MatchPrivateUser {
}
export interface MatchUser {
/** Always present — this is the `user` object `get-profiles` builds. Needed to record who an alert named. */
id: string
name: string
username: string
avatarUrl?: string | null

View File

@@ -0,0 +1,41 @@
import {hasSearchCriteria} from 'common/filters'
describe('hasSearchCriteria', () => {
it('rejects a search with nothing set', () => {
expect(hasSearchCriteria({})).toBe(false)
expect(hasSearchCriteria(null)).toBe(false)
expect(hasSearchCriteria(undefined)).toBe(false)
})
it('rejects the fields every search carries anyway', () => {
expect(hasSearchCriteria({orderBy: 'created_time', shortBio: true})).toBe(false)
})
it('rejects the default language filter, which is pre-set rather than chosen', () => {
expect(hasSearchCriteria({languages: ['english']})).toBe(false)
expect(
hasSearchCriteria({languages: ['english'], orderBy: 'created_time', shortBio: true}),
).toBe(false)
expect(hasSearchCriteria({languages: []})).toBe(false)
})
it('accepts a language the member actually picked', () => {
expect(hasSearchCriteria({languages: ['german']})).toBe(true)
expect(hasSearchCriteria({languages: ['english', 'german']})).toBe(true)
})
it('accepts any other filter, including one alongside the ignored fields', () => {
expect(hasSearchCriteria({genders: ['woman']})).toBe(true)
expect(hasSearchCriteria({pref_age_min: 30})).toBe(true)
expect(hasSearchCriteria({name: 'ana'})).toBe(true)
expect(hasSearchCriteria({languages: ['english'], diet: ['vegan']})).toBe(true)
})
it('treats a location filter as a criterion of its own', () => {
expect(hasSearchCriteria({}, {location: {name: 'Porto'}})).toBe(true)
})
it('ignores empty values', () => {
expect(hasSearchCriteria({genders: [], name: '', diet: undefined})).toBe(false)
})
})

View File

@@ -1,4 +1,5 @@
import {
getLookingForSearchFilters,
getOutreachTier,
getProfileCompleteness,
ProfileCompletenessInput,
@@ -110,3 +111,44 @@ describe('getOutreachTier', () => {
expect(getOutreachTier({...stale, savedSearchCount: 2})).toBe('B')
})
})
describe('getLookingForSearchFilters', () => {
it('maps stated preferences onto the filter keys the search actually reads', () => {
expect(
getLookingForSearchFilters({
prefAgeMin: 28,
prefAgeMax: 40,
prefGender: ['female'],
prefRelationStyles: ['friendship'],
}),
).toEqual({
// Their preferred gender becomes the candidate's own gender, not the candidate's preference.
genders: ['female'],
pref_relation_styles: ['friendship'],
pref_age_min: 28,
pref_age_max: 40,
})
})
it('keeps a partial preference rather than dropping the whole search', () => {
expect(
getLookingForSearchFilters({
prefAgeMin: null,
prefAgeMax: null,
prefGender: [],
prefRelationStyles: ['relationship'],
}),
).toEqual({pref_relation_styles: ['relationship']})
})
it('returns null when they said nothing, so no one gets an alert for everybody', () => {
expect(
getLookingForSearchFilters({
prefAgeMin: null,
prefAgeMax: null,
prefGender: null,
prefRelationStyles: null,
}),
).toBeNull()
})
})

View File

@@ -35,8 +35,12 @@ export function NotificationItem(props: {notification: Notification}) {
return <ProfileLikeNotification {...params} />
} else if (reason === 'new_profile_ship') {
return <ProfileShipNotification {...params} />
} else if (reason === 'new_search_alerts') {
return <SearchAlertNotification {...params} />
} else if (reason === 'connection_interest_match') {
return <ConnectionInterestMatchNotification {...params} />
} else if (reason === 'referred_member_joined') {
return <ReferralJoinedNotification {...params} />
} else {
return <BaseNotification {...params} />
}
@@ -258,6 +262,97 @@ export function ConnectionInterestMatchNotification(props: {
)
}
/**
* A saved-search alert. `sourceSlug` is the profile itself when the alert named one person, and the
* alert's own page when it named several — the same destination the push notification opens.
*/
export function SearchAlertNotification(props: {
notification: Notification
highlighted: boolean
setHighlighted: (highlighted: boolean) => void
isChildOfGroup?: boolean
}) {
const {notification, highlighted, setHighlighted, isChildOfGroup} = props
const {sourceUserName, sourceUserUsername, sourceText} = notification
const t = useT()
const count = Number(notification.data?.count ?? 1)
return (
<NotificationFrame
notification={notification}
isChildOfGroup={isChildOfGroup}
highlighted={highlighted}
setHighlighted={setHighlighted}
icon={<AvatarNotificationIcon notification={notification} symbol={'🔔'} />}
link={notification.sourceSlug}
subtitle={
<div className="line-clamp-2">
<Linkify text={sourceText} />
</div>
}
>
{count > 1 ? (
<span>
{t('notifications.search_alert.item_many', '{count} people match your saved search', {
count,
})}
</span>
) : (
<>
<NotificationUserLink name={sourceUserName} username={sourceUserUsername} />{' '}
<span>{t('notifications.search_alert.item_one', 'matches your saved search')}</span>
</>
)}
</NotificationFrame>
)
}
/**
* Someone signed up from this member's link.
*
* The row leads with their name as a link rather than a flat sentence, because the useful next move is
* to go and look at who arrived. The row itself opens `/referrals` — the page that holds the running
* credit — and the subtitle names the tally, which is the part that makes a second share feel worth it.
*/
export function ReferralJoinedNotification(props: {
notification: Notification
highlighted: boolean
setHighlighted: (highlighted: boolean) => void
isChildOfGroup?: boolean
}) {
const {notification, highlighted, setHighlighted, isChildOfGroup} = props
const {sourceUserName, sourceUserUsername} = notification
const t = useT()
const count = Number(notification.data?.referredCount ?? 0)
return (
<NotificationFrame
notification={notification}
isChildOfGroup={isChildOfGroup}
highlighted={highlighted}
setHighlighted={setHighlighted}
icon={<AvatarNotificationIcon notification={notification} symbol={'🎉'} />}
link={'/referrals'}
subtitle={
count > 1 ? (
<span>
{t('notifications.referral.total', '{count} people have joined from your link', {
count,
})}
</span>
) : (
<span>
{t('notifications.referral.first', 'The first person to join from your link.')}
</span>
)
}
>
<NotificationUserLink name={sourceUserName} username={sourceUserUsername} />{' '}
<span>{t('notifications.referral.joined', 'joined Compass from your link')}</span>
</NotificationFrame>
)
}
const getSourceUrl = (notification: Notification) => {
const {sourceSlug, sourceId} = notification
if (sourceSlug) {

View File

@@ -467,7 +467,9 @@ function ProfileDetailRail(props: {profile: Profile; className?: string}) {
)
}
function ProfilePreview(props: {
// Exported so a page that already knows exactly which profiles to show (the search-alert page) can
// render the same card without the grid's loading, filtering and saved-search machinery.
export function ProfilePreview(props: {
profile: Profile
compatibilityScore: CompatibilityScore | undefined
hasStar: boolean

View File

@@ -1,5 +1,5 @@
import clsx from 'clsx'
import {FilterFields} from 'common/filters'
import {FilterFields, hasSearchCriteria} from 'common/filters'
import {Bell} from 'lucide-react'
import {useEffect, useState} from 'react'
import toast from 'react-hot-toast'
@@ -43,6 +43,9 @@ export function GetNotifiedButton(props: {
const user = useUser()
const t = useT()
const isClearedFilters = useIsClearedFilters(filters)
// An unfiltered search matches everyone who ever signs up, so it would fire on every signup and
// stop meaning anything. Same rule the endpoint enforces — see `hasSearchCriteria`.
const canSave = hasSearchCriteria(filters, locationFilterProps?.location)
const [bookmarked, setBookmarked] = useState(false)
const [loading, setLoading] = useState(false)
@@ -54,13 +57,19 @@ export function GetNotifiedButton(props: {
const label = bookmarked
? t('common.saved', 'Saved!')
: isClearedFilters
? t('common.notified_any', 'Get notified for any new profile')
: t('common.notified', 'Notify me when this search matches someone')
: !canSave
? t(
'common.notified_needs_filter',
'Add a filter first — an empty search would notify you about everyone',
)
: isClearedFilters
? t('common.notified_any', 'Get notified for any new profile')
: t('common.notified', 'Notify me when this search matches someone')
const buttonText = bookmarked ? label : t('common.get_notified', 'Get notified')
const handleClick = () => {
if (!canSave) return
if (bookmarkedSearches.length >= MAX_BOOKMARKED_SEARCHES) {
toast.error(
`You can bookmark maximum ${MAX_BOOKMARKED_SEARCHES} searches; please delete one first.`,
@@ -79,7 +88,7 @@ export function GetNotifiedButton(props: {
const button = (
<Button
disabled={loading}
disabled={loading || !canSave}
loading={loading}
onClick={handleClick}
size={size}
@@ -92,5 +101,7 @@ export function GetNotifiedButton(props: {
</Button>
)
return iconOnly ? <Tooltip text={label}>{button}</Tooltip> : button
// The full-width variant normally says what it does on the face of it, but a disabled button with no
// explanation is just a dead control — so the reason gets a tooltip there too.
return iconOnly || !canSave ? <Tooltip text={label}>{button}</Tooltip> : button
}

View File

@@ -1,16 +1,22 @@
import {BellIcon} from '@heroicons/react/24/outline'
import clsx from 'clsx'
import {IS_LOCAL} from 'common/hosting/constants'
import {
isAskReadyTrigger,
MAX_NEXT_ACTION_LENGTH,
OUTREACH_STAGE_LABELS,
OUTREACH_STAGES,
OUTREACH_TRIGGER_DESCRIPTIONS,
OUTREACH_TRIGGER_LABELS,
OutreachRow,
OutreachStage,
OutreachStatus,
OutreachTrigger,
} from 'common/outreach/outreach'
import {groupBy, orderBy} from 'lodash'
import Link from 'next/link'
import {useState} from 'react'
import toast from 'react-hot-toast'
import {Col} from 'web/components/layout/col'
import {Row} from 'web/components/layout/row'
import {NoSEO} from 'web/components/NoSEO'
@@ -40,13 +46,33 @@ const TIER_CLASS: Record<string, string> = {
C: 'bg-canvas-50 text-ink-400',
}
// The ask-ready triggers read as an opportunity; `empty_room` is a warning that the normal ask would
// land badly, so it must not look like the others.
const TRIGGER_CLASS: Record<OutreachTrigger, string> = {
search_alert_fired: 'bg-primary-100 text-primary-700',
first_reply_received: 'bg-primary-100 text-primary-700',
sent_first_message: 'bg-primary-100 text-primary-700',
empty_room: 'bg-canvas-100 text-ink-500',
}
/**
* Someone an ask would land well on right now: a saved-search alert reached them, another member
* wrote back, or they have written to someone. `empty_room` deliberately does not count — it is the
* signal that the ordinary ask is the wrong message, not the right one.
*/
const isAskReady = (row: OutreachRow) => row.triggers.some(isAskReadyTrigger)
const sortGroup = (status: OutreachStatus, rows: OutreachRow[]) => {
// Ask-ready first in every group. The whole reason for deriving triggers is that they decide who to
// write to today, which is worth more than any of the tie-breaks below.
const byTrigger = (r: OutreachRow) => (isAskReady(r) ? 0 : 1)
if (status === 'not_contacted') {
// Best profile first, and among equals the one who joined longest ago — they have been waiting.
return orderBy(rows, ['tier', 'daysSinceSignup'], ['asc', 'desc'])
return orderBy(rows, [byTrigger, 'tier', 'daysSinceSignup'], ['asc', 'asc', 'desc'])
}
// Longest silence first everywhere else.
return orderBy(rows, [(r) => r.daysSinceLastMessage ?? 0], ['desc'])
return orderBy(rows, [byTrigger, (r) => r.daysSinceLastMessage ?? 0], ['asc', 'desc'])
}
export default function Outreach() {
@@ -56,9 +82,9 @@ export default function Outreach() {
const {data, refresh} = useAPIGetter('get-outreach-queue', {newMemberLimit})
// Written-through edits, so a stage change shows immediately instead of after a refetch.
const [edits, setEdits] = useState<Record<string, {stage?: OutreachStage; nextAction?: string}>>(
{},
)
const [edits, setEdits] = useState<
Record<string, {stage?: OutreachStage; nextAction?: string; savedSearchCount?: number}>
>({})
const isAdmin = useAdmin()
if (!(isAdmin || IS_LOCAL)) return <p>Not authorized</p>
@@ -74,6 +100,23 @@ export default function Outreach() {
await api('update-outreach-contact', {userId, ...update})
}
// Nothing is optimistic here: the search is built from their profile on the server, and the whole
// question the button answers is whether their profile said enough to build one.
const createSearch = async (userId: string) => {
try {
await toast.promise(api('create-outreach-search', {userId}), {
loading: 'Saving a search for them…',
success: 'Search saved — they will get alerts from now on',
error: (e: Error) => e.message ?? 'Could not save a search for them',
})
} catch {
// toast.promise has already said what went wrong; the row just goes back to its button.
return
}
setEdits((prev) => ({...prev, [userId]: {...prev[userId], savedSearchCount: 1}}))
await refresh()
}
return (
<PageBase className="col-span-10 p-2 sm:pt-0">
<Col className={'text-ink-900 mx-4 my-4 gap-6'}>
@@ -85,6 +128,10 @@ export default function Outreach() {
{rows.length} members · every open conversation, plus the {newMemberLimit} newest
members nobody has written to yet
</div>
{/* The one number worth reading first: how many people an ask would land well on today. */}
<div className={'text-primary-700 text-sm'}>
{rows.filter(isAskReady).length} ready to ask
</div>
<Select
className={'!h-8 !py-0 !pl-2 !pr-8 !text-xs'}
value={newMemberLimitQ ?? '20'}
@@ -124,13 +171,20 @@ export default function Outreach() {
<th className={'py-1 pr-3 font-normal'}>Seen</th>
<th className={'py-1 pr-3 font-normal'}>Saved</th>
<th className={'py-1 pr-3 font-normal'}>Brought</th>
<th className={'py-1 pr-3 font-normal'}>Near</th>
<th className={'py-1 pr-3 font-normal'}>Ready</th>
<th className={'py-1 pr-3 font-normal'}>Stage</th>
<th className={'py-1 pr-3 font-normal'}>Next action</th>
</tr>
</thead>
<tbody>
{sortGroup(status, group).map((row) => (
<OutreachTableRow key={row.user.id} row={row} onSave={save} />
<OutreachTableRow
key={row.user.id}
row={row}
onSave={save}
onCreateSearch={createSearch}
/>
))}
</tbody>
</table>
@@ -149,9 +203,20 @@ function OutreachTableRow(props: {
userId: string,
update: {stage?: OutreachStage; nextAction?: string | null},
) => Promise<void>
onCreateSearch: (userId: string) => Promise<void>
}) {
const {row, onSave} = props
const {row, onSave, onCreateSearch} = props
const [nextAction, setNextAction] = useState(row.nextAction ?? '')
const [creatingSearch, setCreatingSearch] = useState(false)
const createSearch = async () => {
setCreatingSearch(true)
try {
await onCreateSearch(row.user.id)
} finally {
setCreatingSearch(false)
}
}
const days = (n: number | null) => (n === null ? '—' : `${n}d`)
@@ -188,11 +253,57 @@ function OutreachTableRow(props: {
<td className={'text-ink-500 py-2 pr-3'}>{days(row.daysSinceSignup)}</td>
<td className={'py-2 pr-3'}>{days(row.daysSinceLastMessage)}</td>
<td className={'text-ink-500 py-2 pr-3'}>{days(row.daysSinceLastOnline)}</td>
<td className={'text-ink-500 py-2 pr-3'}>{row.savedSearchCount || '—'}</td>
{/* No saved search means no alert can ever fire for them, which is the one trigger that makes
the product visibly work. The bell creates the search they described on their own profile. */}
<td className={'text-ink-500 py-2 pr-3'}>
{row.savedSearchCount ? (
row.savedSearchCount
) : (
<button
className={
'text-ink-400 hover:text-primary-600 disabled:text-ink-300 disabled:hover:text-ink-300'
}
disabled={creatingSearch}
title={'Save the search they described in “who Im looking for”'}
aria-label={`Create a search alert for ${row.user.name}`}
onClick={createSearch}
>
<BellIcon className={'h-4 w-4'} />
</button>
)}
</td>
<td className={clsx('py-2 pr-3', row.referredCount > 0 && 'text-primary-700')}>
{row.referredCount || '—'}
</td>
{/* The number Contact #3a would quote them, so it never has to be looked up separately — and so
it is obvious before writing whether the honest number supports the ask or undercuts it. */}
<td
className={'text-ink-500 py-2 pr-3 whitespace-nowrap'}
title={row.localDensity?.city ?? 'no city set'}
>
{row.localDensity === null ? '—' : `${row.localDensity.count} within 50km`}
</td>
{/* Kept on one line: the table already scrolls sideways, so wrapping badges only buys height —
and on a phone a stack of four turns one member into a screenful. */}
<td className={'py-2 pr-3'}>
<Row className={'flex-nowrap gap-1'}>
{row.triggers.map((trigger) => (
<span
key={trigger}
className={clsx(
'rounded px-1.5 py-0.5 text-xs whitespace-nowrap',
TRIGGER_CLASS[trigger],
)}
title={OUTREACH_TRIGGER_DESCRIPTIONS[trigger]}
>
{OUTREACH_TRIGGER_LABELS[trigger]}
</span>
))}
</Row>
</td>
<td className={'py-2 pr-3'}>
<Select
className={'!h-8 !py-0 !pl-2 !pr-8 !text-xs'}

125
web/pages/alerts/[id].tsx Normal file
View File

@@ -0,0 +1,125 @@
import {FilterFields} from 'common/filters'
import {formatFilters, locationType} from 'common/filters-format'
import {useRouter} from 'next/router'
import {Col} from 'web/components/layout/col'
import {Row} from 'web/components/layout/row'
import {NoSEO} from 'web/components/NoSEO'
import {PageBase} from 'web/components/page-base'
import {ProfilePreview} from 'web/components/profile-grid'
import {Title} from 'web/components/widgets/title'
import {useAPIGetter} from 'web/hooks/use-api-getter'
import {useChoicesContext} from 'web/hooks/use-choices'
import {useGetter} from 'web/hooks/use-getter'
import {useMeasurementSystem} from 'web/hooks/use-measurement-system'
import {useUser} from 'web/hooks/use-user'
import {useT} from 'web/lib/locale'
import {getStars} from 'web/lib/supabase/stars'
/**
* The people one saved-search alert was about.
*
* Not a search results page: it shows the exact set the alert named, recorded when it was sent.
* Re-running the search here would put a newly-matching member somewhere inside every older result
* and would silently lose anyone who was *edited* into matching, which is half of what an alert is.
*
* A one-person alert never reaches this page — the notification links straight to the profile.
*/
export default function SearchAlertPage() {
const router = useRouter()
const t = useT()
const user = useUser()
const choicesIdsToLabels = useChoicesContext()
const {measurementSystem} = useMeasurementSystem()
const id = parseInt(String(router.query.id ?? ''))
const {data, error} = useAPIGetter('get-search-alert', isNaN(id) ? undefined : {id})
const {data: starredUsers, refresh: refreshStars} = useGetter('star', user?.id, getStars)
const starredUserIds = starredUsers?.map((u: {id: string}) => u.id)
if (error) {
return (
<PageBase trackPageView={'search alert'}>
<NoSEO />
<Col className={'mx-4 my-6 gap-2'}>
<Title>{t('alerts.not_found_title', 'This alert is not available')}</Title>
<p className={'text-ink-600'}>
{t(
'alerts.not_found_body',
'It may belong to another account, or it may have been removed.',
)}
</p>
</Col>
</PageBase>
)
}
const profiles = data?.profiles ?? []
// What was searched, in the same words the saved-searches list uses.
const descriptions = (data?.searches ?? []).map((search) =>
[
search.name,
formatFilters(
search.filters as Partial<FilterFields>,
(search.location ?? null) as locationType,
choicesIdsToLabels,
measurementSystem,
t,
)?.join(' • '),
]
.filter(Boolean)
.join(' — '),
)
return (
<PageBase trackPageView={'search alert'}>
<NoSEO />
<Col className={'mx-4 my-6 gap-4'}>
<Col className={'gap-1'}>
<Title>{t('alerts.title', 'New for your saved search', {count: profiles.length})}</Title>
{descriptions.map((description, i) => (
<div key={i} className={'text-ink-500 text-sm'}>
{description}
</div>
))}
</Col>
{/* Nothing left to show is a real outcome, not an error: profiles can be deleted or hidden
between the alert going out and it being opened. */}
{data && !profiles.length && (
<p className={'text-ink-600'}>
{t(
'alerts.all_gone',
'The profiles in this alert are no longer available. They may have been removed since it was sent.',
)}
</p>
)}
<Row className={'flex-wrap items-start gap-4'}>
{profiles.map((profile) => (
<div key={profile.id} className={'w-full sm:w-80'}>
<ProfilePreview
profile={profile}
compatibilityScore={undefined}
hasStar={starredUserIds?.includes(profile.user_id) ?? false}
refreshStars={refreshStars}
/>
</div>
))}
</Row>
{!!data?.goneCount && (
<div className={'text-ink-500 text-sm'}>
{t(
'alerts.gone_count',
'{count} more were in this alert but are no longer available.',
{count: data.goneCount},
)}
</div>
)}
</Col>
</PageBase>
)
}

View File

@@ -1,12 +1,15 @@
import {ENV_CONFIG} from 'common/envs/constants'
import {useEffect, useState} from 'react'
import {Col} from 'web/components/layout/col'
import {Row} from 'web/components/layout/row'
import {PageBase} from 'web/components/page-base'
import {SEO} from 'web/components/SEO'
import {Input} from 'web/components/widgets/input'
import {QRCode} from 'web/components/widgets/qr-code'
import {ShareCTAButton} from 'web/components/widgets/share-cta-button'
import {Title} from 'web/components/widgets/title'
import {UserAvatarAndBadge} from 'web/components/widgets/user-link'
import {useAPIGetter} from 'web/hooks/use-api-getter'
import {useUser} from 'web/hooks/use-user'
import {useT} from 'web/lib/locale'
@@ -57,6 +60,55 @@ export default function ReferralsPage() {
<QRCode url={url} className="mt-4 self-center" />
</Col>
{user && <ReferralCredit />}
</PageBase>
)
}
/**
* What this member has actually brought.
*
* `?referrer=` has been recorded since the beginning and never shown to anyone, which made sharing a
* thing you did once into the void. A visible count is most of the difference between a one-time
* sharer and a repeat one — and when the count is zero the honest framing of that is an invitation,
* not a scolding.
*/
function ReferralCredit() {
const t = useT()
const {data} = useAPIGetter('get-my-referrals', {})
if (!data) return null
return (
<Col className="bg-canvas-50 mt-4 gap-3 rounded-lg p-4 sm:p-8">
<div className="text-ink-900 text-lg">
{data.count === 0
? t('referrals.none_yet', "You haven't brought anyone yet")
: t('referrals.count', 'You have brought {count} people to Compass', {
count: String(data.count),
})}
</div>
{data.count === 0 ? (
<div className="text-ink-600 text-sm">
{t(
'referrals.none_yet_note',
"Even one person changes your own odds more than anything we could build — they bring their circles with them, and that's where the people you'd never otherwise meet are.",
)}
</div>
) : (
<Col className="gap-2">
{data.members.map((m) => (
<Row key={m.username} className="items-center gap-2">
<UserAvatarAndBadge user={{...m, avatarUrl: m.avatarUrl ?? undefined}} />
<span className="text-ink-400 text-xs">
{new Date(m.joinedTime).toLocaleDateString()}
</span>
</Row>
))}
</Col>
)}
</Col>
)
}