Drops the shared sql-0 postgres dependency in favor of an embedded SQLite
file (DATABASE_URL=file:/data/<db>), WAL-replicated to SeaweedFS by the
operator persistence sidecar — same pattern as esign (Documenso).
Schema changes for the sqlite connector:
- datasource provider postgresql -> sqlite; drop directUrl/shadowDatabaseUrl
- 18 enums -> String (values preserved as string defaults)
- scalar-list fields (String[]/Int[]) -> Json @default("[]") (arrays
round-trip through Prisma Json at runtime; next.config ignoreBuildErrors
keeps the type-only churn out of the build)
- strip pg-native @db.* attributes (@db.Text/@db.Timestamp)
- startup: prisma migrate deploy -> prisma db push (pg migration history is
postgres-specific; db push reconciles the sqlite schema idempotently)
CI: amd64-only (DOKS has no arm64); deploy:false — rollout is operator-managed
via the universe Service CR, never `kubectl set image`.
Verified: `prisma validate` + `prisma db push` materialize all 62 tables on a
fresh sqlite file. Source DB is empty (0 app rows), so cutover is data-safe.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adopt the canonical Hanzo Node base image. Node 24 uniform across the
fleet; sqlite3 bundled (node:sqlite builtin + native better-sqlite3
toolchain). One base, one way.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replaces non-canonical scale-set / org-prefixed labels with the
existing labels every arcd host registers with. Matches evo for
amd64 and spark for arm64. No new labels added.
The reusable workflow defaults runner-arm64 to spark-hanzoai-arm64,
but that label has zero online registrations on the hanzoai org
runner pool. Spark.local is registered as spark-hanzo (and
spark-hanzobot-arm64) — that's the canonical org-level pool label.
Builds queue forever otherwise.
Replace the placeholder H badge with the canonical Hanzo mark (the
abstract H-shape from ~/work/hanzo/logo). Mark renders via
currentColor so it inherits whatever color the parent text sets —
no per-theme variants needed.
White-label is now fully driven by env:
NEXT_PUBLIC_APP_NAME full app name ('Hanzo Dataroom')
NEXT_PUBLIC_APP_NAME_PRIMARY first wordmark token (default: first
word of APP_NAME)
NEXT_PUBLIC_APP_NAME_SUFFIX remainder (default: rest of APP_NAME)
NEXT_PUBLIC_APP_TAGLINE sub-line under the welcome headline
NEXT_PUBLIC_IAM_PROVIDER_NAME IAM brand for 'Sign in with X' button
NEXT_PUBLIC_MARKETING_URL base URL for Terms / Privacy links
Tenants drop in their own NEXT_PUBLIC_APP_NAME='Acme Rooms' and the
whole page reads correctly with no code changes — wordmark + welcome
copy + terms attribution all pick up automatically. The mark itself
stays Hanzo (this IS a Hanzo app); tenants that need their own glyph
swap the HanzoMark import for a custom <img src=BRAND_LOGO_URL />.
Favicons regenerated from the canonical SVG too.
Login was a split-pane marketing layout with a salmon-pink sign-in
button, light-gray panel, testimonial photo + quote, and 'Trusted by
teams at' sponsor wall. None of that is wanted on a Hanzo property.
Now:
* Solid black full-screen background, no testimonial panel, no
sponsor logos.
* Centered logo + 'Welcome to Hanzo Dataroom' headline + tagline.
* Monochrome white-on-black 'Sign in with Hanzo' button (bg-white,
text-black). No red.
* Terms/Privacy links in zinc-300 underline, body in zinc-400/500.
* <html class='dark'> on the root layout — dark is the default theme.
* Favicon swapped to a Hanzo-H mark across all favicon sizes.
The IAM redirect itself is unchanged — signIn('hanzo-iam') still
hands off to NEXT_PUBLIC_IAM_PROVIDER_NAME via NextAuth, so 'Sign in
with Hanzo' is now the only UI surface and IAM owns everything past
the redirect.
Visual cleanups verified via Playwright after first dev run:
* Replaced the three logo SVGs (light, dark, mark) with text-based
placeholders that read 'Hanzo Dataroom' / 'H' instead of the
original Papermark vector wordmarks. Local dev now reads correctly
in the auth flow; real artwork can drop in by overwriting the
same files.
* Removed 'Data Rooms' duplication from the rebrand-sweep
artifacts. The original copy reads '<brand> Data Rooms' which
became 'Hanzo Dataroom Data Rooms' after the naive
Papermark→Hanzo Dataroom substitution. Fixed across:
- app/(auth)/login/page-client.tsx testimonial
- app/(auth)/auth/email/[[...params]]/page-client.tsx
- components/emails/data-rooms-information.tsx
- components/emails/dataroom-trial-24h.tsx
- components/welcome/dataroom-trial.tsx
Drop the dual-licensed `ee/` tree entirely. All features previously
locked behind the Hanzo Dataroom Commercial License are now part of the
AGPL-3.0 core and freely available — custom domains, branded viewing,
advanced analytics, watermarks, access controls, Q&A, AI vector stores,
workflows, SAML SSO, dataroom invitations, conversations, templates.
Layout changes:
* `ee/features/{ai,conversations,workflows,permissions,storage,templates,
dataroom-invitations,access-notifications,security,conversions}/` →
`features/<name>/` (AGPL).
* `ee/limits/` → `lib/billing/limits/` with every plan resolving to an
unlimited profile (no users/links/documents/domains/datarooms cap,
conversations and watermarks always on).
* `ee/stripe/` → `lib/billing/legacy/` (already a compat shim over
`@hanzoai/commerce`; renewal-reminder webhook is now a no-op).
* `ee/features/billing/cancellation/` deleted. Pause/unpause/cancel/
retention routes now compile against `lib/billing/cancellation.ts`
stubs that return 410 Gone; `isTeamPaused`/`isTeamPausedById` in
`lib/billing/paused.ts` always report active. `CancellationModal` is
a no-op shim in `components/billing/`.
* `app/(ee)/api/*` route group folded into `app/api/*` (SAML auth,
SCIM, AI chat, workflows, FAQ, link-upload).
Build: `npm run build` green.
Reverts the amd64-only narrowing and restores the canonical
multi-arch build per Hanzo's runner topology (RUNNERS.md):
- amd64 leg targets hanzo-build-linux-amd64 (DOKS ARC pool on
hanzo-k8s, default in the shared docker-build.yml — no caller
override needed).
- arm64 leg targets spark-hanzoai-arm64 (Ampere bare metal,
also default).
Both are native — no QEMU emulation. arm64 jobs queue while
spark arcd is offline; once spark is online (or an equivalent
arm64 ARC pool is installed somewhere reachable) the queue
drains automatically.
hanzo-build-linux-amd64 is the only ARC runner pool installed on
hanzo-k8s today (arm64 pool is paused until DigitalOcean ships
arm64 droplets, per ~/work/hanzo/.github/RUNNERS.md). The arm64
job was waiting on spark-hanzoai-arm64 — not provisioned in this
cluster — so every build sat in queue forever and the amd64 leg
got cancelled.
Set platforms: linux/amd64 on the reusable build call to skip
arm64 entirely. Re-add arm64 if/when DO ships arm64 droplets and
the spark runner pool comes back online.
components/view/viewer/excel-viewer.tsx imports
'@/public/vendor/handsontable/handsontable.full.min.css' at
build time, but the file was never committed — every Docker build
failed at the webpack module-resolution step.
The companion JS loads via CDN at runtime
(cdnjs.cloudflare.com/ajax/libs/handsontable/6.2.2/handsontable.full.min.js).
Pin the CSS to the same version, vendor it in, and the build
clears.
Per CTO directive: zero Stripe across all our repos. Migration mirrors
hanzoai/gui@8d05cf6fb3.
- lib/commerce.ts: new hand-rolled Hanzo Commerce REST client + minimal
CommerceTypes namespace covering only what the dataroom touches
(Subscription, Invoice, Customer, CheckoutSession, PortalSession,
Coupon, Event, Checkout.Session). HMAC-SHA256 webhook signature
verification matches the gui pattern.
- ee/stripe/index.ts: now a thin shim — `stripeInstance(...)` returns
the Commerce client; `cancelSubscription(...)` delegates to it.
- ee/stripe/client.ts: Stripe.js loadStripe + redirectToCheckout
replaced with a redirect helper that bounces the browser to
pay.hanzo.ai (@hanzoai/pay). No more card UI in the dataroom.
- ee/stripe/utils.ts: drop `import Stripe from "stripe"`; use
CommerceTypes.Subscription. Stamped TODO(stripe-rip) on the PLANS
const — the Stripe price IDs embedded there still need re-keying
to Commerce plan IDs (5 plans × monthly+yearly × test+prod × new+old
account; coordinate with Commerce admin).
- ee/stripe/webhooks/{checkout-session-completed,customer-subscription-
{updated,deleted},invoice-upcoming}.ts: drop stripe SDK imports;
rebind types to CommerceTypes.
- ee/features/security/lib/fraud-prevention.ts: addEmailToStripeRadar
becomes a no-op with TODO(stripe-rip). Commerce does fraud at the
gateway and does not yet expose a Radar-equivalent endpoint; the
Edge Config blocklist remains the effective block surface.
- ee/features/billing/cancellation/api/*.ts: error message
"No Stripe customer ID" -> "No billing customer ID"; the local
`stripe` variable is now a Commerce instance via stripeInstance().
- pages/api/stripe/webhook.ts + webhook-old.ts: DELETED. Commerce
owns webhook termination; the new dispatcher is at
pages/api/commerce/webhook.ts and reuses the existing per-event
business-logic handlers in ee/stripe/webhooks/.
- pages/api/commerce/webhook.ts: NEW. Accepts hanzo-commerce-signature
(falls back to stripe-signature header so a side-by-side rollout
works), verifies HMAC with HANZO_COMMERCE_WEBHOOK_SECRET, dispatches
to the same handlers as before.
- package.json: removed `stripe` (^16.12.0), `@stripe/stripe-js`
(^4.10.0), and the `stripe:webhook` pkgx script. No new deps — the
Commerce REST client is hand-rolled per spec.
TODO(stripe-rip) markers flag the remaining deep work:
- PLANS const price IDs need re-keying to Commerce plan IDs
- ee/stripe/ directory should be renamed to ee/commerce/
- Local `stripeInstance` identifier should be renamed `commerce`
- Subscription.pause_collection / proration_behavior contract needs
to land in the Commerce OpenAPI spec
- Commerce fraud-blocklist API needs to ship to replace the Radar
call we just stubbed out
`tsc --noEmit` shows zero new errors in ee/, lib/commerce.ts,
pages/api/commerce/, components/billing/. Pre-existing TS errors in
redis-job-store, lib/auth, app routes are unchanged.
LICENSE-attested or repo-attested upstream is now called out at the
top of LLM.md alongside the project description, so downstream
audits (universe/docs/PRODUCTS.md) can rely on a single canonical
location for the OSS lineage statement.
Required for hanzoai/.github/.github/workflows/docker-build.yml@main —
without it the workflow_call dies as startup_failure with no jobs
dispatched. Caller permissions are a CEILING.
Replace bespoke build-and-push + universe-reusable-deploy-service pair
with the canonical caller that does both in a single workflow:
hanzoai/.github/.github/workflows/docker-build.yml@main.
Native amd64+arm64 ARC runners, semver + branch tags, multi-arch manifest,
kubectl rollout restart on main after push.
Co-authored-by: Hanzo AI <dev@hanzo.ai>
- Extract organization from IAM profile (owner/organization/org fields)
- Pass organization through JWT → session → CustomUser
- Add HANZO_IAM_* variables to .env.example
- Deprecate Google OAuth in favor of Hanzo IAM
The Prisma CLI binary references companion .wasm files
(prisma_schema_build_bg.wasm) that live alongside it in .bin/.
Copying only the prisma binary missed these files.
The mass sed replacement of 'Papermark' → 'Hanzo Dataroom' broke
function names that contained 'Papermark' as part of a camelCase
identifier (e.g. PapermarkSparkle → Hanzo DataroomSparkle).
Prisma multi-file schema at prisma/schema/schema.prisma looks for
migrations at prisma/schema/migrations/ by default. Actual migrations
are at prisma/migrations/. Symlink bridges the two.
- Dynamic import DomainMiddleware to avoid pulling ioredis into Edge bundle
- Add hanzo.ai to APP_DOMAINS exclusion list in isCustomDomain
- DomainMiddleware now uses fetch to internal API for Redis lookups
- Create /api/internal/domain-redirect endpoint for Edge-safe KV access
Next.js collects page data during build, importing modules at module scope.
SlackEventManager instantiates SlackClient which threw when SLACK_CLIENT_ID
was unset, crashing the build at /api/views-dataroom page collection.
When a link is duplicated, the custom form fields and visitor group
associations were not being copied to the new link. This adds:
- Include customFields and visitorGroups in the Prisma query for the
source link
- Create new CustomField records for the duplicated link via createMany
- Create new LinkVisitorGroup junction records for the duplicated link
- Return customFields and visitorGroups in the created link response
Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
Remove PPR_RE, PPR_BLOCK_RE, and PARA_RE regex constants that were
used to extract <w:pPr> from paragraph fragments via non-greedy
matching. Replace with proper xml.etree.ElementTree parsing of the
entire header/footer file:
- _register_all_namespaces() preserves original namespace prefixes
during ET serialization
- strip_numpages_fields_in_hf() now iterates the parsed XML tree to
find <w:p> elements with NUMPAGES/SECTIONPAGES instrText, removes
all children except <w:pPr> (found via Element.find), and writes
the cleaned tree back
This handles arbitrarily nested pPr structures safely and avoids
regex truncation on malformed documents.
Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
Instead of hardcoding a Footer style on replacement paragraphs,
extract the existing <w:pPr> block (or self-closing tag) from the
original paragraph and reuse it. This avoids style mismatches in
headers/footers that use custom paragraph properties.
Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
Strip nested IF/NUMPAGES field codes from headers/footers that cause
infinite layout recalculation loops in LibreOffice's UNO API
(loadComponentFromURL hangs). Paragraphs containing NUMPAGES or
SECTIONPAGES instrText are replaced with empty paragraphs to break
the loop. This fix runs in 'all' mode alongside existing RTL compat,
glossary removal, and SDT unwrap fixes.
Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
getFilesFromEvent now tracks a collectedFileCount counter and a fileLimit
derived from remainingDocuments. traverseFolder checks the counter before
entering directories (skipping folder creation) and before resolving files.
Once the budget is exhausted the traversal short-circuits, so no extra API
calls are made for folders that would never receive uploads.
A fileLimitTruncatedRef signals to onDrop that files were capped during
traversal so the warning toast fires even though acceptedFiles.length is
already <= remainingDocuments. The else-if branch in onDrop still handles
the file-picker path (no traversal) as a safety net.
Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
The admin was unable to reply to conversations because the 1000 character
limit was too restrictive for dataroom Q&A. This commit:
- Increases the default message character limit from 1000 to 4000
- Surfaces the actual validation error message (e.g. 'Content cannot be
longer than 4000 characters') through the API instead of returning a
generic 'Internal server error'
- Parses and displays the API error in the frontend toast notification
- Adds a character counter to the admin reply form with visual feedback
when approaching/exceeding the limit
- Adds maxLength to viewer-side input fields for client-side enforcement
Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
- conversation-message.tsx: Change w-max to w-fit on message bubbles so
text wraps at max-w-[80%] instead of overflowing. Add min-w-0 and
break-words to the content paragraph for proper word wrapping.
- faq-section.tsx: Add min-w-0 and break-words to FAQ answer text to
prevent overflow in flex containers. Add break-words to question text
and min-w-0 to accordion trigger for consistent wrapping behavior.
Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
- Handle 'too-many-files' rejection in onDropRejected with clear message
showing the max files per upload limit and suggesting smaller batches
- Improve onDrop document limit check: show usage (X/Y) and upgrade action
when at limit, and truncate files with warning when partially exceeding
- Add early return in getFilesFromEvent to skip folder creation when at limit
- Improve add-document-modal error messages with usage context and upgrade CTA
- Fix remainingDocuments calculation: use Infinity for unlimited plans
Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
The account/general page had a useEffect that automatically opened
an UpgradePlanModal every time a non-annual-plan user navigated to
User Settings. This was disruptive as users would see a billing popup
about upgrading to data rooms on every visit.
Removed the UpgradePlanModal component, the useEffect trigger, the
plan detection logic, and all related imports from the page.
Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
- Fix persisted uploads stuck in 'processing': updatePendingUpload now
updates both in-flight and persisted state arrays
- Fix duplicate filename collision: key pending upload IDs by batch index
instead of filename, thread index through onUploadComplete callback
- Fix modal premature close on multi-file upload: track expected/completed
counts and only fire onUploadSuccess after all files resolve
Co-authored-by: Cursor <cursoragent@cursor.com>
- Add GET endpoint to fetch viewer's previously uploaded documents
- Return document data from POST upload for optimistic UI rendering
- Add PendingUploadsProvider context with server-side persistence
- Add PendingDocumentCard with Trigger.dev realtime processing status
- Add Documents/My Uploads tab switcher in dataroom viewer
- Redesign upload modal with folder indicator and success state
- Redesign upload component with progress bars and drag-drop zone
Co-authored-by: Cursor <cursoragent@cursor.com>
The tooltip was using router.query.id (dataroom ID) instead of the
actual document ID, and versionNumber was NaN. Pass documentId through
BarChartComponent data so the tooltip resolves the correct thumbnail.
Add rate limiting (150 req/min per user) to the get-thumbnail endpoint.
Co-authored-by: Cursor <cursoragent@cursor.com>
Move document stats (duration, completion) into dedicated table columns
instead of squeezing them next to the document name. Page-by-page
analytics now opens as a separate expandable row. Replace "See document"
button with arrow link icon. Add table-fixed layout with explicit column
widths to prevent shifting on expand. Conditionally show column headers
only when a row is expanded. Slim down inner row padding.
Co-authored-by: Cursor <cursoragent@cursor.com>
Use RFC 5987 filename* parameter to include the UTF-8 encoded original
filename alongside the ASCII-safe slug, so downloads show the proper
CJK name in modern browsers. Also adds missing Content-Disposition to
put-file-server and stream-file-server uploads.
Co-authored-by: Cursor <cursoragent@cursor.com>
slugify strips all CJK characters, producing empty strings for
filenames and folder names that contain only CJK text. Replace all
usages with safeSlugify which falls back to a 12-char lowercase
alphanumeric nanoid when slugify returns an empty result.
Co-authored-by: Cursor <cursoragent@cursor.com>
When applyAccentColorToDataroomView is enabled but there are not enough
folders/documents to fill the viewport, a white background was visible
underneath the content. Adding min-h-screen ensures the accent background
color extends to fill at least the full viewport height.
Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
- Create new Tinybird pipe get_dataroom_view_document_stats for bulk document view stats
- Add API endpoint for dataroom view document stats with per-page chart support
- Create SWR hooks with deferred loading (enabled flag)
- Create reusable DocumentViewDuration, DocumentViewCompletion, DocumentPageChart components
- Create DataroomViewStats component combining history with stats
- Update dataroom-visitors-table.tsx and dataroom-viewers.tsx to track expansion state
- Stats only load when visitor row is expanded (deferred loading)
- Per-page charts only load when individual document line is expanded
Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
- Change redirect status from 301 to 302 (non-permanent, user-configurable)
- Remove backfill UPDATE entries from migration SQL
- Remove unnecessary sync-redirects cron endpoint
- Add URL validation: SSRF protection, edge config keyword blocklist,
protocol check, URL sanitization via shared validateRedirectUrl helper
- Add plan gating: require Business plan or higher for redirect URLs
(enforced on both API endpoints and UI)
- Clean up Redis redirect URLs on team deletion
- Clear redirect URLs (Postgres + Redis) on subscription cancellation
and on plan downgrade below Business
- Split redis.ts to avoid Prisma import in edge middleware context
- Gate DomainCard redirect UI based on plan (show upgrade prompt)
Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
- Add redirectUrl field to Domain model in Prisma schema
- Create migration for the new field
- Add Redis helper for domain redirect URL caching
- Update middleware to check Redis for redirect URL instead of hardcoded values
- Update domain API: GET includes redirectUrl, POST accepts redirectUrl, PUT updates redirectUrl
- Sync Redis on domain create, update, and delete
Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
* feat: add option to allow text selection on Notion pages
Add enableTextSelection field to Link model that allows document owners
to toggle text selection/copying for visitors on Notion pages.
Changes:
- Add enableTextSelection Boolean field to Link Prisma schema
- Add database migration for the new field
- Create TextSelectionSection toggle component for link settings
- Wire the setting through link sheet, link options, API routes
- Pass textSelectionEnabled prop to NotionPage viewer component
- Add .notion-text-selection-enabled CSS class that overrides
user-select: none when text selection is allowed
- Update link-active-controls to show when text selection is active
- Update webhooks and link data queries to include new field
By default, text selection remains disabled (preserving existing
behavior). When enabled, visitors can select and copy text content
on Notion document pages.
Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
* refactor: use team feature flag for text selection instead of per-link toggle
Replace the per-link enableTextSelection database field with a team-level
feature flag (textSelection) using the existing Vercel Edge Config system.
This means text selection on Notion pages is controlled at the team level
via the betaFeatures edge config, so there's no per-link toggle to manage.
Changes:
- Remove enableTextSelection from Link Prisma schema and migration
- Remove TextSelectionSection toggle component and link sheet wiring
- Add 'textSelection' to BetaFeatures type in featureFlags
- Fetch textSelection flag in all view page getStaticProps:
- pages/view/[linkId]/index.tsx (document + dataroom paths)
- pages/view/domains/[domain]/[slug]/index.tsx (document + dataroom)
- pages/view/[linkId]/d/[documentId].tsx (dataroom document)
- pages/view/domains/[domain]/[slug]/d/[documentId].tsx (domain dataroom doc)
- Pass textSelectionEnabled prop through component chain:
DocumentView -> ViewData -> NotionPage
DataroomDocumentView -> ViewData -> NotionPage
- Keep CSS override (.notion-text-selection-enabled) and NotionPage
conditional class application from previous commit
To enable: add the team's ID to the 'textSelection' array in
Vercel Edge Config betaFeatures.
Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
---------
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
- Apply accentColor (background color) to the dataroom viewer content area,
not just the document view
- Add explicit white background to document and folder cards so they remain
visible on any background color
- Adapt tree view sidebar, breadcrumbs, search banner, and empty state text
colors for dark backgrounds using determineTextColor utility
- Update room preview demo to also render background color
- Update branding page label to indicate background color applies to
dataroom view as well as front page
Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
Previously, the convert-pdf-to-image-route task returned { success: false }
on failures (document not found, blocked documents, page conversion errors),
which Trigger.dev treated as successful completions. This caused failed
conversion runs to show as 'Completed' in the Trigger dashboard.
Now all failure paths throw AbortTaskRunError which:
- Marks the run as FAILED in Trigger.dev
- Does NOT retry the task (these are non-transient errors)
- Shows the appropriate error message to the user
Affected failure cases:
- Document version not found
- Failed to get signed URL
- Failed to get number of pages
- Invalid page count
- Failed to fetch page count
- Document processing blocked
- Failed to convert individual pages
Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
Convert all setData({...data, ...}) calls in DomainSection to
setData(prev => ({...prev, ...})) so updates read the latest state
and don't overwrite concurrent changes. Applies to handleDomainChange,
the default-domain useEffect, and the slug onChange handler.
Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
- Replace hardcoded aria-invalid="true" on the slug input with a
computed boolean that checks for invalid characters and blocked
pathnames, so assistive technology reflects the real state.
- Reformat the domain/slug handling block in pages/api/links/index.ts
to use consistent 6-space indentation matching the enclosing try block.
Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
Replace hand-rolled Math.random slug generator with nanoid's
customAlphabet for cryptographically secure random slugs.
Uses an unambiguous charset that excludes easily confused
characters (0/O, 1/l/I).
Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
- Revert all lowercase normalization across backend and frontend
- Update random slug generator to use full alphanumeric charset (A-Z, a-z, 0-9)
- Custom domain links remain case-sensitive as originally designed
Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
Instead of duplicating all the version creation and document processing
logic in the webhook handler, this refactors the approach to:
1. Add Bearer token auth support to the versions API endpoint
(matching the pattern already used by the documents endpoint),
so it can be called from webhook/server-side contexts.
2. Update createNewDocumentVersion() to accept an optional token
param and use an absolute URL when provided (server-side context),
while preserving the existing relative-URL behavior for client-side.
3. Simplify handleDocumentUpdate to: fetch file -> upload to storage ->
call createNewDocumentVersion(), which delegates all version creation,
primary flag management, and document processing triggers to the
existing versions endpoint.
Net result: ~130 fewer lines, no duplicated processing logic.
Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
- Auto-generate a 10-character lowercase alphanumeric slug when a custom domain
is selected and no slug exists yet
- Add a shuffle button next to the slug input for generating random slugs
- Normalize all custom domain slugs to lowercase for case-insensitive URL handling
across all create/update endpoints and lookup paths
- Update slug input validation to enforce lowercase characters only
Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
Adds a new 'document.update' resource type to the incoming webhook services
endpoint. This allows API consumers to create a new version for an existing
document by providing a documentId, fileUrl, and contentType.
The handler:
- Validates the document exists and belongs to the team
- Fetches the file from the provided URL
- Uploads it to storage
- Creates a new DocumentVersion (incrementing version number)
- Sets the new version as primary (marks all others as non-primary)
- Updates the document record with the new file reference
- Triggers appropriate document processing based on type:
- docs/slides: convertFilesToPdfTask
- keynote slides: convertKeynoteToPdfTask
- pdf: convertPdfToImageRoute
- cad: convertCadToPdfTask
- video (non-mp4): processVideo
- excel (advanced mode): copyFileToBucketServer
Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
When a domain is already registered on Papermark by another account,
the validation modal previously showed 'is already in use' which leaked
information about domain registrations.
Changes:
- Validate API now returns 'has site' instead of 'conflict' for existing
domains, making it indistinguishable from domains with websites
- POST endpoint error message changed to generic 'Unable to add this
domain' instead of 'Domain already exists'
- Removed unused 'conflict' status from frontend types and config
Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
Incorporates fixes from PR #2069 (cursor/link-allow-list-email-update):
- Replace useEffect-based sync with direct onChange handler updates for
allow-list and deny-list, preventing stale data on quick saves
- useEffect now only handles disabling when emailProtected is turned off
- Add key props to AllowListSection and DenyListSection in link-options
to force remount when switching between links (prevents stale state)
- Visitor group clearing is also handled in the new useEffect pattern
Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
The download flow for visitor links showed a success toast but never
started the actual file download. This affected Chrome, Brave, and
all iOS/iPad browsers.
Root causes:
- nav.tsx: created an <a> element without the download attribute for
cross-origin URLs. The download attribute is ignored for cross-origin
URLs, and link.click() in async callbacks loses user gesture context
on iOS WebKit.
- download-only-viewer.tsx: used window.open() which is blocked by
popup blockers in async callbacks (especially on mobile browsers).
- document-card.tsx: used an iframe approach with a bug where it fell
back to response.url instead of the actual downloadUrl.
Fix: fetch the file as a blob and create a same-origin blob URL with
the download attribute. Same-origin blob URLs always respect the
download attribute and work reliably across all browsers including
iOS Safari, Chrome, and Brave.
Also returns fileName from the download API so the downloaded file
has the correct name.
Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
Tag trigger runs with viewer_${viewerId} and filter by it when cancelling
pending runs, so that concurrent uploads from different visitors each get
their own batched notification.
Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
When a dataroom link has file requests enabled and notifications turned on,
the team (admin, managers, link owner) receives an email notification when
a visitor uploads documents to the dataroom.
Uses the same delayed trigger strategy as existing new document notifications:
- Cancels any pending notification for the same dataroom+link
- Triggers a new notification with a 5-minute delay
- The trigger task collects all uploads in that window and sends a single email
Components:
- Trigger.dev task: send-dataroom-upload-notification (5-min batched)
- Email template: DataroomUploadNotification
- API endpoint: /api/jobs/send-dataroom-upload-notification
- Modified upload route to trigger notification when link.enableNotification is on
Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
Two issues fixed:
1. Concurrent handleDownloadAll guard:
- Added early return guard at the top of handleDownloadAll in all three
files to bail out immediately if downloadProgress is already non-null,
preventing a second async loop from starting and overwriting the shared
state.
- Changed all Download All button disabled props from checking a specific
downloadId/jobId match (e.g. downloadProgress?.downloadId === id) to
!!downloadProgress, so ALL Download All buttons are disabled whenever
any download loop is in progress.
2. Modal close during active downloads:
- Removed setDownloadProgress(null) from handleClose in both modal
components (download-progress-modal.tsx and
viewer-download-progress-modal.tsx). Previously, closing the modal
reset downloadProgress to null, but the still-running async loop
immediately overwrote it on the next iteration, causing progress UI
to reappear or linger after reopening. Now the loop's own final
setDownloadProgress(null) is the only cleanup, which fires naturally
when all downloads complete.
Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
Switches the team links endpoint to use a 'links.get' POST resource type
instead of a separate GET handler. This is consistent with the existing
pattern where all webhook operations use POST with a resourceType
discriminator (document.create, link.create, link.update, dataroom.create).
Usage: POST with body { "resourceType": "links.get" }
Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
Add .doc and .docx MIME types to VIEWER_ACCEPTED_FILE_TYPES so that
external visitors can upload Word documents via file requests in the
dataroom. Updated all related UI text to reflect the new supported
file types.
Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
Browsers (Chrome, Firefox, etc.) limit the number of simultaneous
programmatic downloads to ~10. When a dataroom had 20+ zip parts,
clicking 'Download All' would only download ~10 files because:
1. downloads-panel.tsx used forEach+setTimeout with only 300ms delay
2. viewer-download-progress-modal.tsx used forEach+setTimeout with 1500ms
3. Both patterns schedule all downloads upfront, hitting browser limits
Changes across all three download components:
- Use async/await for truly sequential downloads (one at a time)
- Increase delay between downloads to 2 seconds
- Add visual progress feedback ('Downloading X of Y...')
- Disable the Download All button while downloads are in progress
- Reset download progress state on modal/panel close
Also improved download-progress-modal.tsx (team view):
- Always show 'Download All' button regardless of part count (was
previously hidden for >3 parts, forcing individual clicks)
- Added same progress feedback UI
Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
Adds a new GET handler to the incoming webhooks service endpoint that
returns all links from a team. Each link includes:
- linkId
- name
- linkType (DOCUMENT_LINK, DATAROOM_LINK, WORKFLOW_LINK)
- documentId (if applicable)
- dataroomId (if applicable)
- url, slug, domainSlug
- expiresAt, isArchived
- createdAt, updatedAt
- linkUrl (the full URL for the link)
The endpoint reuses the same authentication (webhook ID + Bearer token)
and rate limiting as the existing POST handler.
Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
The download code was building folder structures using the materialized
'path' field on DataroomFolder records, while the UI uses 'parentId' to
build the tree hierarchy. When folder paths become stale after renames
or moves (e.g., descendant paths not updated), the download would
include ghost folders with slugified names that no longer exist.
This fix:
- Adds buildFolderPathsFromHierarchy() utility that computes folder
paths from the parentId chain (matching UI behavior)
- Updates team bulk download, visitor bulk download, and visitor folder
download to use computed paths instead of stored path field
- Ensures download ZIP structure always matches what users see in the UI
Fixes the issue where deleted/renamed folders like 'company-background'
appeared in downloads alongside the correct '02. Company Background',
and where restructured folder hierarchies (e.g., '04. R&D/Clinical')
appeared in downloads but not in the dataroom view.
Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
Ensure dataroom group links endpoint also returns visitor group
associations for proper editing in the link sheet.
Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
- Add VisitorGroupsSection component with create/edit/delete UI
- Add VisitorGroupModal for creating and editing groups
- Add 'Visitor Groups' tab to /visitors page
- Update AllowListSection with multi-group selector (popover with checkboxes)
- Add visitorGroupIds to DEFAULT_LINK_TYPE and link sheet data flow
- Update link create/update APIs to handle visitor group associations
- Update document and dataroom link fetch APIs to include visitorGroups
- Update LinkWithViews type to include visitorGroups
- Update views and views-dataroom routes to merge group emails with allow list
- Visitor group emails are additive: groups + individual emails combined
Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
- GET/POST /api/teams/:teamId/visitor-groups - list & create
- GET/PUT/DELETE /api/teams/:teamId/visitor-groups/:groupId - CRUD
- Deletion protection: prevents deletion if group is used by active links
- SWR hook for consuming visitor groups data in UI
Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
Add team-level visitor groups that allow users to define named groups
of emails/domains once, then apply them to document and data room links.
New models:
- VisitorGroup: team-scoped named group with emails/domains list
- LinkVisitorGroup: many-to-many join table between Link and VisitorGroup
Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
Replace direct copies of vercel-react-best-practices and web-design-guidelines
in .cursor/skills with symlinks pointing to .agents/skills, keeping a single
source of truth for skill files.
Co-authored-by: Cursor <cursoragent@cursor.com>
- Rewrite lib/jackson.ts to match Dub's pattern: named export,
globalThis singleton, clientSecretVerifier, same DB connection
- Add jackson.prisma schema with jackson_index, jackson_store,
jackson_ttl tables (shared database, separate tables)
- Update migration to create Jackson tables alongside Team fields
- Rename connectionController → apiController across all consumers
- Switch all imports from default to named: { jackson }
- Remove unnecessary env vars (JACKSON_EXTERNAL_URL, SAML_PATH,
JACKSON_ENCRYPTION_KEY) — Jackson uses NEXTAUTH_URL and
NEXTAUTH_SECRET directly
Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
- Add EditDataroomDocumentModal component for renaming documents
- Add 'Rename' menu item to the three-dot dropdown menu on dataroom document cards
- Uses existing /api/teams/[teamId]/documents/[id]/update-name endpoint
- Follows the same UX pattern as folder renaming (modal with input)
- Properly revalidates SWR cache for dataroom documents and folder tree
Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
When a link is soft-deleted, rename the slug from <slug> to
<slug>-DELETED-<random 6 alphanumeric chars> so the original slug
can be reused for new links.
Updated all three link deletion endpoints:
- pages/api/links/[id]/index.ts
- pages/api/teams/[teamId]/links/[id]/index.ts
- app/(ee)/api/workflows/[workflowId]/route.ts
Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
- Limited to 2 levels only: top-level folders + 1 subfolder level
- Reduced top-level folders from 10 to max 8
- Max 5 subfolders per top-level folder
- No deeper nesting allowed (subfolders cannot have subfolders)
Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
- Remove .toLowerCase() to make domain comparison case-sensitive, matching send-notification.ts behavior
- Log only the extracted domain instead of full email address to avoid persisting PII
- Use fallback 'unknown-domain' if email cannot be parsed
Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
- Reduced max folder depth from 5 levels to 3 levels
- Limited top-level folders to max 10
- Limited subfolders: max 5 at level 1, max 4 at level 2
- Reduced maxOutputTokens from 1000 to 600
- Lowered temperature from 0.5 to 0.3 for more consistent output
- Added validation constraints to both generate-ai-structure and generate-ai endpoints
Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
- Skip Slack notifications for viewers whose email domain is in the team's ignored domains list
- Fetch team's ignoredDomains in parallel with integration lookup for efficiency
- Add isViewerDomainIgnored helper method with domain normalization (handles @ prefix)
- Log when notifications are skipped due to ignored domains
This matches the existing behavior in send-notification.ts for email notifications.
Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
- Fix dataroomId being set to undefined for DATAROOM_LINK types in processLinkData
- Update views-dataroom API to use link.dataroomId from database instead of request body
- This fixes the 'dataroomId Required' validation error when accessing documents in a dataroom
- Ensures dataroom session creation works correctly for direct document access
Co-authored-by: marcftone <marcftone@gmail.com>
- Replace manual keyboard event listeners with useHotkeys hook
- Use 'mod+enter' which handles Cmd on Mac and Ctrl on Windows/Linux
- Enable hotkeys on form tags to work within input fields
Co-authored-by: marcftone <marcftone@gmail.com>
This hides the Papermark branding link on access screens for shared documents
and data rooms for the specified team.
Co-authored-by: marcftone <marcftone@gmail.com>
- Add keyboard shortcut (Cmd+Enter on Mac, Ctrl+Enter on Windows/Linux) to submit link forms
- Applied to document link sheet and dataroom link sheet
- The shortcut triggers form submission when the sheet is open
Co-authored-by: marcftone <marcftone@gmail.com>
- TimerOffIcon for expired links (red/destructive styling)
- HourglassIcon for links with expiration set but not yet expired (orange styling)
- Both show timestamp tooltip with expiration date and relative time
Co-authored-by: marcftone <marcftone@gmail.com>
- Show 'Expired' badge with clock icon when a link's expiresAt date is in the past
- Use TimestampTooltip to show when the link expired (local time, UTC)
- The tooltip also shows relative time (how long ago it expired)
- Badge uses destructive/red styling to clearly indicate the expired state
Co-authored-by: marcftone <marcftone@gmail.com>
- Reset showEmailDeliveryNotice to false when emailLocked becomes false
- Timer only starts when emailLocked is true
- Ensures notice resets on unlock and re-starts fresh on subsequent locks
Co-authored-by: marcftone <marcftone@gmail.com>
- Changed generic message to specifically mention Microsoft outage
- Explicitly calls out Outlook and Microsoft email accounts
Co-authored-by: marcftone <marcftone@gmail.com>
- Changed from red to orange colors to match Papermark brand
- border-orange-200, bg-orange-50 for container
- text-orange-800 for body text
- text-orange-600 with hover:text-orange-700 for email link
Co-authored-by: marcftone <marcftone@gmail.com>
- Added new state variable to track notice visibility
- Added useEffect that shows the notice after 5 seconds delay
- Notice only appears when user is waiting for verification email (emailLocked)
- Styled notice with red border/background similar to warning design
- Includes instructions to check spam/junk folders
- References system@papermark.com as allowed sender
Co-authored-by: marcftone <marcftone@gmail.com>
The pause_ends_at was being calculated as pauseStartsAt + 90 days, which
doesn't properly align with billing cycles. For example, Oct 25 + 90 days =
Jan 23, but 3 calendar months from Oct 25 = Jan 25.
This caused the pause_ends_at to be 2-3 days before the actual subscription
billing cycle end date, which is confusing and incorrect.
Changed to use setMonth(getMonth() + 3) to properly calculate 3 calendar
months, ensuring the pause end date aligns with the subscription's billing
cycle.
Co-authored-by: marcftone <marcftone@gmail.com>
- Store magic link tokens in Redis with built-in TTL (24 hours)
- Remove MagicLinkToken Prisma model and migration
- Remove cleanup cron job (Redis handles expiration automatically)
- Export getMagicLinkData function for token lookup
- Cleaner and faster implementation using existing Redis infrastructure
Benefits:
- Faster token lookups (Redis vs database)
- Automatic expiration handling via Redis TTL
- No need for cleanup jobs
- Simpler code with less database overhead
Co-authored-by: marcftone <marcftone@gmail.com>
- Add PendingUploadsContext for managing optimistic uploads
- Create PendingDocumentCard component to show uploading/processing documents
- Update upload API to return document data for optimistic display
- Update ViewerUploadComponent to track pending uploads
- Update DataroomViewer to display pending uploads at top of list
- Update DocumentUploadModal with success feedback and auto-close
- Add 'pending' prefix to id-helper for generating pending upload IDs
This ensures external visitors see their uploaded documents immediately
after upload, with a clear 'Processing...' indicator while the document
is being processed on the backend.
Fixes PM-468
Co-authored-by: marcftone <marcftone@gmail.com>
- Create MagicLinkToken model to store verification URLs server-side
- Generate short 20-character tokens instead of long encoded URLs
- URL format changed from ~400 chars to ~60 chars (e.g., /verify?token=abc123)
- Fix URL display mismatch - button href and plaintext URL now match exactly
- Enhance email content:
- Add personalization with user email
- Add request timestamp
- Add 24-hour expiration notice
- Add security information section
- Include physical mailing address for CAN-SPAM compliance
- Add cleanup cron job for expired magic link tokens
- Maintain backward compatibility with legacy checksum-based verification
- Add friendly expired link page with option to request new link
Fixes PM-467
Co-authored-by: marcftone <marcftone@gmail.com>
The cancel-route.ts was calling stripe.subscriptions.deleteDiscount()
unconditionally inside a Promise.all. This Stripe API call throws an
error if the subscription doesn't have a discount applied.
Added a .catch() handler to gracefully ignore the error when there's
no discount to delete, since this is an expected scenario for most
subscription cancellations.
Co-authored-by: marcftone <marcftone@gmail.com>
- Change from Link wrapping Button to Button with asChild prop
- Place Link inside Button as the underlying element
- Remove title prop and add aria-label for better accessibility
Co-authored-by: marcftone <marcftone@gmail.com>
- Use useHiddenDocuments hook to check for hidden folders/documents
- Conditionally render the eye-off button based on hasHiddenItems
Co-authored-by: marcftone <marcftone@gmail.com>
- Add icon and color columns to Folder and DataroomFolder models
- Create Prisma migration for new columns
- Add folder icon list (27 icons) and color palette (8 colors) constants
- Update API endpoints to validate and persist icon/color fields
- Create FolderIconPicker and FolderColorPicker UI components
- Update EditFolderModal with icon/color selection and live preview
- Update FolderCard components to display custom icons/colors
- Support both regular folders and dataroom folders
Closes PM-466
Co-authored-by: marcftone <marcftone@gmail.com>
- Add guards for teamInfo?.currentTeam?.id in handleDeleteDocument and handleUnhideDocument
- Return early with toast error when team information is missing
- Remove duplicate key props from inner Skeleton elements in loading states
Co-authored-by: marcftone <marcftone@gmail.com>
- Add API endpoint /api/teams/[teamId]/documents/hidden to fetch hidden documents and folders
- Add useHiddenDocuments SWR hook
- Create HiddenDocumentsList component with bulk unhide functionality
- Create HiddenDocumentCard and HiddenFolderCard components with individual unhide
- Add hidden documents page at /documents/hidden
- Add link to hidden documents page from main documents page
Co-authored-by: marcftone <marcftone@gmail.com>
- Updated API to include internalName in simple mode response
- Updated DataroomSimple type to include internalName
- Modified sidebar to display internalName instead of name when available
Co-authored-by: marcftone <marcftone@gmail.com>
- Replace UpgradePlanModalWithDiscount with UpgradePlanModal in all components
- Update yearly-upgrade-banner.tsx to show regular yearly prices without discount
- Update all upgrade routes from /settings/upgrade-holiday-offer to /settings/upgrade
- Remove New Year's offer promotional text and 30% discount messaging
Co-authored-by: marcftone <marcftone@gmail.com>
- Added 'Add Seat' button alongside the 'Invite' button
- Users can now proactively add more seats before exhausting their current limit
- When seat limit is exhausted, 'Invite' button becomes disabled with a tooltip
Co-authored-by: marcftone <marcftone@gmail.com>
This allows the GET /api/teams/[teamId]/datarooms endpoint to run
for up to 180 seconds before timing out, accommodating larger
dataroom listings.
Co-authored-by: marcftone <marcftone@gmail.com>
When the obfuscateNotionIds function changes element IDs from Notion
UUIDs to obfuscated IDs (block-0, block-1, etc.), the table of contents
anchor links were still pointing to the original UUIDs. This caused
clicking on TOC items to not scroll to the corresponding sections.
This fix updates the anchor href attributes to use the same obfuscated
IDs, ensuring TOC navigation works correctly after ID obfuscation.
This feature allows users to hide folders and documents from the All Documents
view without deleting them:
- Add hiddenInAllDocuments field to Document and Folder models
- Create API endpoints for hiding documents (/documents/hide) and folders
(/folders/hide)
- Update documents and folders APIs to exclude hidden items from All Documents
view
- Add "Hide from All Documents" action to document and folder dropdown menus
- Add bulk hide action to multi-select action panel
- Cascade hiding: when a folder is hidden, all subfolders and documents within
are also hidden
This is a non-destructive feature that only affects visibility in the All
Documents section. Hidden items remain accessible via Data Rooms and are not
deleted.
Resolves PM-465
This feature allows users to set a private, internal name for Data Rooms
that is only visible to the owner. This helps distinguish between multiple
data rooms that share the same public-facing name.
Changes:
- Add internalName field to Dataroom schema
- Create database migration for the new field
- Update API endpoints to support creating/updating internal names
- Search now includes internal names
- Update add-dataroom-modal with internal name input
- Display internal name in dataroom cards and headers
- Add internal name form in settings page
Refactored ID validation in workflow API routes for consistency and readability. Updated workflow engine to improve action result handling. Added 'WORKFLOW_LINK' to linkType enum in webhook schema. Improved type safety and code clarity in view pages, including dataroom lastUpdatedAt calculation and link prop usage.
All workflow-related API routes now require a teamId query parameter and validate user membership via the userTeam table. This change improves security and access control, ensuring users can only access workflows and steps belonging to their teams. Frontend components and pages have been updated to pass teamId in API requests, and the workflow creation schema no longer requires teamId in the request body.
When the domain is 'papermark.com', the slug field is now set to undefined instead of a trimmed value. This aligns slug handling with domain-specific requirements.
Changed the code field validation from fixed length to a regex that enforces exactly 6 digits. This ensures the verification code consists only of digits and is 6 characters long.
Changed the 'domain' field in CreateWorkflowRequestSchema from optional to nullish, allowing both null and undefined values to indicate papermark.com as the default.
Added a useEffect hook to re-populate form state when the dialog is opened or the step changes, preventing stale values in name, targetLinkId, and allowListInput fields.
Refactors PATCH and POST handlers to use validated conditions and actions for workflow step updates and creation, ensuring enrichment and mutation persist. Also improves Zod error formatting in validation utilities for better readability.
description:Create distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user asks to build web components, pages, artifacts, posters, or applications (examples include websites, landing pages, dashboards, React components, HTML/CSS layouts, or when styling/beautifying any web UI). Generates creative, polished code and UI design that avoids generic AI aesthetics.
license:Complete terms in LICENSE.txt
---
This skill guides creation of distinctive, production-grade frontend interfaces that avoid generic "AI slop" aesthetics. Implement real working code with exceptional attention to aesthetic details and creative choices.
The user provides frontend requirements: a component, page, application, or interface to build. They may include context about the purpose, audience, or technical constraints.
## Design Thinking
Before coding, understand the context and commit to a BOLD aesthetic direction:
- **Purpose**: What problem does this interface solve? Who uses it?
- **Tone**: Pick an extreme: brutally minimal, maximalist chaos, retro-futuristic, organic/natural, luxury/refined, playful/toy-like, editorial/magazine, brutalist/raw, art deco/geometric, soft/pastel, industrial/utilitarian, etc. There are so many flavors to choose from. Use these for inspiration but design one that is true to the aesthetic direction.
- **Differentiation**: What makes this UNFORGETTABLE? What's the one thing someone will remember?
**CRITICAL**: Choose a clear conceptual direction and execute it with precision. Bold maximalism and refined minimalism both work - the key is intentionality, not intensity.
Then implement working code (HTML/CSS/JS, React, Vue, etc.) that is:
- Production-grade and functional
- Visually striking and memorable
- Cohesive with a clear aesthetic point-of-view
- Meticulously refined in every detail
## Frontend Aesthetics Guidelines
Focus on:
- **Typography**: Choose fonts that are beautiful, unique, and interesting. Avoid generic fonts like Arial and Inter; opt instead for distinctive choices that elevate the frontend's aesthetics; unexpected, characterful font choices. Pair a distinctive display font with a refined body font.
- **Color & Theme**: Commit to a cohesive aesthetic. Use CSS variables for consistency. Dominant colors with sharp accents outperform timid, evenly-distributed palettes.
- **Motion**: Use animations for effects and micro-interactions. Prioritize CSS-only solutions for HTML. Use Motion library for React when available. Focus on high-impact moments: one well-orchestrated page load with staggered reveals (animation-delay) creates more delight than scattered micro-interactions. Use scroll-triggering and hover states that surprise.
- **Spatial Composition**: Unexpected layouts. Asymmetry. Overlap. Diagonal flow. Grid-breaking elements. Generous negative space OR controlled density.
- **Backgrounds & Visual Details**: Create atmosphere and depth rather than defaulting to solid colors. Add contextual effects and textures that match the overall aesthetic. Apply creative forms like gradient meshes, noise textures, geometric patterns, layered transparencies, dramatic shadows, decorative borders, custom cursors, and grain overlays.
NEVER use generic AI-generated aesthetics like overused font families (Inter, Roboto, Arial, system fonts), cliched color schemes (particularly purple gradients on white backgrounds), predictable layouts and component patterns, and cookie-cutter design that lacks context-specific character.
Interpret creatively and make unexpected choices that feel genuinely designed for the context. No design should be the same. Vary between light and dark themes, different fonts, different aesthetics. NEVER converge on common choices (Space Grotesk, for example) across generations.
**IMPORTANT**: Match implementation complexity to the aesthetic vision. Maximalist designs need elaborate code with extensive animations and effects. Minimalist or refined designs need restraint, precision, and careful attention to spacing, typography, and subtle details. Elegance comes from executing the vision well.
Remember: Claude is capable of extraordinary creative work. Don't hold back, show what can truly be created when thinking outside the box and committing fully to a distinctive vision.
description:PostgreSQL best practices, query optimization, connection troubleshooting, and performance improvement. Load when working with Postgres databases.
| Index Optimization | [references/index-optimization.md](https://raw.githubusercontent.com/planetscale/database-skills/main/skills/postgres/references/index-optimization.md) | Unused/duplicate index queries, index audit |
| Partitioning | [references/partitioning.md](https://raw.githubusercontent.com/planetscale/database-skills/main/skills/postgres/references/partitioning.md) | Large tables, time-series, data retention |
| MVCC and VACUUM | [references/mvcc-vacuum.md](https://raw.githubusercontent.com/planetscale/database-skills/main/skills/postgres/references/mvcc-vacuum.md) | Dead tuples, long transactions, xid wraparound prevention |
| Connection Pooling | [references/ps-connection-pooling.md](https://raw.githubusercontent.com/planetscale/database-skills/main/skills/postgres/references/ps-connection-pooling.md) | PgBouncer, pool sizing, pooled vs direct |
**FUNDAMENTAL RULE: Backups are useless until you've successfully tested recovery.**
## Logical Backups (pg_dump)
Exports as SQL or custom format; portable across PG versions and architectures. Formats: `-Fp` (plain SQL), `-Fc` (custom compressed, selective restore), `-Fd` (directory, parallel with `-j`), `-Ft` (tar, avoid). Use `-Fd -j 4` for large DBs. Restore: `pg_restore -d dbname file.dump`; add `-j` for parallel restore. Selective table restore: `pg_restore -t tablename`. Slow for large DBs; RPO = backup frequency (typically 24h).
## Physical Backups (pg_basebackup)
Copies raw PGDATA; same major version and platform required; cross-architecture works if same endianness (e.g., x86_64 ↔ ARM64). Faster for large clusters; includes all databases. Flags: `-Ft -z -P` for compressed tar with progress. Manual alternative: `pg_backup_start()` → copy PGDATA → `pg_backup_stop()` (complex; must write returned `backup_label`).
## PITR (Point-in-Time Recovery)
Requires base backup + continuous WAL archiving. Restores to any timestamp, transaction, or named restore point. Without PITR: restore only to backup time (potentially lose hours). With PITR: RPO = minutes. `archive_command` must return 0 ONLY when file is safely stored—premature 0 = data loss risk. `wal_level` must be `replica` or `logical` (not `minimal`).
## WAL Archiving
`archive_mode=on`, `archive_command='test ! -f /archive/%f && cp %p /archive/%f'`. **Test archive command as postgres user** (not root) since permission issues are common. Monitor `pg_stat_archiver` for `failed_count`, `last_archived_time`. Archive failures prevent WAL recycling → disk fills.
## Tool Comparison
| Tool | Use case |
|------|----------|
| pg_dump | Small DBs, migrations, selective restore |
regexp_replace(indexdef,'INDEX \S+ ON ','INDEX ON ')
HAVINGcount(*)>1;
```
**Always confirm with a human before dropping or removing any indexes identified by the queries above.** Even indexes with 0 scans may be needed for infrequent but critical queries, and stats may have been reset recently.
- **Shared memory**: `shared_buffers` — main data cache, all processes, requires restart to change.
- **Private per backend**: `work_mem` (sorts/hashes/joins, per-operation); `maintenance_work_mem` (VACUUM, CREATE INDEX, ALTER TABLE ADD FOREIGN KEY); `temp_buffers` (8MB default).
- **Planner hint only**: `effective_cache_size` is NOT allocated — set to ~50–75% of total RAM.
- **Hash multiplier**: `hash_mem_multiplier` (default 2.0) means hash ops use up to 2×`work_mem`.
## Memory Multiplication Danger
Maximum potential: `work_mem × operations_per_query × (parallel_workers + 1) × connections` (leader participates by default via `parallel_leader_participation = on`; hash operations use up to `hash_mem_multiplier × work_mem`, default 2.0). Example: 128MB work_mem, 3 ops (2 sorts + 1 hash join), 2 parallel workers, 100 connections → 2 sorts at 128MB = 256MB, 1 hash join at 128MB × 2.0 = 256MB, per process = 512MB, × 3 processes (2 workers + leader) = 1536MB/query, × 100 connections = **~150GB** worst case. This case is rare.
Not all queries hit limits at once, but high concurrency + large datasets approach it. This is a common cause of OOM in containerized/Kubernetes deployments. Plan capacity with a 1.5–2× safety margin.
## OS Page Cache (Double Buffering)
Data exists in both `shared_buffers` and OS page cache. A miss in shared_buffers can still hit OS cache (avoiding disk I/O). Extremely large shared_buffers can hurt performance: less OS cache, slower startup, heavier checkpoints. Optimal split depends on workload (OLTP vs OLAP).
## OOM Prevention
- Implement connection pooling to reduce total backend count.
- Reduce `work_mem` globally; use per-session overrides for heavy queries only.
- Lower `max_parallel_workers_per_gather` in high-concurrency systems.
- Set `statement_timeout` to kill runaway queries.
- Monitor: `dmesg -T | grep "killed process"` and `temp_blks_written` in pg_stat_statements.
## Operational Rules
- Tune per-session first, global last.
- Suspect OOM when memory spikes during high concurrency, dashboards, or large batch jobs.
- Increase memory only after confirming spill behavior (`temp_blks_written > 0`).
-`maintenance_work_mem` can be set much higher (1–2GB) — fewer processes use it. Cap autovacuum with `autovacuum_work_mem` to avoid `autovacuum_max_workers × maintenance_work_mem` memory spikes.
-`shared_buffers` change requires full restart; `work_mem` is per-session changeable.
- **pg_stat_activity**: First stop when something is wrong — running queries, states, wait events, locks.
- **pg_stat_statements**: Execution stats for all SQL. Requires `shared_preload_libraries = 'pg_stat_statements'` and `CREATE EXTENSION pg_stat_statements`.
- **pg_stat_database**: Cache hit ratio, temp files, deadlocks, connections per database.
- **pg_stat_user_tables**: `seq_scan` vs `idx_scan`, dead tuples, last vacuum/analyze times.
- **pg_stat_user_indexes**: Find unused indexes (`idx_scan = 0` with large size).
- **pg_stat_bgwriter**: `buffers_clean`, `maxwritten_clean`, `buffers_alloc`. Pre-PG 17 also had `buffers_checkpoint`, `buffers_backend` (high = backends bypassing bgwriter). PG 17+ moved checkpoint stats to `pg_stat_checkpointer`.
- **pg_stat_checkpointer** (PG 17+): Checkpoint frequency (`num_timed`, `num_requested`), write/sync time.
-- last_autovacuum = <null> means autovacuum has not run on this table
```
Blocking: use `pg_blocking_pids(pid)` with `pg_stat_activity` to find blocked and blocking sessions.
## Logging — First Line of Defense
PostgreSQL is extremely vocal about problems. **Always check logs first**: `tail -f /var/log/postgresql/postgresql-*.log`.
Key settings: `log_min_duration_statement` (OLTP: 1–3s, analytics: 30–60s, dev: 100–500ms). Enable `log_checkpoints=on`, `log_connections=on`, `log_disconnections=on`, `log_lock_waits=on`, `log_temp_files=0`. Use CSV log format for pgBadger analysis; pgBadger generates HTML reports with query stats and performance graphs.
## pg_activity
Interactive top-like tool (pip install pg_activity). Run on DB host for OS metrics alongside PG metrics. Combines `pg_stat_activity` with CPU/memory/I/O context.
## Host Metrics — Critical
PostgreSQL cannot report these. **Monitor them yourself:**
- **CPU**: Steal time >10% in VMs bad; load average > core count; context switches >100k/sec.
- **Memory**: Any swap = performance degradation. Check `dmesg` for OOM kills.
- **Disk I/O**: `iostat -x` — `%util=100%` means saturated; `await` >10ms = high latency.
- **Network**: Packet loss >0% = problems; high retransmits = instability.
## Statistics Management
Stats accumulate since last reset or restart; check `stats_reset` timestamp. `pg_stat_statements_reset()` clears query stats; `pg_stat_reset()` clears database stats. Reset after major maintenance, config changes, or perf testing — not routinely. Prefer snapshotting stats to external monitoring (Prometheus, Datadog) over resetting. **Always confirm with a human before resetting statistics** — resetting destroys historical performance baselines and can make it harder to identify unused indexes or regressions.
Readers never block writers; writers never block readers (only writer-writer conflicts on same row). No lock escalation — row locks never degrade to table locks.
## XID Wraparound
32-bit transaction IDs wrap at ~2 billion (2^31). `VACUUM FREEZE` replaces old XIDs with FrozenXID (value 2, always visible). Without freeze: after wraparound, old rows appear "in the future" and become **invisible**. Data physically exists but is invisible to all queries — looks like total data loss. PostgreSQL emergency shutdown at 2B XIDs to prevent this. XID wraparound should be avoided at all cost.
Warning messages start at ~1.4B XIDs; shutdown at 2B. Recovery requires single-user mode VACUUM — can take hours to days on large DBs. **Never disable autovacuum** — it's your protection against wraparound.
## XID Age Monitoring
```sql
SELECTdatname,age(datfrozenxid),
ROUND(100.0*age(datfrozenxid)/2147483648,2)ASpct
FROMpg_databaseORDERBYage(datfrozenxid)DESC;
```
## Long Transaction Impact
A single long-running transaction blocks VACUUM from removing dead tuples across the **entire database**. Causes table bloat, increased disk, slower queries, cache pollution. `idle_in_transaction` connections are the #1 operational MVCC issue. Set `idle_in_transaction_session_timeout` (30s–5min). Dead tuples waste I/O on seq scans and cause useless heap lookups from indexes.
## Serialization Errors
Apps **must** handle "could not serialize access" with retry logic. More common in REPEATABLE READ and SERIALIZABLE. Smaller, faster transactions reduce conflict frequency.
Every `UPDATE` creates a new tuple and marks the old one dead; `DELETE` marks tuples dead. Dead tuples accumulate until `VACUUM` reclaims space. Each transaction gets a 32-bit XID (2^32 ≈ 4B values, but modular comparison means the effective danger zone is 2^31 ≈ 2B). VACUUM must freeze old XIDs to prevent wraparound.
## VACUUM vs VACUUM FULL
`VACUUM` is non-blocking (ShareUpdateExclusive lock) and marks dead space reusable. `VACUUM FULL` rewrites the table and requires an AccessExclusive lock — use only as a last resort. For online bloat reduction prefer `pg_squeeze` or `pg_repack`.
## Autovacuum Tuning
Triggers when dead tuples > `Min(autovacuum_vacuum_max_threshold, autovacuum_vacuum_threshold + autovacuum_vacuum_scale_factor * reltuples)`. `autovacuum_vacuum_max_threshold` defaults to 100M (PG 18+), capping the threshold for very large tables. Also triggers on inserts exceeding `autovacuum_vacuum_insert_threshold + autovacuum_vacuum_insert_scale_factor * reltuples * pct_not_frozen` (ensures insert-only tables get frozen; PG 13+). For large/hot tables, set per-table overrides:
-`autovacuum_vacuum_scale_factor` — default 0.2; lower to 0.01–0.05 for large tables.
-`autovacuum_vacuum_cost_delay` — default 2 ms; set to 0 on fast storage.
-`autovacuum_vacuum_cost_limit` — default -1 (uses `vacuum_cost_limit`, effectively 200); raise to 1000–2000 on fast storage.
- Use `pg_partman` (extension) to automate partition creation and cleanup.
- Use `DETACH PARTITION` to remove a partition while retaining it as a standalone table (e.g., for archiving).
- Use `DETACH PARTITION ... CONCURRENTLY` (PG 14+) to avoid `ACCESS EXCLUSIVE` locks on the parent table.
- Drop old partitions for data retention instead of `DELETE` to avoid vacuum overhead and bloat.
- Create future partitions ahead of time to avoid insert failures.
- **Always confirm with a human before detaching or dropping partitions.** These are destructive actions — detaching removes data from the partitioned table, and dropping permanently deletes the data.
```sql
-- DESTRUCTIVE: confirm with a human before executing
PostgreSQL uses a **multi-process** model, not multi-threaded: one OS process per client connection. The postmaster is the parent; it spawns backend processes per connection. Each backend has some private memory (`work_mem`, temp buffers). 1000 connections = 1000 processes (~5–10MB base + query memory each). There is also a large buffer shared amongst all.
## Auxiliary Processes
WAL Writer, Background Writer, Checkpointer, Autovacuum Launcher/Workers, Archiver, WAL Summarizer (PG 17+). These run alongside backends and are not spawned per connection.
## Memory Risk
`work_mem` is per-operation, not per-query. Estimate: `work_mem × operations_per_query × parallel_workers × connections` can grow very large at high concurrency. Scale connections and parallelism before raising `work_mem`.
## Connection Pooling (Critical)
Each connection = OS process (fork overhead, context switching, memory). PgBouncer can multiplex many app connections to fewer DB connections. Typical: 1000 app connections → pooler → 20–50 backends. Implement pooling before raising `max_connections`; `max_connections` requires a full restart to change (default 100). Note: `superuser_reserved_connections` (default 3) reserves slots for emergency superuser access, so non-superusers are rejected before `max_connections` is fully reached.
Use `pg_activity` for interactive top-like monitoring. Alert at 80% connection usage, critical at 95%. Count by state to find idle-in-transaction leaks — these hold locks and **block VACUUM** from reclaiming dead tuples.
## Common Problems
| Problem | Fix |
| ------- | --- |
| `too many clients already` | Implement pooling; find idle connections; check for connection leaks |
| High memory / OOM | Reduce `work_mem`; add pooling; set `statement_timeout` |
| Stuck process | `SELECT pg_cancel_backend(pid);` then `SELECT pg_terminate_backend(pid);` — **always confirm with a human before terminating backends**, as this may abort in-flight transactions and cause data issues for the application |
Prefer pooling + conservative `max_connections` over raising limits reactively.
tags:postgres, planetscale, cli, insights, query-patterns, api
---
# Query Insights via pscale CLI
Analyze slow queries and missing indexes using `pscale api`. Endpoints may change—see https://planetscale.com/docs/api/reference/getting-started-with-planetscale-api for current API docs.
## Using pscale api
The `pscale api` command makes authenticated API calls using your current login or service token (see [ps-cli-commands.md](ps-cli-commands.md#service-token-cicd) for auth setup). No need to manage auth headers manually.
```bash
pscale api "<endpoint>"[--method POST][--field key=value][--org <org>]
```
## Query Patterns Reports
```bash
# Create a new report
pscale api "organizations/{org}/databases/{db}/branches/{branch}/query-patterns-reports"\
--method POST --org my-org
# Check status (poll until state=complete)
pscale api "organizations/{org}/databases/{db}/branches/{branch}/query-patterns-reports/{id}/status"
# Download completed report
pscale api "organizations/{org}/databases/{db}/branches/{branch}/query-patterns-reports/{id}"
# List all reports
pscale api "organizations/{org}/databases/{db}/branches/{branch}/query-patterns-reports"
```
## Schema Analysis
```bash
# Get branch schema
pscale api "organizations/{org}/databases/{db}/branches/{branch}/schema"
# Lint schema for issues
pscale api "organizations/{org}/databases/{db}/branches/{branch}/schema/lint"
PlanetScale provides PgBouncer for connection pooling. Connect on port `6432` instead of `5432`.
## When to Use PgBouncer (Port 6432)
All OLTP application workloads: web apps, APIs, high-concurrency read/write operations.
## When to Use Direct Connections (Port 5432)
- Schema changes (DDL)
- Analytics, reporting, batch processing
- Session-specific features (temp tables, session variables)
- ETL, data streaming, `pg_dump`
- Long-running admin transactions
## PgBouncer Types
PlanetScale offers three PgBouncer options. All use port `6432`.
| Type | Runs On | Routes To | Key Trait |
| ---- | ------- | --------- | --------- |
| **Local** | Same node as primary | Primary only | Included with every database; no replica routing |
| **Dedicated Primary** | Separate node | Primary | Connections persist through resizes, upgrades, and most failovers |
| **Dedicated Replica** | Separate node | Replicas | Read-only traffic; supports AZ affinity for lower latency |
- **Local PgBouncer** — use same credentials as direct, just change port to `6432`. Always routes to primary regardless of username.
- **Dedicated Primary** — runs off-server for improved HA. Use for production OLTP write traffic.
- **Dedicated Replica** — runs off-server for read-heavy workloads. Supports AZ affinity to prefer same-zone replicas. Multiple can be created for capacity or per-app isolation.
To connect to a dedicated PgBouncer, append `|pgbouncer-name` to the username (e.g., `postgres.xxx|write-pool` or `postgres.xxx|read-bouncer`).
## Transaction Pooling Limitations
PlanetScale PgBouncer uses **transaction pooling mode**. These features are unavailable:
- Prepared statements that persist across transactions
- Temporary tables
-`LISTEN`/`NOTIFY`
- Session-level advisory locks
-`SET` commands persisting beyond a transaction
## Recommended Patterns
- Size pools from observed concurrency, query memory behavior, and connection limits.
- Keep pooled app traffic on `6432` and reserve direct connections for DDL/admin/long-running jobs.
## Avoid Patterns
- Avoid setting pool size with only `CPU_cores * N` while ignoring query-memory amplification.
- Avoid running session-dependent workflows through transaction pooling.
Only use PlanetScale-supported extensions. For the complete and up-to-date list of available extensions, see: https://planetscale.com/docs/postgres/extensions
Do not rely on hard-coded extension lists — always check the documentation above for current availability.
## Enabling Extensions
Some extensions must first be **enabled in the PlanetScale Dashboard** (Clusters > Extensions) before they can be created in SQL. This often requires a database restart.
Once enabled in the dashboard, create the extension in SQL:
```sql
CREATEEXTENSIONIFNOTEXISTS<extension_name>;
```
## Recommended Patterns
- Always check the [PlanetScale extensions docs](https://planetscale.com/docs/postgres/extensions) before assuming an extension is available.
- Verify extension availability in PlanetScale configuration and docs before schema design depends on it.
- Enable `pg_stat_statements` early for baseline query telemetry.
The MCP server is the ideal way to interact with insights from an AI agent.
If not installed, prompt the user to install it to make the agent more effective.
## Query Insights (CLI)
Generating reports via CLI is a multi-step process (create → wait → download).
See [ps-cli-api-insights.md](https://raw.githubusercontent.com/planetscale/database-skills/main/skills/postgres/references/ps-cli-api-insights.md) for how to use.
What to look for:
- High `rows_read / rows_returned` ratio → missing index
- High `total_time_s` → optimization target
## Insights UI (Dashboard)
In the [PlanetScale dashboard](https://app.planetscale.com/), select your database and click **Insights**.
- **Filtering** — Pick a branch, choose primary or replica, and scroll through the last 7 days. Click-and-drag on graphs to zoom into a time window.
- **Graphs** — Four tabs: Query latency (p50/p95/p99/p99.9), Queries per second, Rows read/s, and Rows written/s.
- **Queries table** — All queries in the selected timeframe, normalized into patterns. Sortable and filterable by SQL, schema, table, latency, index usage, and more. Customizable columns (count, total time, latency percentiles, rows read/returned/affected, CPU/IO time, cache hit ratio, etc.). Enable sparklines for inline trend graphs. Orange icons flag full table scans.
- **Query deep dive** — Click any query to see per-pattern graphs, summary stats, index usage breakdown, and a table of notable executions (>1 s, >10k rows read, or errors). Use "Summarize query" for an LLM-generated plain-English description.
- **Anomalies tab** — Flags periods with elevated slow-running queries and surfaces the responsible patterns.
- **Errors tab** — Surfaces queries that produced errors.
- **pginsights settings** — `pginsights.raw_queries` enables full query text collection for notable queries; `pginsights.normalize_schema_names` groups identical patterns across schemas (useful for schema-per-tenant designs). Both configurable in the Extensions tab on the Clusters page.
**Always confirm with a human before removing indexes, dropping tables/partitions, or archiving data.** These are destructive actions that cannot be easily undone.
Use physical (byte-for-byte) replication via WAL stream from primary to standbys. Standbys are read-only (hot standby); same major PG version and architecture required (same minor recommended). Without replication slots, the primary may recycle WAL before the standby receives it → standby needs full resync via `pg_basebackup`. Use replication slots to guarantee WAL retention for specific standbys.
## Replication Slots
Postgres supports Physical slots (streaming) and logical slots (logical replication). Slots prevent WAL deletion even if standby is offline — can exhaust `pg_wal/` disk. Use `max_slot_wal_keep_size` to cap retained WAL per slot. Use `idle_replication_slot_timeout` (PG 17+) to auto-invalidate idle slots. `wal_keep_size` is a simpler alternative to slots for WAL retention. Drop inactive slots immediately to prevent disk exhaustion.
Slot lag (MB behind): `SELECT slot_name, pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)/1024/1024 AS mb_behind FROM pg_replication_slots;`
Drop inactive slot: `SELECT pg_drop_replication_slot('slot_name');`
**Always confirm with a human before dropping replication slots.** Dropping an active or needed slot can cause downstream issues.
## Synchronous Commit Levels
| Level | Behavior | Use Case |
|-------|----------|----------|
| `off` | Returns immediately, no wait | Non-critical writes; risks losing ~600ms of commits on crash (no inconsistency) |
| `local` | Waits for local WAL fsync only | Local durability only; no standby wait |
| `remote_write` | Waits for standby OS buffer | Data loss on standby OS crash |
| `on` | Waits for standby WAL to disk when `synchronous_standby_names` is set; otherwise same as `local` | **Default. This level or higher recommended for HA** |
| `remote_apply` | Waits for standby to apply WAL | Strongest; read-your-writes |
Configure with `synchronous_standby_names`. Use `ANY N` for quorum or `FIRST N` for priority-based sync.
## Quorum and Failure
`FIRST 2 (s1, s2, s3)` is priority-based: waits for the 2 highest-priority connected standbys (s1+s2; s3 takes over only if one disconnects). `ANY 2 (s1, s2, s3)` is quorum-based: waits for any 2. With either, if only 1 is healthy, commits hang. Provision at least N+1 standbys: need 2 confirmations → provision 3. PostgreSQL never commits unless required standbys confirm — no inconsistency, but clients may timeout.
## Failover
`pg_ctl promote` or `SELECT pg_promote()` (SQL function, PG 12+) converts standby to primary. One-way: promoted standby cannot rejoin as standby without rebuild. `pg_rewind` can resync old primary to new primary (requires `wal_log_hints=on` or data checksums) — faster than full rebuild. After promotion: update connection strings, rebuild old primary as standby, reconfigure other standbys.
## Monitoring
On the primary, query `pg_stat_replication` for each connected standby's `state` (`streaming` = healthy, `catchup` = behind), `sync_state` (`sync`/`async`), and LSN positions (`sent_lsn`, `write_lsn`, `flush_lsn`, `replay_lsn`) to compute lag. On standbys, `pg_stat_wal_receiver` shows the receiver process status and `flushed_lsn`; compare `pg_last_wal_receive_lsn()` vs `pg_last_wal_replay_lsn()` for local replay lag.
Replication lag (MB): `SELECT application_name, pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn)/1024/1024 AS lag_mb FROM pg_stat_replication;`
Enable `wal_compression` (`pglz`, `lz4`, or `zstd`) to compress full page images in WAL (not all WAL data) — reduces WAL size for bandwidth-limited replication.
"Cluster" in PostgreSQL = single instance with one PGDATA, not an HA cluster. Each table/index = one or more files, split into 1GB segments. Tables have companion **_fsm** (free space map) and **_vm** (visibility map); indexes have **_fsm** only (no _vm), except hash indexes.
## Visibility Map and Free Space Map
- **_vm** tracks all-visible pages — VACUUM skips these
- **_fsm** tracks free space per page — INSERT uses this to find pages with room
- Both are small files but critical for performance
## TOAST
TOAST triggers when a **row** exceeds ~2KB. Large values are compressed and/or moved out-of-line to `pg_toast.pg_toast_<oid>` tables. **Strategies:** PLAIN (no TOAST), EXTENDED (compress+out-of-line, default for text/bytea), EXTERNAL (out-of-line, no compression — use for pre-compressed data), MAIN (compress, avoid out-of-line). TOAST tables bloat like regular tables — they need VACUUM. `SELECT *` fetches all TOAST columns; always SELECT only needed columns. Move large rarely-accessed columns to separate tables.
## Fillfactor
Controls how full pages are packed (default 100%). Lower fillfactor (70–80%) leaves room for HOT (Heap-Only Tuple) updates, which avoid index entries and reduce bloat on UPDATE-heavy tables. Keep 100% for insert-only or read-mostly tables. `ALTER TABLE t SET (fillfactor = 70);`
## Tablespaces
`pg_default` (base/), `pg_global` (global/) are built-in. Custom tablespaces: symbolic links in **pg_tblspc/** to other filesystem locations. Use for separating hot data (SSD) from archives (HDD). Moving tablespaces requires exclusive lock on affected tables.
Write-Ahead Logging: logs changes to `pg_wal/`**before** modifying data files. WAL segments are 16MB (fixed at initdb). On COMMIT, PostgreSQL fsyncs WAL to disk and returns SUCCESS — data files are updated lazily. WAL records are written for all changes (including uncommitted transactions and rollbacks). **Never disable `fsync` in production** — power loss without fsync risks unrecoverable data loss.
A dirty page is modified in shared_buffers but not yet written to data files. A checkpoint flushes all dirty pages to disk and writes a checkpoint record to WAL; recovery only replays WAL since the last checkpoint.
-`checkpoint_timeout` (default 5 min) and `max_wal_size` (default 1GB) — checkpoint on whichever triggers first.
-`checkpoint_completion_target=0.9` spreads I/O over 90% of the interval; avoid spikes.
- "Checkpoints are occurring too frequently" in logs → increase `max_wal_size`.
- **Target: >90% of checkpoints should be time-based** (`num_timed` in `pg_stat_checkpointer`), not size-based (`num_requested`). If num_requested/(num_timed+num_requested) > 10%, tune `max_wal_size` up.
## WAL Disk Management
Replication slots prevent WAL deletion even when standbys are offline — they can fill disk. WAL archiving failures also block recycling. `max_wal_size` is a *soft* limit; WAL can grow beyond it under heavy load.
WAL size: `SELECT count(*) AS files, pg_size_pretty(sum(size)) AS total FROM pg_ls_waldir();`
Slot lag: `SELECT slot_name, pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn) AS lag_bytes FROM pg_replication_slots;`
## Checkpoint Monitoring
PG17+ moved checkpoint stats from `pg_stat_bgwriter` to `pg_stat_checkpointer` and renamed columns.
`SELECT num_timed, num_requested, write_time, sync_time, buffers_written FROM pg_stat_checkpointer;`
Backend-direct writes (formerly `buffers_backend` in `pg_stat_bgwriter`) are now tracked in `pg_stat_io`: `SELECT writes FROM pg_stat_io WHERE backend_type = 'client backend' AND object = 'relation';`
## Crash Recovery
On crash, PostgreSQL replays WAL from the last checkpoint. Longer checkpoint intervals → more WAL to replay → longer recovery. Trade-off: frequent checkpoints (faster recovery, more I/O) vs infrequent (less I/O, slower recovery). For most workloads, `checkpoint_timeout=5min` and `max_wal_size` tuned to keep checkpoints time-based is the right balance.
description:React and Next.js performance optimization guidelines from Vercel Engineering. This skill should be used when writing, reviewing, or refactoring React/Next.js code to ensure optimal performance patterns. Triggers on tasks involving React components, Next.js pages, data fetching, bundle optimization, or performance improvements.
license:MIT
metadata:
author:vercel
version:"1.0.0"
---
# Vercel React Best Practices
Comprehensive performance optimization guide for React and Next.js applications, maintained by Vercel. Contains 57 rules across 8 categories, prioritized by impact to guide automated refactoring and code generation.
## When to Apply
Reference these guidelines when:
- Writing new React components or Next.js pages
- Implementing data fetching (client or server-side)
`useEffectEvent` provides a cleaner API for the same pattern: it creates a stable function reference that always calls the latest version of the handler.
Do not put app-wide initialization that must run once per app load inside `useEffect([])` of a component. Components can remount and effects will re-run. Use a module-level guard or top-level init in the entry module instead.
**Incorrect (runs twice in dev, re-runs on remount):**
```tsx
functionComp() {
useEffect(()=>{
loadFromStorage()
checkAuthToken()
},[])
// ...
}
```
**Correct (once per app load):**
```tsx
letdidInit=false
functionComp() {
useEffect(()=>{
if(didInit)return
didInit=true
loadFromStorage()
checkAuthToken()
},[])
// ...
}
```
Reference: [Initializing the application](https://react.dev/learn/you-might-not-need-an-effect#initializing-the-application)
Import directly from source files instead of barrel files to avoid loading thousands of unused modules. **Barrel files** are entry points that re-export multiple modules (e.g., `index.js` that does `export * from './module'`).
Popular icon and component libraries can have **up to 10,000 re-exports** in their entry file. For many React packages, **it takes 200-800ms just to import them**, affecting both development speed and production cold starts.
**Why tree-shaking doesn't help:** When a library is marked as external (not bundled), the bundler can't optimize it. If you bundle it to enable tree-shaking, builds become substantially slower analyzing the entire module graph.
## Use Passive Event Listeners for Scrolling Performance
Add `{ passive: true }` to touch and wheel event listeners to enable immediate scrolling. Browsers normally wait for listeners to finish to check if `preventDefault()` is called, causing scroll delay.
Avoid interleaving style writes with layout reads. When you read a layout property (like `offsetWidth`, `getBoundingClientRect()`, or `getComputedStyle()`) between style changes, the browser is forced to trigger a synchronous reflow.
Prefer CSS classes over inline styles when possible. CSS files are cached by the browser, and classes provide better separation of concerns and are easier to maintain.
See [this gist](https://gist.github.com/paulirish/5d52fb081b3570c81e3a) and [CSS Triggers](https://csstriggers.com/) for more information on layout-forcing operations.
When comparing arrays with expensive operations (sorting, deep equality, serialization), check lengths first. If lengths differ, the arrays cannot be equal.
In real-world applications, this optimization is especially valuable when the comparison runs in hot paths (event handlers, render loops).
Two O(n log n) sorts run even when `current.length` is 5 and `original.length` is 100. There is also overhead of joining the arrays and comparing the strings.
Single pass through the array, no copying, no sorting.
**Alternative (Math.min/Math.max for small arrays):**
```typescript
constnumbers=[5,2,8,1,9]
constmin=Math.min(...numbers)
constmax=Math.max(...numbers)
```
This works for small arrays, but can be slower or just throw an error for very large arrays due to spread operator limitations. Maximal array length is approximately 124000 in Chrome 143 and 638000 in Safari 18; exact numbers may vary - see [the fiddle](https://jsfiddle.net/qw1jabsx/4/). Use the loop approach for reliability.
## Use toSorted() Instead of sort() for Immutability
`.sort()` mutates the array in place, which can cause bugs with React state and props. Use `.toSorted()` to create a new sorted array without mutation.
This applies to all CSS transforms and transitions (`transform`, `opacity`, `translate`, `scale`, `rotate`). The wrapper div allows browsers to use GPU acceleration for smoother animations.
Use explicit ternary operators (`? :`) instead of `&&` for conditional rendering when the condition can be `0`, `NaN`, or other falsy values that render.
**Incorrect (renders "0" when count is 0):**
```tsx
functionBadge({count}:{count: number}){
return(
<div>
{count&&<spanclassName="badge">{count}</span>}
</div>
)
}
// When count = 0, renders: <div>0</div>
// When count = 5, renders: <div><span class="badge">5</span></div>
This is especially helpful for large and static SVG nodes, which can be expensive to recreate on every render.
**Note:** If your project has [React Compiler](https://react.dev/learn/react-compiler) enabled, the compiler automatically hoists static JSX elements and optimizes component re-renders, making manual hoisting unnecessary.
When rendering content that depends on client-side storage (localStorage, cookies), avoid both SSR breakage and post-hydration flickering by injecting a synchronous script that updates the DOM before React hydrates.
var theme = localStorage.getItem('theme') || 'light';
var el = document.getElementById('theme-wrapper');
if (el) el.className = theme;
} catch (e) {}
})();
`,
}}
/>
</>
)
}
```
The inline script executes synchronously before showing the element, ensuring the DOM already has the correct value. No flickering, no hydration mismatch.
This pattern is especially useful for theme toggles, user preferences, authentication states, and any client-only data that should render immediately without flashing default values.
impactDescription:avoids noisy hydration warnings for known differences
tags:rendering, hydration, ssr, nextjs
---
## Suppress Expected Hydration Mismatches
In SSR frameworks (e.g., Next.js), some values are intentionally different on server vs client (random IDs, dates, locale/timezone formatting). For these *expected* mismatches, wrap the dynamic text in an element with `suppressHydrationWarning` to prevent noisy warnings. Do not use this to hide real bugs. Don’t overuse it.
Reduce SVG coordinate precision to decrease file size. The optimal precision depends on the viewBox size, but in general reducing precision should be considered.
**Incorrect (excessive precision):**
```svg
<pathd="M 10.293847 20.847362 L 30.938472 40.192837"/>
impactDescription:avoids redundant renders and state drift
tags:rerender, derived-state, useEffect, state
---
## Calculate Derived State During Rendering
If a value can be computed from current props/state, do not store it in state or update it in an effect. Derive it during render to avoid extra renders and state drift. Do not set state in effects solely in response to prop changes; prefer derived values or keyed resets instead.
**Incorrect (redundant state and effect):**
```tsx
functionForm() {
const[firstName,setFirstName]=useState('First')
const[lastName,setLastName]=useState('Last')
const[fullName,setFullName]=useState('')
useEffect(()=>{
setFullName(firstName+' '+lastName)
},[firstName,lastName])
return<p>{fullName}</p>
}
```
**Correct (derive during render):**
```tsx
functionForm() {
const[firstName,setFirstName]=useState('First')
const[lastName,setLastName]=useState('Last')
constfullName=firstName+' '+lastName
return<p>{fullName}</p>
}
```
References: [You Might Not Need an Effect](https://react.dev/learn/you-might-not-need-an-effect)
When updating state based on the current state value, use the functional update form of setState instead of directly referencing the state variable. This prevents stale closures, eliminates unnecessary dependencies, and creates stable callback references.
**Incorrect (requires state as dependency):**
```tsx
functionTodoList() {
const[items,setItems]=useState(initialItems)
// Callback must depend on items, recreated on every items change
The first callback is recreated every time `items` changes, which can cause child components to re-render unnecessarily. The second callback has a stale closure bug—it will always reference the initial `items` value.
**Correct (stable callbacks, no stale closures):**
```tsx
functionTodoList() {
const[items,setItems]=useState(initialItems)
// Stable callback, never recreated
constaddItems=useCallback((newItems: Item[])=>{
setItems(curr=>[...curr,...newItems])
},[])// ✅ No dependencies needed
// Always uses latest state, no stale closure risk
1.**Stable callback references** - Callbacks don't need to be recreated when state changes
2.**No stale closures** - Always operates on the latest state value
3.**Fewer dependencies** - Simplifies dependency arrays and reduces memory leaks
4.**Prevents bugs** - Eliminates the most common source of React closure bugs
**When to use functional updates:**
- Any setState that depends on the current state value
- Inside useCallback/useMemo when state is needed
- Event handlers that reference state
- Async operations that update state
**When direct updates are fine:**
- Setting state to a static value: `setCount(0)`
- Setting state from props/arguments only: `setName(newName)`
- State doesn't depend on previous value
**Note:** If your project has [React Compiler](https://react.dev/learn/react-compiler) enabled, the compiler can automatically optimize some cases, but functional updates are still recommended for correctness and to prevent stale closure bugs.
Pass a function to `useState` for expensive initial values. Without the function form, the initializer runs on every render even though the value is only used once.
**Incorrect (runs on every render):**
```tsx
function FilteredList({ items }: { items: Item[] }) {
// buildSearchIndex() runs on EVERY render, even after initialization
Use lazy initialization when computing initial values from localStorage/sessionStorage, building data structures (indexes, maps), reading from the DOM, or performing heavy transformations.
For simple primitives (`useState(0)`), direct references (`useState(props.value)`), or cheap literals (`useState({})`), the function form is unnecessary.
title: Extract Default Non-primitive Parameter Value from Memoized Component to Constant
impact: MEDIUM
impactDescription: restores memoization by using a constant for default value
tags: rerender, memo, optimization
---
## Extract Default Non-primitive Parameter Value from Memoized Component to Constant
When memoized component has a default value for some non-primitive optional parameter, such as an array, function, or object, calling the component without that parameter results in broken memoization. This is because new value instances are created on every rerender, and they do not pass strict equality comparison in `memo()`.
To address this issue, extract the default value into a constant.
**Incorrect (`onClick` has different values on every rerender):**
Extract expensive work into memoized components to enable early returns before computation.
**Incorrect (computes avatar even when loading):**
```tsx
function Profile({ user, loading }: Props) {
const avatar = useMemo(() => {
const id = computeAvatarId(user)
return <Avatar id={id} />
}, [user])
if (loading) return <Skeleton />
return <div>{avatar}</div>
}
```
**Correct (skips computation when loading):**
```tsx
const UserAvatar = memo(function UserAvatar({ user }: { user: User }) {
const id = useMemo(() => computeAvatarId(user), [user])
return <Avatar id={id} />
})
function Profile({ user, loading }: Props) {
if (loading) return <Skeleton />
return (
<div>
<UserAvatar user={user} />
</div>
)
}
```
**Note:** If your project has [React Compiler](https://react.dev/learn/react-compiler) enabled, manual memoization with `memo()` and `useMemo()` is not necessary. The compiler automatically optimizes re-renders.
If a side effect is triggered by a specific user action (submit, click, drag), run it in that event handler. Do not model the action as state + effect; it makes effects re-run on unrelated changes and can duplicate the action.
Reference: [Should this code move to an event handler?](https://react.dev/learn/removing-effect-dependencies#should-this-code-move-to-an-event-handler)
title: Do not wrap a simple expression with a primitive result type in useMemo
impact: LOW-MEDIUM
impactDescription: wasted computation on every render
tags: rerender, useMemo, optimization
---
## Do not wrap a simple expression with a primitive result type in useMemo
When an expression is simple (few logical or arithmetical operators) and has a primitive result type (boolean, number, string), do not wrap it in `useMemo`.
Calling `useMemo` and comparing hook dependencies may consume more resources than the expression itself.
impactDescription: avoids unnecessary re-renders on frequent updates
tags: rerender, useref, state, performance
---
## Use useRef for Transient Values
When a value changes frequently and you don't want a re-render on every update (e.g., mouse trackers, intervals, transient flags), store it in `useRef` instead of `useState`. Keep component state for UI; use refs for temporary DOM-adjacent values. Updating a ref does not trigger a re-render.
Use Next.js's `after()` to schedule work that should execute after a response is sent. This prevents logging, analytics, and other side effects from blocking the response.
**Impact: CRITICAL (prevents unauthorized access to server mutations)**
Server Actions (functions with `"use server"`) are exposed as public endpoints, just like API routes. Always verify authentication and authorization **inside** each Server Action—do not rely solely on middleware, layout guards, or page-level checks, as Server Actions can be invoked directly.
Next.js documentation explicitly states: "Treat Server Actions with the same security considerations as public-facing API endpoints, and verify if the user is allowed to perform a mutation."
**Incorrect (no authentication check):**
```typescript
'use server'
export async function deleteUser(userId: string) {
// Anyone can call this! No auth check
await db.user.delete({ where: { id: userId } })
return { success: true }
}
```
**Correct (authentication inside the action):**
```typescript
'use server'
import { verifySession } from '@/lib/auth'
import { unauthorized } from '@/lib/errors'
export async function deleteUser(userId: string) {
// Always check auth inside the action
const session = await verifySession()
if (!session) {
throw unauthorized('Must be logged in')
}
// Check authorization too
if (session.user.role !== 'admin' && session.user.id !== userId) {
throw unauthorized('Cannot delete other users')
}
await db.user.delete({ where: { id: userId } })
return { success: true }
}
```
**With input validation:**
```typescript
'use server'
import { verifySession } from '@/lib/auth'
import { z } from 'zod'
const updateProfileSchema = z.object({
userId: z.string().uuid(),
name: z.string().min(1).max(100),
email: z.string().email()
})
export async function updateProfile(data: unknown) {
`React.cache()` only works within one request. For data shared across sequential requests (user clicks button A then button B), use an LRU cache.
**Implementation:**
```typescript
import { LRUCache } from 'lru-cache'
const cache = new LRUCache<string, any>({
max: 1000,
ttl: 5 * 60 * 1000 // 5 minutes
})
export async function getUser(id: string) {
const cached = cache.get(id)
if (cached) return cached
const user = await db.user.findUnique({ where: { id } })
cache.set(id, user)
return user
}
// Request 1: DB query, result cached
// Request 2: cache hit, no DB query
```
Use when sequential user actions hit multiple endpoints needing the same data within seconds.
**With Vercel's [Fluid Compute](https://vercel.com/docs/fluid-compute):** LRU caching is especially effective because multiple concurrent requests can share the same function instance and cache. This means the cache persists across requests without needing external storage like Redis.
**In traditional serverless:** Each invocation runs in isolation, so consider Redis for cross-process caching.
If you must pass objects, pass the same reference:
```typescript
const params = { uid: 1 }
getUser(params) // Query runs
getUser(params) // Cache hit (same reference)
```
**Next.js-Specific Note:**
In Next.js, the `fetch` API is automatically extended with request memoization. Requests with the same URL and options are automatically deduplicated within a single request, so you don't need `React.cache()` for `fetch` calls. However, `React.cache()` is still essential for other async tasks:
- Database queries (Prisma, Drizzle, etc.)
- Heavy computations
- Authentication checks
- File system operations
- Any non-fetch async work
Use `React.cache()` to deduplicate these operations across your component tree.
**Impact: LOW (reduces network payload by avoiding duplicate serialization)**
RSC→client serialization deduplicates by object reference, not value. Same reference = serialized once; new reference = serialized again. Do transformations (`.toSorted()`, `.filter()`, `.map()`) in client, not server.
The React Server/Client boundary serializes all object properties into strings and embeds them in the HTML response and subsequent RSC requests. This serialized data directly impacts page weight and load time, so **size matters a lot**. Only pass fields that the client actually uses.
description: Review UI code for Web Interface Guidelines compliance. Use when asked to "review my UI", "check accessibility", "audit design", "review UX", or "check my site against best practices".
metadata:
author: vercel
version: "1.0.0"
argument-hint: <file-or-pattern>
---
# Web Interface Guidelines
Review files for compliance with Web Interface Guidelines.
## How It Works
1. Fetch the latest guidelines from the source URL below
2. Read the specified files (or prompt user for files/pattern)
3. Check against all rules in the fetched guidelines
4. Output findings in the terse `file:line` format
Triggers a task and polls until completion. Not recommended for web requests as it blocks until the run completes. Consider using Realtime docs for better alternatives.
```typescript
import { tasks } from "@trigger.dev/sdk/v3";
import type { emailSequence } from "~/trigger/emails";
export async function POST(request: Request) {
const data = await request.json();
const result = await tasks.triggerAndPoll<typeof emailSequence>(
"email-sequence",
{
to: data.email,
name: data.name,
},
{ pollIntervalMs: 5000 }
);
return Response.json(result);
}
```
### batch.trigger()
Triggers multiple runs of different tasks at once, useful when you need to execute multiple tasks simultaneously.
```typescript
```ts
import { batch } from "@trigger.dev/sdk/v3";
import type { myTask1, myTask2 } from "~/trigger/myTasks";
@@ -482,7 +465,7 @@ export async function POST(request: Request) {
Triggers a single run of a task with specified payload and options.
```typescript
```ts
import { myOtherTask, runs } from "~/trigger/my-other-task";
export const myTask = task({
@@ -502,13 +485,15 @@ If you need to call `trigger()` on a task in a loop, use `batchTrigger()` instea
Triggers multiple runs of a single task with different payloads.
```typescript
import { myOtherTask, batch } from "~/trigger/my-other-task";
```ts
import { batch, myOtherTask } from "~/trigger/my-other-task";
external: ["header-generator"], // Don't bundle these packages
jsx: {
@@ -1245,11 +1245,11 @@ You can also create custom build extensions with hooks like `onBuildStart`, `onB
#### Trigger with:
```typescript
```ts
await myTask.trigger({ name: "Alice", age: 30 });
```
## AI MODEL VERIFICATION STEPS
## AI model verification steps
Before generating any code, you MUST verify:
@@ -1257,7 +1257,7 @@ Before generating any code, you MUST verify:
2. Have you exported every task? If not, STOP and FIX.
3. Have you generated any DEPRECATED code patterns? If yes, STOP and FIX.
## CONSEQUENCES OF INCORRECT IMPLEMENTATION
## Consequences of incorrect implementations
If you generate code that fails the verification steps above, your implementation will:
@@ -1265,10 +1265,10 @@ If you generate code that fails the verification steps above, your implementatio
2. Fail to deploy to the Trigger.dev servers
3. Fail to run in a local Dev environment
## AI MODEL RESPONSE TEMPLATE
## AI model response template
When asked about Trigger.dev task implementation, you MUST:
1. FIRST use code patterns from this guide
2. NEVER suggest deprecated approaches
3. VERIFY your response against the patterns shown here
4. If an answer cannot be found using this guide, look up further information ONLY from the official LLM-friendly version of the [Trigger.dev docs site](mdc:https:/trigger.dev/docs/llms.txt).
<textx="378"y="322"font-family="Inter,system-ui,sans-serif"font-size="30"fill="#ffffff"opacity=".66">Papermark is the open-source DocSend alternative with built-in…</text>
if:(github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target'
@@ -25,10 +25,10 @@ jobs:
# This token is required only if you have configured to store the signatures in a remote repository/organization
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.