Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,12 @@ EMAIL_SES_REGION=
# Format (no quotes): Your App <noreply@yourdomain.com>
EMAIL_FROM=

# Product name shown inside transactional emails — the sign-in, invite, welcome,
# and password-reset subjects and headings. Defaults to "Quackback". Set this if
# you run under your own brand so those emails say your name, not ours. This is
# the product name, separate from each workspace's own name.
# EMAIL_BRAND_NAME=Your App

# ============================================================================
# Inbound email (optional) — conversation email channel
# ============================================================================
Expand Down
3 changes: 3 additions & 0 deletions .env.prod.example
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ EMAIL_SMTP_PORT=587
EMAIL_SMTP_USER=
EMAIL_SMTP_PASS=
EMAIL_FROM=Quackback <noreply@example.com>
# Product name shown inside transactional emails (subjects and headings).
# Defaults to "Quackback"; set it to run those emails under your own brand.
# EMAIL_BRAND_NAME=Quackback
# Or use the Amazon SES API instead of SMTP. All three are required: the
# region is the one the sending identity is verified in, and there is no
# default because a verified identity is regional.
Expand Down
15 changes: 14 additions & 1 deletion SELF-IMPROVE.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ when the same thing bites again and re-sort the list by counter, descending.
Entries that have actually been fixed move to **Resolved** at the end, with what
fixed them — they are the record of what the counters bought.

## 7x — Test suites are flaky under parallel load
## 8x — Test suites are flaky under parallel load

`principals/__tests__/seat-usage.db.test.ts` and
`tickets/__tests__/ticket-convergence-1b.test.ts` each fail intermittently when
Expand Down Expand Up @@ -90,6 +90,19 @@ Raising `hookTimeout` to match is the obvious fix; not done inside the
back-merge, for the reason above -- a change to shared test infrastructure
needs its own run to be falsifiable.

Eighth hit, on the back-merge of upstream #506–#520: a run over 345 suites with
coverage ended on one red test, `policy/module-state/__tests__/module-state.test.ts`
(`Test timed out in 20000ms` on the scanner walk), and the same file passed alone in
under a second of test time. Another scanner-shaped suite that walks the source tree
and lives close to the 20s ceiling under contention; nothing in the change touched it.
The re-run of the same set, capped at six workers, went red on a different file
instead: `jobs/__tests__/runner.test.ts` ("reaps a stranded lease and prunes an aged
terminal row in one pass") saw `pruned` come back 0 for a row it had just aged
400 days, and passed three times in a row alone. `job-queue.test.ts` and
`worker.test.ts` prune the same shared `job_queue` table from their own workers,
so whichever process prunes first takes the other's row and its count. Same
shape as `seat-usage`: a database-wide count asserted across parallel suites.

## 1x — A line that is only an arrow function passed as a JSX prop reads as uncovered until the handler actually fires

Filling a diff-coverage hole for `onEdit={() => onEdit(row)}`-shaped lines
Expand Down
5 changes: 5 additions & 0 deletions apps/web/e2e/tests/admin/settings-tags.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,11 @@ test.describe('Admin Tags Settings', () => {
// Color section label
await expect(dialog.getByText('Color')).toBeVisible()

// Portal visibility switch, on by default for new tags
const portalSwitch = dialog.getByRole('switch', { name: /show on portal/i })
await expect(portalSwitch).toBeVisible()
await expect(portalSwitch).toHaveAttribute('aria-checked', 'true')

// Create and Cancel buttons
await expect(dialog.getByRole('button', { name: /cancel/i })).toBeVisible()
await expect(dialog.getByRole('button', { name: /create tag/i })).toBeVisible()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,12 @@ afterEach(() => {
})

describe('<ImportCsv>', () => {
it('explains how author_email and author_name are applied', () => {
renderCsv()
expect(screen.getByText(/Every row needs author_email or author_name/)).toBeTruthy()
expect(screen.getByText(/name-only contact/)).toBeTruthy()
})

it('walks upload -> dry-run review -> commit -> done', async () => {
const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,8 @@ export function ImportCsv() {
</span>
<span className="text-xs text-muted-foreground">
Must use the template columns — title and content are required. Up to 10MB / 10,000
rows.
rows. Every row needs author_email or author_name: email matches or creates a person;
name without an email creates a name-only contact.
</span>
<input
ref={fileInputRef}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
// @vitest-environment happy-dom
/**
* Tag settings — "Show on portal" visibility control.
*
* Covers the admin-facing half of tag portal visibility: the create/edit
* dialog exposes a switch that defaults to public for new tags, mirrors the
* saved flag when editing, and sends `isPublic` on save; internal tags are
* marked in the list so the state is visible without opening the dialog.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent, waitFor, within } from '@testing-library/react'
import type { PostTag } from '@/lib/shared/db-types'

const mockCreate = vi.fn()
const mockUpdate = vi.fn()
vi.mock('@/lib/server/functions/post-tags', () => ({
createPostTagFn: (...args: unknown[]) => mockCreate(...args),
updatePostTagFn: (...args: unknown[]) => mockUpdate(...args),
deletePostTagFn: vi.fn(),
}))

vi.mock('@tanstack/react-router', () => ({
useRouter: () => ({ invalidate: vi.fn() }),
}))

vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))

import { TagList } from '../tag-list'

const PUBLIC_TAG = {
id: 'post_tag_public',
name: 'Bug',
color: '#ef4444',
description: 'Broken behaviour',
aiPrompt: null,
isPublic: true,
createdAt: new Date('2026-01-01'),
deletedAt: null,
} as PostTag

const INTERNAL_TAG = {
...PUBLIC_TAG,
id: 'post_tag_internal',
name: 'Churn risk',
description: null,
isPublic: false,
} as PostTag

beforeEach(() => {
vi.clearAllMocks()
mockCreate.mockImplementation(async ({ data }) => ({
...PUBLIC_TAG,
id: 'post_tag_new',
...data,
}))
mockUpdate.mockImplementation(async ({ data }) => ({ ...PUBLIC_TAG, ...data }))
})

function portalSwitch() {
return screen.getByRole('switch', { name: /show on portal/i })
}

describe('<TagList> — portal visibility', () => {
it('marks internal tags in the list and leaves public tags unmarked', () => {
render(<TagList initialTags={[PUBLIC_TAG, INTERNAL_TAG]} />)

const internalRow = screen.getByText('Churn risk').closest('div')!
expect(within(internalRow).getByText('Internal')).toBeTruthy()

const publicRow = screen.getByText('Bug').closest('div')!
expect(within(publicRow).queryByText('Internal')).toBeNull()
})

it('defaults a new tag to public and sends isPublic on create', async () => {
render(<TagList initialTags={[]} />)

fireEvent.click(screen.getByRole('button', { name: /add new tag/i }))
expect(portalSwitch()).toHaveAttribute('aria-checked', 'true')

fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Design' } })
fireEvent.click(screen.getByRole('button', { name: /create tag/i }))

await waitFor(() =>
expect(mockCreate).toHaveBeenCalledWith({
data: expect.objectContaining({ name: 'Design', isPublic: true }),
})
)
})

it('lets an admin create an internal tag by turning the switch off', async () => {
render(<TagList initialTags={[]} />)

fireEvent.click(screen.getByRole('button', { name: /add new tag/i }))
fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Churn risk' } })
fireEvent.click(portalSwitch())
expect(portalSwitch()).toHaveAttribute('aria-checked', 'false')

fireEvent.click(screen.getByRole('button', { name: /create tag/i }))

await waitFor(() =>
expect(mockCreate).toHaveBeenCalledWith({
data: expect.objectContaining({ name: 'Churn risk', isPublic: false }),
})
)
})

it('reflects the saved flag when editing and sends the toggled value', async () => {
render(<TagList initialTags={[INTERNAL_TAG]} />)

fireEvent.click(screen.getByRole('button', { name: /edit tag/i }))
expect(portalSwitch()).toHaveAttribute('aria-checked', 'false')

fireEvent.click(portalSwitch())
fireEvent.click(screen.getByRole('button', { name: /save changes/i }))

await waitFor(() =>
expect(mockUpdate).toHaveBeenCalledWith({
data: expect.objectContaining({ id: 'post_tag_internal', isPublic: true }),
})
)
})
})
37 changes: 35 additions & 2 deletions apps/web/src/components/admin/settings/tags/tag-list.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
import { useState, useEffect, useTransition } from 'react'
import { useRouter } from '@tanstack/react-router'
import { toast } from 'sonner'
import { PlusIcon, TrashIcon, PencilSquareIcon, ArrowPathIcon } from '@heroicons/react/24/solid'
import {
PlusIcon,
TrashIcon,
PencilSquareIcon,
ArrowPathIcon,
EyeSlashIcon,
} from '@heroicons/react/24/solid'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Switch } from '@/components/ui/switch'
import {
Dialog,
DialogContent,
Expand Down Expand Up @@ -164,6 +171,7 @@ function TagDialog({ open, onOpenChange, tag, onSaved }: TagDialogProps) {
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [color, setColor] = useState('#6b7280')
const [isPublic, setIsPublic] = useState(true)
const [error, setError] = useState<string | null>(null)
const [isSaving, setIsSaving] = useState(false)

Expand All @@ -175,10 +183,12 @@ function TagDialog({ open, onOpenChange, tag, onSaved }: TagDialogProps) {
setName(tag.name)
setDescription(tag.description ?? '')
setColor(tag.color)
setIsPublic(tag.isPublic)
} else {
setName('')
setDescription('')
setColor(randomColor())
setIsPublic(true)
}
setError(null)
}
Expand Down Expand Up @@ -211,6 +221,7 @@ function TagDialog({ open, onOpenChange, tag, onSaved }: TagDialogProps) {
name: trimmedName,
color,
description: description.trim() || null,
isPublic,
},
})
} else {
Expand All @@ -219,6 +230,7 @@ function TagDialog({ open, onOpenChange, tag, onSaved }: TagDialogProps) {
name: trimmedName,
color,
description: description.trim() || undefined,
isPublic,
},
})
}
Expand Down Expand Up @@ -280,6 +292,17 @@ function TagDialog({ open, onOpenChange, tag, onSaved }: TagDialogProps) {
<ColorHexInput color={color} onColorChange={setColor} />
</div>

<div className="flex items-center justify-between gap-4">
<div>
<Label htmlFor="tag-is-public">Show on portal</Label>
<p className="text-xs text-muted-foreground">
Customers can see this tag on posts and filter by it in the public portal. Turn off to
keep it internal to your team.
</p>
</div>
<Switch id="tag-is-public" checked={isPublic} onCheckedChange={setIsPublic} />
</div>

{error && <p className="text-sm text-destructive">{error}</p>}

<DialogFooter>
Expand Down Expand Up @@ -365,7 +388,7 @@ export function TagList({ initialTags }: TagListProps) {
<div className="space-y-8">
<SettingsCard
title="Tags"
description="Label posts across boards for filtering and organization. Tags appear as colored badges throughout the app."
description="Label posts across boards for filtering and organization. Tags appear as colored badges throughout the app, and on the public portal unless marked internal."
contentClassName="p-4"
>
<div className="space-y-1">
Expand Down Expand Up @@ -403,6 +426,16 @@ export function TagList({ initialTags }: TagListProps) {
{/* Name */}
<span className="text-sm font-medium">{tag.name}</span>

{!tag.isPublic && (
<span
className="inline-flex items-center gap-1 rounded-md bg-muted px-1.5 py-0.5 text-[11px] font-medium text-muted-foreground shrink-0"
title="Hidden from the public portal"
>
<EyeSlashIcon className="h-3 w-3" />
Internal
</span>
)}

{/* Description */}
<span className="text-xs text-muted-foreground truncate flex-1">
{tag.description ?? ''}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ vi.mock('@tanstack/react-router', () => ({

// GateCard invalidates portal queries on sign-out.
vi.mock('@tanstack/react-query', () => ({
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
useQueryClient: () => ({ invalidateQueries: vi.fn(), removeQueries: vi.fn() }),
}))

// GateCard listens for cross-tab auth broadcasts.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ vi.mock('@tanstack/react-router', () => ({
}))

vi.mock('@tanstack/react-query', () => ({
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
useQueryClient: () => ({ invalidateQueries: vi.fn(), removeQueries: vi.fn() }),
}))

// GateCard subscribes without an `enabled` flag.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// @vitest-environment happy-dom
import { render, screen, act } from '@testing-library/react'
import { render, screen, act, fireEvent } from '@testing-library/react'
import { vi, describe, it, expect, beforeEach } from 'vitest'

const navigate = vi.fn()
Expand All @@ -18,8 +18,10 @@ vi.mock('@/lib/client/hooks/use-auth-broadcast', () => ({
postAuthSuccess: vi.fn(),
}))

const invalidateQueries = vi.fn().mockResolvedValue(undefined)
const removeQueries = vi.fn()
vi.mock('@tanstack/react-query', () => ({
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
useQueryClient: () => ({ invalidateQueries, removeQueries }),
}))

vi.mock('@/lib/client/auth-client', () => ({ signOut: vi.fn() }))
Expand All @@ -37,6 +39,8 @@ vi.mock('@/lib/client/post-auth-navigation', () => ({ navigateAfterAuth: vi.fn()

import { PortalAccessGate } from '../portal-access-gate'
import { navigateAfterAuth } from '@/lib/client/post-auth-navigation'
import { signOut } from '@/lib/client/auth-client'
import { VIEWER_SCOPED_PORTAL_QUERY_KEYS } from '@/lib/client/queries/portal'

const baseProps = {
reason: 'unauthenticated' as const,
Expand All @@ -52,6 +56,9 @@ const baseProps = {
beforeEach(() => {
navigate.mockClear()
invalidate.mockClear()
invalidateQueries.mockClear()
removeQueries.mockClear()
vi.mocked(signOut).mockClear()
vi.mocked(navigateAfterAuth).mockClear()
broadcastOnSuccess = undefined
formProps = {}
Expand All @@ -70,6 +77,26 @@ describe('PortalAccessGate — inline auth form', () => {
expect(screen.getByRole('button', { name: /sign out/i })).toBeInTheDocument()
})

it('signing out from the unauthorized screen drops the viewer-scoped portal caches before the loaders re-run', async () => {
render(<PortalAccessGate {...baseProps} reason="unauthorized" userEmail="alice@example.com" />)

await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /sign out/i }))
})

expect(signOut).toHaveBeenCalledTimes(1)
const removedKeys = removeQueries.mock.calls.map(
(call) => (call as unknown as [{ queryKey: unknown[] }])[0].queryKey
)
expect(removedKeys).toEqual(expect.arrayContaining([...VIEWER_SCOPED_PORTAL_QUERY_KEYS]))
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['votedPosts'] })
expect(invalidate).toHaveBeenCalledTimes(1)
// The caches must be gone before the loaders re-run, or ensureQueryData
// hands the next viewer the previous session's payload.
const lastRemoval = Math.max(...removeQueries.mock.invocationCallOrder)
expect(lastRemoval).toBeLessThan(invalidate.mock.invocationCallOrder[0])
})

it('seeds the form mode from autoOpenSignin', () => {
render(<PortalAccessGate {...baseProps} autoOpenSignin="signup" />)
expect(formProps.mode).toBe('signup')
Expand Down
Loading
Loading