Add support for pinned images in profile completeness calculation and member context script

This commit is contained in:
MartinBraquet
2026-08-04 03:37:24 +02:00
parent afd5dd3600
commit 3a2246669b
4 changed files with 433 additions and 1 deletions

View File

@@ -54,6 +54,7 @@ type QueueQueryRow = {
city: string | null
pref_gender: string[] | null
photo_urls: string[] | null
pinned_url: string | null
has_big5: boolean
interest_count: string
cause_count: string
@@ -139,6 +140,7 @@ const QUEUE_SQL = `
p.city,
p.pref_gender,
p.photo_urls,
p.pinned_url,
(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,
@@ -227,6 +229,7 @@ const toOutreachRow = (row: QueueQueryRow, adminId: string): OutreachRow => {
bioLength: row.bio_length,
headline: row.headline,
photoCount: row.photo_urls?.length ?? 0,
pinnedUrl: row.pinned_url,
occupation: row.occupation,
educationLevel: row.education_level,
politicalBeliefs: row.political_beliefs,

View File

@@ -0,0 +1,419 @@
// Info about one member and someone else's interaction with them, as markdown
// Run with:
// export ENVIRONMENT=PROD && npx tsx backend/scripts/member_context.ts <username>
//
// Options (env vars):
// USERNAME=ana the member (or pass as the first argument)
// ME=Martin whose side of the conversation is "me"
// OUT=../../martin/context.md also write to a file (default: stdout only)
import chalk from 'chalk'
import {writeFileSync} from 'fs'
import {debug} from 'common/logger'
import {
DORMANT_AFTER_DAYS,
EMPTY_ROOM_MAX_NEARBY,
getOutreachTier,
getProfileCompleteness,
OUTREACH_RADIUS_KM,
} from 'common/outreach/outreach'
import {richTextToString} from 'common/util/parse'
import {convertPrivateChatMessage} from 'shared/supabase/messages'
import {getLocalDensity} from 'shared/outreach/local-density'
import {runScript} from './run-script'
const USERNAME = process.argv[2] ?? process.env.USERNAME
const ME = process.env.ME ?? 'Martin'
const OUT = process.env.OUT
const MS_PER_DAY = 24 * 60 * 60 * 1000
const daysSince = (ts: string | Date | null | undefined): number | null =>
ts ? Math.floor((Date.now() - new Date(ts).valueOf()) / MS_PER_DAY) : null
/** `2026-08-04 15:48`, matching how I quote conversations by hand. */
const stamp = (ms: number) => {
const d = new Date(ms)
const p = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(
d.getMinutes(),
)}`
}
const day = (ts: string | Date | null | undefined) =>
ts ? new Date(ts).toISOString().slice(0, 10) : '—'
const list = (values: unknown): string => {
if (Array.isArray(values)) return values.length ? values.join(', ') : '—'
return values === null || values === undefined || values === '' ? '—' : String(values)
}
/** Only the fields worth a line. A dump of every column buries the two facts worth writing about. */
const field = (label: string, value: unknown) => `- **${label}**: ${list(value)}`
/**
* For tables that may not exist yet on whichever database this is pointed at — outreach tables reach
* dev later than prod. Missing table means missing section, not a dead script.
*/
const optional = async <T>(query: Promise<T>, fallback: T): Promise<T> => {
try {
return await query
} catch (e: any) {
if (e?.code === '42P01') return fallback
throw e
}
}
runScript(async ({pg}) => {
if (!USERNAME) throw new Error('Usage: npx tsx backend/scripts/member_context.ts <username>')
const me = await pg.oneOrNone<{id: string; name: string}>(
`select id, name from users where username ilike $1`,
[ME],
)
if (!me) throw new Error(`No user with username "${ME}"`)
const user = await pg.oneOrNone<any>(
`select u.id,
u.name,
u.username,
u.created_time,
u.is_banned_from_posting,
ua.last_online_time,
pu.data ->> 'email' as email
from users u
left join user_activity ua on ua.user_id = u.id
left join private_users pu on pu.id = u.id
where u.username ilike $1`,
[USERNAME],
)
if (!user) throw new Error(`No user with username "${USERNAME}"`)
const profile = await pg.oneOrNone<any>(`select * from profiles where user_id = $1`, [user.id])
const options = profile
? await pg.manyOrNone<{kind: string; name: string}>(
`select 'interest' as kind, i.name from profile_interests pi join interests i on i.id = pi.option_id where pi.profile_id = $1
union all
select 'cause', c.name from profile_causes pc join causes c on c.id = pc.option_id where pc.profile_id = $1
union all
select 'work', w.name from profile_work pw join work w on w.id = pw.option_id where pw.profile_id = $1
order by kind, name`,
[profile.id],
)
: []
const named = (kind: string) => options.filter((o) => o.kind === kind).map((o) => o.name)
// The prose answers, not the multiple-choice ones: these are where the specific observation that
// Contact #1 needs actually lives.
const promptAnswers = await pg.manyOrNone<{question: string; free_response: string | null}>(
`select cp.question, af.free_response
from compatibility_answers_free af
join compatibility_prompts cp on cp.id = af.question_id
where af.creator_id = $1
and coalesce(af.free_response, '') <> ''
order by af.created_time`,
[user.id],
)
const answerCount = await pg.one<{count: string}>(
`select count(*) from compatibility_answers where creator_id = $1`,
[user.id],
)
const searches = await pg.manyOrNone<any>(
`select id, search_name, search_filters, location, created_time, last_notified_at
from bookmarked_searches
where creator_id = $1
order by created_time desc`,
[user.id],
)
const outreach = await optional<any>(
pg.oneOrNone<any>(
`select stage, next_action, updated_time from outreach_contacts where user_id = $1`,
[user.id],
),
null,
)
const referred = await pg.manyOrNone<{name: string; username: string; created_time: string}>(
`select u.name, u.username, u.created_time
from profiles p
join users u on u.id = p.user_id
where p.referred_by_username = $1
order by u.created_time desc`,
[user.username],
)
// Engagement with other members. Counted, never read — and admin messages are excluded, because a
// founder DM is not the platform working and counting it would make every thread look successful.
const engagement = await pg.one<{sent: string; received: string; partners: string}>(
`select count(*) filter (where pm.user_id = $(id)) as sent,
count(*) filter (where pm.user_id <> $(id)) as received,
count(distinct other.user_id) as partners
from private_user_messages pm
join private_user_message_channel_members mine
on mine.channel_id = pm.channel_id and mine.user_id = $(id)
join private_user_message_channel_members other
on other.channel_id = pm.channel_id and other.user_id <> $(id)
where pm.visibility <> 'system_status'
and not coalesce(pm.deleted, false)
and other.user_id <> $(adminId)`,
{id: user.id, adminId: me.id},
)
const likes = await pg.one<{given: string; received: string}>(
`select (select count(*) from profile_likes where creator_id = $1) as given,
(select count(*) from profile_likes where target_id = $1) as received`,
[user.id],
)
// My own thread with them, decrypted — the one conversation I am entitled to read back.
const thread = await pg.map(
`select m.*
from private_user_messages m
join private_user_message_channel_members mine
on mine.channel_id = m.channel_id and mine.user_id = $(adminId)
join private_user_message_channel_members theirs
on theirs.channel_id = m.channel_id and theirs.user_id = $(id)
where m.visibility <> 'system_status'
and not coalesce(m.deleted, false)
order by m.created_time`,
{adminId: me.id, id: user.id},
convertPrivateChatMessage,
)
const density = await getLocalDensity(user.id, {pg})
const completeness = getProfileCompleteness({
bioLength: profile?.bio_length ?? null,
headline: profile?.headline ?? null,
photoCount: profile?.photo_urls?.length ?? 0,
pinnedUrl: profile?.pinned_url ?? null,
occupation: profile?.occupation ?? null,
educationLevel: profile?.education_level ?? null,
politicalBeliefs: profile?.political_beliefs ?? null,
diet: profile?.diet ?? null,
languages: profile?.languages ?? null,
city: profile?.city ?? null,
prefGender: profile?.pref_gender ?? null,
interestCount: named('interest').length,
causeCount: named('cause').length,
compatibilityAnswerCount: Number(answerCount.count),
hasBig5: profile?.big5_openness !== null && profile?.big5_openness !== undefined,
})
const daysSinceLastOnline = daysSince(user.last_online_time)
const repliedToMe = thread.some((m: any) => m.userId === user.id)
const tier = getOutreachTier({
completeness: completeness.score,
daysSinceLastOnline,
repliedToUs: repliedToMe,
savedSearchCount: searches.length,
})
const lastFromMe = [...thread].reverse().find((m: any) => m.userId === me.id)
const daysSinceMyLast = lastFromMe ? daysSince(new Date((lastFromMe as any).createdTime)) : null
const awaitingMyReply = thread.length > 0 && (thread[thread.length - 1] as any).userId === user.id
// The triggers, minus the profile-view one, which needs view logging that does not exist.
const alertFired = searches.some((s) => !!s.last_notified_at)
const triggers = [
alertFired && 'search alert fired',
Number(engagement.received) > 0 && 'got a reply from another member',
Number(engagement.sent) > 0 && 'wrote to another member',
density !== null && density.count < EMPTY_ROOM_MAX_NEARBY && 'empty room (few nearby)',
daysSinceLastOnline !== null &&
daysSinceLastOnline >= DORMANT_AFTER_DAYS &&
'dormant — gone quiet',
].filter(Boolean) as string[]
// Whether the local number is even their problem. Most members search far wider than their city,
// and quoting them a 322km count they never asked about invents a complaint they do not have.
const searchesLocally = searches.some((s) => !!s.location)
const out: string[] = [
`# ${user.name} (@${user.username})`,
'',
'## Where they stand',
'',
field('Tier', tier),
field(
'Profile completeness',
`${completeness.filled}/${completeness.total}` +
(completeness.missing.length ? ` — missing: ${completeness.missing.join(', ')}` : ''),
),
field('Outreach stage', outreach?.stage ?? 'not_started'),
field('Next action (my note)', outreach?.next_action),
field('Signed up', `${day(user.created_time)} (${daysSince(user.created_time)} days ago)`),
field(
'Last online',
user.last_online_time
? `${day(user.last_online_time)} (${daysSinceLastOnline} days ago)`
: '—',
),
field(
'Messages with me',
`${thread.length} total, they replied: ${repliedToMe ? 'yes' : 'no'}`,
),
field(
'Thread state',
thread.length === 0
? 'never contacted'
: awaitingMyReply
? '**they wrote last — I owe a reply**'
: `waiting on them for ${daysSinceMyLast} days`,
),
field('Triggers', triggers.length ? triggers.join('; ') : 'none'),
field(
`Members within ${OUTREACH_RADIUS_KM}km`,
density === null
? 'unknown — no city set'
: `${density.count}${density.city ? ` (${density.city})` : ''}` +
(density.nearby.length
? ` — nearest: ${density.nearby.map((n) => `${n.name} (@${n.username})`).join(', ')}`
: ''),
),
field(
'Searching locally?',
searchesLocally
? 'yes — a saved search has a location filter, so the local number is fair game'
: searches.length
? 'no location filter on any saved search — **do not lead with the local number**'
: 'unknown — no saved searches to read it off. Ask before assuming distance is their problem',
),
field('Banned', user.is_banned_from_posting ? 'yes' : 'no'),
field('Email', user.email),
'',
]
if (!profile) {
out.push('## Profile', '', '_No profile row._', '')
} else {
out.push(
'## Profile',
'',
field('Headline', profile.headline),
field('City', [profile.city, profile.country].filter(Boolean).join(', ')),
field(
'Raised in',
[profile.raised_in_city, profile.raised_in_country].filter(Boolean).join(', '),
),
field('Age / gender', [profile.age, profile.gender].filter(Boolean).join(' / ')),
field('Orientation', profile.orientation),
field('Relationship status', profile.relationship_status),
field('Looking for', profile.pref_gender),
field('Connection goal', profile.pref_relation_styles),
field(
'Age range wanted',
[profile.pref_age_min, profile.pref_age_max].filter((v) => v !== null).join(''),
),
field(
'Occupation',
[profile.occupation_title, profile.occupation, profile.company].filter(Boolean).join(' · '),
),
field('Education', [profile.education_level, profile.university].filter(Boolean).join(' · ')),
field('Languages', profile.languages),
field('Diet', profile.diet),
field(
'Politics',
[list(profile.political_beliefs), profile.political_details]
.filter((v) => v && v !== '—')
.join(' · '),
),
field('Religion', profile.religion),
field('Neurotype', profile.neurotype),
field('MBTI', profile.mbti),
field(
'Kids',
`has: ${list(profile.has_kids)}, wants (strength): ${list(profile.wants_kids_strength)}`,
),
field(
'Smoker / drinks per month',
`${list(profile.is_smoker)} / ${list(profile.drinks_per_month)}`,
),
field('Exercise', profile.exercise),
field('Photos', profile.photo_urls?.length ?? 0),
field('Links', profile.links ? JSON.stringify(profile.links) : '—'),
field('Keywords', profile.keywords),
field('Open to matches', profile.looking_for_matches ? 'yes' : 'no'),
field('Referred by', profile.referred_by_username),
field('Profile last edited', day(profile.last_modification_time)),
'',
'### Bio',
'',
profile.bio_text?.trim() || '_empty_',
'',
)
}
out.push(
'### Interests, causes, work',
'',
field('Interests', named('interest')),
field('Causes', named('cause')),
field('Work', named('work')),
'',
'## Prompt answers',
'',
)
if (!promptAnswers.length) out.push('_None written._', '')
for (const a of promptAnswers) {
out.push(`**${a.question}**`, '', a.free_response?.trim() ?? '', '')
}
out.push(field('Multiple-choice compatibility answers', Number(answerCount.count)), '')
out.push('## Saved searches', '')
if (!searches.length) {
out.push(
'_None._ Nothing to point at when Contact #3a says "your saved search is live" — setting one up for them is itself a give.',
'',
)
}
for (const s of searches) {
out.push(
`- **${s.search_name ?? `search ${s.id}`}** — created ${day(s.created_time)}, ` +
`last fired ${s.last_notified_at ? day(s.last_notified_at) : 'never'}`,
` - filters: \`${JSON.stringify(s.search_filters)}\``,
` - location: ${s.location ? `\`${JSON.stringify(s.location)}\`` : 'none — they are searching everywhere'}`,
)
}
out.push('')
out.push(
'## Referrals and engagement',
'',
field(
'People they brought',
referred.length
? referred.map((r) => `${r.name} (@${r.username}, ${day(r.created_time)})`)
: '—',
),
field('Messages to other members', engagement.sent),
field('Messages received from other members', engagement.received),
field('Distinct people messaged with (excluding me)', engagement.partners),
field('Likes given / received', `${likes.given} / ${likes.received}`),
'',
'## Conversation with me',
'',
)
if (!thread.length) out.push('_Never contacted._', '')
for (const m of thread) {
const text = richTextToString((m as any).content).trim()
if (!text) continue
const who = (m as any).userId === me.id ? me.name : user.name
out.push(`[${stamp((m as any).createdTime)}] ${who}: ${text}`, '')
}
const markdown = out.join('\n')
// Printed rather than logged: this is the artefact, not a progress message.
process.stdout.write(markdown + '\n')
if (OUT) {
writeFileSync(OUT, markdown)
debug(`[member_context] Wrote @${user.username} to ${chalk.yellow(OUT)}`)
}
})

View File

@@ -114,6 +114,8 @@ export type ProfileCompletenessInput = {
bioLength: number | null
headline: string | null
photoCount: number
/** A pinned image counts as their photo — it is what the profile actually shows. */
pinnedUrl?: string | null
occupation: string | null
educationLevel: string | null
politicalBeliefs: string[] | null
@@ -145,7 +147,7 @@ export const getProfileCompleteness = (p: ProfileCompletenessInput): ProfileComp
const checks: [string, boolean][] = [
['bio', (p.bioLength ?? 0) >= MIN_USEFUL_BIO_LENGTH],
['headline', !!p.headline],
['photo', p.photoCount > 0],
['photo', p.photoCount > 0 || !!p.pinnedUrl],
['occupation', !!p.occupation],
['education', !!p.educationLevel],
['politics', hasAny(p.politicalBeliefs)],

View File

@@ -59,6 +59,14 @@ describe('getProfileCompleteness', () => {
expect(result.score).toBeLessThan(1)
})
it('counts a pinned image as their photo', () => {
const noPhotos = {...fullProfile, photoCount: 0}
expect(getProfileCompleteness(noPhotos).missing).toEqual(['photo'])
expect(
getProfileCompleteness({...noPhotos, pinnedUrl: 'https://…/pinned.jpg'}).missing,
).toEqual([])
})
it('requires more than a token interest or cause', () => {
const result = getProfileCompleteness({...fullProfile, interestCount: 1, causeCount: 2})
expect(result.missing).toEqual(['interests', 'causes'])