diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..0021879 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,106 @@ +# Publish the release to npm. +# +# The release is the trigger, so there is no separate step to forget: a channel +# that only updates when somebody remembers is a channel that silently serves an +# old version forever. `workflow_dispatch` is for re-running one that failed on +# something transient, and is safe because a version already on the registry is +# skipped rather than attempted. +# +# Authenticates with an npm automation token in the repository secret +# `NPM_TOKEN`. `registry-url` is what makes setup-node write the token into +# .npmrc; it is required for token auth and fatal under trusted publishing. +name: publish + +on: + release: + types: [published] + workflow_dispatch: + +permissions: + contents: read + id-token: write + +jobs: + publish: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 24 + registry-url: https://registry.npmjs.org + + # The Postgres half of the suite runs in test.yml; a release reruns the + # rest (libSQL in memory, rendering, validation) against the exact tree. + - run: npm ci --no-audit --no-fund + - run: npm test + env: + CI: true + + - name: Read the version being published + id: version + run: | + VERSION="$(node -p "require('./package.json').version")" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "@profullstack/profile@$VERSION" + + - name: Check the tag matches package.json + if: github.event_name == 'release' + env: + TAG: ${{ github.event.release.tag_name }} + VERSION: ${{ steps.version.outputs.version }} + run: | + if [ "$TAG" != "v$VERSION" ]; then + echo "::error::release tag $TAG does not match package.json version $VERSION" + exit 1 + fi + + - name: Skip if this version is already on npm + id: published + env: + VERSION: ${{ steps.version.outputs.version }} + run: | + if npm view "@profullstack/profile@$VERSION" version >/dev/null 2>&1; then + echo "already=true" >> "$GITHUB_OUTPUT" + echo "@profullstack/profile@$VERSION is already published — nothing to do" + else + echo "already=false" >> "$GITHUB_OUTPUT" + fi + + - name: Require an npm token + if: steps.published.outputs.already == 'false' + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + if [ -z "$NPM_TOKEN" ]; then + echo "::error::NPM_TOKEN is not set — add an npm automation token as a repository secret named NPM_TOKEN" + exit 1 + fi + + - name: Publish + if: steps.published.outputs.already == 'false' + run: npm publish --access public --provenance + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + # A brand-new scoped package 404s on every read path for minutes after a + # successful publish, so a short window here fails a release that worked. + # Wait longer, and never fail the run on the read alone: `npm publish` + # exiting 0 is the authority on whether it published. + - name: Confirm the registry has it + if: steps.published.outputs.already == 'false' + continue-on-error: true + env: + VERSION: ${{ steps.version.outputs.version }} + run: | + for _ in $(seq 1 30); do + if [ "$(npm view "@profullstack/profile@$VERSION" version 2>/dev/null)" = "$VERSION" ]; then + echo "@profullstack/profile@$VERSION is on the registry" + exit 0 + fi + sleep 10 + done + echo "::warning::published, but the registry does not report @profullstack/profile@$VERSION yet (first publishes can lag several minutes)" + diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..c3e0571 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,34 @@ +name: test + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + timeout-minutes: 10 + # The store's whole point is behaving the same on both databases, so the + # Postgres half runs against a real one, not a mock. + services: + postgres: + image: postgres:17-alpine + env: + POSTGRES_PASSWORD: test + POSTGRES_DB: profile_test + ports: ["5432:5432"] + options: >- + --health-cmd "pg_isready -U postgres" --health-interval 2s --health-timeout 2s --health-retries 20 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + - run: npm ci --no-audit --no-fund + - run: npm test + env: + TEST_DATABASE_URL: postgres://postgres:test@127.0.0.1:5432/profile_test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..21d1c1c --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules +*.db diff --git a/README.md b/README.md new file mode 100644 index 0000000..d1406c4 --- /dev/null +++ b/README.md @@ -0,0 +1,154 @@ +# @profullstack/profile + +One user profile for every Profullstack property. Six fields, in this order, everywhere: + +| column | what it holds | example | +| --- | --- | --- | +| `email` | the contact address, lower-cased | `ada@acme.com` | +| `first_name` | as typed, case kept | `Ada` | +| `last_name` | as typed, case kept | `Lovelace` | +| `company_domain` | bare domain, no scheme or `www.` | `acme.com` | +| `job_title` | as typed, whitespace cleaned | `VP Engineering` | +| `linkedin_url` | canonical profile URL | `https://www.linkedin.com/in/ada-lovelace` | + +The order is the CSV column order Explee, Apollo and cli-tools `user-export --full` read, so an export from any property imports anywhere without remapping. + +What the package gives a property: + +- **Validation and normalization.** `acme.com/about`, `https://www.Acme.com` and `@acme.com` all become `acme.com`. Any LinkedIn URL, `in/x` or bare handle becomes the canonical URL. A link in a name box is refused as spam. Names are never "corrected" (McDonald stays McDonald). +- **Company from a work email.** A blank company domain is filled from the signup email unless it is a personal or relay mailbox (gmail, proton, passmail and so on). +- **Signup form fields.** As an HTML string for server-rendered pages, and as a React component, with the right `autocomplete` tokens so browsers fill them. +- **Storage on whatever the property already runs:** postgres.js, node-postgres, libSQL/Turso, or supabase-js on the server. Either its own `user_profiles` table, or the columns added to an existing `profiles`/`users` table. +- **`GET`/`PUT`/`PATCH /api/v1/me/profile`** handlers on Web-standard Request/Response, for Next and Hono alike. +- **A CLI and an MCP server** (`profile`), including `profile export` in contract order. + +```sh +npm i @profullstack/profile +``` + +It has no dependencies. `postgres`, `@libsql/client` and `react` are optional peers; the property already has whichever it uses. + +## Required fields + +By default a signup must give `email`, `first_name` and `last_name`; the other three are offered and marked optional, because every required box costs signups. A property that sells to companies passes `LEAD_REQUIRED` (adds `company_domain` and `job_title`, the fields an outbound tool will not import a lead without). + +```js +import { validateProfile, LEAD_REQUIRED } from "@profullstack/profile"; +validateProfile(form, { required: LEAD_REQUIRED }); +``` + +## Adopting it in a property + +1. **Schema.** Call `migrate()` on boot or in the migration runner. It creates `user_profiles`, or adds only the missing columns to the table you name. Postgres takes an advisory lock, so concurrent boots are safe. On Supabase (no DDL through supabase-js), put `profile schema` output in a migration file. +2. **Signup form.** Add the fields beside the email box. +3. **Signup handler.** Validate before creating the user, save after. +4. **Profile page.** Mount the handlers and let people fill in what they skipped. +5. **Backfill.** Copy any name or company columns the property already has, using `fillBlanks` so nothing typed is overwritten. + +### Next on Postgres (postgres.js) + +```js +// lib/profile.js +import sql from "@/lib/db"; +import { createProfileStore } from "@profullstack/profile"; +export const profiles = createProfileStore(sql); // or { table: "profiles", key: "id" } +``` + +```jsx +// app/signup/page.jsx (a server component posting to a route handler or a server action) +import { ProfileFields } from "@profullstack/profile/react"; + +
+ + + +``` + +```js +// the signup action / POST handler +import { validateProfile, saveSignupProfile } from "@profullstack/profile"; + +const check = validateProfile(formData); +if (!check.ok) return { errors: check.errors }; +const user = await createUserAndSendMagicLink(check.profile.email); +await saveSignupProfile(profiles, user.id, formData, { email: user.email }); +``` + +```js +// app/api/v1/me/profile/route.js +import { createProfileHandlers } from "@profullstack/profile"; +import { profiles } from "@/lib/profile"; +import { currentUser } from "@/lib/session"; + +export const { GET, PUT, PATCH } = createProfileHandlers({ + store: profiles, + getUser: (req) => currentUser(req), // { id, email } or null +}); +``` + +### Hono on Turso + +```js +import { createClient } from "@libsql/client"; +import { createProfileStore, mountProfileRoutes, renderProfileFields, PROFILE_ENHANCE_SCRIPT } from "@profullstack/profile"; + +const profiles = createProfileStore(createClient({ url, authToken })); +await profiles.migrate(); +mountProfileRoutes(app, { store: profiles, getUser: (req) => sessionUser(req) }); + +app.get("/signup", (c) => + c.html(`
+ ${renderProfileFields()} + +
`), +); +``` + +### Supabase + +Browsers talk only to `/api`, so the store runs on the server with the service client: + +```js +const profiles = createProfileStore(supabaseAdmin, { table: "profiles", key: "id" }); +``` + +```sh +npx @profullstack/profile schema --table profiles --key id > supabase/migrations/20261026000000_profile_fields.sql +``` + +(For an existing table, write the `alter table ... add column if not exists` lines yourself, or run `profile migrate` with `DATABASE_URL` pointing at the database.) + +## Store API + +```js +const profiles = createProfileStore(db, { table = "user_profiles", key = "user_id" }); +await profiles.migrate(); // { created, added: [columns] } +await profiles.save(userId, { job_title: "CTO" }); // only given fields; null clears +await profiles.fillBlanks(userId, enriched); // never overwrites what a person typed +await profiles.get(userId); await profiles.getByEmail(email); +await profiles.list({ limit, after, since }); // keyset pages: { profiles, next } +for await (const p of profiles.all()) { ... } +await profiles.remove(userId); +``` + +There is no foreign key to a users table: the host's may be named anything or live elsewhere. Deleting a user is the host's job, so call `remove()` there. + +## CLI and MCP + +```sh +export DATABASE_URL=postgres://... # or libsql://..., file:app.db (TURSO_AUTH_TOKEN for Turso) +profile migrate +profile get +profile find ada@acme.com +profile set --job-title CTO --linkedin in/ada-lovelace +profile check --company-domain https://www.acme.com/about +profile export --complete > leads.csv # only rows with every lead field +profile export --format tsv --since 2026-10-01 +profile mcp # tools: get_profile, update_profile, validate_profile, export_profiles +``` + +`--table` and `--key` (or `PROFILE_TABLE`, `PROFILE_KEY`) point it at an existing table. + +## License + +MIT diff --git a/index.d.ts b/index.d.ts new file mode 100644 index 0000000..fdfa704 --- /dev/null +++ b/index.d.ts @@ -0,0 +1,159 @@ +export type ProfileField = + | "email" + | "first_name" + | "last_name" + | "company_domain" + | "job_title" + | "linkedin_url"; + +export type ProfileFields = { [K in ProfileField]: string | null }; + +export interface Profile extends ProfileFields { + user_id: string; + created_at: string | null; + updated_at: string | null; +} + +export interface FieldMeta { + name: ProfileField; + label: string; + type: "email" | "text" | "url"; + autocomplete: string; + inputmode?: string; + maxLength: number; + placeholder?: string; + hint?: string; +} + +export const COLUMNS: readonly ProfileField[]; +export const FIELDS: readonly FieldMeta[]; +export const FIELD_BY_NAME: Readonly>; +export const DEFAULT_REQUIRED: readonly ProfileField[]; +export const LEAD_REQUIRED: readonly ProfileField[]; + +export class ProfileFieldError extends Error { + field: ProfileField; +} +export class ProfileValidationError extends Error { + status: 400; + errors: Partial>; +} + +export const NORMALIZERS: Readonly string | null>>; +export const FREEMAIL: Set; +export function isFreemail(domain: string | null | undefined): boolean; +export function cleanText(v: unknown): string; +export function normalizeEmail(v: unknown): string | null; +export function normalizeFirstName(v: unknown): string | null; +export function normalizeLastName(v: unknown): string | null; +export function normalizeDomain(v: unknown): string | null; +export function normalizeJobTitle(v: unknown): string | null; +export function normalizeLinkedin(v: unknown): string | null; +export function domainFromEmail(email: string | null | undefined): string | null; +export function splitFullName(full: string | null | undefined): { + first_name: string | null; + last_name: string | null; +}; + +export type ProfileInput = + | FormData + | URLSearchParams + | Map + | Record + | null + | undefined; + +export interface ValidateOptions { + required?: readonly ProfileField[]; + partial?: boolean; + deriveDomain?: boolean; +} +export interface ValidationResult { + ok: boolean; + profile: Partial; + errors: Partial>; +} +export function readProfileInput(input: ProfileInput): Partial>; +export function validateProfile(input: ProfileInput, options?: ValidateOptions): ValidationResult; +export function parseProfile(input: ProfileInput, options?: ValidateOptions): Partial; +export function missingFields( + profile: Partial | null | undefined, + fields?: readonly ProfileField[], +): ProfileField[]; + +export interface DbAdapter { + dialect: "postgres" | "sqlite"; + query(text: string, params?: unknown[]): Promise[]>; +} +export interface StoreOptions { + table?: string; + key?: string; + keyType?: string; +} +export interface ProfileStore { + readonly table: string; + readonly key: string; + migrate(): Promise<{ created: boolean; added: string[] }>; + get(userId: string | number): Promise; + getByEmail(email: string): Promise; + save(userId: string | number, profile: Partial): Promise; + fillBlanks(userId: string | number, profile: Partial): Promise; + remove(userId: string | number): Promise; + list(opts?: { limit?: number; after?: string | null; since?: string | null }): Promise<{ + profiles: Profile[]; + next: string | null; + }>; + all(opts?: { limit?: number; since?: string | null }): AsyncGenerator; +} +/** postgres.js sql, a pg Pool/Client, an @libsql/client, a server-side supabase-js client, or a DbAdapter. */ +export function createProfileStore(db: unknown, options?: StoreOptions): ProfileStore; +export function migrate( + dbOrStore: unknown, + options?: StoreOptions, +): Promise<{ created: boolean; added: string[] }>; +export function schemaSql(options?: StoreOptions & { dialect?: "postgres" | "sqlite" }): string; +export function adapt(db: unknown): DbAdapter | { dialect: "supabase"; client: unknown }; + +export interface SessionUser { + id: string | number; + email?: string | null; +} +export interface HandlerOptions { + store: ProfileStore; + getUser(request: Request, ...rest: unknown[]): SessionUser | null | Promise; + required?: readonly ProfileField[]; + onSaved?(profile: Profile, user: SessionUser): unknown; +} +export function createProfileHandlers(options: HandlerOptions): { + GET(request: Request, ...rest: unknown[]): Promise; + PUT(request: Request, ...rest: unknown[]): Promise; + PATCH(request: Request, ...rest: unknown[]): Promise; +}; +export function mountProfileRoutes(app: T, options: HandlerOptions & { path?: string }): T; +export function saveSignupProfile( + store: ProfileStore, + userId: string | number, + input: ProfileInput, + options?: { email?: string | null; required?: readonly ProfileField[] }, +): Promise | Profile }>; + +export interface RenderOptions { + values?: Partial>; + errors?: Partial>; + required?: readonly ProfileField[]; + fields?: readonly ProfileField[]; + idPrefix?: string; + className?: string; + labels?: Partial>; +} +export function renderProfileFields(options?: RenderOptions): string; +export const PROFILE_ENHANCE_SCRIPT: string; + +export interface CsvOptions { + columns?: readonly string[]; + delimiter?: string; +} +export function csvCell(v: unknown, delimiter?: string): string; +export function csvHeader(options?: CsvOptions): string; +export function csvRow(profile: Partial>, options?: CsvOptions): string; +export function toCsv(profiles: Partial>[], options?: CsvOptions): string; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..2070dc6 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,479 @@ +{ + "name": "@profullstack/profile", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@profullstack/profile", + "version": "0.1.0", + "license": "MIT", + "bin": { + "profile": "src/cli.js" + }, + "devDependencies": { + "@libsql/client": "^0.15.15", + "postgres": "^3.4.7", + "react": "^19.1.0", + "react-dom": "^19.1.0" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@libsql/client": ">=0.6.0", + "postgres": "^3.4.0", + "react": ">=18" + }, + "peerDependenciesMeta": { + "@libsql/client": { + "optional": true + }, + "postgres": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/@libsql/client": { + "version": "0.15.15", + "resolved": "https://registry.npmjs.org/@libsql/client/-/client-0.15.15.tgz", + "integrity": "sha512-twC0hQxPNHPKfeOv3sNT6u2pturQjLcI+CnpTM0SjRpocEGgfiZ7DWKXLNnsothjyJmDqEsBQJ5ztq9Wlu470w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@libsql/core": "^0.15.14", + "@libsql/hrana-client": "^0.7.0", + "js-base64": "^3.7.5", + "libsql": "^0.5.22", + "promise-limit": "^2.7.0" + } + }, + "node_modules/@libsql/core": { + "version": "0.15.15", + "resolved": "https://registry.npmjs.org/@libsql/core/-/core-0.15.15.tgz", + "integrity": "sha512-C88Z6UKl+OyuKKPwz224riz02ih/zHYI3Ho/LAcVOgjsunIRZoBw7fjRfaH9oPMmSNeQfhGklSG2il1URoOIsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-base64": "^3.7.5" + } + }, + "node_modules/@libsql/darwin-arm64": { + "version": "0.5.29", + "resolved": "https://registry.npmjs.org/@libsql/darwin-arm64/-/darwin-arm64-0.5.29.tgz", + "integrity": "sha512-K+2RIB1OGFPYQbfay48GakLhqf3ArcbHqPFu7EZiaUcRgFcdw8RoltsMyvbj5ix2fY0HV3Q3Ioa/ByvQdaSM0A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@libsql/darwin-x64": { + "version": "0.5.29", + "resolved": "https://registry.npmjs.org/@libsql/darwin-x64/-/darwin-x64-0.5.29.tgz", + "integrity": "sha512-OtT+KFHsKFy1R5FVadr8FJ2Bb1mghtXTyJkxv0trocq7NuHntSki1eUbxpO5ezJesDvBlqFjnWaYYY516QNLhQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@libsql/hrana-client": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@libsql/hrana-client/-/hrana-client-0.7.0.tgz", + "integrity": "sha512-OF8fFQSkbL7vJY9rfuegK1R7sPgQ6kFMkDamiEccNUvieQ+3urzfDFI616oPl8V7T9zRmnTkSjMOImYCAVRVuw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@libsql/isomorphic-fetch": "^0.3.1", + "@libsql/isomorphic-ws": "^0.1.5", + "js-base64": "^3.7.5", + "node-fetch": "^3.3.2" + } + }, + "node_modules/@libsql/isomorphic-fetch": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@libsql/isomorphic-fetch/-/isomorphic-fetch-0.3.1.tgz", + "integrity": "sha512-6kK3SUK5Uu56zPq/Las620n5aS9xJq+jMBcNSOmjhNf/MUvdyji4vrMTqD7ptY7/4/CAVEAYDeotUz60LNQHtw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@libsql/isomorphic-ws": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/@libsql/isomorphic-ws/-/isomorphic-ws-0.1.5.tgz", + "integrity": "sha512-DtLWIH29onUYR00i0GlQ3UdcTRC6EP4u9w/h9LxpUZJWRMARk6dQwZ6Jkd+QdwVpuAOrdxt18v0K2uIYR3fwFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ws": "^8.5.4", + "ws": "^8.13.0" + } + }, + "node_modules/@libsql/linux-arm-gnueabihf": { + "version": "0.5.29", + "resolved": "https://registry.npmjs.org/@libsql/linux-arm-gnueabihf/-/linux-arm-gnueabihf-0.5.29.tgz", + "integrity": "sha512-CD4n4zj7SJTHso4nf5cuMoWoMSS7asn5hHygsDuhRl8jjjCTT3yE+xdUvI4J7zsyb53VO5ISh4cwwOtf6k2UhQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@libsql/linux-arm-musleabihf": { + "version": "0.5.29", + "resolved": "https://registry.npmjs.org/@libsql/linux-arm-musleabihf/-/linux-arm-musleabihf-0.5.29.tgz", + "integrity": "sha512-2Z9qBVpEJV7OeflzIR3+l5yAd4uTOLxklScYTwpZnkm2vDSGlC1PRlueLaufc4EFITkLKXK2MWBpexuNJfMVcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@libsql/linux-arm64-gnu": { + "version": "0.5.29", + "resolved": "https://registry.npmjs.org/@libsql/linux-arm64-gnu/-/linux-arm64-gnu-0.5.29.tgz", + "integrity": "sha512-gURBqaiXIGGwFNEaUj8Ldk7Hps4STtG+31aEidCk5evMMdtsdfL3HPCpvys+ZF/tkOs2MWlRWoSq7SOuCE9k3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@libsql/linux-arm64-musl": { + "version": "0.5.29", + "resolved": "https://registry.npmjs.org/@libsql/linux-arm64-musl/-/linux-arm64-musl-0.5.29.tgz", + "integrity": "sha512-fwgYZ0H8mUkyVqXZHF3mT/92iIh1N94Owi/f66cPVNsk9BdGKq5gVpoKO+7UxaNzuEH1roJp2QEwsCZMvBLpqg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@libsql/linux-x64-gnu": { + "version": "0.5.29", + "resolved": "https://registry.npmjs.org/@libsql/linux-x64-gnu/-/linux-x64-gnu-0.5.29.tgz", + "integrity": "sha512-y14V0vY0nmMC6G0pHeJcEarcnGU2H6cm21ZceRkacWHvQAEhAG0latQkCtoS2njFOXiYIg+JYPfAoWKbi82rkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@libsql/linux-x64-musl": { + "version": "0.5.29", + "resolved": "https://registry.npmjs.org/@libsql/linux-x64-musl/-/linux-x64-musl-0.5.29.tgz", + "integrity": "sha512-gquqwA/39tH4pFl+J9n3SOMSymjX+6kZ3kWgY3b94nXFTwac9bnFNMffIomgvlFaC4ArVqMnOZD3nuJ3H3VO1w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@libsql/win32-x64-msvc": { + "version": "0.5.29", + "resolved": "https://registry.npmjs.org/@libsql/win32-x64-msvc/-/win32-x64-msvc-0.5.29.tgz", + "integrity": "sha512-4/0CvEdhi6+KjMxMaVbFM2n2Z44escBRoEYpR+gZg64DdetzGnYm8mcNLcoySaDJZNaBd6wz5DNdgRmcI4hXcg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@neon-rs/load": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/@neon-rs/load/-/load-0.0.4.tgz", + "integrity": "sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.6.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.5.tgz", + "integrity": "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.9.0" + } + }, + "node_modules/@types/ws": { + "version": "8.18.2", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.2.tgz", + "integrity": "sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/detect-libc": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.2.tgz", + "integrity": "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/js-base64": { + "version": "3.9.4", + "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.9.4.tgz", + "integrity": "sha512-PtOMXpEGuP0RRiRXsjzHzl44dMHxSu2CPvAhinupR1tBa88me+1DPqsobl7eupn5UukjLkln1ZnAOAOXOrYG0Q==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/libsql": { + "version": "0.5.29", + "resolved": "https://registry.npmjs.org/libsql/-/libsql-0.5.29.tgz", + "integrity": "sha512-8lMP8iMgiBzzoNbAPQ59qdVcj6UaE/Vnm+fiwX4doX4Narook0a4GPKWBEv+CR8a1OwbfkgL18uBfBjWdF0Fzg==", + "cpu": [ + "x64", + "arm64", + "wasm32", + "arm" + ], + "dev": true, + "license": "MIT", + "os": [ + "darwin", + "linux", + "win32" + ], + "dependencies": { + "@neon-rs/load": "^0.0.4", + "detect-libc": "2.0.2" + }, + "optionalDependencies": { + "@libsql/darwin-arm64": "0.5.29", + "@libsql/darwin-x64": "0.5.29", + "@libsql/linux-arm-gnueabihf": "0.5.29", + "@libsql/linux-arm-musleabihf": "0.5.29", + "@libsql/linux-arm64-gnu": "0.5.29", + "@libsql/linux-arm64-musl": "0.5.29", + "@libsql/linux-x64-gnu": "0.5.29", + "@libsql/linux-x64-musl": "0.5.29", + "@libsql/win32-x64-msvc": "0.5.29" + } + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/postgres": { + "version": "3.4.9", + "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.9.tgz", + "integrity": "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw==", + "dev": true, + "license": "Unlicense", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/porsager" + } + }, + "node_modules/promise-limit": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/promise-limit/-/promise-limit-2.7.0.tgz", + "integrity": "sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw==", + "dev": true, + "license": "ISC" + }, + "node_modules/react": { + "version": "19.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.1.tgz", + "integrity": "sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.1.tgz", + "integrity": "sha512-Y0bARz53lhEC1NMCASs0/j+faB/zqt3R8hpvg1e8+61ZVJ4UL0UdC4cBbXfZMcIuhrfgeILTLe+ZeBCavQPtKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.1" + } + }, + "node_modules/scheduler": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", + "dev": true, + "license": "MIT" + }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/ws": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz", + "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..0204d48 --- /dev/null +++ b/package.json @@ -0,0 +1,40 @@ +{ + "name": "@profullstack/profile", + "version": "0.1.0", + "type": "module", + "description": "One user profile for every property: email, first_name, last_name, company_domain, job_title, linkedin_url. Validation, signup form fields, storage on Postgres/Turso/Supabase, HTTP handlers, CSV export, CLI and MCP.", + "keywords": ["profile", "signup", "user-profile", "lead", "postgres", "turso", "libsql", "supabase", "mcp"], + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/profullstack/profile.git" + }, + "homepage": "https://github.com/profullstack/profile#readme", + "exports": { + ".": { "types": "./index.d.ts", "import": "./src/index.js" }, + "./react": { "types": "./react.d.ts", "import": "./src/react.js" }, + "./cli": { "import": "./src/cli.js" } + }, + "bin": { "profile": "./src/cli.js" }, + "files": ["src", "index.d.ts", "react.d.ts", "README.md"], + "scripts": { + "test": "node --test test/*.test.js" + }, + "peerDependencies": { + "@libsql/client": ">=0.6.0", + "postgres": "^3.4.0", + "react": ">=18" + }, + "peerDependenciesMeta": { + "@libsql/client": { "optional": true }, + "postgres": { "optional": true }, + "react": { "optional": true } + }, + "devDependencies": { + "@libsql/client": "^0.15.15", + "postgres": "^3.4.7", + "react": "^19.1.0", + "react-dom": "^19.1.0" + }, + "engines": { "node": ">=20" } +} diff --git a/react.d.ts b/react.d.ts new file mode 100644 index 0000000..68f3d4e --- /dev/null +++ b/react.d.ts @@ -0,0 +1,13 @@ +import type { ReactElement } from "react"; +import type { ProfileField, RenderOptions } from "./index.js"; + +export interface ProfileFieldsProps extends RenderOptions { + /** Controlled values; pass with onChange. */ + values?: Partial>; + onChange?(values: Partial>): void; + /** Initial values when uncontrolled. */ + defaultValues?: Partial>; + disabled?: boolean; +} + +export function ProfileFields(props: ProfileFieldsProps): ReactElement; diff --git a/src/cli.js b/src/cli.js new file mode 100644 index 0000000..9e8212e --- /dev/null +++ b/src/cli.js @@ -0,0 +1,277 @@ +#!/usr/bin/env node +/** + * profile - the six signup fields of any property, from a terminal or an agent. + * + * Reads PROFILE_DATABASE_URL or DATABASE_URL: postgres:// uses postgres.js, + * libsql://, file: and *.db use @libsql/client. Whichever the property uses is + * already installed there; this package depends on neither. + */ +import { createWriteStream } from "node:fs"; +import { createInterface } from "node:readline"; +import { + COLUMNS, + LEAD_REQUIRED, + createProfileStore, + migrate, + schemaSql, + validateProfile, + missingFields, + csvHeader, + csvRow, +} from "./index.js"; + +const VERSION = "0.1.0"; + +const USAGE = `profile - signup profile fields (${COLUMNS.join(", ")}) + + profile migrate create the table, or add missing columns + profile schema [--dialect sqlite] print the DDL for a migration file + profile get one profile + profile find look a profile up by email + profile set --first-name Ada --company-domain acme.com ... + validate and save the given fields + profile check --email a@acme.com --linkedin linkedin.com/in/ada + validate and normalize, no database + profile export [--format csv|tsv|json|jsonl] [--complete] [--since ISO] [--out FILE] + every profile, columns in contract order; + --complete keeps only lead-ready rows + profile mcp MCP server on stdio + +Options: --table user_profiles --key user_id (or PROFILE_TABLE, PROFILE_KEY) +Environment: PROFILE_DATABASE_URL or DATABASE_URL`; + +function parseArgs(argv) { + const flags = {}; + const args = []; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (!a.startsWith("--")) { + args.push(a); + continue; + } + const [k, inline] = a.slice(2).split(/=(.*)/s); + const key = k.replace(/-/g, "_"); + if (inline !== undefined) flags[key] = inline; + else if (argv[i + 1] === undefined || argv[i + 1].startsWith("--")) flags[key] = true; + else flags[key] = argv[++i]; + } + return { flags, args }; +} + +/** Flags named like the fields (or their aliases) become a profile input. */ +function fieldFlags(flags) { + const skip = new Set(["table", "key", "format", "out", "since", "complete", "dialect", "help", "json"]); + return Object.fromEntries(Object.entries(flags).filter(([k]) => !skip.has(k))); +} + +async function connect(flags) { + const url = process.env.PROFILE_DATABASE_URL || process.env.DATABASE_URL; + if (!url) { + console.error("PROFILE_DATABASE_URL or DATABASE_URL is not set"); + process.exit(2); + } + const opts = { + table: flags.table || process.env.PROFILE_TABLE || undefined, + key: flags.key || process.env.PROFILE_KEY || undefined, + }; + if (/^postgres(ql)?:/.test(url)) { + const { default: postgres } = await import("postgres"); + const sql = postgres(url, { onnotice: () => {}, max: 2 }); + return { store: createProfileStore(sql, opts), close: () => sql.end() }; + } + const { createClient } = await import("@libsql/client"); + const client = createClient({ + url: /^(libsql|https?|wss?|file):/.test(url) ? url : `file:${url}`, + authToken: process.env.PROFILE_DATABASE_TOKEN || process.env.TURSO_AUTH_TOKEN || undefined, + }); + return { store: createProfileStore(client, opts), close: () => client.close() }; +} + +const print = (v) => console.log(typeof v === "string" ? v : JSON.stringify(v, null, 2)); + +async function exportProfiles(store, { format = "csv", complete = false, since, out } = {}) { + const stream = out ? createWriteStream(out) : process.stdout; + const write = (s) => new Promise((r) => (stream.write(s) ? r() : stream.once("drain", r))); + const delimiter = format === "tsv" ? "\t" : ","; + let n = 0; + if (format === "csv" || format === "tsv") await write(csvHeader({ delimiter }) + "\n"); + if (format === "json") await write("["); + for await (const p of store.all({ since })) { + if (complete && missingFields(p, LEAD_REQUIRED).length) continue; + if (format === "json") await write((n ? ",\n" : "\n") + JSON.stringify(p)); + else if (format === "jsonl") await write(JSON.stringify(p) + "\n"); + else await write(csvRow(p, { delimiter }) + "\n"); + n++; + } + if (format === "json") await write("\n]\n"); + if (out) await new Promise((r) => stream.end(r)); + return n; +} + +// ---------------------------------------------------------------- MCP (stdio) + +const TOOLS = [ + { + name: "get_profile", + description: "Read one user's profile (email, name, company domain, job title, LinkedIn) by user id or email.", + inputSchema: { + type: "object", + properties: { user_id: { type: "string" }, email: { type: "string" } }, + }, + }, + { + name: "update_profile", + description: + "Validate and save profile fields for a user. Only the fields given are changed; an empty string clears one. company_domain accepts a URL or email; linkedin_url accepts a handle or any LinkedIn profile URL.", + inputSchema: { + type: "object", + required: ["user_id"], + properties: { + user_id: { type: "string" }, + ...Object.fromEntries(COLUMNS.map((c) => [c, { type: "string" }])), + only_blanks: { type: "boolean", description: "Write only into fields that are empty (enrichment)." }, + }, + }, + }, + { + name: "validate_profile", + description: "Normalize and validate profile fields without saving. Returns the canonical values and any errors.", + inputSchema: { type: "object", properties: Object.fromEntries(COLUMNS.map((c) => [c, { type: "string" }])) }, + }, + { + name: "export_profiles", + description: `Export profiles as CSV with columns ${COLUMNS.join(",")}. complete=true keeps only rows with every lead field.`, + inputSchema: { + type: "object", + properties: { + complete: { type: "boolean" }, + since: { type: "string", description: "ISO time; only rows updated since." }, + limit: { type: "number", description: "Default 1000." }, + }, + }, + }, +]; + +async function callTool(getStore, name, a = {}) { + if (name === "validate_profile") return validateProfile(a, { required: [] }); + const store = await getStore(); + if (name === "get_profile") { + const p = a.user_id ? await store.get(a.user_id) : a.email ? await store.getByEmail(a.email) : null; + if (!a.user_id && !a.email) throw new Error("give user_id or email"); + return p ?? { error: "not found" }; + } + if (name === "update_profile") { + const { user_id, only_blanks, ...fields } = a; + const r = validateProfile(fields, { required: [], partial: true }); + if (!r.ok) return { error: "invalid", errors: r.errors }; + return only_blanks ? store.fillBlanks(user_id, r.profile) : store.save(user_id, r.profile); + } + if (name === "export_profiles") { + const limit = Math.min(Number(a.limit) || 1000, 20000); + const rows = [csvHeader()]; + for await (const p of store.all({ since: a.since })) { + if (a.complete && missingFields(p, LEAD_REQUIRED).length) continue; + rows.push(csvRow(p)); + if (rows.length > limit) break; + } + return rows.join("\n"); + } + throw new Error(`unknown tool ${name}`); +} + +async function serveMcp(flags) { + let conn; + const getStore = async () => (conn ??= await connect(flags)).store; + const send = (msg) => process.stdout.write(JSON.stringify(msg) + "\n"); + const rl = createInterface({ input: process.stdin }); + for await (const line of rl) { + if (!line.trim()) continue; + let msg; + try { + msg = JSON.parse(line); + } catch { + send({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "Parse error" } }); + continue; + } + const { id, method, params } = msg; + if (id === undefined) continue; // notifications need no answer + const reply = (result) => send({ jsonrpc: "2.0", id, result }); + try { + if (method === "initialize") { + reply({ + protocolVersion: params?.protocolVersion ?? "2025-06-18", + capabilities: { tools: {} }, + serverInfo: { name: "@profullstack/profile", version: VERSION }, + }); + } else if (method === "ping") reply({}); + else if (method === "tools/list") reply({ tools: TOOLS }); + else if (method === "tools/call") { + try { + const out = await callTool(getStore, params?.name, params?.arguments); + reply({ content: [{ type: "text", text: typeof out === "string" ? out : JSON.stringify(out, null, 2) }] }); + } catch (e) { + reply({ content: [{ type: "text", text: String(e.message ?? e) }], isError: true }); + } + } else send({ jsonrpc: "2.0", id, error: { code: -32601, message: `Method not found: ${method}` } }); + } catch (e) { + send({ jsonrpc: "2.0", id, error: { code: -32603, message: String(e.message ?? e) } }); + } + } + await conn?.close(); +} + +// ---------------------------------------------------------------- main + +const { flags, args } = parseArgs(process.argv.slice(2)); +const [cmd, ...rest] = args; + +if (flags.version) { + print(VERSION); + process.exit(0); +} +if (!cmd || flags.help || cmd === "help") { + print(USAGE); + process.exit(0); +} + +if (cmd === "mcp") { + await serveMcp(flags); + process.exit(0); +} +if (cmd === "schema") { + print(schemaSql({ dialect: flags.dialect === "sqlite" ? "sqlite" : "postgres", table: flags.table, key: flags.key })); + process.exit(0); +} +if (cmd === "check") { + const r = validateProfile(fieldFlags(flags), { required: [] }); + print(r); + process.exit(r.ok ? 0 : 1); +} + +const { store, close } = await connect(flags); +let code = 0; +try { + if (cmd === "migrate") print(await migrate(store)); + else if (cmd === "get") print((await store.get(rest[0])) ?? "not found"); + else if (cmd === "find") print((await store.getByEmail(rest[0])) ?? "not found"); + else if (cmd === "set") { + if (!rest[0]) throw new Error("profile set --field value ..."); + const r = validateProfile(fieldFlags(flags), { required: [], partial: true }); + if (!r.ok) { + print(r.errors); + code = 1; + } else print(await store.save(rest[0], r.profile)); + } else if (cmd === "export") { + const n = await exportProfiles(store, flags); + console.error(`${n} profile(s)`); + } else { + console.error(`unknown command: ${cmd}\n\n${USAGE}`); + code = 2; + } +} catch (e) { + console.error(e.message ?? e); + code = 1; +} finally { + await close(); +} +process.exit(code); diff --git a/src/csv.js b/src/csv.js new file mode 100644 index 0000000..892fe13 --- /dev/null +++ b/src/csv.js @@ -0,0 +1,27 @@ +/** + * CSV in the contract's column order, which is what Explee, Apollo and our own + * `user-export --full` read, so an export from any property imports anywhere. + */ +import { COLUMNS } from "./fields.js"; + +// A cell a spreadsheet would run as a formula gets a leading apostrophe. +const FORMULA = /^[=+@\t\r]|^-(?![\d.])/; + +export function csvCell(v, delimiter = ",") { + if (v == null) return ""; + let s = String(v); + if (FORMULA.test(s)) s = `'${s}`; + return s.includes(delimiter) || /["\r\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; +} + +export function csvHeader({ columns = COLUMNS, delimiter = "," } = {}) { + return columns.join(delimiter); +} + +export function csvRow(profile, { columns = COLUMNS, delimiter = "," } = {}) { + return columns.map((c) => csvCell(profile?.[c], delimiter)).join(delimiter); +} + +export function toCsv(profiles, opts = {}) { + return [csvHeader(opts), ...profiles.map((p) => csvRow(p, opts))].join("\n") + "\n"; +} diff --git a/src/fields.js b/src/fields.js new file mode 100644 index 0000000..2125dc5 --- /dev/null +++ b/src/fields.js @@ -0,0 +1,92 @@ +/** + * The profile contract: six fields, in this order, everywhere. + * + * The order is not cosmetic. It is the column order of the CSV every sales tool + * we feed expects (Explee, Apollo, our own `user-export --full`), so an export + * from any property can be imported anywhere without remapping. Add a field at + * the end or not at all. + */ +export const COLUMNS = Object.freeze([ + "email", + "first_name", + "last_name", + "company_domain", + "job_title", + "linkedin_url", +]); + +/** + * What a signup form asks for out of the box. Email is the account, and a name + * is what makes a person out of an address; the rest is asked for, not demanded, + * because every required box costs signups. A site that sells to companies can + * require more by passing its own list. + */ +export const DEFAULT_REQUIRED = Object.freeze(["email", "first_name", "last_name"]); + +/** The fields a lead needs before an outbound tool will accept it at all. */ +export const LEAD_REQUIRED = Object.freeze([ + "email", + "first_name", + "last_name", + "company_domain", + "job_title", +]); + +/** + * Form metadata. `autocomplete` uses the HTML tokens so browsers and password + * managers fill the right box; `inputmode` picks a phone keyboard that has the + * characters the field needs. + */ +export const FIELDS = Object.freeze([ + { + name: "email", + label: "Email", + type: "email", + autocomplete: "email", + inputmode: "email", + maxLength: 254, + placeholder: "you@company.com", + }, + { + name: "first_name", + label: "First name", + type: "text", + autocomplete: "given-name", + maxLength: 100, + }, + { + name: "last_name", + label: "Last name", + type: "text", + autocomplete: "family-name", + maxLength: 100, + }, + { + name: "company_domain", + label: "Company website", + type: "text", + autocomplete: "url", + inputmode: "url", + maxLength: 253, + placeholder: "company.com", + hint: "Filled from a work email.", + }, + { + name: "job_title", + label: "Job title", + type: "text", + autocomplete: "organization-title", + maxLength: 120, + }, + { + name: "linkedin_url", + label: "LinkedIn", + type: "url", + autocomplete: "url", + inputmode: "url", + maxLength: 200, + placeholder: "linkedin.com/in/you", + }, +]); + +export const FIELD_BY_NAME = Object.freeze(Object.fromEntries(FIELDS.map((f) => [f.name, f]))); diff --git a/src/handlers.js b/src/handlers.js new file mode 100644 index 0000000..fb6ef75 --- /dev/null +++ b/src/handlers.js @@ -0,0 +1,102 @@ +/** + * HTTP for the profile, on Web-standard Request/Response so the same handlers + * are a Next route (`export const { GET, PUT, PATCH } = createProfileHandlers(...)`) + * and a Hono route (`mountProfileRoutes(app, ...)`), with no dependency on either. + */ +import { DEFAULT_REQUIRED } from "./fields.js"; +import { validateProfile, missingFields } from "./validate.js"; + +const json = (body, status = 200) => + new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" }, + }); + +async function readBody(request) { + const type = request.headers.get("content-type") ?? ""; + if (type.includes("application/json")) return request.json(); + if (type.includes("form")) return request.formData(); + const text = await request.text(); + return text ? JSON.parse(text) : {}; +} + +/** + * Validate a signup's profile fields and store them for a user who was just + * created. The signup handler calls this right after it has a user id: + * + * const result = await saveSignupProfile(store, user.id, form, { email: user.email }); + * if (!result.ok) return badRequest(result.errors); + * + * Validate before creating the user (validateProfile) when a failure should + * stop the signup; this call validates again and is safe to repeat. + */ +export async function saveSignupProfile(store, userId, input, { email, required = DEFAULT_REQUIRED } = {}) { + const result = validateProfile(withEmail(input, email), { required }); + if (!result.ok) return result; + return { ...result, profile: await store.save(userId, result.profile) }; +} + +function withEmail(input, email) { + if (!email) return input; + const has = input && (typeof input.get === "function" ? input.get("email") : input.email); + if (has) return input; + if (typeof input?.entries === "function" && input.constructor !== Object) { + return { ...Object.fromEntries(input.entries()), email }; + } + return { ...(input ?? {}), email }; +} + +/** + * `getUser(request)` returns `{ id, email? }` for the signed-in user, or null. + * The account email fills the profile email when the profile has none. + * `required` applies to PUT (a whole profile); PATCH checks only what it sends. + */ +export function createProfileHandlers({ store, getUser, required = DEFAULT_REQUIRED, onSaved } = {}) { + if (!store || typeof getUser !== "function") throw new Error("profile: store and getUser are required"); + + const authed = (fn) => async (request, ...rest) => { + let user; + try { + user = await getUser(request, ...rest); + } catch { + user = null; + } + if (!user?.id) return json({ error: "Sign in first." }, 401); + try { + return await fn(request, user); + } catch (e) { + if (e instanceof SyntaxError) return json({ error: "The body is not valid JSON." }, 400); + throw e; + } + }; + + const write = (partial) => + authed(async (request, user) => { + const body = await readBody(request); + const input = partial ? body : withEmail(body, user.email); + const result = validateProfile(input, { required, partial }); + if (!result.ok) return json({ error: "Some fields need fixing.", errors: result.errors }, 400); + const profile = await store.save(user.id, result.profile); + await onSaved?.(profile, user); + return json({ profile, missing: missingFields(profile, required) }); + }); + + return { + GET: authed(async (_request, user) => { + const profile = (await store.get(user.id)) ?? { user_id: String(user.id), email: user.email ?? null }; + if (!profile.email && user.email) profile.email = user.email; + return json({ profile, missing: missingFields(profile, required) }); + }), + PUT: write(false), + PATCH: write(true), + }; +} + +/** Mount on a Hono app (or anything with get/put/patch(path, c => Response)). */ +export function mountProfileRoutes(app, { path = "/api/v1/me/profile", ...opts } = {}) { + const h = createProfileHandlers(opts); + app.get(path, (c) => h.GET(c.req.raw, c)); + app.put(path, (c) => h.PUT(c.req.raw, c)); + app.patch(path, (c) => h.PATCH(c.req.raw, c)); + return app; +} diff --git a/src/html.js b/src/html.js new file mode 100644 index 0000000..ed2c52d --- /dev/null +++ b/src/html.js @@ -0,0 +1,61 @@ +/** + * The signup fields as HTML, for server-rendered pages (Hono, static sites, + * anything that is not React). Inputs are named by the column, so the posted + * form goes straight into validateProfile(). + */ +import { COLUMNS, DEFAULT_REQUIRED, FIELD_BY_NAME } from "./fields.js"; +import { FREEMAIL } from "./normalize.js"; + +const esc = (v) => + String(v ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]); + +/** + * Options: `values`, `errors` (from validateProfile), `required`, `fields` + * (which to show, default all six), `idPrefix`, `className` (wrapper class, + * each row is `${className}__row`), `labels` (override text per field). + */ +export function renderProfileFields({ + values = {}, + errors = {}, + required = DEFAULT_REQUIRED, + fields = COLUMNS, + idPrefix = "profile-", + className = "pfs-profile", + labels = {}, +} = {}) { + const rows = fields.map((name) => { + const f = FIELD_BY_NAME[name]; + if (!f) throw new Error(`profile: unknown field ${name}`); + const id = `${idPrefix}${name}`; + const req = required.includes(name); + const err = errors[name]; + const attrs = [ + `id="${esc(id)}"`, + `name="${name}"`, + `type="${f.type}"`, + `autocomplete="${f.autocomplete}"`, + f.inputmode && `inputmode="${f.inputmode}"`, + `maxlength="${f.maxLength}"`, + f.placeholder && `placeholder="${esc(f.placeholder)}"`, + values[name] != null && `value="${esc(values[name])}"`, + req && "required", + err && `aria-invalid="true" aria-describedby="${esc(id)}-error"`, + name === "email" && "spellcheck=\"false\" autocapitalize=\"off\"", + ].filter(Boolean); + return `
+ + ${err ? `\n ` : ""} +
`; + }); + return `
\n${rows.join("\n")}\n
`; +} + +/** + * A small script that fills the company box from a work email when the person + * leaves the email box and the company box is still empty. Inline it once + * after the form, inside the page's CSP nonce if it has one. + */ +export const PROFILE_ENHANCE_SCRIPT = `(()=>{const free=new Set(${JSON.stringify([...FREEMAIL])}); +document.querySelectorAll('input[name="email"]').forEach(e=>{const f=e.form;const d=f&&f.querySelector('input[name="company_domain"]');if(!d)return; +e.addEventListener('blur',()=>{if(d.value.trim())return;const h=(e.value.split('@')[1]||'').trim().toLowerCase(); +if(h.includes('.')&&!free.has(h)&&!/^(yahoo|hotmail|outlook|live|gmx|yandex)\\./.test(h))d.value=h;});});})();`; diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..a08b8d1 --- /dev/null +++ b/src/index.js @@ -0,0 +1,27 @@ +export { COLUMNS, FIELDS, FIELD_BY_NAME, DEFAULT_REQUIRED, LEAD_REQUIRED } from "./fields.js"; +export { + ProfileFieldError, + NORMALIZERS, + FREEMAIL, + isFreemail, + cleanText, + normalizeEmail, + normalizeFirstName, + normalizeLastName, + normalizeDomain, + normalizeJobTitle, + normalizeLinkedin, + domainFromEmail, + splitFullName, +} from "./normalize.js"; +export { + validateProfile, + parseProfile, + readProfileInput, + missingFields, + ProfileValidationError, +} from "./validate.js"; +export { createProfileStore, migrate, schemaSql, adapt } from "./store.js"; +export { createProfileHandlers, mountProfileRoutes, saveSignupProfile } from "./handlers.js"; +export { renderProfileFields, PROFILE_ENHANCE_SCRIPT } from "./html.js"; +export { toCsv, csvHeader, csvRow, csvCell } from "./csv.js"; diff --git a/src/normalize.js b/src/normalize.js new file mode 100644 index 0000000..ce7a05c --- /dev/null +++ b/src/normalize.js @@ -0,0 +1,178 @@ +/** + * Normalizers. Each takes whatever a person typed and returns the canonical + * value, null for "left blank", or throws a ProfileFieldError with a message + * fit to show next to the box. + * + * Deliberately lenient about form and strict about meaning: "Acme.com/about", + * "https://www.acme.com" and "@acme.com" are all the domain acme.com, but a URL + * in a name box is spam and is refused. What is never done is "correcting" a + * person's name: McDonald, de la Cruz and O'Neil stay as typed. + */ + +export class ProfileFieldError extends Error { + constructor(field, message) { + super(message); + this.name = "ProfileFieldError"; + this.field = field; + } +} + +const blank = (v) => v === undefined || v === null || String(v).trim() === ""; + +/** Collapse runs of whitespace and drop control characters. */ +export function cleanText(v) { + return String(v) + .replace(/[\u0000-\u001f\u007f\u200b-\u200f\u2028-\u202e\u2060-\u206f\ufeff]/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +const looksLikeUrlOrEmail = (s) => /(https?:\/\/|www\.|@|\.(com|net|org|io|ru|xyz|top|info)\b)/i.test(s); + +// Addresses that are a person's own and say nothing about where they work. +// Shared relay and temp-mail hosts are here too: their domain is nobody's company. +export const FREEMAIL = new Set([ + "gmail.com", "googlemail.com", "yahoo.com", "ymail.com", "rocketmail.com", + "hotmail.com", "outlook.com", "live.com", "msn.com", "passport.com", + "icloud.com", "me.com", "mac.com", "aol.com", "aim.com", + "proton.me", "protonmail.com", "protonmail.ch", "pm.me", + "gmx.com", "gmx.net", "gmx.de", "web.de", "mail.com", "email.com", + "yandex.com", "yandex.ru", "ya.ru", "mail.ru", "inbox.ru", "list.ru", "bk.ru", + "qq.com", "163.com", "126.com", "sina.com", "foxmail.com", + "fastmail.com", "fastmail.fm", "hey.com", "tutanota.com", "tuta.io", "tutamail.com", + "duck.com", "passmail.net", "passmail.com", "aleeas.com", "simplelogin.com", + "anonaddy.me", "addy.io", "relay.firefox.com", "mozmail.com", "privaterelay.appleid.com", + "mailinator.com", "guerrillamail.com", "10minutemail.com", "temp-mail.org", "yopmail.com", + "hushmail.com", "zohomail.com", "rediffmail.com", "libero.it", "orange.fr", "free.fr", + "laposte.net", "btinternet.com", "comcast.net", "verizon.net", "att.net", "sbcglobal.net", + "cox.net", "charter.net", "earthlink.net", "t-online.de", "seznam.cz", "naver.com", + "daum.net", "hanmail.net", "uol.com.br", "bol.com.br", "terra.com.br", +]); + +export function isFreemail(domain) { + if (!domain) return false; + const d = String(domain).toLowerCase(); + if (FREEMAIL.has(d)) return true; + // yahoo.co.uk, hotmail.fr, outlook.de and friends. + return /^(yahoo|hotmail|outlook|live|gmx|yandex)\.[a-z.]+$/.test(d); +} + +export function normalizeEmail(v) { + if (blank(v)) return null; + const s = String(v).trim().toLowerCase(); + if (s.length > 254 || !/^[^\s@]+@[^\s@]+\.[a-z0-9-]{2,}$/i.test(s)) { + throw new ProfileFieldError("email", "Enter an email address like you@company.com."); + } + return s; +} + +function personName(field, label) { + return (v) => { + if (blank(v)) return null; + const s = cleanText(v); + if (s.length > 100) throw new ProfileFieldError(field, `${label} is too long.`); + if (looksLikeUrlOrEmail(s)) throw new ProfileFieldError(field, `${label} cannot be a link or an email.`); + return s; + }; +} + +export const normalizeFirstName = personName("first_name", "First name"); +export const normalizeLastName = personName("last_name", "Last name"); + +/** + * A company domain from a domain, a URL or an email address. Lower-case, no + * scheme, no www., no path, no port. IDNs pass through as typed (the browser + * gives us punycode or Unicode; both are the same company). + */ +export function normalizeDomain(v) { + if (blank(v)) return null; + let s = String(v).trim().toLowerCase(); + if (s.includes("@")) s = s.slice(s.lastIndexOf("@") + 1); + s = s.replace(/^[a-z][a-z0-9+.-]*:\/\//, ""); + s = s.split(/[/?#]/)[0].split(":")[0].replace(/\.$/, ""); + s = s.replace(/^www\d*\./, ""); + const label = "[a-z0-9\\u00a1-\\uffff](?:[a-z0-9\\u00a1-\\uffff-]{0,61}[a-z0-9\\u00a1-\\uffff])?"; + const ok = new RegExp(`^(?:${label}\\.)+(?:[a-z\\u00a1-\\uffff]{2,63}|xn--[a-z0-9-]{1,59})$`); + if (s.length > 253 || !ok.test(s)) { + throw new ProfileFieldError("company_domain", "Enter your company's website, like company.com."); + } + return s; +} + +/** The company domain an email implies, or null when it is a personal mailbox. */ +export function domainFromEmail(email) { + if (blank(email)) return null; + let domain; + try { + domain = normalizeDomain(String(email).split("@").pop()); + } catch { + return null; + } + return isFreemail(domain) ? null : domain; +} + +export function normalizeJobTitle(v) { + if (blank(v)) return null; + const s = cleanText(v); + if (s.length > 120) throw new ProfileFieldError("job_title", "Job title is too long."); + if (/(https?:\/\/|www\.)/i.test(s)) throw new ProfileFieldError("job_title", "Job title cannot be a link."); + return s; +} + +/** + * The canonical public profile URL: https://www.linkedin.com/in/. + * Accepts the full URL with tracking junk, a country subdomain, a bare + * "linkedin.com/in/x", "in/x", or just the vanity name. Company pages are + * refused: this field is the person. + */ +export function normalizeLinkedin(v) { + if (blank(v)) return null; + const err = () => + new ProfileFieldError("linkedin_url", "Enter your LinkedIn profile, like linkedin.com/in/yourname."); + let s = String(v).trim(); + let slug; + const bare = s.replace(/^@/, "").replace(/^\/?in\//i, "").replace(/\/$/, ""); + if (!/[.:]/.test(s) && /^[\p{L}\p{N}%_-]{3,100}$/u.test(bare)) { + slug = bare; + } else { + if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(s)) s = `https://${s}`; + let url; + try { + url = new URL(s); + } catch { + throw err(); + } + if (!/(^|\.)linkedin\.com$/i.test(url.hostname)) throw err(); + const m = url.pathname.match(/^\/(?:in|pub)\/([^/]+)/i); + if (!m) throw err(); + slug = m[1]; + } + try { + slug = decodeURIComponent(slug); + } catch { + throw err(); + } + slug = slug.toLowerCase(); + if (!/^[\p{L}\p{N}_-]{3,100}$/u.test(slug)) throw err(); + return `https://www.linkedin.com/in/${encodeURIComponent(slug)}`; +} + +export const NORMALIZERS = Object.freeze({ + email: normalizeEmail, + first_name: normalizeFirstName, + last_name: normalizeLastName, + company_domain: normalizeDomain, + job_title: normalizeJobTitle, + linkedin_url: normalizeLinkedin, +}); + +/** + * "Ada King Lovelace" -> { first_name: "Ada", last_name: "King Lovelace" }. + * For backfilling a table that only ever kept a full name. A single word is a + * first name; nothing is guessed about particles or order. + */ +export function splitFullName(full) { + if (blank(full)) return { first_name: null, last_name: null }; + const parts = cleanText(full).split(" "); + return { first_name: parts[0], last_name: parts.length > 1 ? parts.slice(1).join(" ") : null }; +} diff --git a/src/react.js b/src/react.js new file mode 100644 index 0000000..7c2a241 --- /dev/null +++ b/src/react.js @@ -0,0 +1,82 @@ +"use client"; +/** + * for React signup forms. Written with createElement so the + * package needs no JSX build. Renders the same markup as renderProfileFields, + * so one stylesheet serves both. + * + * Uncontrolled by default (inputs post with the form, or read them with + * FormData); pass `values` and `onChange(values)` to control it. + */ +import { createElement as h, useRef } from "react"; +import { COLUMNS, DEFAULT_REQUIRED, FIELD_BY_NAME } from "./fields.js"; +import { domainFromEmail } from "./normalize.js"; + +export function ProfileFields({ + values, + defaultValues = {}, + onChange, + errors = {}, + required = DEFAULT_REQUIRED, + fields = COLUMNS, + idPrefix = "profile-", + className = "pfs-profile", + labels = {}, + disabled = false, +}) { + const domainRef = useRef(null); + const controlled = values !== undefined && typeof onChange === "function"; + + const onEmailBlur = (e) => { + const derived = domainFromEmail(e.target.value); + if (!derived) return; + if (controlled) { + if (!values.company_domain) onChange({ ...values, company_domain: derived }); + } else if (domainRef.current && !domainRef.current.value.trim()) { + domainRef.current.value = derived; + } + }; + + return h( + "fieldset", + { className, disabled }, + fields.map((name) => { + const f = FIELD_BY_NAME[name]; + const id = `${idPrefix}${name}`; + const req = required.includes(name); + const err = errors[name]; + const input = { + id, + name, + type: f.type, + autoComplete: f.autocomplete, + inputMode: f.inputmode, + maxLength: f.maxLength, + placeholder: f.placeholder, + required: req, + "aria-invalid": err ? true : undefined, + "aria-describedby": err ? `${id}-error` : undefined, + ref: name === "company_domain" ? domainRef : undefined, + onBlur: name === "email" ? onEmailBlur : undefined, + }; + if (name === "email") Object.assign(input, { spellCheck: false, autoCapitalize: "off" }); + if (controlled) { + input.value = values[name] ?? ""; + input.onChange = (e) => onChange({ ...values, [name]: e.target.value }); + } else { + input.defaultValue = defaultValues[name] ?? undefined; + } + return h( + "div", + { key: name, className: `${className}__row`, "data-field": name }, + h( + "label", + { htmlFor: id }, + labels[name] ?? f.label, + req ? null : h("span", { className: `${className}__optional` }, " (optional)"), + ), + h("input", input), + err ? h("p", { id: `${id}-error`, className: `${className}__error`, role: "alert" }, err) : null, + ); + }), + ); +} diff --git a/src/store.js b/src/store.js new file mode 100644 index 0000000..f10267c --- /dev/null +++ b/src/store.js @@ -0,0 +1,301 @@ +/** + * Storage for the six profile fields, on whatever database the host already has. + * + * Two shapes are supported, because the fleet has both: + * - a table of its own (default `user_profiles`, keyed by `user_id`), or + * - the host's existing table (`profiles` keyed by `id`, or even `users`), to + * which `migrate()` adds only the columns that are missing. + * + * Like @profullstack/orgs there is no foreign key to a users table: the host's + * may be named anything or live elsewhere, so the key is a bare value with no + * constraint, and deleting a user is the host's job (call `remove()`). + * + * Drivers: postgres.js (`sql`), node-postgres (Pool/Client), @libsql/client + * (Turso, SQLite files), and supabase-js on the server. Anything else can pass + * `{ dialect: "postgres" | "sqlite", query(text, params) => rows }`. + */ +import { COLUMNS } from "./fields.js"; + +const IDENT = /^[a-z_][a-z0-9_]*(\.[a-z_][a-z0-9_]*)?$/i; +const quote = (name) => name.split(".").map((p) => `"${p}"`).join("."); + +function checkIdent(name, what) { + if (!IDENT.test(name)) throw new Error(`profile: invalid ${what} name ${JSON.stringify(name)}`); + return name; +} + +/** Wrap a driver in `{ dialect, query(text, params) -> rows }`; `?` placeholders. */ +export function adapt(db) { + if (!db) throw new Error("profile: a database client is required"); + if (db.dialect && typeof db.query === "function") return db; + if (typeof db.from === "function" && db.auth) return { dialect: "supabase", client: db }; + if (typeof db === "function" && typeof db.unsafe === "function") { + return { dialect: "postgres", query: (text, params = []) => db.unsafe(dollars(text), params) }; + } + if (typeof db.execute === "function") { + return { + dialect: "sqlite", + query: async (text, params = []) => (await db.execute({ sql: text, args: params })).rows, + }; + } + if (typeof db.query === "function") { + return { dialect: "postgres", query: async (text, params = []) => (await db.query(dollars(text), params)).rows }; + } + throw new Error("profile: unsupported database client"); +} + +function dollars(text) { + let n = 0; + return text.replace(/\?/g, () => `$${++n}`); +} + +/** + * The DDL for a fresh table, for hosts that keep migrations as files + * (Supabase, Drizzle). `migrate()` runs the same thing and also upgrades an + * existing table in place. + */ +export function schemaSql({ dialect = "postgres", table = "user_profiles", key = "user_id", keyType } = {}) { + checkIdent(table, "table"); + checkIdent(key, "key"); + const pg = dialect !== "sqlite"; + const t = quote(table); + const idx = table.replace(/\./g, "_"); + const ts = pg ? "timestamptz not null default now()" : "text not null default (strftime('%Y-%m-%dT%H:%M:%fZ','now'))"; + return `create table if not exists ${t} ( + "${key}" ${keyType ?? (pg ? "uuid" : "text")} primary key, +${COLUMNS.map((c) => ` "${c}" text,`).join("\n")} + "created_at" ${ts}, + "updated_at" ${ts} +); +create index if not exists "${idx}_email" on ${t} (lower("email")); +create index if not exists "${idx}_company_domain" on ${t} ("company_domain"); +`; +} + +async function existingColumns(db, table) { + if (db.dialect === "sqlite") { + const rows = await db.query(`select name from pragma_table_info(?)`, [table.split(".").pop()]); + return new Set(rows.map((r) => r.name)); + } + const [schema, name] = table.includes(".") ? table.split(".") : [null, table]; + const rows = await db.query( + `select column_name from information_schema.columns + where table_name = ? and table_schema = coalesce(?, current_schema())`, + [name, schema], + ); + return new Set(rows.map((r) => r.column_name)); +} + +/** + * Create the table, or add whatever columns an existing one lacks. Safe to run + * on every boot and from two instances at once (Postgres takes an advisory lock + * for the duration). + */ +export async function migrate(dbOrStore, opts = {}) { + const store = dbOrStore?.__profileStore ? dbOrStore : createProfileStore(dbOrStore, opts); + const { db, table, key, keyType } = store; + if (db.dialect === "supabase") { + throw new Error( + "profile: supabase-js cannot run DDL. Add schemaSql() to a migration file instead:\n\n" + + schemaSql({ table, key, keyType }), + ); + } + const pg = db.dialect === "postgres"; + if (pg) await db.query(`select pg_advisory_lock(hashtext(?))`, [`@profullstack/profile:${table}`]); + try { + const have = await existingColumns(db, table); + if (have.size === 0) { + for (const stmt of schemaSql({ dialect: db.dialect, table, key, keyType }).split(";\n")) { + if (stmt.trim()) await db.query(stmt); + } + return { created: true, added: [] }; + } + if (!have.has(key)) throw new Error(`profile: table ${table} has no key column ${key}`); + const added = []; + for (const col of [...COLUMNS, "created_at", "updated_at"]) { + if (have.has(col)) continue; + // SQLite cannot add a column whose default is not a constant, so the + // timestamps arrive empty and are filled on the next write. + const type = col.endsWith("_at") ? (pg ? "timestamptz default now()" : "text") : "text"; + await db.query(`alter table ${quote(table)} add column ${pg ? "if not exists " : ""}"${col}" ${type}`); + added.push(col); + } + if (!have.has("email") || added.includes("email")) { + const idx = table.replace(/\./g, "_"); + await db.query(`create index if not exists "${idx}_email" on ${quote(table)} (lower("email"))`); + } + return { created: false, added }; + } finally { + if (pg) await db.query(`select pg_advisory_unlock(hashtext(?))`, [`@profullstack/profile:${table}`]); + } +} + +/** Only the contract's fields, from whatever object was passed. */ +function pick(profile) { + const out = {}; + for (const c of COLUMNS) if (profile && c in profile && profile[c] !== undefined) out[c] = profile[c] ?? null; + return out; +} + +function toProfile(row, key) { + if (!row) return null; + const out = { user_id: row[key] == null ? null : String(row[key]) }; + for (const c of COLUMNS) out[c] = row[c] ?? null; + for (const c of ["created_at", "updated_at"]) { + const v = row[c]; + out[c] = v == null ? null : v instanceof Date ? v.toISOString() : String(v); + } + return out; +} + +/** + * Create a store. Options: `table` (default "user_profiles"), `key` (default + * "user_id"; "id" for a Supabase-style `profiles` table), `keyType` (DDL only; + * default uuid on Postgres, text on SQLite). + */ +export function createProfileStore(dbClient, { table = "user_profiles", key = "user_id", keyType } = {}) { + checkIdent(table, "table"); + checkIdent(key, "key"); + const db = adapt(dbClient); + if (db.dialect === "supabase") return supabaseStore(db.client, { table, key, keyType, db }); + + const t = quote(table); + const k = `"${key}"`; + const now = () => new Date().toISOString(); + const cast = db.dialect === "postgres" ? "::timestamptz" : ""; + const cols = [key, ...COLUMNS, "created_at", "updated_at"].map((c) => `"${c}"`).join(", "); + + async function write(userId, profile, mode) { + if (userId == null || userId === "") throw new Error("profile: a user id is required"); + const fields = pick(profile); + const names = Object.keys(fields); + const stamp = now(); + const insertCols = [k, ...names.map((n) => `"${n}"`), `"created_at"`, `"updated_at"`]; + const values = [String(userId), ...names.map((n) => fields[n]), stamp, stamp]; + const placeholders = insertCols.map((c) => (c.endsWith(`_at"`) ? `?${cast}` : "?")); + const set = names.map((n) => + mode === "fill" ? `"${n}" = coalesce(p."${n}", excluded."${n}")` : `"${n}" = excluded."${n}"`, + ); + set.push(`"updated_at" = excluded."updated_at"`); + const rows = await db.query( + `insert into ${t} as p (${insertCols.join(", ")}) values (${placeholders.join(", ")}) + on conflict (${k}) do update set ${set.join(", ")} + returning ${cols}`, + values, + ); + return toProfile(rows[0], key); + } + + const self = { + __profileStore: true, + db, + table, + key, + keyType, + migrate: () => migrate(self), + + async get(userId) { + const rows = await db.query(`select ${cols} from ${t} where ${k} = ?`, [String(userId)]); + return toProfile(rows[0], key); + }, + + async getByEmail(email) { + const rows = await db.query(`select ${cols} from ${t} where lower("email") = lower(?) limit 1`, [ + String(email), + ]); + return toProfile(rows[0], key); + }, + + /** Write the fields present in `profile`; absent ones are left alone, null clears. */ + save: (userId, profile) => write(userId, profile, "set"), + + /** Write only into fields that are empty. For backfills and enrichment. */ + fillBlanks: (userId, profile) => write(userId, profile, "fill"), + + async remove(userId) { + await db.query(`delete from ${t} where ${k} = ?`, [String(userId)]); + }, + + /** + * A page of profiles in key order. `after` is the previous page's `next`; + * `since` limits to rows updated at or after an ISO time. + */ + async list({ limit = 500, after = null, since = null } = {}) { + const where = []; + const params = []; + if (after != null) (where.push(`${k} > ?`), params.push(String(after))); + if (since) (where.push(`"updated_at" >= ?${cast}`), params.push(since)); + params.push(Math.min(Math.max(1, Number(limit) || 500), 5000)); + const rows = await db.query( + `select ${cols} from ${t} ${where.length ? `where ${where.join(" and ")}` : ""} + order by ${k} limit ?`, + params, + ); + const profiles = rows.map((r) => toProfile(r, key)); + return { profiles, next: profiles.length === params.at(-1) ? profiles.at(-1).user_id : null }; + }, + + async *all(opts = {}) { + let after = null; + do { + const page = await self.list({ ...opts, after }); + yield* page.profiles; + after = page.next; + } while (after != null); + }, + }; + return self; +} + +function supabaseStore(client, { table, key, keyType, db }) { + const sel = [key, ...COLUMNS, "created_at", "updated_at"].join(","); + const from = () => client.from(table); + const must = ({ data, error }) => { + if (error) throw new Error(`profile: ${error.message}`); + return data; + }; + const self = { + __profileStore: true, + db, + table, + key, + keyType, + migrate: () => migrate(self), + async get(userId) { + return toProfile(must(await from().select(sel).eq(key, String(userId)).maybeSingle()), key); + }, + async getByEmail(email) { + return toProfile(must(await from().select(sel).ilike("email", String(email)).limit(1).maybeSingle()), key); + }, + async save(userId, profile) { + const row = { [key]: String(userId), ...pick(profile), updated_at: new Date().toISOString() }; + return toProfile(must(await from().upsert(row, { onConflict: key }).select(sel).single()), key); + }, + async fillBlanks(userId, profile) { + const current = (await self.get(userId)) ?? {}; + const fields = pick(profile); + for (const c of Object.keys(fields)) if (current[c]) delete fields[c]; + return self.save(userId, fields); + }, + async remove(userId) { + must(await from().delete().eq(key, String(userId))); + }, + async list({ limit = 500, after = null, since = null } = {}) { + const n = Math.min(Math.max(1, Number(limit) || 500), 5000); + let q = from().select(sel).order(key).limit(n); + if (after != null) q = q.gt(key, String(after)); + if (since) q = q.gte("updated_at", since); + const profiles = must(await q).map((r) => toProfile(r, key)); + return { profiles, next: profiles.length === n ? profiles.at(-1).user_id : null }; + }, + async *all(opts = {}) { + let after = null; + do { + const page = await self.list({ ...opts, after }); + yield* page.profiles; + after = page.next; + } while (after != null); + }, + }; + return self; +} diff --git a/src/validate.js b/src/validate.js new file mode 100644 index 0000000..4a8cca5 --- /dev/null +++ b/src/validate.js @@ -0,0 +1,89 @@ +import { COLUMNS, DEFAULT_REQUIRED, FIELD_BY_NAME } from "./fields.js"; +import { NORMALIZERS, ProfileFieldError, domainFromEmail } from "./normalize.js"; + +// Names apps and libraries already send, so a form need not be rewritten +// before it can be validated here. +const ALIASES = { + firstName: "first_name", given_name: "first_name", givenName: "first_name", + lastName: "last_name", family_name: "last_name", familyName: "last_name", surname: "last_name", + companyDomain: "company_domain", company: "company_domain", company_website: "company_domain", + website: "company_domain", domain: "company_domain", + jobTitle: "job_title", title: "job_title", position: "job_title", + linkedinUrl: "linkedin_url", linkedin: "linkedin_url", linkedIn: "linkedin_url", +}; + +/** Read fields from FormData, URLSearchParams, a Map or a plain object. */ +export function readProfileInput(input) { + const out = {}; + if (!input) return out; + const entries = + typeof input.entries === "function" && !(input.constructor === Object) + ? [...input.entries()] + : Object.entries(input); + for (const [rawKey, value] of entries) { + const key = FIELD_BY_NAME[rawKey] ? rawKey : ALIASES[rawKey]; + // A canonical key wins over an alias for the same field. + if (!key || (key in out && !FIELD_BY_NAME[rawKey])) continue; + out[key] = typeof value === "string" || value === null ? value : value?.toString?.() ?? null; + } + return out; +} + +/** + * Validate and normalize a profile. + * + * - `required`: field names that must be present (default email, first and + * last name). Pass `LEAD_REQUIRED` on a B2B signup. + * - `partial`: an update. Only fields present in the input are checked and + * returned, `required` applies only to those, and an explicit empty value + * clears the field (returned as null). + * - `deriveDomain` (default true): a blank company domain is filled from a work + * email. Personal mailboxes (gmail, proton, relays) never fill it. + * + * Returns `{ ok, profile, errors }`; `errors` maps a field to a message. + */ +export function validateProfile(input, { required = DEFAULT_REQUIRED, partial = false, deriveDomain = true } = {}) { + const raw = readProfileInput(input); + const profile = {}; + const errors = {}; + for (const name of COLUMNS) { + if (partial && !(name in raw)) continue; + try { + profile[name] = NORMALIZERS[name](raw[name]); + } catch (e) { + if (!(e instanceof ProfileFieldError)) throw e; + errors[name] = e.message; + } + } + // Not on an update: there the stored domain may be one the person typed, and + // changing an email address is no reason to overwrite it. + if (deriveDomain && !partial && !errors.company_domain && !profile.company_domain && profile.email) { + profile.company_domain = domainFromEmail(profile.email); + } + for (const name of required) { + if (partial && !(name in raw)) continue; + if (!errors[name] && !profile[name]) errors[name] = `${FIELD_BY_NAME[name]?.label ?? name} is required.`; + } + return { ok: Object.keys(errors).length === 0, profile, errors }; +} + +/** Like validateProfile, but throws a ProfileValidationError carrying `errors`. */ +export function parseProfile(input, options) { + const result = validateProfile(input, options); + if (!result.ok) throw new ProfileValidationError(result.errors); + return result.profile; +} + +export class ProfileValidationError extends Error { + constructor(errors) { + super(Object.values(errors)[0] ?? "Invalid profile."); + this.name = "ProfileValidationError"; + this.status = 400; + this.errors = errors; + } +} + +/** Which of `fields` a stored profile is still missing. */ +export function missingFields(profile, fields = DEFAULT_REQUIRED) { + return fields.filter((f) => !profile?.[f]); +} diff --git a/test/normalize.test.js b/test/normalize.test.js new file mode 100644 index 0000000..7e56501 --- /dev/null +++ b/test/normalize.test.js @@ -0,0 +1,173 @@ +import { test, describe } from "node:test"; +import assert from "node:assert/strict"; +import { + COLUMNS, + LEAD_REQUIRED, + normalizeDomain, + normalizeLinkedin, + normalizeEmail, + normalizeFirstName, + normalizeJobTitle, + domainFromEmail, + splitFullName, + validateProfile, + parseProfile, + ProfileValidationError, + missingFields, + renderProfileFields, + toCsv, +} from "../src/index.js"; + +describe("the contract", () => { + test("columns are the import order sales tools expect", () => { + assert.deepEqual(COLUMNS, ["email", "first_name", "last_name", "company_domain", "job_title", "linkedin_url"]); + }); +}); + +describe("company domain", () => { + for (const [input, want] of [ + ["acme.com", "acme.com"], + ["https://www.Acme.com/about?x=1", "acme.com"], + ["http://acme.co.uk:8080", "acme.co.uk"], + ["@acme.io", "acme.io"], + ["ada@sub.acme.dev", "sub.acme.dev"], + ["WWW2.acme.com.", "acme.com"], + ["münchen.de", "münchen.de"], + ]) { + test(`${input} -> ${want}`, () => assert.equal(normalizeDomain(input), want)); + } + for (const bad of ["acme", "not a domain.com", "http://", "a..com", "-acme.com"]) { + test(`refuses ${JSON.stringify(bad)}`, () => assert.throws(() => normalizeDomain(bad), /company/)); + } + test("blank is null, not an error", () => assert.equal(normalizeDomain(" "), null)); +}); + +describe("domain from email", () => { + test("a work address gives its company", () => assert.equal(domainFromEmail("Ada@Acme.com"), "acme.com")); + test("personal and relay mailboxes give nothing", () => { + for (const e of ["a@gmail.com", "a@proton.me", "a@yahoo.co.uk", "a@passmail.net", "a@hotmail.fr", "a@icloud.com"]) { + assert.equal(domainFromEmail(e), null, e); + } + }); +}); + +describe("linkedin", () => { + const want = "https://www.linkedin.com/in/ada-lovelace"; + for (const input of [ + "https://www.linkedin.com/in/ada-lovelace/", + "linkedin.com/in/Ada-Lovelace?trk=public_profile", + "https://uk.linkedin.com/in/ada-lovelace", + "in/ada-lovelace", + "ada-lovelace", + "@ada-lovelace", + "http://linkedin.com/in/ada-lovelace/details/experience/", + ]) { + test(`${input}`, () => assert.equal(normalizeLinkedin(input), want)); + } + test("unicode vanity names survive", () => + assert.equal(normalizeLinkedin("linkedin.com/in/jos%C3%A9-p"), "https://www.linkedin.com/in/jos%C3%A9-p")); + for (const bad of ["https://linkedin.com/company/acme", "https://evil.com/in/ada", "linkedin.com", "a b"]) { + test(`refuses ${bad}`, () => assert.throws(() => normalizeLinkedin(bad), /LinkedIn/)); + } +}); + +describe("names, titles, email", () => { + test("names keep their case and lose extra space", () => { + assert.equal(normalizeFirstName(" Mary Ann "), "Mary Ann"); + assert.equal(normalizeFirstName("McDonald"), "McDonald"); + assert.equal(normalizeFirstName("de la Cruz"), "de la Cruz"); + }); + test("a link in a name is spam", () => { + assert.throws(() => normalizeFirstName("Win $$$ at http://x.ru")); + assert.throws(() => normalizeFirstName("bob@spam.com")); + }); + test("titles", () => { + assert.equal(normalizeJobTitle(" VP,\tEngineering "), "VP, Engineering"); + assert.throws(() => normalizeJobTitle("see www.x.com")); + }); + test("email is lower-cased and checked", () => { + assert.equal(normalizeEmail(" Ada@Acme.COM "), "ada@acme.com"); + assert.throws(() => normalizeEmail("ada@acme")); + }); + test("splitFullName", () => { + assert.deepEqual(splitFullName("Ada King Lovelace"), { first_name: "Ada", last_name: "King Lovelace" }); + assert.deepEqual(splitFullName("Cher"), { first_name: "Cher", last_name: null }); + }); +}); + +describe("validateProfile", () => { + test("a signup with a work email gets its company filled in", () => { + const r = validateProfile({ email: "ada@acme.com", firstName: "Ada", last_name: "Lovelace" }); + assert.ok(r.ok); + assert.equal(r.profile.company_domain, "acme.com"); + assert.equal(r.profile.job_title, null); + }); + test("a typed company beats the email's", () => { + const r = validateProfile({ email: "ada@gmail.com", first_name: "A", last_name: "L", company_domain: "https://acme.com" }); + assert.equal(r.profile.company_domain, "acme.com"); + }); + test("required fields are reported per field", () => { + const r = validateProfile({ email: "x" }); + assert.equal(r.ok, false); + assert.match(r.errors.email, /email/); + assert.match(r.errors.first_name, /required/); + assert.match(r.errors.last_name, /required/); + }); + test("LEAD_REQUIRED asks for title and company", () => { + const r = validateProfile({ email: "a@gmail.com", first_name: "A", last_name: "B" }, { required: LEAD_REQUIRED }); + assert.deepEqual(Object.keys(r.errors).sort(), ["company_domain", "job_title"]); + }); + test("partial only touches what was sent, and blank clears", () => { + const r = validateProfile({ job_title: "CTO", linkedin_url: "" }, { partial: true }); + assert.deepEqual(r.profile, { job_title: "CTO", linkedin_url: null }); + }); + test("partial never derives a domain over a stored one", () => { + const r = validateProfile({ email: "a@acme.com" }, { partial: true }); + assert.deepEqual(r.profile, { email: "a@acme.com" }); + }); + test("FormData works", () => { + const fd = new FormData(); + fd.set("email", "a@acme.com"); + fd.set("first_name", "A"); + fd.set("last_name", "B"); + fd.set("linkedin", "in/abc"); + assert.equal(validateProfile(fd).profile.linkedin_url, "https://www.linkedin.com/in/abc"); + }); + test("parseProfile throws with the errors attached", () => { + try { + parseProfile({}); + assert.fail("should throw"); + } catch (e) { + assert.ok(e instanceof ProfileValidationError); + assert.equal(e.status, 400); + assert.ok(e.errors.email); + } + }); + test("missingFields", () => { + assert.deepEqual(missingFields({ email: "a@b.co", first_name: "A" }, LEAD_REQUIRED), [ + "last_name", + "company_domain", + "job_title", + ]); + }); +}); + +describe("rendering", () => { + test("html fields carry names, autocomplete and errors, escaped", () => { + const html = renderProfileFields({ values: { first_name: '">