mirror of
https://github.com/CompassConnections/Compass.git
synced 2026-08-04 12:15:12 -04:00
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:
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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(
|
||||
|
||||
62
backend/api/src/create-outreach-search.ts
Normal file
62
backend/api/src/create-outreach-search.ts
Normal 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)}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
22
backend/api/src/get-my-referrals.ts
Normal file
22
backend/api/src/get-my-referrals.ts
Normal 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}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
60
backend/api/src/get-search-alert.ts
Normal file
60
backend/api/src/get-search-alert.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
126
backend/api/src/send-city-number-emails.ts
Normal file
126
backend/api/src/send-city-number-emails.ts
Normal 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
|
||||
}
|
||||
165
backend/api/src/send-empty-room-emails.ts
Normal file
165
backend/api/src/send-empty-room-emails.ts
Normal 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)
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
121
backend/email/emails/empty-room.tsx
Normal file
121
backend/email/emails/empty-room.tsx
Normal 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
|
||||
@@ -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: {
|
||||
|
||||
@@ -73,6 +73,7 @@ const SAVED_SEARCH = {
|
||||
*/
|
||||
const MATCHES = [
|
||||
{
|
||||
id: 'showcase-julien',
|
||||
name: 'Julien Sarr',
|
||||
username: 'juliensarr',
|
||||
avatarUrl: '/images/showcase/juliensarr-1.jpg',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
66
backend/shared/src/outreach/local-density.ts
Normal file
66
backend/shared/src/outreach/local-density.ts
Normal 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 ?? []}
|
||||
}
|
||||
115
backend/shared/src/outreach/referrals.ts
Normal file
115
backend/shared/src/outreach/referrals.ts
Normal 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})
|
||||
}
|
||||
}
|
||||
}
|
||||
43
backend/shared/src/outreach/sends.ts
Normal file
43
backend/shared/src/outreach/sends.ts
Normal 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
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
53
backend/supabase/migrations/20260803_add_outreach_sends.sql
Normal file
53
backend/supabase/migrations/20260803_add_outreach_sends.sql
Normal 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.
|
||||
@@ -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.
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 member’s behalf, built from the preferences already on their profile. Admin only.',
|
||||
tag: 'Admin',
|
||||
},
|
||||
'get-testimonials': {
|
||||
method: 'GET',
|
||||
authed: false,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 I’m 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[]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
41
common/tests/unit/filters.test.ts
Normal file
41
common/tests/unit/filters.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 I’m 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
125
web/pages/alerts/[id].tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user