mirror of
https://github.com/CompassConnections/Compass.git
synced 2026-08-02 19:31:19 -04:00
Introduce outreach system: add admin interface, member queue, and contact tracking via new API endpoints and database schema.
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
216
backend/api/src/get-outreach-queue.ts
Normal file
216
backend/api/src/get-outreach-queue.ts
Normal 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'
|
||||
}
|
||||
38
backend/api/src/update-outreach-contact.ts
Normal file
38
backend/api/src/update-outreach-contact.ts
Normal 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,
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
146
common/src/outreach/outreach.ts
Normal file
146
common/src/outreach/outreach.ts
Normal 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 = {
|
||||
/** 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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
112
common/tests/unit/outreach.test.ts
Normal file
112
common/tests/unit/outreach.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -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
65
web/pages/admin/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
226
web/pages/admin/outreach.tsx
Normal file
226
web/pages/admin/outreach.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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'),
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user