diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index cfd6e26..263f20b 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -61,6 +61,7 @@ import { previewRoutes } from './routes/preview.js'; import { attachmentRoutes } from './routes/attachments.js'; import { chatRoutes } from './routes/chat.js'; import { moderationRoutes } from './routes/moderation.js'; +import { webhookRoutes } from './routes/webhooks.js'; import { samlRoutes } from './routes/saml.js'; import { internalRoutes } from './routes/internal.js'; @@ -203,6 +204,7 @@ export async function buildApp(opts: BuildAppOptions = {}): Promise if (!body || typeof body.id !== 'number' || typeof body.login !== 'string') { throw new GitHubApiError('GitHub /user returned unexpected shape', 'github_unreachable'); } + return toGitHubUser(body as Partial & { id: number; login: string }); +} + +function toGitHubUser(body: Partial & { id: number; login: string }): GitHubUser { return { id: body.id, login: body.login, name: typeof body.name === 'string' ? body.name : null, ...(typeof body.avatar_url === 'string' ? { avatar_url: body.avatar_url } : {}), + created_at: typeof body.created_at === 'string' ? body.created_at : null, + public_repos: typeof body.public_repos === 'number' ? body.public_repos : null, + followers: typeof body.followers === 'number' ? body.followers : null, + following: typeof body.following === 'number' ? body.following : null, + type: typeof body.type === 'string' ? body.type : null, }; } +/** + * Is the linked GitHub account still there? `GET /user/{id}` authenticated + * with the OAuth app's client credentials (5,000 req/h). GitHub answers 404 + * once it has deleted or suspended the account; that is the signal. Any other + * non-2xx throws so the caller keeps its previous record rather than + * misreading an outage as a verdict. + */ +export async function probeGitHubUser( + githubUserId: number, + clientId: string, + clientSecret: string, + opts: { readonly timeoutMs?: number } = {}, +): Promise { + const url = `https://api.github.com/user/${githubUserId}`; + const basic = Buffer.from(`${clientId}:${clientSecret}`).toString('base64'); + let res: Response; + try { + res = await fetch(url, { + method: 'GET', + headers: { + Authorization: `Basic ${basic}`, + Accept: 'application/vnd.github+json', + 'User-Agent': USER_AGENT, + }, + signal: AbortSignal.timeout(opts.timeoutMs ?? 4000), + }); + } catch (err) { + throw new GitHubApiError(`GitHub API transport error: ${url}`, 'github_unreachable', { cause: err }); + } + if (res.status === 404) return { status: 'gone', user: null }; + if (!res.ok) { + throw new GitHubApiError(`GitHub API ${url} returned ${res.status}`, 'github_unreachable', { + status: res.status, + }); + } + const body = (await res.json().catch(() => null)) as Partial | null; + if (!body || typeof body.id !== 'number' || typeof body.login !== 'string') { + throw new GitHubApiError('GitHub /user/{id} returned unexpected shape', 'github_unreachable'); + } + return { status: 'ok', user: toGitHubUser(body as Partial & { id: number; login: string }) }; +} + export async function fetchGitHubEmails(accessToken: string): Promise { const body = await ghGet(EMAILS_URL, accessToken); if (!Array.isArray(body)) { @@ -197,6 +262,12 @@ export interface ResolvedGitHubIdentity { readonly name: string | null; readonly emails: readonly GitHubEmail[]; readonly primaryEmail: string | null; + /** + * The full /user snapshot, kept so sign-in can record reputation facts. + * Absent when the identity was rebuilt from a claim-pending token rather + * than a live GitHub response. + */ + readonly user?: GitHubUser; } export function resolveIdentitySnapshot( @@ -211,5 +282,20 @@ export function resolveIdentitySnapshot( name: user.name, emails: verified, primaryEmail: primary?.email.toLowerCase() ?? null, + user, + }; +} + +/** Shape the reputation facts for the private profile (specs/behaviors/private-storage.md). */ +export function githubFactsFrom(user: GitHubUser, status: GitHubProbeStatus, checkedAt: string) { + return { + login: user.login, + accountCreatedAt: user.created_at, + publicRepos: user.public_repos, + followers: user.followers, + following: user.following, + type: user.type, + status, + checkedAt, }; } diff --git a/apps/api/src/env.ts b/apps/api/src/env.ts index 6c2908f..3986408 100644 --- a/apps/api/src/env.ts +++ b/apps/api/src/env.ts @@ -104,6 +104,12 @@ export const EnvSchema = z.object({ CFP_NOTIFICATION_FROM: z .string() .default('Code for Philly '), + /** + * Shared secret Postmark presents on the bounce webhook (basic-auth password + * or bearer token). Unset → POST /api/_webhooks/postmark/bounce answers 503. + * See specs/api/webhooks.md. + */ + POSTMARK_WEBHOOK_SECRET: z.string().min(16).optional(), }); export type Env = z.infer; @@ -144,6 +150,7 @@ export const envJsonSchema = { CFP_SITE_HOST: { type: 'string', default: 'codeforphilly.org' }, POSTMARK_SERVER_TOKEN: { type: 'string' }, POSTMARK_MESSAGE_STREAM: { type: 'string', default: 'outbound' }, + POSTMARK_WEBHOOK_SECRET: { type: 'string', minLength: 16 }, CFP_NOTIFICATION_FROM: { type: 'string', default: 'Code for Philly ', diff --git a/apps/api/src/plugins/services.ts b/apps/api/src/plugins/services.ts index 0b9da08..df4f8af 100644 --- a/apps/api/src/plugins/services.ts +++ b/apps/api/src/plugins/services.ts @@ -29,6 +29,7 @@ import { TagWriteService } from '../services/tag.write.js'; import { GitHubAccountService } from '../services/github-account.js'; import { AccountClaimService } from '../services/account-claim.js'; import { ModerationService, ModerationWriteService } from '../services/moderation.js'; +import { probeGitHubUser } from '../auth/github-client.js'; import { LoggingNotifier, type Notifier } from '../notify/index.js'; import { EmailNotifier } from '../notify/email-notifier.js'; import { PostmarkTransport } from '../notify/postmark-transport.js'; @@ -110,15 +111,36 @@ async function servicesPlugin(fastify: FastifyInstance): Promise { tagsWrite: new TagWriteService(state), githubAccount, accountClaim: new AccountClaimService(state, fastify.store.private, githubAccount), - moderation: new ModerationService(state, fastify.store.private, (personId) => { - // Newest sign-in from session metadata; the auth plugin decorates it - // after this one registers, so resolve lazily per call. - let latest: string | null = null; - for (const m of fastify.sessionMetadata?.getAll(personId) ?? []) { - if (!latest || m.issuedAt > latest) latest = m.issuedAt; - } - return latest; - }), + moderation: new ModerationService( + state, + fastify.store.private, + (personId) => { + // Session facts from session metadata; the auth plugin decorates it + // after this one registers, so resolve lazily per call. + let latest: string | null = null; + let count = 0; + for (const m of fastify.sessionMetadata?.getAll(personId) ?? []) { + count += 1; + if (!latest || m.issuedAt > latest) latest = m.issuedAt; + } + return { lastLoginAt: latest, count }; + }, + { + log: fastify.log, + // The roster re-checks linked GitHub accounts against the API using the + // OAuth app's client credentials; without them the probe is simply off. + ...(fastify.config.GITHUB_OAUTH_CLIENT_ID && fastify.config.GITHUB_OAUTH_CLIENT_SECRET + ? { + probe: (githubUserId: number) => + probeGitHubUser( + githubUserId, + fastify.config.GITHUB_OAUTH_CLIENT_ID as string, + fastify.config.GITHUB_OAUTH_CLIENT_SECRET as string, + ), + } + : {}), + }, + ), moderationWrite: new ModerationWriteService(state), }); } diff --git a/apps/api/src/routes/moderation.ts b/apps/api/src/routes/moderation.ts index ddf8d62..87335e6 100644 --- a/apps/api/src/routes/moderation.ts +++ b/apps/api/src/routes/moderation.ts @@ -13,7 +13,7 @@ import { ok, paginated } from '../lib/response.js'; import { ApiNotFoundError, ApiValidationError } from '../lib/errors.js'; import { getCallerSession } from '../services/permissions.js'; import { buildTransactionOptions } from '../store/commit-meta.js'; -import type { VoteFilter } from '../services/moderation.js'; +import type { MemberOrigin, VoteFilter } from '../services/moderation.js'; function requireStaffOr404(request: FastifyRequest): void { const level = request.session.accountLevel; @@ -34,6 +34,7 @@ export async function moderationRoutes(fastify: FastifyInstance): Promise properties: { q: { type: 'string' }, vote: { type: 'string', enum: ['none', 'spam', 'legit'] }, + origin: { type: 'string', enum: ['imported', 'signed-up'] }, joinedAfter: { type: 'string' }, joinedBefore: { type: 'string' }, includeDeactivated: { type: 'boolean' }, @@ -51,6 +52,7 @@ export async function moderationRoutes(fastify: FastifyInstance): Promise const result = await fastify.services.moderation.listMembers({ q: q['q'] as string | undefined, vote: q['vote'] as VoteFilter | undefined, + origin: q['origin'] as MemberOrigin | undefined, joinedAfter: q['joinedAfter'] as string | undefined, joinedBefore: q['joinedBefore'] as string | undefined, includeDeactivated: q['includeDeactivated'] as boolean | undefined, diff --git a/apps/api/src/routes/saml.ts b/apps/api/src/routes/saml.ts index 0f6ebf2..801b1cb 100644 --- a/apps/api/src/routes/saml.ts +++ b/apps/api/src/routes/saml.ts @@ -371,6 +371,8 @@ async function handleSpInitiatedSso( { relayState, customTagReplacement }, ); + await stampSlackSso(fastify, person.id); + const samlResponse = bindingCtx.context; const actionUrl = 'entityEndpoint' in bindingCtx && typeof bindingCtx.entityEndpoint === 'string' @@ -378,6 +380,7 @@ async function handleSpInitiatedSso( : acsUrl; const replyRelayState = 'relayState' in bindingCtx ? bindingCtx.relayState : relayState; + await stampSlackSso(fastify, person.id); return reply.header('Content-Type', 'text/html; charset=utf-8').send( renderPostForm({ actionUrl, @@ -387,6 +390,22 @@ async function handleSpInitiatedSso( ); } +/** + * Record that the IdP just vouched for this person to Slack. Best-effort: a + * private-store hiccup must not turn a successful assertion into an error. + * specs/api/saml.md → "Slack SSO stamp". + */ +async function stampSlackSso(fastify: FastifyInstance, personId: string): Promise { + try { + const profile = await fastify.store.private.getProfile(personId); + if (!profile) return; + const now = new Date().toISOString(); + await fastify.store.private.putProfile({ ...profile, lastSlackSsoAt: now, updatedAt: now }); + } catch (err) { + fastify.log.warn({ err, personId }, 'could not stamp lastSlackSsoAt'); + } +} + // --------------------------------------------------------------------------- // Routes // --------------------------------------------------------------------------- @@ -487,6 +506,7 @@ export async function samlRoutes(fastify: FastifyInstance): Promise { ); // PostBindingContext.context holds the base64-encoded signed Response. + await stampSlackSso(fastify, person.id); const samlResponse = bindingCtx.context; const relayState = 'relayState' in bindingCtx ? bindingCtx.relayState : query.redir; const actionUrl = @@ -640,6 +660,8 @@ export async function samlRoutes(fastify: FastifyInstance): Promise { { relayState: resumeClaims.relayState, customTagReplacement }, ); + await stampSlackSso(fastify, person.id); + const samlResponse = bindingCtx.context; const actionUrl = 'entityEndpoint' in bindingCtx && typeof bindingCtx.entityEndpoint === 'string' diff --git a/apps/api/src/routes/webhooks.ts b/apps/api/src/routes/webhooks.ts new file mode 100644 index 0000000..88c018d --- /dev/null +++ b/apps/api/src/routes/webhooks.ts @@ -0,0 +1,116 @@ +/** + * Inbound webhooks — specs/api/webhooks.md. + * + * POST /api/_webhooks/postmark/bounce + * Postmark's bounce webhook. Authenticated with POSTMARK_WEBHOOK_SECRET as + * either the basic-auth password or a bearer token. Records terminal + * bounce types on the member's private profile so the roster can show + * "mailbox dead" without any SMTP probing of our own. + */ +import { timingSafeEqual } from 'node:crypto'; +import type { FastifyInstance, FastifyRequest } from 'fastify'; +import { PrivateProfileSchema } from '@cfp/shared/schemas'; +import { ok, errorResponse } from '../lib/response.js'; +import { UnauthenticatedError } from '../lib/errors.js'; + +/** Bounce types that mean the mailbox is not going to work. */ +const TERMINAL_BOUNCE_TYPES = new Set([ + 'HardBounce', + 'SpamComplaint', + 'SpamNotification', + 'Blocked', + 'DnsError', + 'BadEmailAddress', + 'ManuallyDeactivated', + 'Unsubscribe', +]); + +function secretMatches(presented: string | undefined, expected: string): boolean { + if (!presented) return false; + const a = Buffer.from(presented); + const b = Buffer.from(expected); + return a.length === b.length && timingSafeEqual(a, b); +} + +function presentedSecret(request: FastifyRequest): string | undefined { + const header = request.headers['authorization']; + if (typeof header !== 'string') return undefined; + if (header.startsWith('Bearer ')) return header.slice('Bearer '.length).trim(); + if (header.startsWith('Basic ')) { + const decoded = Buffer.from(header.slice('Basic '.length).trim(), 'base64').toString('utf8'); + const colon = decoded.indexOf(':'); + return colon === -1 ? decoded : decoded.slice(colon + 1); + } + return undefined; +} + +interface PostmarkBouncePayload { + RecordType?: string; + Type?: string; + Email?: string; + BouncedAt?: string; + Description?: string; + Inactive?: boolean; +} + +export async function webhookRoutes(fastify: FastifyInstance): Promise { + fastify.post( + '/api/_webhooks/postmark/bounce', + { + schema: { + tags: ['webhooks'], + summary: 'Postmark bounce webhook', + body: { type: 'object', additionalProperties: true }, + }, + }, + async (request, reply) => { + const expected = fastify.config.POSTMARK_WEBHOOK_SECRET; + if (!expected) { + return reply.code(503).send( + errorResponse( + 'not_configured', + 'POSTMARK_WEBHOOK_SECRET is not set', + (request as FastifyRequest & { traceId?: string }).traceId, + ), + ); + } + if (!secretMatches(presentedSecret(request), expected)) { + throw new UnauthenticatedError('Invalid webhook credentials'); + } + + const body = request.body as PostmarkBouncePayload; + if (body.RecordType !== 'Bounce' && body.RecordType !== 'SpamComplaint') { + return ok({ ignored: true }); + } + const type = typeof body.Type === 'string' ? body.Type : body.RecordType; + if (!TERMINAL_BOUNCE_TYPES.has(type)) { + return ok({ ignored: true, type }); + } + const email = typeof body.Email === 'string' ? body.Email.trim().toLowerCase() : ''; + if (!email) return ok({ ignored: true, reason: 'no email' }); + + const personId = await fastify.store.private.findPersonIdByEmail(email); + if (!personId) return ok({ matched: false }); + const profile = await fastify.store.private.getProfile(personId); + if (!profile) return ok({ matched: false }); + + const bouncedAt = + typeof body.BouncedAt === 'string' && !Number.isNaN(Date.parse(body.BouncedAt)) + ? new Date(body.BouncedAt).toISOString() + : new Date().toISOString(); + const updated = PrivateProfileSchema.parse({ + ...profile, + emailBounce: { + type, + bouncedAt, + description: typeof body.Description === 'string' ? body.Description.slice(0, 500) : null, + ...(typeof body.Inactive === 'boolean' ? { inactive: body.Inactive } : {}), + }, + updatedAt: new Date().toISOString(), + }); + await fastify.store.private.putProfile(updated); + request.log.info({ personId, type }, 'postmark bounce recorded'); + return ok({ matched: true, recorded: true }); + }, + ); +} diff --git a/apps/api/src/services/github-account.ts b/apps/api/src/services/github-account.ts index 29c35d7..4c2f48f 100644 --- a/apps/api/src/services/github-account.ts +++ b/apps/api/src/services/github-account.ts @@ -24,7 +24,7 @@ import { isValidPersonSlug, slugify, } from '../lib/slug.js'; -import type { ResolvedGitHubIdentity } from '../auth/github-client.js'; +import { githubFactsFrom, type ResolvedGitHubIdentity } from '../auth/github-client.js'; const PERSON_SLUG_MAX = 50; @@ -107,6 +107,7 @@ export class GitHubAccountService { email: primaryEmail.toLowerCase(), emailRefreshedAt: now, newsletter: null, + github: identity.user ? githubFactsFrom(identity.user, 'ok', now) : null, updatedAt: now, }); @@ -162,6 +163,9 @@ export class GitHubAccountService { email: normalized, emailRefreshedAt: now, newsletter: currentProfile?.newsletter ?? null, + lastSlackSsoAt: currentProfile?.lastSlackSsoAt ?? null, + emailBounce: currentProfile?.emailBounce ?? null, + github: identity.user ? githubFactsFrom(identity.user, 'ok', now) : (currentProfile?.github ?? null), updatedAt: now, }); tx.private.putProfile(profile); @@ -171,6 +175,7 @@ export class GitHubAccountService { const profile: PrivateProfile = PrivateProfileSchema.parse({ ...currentProfile, emailRefreshedAt: now, + github: identity.user ? githubFactsFrom(identity.user, 'ok', now) : (currentProfile?.github ?? null), updatedAt: now, }); tx.private.putProfile(profile); diff --git a/apps/api/src/services/moderation.ts b/apps/api/src/services/moderation.ts index fc8de4c..971ad5d 100644 --- a/apps/api/src/services/moderation.ts +++ b/apps/api/src/services/moderation.ts @@ -22,6 +22,7 @@ import type { DualStoreTx } from '../store/store.js'; import { StateApply } from '../store/state-apply.js'; import type { SessionContext } from '../auth/middleware.js'; import { ApiNotFoundError, ApiValidationError } from '../lib/errors.js'; +import { githubFactsFrom, type GitHubProbeResult } from '../auth/github-client.js'; export type VoteVerdict = 'spam' | 'legit'; export type VoteFilter = 'none' | VoteVerdict; @@ -29,6 +30,7 @@ export type VoteFilter = 'none' | VoteVerdict; export interface MemberListOptions { readonly q?: string; readonly vote?: VoteFilter; + readonly origin?: MemberOrigin; readonly joinedAfter?: string; readonly joinedBefore?: string; readonly includeDeactivated?: boolean; @@ -58,6 +60,17 @@ export interface FootprintCounts { readonly tags: number; } +export type MemberOrigin = 'imported' | 'signed-up'; + +export interface MemberGitHub { + readonly login: string | null; + readonly accountCreatedAt: string | null; + readonly publicRepos: number | null; + readonly followers: number | null; + readonly status: 'ok' | 'gone' | 'unknown'; + readonly checkedAt: string | null; +} + export interface MemberRow { readonly id: string; readonly slug: string; @@ -65,12 +78,22 @@ export interface MemberRow { readonly avatarUrl: string | null; readonly createdAt: string; readonly deletedAt: string | null; + /** Imported from laddr (has a legacyId) or signed up on this site through GitHub. */ + readonly origin: MemberOrigin; readonly email: string | null; readonly hasGitHubLink: boolean; + readonly github: MemberGitHub | null; readonly lastLoginAt: string | null; + readonly signInCount: number; + readonly lastSlackSsoAt: string | null; + readonly emailBounce: { readonly type: string; readonly bouncedAt: string } | null; readonly bioExcerpt: string; readonly footprint: FootprintCounts; readonly latestVote: VoteView | null; + /** Row-local facts worth a glance; see `computeSignals`. */ + readonly signals: string[]; + /** Number of negative signals — drives the roster's attention tint. */ + readonly attention: number; } export interface ProjectRef { @@ -87,6 +110,13 @@ export interface MemberFootprint { readonly tags: Array<{ handle: string; type: string }>; } +interface AuthoredCounts { + updates: Map; + buzz: Map; + blogPosts: Map; + interest: Map; +} + export interface MemberListResult { readonly items: MemberRow[]; readonly totalItems: number; @@ -94,11 +124,91 @@ export interface MemberListResult { readonly perPage: number; } -/** Newest sign-in for a person, from session metadata; injected to avoid a plugin dependency. */ -export type LastLoginLookup = (personId: string) => string | null; +/** Sign-in facts for a person from session metadata; injected to avoid a plugin dependency. */ +export type SessionLookup = (personId: string) => { lastLoginAt: string | null; count: number }; + +/** + * Asks GitHub whether a linked account still exists. Injected so tests and + * deployments without an OAuth app never touch the network. + */ +export type GitHubProbe = (githubUserId: number) => Promise; + +/** How old a `github` record may be before the roster re-probes it. */ +const GITHUB_PROBE_TTL_MS = 24 * 60 * 60 * 1000; +const GITHUB_PROBE_CONCURRENCY = 5; +const GITHUB_NEW_ACCOUNT_DAYS = 30; const SORT_KEYS = new Set(['createdAt', 'fullName', 'lastLoginAt']); +const EMAIL_TOKEN_MIN = 3; + +/** + * Does the email's local part share a recognisable token with the display + * name? `stacey.villarreal@` vs "Stacey Villarreal" → true; `benjamin_cox8mzr@` + * vs "Stacey Villarreal" → false. Names shorter than three letters are ignored + * to avoid false matches on initials. + */ +export function emailMatchesName(email: string | null, fullName: string): boolean | null { + if (!email) return null; + const local = email.split('@')[0]?.toLowerCase().replace(/[^a-z]/g, '') ?? ''; + const tokens = fullName + .toLowerCase() + .split(/[^a-z]+/) + .filter((t) => t.length >= EMAIL_TOKEN_MIN); + if (!local || tokens.length === 0) return null; + return tokens.some((t) => local.includes(t)); +} + +export function countLinks(bio: string | null | undefined): number { + if (!bio) return 0; + return (bio.match(/https?:\/\/|www\.|\]\(| { + signals.push(s); + attention += 1; + }; + + if (input.signInCount === 0) negative('never-signed-in'); + if (!person.avatarKey) negative('no-avatar'); + if (!person.bio || person.bio.trim() === '') negative('no-bio'); + const links = countLinks(person.bio); + if (links > 0) negative(`bio-links:${links}`); + if (emailMatchesName(input.email, person.fullName) === false) negative('email-name-mismatch'); + if (input.emailBounce) negative(`email-bounced:${input.emailBounce.type}`); + if (input.github) { + if (input.github.status === 'gone') negative('github-gone'); + if (input.github.accountCreatedAt) { + const ageDays = (Date.now() - Date.parse(input.github.accountCreatedAt)) / 86_400_000; + if (ageDays < GITHUB_NEW_ACCOUNT_DAYS) negative('github-new-account'); + } + if ((input.github.publicRepos ?? 0) === 0 && (input.github.followers ?? 0) === 0) { + negative('github-no-activity'); + } + } + if (input.lastSlackSsoAt) signals.push('slack-sso'); + const fp = input.footprint; + if (fp.memberships + fp.updates + fp.buzz + fp.blogPosts + fp.helpWantedInterest > 0) signals.push('has-footprint'); + return { signals, attention }; +} + function parseSort(sort: string | undefined): { key: string; desc: boolean } | null { const raw = sort && sort.trim() !== '' ? sort.trim() : '-createdAt'; const desc = raw.startsWith('-'); @@ -126,12 +236,66 @@ function nowIso(): string { export class ModerationService { readonly #state: InMemoryState; readonly #privateStore: PrivateStore; - readonly #lastLogin: LastLoginLookup; - - constructor(state: InMemoryState, privateStore: PrivateStore, lastLogin: LastLoginLookup) { + readonly #sessions: SessionLookup; + readonly #probe: GitHubProbe | null; + readonly #log: { warn(obj: unknown, msg: string): void } | null; + + constructor( + state: InMemoryState, + privateStore: PrivateStore, + sessions: SessionLookup, + opts: { readonly probe?: GitHubProbe; readonly log?: { warn(obj: unknown, msg: string): void } } = {}, + ) { this.#state = state; this.#privateStore = privateStore; - this.#lastLogin = lastLogin; + this.#sessions = sessions; + this.#probe = opts.probe ?? null; + this.#log = opts.log ?? null; + } + + /** + * Refresh stale `github` records for the given people (bounded concurrency, + * best-effort). A probe failure keeps the old record; a 404 marks it gone. + */ + async refreshGitHubFacts(people: readonly Person[]): Promise { + if (!this.#probe) return; + const cutoff = Date.now() - GITHUB_PROBE_TTL_MS; + const stale: Person[] = []; + for (const p of people) { + if (typeof p.githubUserId !== 'number') continue; + const profile = await this.#privateStore.getProfile(p.id); + const checked = profile?.github?.checkedAt ? Date.parse(profile.github.checkedAt) : 0; + if (checked < cutoff) stale.push(p); + } + const queue = [...stale]; + const worker = async (): Promise => { + for (let p = queue.shift(); p; p = queue.shift()) { + try { + const result = await this.#probe!(p.githubUserId as number); + const profile = await this.#privateStore.getProfile(p.id); + if (!profile) continue; + const now = new Date().toISOString(); + const previous = profile.github ?? null; + const github = + result.status === 'ok' && result.user + ? githubFactsFrom(result.user, 'ok', now) + : { + login: previous?.login ?? p.githubLogin ?? '', + accountCreatedAt: previous?.accountCreatedAt ?? null, + publicRepos: previous?.publicRepos ?? null, + followers: previous?.followers ?? null, + following: previous?.following ?? null, + type: previous?.type ?? null, + status: 'gone' as const, + checkedAt: now, + }; + await this.#privateStore.putProfile({ ...profile, github, updatedAt: now }); + } catch (err) { + this.#log?.warn({ err, personId: p.id }, 'github probe failed; keeping previous record'); + } + } + }; + await Promise.all(Array.from({ length: Math.min(GITHUB_PROBE_CONCURRENCY, queue.length) }, worker)); } /** Every human vote on a person, newest first. */ @@ -161,6 +325,11 @@ export class ModerationService { let people = [...this.#state.people.values()]; if (!includeDeactivated) people = people.filter((p) => !p.deletedAt); + if (opts.origin) { + people = people.filter( + (p) => (typeof p.legacyId === 'number' ? 'imported' : 'signed-up') === opts.origin, + ); + } if (opts.joinedAfter) people = people.filter((p) => p.createdAt >= opts.joinedAfter!); if (opts.joinedBefore) people = people.filter((p) => p.createdAt <= opts.joinedBefore!); if (opts.vote) { @@ -172,7 +341,7 @@ export class ModerationService { } const authored = this.#authoredCounts(); - const lastLogins = new Map(); + const lastLogins = new Map(); const emails = new Map(); const emailOf = async (p: Person): Promise => { if (!emails.has(p.id)) emails.set(p.id, (await this.#privateStore.getProfile(p.id))?.email ?? null); @@ -195,10 +364,11 @@ export class ModerationService { people = matched; } - const lastLoginOf = (p: Person): string | null => { - if (!lastLogins.has(p.id)) lastLogins.set(p.id, this.#lastLogin(p.id)); - return lastLogins.get(p.id) ?? null; + const sessionsOf = (p: Person): { lastLoginAt: string | null; count: number } => { + if (!lastLogins.has(p.id)) lastLogins.set(p.id, this.#sessions(p.id)); + return lastLogins.get(p.id)!; }; + const lastLoginOf = (p: Person): string | null => sessionsOf(p).lastLoginAt; people.sort((a, b) => { let cmp: number; @@ -213,34 +383,86 @@ export class ModerationService { const perPage = Math.min(200, Math.max(1, opts.perPage ?? 50)); const slice = people.slice((page - 1) * perPage, page * perPage); + // Only the rows on this page get a (possibly stale) GitHub re-check. + await this.refreshGitHubFacts(slice); + const items: MemberRow[] = []; - for (const p of slice) { - items.push({ - id: p.id, - slug: p.slug, - fullName: p.fullName, - avatarUrl: p.avatarKey ? `/api/attachments/${p.avatarKey}` : null, - createdAt: p.createdAt, - deletedAt: p.deletedAt ?? null, - email: await emailOf(p), - hasGitHubLink: typeof p.githubUserId === 'number', - lastLoginAt: lastLoginOf(p), - bioExcerpt: bioExcerpt(p.bio), - footprint: { - memberships: this.#state.membershipsByPerson.get(p.id)?.size ?? 0, - updates: authored.updates.get(p.id) ?? 0, - buzz: authored.buzz.get(p.id) ?? 0, - blogPosts: authored.blogPosts.get(p.id) ?? 0, - helpWantedInterest: authored.interest.get(p.id) ?? 0, - tags: this.#state.tagAssignmentsByTaggable.get(p.id)?.size ?? 0, - }, - latestVote: this.latestHumanVote(p.slug), - }); - } + for (const p of slice) items.push(await this.#row(p, authored, sessionsOf(p))); return { items, totalItems: people.length, page, perPage }; } + /** The roster row for one person (used by the list and the detail endpoint). */ + async memberRow(slug: string): Promise { + const id = this.#state.personIdBySlug.get(slug); + const person = id ? this.#state.people.get(id) : undefined; + if (!person) return null; + await this.refreshGitHubFacts([person]); + return this.#row(person, this.#authoredCounts(), this.#sessions(person.id)); + } + + async #row( + p: Person, + authored: AuthoredCounts, + sessions: { lastLoginAt: string | null; count: number }, + ): Promise { + const profile = await this.#privateStore.getProfile(p.id); + const github: MemberGitHub | null = + typeof p.githubUserId === 'number' + ? { + login: profile?.github?.login ?? p.githubLogin ?? null, + accountCreatedAt: profile?.github?.accountCreatedAt ?? null, + publicRepos: profile?.github?.publicRepos ?? null, + followers: profile?.github?.followers ?? null, + status: profile?.github?.status ?? 'unknown', + checkedAt: profile?.github?.checkedAt ?? null, + } + : null; + const emailBounce = profile?.emailBounce + ? { type: profile.emailBounce.type, bouncedAt: profile.emailBounce.bouncedAt } + : null; + const footprint: FootprintCounts = { + memberships: this.#state.membershipsByPerson.get(p.id)?.size ?? 0, + updates: authored.updates.get(p.id) ?? 0, + buzz: authored.buzz.get(p.id) ?? 0, + blogPosts: authored.blogPosts.get(p.id) ?? 0, + helpWantedInterest: authored.interest.get(p.id) ?? 0, + tags: this.#state.tagAssignmentsByTaggable.get(p.id)?.size ?? 0, + }; + const email = profile?.email ?? null; + const lastSlackSsoAt = profile?.lastSlackSsoAt ?? null; + const { signals, attention } = computeSignals({ + person: p, + email, + signInCount: sessions.count, + github, + emailBounce, + lastSlackSsoAt, + footprint, + }); + return { + id: p.id, + slug: p.slug, + fullName: p.fullName, + avatarUrl: p.avatarKey ? `/api/attachments/${p.avatarKey}` : null, + createdAt: p.createdAt, + deletedAt: p.deletedAt ?? null, + origin: typeof p.legacyId === 'number' ? 'imported' : 'signed-up', + email, + hasGitHubLink: typeof p.githubUserId === 'number', + github, + lastLoginAt: sessions.lastLoginAt, + signInCount: sessions.count, + lastSlackSsoAt, + emailBounce, + bioExcerpt: bioExcerpt(p.bio), + footprint, + latestVote: this.latestHumanVote(p.slug), + signals, + attention, + }; + } + /** Full footprint for one member (deactivated included — this is moderation). */ footprint(slug: string): MemberFootprint | null { const id = this.#state.personIdBySlug.get(slug); @@ -311,12 +533,7 @@ export class ModerationService { }; } - #authoredCounts(): { - updates: Map; - buzz: Map; - blogPosts: Map; - interest: Map; - } { + #authoredCounts(): AuthoredCounts { const bump = (m: Map, k: string | null | undefined): void => { if (k) m.set(k, (m.get(k) ?? 0) + 1); }; diff --git a/apps/api/tests/moderation-signals.test.ts b/apps/api/tests/moderation-signals.test.ts new file mode 100644 index 0000000..c6e3d7b --- /dev/null +++ b/apps/api/tests/moderation-signals.test.ts @@ -0,0 +1,174 @@ +/** + * Row-local signals and the GitHub probe — specs/api/moderation.md "Field notes". + * + * - emailMatchesName / countLinks edge cases + * - computeSignals: the spam shape counts toward attention; positives don't + * - refreshGitHubFacts: probes only stale linked accounts, persists ok/gone, + * keeps the old record on failure, bounded by TTL + */ +import { describe, expect, it } from 'vitest'; +import { PersonSchema, type Person, type PrivateProfile } from '@cfp/shared/schemas'; +import type { PrivateStore } from '../src/store/private/interface.js'; +import { createEmptyState, indexPerson } from '../src/store/memory/state.js'; +import { + ModerationService, + computeSignals, + countLinks, + emailMatchesName, + type GitHubProbe, +} from '../src/services/moderation.js'; + +function person(overrides: Partial & { id: string; slug: string }): Person { + return PersonSchema.parse({ + fullName: `Test ${overrides.slug}`, + accountLevel: 'user', + createdAt: '2026-09-01T00:00:00Z', + updatedAt: '2026-09-01T00:00:00Z', + ...overrides, + }); +} + +/** Just enough of PrivateStore for the moderation service. */ +function fakePrivateStore(seed: PrivateProfile[]): PrivateStore & { profiles: Map } { + const profiles = new Map(seed.map((p) => [p.personId, p])); + return { + profiles, + getProfile: async (id: string) => profiles.get(id) ?? null, + putProfile: async (p: PrivateProfile) => { + profiles.set(p.personId, p); + }, + } as unknown as PrivateStore & { profiles: Map }; +} + +function profile(personId: string, extra: Partial = {}): PrivateProfile { + return { + personId, + email: 'someone@example.org', + emailRefreshedAt: '2026-09-01T00:00:00Z', + newsletter: null, + updatedAt: '2026-09-01T00:00:00Z', + ...extra, + }; +} + +const NO_FOOTPRINT = { memberships: 0, updates: 0, buzz: 0, blogPosts: 0, helpWantedInterest: 0, tags: 0 }; + +describe('emailMatchesName', () => { + it('matches when the local part contains a name token', () => { + expect(emailMatchesName('stacey.villarreal@mail.com', 'Stacey Villarreal')).toBe(true); + expect(emailMatchesName('jdoe@example.org', 'Jane Doe')).toBe(true); + }); + it('flags a local part unrelated to the name', () => { + expect(emailMatchesName('benjamin_cox8mzr@mail.com', 'Stacey Villarreal')).toBe(false); + }); + it('is undecided without an email or with only short name tokens', () => { + expect(emailMatchesName(null, 'Stacey Villarreal')).toBeNull(); + expect(emailMatchesName('x@example.org', 'JT Li')).toBeNull(); + }); +}); + +describe('countLinks', () => { + it('counts URLs and markdown/html links', () => { + expect(countLinks('Visit https://a.example and [me](https://b.example) or www.c.example')).toBe(4); + expect(countLinks('no links here')).toBe(0); + expect(countLinks(null)).toBe(0); + }); +}); + +describe('computeSignals', () => { + it('scores the throwaway shape and leaves an established member plain', () => { + const throwaway = computeSignals({ + person: person({ id: '01951a3c-0000-7000-8000-000000000101', slug: 'ceoviedeopu1972', fullName: 'Stacey Villarreal', bio: 'Buy https://x.example' }), + email: 'benjamin_cox8mzr@mail.com', + signInCount: 0, + github: null, + emailBounce: null, + lastSlackSsoAt: null, + footprint: NO_FOOTPRINT, + }); + expect(throwaway.signals).toEqual(['never-signed-in', 'no-avatar', 'bio-links:1', 'email-name-mismatch']); + expect(throwaway.attention).toBe(4); + + const established = computeSignals({ + person: person({ id: '01951a3c-0000-7000-8000-000000000102', slug: 'jane', fullName: 'Jane Doe', bio: 'Civic hacker', avatarKey: 'people/jane/avatar.jpg' }), + email: 'jane.doe@example.org', + signInCount: 12, + github: { login: 'janedoe', accountCreatedAt: '2015-01-01T00:00:00Z', publicRepos: 20, followers: 30, status: 'ok', checkedAt: '2026-09-18T00:00:00Z' }, + emailBounce: null, + lastSlackSsoAt: '2026-09-18T00:00:00Z', + footprint: { ...NO_FOOTPRINT, memberships: 2 }, + }); + expect(established.signals).toEqual(['slack-sso', 'has-footprint']); + expect(established.attention).toBe(0); + }); + + it('flags gone, new, and inactive GitHub accounts and bounced email', () => { + const r = computeSignals({ + person: person({ id: '01951a3c-0000-7000-8000-000000000103', slug: 'newgh', bio: 'hi', avatarKey: 'x' }), + email: 'newgh@example.org', + signInCount: 1, + github: { login: 'newgh', accountCreatedAt: new Date().toISOString(), publicRepos: 0, followers: 0, status: 'gone', checkedAt: '2026-09-18T00:00:00Z' }, + emailBounce: { type: 'HardBounce' }, + lastSlackSsoAt: null, + footprint: NO_FOOTPRINT, + }); + expect(r.signals).toEqual(['email-bounced:HardBounce', 'github-gone', 'github-new-account', 'github-no-activity']); + expect(r.attention).toBe(4); + }); +}); + +describe('ModerationService.refreshGitHubFacts', () => { + const LINKED = '01951a3c-0000-7000-8000-000000000201'; + const STALE = '01951a3c-0000-7000-8000-000000000202'; + const UNLINKED = '01951a3c-0000-7000-8000-000000000203'; + const FRESH = '01951a3c-0000-7000-8000-000000000204'; + + function setup(probe: GitHubProbe) { + const state = createEmptyState(); + const people = [ + person({ id: LINKED, slug: 'linked', githubUserId: 1001, githubLogin: 'linked' }), + person({ id: STALE, slug: 'stale', githubUserId: 1002, githubLogin: 'stale' }), + person({ id: UNLINKED, slug: 'unlinked' }), + person({ id: FRESH, slug: 'fresh', githubUserId: 1004, githubLogin: 'fresh' }), + ]; + for (const p of people) indexPerson(state, p); + const store = fakePrivateStore([ + profile(LINKED), + profile(STALE, { + github: { login: 'stale', accountCreatedAt: '2020-01-01T00:00:00Z', publicRepos: 3, followers: 1, following: 0, type: 'User', status: 'ok', checkedAt: '2026-09-01T00:00:00Z' }, + }), + profile(UNLINKED), + profile(FRESH, { + github: { login: 'fresh', accountCreatedAt: '2020-01-01T00:00:00Z', publicRepos: 3, followers: 1, following: 0, type: 'User', status: 'ok', checkedAt: new Date().toISOString() }, + }), + ]); + const service = new ModerationService(state, store, () => ({ lastLoginAt: null, count: 0 }), { probe }); + return { state, store, service, people }; + } + + it('probes only stale linked accounts and persists ok / gone', async () => { + const probed: number[] = []; + const { store, service, people } = setup(async (id) => { + probed.push(id); + if (id === 1002) return { status: 'gone', user: null }; + return { + status: 'ok', + user: { id, login: `gh${id}`, name: null, created_at: '2018-05-05T00:00:00Z', public_repos: 7, followers: 2, following: 1, type: 'User' }, + }; + }); + await service.refreshGitHubFacts(people); + expect(probed.sort()).toEqual([1001, 1002]); // not 1003 (unlinked), not 1004 (fresh) + expect(store.profiles.get(LINKED)?.github).toMatchObject({ login: 'gh1001', status: 'ok', publicRepos: 7, accountCreatedAt: '2018-05-05T00:00:00Z' }); + expect(store.profiles.get(STALE)?.github).toMatchObject({ login: 'stale', status: 'gone', publicRepos: 3 }); + expect(store.profiles.get(FRESH)?.github?.status).toBe('ok'); + }); + + it('keeps the previous record when the probe fails', async () => { + const { store, service, people } = setup(async () => { + throw new Error('rate limited'); + }); + await service.refreshGitHubFacts(people); + expect(store.profiles.get(STALE)?.github?.checkedAt).toBe('2026-09-01T00:00:00Z'); + expect(store.profiles.get(LINKED)?.github ?? null).toBeNull(); + }); +}); diff --git a/apps/api/tests/saml.test.ts b/apps/api/tests/saml.test.ts index bdfc014..698b64c 100644 --- a/apps/api/tests/saml.test.ts +++ b/apps/api/tests/saml.test.ts @@ -259,6 +259,20 @@ describe('SAML IdP — Slack', () => { expect(loginReturnOf(res.headers.location)).toBe('/api/saml/slack/launch?channel=general'); }); + it('GET /api/saml/slack/launch (signed-in) stamps lastSlackSsoAt on the private profile', async () => { + const { accessToken } = await mintSessionFor(personId, 'user', JWT_KEY); + const before = await app.store.private.getProfile(personId); + expect(before?.lastSlackSsoAt ?? null).toBeNull(); + const res = await app.inject({ + method: 'GET', + url: '/api/saml/slack/launch', + cookies: { cfp_session: accessToken }, + }); + expect(res.statusCode).toBe(200); + const after = await app.store.private.getProfile(personId); + expect(typeof after?.lastSlackSsoAt).toBe('string'); + }); + it('GET /api/saml/slack/launch (signed-in) returns auto-submit form with signed SAML response', async () => { const { accessToken } = await mintSessionFor(personId, 'user', JWT_KEY); const res = await app.inject({ diff --git a/apps/api/tests/webhooks.test.ts b/apps/api/tests/webhooks.test.ts new file mode 100644 index 0000000..3c7a3ef --- /dev/null +++ b/apps/api/tests/webhooks.test.ts @@ -0,0 +1,136 @@ +/** + * POST /api/_webhooks/postmark/bounce — specs/api/webhooks.md. + * + * - 503 when POSTMARK_WEBHOOK_SECRET is unset + * - 401 with a wrong secret (bearer or basic) + * - terminal bounce for a known address → profile.emailBounce recorded + * - transient bounce → acknowledged, nothing recorded + * - unknown address → 200 matched:false + */ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import type { FastifyInstance } from 'fastify'; +import { writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { buildApp } from '../src/app.js'; +import { createFullDataRepo, createPrivateStorageDir } from './helpers/test-full-repo.js'; +import { seedRawToml } from './helpers/seed-fixtures.js'; + +const SECRET = 'postmark-webhook-secret-for-tests!'; +const PERSON_ID = '01951a3c-0000-7000-8000-0000000000c1'; + +describe('Postmark bounce webhook', () => { + let dataRepo: { path: string; cleanup: () => Promise }; + let privateStore: { path: string; cleanup: () => Promise }; + let app: FastifyInstance; + let unconfigured: FastifyInstance; + + beforeAll(async () => { + dataRepo = await createFullDataRepo(); + privateStore = await createPrivateStorageDir(); + await seedRawToml( + dataRepo.path, + 'people/bouncy.toml', + [ + `id = "${PERSON_ID}"`, + 'slug = "bouncy"', + 'fullName = "Bouncy Person"', + 'accountLevel = "user"', + 'createdAt = "2026-05-01T00:00:00Z"', + 'updatedAt = "2026-05-01T00:00:00Z"', + ].join('\n'), + 'seed bouncy', + ); + await writeFile( + join(privateStore.path, 'profiles.jsonl'), + JSON.stringify({ + personId: PERSON_ID, + email: 'bouncy@example.org', + emailRefreshedAt: '2026-05-01T00:00:00.000Z', + newsletter: null, + updatedAt: '2026-05-01T00:00:00.000Z', + }) + '\n', + ); + const env = { + CFP_DATA_REPO_PATH: dataRepo.path, + STORAGE_BACKEND: 'filesystem', + CFP_PRIVATE_STORAGE_PATH: privateStore.path, + CFP_JWT_SIGNING_KEY: 'test-jwt-signing-key-at-least-32-chars!!', + NODE_ENV: 'test', + }; + app = await buildApp({ serverOptions: { logger: false }, overrideEnv: { ...env, POSTMARK_WEBHOOK_SECRET: SECRET } }); + unconfigured = await buildApp({ serverOptions: { logger: false }, overrideEnv: env }); + }, 60_000); + + afterAll(async () => { + await app.close(); + await unconfigured.close(); + await dataRepo.cleanup(); + await privateStore.cleanup(); + }); + + const bounce = (overrides: Record = {}) => ({ + RecordType: 'Bounce', + Type: 'HardBounce', + Email: 'Bouncy@Example.org', + BouncedAt: '2026-09-18T12:00:00Z', + Description: 'The server was unable to deliver your message', + Inactive: true, + ...overrides, + }); + + it('503s when the secret is not configured', async () => { + const res = await unconfigured.inject({ + method: 'POST', + url: '/api/_webhooks/postmark/bounce', + payload: bounce(), + headers: { authorization: `Bearer ${SECRET}` }, + }); + expect(res.statusCode).toBe(503); + }); + + it('401s on a wrong secret, bearer or basic', async () => { + for (const authorization of ['Bearer nope', `Basic ${Buffer.from('postmark:nope').toString('base64')}`, '']) { + const res = await app.inject({ + method: 'POST', + url: '/api/_webhooks/postmark/bounce', + payload: bounce(), + headers: authorization ? { authorization } : {}, + }); + expect(res.statusCode).toBe(401); + } + }); + + it('records a terminal bounce on the matching profile (basic auth, case-insensitive email)', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/_webhooks/postmark/bounce', + payload: bounce(), + headers: { authorization: `Basic ${Buffer.from(`postmark:${SECRET}`).toString('base64')}` }, + }); + expect(res.statusCode).toBe(200); + expect(res.json<{ data: { matched: boolean; recorded: boolean } }>().data).toEqual({ matched: true, recorded: true }); + const profile = await app.store.private.getProfile(PERSON_ID); + expect(profile?.emailBounce).toMatchObject({ type: 'HardBounce', bouncedAt: '2026-09-18T12:00:00.000Z', inactive: true }); + }); + + it('acknowledges but ignores transient bounces and unknown addresses', async () => { + const transient = await app.inject({ + method: 'POST', + url: '/api/_webhooks/postmark/bounce', + payload: bounce({ Type: 'Transient', Email: 'someone-else@example.org' }), + headers: { authorization: `Bearer ${SECRET}` }, + }); + expect(transient.statusCode).toBe(200); + expect(transient.json<{ data: { ignored: boolean } }>().data.ignored).toBe(true); + + const unknown = await app.inject({ + method: 'POST', + url: '/api/_webhooks/postmark/bounce', + payload: bounce({ Email: 'nobody@example.org' }), + headers: { authorization: `Bearer ${SECRET}` }, + }); + expect(unknown.statusCode).toBe(200); + expect(unknown.json<{ data: { matched: boolean } }>().data.matched).toBe(false); + }); +}); diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 175a875..c418bfe 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -591,6 +591,17 @@ export interface VoteView { readonly evaluatedAt: string; } +export type MemberOrigin = 'imported' | 'signed-up'; + +export interface MemberGitHub { + readonly login: string | null; + readonly accountCreatedAt: string | null; + readonly publicRepos: number | null; + readonly followers: number | null; + readonly status: 'ok' | 'gone' | 'unknown'; + readonly checkedAt: string | null; +} + export interface MemberRow { readonly id: string; readonly slug: string; @@ -598,9 +609,14 @@ export interface MemberRow { readonly avatarUrl: string | null; readonly createdAt: string; readonly deletedAt: string | null; + readonly origin: MemberOrigin; readonly email: string | null; readonly hasGitHubLink: boolean; + readonly github: MemberGitHub | null; readonly lastLoginAt: string | null; + readonly signInCount: number; + readonly lastSlackSsoAt: string | null; + readonly emailBounce: { readonly type: string; readonly bouncedAt: string } | null; readonly bioExcerpt: string; readonly footprint: { readonly memberships: number; @@ -611,11 +627,14 @@ export interface MemberRow { readonly tags: number; }; readonly latestVote: VoteView | null; + readonly signals: string[]; + readonly attention: number; } export interface MemberListParams { q?: string; vote?: 'none' | VoteVerdict; + origin?: MemberOrigin; joinedAfter?: string; joinedBefore?: string; includeDeactivated?: boolean; diff --git a/apps/web/src/pages/AdminMembers.tsx b/apps/web/src/pages/AdminMembers.tsx index bd97655..47ba317 100644 --- a/apps/web/src/pages/AdminMembers.tsx +++ b/apps/web/src/pages/AdminMembers.tsx @@ -1,8 +1,8 @@ /** - * /admin/members — staff roster with footprint and human spam votes. + * /admin/members — staff roster with footprint, signals, and human spam votes. * Per specs/screens/admin-members.md. */ -import { useEffect, useState, type FormEvent } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState, type FormEvent } from 'react'; import { Link, useNavigate, useParams, useSearchParams } from 'react-router'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; @@ -20,6 +20,86 @@ function isStaff(level: string | undefined): boolean { return level === 'staff' || level === 'administrator'; } +type Tone = 'neutral' | 'good' | 'warn' | 'bad'; + +const TONE_CLASS: Record = { + neutral: 'bg-muted text-muted-foreground', + good: 'bg-emerald-100 text-emerald-800', + warn: 'bg-amber-100 text-amber-900', + bad: 'bg-destructive/10 text-destructive', +}; + +function Chip({ tone = 'neutral', title, children, href }: { tone?: Tone; title?: string; children: React.ReactNode; href?: string }) { + const cls = `inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs font-medium ${TONE_CLASS[tone]}`; + if (href) { + return ( + + {children} + + ); + } + return ( + + {children} + + ); +} + +function accountAge(iso: string): string { + const days = Math.floor((Date.now() - Date.parse(iso)) / 86_400_000); + if (days < 1) return 'today'; + if (days < 30) return `${days}d`; + if (days < 365) return `${Math.floor(days / 30)}mo`; + return `${Math.floor(days / 365)}y`; +} + +/** The badges for a row, from origin/github/slack/bounce and the row-local signals. */ +function Badges({ row }: { row: MemberRow }) { + const has = (s: string) => row.signals.includes(s); + const links = row.signals.find((s) => s.startsWith('bio-links:'))?.split(':')[1]; + const bounce = row.emailBounce; + const gh = row.github; + return ( +
+ {row.origin === 'signed-up' ? Signed up here : Imported} + {gh && gh.status === 'gone' && ( + + GitHub account gone + + )} + {gh && gh.status !== 'gone' && ( + + GitHub{gh.login ? ` · @${gh.login}` : ''} + {gh.accountCreatedAt ? ` · ${accountAge(gh.accountCreatedAt)} old` : ''} + {gh.publicRepos !== null ? ` · ${gh.publicRepos} repos` : ''} + {gh.followers !== null ? ` · ${gh.followers} followers` : ''} + + )} + {row.lastSlackSsoAt && Slack · {formatRelativeTime(row.lastSlackSsoAt)}} + {bounce && ( + + Email bounced · {bounce.type} + + )} + {has('email-name-mismatch') && email ≠ name} + {links && {links} link{links === '1' ? '' : 's'} in bio} + {has('no-bio') && no bio} + {has('no-avatar') && no avatar} +
+ ); +} + +function rowTint(row: MemberRow): string { + const critical = row.signals.some((s) => s === 'github-gone' || s.startsWith('email-bounced')); + if (critical || row.attention >= 5) return 'border-l-4 border-l-destructive'; + if (row.attention >= 3) return 'border-l-4 border-l-amber-500'; + return 'border-l-4 border-l-transparent'; +} + function VoteBadge({ vote, hiddenByVote }: { vote: VoteView; hiddenByVote: boolean }) { const spam = vote.verdict === 'spam'; return ( @@ -38,10 +118,15 @@ function VoteBadge({ vote, hiddenByVote }: { vote: VoteView; hiddenByVote: boole function VoteButtons({ slug, disabled, + pending, + onPendingHandled, onDone, }: { slug: string; disabled: boolean; + /** A verdict requested from the keyboard; the component opens the confirm (spam) or fires (legit). */ + pending: VoteVerdict | null; + onPendingHandled: () => void; onDone: () => Promise; }) { const [confirming, setConfirming] = useState(null); @@ -58,15 +143,32 @@ function VoteButtons({ onError: (err) => toast.error(err instanceof ApiError ? err.message : 'Vote failed'), }); - if (confirming) { + // Keyboard `n` fires immediately; keyboard `s` is rendered as the open + // confirm below (derived, not stored) until the staffer confirms or cancels. + useEffect(() => { + if (pending === 'legit' && !disabled) { + mutation.mutate({ verdict: 'legit', why: '' }); + onPendingHandled(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [pending]); + + const active = confirming ?? (pending === 'spam' && !disabled ? 'spam' : null); + + if (active) { const submit = (e: FormEvent) => { e.preventDefault(); - mutation.mutate({ verdict: confirming, why: reasoning }); + mutation.mutate({ verdict: active, why: reasoning }); + onPendingHandled(); + }; + const cancel = () => { + setConfirming(null); + onPendingHandled(); }; return (