Clear the live error-tracking issues, and the workspace rename that renamed the wrong workspace (#533)

* feat: stop a managed Kafka cluster refusing topic creation from failing the publish, by treating a topic- or cluster-authorization failure from CreateTopics as a topic the cluster owns rather than one that is missing, which on Confluent Cloud dropped every warmup event and filed one issue per message because the topic never became known

* feat: drop a report whose error is a cancelled context in errs rather than at ninety call sites, so a browser navigating away or a container draining on deploy stops filing one issue per query that happened to be in flight, while a deadline this process set and blew through still reports

* feat: stop renaming one workspace from renaming another, by keying the workspace settings editor on the workspace id so a switch re-seeds the name field instead of leaving the previous workspace's name against the new workspace's autosave baseline, and pinning every save on the workspace, sending and tracking pages to the workspace its draft was hydrated from

* feat: drop Script error. and the ResizeObserver notice on the marketing site and the hosted form page the way the dashboard and admin panel already do, since those two carry no stack and no bug and between them were the largest issues in error tracking, all of it from warmbly.com

* feat: rename the forms Turnstile script module to turnstileScript.ts so it no longer differs only in case from the Turnstile.tsx component, which resolved both imports to one file on a case-insensitive filesystem and failed forms' typecheck with TS1149

* feat: upload source maps from the static build:pages build as well as the image build, so the dashboards served from a static host stop reporting every stack frame as a minified name beside 'Invalid source map: bad json', which is PostHog falling back to fetching the .map from a host that answers with its SPA fallback

* feat: build every admin list in pg_admin.go with make rather than declaring it nil, so an empty page serializes as [] instead of null, and guard the audit table's own empty check, which is what crashed admin.warmbly.com/audit with 'null is not an object (evaluating d.data.length)' whenever a filter matched nothing

* feat: match the whole broker description rather than a substring when deciding a topic create was refused for permissions, since that answer remembers the topic as present, and clear the cached promise and dead tag when the forms Turnstile script fails to load so a blocked first attempt no longer leaves every later mount with the same rejection and the captcha permanently missing
This commit is contained in:
Matthew Meszaros
2026-09-15 09:05:53 -07:00
committed by GitHub
parent c4c58cc116
commit ae012dd13f
18 changed files with 309 additions and 50 deletions
+3 -2
View File
@@ -12,8 +12,9 @@
"preview": "vite preview",
"test": "vitest",
"test:run": "vitest run",
"build:pages": "vite build && WARMBLY_CONFIG_OUT=dist/config.js sh ./docker-entrypoint.sh",
"sourcemaps:posthog": "pnpm dlx --package @posthog/cli@0.18.2 posthog-cli sourcemap inject --directory dist && pnpm dlx --package @posthog/cli@0.18.2 posthog-cli sourcemap upload --directory dist --delete-after"
"build:pages": "vite build && pnpm sourcemaps:maybe && WARMBLY_CONFIG_OUT=dist/config.js sh ./docker-entrypoint.sh",
"sourcemaps:posthog": "pnpm dlx --package @posthog/cli@0.18.2 posthog-cli sourcemap inject --directory dist && pnpm dlx --package @posthog/cli@0.18.2 posthog-cli sourcemap upload --directory dist --delete-after",
"sourcemaps:maybe": "sh -c 'if [ -n \"$POSTHOG_CLI_PROJECT_ID\" ] && [ -n \"$POSTHOG_CLI_API_KEY\" ]; then pnpm sourcemaps:posthog; else echo \"no PostHog CLI credentials; skipping the source map upload\"; fi'"
},
"dependencies": {
"@fontsource/inter": "^5.2.8",
+1 -1
View File
@@ -183,7 +183,7 @@ export default function AuditPage() {
{(data?.data ?? []).map((row) => (
<Row key={row.id} row={row} />
))}
{data && data.data.length === 0 && (
{data && !data.data?.length && (
<tr>
<td colSpan={6} className="text-center text-muted-foreground py-8 text-sm">
No audit entries match these filters.
+4 -1
View File
@@ -129,7 +129,10 @@ export interface AdminAuditLogSearch {
}
export interface AdminAuditLogsResult {
data: AdminAuditLog[];
// Nullable on purpose, like AdminUserPreview's slices: a backend older than
// the make-not-declare fix in pg_admin.go answers null for an empty page,
// and typing it as an array is what let `data.data.length` crash the page.
data: AdminAuditLog[] | null;
pagination: {
cursor?: string;
has_more?: boolean;
@@ -537,12 +537,14 @@ A browser stack trace is minified without them. Uploading them is optional and o
| Build variable | Backend | What it does |
|---|---|---|
| `POSTHOG_CLI_API_KEY`, `POSTHOG_CLI_PROJECT_ID` | PostHog | Both set makes the image build run `pnpm sourcemaps:posthog` after `vite build`, which injects a chunk id, uploads, and deletes the `.map` files it sent. The `web`, `admin` and `forms` images all do this, so a form page's stack trace is readable too |
| `POSTHOG_CLI_API_KEY`, `POSTHOG_CLI_PROJECT_ID` | PostHog | Both set makes the build run `pnpm sourcemaps:posthog` after `vite build`, which injects a chunk id, uploads, and deletes the `.map` files it sent. The `web`, `admin` and `forms` images all do this, so a form page's stack trace is readable too |
| `POSTHOG_CLI_HOST` | PostHog | The instance to upload to, for PostHog EU or a self-hosted one |
| `SENTRY_AUTH_TOKEN`, `SENTRY_ORG`, `SENTRY_PROJECT` | Sentry | All three set puts the Sentry Vite plugin in the build |
In this repository's release workflow the two project ids are repository variables and the two keys are repository secrets, passed to the image build as build secrets. Nothing in the app has to match a release name: PostHog pairs a stack frame with its map through the chunk id the CLI injected into the served file, so an event from any build resolves as long as that build's maps were uploaded.
A static host builds with `pnpm build:pages` instead of an image, and that runs the same upload when the same two variables are set in the host's build environment. Setting them there matters more than it looks: with no maps uploaded, PostHog falls back to fetching `<bundle>.js.map` from the site itself, a static host answers that with its SPA fallback, and every frame in every issue reads `Invalid source map: bad json` beside a minified function name.
### Product analytics and session replay
These are the same two keys as the error tracking above, and they exist for the hosted service. A self-hosted instance that sets no key loads no analytics and sends no usage data: the installer never asks about them, and a build with none of them set contains no analytics script to block. An instance that does set a key gets everything below.
+1 -1
View File
@@ -17,7 +17,7 @@ import { visitorKey } from "./events";
import type { AnswerValue } from "./fields";
import { FieldControl } from "./fields";
import { Turnstile } from "./Turnstile";
import { resetTurnstile } from "./turnstile";
import { resetTurnstile } from "./turnstileScript";
type Answers = Record<string, AnswerValue>;
+2 -2
View File
@@ -1,10 +1,10 @@
// Cloudflare Turnstile widget, rendered explicitly so React owns the mount
// point. resetTurnstile() (turnstile.ts) clears the used token after a
// point. resetTurnstile() (turnstileScript.ts) clears the used token after a
// rejected submit.
import { useEffect, useRef } from "react";
import { loadTurnstileScript } from "./turnstile";
import { loadTurnstileScript } from "./turnstileScript";
export function Turnstile({ siteKey, onToken }: { siteKey: string; onToken: (token: string) => void }) {
const el = useRef<HTMLDivElement>(null);
+21 -1
View File
@@ -16,7 +16,7 @@
// surface where that is the right call: the screen would be somebody typing
// their answers into a customer's form. Pageviews, autocapture, heatmaps, web
// vitals, exceptions and the named funnel events below all work without it.
import type { PostHog } from "posthog-js";
import type { CaptureResult, PostHog } from "posthog-js";
let client: PostHog | null = null;
@@ -60,6 +60,7 @@ export function initErrorReporting(): void {
capture_console_errors: true,
}
: false,
before_send: dropBrowserNoise,
});
// Named so form-page events are separable from the dashboard's in a
// shared project, the same way the Go services set a service
@@ -114,3 +115,22 @@ export function track(event: Event, form: string): void {
}
if (pending && pending.length < PENDING_LIMIT) pending.push({ event, form });
}
// Browser noise: reported by the window error handler with no stack and no bug
// behind it. A form page is embedded in a customer's own site, so a script of
// theirs failing arrives here as the opaque "Script error."; the ResizeObserver
// notice is a benign scheduling message the spec requires browsers to fire.
// The dashboard, the admin panel and the marketing site drop the same three.
const NOISE = [
"Script error.",
"ResizeObserver loop completed with undelivered notifications.",
"ResizeObserver loop limit exceeded",
];
function dropBrowserNoise(event: CaptureResult | null): CaptureResult | null {
if (!event?.properties || event.event !== "$exception") return event;
const values = event.properties.$exception_values;
if (!Array.isArray(values)) return event;
if (values.some((v) => typeof v === "string" && NOISE.includes(v.trim()))) return null;
return event;
}
+47
View File
@@ -0,0 +1,47 @@
// Cloudflare Turnstile script plumbing, split from the widget component so
// the component file only exports components (react-refresh constraint).
export interface TurnstileAPI {
render: (el: HTMLElement, opts: Record<string, unknown>) => string;
remove: (id: string) => void;
reset: (id?: string) => void;
}
declare global {
interface Window {
turnstile?: TurnstileAPI;
}
}
const SCRIPT_SRC = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
let scriptLoading: Promise<void> | null = null;
export function loadTurnstileScript(): Promise<void> {
if (window.turnstile) return Promise.resolve();
scriptLoading ??= new Promise((resolve, reject) => {
const s = document.createElement("script");
s.src = SCRIPT_SRC;
s.async = true;
s.onload = () => resolve();
s.onerror = () => {
// Drop the cached promise and the dead tag, or every later mount
// gets this same rejection back: one blocked or flaky load left the
// captcha permanently missing, and a form that requires one cannot
// be submitted at all until the visitor reloads the page.
scriptLoading = null;
s.remove();
reject(new Error("turnstile script failed to load"));
};
document.head.appendChild(s);
});
return scriptLoading;
}
export function resetTurnstile() {
try {
window.turnstile?.reset();
} catch {
// a widget that was never rendered has nothing to reset
}
}
@@ -72,6 +72,19 @@ func (b *KafkaBus) ensureTopics(ctx context.Context, names ...string) error {
case ckf.ErrTopicAlreadyExists:
// The steady state on every process after the first.
default:
// An authorization failure is not a missing topic. A managed
// cluster hands out a key with Write and Read and creates topics
// from its own console, so it answers this for a topic that is
// already there. Failing on it dropped every event and filed one
// issue per message forever, because the topic never became known.
// Remember it instead and let the produce decide: a topic that
// really is absent fails there, saying exactly that.
if isAuthorizationFailure(r.Error) {
log.Warn().
Str("topic", r.Topic).
Msg("eventbus kafka: not allowed to create topics on this cluster; assuming it owns them")
break
}
// A replication factor the cluster cannot satisfy is the usual
// cause on a single-broker development cluster, and the bare
// error does not say so.
@@ -92,6 +105,25 @@ func (b *KafkaBus) ensureTopics(ctx context.Context, names ...string) error {
return nil
}
// isAuthorizationFailure reports whether the broker refused the create because
// this key may not make topics, rather than because the create itself was bad.
//
// The two codes are the answer; the description is a fallback because Confluent
// Cloud substitutes its own "Authorization failed." for them and the code that
// arrives with it is not documented. The fallback is the whole description and
// not a substring on purpose: this waves a topic through as created, so an error
// that merely mentions authorization must not be mistaken for a refusal to
// create one, or the topic is remembered as present and every later publish to
// it fails for a reason nothing reported.
func isAuthorizationFailure(err ckf.Error) bool {
switch err.Code() {
case ckf.ErrTopicAuthorizationFailed, ckf.ErrClusterAuthorizationFailed:
return true
}
desc := strings.TrimSuffix(strings.ToLower(strings.TrimSpace(err.String())), ".")
return desc == "authorization failed"
}
// unknownTopics returns specs for the names this process has not created yet.
// Short critical section: no network happens under the lock.
func (b *KafkaBus) unknownTopics(names []string) ([]ckf.TopicSpecification, error) {
@@ -6,6 +6,8 @@ import (
"context"
"testing"
"time"
ckf "github.com/confluentinc/confluent-kafka-go/v2/kafka"
)
// A topic already created by this process must not reach the broker again:
@@ -92,3 +94,39 @@ func TestUnknownTopicsDoesNotHoldLockForCaller(t *testing.T) {
t.Fatal("the topic lock was still held after unknownTopics returned")
}
}
// A managed cluster refuses the create for a topic it already owns, and that
// answer used to fail the publish and requeue the create for the next message:
// one lost event and one reported issue per message, forever. The refusal must
// read as "not ours to create" whichever of the two codes carries it, and
// whatever description the broker substituted for them.
func TestAuthorizationFailureIsNotACreateFailure(t *testing.T) {
refusals := []ckf.Error{
ckf.NewError(ckf.ErrTopicAuthorizationFailed, "Broker: Topic authorization failed", false),
ckf.NewError(ckf.ErrClusterAuthorizationFailed, "Broker: Cluster authorization failed", false),
// What Confluent Cloud actually sends, under a code of its choosing.
ckf.NewError(ckf.ErrUnknown, "Authorization failed.", false),
}
for _, r := range refusals {
if !isAuthorizationFailure(r) {
t.Errorf("a create refused for permissions read as a create failure: %v", r)
}
}
// The single-broker development case must still fail loudly: nothing is
// going to produce to a topic the cluster could not build. Neither must an
// error that only mentions authorization, because waving one through
// remembers a topic that was never created and every later publish to it
// then fails with nothing reporting why.
notRefusals := []ckf.Error{
ckf.NewError(ckf.ErrInvalidReplicationFactor, "Broker: Invalid replication factor", false),
ckf.NewError(ckf.ErrTopicException, "Broker: Invalid topic", false),
ckf.NewError(ckf.ErrUnknown, "Create failed after transactional id authorization failed for this client", false),
ckf.NewError(ckf.ErrUnknown, "Broker: SASL authentication failed", false),
}
for _, r := range notRefusals {
if isAuthorizationFailure(r) {
t.Errorf("a real create failure was waved through as a permissions refusal: %v", r)
}
}
}
+18 -2
View File
@@ -19,6 +19,7 @@ package errs
import (
"context"
"errors"
"log"
"sync/atomic"
"time"
@@ -136,7 +137,7 @@ type event struct {
// CaptureException reports err. A nil error is dropped: a caller that reports
// unconditionally should not mint an issue with nothing in it.
func CaptureException(err error, opts ...Option) {
if err == nil {
if err == nil || abandoned(err) {
return
}
report(event{err: err, scope: build(opts)})
@@ -145,12 +146,27 @@ func CaptureException(err error, opts ...Option) {
// CaptureExceptionContext reports err on the reporting state carried by ctx
// when there is one, so a request's scope travels with the event.
func CaptureExceptionContext(ctx context.Context, err error, opts ...Option) {
if err == nil {
if err == nil || abandoned(err) {
return
}
report(event{ctx: ctx, err: err, scope: build(opts)})
}
// abandoned reports whether err says the caller stopped waiting, rather than
// that anything went wrong. A cancelled context is a browser navigating away, a
// client hanging up or a container draining on deploy: the work was dropped on
// purpose, and every layer it unwound through reported the same non-event, so a
// rolling restart filed a handful of issues naming whichever queries happened to
// be in flight.
//
// Dropped here rather than at each call site because the caller cannot tell:
// a repository reporting a failed query has no idea whether the request behind
// it still exists. context.DeadlineExceeded is deliberately not included; a
// deadline this process set and then blew through is its own problem.
func abandoned(err error) bool {
return errors.Is(err, context.Canceled)
}
// CaptureMessage reports a message with no error attached.
func CaptureMessage(message string, opts ...Option) {
if message == "" {
+34
View File
@@ -2,7 +2,9 @@ package errs
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"log"
"net/http"
@@ -99,3 +101,35 @@ func TestPostHogPostsAnException(t *testing.T) {
}
}
}
// A rolling restart cancels every request in flight, and each one unwound
// through a repository that reported the failed query. Nine issues naming
// whichever statements happened to be running is not a deploy anybody needs
// told about, so a cancelled context must not reach a backend at all. A
// deadline this process set still must.
func TestCancelledWorkIsNotReported(t *testing.T) {
var buf bytes.Buffer
log.SetOutput(&buf)
t.Cleanup(func() { log.SetOutput(os.Stderr) })
if err := Init(Config{Service: "backend", Environment: "dev"}); err != nil {
t.Fatalf("Init: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
CaptureException(context.Canceled)
// Wrapped the way a repository reports it, through the query text.
CaptureException(fmt.Errorf("queryrow failed: %w", context.Canceled))
CaptureExceptionContext(ctx, fmt.Errorf("get organization: %w", context.Canceled))
if strings.Contains(buf.String(), "[issue-local]") {
t.Errorf("a cancelled request was reported: %s", buf.String())
}
CaptureException(fmt.Errorf("queryrow failed: %w", context.DeadlineExceeded))
if !strings.Contains(buf.String(), "[issue-local]") {
t.Error("a deadline this process set and blew through was dropped as if the caller had gone away")
}
}
+17 -16
View File
@@ -256,7 +256,10 @@ func (r *adminRepository) SearchUsers(ctx context.Context, search *models.AdminU
}
defer rows.Close()
var users []models.AdminUserDetail
// Every list this file builds is made rather than declared: a nil slice
// marshals to JSON null, and the panel reads .length off these without a
// guard, so an empty result took the page down instead of showing "none".
users := make([]models.AdminUserDetail, 0)
for rows.Next() {
var u models.AdminUserDetail
err := rows.Scan(
@@ -437,9 +440,7 @@ func (r *adminRepository) GetUserPreview(ctx context.Context, userID uuid.UUID)
if len(bans) > 5 {
bans = bans[:5]
}
if bans != nil {
preview.RecentBans = bans
}
preview.RecentBans = bans
// Get rate limits
limits, err := r.GetUserRateLimits(ctx, userID)
@@ -566,7 +567,7 @@ func (r *adminRepository) GetUserBans(ctx context.Context, userID uuid.UUID) ([]
}
defer rows.Close()
var bans []models.UserBan
bans := make([]models.UserBan, 0)
for rows.Next() {
var ban models.UserBan
var bannedByUser models.AdminUserSummary
@@ -628,7 +629,7 @@ func (r *adminRepository) GetUserEmails(ctx context.Context, userID uuid.UUID, c
}
defer rows.Close()
var emails []models.AdminWorkerEmail
emails := make([]models.AdminWorkerEmail, 0)
for rows.Next() {
var e models.AdminWorkerEmail
err := rows.Scan(
@@ -683,7 +684,7 @@ func (r *adminRepository) ListAdmins(ctx context.Context, cursor *uuid.UUID, lim
}
defer rows.Close()
var admins []models.AdminInfo
admins := make([]models.AdminInfo, 0)
for rows.Next() {
var admin models.AdminInfo
var grantedByID *uuid.UUID
@@ -757,7 +758,7 @@ func (r *adminRepository) ListWorkers(ctx context.Context, cursor *uuid.UUID, li
}
defer rows.Close()
var workers []models.AdminWorkerDetail
workers := make([]models.AdminWorkerDetail, 0)
for rows.Next() {
var w models.AdminWorkerDetail
err := rows.Scan(
@@ -905,7 +906,7 @@ func (r *adminRepository) GetWorkerEmails(ctx context.Context, workerID uuid.UUI
}
defer rows.Close()
var emails []models.AdminWorkerEmail
emails := make([]models.AdminWorkerEmail, 0)
for rows.Next() {
var e models.AdminWorkerEmail
err := rows.Scan(
@@ -1004,7 +1005,7 @@ func (r *adminRepository) ListWarmupPools(ctx context.Context) ([]models.WarmupP
}
defer rows.Close()
var pools []models.WarmupPoolInfo
pools := make([]models.WarmupPoolInfo, 0)
for rows.Next() {
var p models.WarmupPoolInfo
if err := rows.Scan(&p.Type, &p.TotalParticipants, &p.ActiveParticipants, &p.BlockedCount); err != nil {
@@ -1050,7 +1051,7 @@ func (r *adminRepository) GetPoolParticipants(ctx context.Context, poolType stri
}
defer rows.Close()
var participants []models.WarmupPoolParticipant
participants := make([]models.WarmupPoolParticipant, 0)
for rows.Next() {
var p models.WarmupPoolParticipant
if err := rows.Scan(
@@ -1113,7 +1114,7 @@ func (r *adminRepository) ListBlockedAccounts(ctx context.Context, cursor *uuid.
}
defer rows.Close()
var accounts []models.AdminBlockedAccount
accounts := make([]models.AdminBlockedAccount, 0)
for rows.Next() {
var a models.AdminBlockedAccount
var user models.AdminUserSummary
@@ -1238,7 +1239,7 @@ func (r *adminRepository) ListAppeals(ctx context.Context, status string, cursor
}
defer rows.Close()
var appeals []models.WarmupAppeal
appeals := make([]models.WarmupAppeal, 0)
for rows.Next() {
var a models.WarmupAppeal
var user models.AdminUserSummary
@@ -1511,7 +1512,7 @@ func (r *adminRepository) SearchCampaigns(ctx context.Context, search *models.Ad
}
defer rows.Close()
var campaigns []models.AdminCampaignDetail
campaigns := make([]models.AdminCampaignDetail, 0)
for rows.Next() {
var c models.AdminCampaignDetail
var user models.AdminUserSummary
@@ -1697,7 +1698,7 @@ func (r *adminRepository) SearchAuditLogs(ctx context.Context, search *models.Ad
}
defer rows.Close()
var logs []models.AdminAuditLog
logs := make([]models.AdminAuditLog, 0)
for rows.Next() {
var log models.AdminAuditLog
var user models.AdminUserSummary
@@ -1806,7 +1807,7 @@ func (r *adminRepository) GetUserGrowthStats(ctx context.Context, startDate, end
}
defer rows.Close()
var stats []models.UserGrowthStats
stats := make([]models.UserGrowthStats, 0)
for rows.Next() {
var s models.UserGrowthStats
err := rows.Scan(&s.Date, &s.NewUsers)
+21
View File
@@ -191,6 +191,27 @@ const websiteJsonLd = {
capture_exceptions: true,
disable_session_recording: true,
respect_dnt: false,
// Browser noise the window error handler reports with no stack and
// no bug behind it: "Script error." is what a cross-origin script is
// flattened to, and the ResizeObserver notice is a benign scheduling
// message the spec requires browsers to fire. Between them they were
// the two largest issues in error tracking, all of it from this page,
// which is what buried the real ones. The dashboard and the admin
// panel already drop the same three.
before_send: function (event) {
if (!event || event.event !== '$exception') return event;
var values = event.properties && event.properties.$exception_values;
if (!Array.isArray(values)) return event;
var noise = [
'Script error.',
'ResizeObserver loop completed with undelivered notifications.',
'ResizeObserver loop limit exceeded',
];
for (var i = 0; i < values.length; i++) {
if (typeof values[i] === 'string' && noise.indexOf(values[i].trim()) !== -1) return null;
}
return event;
},
});
window.posthog.register({ service: 'site' });
</script>
+3 -2
View File
@@ -13,8 +13,9 @@
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage",
"build:pages": "vite build && WARMBLY_CONFIG_OUT=dist/config.js sh ./docker-entrypoint.sh",
"sourcemaps:posthog": "pnpm dlx --package @posthog/cli@0.18.2 posthog-cli sourcemap inject --directory dist && pnpm dlx --package @posthog/cli@0.18.2 posthog-cli sourcemap upload --directory dist --delete-after"
"build:pages": "vite build && pnpm sourcemaps:maybe && WARMBLY_CONFIG_OUT=dist/config.js sh ./docker-entrypoint.sh",
"sourcemaps:posthog": "pnpm dlx --package @posthog/cli@0.18.2 posthog-cli sourcemap inject --directory dist && pnpm dlx --package @posthog/cli@0.18.2 posthog-cli sourcemap upload --directory dist --delete-after",
"sourcemaps:maybe": "sh -c 'if [ -n \"$POSTHOG_CLI_PROJECT_ID\" ] && [ -n \"$POSTHOG_CLI_API_KEY\" ]; then pnpm sourcemaps:posthog; else echo \"no PostHog CLI credentials; skipping the source map upload\"; fi'"
},
"dependencies": {
"@dagrejs/dagre": "^3.0.0",
+17 -7
View File
@@ -4,6 +4,7 @@
// what the current selection means before anyone saves it.
import React from "react";
import { useAppStore } from "@/stores";
import { ClockIcon } from "lucide-react";
import { Row, Section, SectionShell, Toggle } from "../_components/SectionShell";
import { NoAccess } from "@/components/layout/NoAccess";
@@ -56,25 +57,34 @@ function SendingSettings() {
const timezones = useTimezones();
const [draft, setDraft] = React.useState<OutreachSettings | null>(null);
// These are one workspace's settings, so the draft belongs to the workspace
// it was hydrated from. Switching workspaces re-hydrates it, and a save that
// would land on a different workspace than the draft came from is dropped:
// otherwise the next edit after a switch wrote the previous workspace's
// whole settings object onto the new one.
const orgID = useAppStore((st) => st.currentOrganization?.id);
const hydratedFor = React.useRef<string | undefined>(undefined);
const autosave = useAutosave({
value: draft,
enabled: !!draft,
save: async (v) => {
if (v) await update.mutateAsync(v);
if (!v) return;
if (hydratedFor.current !== useAppStore.getState().currentOrganization?.id) return;
await update.mutateAsync(v);
},
});
useRegisterUnsaved(autosave, () => setDraft(autosave.savedValue));
// One-shot hydration: the server value seeds the draft once, then the save
// path owns the baseline so a refetch can't stomp an in-flight edit.
const hydrated = React.useRef(false);
// Hydration is once per workspace: the server value seeds the draft, then
// the save path owns the baseline so a refetch can't stomp an in-flight edit.
React.useEffect(() => {
if (!data || hydrated.current) return;
hydrated.current = true;
if (!data || hydratedFor.current === orgID) return;
hydratedFor.current = orgID;
setDraft(data);
autosave.markSaved(data);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data]);
}, [data, orgID]);
const sto = draft?.send_time_optimization;
+15 -7
View File
@@ -4,6 +4,7 @@
// workspace's decision, enforced on the server rather than in the snippet.
import React from "react";
import { useAppStore } from "@/stores";
import { CheckIcon, CopyIcon } from "lucide-react";
import { Row, Section, SectionShell, Toggle } from "../_components/SectionShell";
import { NoAccess } from "@/components/layout/NoAccess";
@@ -80,27 +81,34 @@ function WebsiteTrackingSettingsView() {
const confirm = useConfirm();
const [draft, setDraft] = React.useState<Draft | null>(null);
// One workspace's tracking settings, so the draft belongs to the workspace
// it was hydrated from: switching re-hydrates it, and a save that would land
// on a different workspace is dropped rather than written to it.
const orgID = useAppStore((st) => st.currentOrganization?.id);
const hydratedFor = React.useRef<string | undefined>(undefined);
const autosave = useAutosave({
value: draft,
enabled: !!draft,
debounceMs: 600,
save: async (v) => {
if (v) await update.mutateAsync(toPatch(v));
if (!v) return;
if (hydratedFor.current !== useAppStore.getState().currentOrganization?.id) return;
await update.mutateAsync(toPatch(v));
},
});
useRegisterUnsaved(autosave, () => setDraft(autosave.savedValue));
// One-shot hydration, as on the other autosave settings pages: the server
// seeds the draft once and the save path owns the baseline after that.
const hydrated = React.useRef(false);
// Hydration is once per workspace, as on the other autosave settings pages:
// the server seeds the draft and the save path owns the baseline after that.
React.useEffect(() => {
if (!data || hydrated.current) return;
hydrated.current = true;
if (!data || hydratedFor.current === orgID) return;
hydratedFor.current = orgID;
const d = toDraft(data);
setDraft(d);
autosave.markSaved(d);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data]);
}, [data, orgID]);
const patch = React.useCallback((next: Partial<Draft>) => {
setDraft((prev) => (prev ? { ...prev, ...next } : prev));
+32 -7
View File
@@ -1,9 +1,10 @@
import React from "react";
import { Link } from "react-router-dom";
import { useAppStore } from "@/stores";
import { useAppStore, type Organization as StoreOrganization } from "@/stores";
import { TextInput } from "@/components/ui/field";
import { Textarea } from "@/components/ui/textarea";
import useUpdateOrganization from "@/lib/api/hooks/app/organizations/useUpdateOrganization";
import type Organization from "@/lib/api/models/app/organizations/Organization";
import { AvatarUploader } from "@/components/app/avatar/AvatarUploader";
import {
useDeleteOrgAvatar,
@@ -18,14 +19,38 @@ import { usePermission } from "@/hooks/usePermission";
import useAiMetered from "@/hooks/useAiMetered";
import AdvisorSettingsSection from "@/components/app/advisor/AdvisorSettingsSection";
// Keyed on the workspace id, which is what makes a switch re-seed the editors
// below. Each of them takes its initial value from the org it mounted with, and
// nothing here re-reads that on a change: the name field kept the previous
// workspace's name while the autosave baseline moved to the new one, so merely
// switching workspaces (or creating one, which switches to it) saved the old
// name over the new workspace's. That is the reported bug where renaming one
// workspace renamed the other.
export default function WorkspaceSettingsPage() {
const currentOrg = useAppStore((s) => s.currentOrganization);
return <WorkspaceSettings key={currentOrg?.id ?? "none"} org={currentOrg} />;
}
function WorkspaceSettings({ org: currentOrg }: { org: StoreOrganization | null }) {
const [name, setName] = React.useState(currentOrg?.name ?? "");
const orgID = currentOrg?.id;
const uploadOrgAvatar = useUploadOrgAvatar();
const removeOrgAvatar = useDeleteOrgAvatar();
const updateOrg = useUpdateOrganization();
// Every save here renames whatever workspace the server session has
// selected, and a debounce or a blur armed on this page can land after a
// switch. orgID is the workspace this editor was opened for, so a write
// that would reach a different one is dropped rather than applied to it.
const saveToThisWorkspace = React.useCallback(
async (patch: Partial<Organization>) => {
if (!orgID || useAppStore.getState().currentOrganization?.id !== orgID) return;
await updateOrg.mutateAsync(patch);
},
[orgID, updateOrg],
);
// Team presence privacy. The full org (with the flags) comes from
// /organization/current; toggling saves immediately and the realtime
// service re-gates everyone live. Only admins with Manage settings can edit.
@@ -43,11 +68,11 @@ export default function WorkspaceSettingsPage() {
const onToggleOnline = (next: boolean) => {
setShowOnline(next);
updateOrg.mutate({ presence_show_online: next });
void saveToThisWorkspace({ presence_show_online: next });
};
const onToggleActivity = (next: boolean) => {
setShowActivity(next);
updateOrg.mutate({ presence_show_activity: next });
void saveToThisWorkspace({ presence_show_activity: next });
};
// AI voice profile. Grounds every AI writing surface. Saved on blur when
@@ -67,7 +92,7 @@ export default function WorkspaceSettingsPage() {
orgQuery.data?.voice_profile,
]);
const saveVoiceField = (key: "product_description" | "icp_notes" | "voice_profile", value: string, saved: string) => {
if (value !== saved) updateOrg.mutate({ [key]: value });
if (value !== saved) void saveToThisWorkspace({ [key]: value });
};
// Inbox agent opt-in (paid). When on, an inbound human reply gets an
@@ -81,11 +106,11 @@ export default function WorkspaceSettingsPage() {
}, [orgQuery.data?.inbox_agent_enabled, orgQuery.data?.assistant_shared_history]);
const onToggleInboxAgent = (next: boolean) => {
setInboxAgent(next);
updateOrg.mutate({ inbox_agent_enabled: next });
void saveToThisWorkspace({ inbox_agent_enabled: next });
};
const onToggleSharedHistory = (next: boolean) => {
setSharedHistory(next);
updateOrg.mutate({ assistant_shared_history: next });
void saveToThisWorkspace({ assistant_shared_history: next });
};
// Auto-save the workspace name ~700ms after typing stops. An empty name is
@@ -95,7 +120,7 @@ export default function WorkspaceSettingsPage() {
debounceMs: 700,
save: async (v) => {
if (!v) throw new Error("name required");
await updateOrg.mutateAsync({ name: v });
await saveToThisWorkspace({ name: v });
},
});
useRegisterUnsaved(autosave, () => setName(autosave.savedValue));