From 5f4136fa662c2b14adbdd01c7ff608778e5f9ba8 Mon Sep 17 00:00:00 2001 From: MartinBraquet Date: Sun, 2 Aug 2026 12:03:05 +0200 Subject: [PATCH] Introduce outreach system: add admin interface, member queue, and contact tracking via new API endpoints and database schema. --- android/app/build.gradle | 2 +- backend/api/package.json | 2 +- backend/api/src/app.ts | 4 + backend/api/src/get-outreach-queue.ts | 216 +++++++++++++++++ backend/api/src/update-outreach-contact.ts | 38 +++ backend/supabase/migration.sql | 1 + .../20260801_add_outreach_contacts.sql | 31 +++ common/messages/de.json | 1 + common/messages/fr.json | 1 + common/src/api/schema.ts | 33 +++ common/src/constants.ts | 1 + common/src/outreach/outreach.ts | 146 +++++++++++ common/src/supabase/schema.ts | 32 +++ common/tests/unit/outreach.test.ts | 112 +++++++++ web/components/page-base.tsx | 12 +- web/pages/admin/index.tsx | 65 +++++ web/pages/admin/journeys.tsx | 5 +- web/pages/admin/outreach.tsx | 226 ++++++++++++++++++ web/pages/social.tsx | 16 +- web/public/robots.txt | 3 + 20 files changed, 939 insertions(+), 8 deletions(-) create mode 100644 backend/api/src/get-outreach-queue.ts create mode 100644 backend/api/src/update-outreach-contact.ts create mode 100644 backend/supabase/migrations/20260801_add_outreach_contacts.sql create mode 100644 common/src/outreach/outreach.ts create mode 100644 common/tests/unit/outreach.test.ts create mode 100644 web/pages/admin/index.tsx create mode 100644 web/pages/admin/outreach.tsx diff --git a/android/app/build.gradle b/android/app/build.gradle index 9596b61a..f09b6ab1 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -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 { diff --git a/backend/api/package.json b/backend/api/package.json index 0479d992..264f5075 100644 --- a/backend/api/package.json +++ b/backend/api/package.json @@ -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", diff --git a/backend/api/src/app.ts b/backend/api/src/app.ts index 66091aa3..eabd9f78 100644 --- a/backend/api/src/app.ts +++ b/backend/api/src/app.ts @@ -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} = { '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, diff --git a/backend/api/src/get-outreach-queue.ts b/backend/api/src/get-outreach-queue.ts new file mode 100644 index 00000000..74b5d22a --- /dev/null +++ b/backend/api/src/get-outreach-queue.ts @@ -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(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' +} diff --git a/backend/api/src/update-outreach-contact.ts b/backend/api/src/update-outreach-contact.ts new file mode 100644 index 00000000..a0c2708a --- /dev/null +++ b/backend/api/src/update-outreach-contact.ts @@ -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, + }, + ) +} diff --git a/backend/supabase/migration.sql b/backend/supabase/migration.sql index 23b6353f..5a7fef73 100644 --- a/backend/supabase/migration.sql +++ b/backend/supabase/migration.sql @@ -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; diff --git a/backend/supabase/migrations/20260801_add_outreach_contacts.sql b/backend/supabase/migrations/20260801_add_outreach_contacts.sql new file mode 100644 index 00000000..4cf1f91f --- /dev/null +++ b/backend/supabase/migrations/20260801_add_outreach_contacts.sql @@ -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; diff --git a/common/messages/de.json b/common/messages/de.json index 213a0bfb..d1c14fa9 100644 --- a/common/messages/de.json +++ b/common/messages/de.json @@ -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", diff --git a/common/messages/fr.json b/common/messages/fr.json index 489de56a..c6016da1 100644 --- a/common/messages/fr.json +++ b/common/messages/fr.json @@ -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", diff --git a/common/src/api/schema.ts b/common/src/api/schema.ts index e791c4a8..ed5e0315 100644 --- a/common/src/api/schema.ts +++ b/common/src/api/schema.ts @@ -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 diff --git a/common/src/constants.ts b/common/src/constants.ts index 0d85fcc2..5ce27e99 100644 --- a/common/src/constants.ts +++ b/common/src/constants.ts @@ -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' diff --git a/common/src/outreach/outreach.ts b/common/src/outreach/outreach.ts new file mode 100644 index 00000000..d0da210e --- /dev/null +++ b/common/src/outreach/outreach.ts @@ -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 = { + 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 = { + /** 0–1. */ + 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 +} diff --git a/common/src/supabase/schema.ts b/common/src/supabase/schema.ts index 0f9dd969..2cca010b 100644 --- a/common/src/supabase/schema.ts +++ b/common/src/supabase/schema.ts @@ -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 diff --git a/common/tests/unit/outreach.test.ts b/common/tests/unit/outreach.test.ts new file mode 100644 index 00000000..d9cb0f63 --- /dev/null +++ b/common/tests/unit/outreach.test.ts @@ -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') + }) +}) diff --git a/web/components/page-base.tsx b/web/components/page-base.tsx index e5130f79..a6ddb509 100644 --- a/web/components/page-base.tsx +++ b/web/components/page-base.tsx @@ -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) => @@ -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) } diff --git a/web/pages/admin/index.tsx b/web/pages/admin/index.tsx new file mode 100644 index 00000000..e0fb014a --- /dev/null +++ b/web/pages/admin/index.tsx @@ -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

Not authorized

+ + return ( + + + + +
Admin
+
Internal tools. Visible to admins only.
+ + +
+ {ADMIN_PAGES.map((page) => ( + + + + +
{page.name}
+
{page.description}
+
{page.href}
+ +
+ + ))} +
+ +
+ ) +} diff --git a/web/pages/admin/journeys.tsx b/web/pages/admin/journeys.tsx index b4409880..638e8a6d 100644 --- a/web/pages/admin/journeys.tsx +++ b/web/pages/admin/journeys.tsx @@ -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

Not authorized

return ( - +
User Journeys
@@ -111,6 +112,6 @@ export default function Journeys() { })}
-
+ ) } diff --git a/web/pages/admin/outreach.tsx b/web/pages/admin/outreach.tsx new file mode 100644 index 00000000..1250e85d --- /dev/null +++ b/web/pages/admin/outreach.tsx @@ -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 = { + needs_reply: 'They replied — owe them an answer', + not_contacted: 'Never contacted', + awaiting_reply: 'Waiting on them', + dormant: 'Gone quiet', +} + +const TIER_CLASS: Record = { + 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>( + {}, + ) + + const isAdmin = useAdmin() + if (!(isAdmin || IS_LOCAL)) return

Not authorized

+ + 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 ( + + + + + +
Outreach
+
+ {rows.length} members · every open conversation, plus the {newMemberLimit} newest + members nobody has written to yet +
+ + +
+ + {STATUS_ORDER.map((status) => { + const group = byStatus[status] ?? [] + if (!group.length) return null + + return ( + + +
{STATUS_LABELS[status]}
+
{group.length}
+
+ +
+ + + + + + + + + + + + + + + + + {sortGroup(status, group).map((row) => ( + + ))} + +
MemberTierProfileJoinedSilenceSeenSavedBroughtStageNext action
+
+ + ) + })} + +
+ ) +} + +function OutreachTableRow(props: { + row: OutreachRow + onSave: ( + userId: string, + update: {stage?: OutreachStage; nextAction?: string | null}, + ) => Promise +}) { + const {row, onSave} = props + const [nextAction, setNextAction] = useState(row.nextAction ?? '') + + const days = (n: number | null) => (n === null ? '—' : `${n}d`) + + return ( + + + + + {row.channelId !== null && ( + + thread + + )} + + + + + + {row.tier} + + + + {/* The missing fields are the whole point of showing a percentage — they are what you'd tell + them to go fix. */} + + + {Math.round(row.completeness.score * 100)}% + + + + {days(row.daysSinceSignup)} + {days(row.daysSinceLastMessage)} + {days(row.daysSinceLastOnline)} + {row.savedSearchCount || '—'} + 0 && 'text-primary-700')}> + {row.referredCount || '—'} + + + + + + + + setNextAction(e.target.value)} + onBlur={() => { + if (nextAction !== (row.nextAction ?? '')) { + onSave(row.user.id, {nextAction: nextAction || null}) + } + }} + /> + + + ) +} diff --git a/web/pages/social.tsx b/web/pages/social.tsx index 8a0f4e7b..c3233a36 100644 --- a/web/pages/social.tsx +++ b/web/pages/social.tsx @@ -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 ( , primary: true, }, + { + url: mastodonLink, + label: t('social.mastodon', 'Mastodon'), + icon: , + rel: 'me', + }, { url: instagramLink, label: t('social.instagram', 'Instagram'), diff --git a/web/public/robots.txt b/web/public/robots.txt index 7bf38a3a..410189d1 100644 --- a/web/public/robots.txt +++ b/web/public/robots.txt @@ -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