Introduce outreach system: add admin interface, member queue, and contact tracking via new API endpoints and database schema.

This commit is contained in:
MartinBraquet
2026-08-02 12:03:05 +02:00
parent 4a2ea66e5d
commit 5f4136fa66
20 changed files with 939 additions and 8 deletions

View File

@@ -11,7 +11,7 @@ android {
applicationId "com.compassconnections.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 145
versionCode 146
versionName "1.35.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {

View File

@@ -1,6 +1,6 @@
{
"name": "@compass/api",
"version": "1.57.1",
"version": "1.58.0",
"private": true,
"description": "Backend API endpoints",
"main": "src/serve.ts",

View File

@@ -67,6 +67,7 @@ import {getEvents} from './get-events'
import {getLikesAndShips} from './get-likes-and-ships'
import {getMe} from './get-me'
import {getNotifications} from './get-notifications'
import {getOutreachQueue} from './get-outreach-queue'
import {getProfileAnswers} from './get-profile-answers'
import {getProfiles} from './get-profiles'
import {getSupabaseToken} from './get-supabase-token'
@@ -98,6 +99,7 @@ import {unsubscribe} from './unsubscribe'
import {updateEvent} from './update-event'
import {updateMe} from './update-me'
import {updateNotifSettings} from './update-notif-setting'
import {updateOutreachContact} from './update-outreach-contact'
import {updatePrivateUserMessageChannel} from './update-private-user-message-channel'
import {updateProfileEndpoint} from './update-profile'
import {updateUserLocale} from './update-user-locale'
@@ -614,6 +616,8 @@ const handlers: {[k in APIPath]: APIHandler<k>} = {
'get-channels-count': getChannelsCountEndpoint,
'get-notifications': getNotifications,
'get-options': getOptionsEndpoint,
'get-outreach-queue': getOutreachQueue,
'update-outreach-contact': updateOutreachContact,
'get-profile-answers': getProfileAnswers,
'get-profiles': getProfiles,
'get-supabase-token': getSupabaseToken,

View File

@@ -0,0 +1,216 @@
import {APIErrors, APIHandler} from 'api/helpers/endpoint'
import {isAdminId} from 'common/envs/constants'
import {
DORMANT_AFTER_DAYS,
getOutreachTier,
getProfileCompleteness,
OutreachRow,
OutreachStage,
OutreachStatus,
} from 'common/outreach/outreach'
import {createSupabaseDirectClient} from 'shared/supabase/init'
const DEFAULT_NEW_MEMBER_LIMIT = 20
const DEFAULT_MIN_SIGNUP_DAYS = 3
const MS_PER_DAY = 24 * 60 * 60 * 1000
const daysSince = (ts: string | null): number | null =>
ts === null ? null : Math.floor((Date.now() - new Date(ts).valueOf()) / MS_PER_DAY)
type QueueQueryRow = {
id: string
name: string
username: string
avatar_url: string | null
created_time: string
stage: OutreachStage | null
next_action: string | null
channel_id: string | null
last_message_user_id: string | null
last_message_time: string | null
last_online_time: string | null
bio_length: number | null
headline: string | null
occupation: string | null
education_level: string | null
political_beliefs: string[] | null
diet: string[] | null
languages: string[] | null
city: string | null
pref_gender: string[] | null
photo_urls: string[] | null
has_big5: boolean
interest_count: string
cause_count: string
compatibility_answer_count: string
saved_search_count: string
referred_count: string
}
/**
* The whole queue in one query.
*
* Two populations, deliberately sized differently: every member there is already a thread with is
* returned, because a conversation in flight is never something to page through; members never
* contacted are capped to the most recent handful, because that set is otherwise the entire
* directory and only the top of it is ever acted on.
*
* Only message *metadata* is read — who sent the last one and when. Nothing is decrypted, so message
* content never reaches this endpoint.
*/
const QUEUE_SQL = `
with my_channels as (
select channel_id
from private_user_message_channel_members
where user_id = $(adminId)
),
counterparts as (
select m.user_id, max(m.channel_id)::bigint as channel_id
from private_user_message_channel_members m
where m.channel_id in (select channel_id from my_channels)
and m.user_id != $(adminId)
group by m.user_id
),
last_message as (
select distinct on (channel_id) channel_id, user_id, created_time
from private_user_messages
where channel_id in (select channel_id from counterparts)
and visibility != 'system_status'
order by channel_id, created_time desc
),
new_members as (
select u.id as user_id, null::bigint as channel_id
from users u
left join outreach_contacts oc on oc.user_id = u.id
where u.id != $(adminId)
and u.id not in (select user_id from counterparts)
and u.created_time <= now() - make_interval(days => $(minSignupDays))
and not coalesce(u.is_banned_from_posting, false)
and coalesce(oc.stage, '') != 'excluded'
order by u.created_time desc
limit $(newMemberLimit)
),
candidates as (
select user_id, channel_id from counterparts
union all
select user_id, channel_id from new_members
)
select u.id,
u.name,
u.username,
u.avatar_url,
u.created_time,
oc.stage,
oc.next_action,
c.channel_id,
lm.user_id as last_message_user_id,
lm.created_time as last_message_time,
ua.last_online_time,
p.bio_length,
p.headline,
p.occupation,
p.education_level,
p.political_beliefs,
p.diet,
p.languages,
p.city,
p.pref_gender,
p.photo_urls,
(p.big5_openness is not null) as has_big5,
(select count(*) from profile_interests pi where pi.profile_id = p.id) as interest_count,
(select count(*) from profile_causes pc where pc.profile_id = p.id) as cause_count,
(select count(*) from compatibility_answers ca where ca.creator_id = u.id)
as compatibility_answer_count,
(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
from candidates c
join users u on u.id = c.user_id
left join profiles p on p.user_id = u.id
left join outreach_contacts oc on oc.user_id = u.id
left join user_activity ua on ua.user_id = u.id
left join last_message lm on lm.channel_id = c.channel_id
where coalesce(oc.stage, '') != 'excluded'
and not coalesce(u.is_banned_from_posting, false)
`
export const getOutreachQueue: APIHandler<'get-outreach-queue'> = async (props, auth) => {
// Admin rather than mod: the queue is built from the caller's own threads, and the state it shows
// is one person's working notes rather than shared moderation data.
if (!isAdminId(auth.uid)) throw APIErrors.forbidden('Admin only')
const pg = createSupabaseDirectClient()
const rows = await pg.any<QueueQueryRow>(QUEUE_SQL, {
adminId: auth.uid,
minSignupDays: props.minSignupDays ?? DEFAULT_MIN_SIGNUP_DAYS,
newMemberLimit: props.newMemberLimit ?? DEFAULT_NEW_MEMBER_LIMIT,
})
return {rows: rows.map((row) => toOutreachRow(row, auth.uid))}
}
const toOutreachRow = (row: QueueQueryRow, adminId: string): OutreachRow => {
const daysSinceLastOnline = daysSince(row.last_online_time)
const daysSinceLastMessage = daysSince(row.last_message_time)
const repliedToUs = !!row.last_message_user_id && row.last_message_user_id !== adminId
const completeness = getProfileCompleteness({
bioLength: row.bio_length,
headline: row.headline,
photoCount: row.photo_urls?.length ?? 0,
occupation: row.occupation,
educationLevel: row.education_level,
politicalBeliefs: row.political_beliefs,
diet: row.diet,
languages: row.languages,
city: row.city,
prefGender: row.pref_gender,
interestCount: Number(row.interest_count),
causeCount: Number(row.cause_count),
compatibilityAnswerCount: Number(row.compatibility_answer_count),
hasBig5: row.has_big5,
})
const savedSearchCount = Number(row.saved_search_count)
return {
user: {
id: row.id,
name: row.name,
username: row.username,
avatarUrl: row.avatar_url ?? undefined,
},
stage: row.stage,
nextAction: row.next_action,
status: getStatus(row.channel_id, repliedToUs, daysSinceLastMessage),
tier: getOutreachTier({
completeness: completeness.score,
daysSinceLastOnline,
repliedToUs,
savedSearchCount,
}),
completeness,
daysSinceSignup: daysSince(row.created_time) ?? 0,
daysSinceLastOnline,
daysSinceLastMessage,
channelId: row.channel_id === null ? null : Number(row.channel_id),
savedSearchCount,
referredCount: Number(row.referred_count),
}
}
const getStatus = (
channelId: string | null,
repliedToUs: boolean,
daysSinceLastMessage: number | null,
): OutreachStatus => {
if (channelId === null) return 'not_contacted'
// An unanswered message is the one thing that should never go quiet, so a reply outranks dormancy
// however old it is.
if (repliedToUs) return 'needs_reply'
if (daysSinceLastMessage !== null && daysSinceLastMessage > DORMANT_AFTER_DAYS) return 'dormant'
return 'awaiting_reply'
}

View File

@@ -0,0 +1,38 @@
import {APIErrors, APIHandler} from 'api/helpers/endpoint'
import {isAdminId} from 'common/envs/constants'
import {createSupabaseDirectClient} from 'shared/supabase/init'
/**
* Upsert the two hand-set fields for one member. `undefined` leaves a field alone, `null` clears it —
* the page edits stage and next action independently, so a write must never blank the other one.
*/
export const updateOutreachContact: APIHandler<'update-outreach-contact'> = async (props, auth) => {
if (!isAdminId(auth.uid)) throw APIErrors.forbidden('Admin only')
const {userId, stage, nextAction} = props
// An empty box means "nothing owed", which is the same state as never having written one.
const trimmedNextAction = nextAction?.trim() ? nextAction.trim() : null
const pg = createSupabaseDirectClient()
await pg.none(
`insert into outreach_contacts (user_id, stage, next_action)
values ($(userId), $(stage), $(nextAction))
on conflict (user_id) do update
set stage = case
when $(stageProvided) then excluded.stage
else outreach_contacts.stage end,
next_action = case
when $(nextActionProvided) then excluded.next_action
else outreach_contacts.next_action end,
updated_time = now()`,
{
userId,
stage: stage ?? null,
nextAction: trimmedNextAction,
stageProvided: stage !== undefined,
nextActionProvided: nextAction !== undefined,
},
)
}

View File

@@ -62,4 +62,5 @@ BEGIN;
\i backend/supabase/migrations/20260730_cap_profiles_users_reads.sql
\i backend/supabase/migrations/20260731_add_exercise_to_profiles.sql
\i backend/supabase/migrations/20260731_lock_activity_stars_compat.sql
\i backend/supabase/migrations/20260801_add_outreach_contacts.sql
COMMIT;

View File

@@ -0,0 +1,31 @@
-- Migration: add_outreach_contacts
-- Created: 2026-08-01
--
-- Admin-only bookkeeping for member outreach. Everything that can be derived from existing tables
-- (signup date, profile completeness, last activity, who spoke last in a thread) stays derived and is
-- not stored here. This table holds only the two things no query can infer: where a conversation
-- stands, which depends on what was actually said, and a one-line reminder of what is owed next.
--
-- A row exists only for members whose state has been set by hand; absence means "not started".
CREATE TABLE IF NOT EXISTS outreach_contacts
(
user_id TEXT PRIMARY KEY REFERENCES users (id) ON DELETE CASCADE,
-- Values mirror OUTREACH_STAGES in common/src/outreach/outreach.ts. 'excluded' marks accounts that
-- are deliberately outside outreach (people already known personally, test accounts) so they stop
-- appearing in the queue instead of being skipped over by memory every time.
stage TEXT CHECK (stage IN ('not_started', 'opened', 'replied', 'suggestions_sent',
'nudged', 'closed', 'excluded')),
next_action TEXT CHECK (next_action IS NULL OR char_length(next_action) <= 200),
created_time TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_time TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_outreach_contacts_stage ON outreach_contacts (stage);
-- RLS enabled with no policies: this denies the anon and authenticated roles outright. Reads and
-- writes go through the API on the service-role connection, which bypasses RLS.
ALTER TABLE outreach_contacts
ENABLE ROW LEVEL SECURITY;
REVOKE ALL ON outreach_contacts FROM anon, authenticated;

View File

@@ -1583,6 +1583,7 @@
"social.follow.title": "Folgen & Updates",
"social.github": "GitHub",
"social.instagram": "Instagram",
"social.mastodon": "Mastodon",
"social.reddit": "Reddit",
"social.seo.description": "Soziale Netzwerke",
"social.seo.title": "Soziale Netzwerke",

View File

@@ -1582,6 +1582,7 @@
"social.follow.title": "Annonces & Mises à jour",
"social.github": "GitHub",
"social.instagram": "Instagram",
"social.mastodon": "Mastodon",
"social.reddit": "Reddit",
"social.seo.description": "Réseaux sociaux",
"social.seo.title": "Réseaux sociaux",

View File

@@ -9,6 +9,7 @@ import {
import {ChatMessage} from 'common/chat-message'
import {BAN_REASONS} from 'common/moderation/ban'
import {Notification} from 'common/notifications'
import {MAX_NEXT_ACTION_LENGTH, OUTREACH_STAGES, OutreachRow} from 'common/outreach/outreach'
import {CompatibilityScore} from 'common/profiles/compatibility-score'
import {MAX_COMPATIBILITY_QUESTION_LENGTH, OPTION_TABLES} from 'common/profiles/constants'
import {Profile, ProfileRow, ProfileWithoutUser} from 'common/profiles/profile'
@@ -1349,6 +1350,38 @@ export const API = (_apiTypeCheck = {
summary: 'Get user journeys (events) for users created within the last N hours. Admin only.',
tag: 'Admin',
},
'get-outreach-queue': {
method: 'GET',
authed: true,
rateLimited: false,
props: z
.object({
// Members with an existing thread are always returned in full. This caps only the
// never-contacted bucket, which would otherwise be the entire member list on every load.
newMemberLimit: z.coerce.number().min(1).max(100).optional(),
// Skip the first few days after signup so a member has had time to fill their profile in
// before being judged on how complete it is.
minSignupDays: z.coerce.number().min(0).max(30).optional(),
})
.strict(),
returns: {} as {rows: OutreachRow[]},
summary: 'Get the member outreach queue. Admin only.',
tag: 'Admin',
},
'update-outreach-contact': {
method: 'POST',
authed: true,
rateLimited: false,
props: z
.object({
userId: z.string(),
stage: z.enum(OUTREACH_STAGES).nullable().optional(),
nextAction: z.string().max(MAX_NEXT_ACTION_LENGTH).nullable().optional(),
})
.strict(),
summary: 'Set the outreach stage or next action for a member. Admin only.',
tag: 'Admin',
},
} as const)
export type APIPath = keyof typeof API

View File

@@ -17,6 +17,7 @@ export const stoatLink = 'https://stt.gg/YKQp81yA'
export const redditLink = 'https://www.reddit.com/r/CompassConnect'
export const xLink = 'https://x.com/compassmeet'
export const instagramLink = 'https://www.instagram.com/compassmeet/'
export const mastodonLink = 'https://mastodon.social/@compassmeet'
export const formLink = 'https://forms.gle/tKnXUMAbEreMK6FC6'
export const ANDROID_APP_URL =
'https://play.google.com/store/apps/details?id=com.compassconnections.app'

View File

@@ -0,0 +1,146 @@
import {clamp} from 'lodash'
/**
* Where a one-to-one conversation with a member stands. Set by hand: it depends on what was actually
* said, which no query can infer. Absence of a row means `not_started`.
*
* `excluded` takes a member out of the queue for good — people already known personally, test
* accounts, anyone outreach does not apply to.
*/
export const OUTREACH_STAGES = [
'not_started',
'opened',
'replied',
'suggestions_sent',
'nudged',
'closed',
'excluded',
] as const
export type OutreachStage = (typeof OUTREACH_STAGES)[number]
export const OUTREACH_STAGE_LABELS: Record<OutreachStage, string> = {
not_started: 'Not started',
opened: 'Opened',
replied: 'Replied',
suggestions_sent: 'Suggestions sent',
nudged: 'Nudged',
closed: 'Closed',
excluded: 'Excluded',
}
export const MAX_NEXT_ACTION_LENGTH = 200
/** Derived from the thread, never stored. */
export type OutreachStatus = 'needs_reply' | 'not_contacted' | 'awaiting_reply' | 'dormant'
/** A thread with no message either way for this long is dormant rather than merely pending. */
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 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
* nothing to match on.
*/
export type ProfileCompletenessInput = {
bioLength: number | null
headline: string | null
photoCount: number
occupation: string | null
educationLevel: string | null
politicalBeliefs: string[] | null
diet: string[] | null
languages: string[] | null
city: string | null
prefGender: string[] | null
interestCount: number
causeCount: number
compatibilityAnswerCount: number
hasBig5: boolean
}
/** Below this a bio is a one-liner: present, but not something search or a reader can use. */
const MIN_USEFUL_BIO_LENGTH = 200
const hasAny = (v: string[] | null | undefined) => !!v && v.length > 0
export type ProfileCompleteness = {
/** 01. */
score: number
filled: number
total: number
/** Field keys still empty, in the order below — the list to hand back as concrete advice. */
missing: string[]
}
export const getProfileCompleteness = (p: ProfileCompletenessInput): ProfileCompleteness => {
const checks: [string, boolean][] = [
['bio', (p.bioLength ?? 0) >= MIN_USEFUL_BIO_LENGTH],
['headline', !!p.headline],
['photo', p.photoCount > 0],
['occupation', !!p.occupation],
['education', !!p.educationLevel],
['politics', hasAny(p.politicalBeliefs)],
['diet', hasAny(p.diet)],
['languages', hasAny(p.languages)],
['city', !!p.city],
['looking for', hasAny(p.prefGender)],
['interests', p.interestCount >= 3],
['causes', p.causeCount >= 3],
['compatibility', p.compatibilityAnswerCount >= 5],
['big five', p.hasBig5],
]
const missing = checks.filter(([, ok]) => !ok).map(([key]) => key)
const filled = checks.length - missing.length
return {score: filled / checks.length, filled, total: checks.length, missing}
}
export type OutreachTierInput = {
completeness: number
daysSinceLastOnline: number | null
repliedToUs: boolean
savedSearchCount: number
}
/**
* Completeness sets the baseline; engagement moves it one step either way. A thin profile from
* someone who writes back is worth more than a full profile from someone who signed up and left, and
* neither is captured by completeness alone.
*/
export const getOutreachTier = (p: OutreachTierInput): OutreachTier => {
const base = p.completeness >= 0.65 ? 2 : p.completeness >= 0.35 ? 1 : 0
const engaged =
p.repliedToUs ||
p.savedSearchCount > 0 ||
(p.daysSinceLastOnline !== null && p.daysSinceLastOnline <= 7)
const stale = p.daysSinceLastOnline === null || p.daysSinceLastOnline > DORMANT_AFTER_DAYS
const level = clamp(base + (engaged ? 1 : 0) - (stale ? 1 : 0), 0, 2)
return (['C', 'B', 'A'] as const)[level]
}
/** One member as the outreach queue sees them: stored state plus everything derived at query time. */
export type OutreachRow = {
user: {id: string; name: string; username: string; avatarUrl?: string}
stage: OutreachStage | null
nextAction: string | null
status: OutreachStatus
tier: OutreachTier
completeness: ProfileCompleteness
daysSinceSignup: number
daysSinceLastOnline: number | null
/** Days since the last message in the thread, whoever sent it. Null when there is no thread. */
daysSinceLastMessage: number | null
channelId: number | null
savedSearchCount: number
/** How many members joined with this member's username as their referrer. */
referredCount: number
}

View File

@@ -726,6 +726,38 @@ export type Database = {
}
Relationships: []
}
outreach_contacts: {
Row: {
created_time: string
next_action: string | null
stage: string | null
updated_time: string
user_id: string
}
Insert: {
created_time?: string
next_action?: string | null
stage?: string | null
updated_time?: string
user_id: string
}
Update: {
created_time?: string
next_action?: string | null
stage?: string | null
updated_time?: string
user_id?: string
}
Relationships: [
{
foreignKeyName: 'outreach_contacts_user_id_fkey'
columns: ['user_id']
isOneToOne: true
referencedRelation: 'users'
referencedColumns: ['id']
},
]
}
private_user_message_channel_members: {
Row: {
channel_id: number

View File

@@ -0,0 +1,112 @@
import {
getOutreachTier,
getProfileCompleteness,
ProfileCompletenessInput,
} from 'common/outreach/outreach'
const emptyProfile: ProfileCompletenessInput = {
bioLength: null,
headline: null,
photoCount: 0,
occupation: null,
educationLevel: null,
politicalBeliefs: null,
diet: null,
languages: null,
city: null,
prefGender: null,
interestCount: 0,
causeCount: 0,
compatibilityAnswerCount: 0,
hasBig5: false,
}
const fullProfile: ProfileCompletenessInput = {
bioLength: 800,
headline: 'Stochastic hacker',
photoCount: 3,
occupation: 'physician',
educationLevel: 'doctorate',
politicalBeliefs: ['progressive'],
diet: ['vegan'],
languages: ['english', 'italian'],
city: 'Perugia',
prefGender: ['female'],
interestCount: 8,
causeCount: 5,
compatibilityAnswerCount: 8,
hasBig5: true,
}
describe('getProfileCompleteness', () => {
it('scores an empty profile at zero and lists every field', () => {
const result = getProfileCompleteness(emptyProfile)
expect(result.score).toBe(0)
expect(result.filled).toBe(0)
expect(result.missing).toHaveLength(result.total)
})
it('scores a full profile at one with nothing missing', () => {
const result = getProfileCompleteness(fullProfile)
expect(result.score).toBe(1)
expect(result.missing).toEqual([])
})
it('does not count a one-line bio as a bio', () => {
const result = getProfileCompleteness({...fullProfile, bioLength: 24})
expect(result.missing).toEqual(['bio'])
expect(result.score).toBeLessThan(1)
})
it('requires more than a token interest or cause', () => {
const result = getProfileCompleteness({...fullProfile, interestCount: 1, causeCount: 2})
expect(result.missing).toEqual(['interests', 'causes'])
})
})
describe('getOutreachTier', () => {
it('puts a complete, active profile in A', () => {
expect(
getOutreachTier({
completeness: 0.9,
daysSinceLastOnline: 1,
repliedToUs: false,
savedSearchCount: 0,
}),
).toBe('A')
})
it('puts an empty, never-seen profile in C', () => {
expect(
getOutreachTier({
completeness: 0.1,
daysSinceLastOnline: null,
repliedToUs: false,
savedSearchCount: 0,
}),
).toBe('C')
})
it('promotes a middling profile when they have written back', () => {
const base = {completeness: 0.5, daysSinceLastOnline: 2, savedSearchCount: 0}
expect(getOutreachTier({...base, repliedToUs: false})).toBe('A')
expect(getOutreachTier({...base, repliedToUs: true})).toBe('A')
})
it('demotes a complete profile that has gone stale', () => {
expect(
getOutreachTier({
completeness: 0.9,
daysSinceLastOnline: 120,
repliedToUs: false,
savedSearchCount: 0,
}),
).toBe('B')
})
it('treats a saved search as engagement', () => {
const stale = {completeness: 0.5, daysSinceLastOnline: 60, repliedToUs: false}
expect(getOutreachTier({...stale, savedSearchCount: 0})).toBe('C')
expect(getOutreachTier({...stale, savedSearchCount: 2})).toBe('B')
})
})

View File

@@ -3,6 +3,7 @@ import {
HomeIcon,
NewspaperIcon,
QuestionMarkCircleIcon,
WrenchScrewdriverIcon,
} from '@heroicons/react/24/outline'
import {
CogIcon,
@@ -15,6 +16,7 @@ import {
} from '@heroicons/react/24/solid'
import clsx from 'clsx'
import {IS_MAINTENANCE} from 'common/constants'
import {isAdminId} from 'common/envs/constants'
import {Profile} from 'common/profiles/profile'
import {User} from 'common/user'
import {buildArray} from 'common/util/array'
@@ -198,6 +200,13 @@ const Events = {
href: '/events',
icon: CalendarIcon,
}
// Not translated on purpose: only admins ever see it, and it's an internal-tools index.
const Admin = {
key: 'nav.admin',
name: 'Admin',
href: '/admin',
icon: WrenchScrewdriverIcon,
}
// Stable component for Messages icon to prevent re-mounting on every render
const MessagesIconComponent = (props: any) => <PrivateMessagesIcon solid {...props} />
@@ -238,13 +247,14 @@ const getDesktopNavigation = (user: User | null | undefined) => {
},
Settings,
...base,
isAdminId(user.id) && Admin,
)
return buildArray(...base)
}
const getMobileSidebar = (user: User | null | undefined, _toggleModal: () => void) => {
if (user) return buildArray(Settings, ...base)
if (user) return buildArray(Settings, ...base, isAdminId(user.id) && Admin)
return buildArray(...base)
}

65
web/pages/admin/index.tsx Normal file
View File

@@ -0,0 +1,65 @@
import {EnvelopeIcon, MapIcon} from '@heroicons/react/24/outline'
import {IS_LOCAL} from 'common/hosting/constants'
import Link from 'next/link'
import {ComponentType} from 'react'
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 {surface, surfaceHover} from 'web/components/widgets/surface'
import {useAdmin} from 'web/hooks/use-admin'
// The single list of admin tools. Adding a page here is what makes it reachable — the sidebar only ever
// links to this index, so no new nav entry is needed per tool.
const ADMIN_PAGES: {
href: string
name: string
description: string
icon: ComponentType<{className?: string}>
}[] = [
{
href: '/admin/outreach',
name: 'Outreach',
description: 'Every open conversation plus the newest members nobody has written to yet.',
icon: EnvelopeIcon,
},
{
href: '/admin/journeys',
name: 'User journeys',
description: 'Event-by-event replay of what recently-created users did on the site.',
icon: MapIcon,
},
]
export default function AdminHome() {
const isAdmin = useAdmin()
if (!(isAdmin || IS_LOCAL)) return <p>Not authorized</p>
return (
<PageBase className="p-2 sm:pt-0">
<NoSEO />
<Col className={'text-ink-900 mx-4 my-4 gap-6'}>
<Col className={'gap-1'}>
<div className={'text-primary-700 text-2xl'}>Admin</div>
<div className={'text-ink-500 text-sm'}>Internal tools. Visible to admins only.</div>
</Col>
<div className={'grid gap-3 sm:grid-cols-2'}>
{ADMIN_PAGES.map((page) => (
<Link key={page.href} href={page.href} className={`${surface} ${surfaceHover} p-4`}>
<Row className={'items-start gap-3'}>
<page.icon className={'text-primary-600 mt-0.5 h-5 w-5 flex-shrink-0'} />
<Col className={'gap-1'}>
<div className={'text-ink-900 font-medium'}>{page.name}</div>
<div className={'text-ink-500 text-sm'}>{page.description}</div>
<div className={'text-ink-300 text-xs'}>{page.href}</div>
</Col>
</Row>
</Link>
))}
</div>
</Col>
</PageBase>
)
}

View File

@@ -6,6 +6,7 @@ import {Button} from 'web/components/buttons/button'
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 {UserAvatarAndBadge} from 'web/components/widgets/user-link'
import {useAdmin} from 'web/hooks/use-admin'
import {useAPIGetter} from 'web/hooks/use-api-getter'
@@ -32,7 +33,7 @@ export default function Journeys() {
if (!authorized) return <p>Not authorized</p>
return (
<Row>
<PageBase className="col-span-10 p-2 sm:pt-0">
<NoSEO />
<div className="text-ink-900 mx-8">
<div className={'text-primary-700 my-1 text-2xl'}>User Journeys</div>
@@ -111,6 +112,6 @@ export default function Journeys() {
})}
</Row>
</div>
</Row>
</PageBase>
)
}

View File

@@ -0,0 +1,226 @@
import clsx from 'clsx'
import {IS_LOCAL} from 'common/hosting/constants'
import {
MAX_NEXT_ACTION_LENGTH,
OUTREACH_STAGE_LABELS,
OUTREACH_STAGES,
OutreachRow,
OutreachStage,
OutreachStatus,
} from 'common/outreach/outreach'
import {groupBy, orderBy} from 'lodash'
import Link from 'next/link'
import {useState} from 'react'
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 {Input} from 'web/components/widgets/input'
import {Select} from 'web/components/widgets/select'
import {UserAvatarAndBadge} from 'web/components/widgets/user-link'
import {useAdmin} from 'web/hooks/use-admin'
import {useAPIGetter} from 'web/hooks/use-api-getter'
import {usePersistentQueryState} from 'web/hooks/use-persistent-query-state'
import {api} from 'web/lib/api'
// Ordered by what needs doing, not alphabetically: an unanswered reply is a debt, a new member is an
// opportunity, and everything else can wait until those two are empty.
const STATUS_ORDER: OutreachStatus[] = ['needs_reply', 'not_contacted', 'awaiting_reply', 'dormant']
const STATUS_LABELS: Record<OutreachStatus, string> = {
needs_reply: 'They replied — owe them an answer',
not_contacted: 'Never contacted',
awaiting_reply: 'Waiting on them',
dormant: 'Gone quiet',
}
const TIER_CLASS: Record<string, string> = {
A: 'bg-primary-100 text-primary-700',
B: 'bg-canvas-100 text-ink-700',
C: 'bg-canvas-50 text-ink-400',
}
const sortGroup = (status: OutreachStatus, rows: OutreachRow[]) => {
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'])
}
// Longest silence first everywhere else.
return orderBy(rows, [(r) => r.daysSinceLastMessage ?? 0], ['desc'])
}
export default function Outreach() {
const [newMemberLimitQ, setNewMemberLimitQ] = usePersistentQueryState('n', '20')
const newMemberLimit = parseInt(newMemberLimitQ ?? '20')
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 isAdmin = useAdmin()
if (!(isAdmin || IS_LOCAL)) return <p>Not authorized</p>
const rows = (data?.rows ?? []).map((row) => ({...row, ...edits[row.user.id]}))
const byStatus = groupBy(rows, 'status')
const save = async (
userId: string,
update: {stage?: OutreachStage; nextAction?: string | null},
) => {
setEdits((prev) => ({...prev, [userId]: {...prev[userId], ...update} as any}))
await api('update-outreach-contact', {userId, ...update})
}
return (
<PageBase className="col-span-10 p-2 sm:pt-0">
<Col className={'text-ink-900 mx-4 my-4 gap-6'}>
<NoSEO />
<Row className={'items-baseline gap-3'}>
<div className={'text-primary-700 text-2xl'}>Outreach</div>
<div className={'text-ink-500 text-sm'}>
{rows.length} members · every open conversation, plus the {newMemberLimit} newest
members nobody has written to yet
</div>
<Select
className={'!h-8 !py-0 !pl-2 !pr-8 !text-xs'}
value={newMemberLimitQ ?? '20'}
onChange={(e) => setNewMemberLimitQ(e.target.value)}
>
{['10', '20', '50', '100'].map((n) => (
<option key={n} value={n}>
show {n} new
</option>
))}
</Select>
<button className={'text-ink-500 text-xs underline'} onClick={refresh}>
refresh
</button>
</Row>
{STATUS_ORDER.map((status) => {
const group = byStatus[status] ?? []
if (!group.length) return null
return (
<Col key={status} className={'gap-2'}>
<Row className={'items-baseline gap-2'}>
<div className={'text-ink-800 text-lg'}>{STATUS_LABELS[status]}</div>
<div className={'text-ink-400 text-sm'}>{group.length}</div>
</Row>
<div className={'overflow-x-auto'}>
<table className={'w-full min-w-[60rem] text-sm'}>
<thead className={'text-ink-400 text-left text-xs uppercase'}>
<tr>
<th className={'py-1 pr-3 font-normal'}>Member</th>
<th className={'py-1 pr-3 font-normal'}>Tier</th>
<th className={'py-1 pr-3 font-normal'}>Profile</th>
<th className={'py-1 pr-3 font-normal'}>Joined</th>
<th className={'py-1 pr-3 font-normal'}>Silence</th>
<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'}>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} />
))}
</tbody>
</table>
</div>
</Col>
)
})}
</Col>
</PageBase>
)
}
function OutreachTableRow(props: {
row: OutreachRow
onSave: (
userId: string,
update: {stage?: OutreachStage; nextAction?: string | null},
) => Promise<void>
}) {
const {row, onSave} = props
const [nextAction, setNextAction] = useState(row.nextAction ?? '')
const days = (n: number | null) => (n === null ? '—' : `${n}d`)
return (
<tr className={'border-canvas-100 border-t align-middle'}>
<td className={'py-2 pr-3'}>
<Row className={'items-center gap-2'}>
<UserAvatarAndBadge user={row.user} />
{row.channelId !== null && (
<Link
className={'text-primary-600 text-xs underline'}
href={`/messages/${row.channelId}`}
>
thread
</Link>
)}
</Row>
</td>
<td className={'py-2 pr-3'}>
<span className={clsx('rounded px-1.5 py-0.5 text-xs', TIER_CLASS[row.tier])}>
{row.tier}
</span>
</td>
{/* The missing fields are the whole point of showing a percentage — they are what you'd tell
them to go fix. */}
<td className={'py-2 pr-3'} title={row.completeness.missing.join(', ') || 'nothing missing'}>
<span className={clsx(row.completeness.score < 0.35 && 'text-ink-400')}>
{Math.round(row.completeness.score * 100)}%
</span>
</td>
<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>
<td className={clsx('py-2 pr-3', row.referredCount > 0 && 'text-primary-700')}>
{row.referredCount || '—'}
</td>
<td className={'py-2 pr-3'}>
<Select
className={'!h-8 !py-0 !pl-2 !pr-8 !text-xs'}
value={row.stage ?? 'not_started'}
onChange={(e) => onSave(row.user.id, {stage: e.target.value as OutreachStage})}
>
{OUTREACH_STAGES.map((stage) => (
<option key={stage} value={stage}>
{OUTREACH_STAGE_LABELS[stage]}
</option>
))}
</Select>
</td>
<td className={'py-2 pr-3'}>
<Input
className={'!h-8 w-64 !text-xs'}
maxLength={MAX_NEXT_ACTION_LENGTH}
placeholder={'what you owe them'}
value={nextAction}
onChange={(e) => setNextAction(e.target.value)}
onBlur={() => {
if (nextAction !== (row.nextAction ?? '')) {
onSave(row.user.id, {nextAction: nextAction || null})
}
}}
/>
</td>
</tr>
)
}

View File

@@ -3,12 +3,13 @@ import {
discordLink,
githubRepo,
instagramLink,
mastodonLink,
redditLink,
supportEmail,
xLink,
} from 'common/constants'
import {ComponentType, ReactNode, SVGProps} from 'react'
import {FaDiscord, FaGithub, FaInstagram, FaReddit} from 'react-icons/fa'
import {FaDiscord, FaGithub, FaInstagram, FaMastodon, FaReddit} from 'react-icons/fa'
import {FaXTwitter} from 'react-icons/fa6'
import {PageBase} from 'web/components/page-base'
import {SEO} from 'web/components/SEO'
@@ -23,6 +24,9 @@ interface SocialLink {
label: string
icon: ReactNode
primary?: boolean
// Extra rel tokens, merged with the defaults. `me` is what lets Mastodon verify that this site and the
// account belong to the same owner (it needs the matching link on the profile too).
rel?: string
}
interface SectionCardProps {
@@ -34,12 +38,12 @@ interface SectionCardProps {
// ─── Social Link Button ───────────────────────────────────────────────────────
function SocialLinkButton({url, label, icon, primary}: SocialLink) {
function SocialLinkButton({url, label, icon, primary, rel}: SocialLink) {
return (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
rel={rel ? `${rel} noopener noreferrer` : 'noopener noreferrer'}
className={`
inline-flex items-center gap-2.5 px-4 py-2.5 rounded-xl
border-[1.5px] text-sm font-semibold
@@ -153,6 +157,12 @@ export default function Social() {
icon: <FaXTwitter className="w-4 h-4" />,
primary: true,
},
{
url: mastodonLink,
label: t('social.mastodon', 'Mastodon'),
icon: <FaMastodon className="w-4 h-4" />,
rel: 'me',
},
{
url: instagramLink,
label: t('social.instagram', 'Instagram'),

View File

@@ -1,3 +1,6 @@
# *
User-agent: *
Allow: /
# Internal tools. They already send `noindex` per page; this keeps crawlers from spending the budget
# on pages that only ever render "Not authorized" for them.
Disallow: /admin