Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
38 changes: 37 additions & 1 deletion apps/website/src/api/error.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,31 @@
/**
* Mirrors zod v4's `treeifyError()` output shape (see `services/api/src/util/sendBoom.ts`, which spreads it
* into the JSON body of any 400 raised from failed body/query/params validation in `core/server.ts`). `items`
* covers array-schema fields (e.g. `prompt_raw.embeds`); `properties` covers object fields.
*/
export interface ZodErrorTree {
readonly errors: string[];
readonly items: ZodErrorTree[] | undefined;
readonly properties: Record<string, ZodErrorTree> | undefined;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}

export class APIError extends Error {
public readonly statusCode: number;

public readonly error: string;

public constructor(statusCode: number, error: string, message: string) {
/**
* Full validation tree, present only on a 400 raised by `mountRoute`'s zod validation step. `undefined` for
* every other error (domain errors like "not found", auth failures, 5xxs, ...).
*/
public readonly validationErrors: ZodErrorTree | undefined;

public constructor(statusCode: number, error: string, message: string, validationErrors?: ZodErrorTree) {
super(message);
this.name = 'APIError';
this.statusCode = statusCode;
this.error = error;
this.validationErrors = validationErrors;
}

/**
Expand All @@ -16,4 +34,22 @@ export class APIError extends Error {
public isClientError(): boolean {
return this.statusCode >= 400 && this.statusCode < 500;
}

/**
* First validation message at a (possibly nested) field path, e.g. `error.fieldError('prompt', 'description')`
* for an object field, or `error.fieldError('prompt_raw', 'embeds', 0)` for an array index. `undefined` if
* this wasn't a validation error, or the given path had no error.
*/
public fieldError(...path: (number | string)[]): string | undefined {
let node: ZodErrorTree | undefined = this.validationErrors;
for (const key of path) {
if (!node) {
return undefined;
}

node = typeof key === 'number' ? node.items?.[key] : node.properties?.[key];
}

return node?.errors[0];
}
}
24 changes: 24 additions & 0 deletions apps/website/src/api/errorBanner.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { atom } from 'jotai';
import { store } from './store';

export interface ErrorBannerMessage {
readonly id: number;
readonly message: string;
}

export const errorBannerMessagesAtom = atom<ErrorBannerMessage[]>([]);

let nextId = 0;

/**
* Queues a dismissible global error banner (`ErrorBanner`, mounted in `Providers`). Called from outside React
* (`queryClient.ts`'s `QueryCache.onError`), hence writing through the shared store rather than a hook.
*/
export function pushErrorBanner(message: string): void {
const id = nextId++;
store.set(errorBannerMessagesAtom, (prev) => [...prev, { id, message }]);
}

export function dismissErrorBanner(id: number): void {
store.set(errorBannerMessagesAtom, (prev) => prev.filter((banner) => banner.id !== id));
}
23 changes: 19 additions & 4 deletions apps/website/src/api/fetch.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { NewAccessTokenHeader, RefreshTokenCookie } from '@chatsift/core';
import type { DehydratedState } from '@tanstack/react-query';
import { getDefaultStore } from 'jotai';
import type { ZodErrorTree } from './error';
import { APIError } from './error';
import { clearCachedAccessToken, getCachedAccessToken, setCachedAccessToken } from './serverTokenCache';
import { store } from './store';
import { accessTokenAtom } from './token';

function getBaseURL(): string {
Expand Down Expand Up @@ -30,8 +31,23 @@ function buildURL(path: string, query?: FetchOptions['query']): string {

async function parseError(response: Response): Promise<APIError> {
try {
const data = (await response.json()) as { error: string; message: string; statusCode: number };
return new APIError(data.statusCode, data.error, data.message);
const data = (await response.json()) as {
error: string;
// Present (via `sendBoom`'s `treeifyError` spread) only on a zod-validation 400 — all three keys are
// only ever present together, spread directly from `treeifyError()`'s root node.
errors?: string[];
items?: ZodErrorTree[];
message: string;
properties?: Record<string, ZodErrorTree>;
statusCode: number;
};

const hasValidationErrors = data.errors !== undefined || data.properties !== undefined || data.items !== undefined;
const validationErrors: ZodErrorTree | undefined = hasValidationErrors
? { errors: data.errors ?? [], properties: data.properties, items: data.items }
: undefined;

return new APIError(data.statusCode, data.error, data.message, validationErrors);
} catch (error) {
console.error('failed to parse error response', {
status: response.status,
Expand All @@ -53,7 +69,6 @@ async function parseSuccess<TResponse>(response: Response): Promise<TResponse> {
}

async function apiFetchClient<TResponse>(method: string, path: string, options: FetchOptions): Promise<TResponse> {
const store = getDefaultStore();
const accessToken = store.get(accessTokenAtom);

const headers: Record<string, string> = {
Expand Down
17 changes: 15 additions & 2 deletions apps/website/src/api/queryClient.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,29 @@
import { isServer, QueryCache, QueryClient } from '@tanstack/react-query';
import { APIError } from './error';
import { pushErrorBanner } from './errorBanner';

export function makeQueryClient(): QueryClient {
return new QueryClient({
queryCache: new QueryCache({
// TODO: Handle in some way
onError: (error) => {
onError: (error, query) => {
if (error instanceof APIError) {
console.error('Query error:', { statusCode: error.statusCode, error: error.error, message: error.message });

// 401s mean the session expired — `NavGateProvider` already redirects to Discord OAuth off the same
// error, so a banner here would just flash right before that navigation happens.
if (error.statusCode === 401) {
return;
}
} else {
console.error('Network error:', error);
}

// Only bother the user for a *background* refetch failure (stale data is still on screen, and they'd
// otherwise have no idea the refresh silently failed). A first-load failure (no cached data yet) is
// already surfaced in-place by whichever component renders `UserErrorHandler` for that query's `error`.
if (query.state.data !== undefined) {
pushErrorBanner(error instanceof APIError ? error.message : 'Something went wrong. Please try again.');
}
},
}),
defaultOptions: {
Expand Down
4 changes: 2 additions & 2 deletions apps/website/src/api/routes/auth.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import type { InferRouteContract, logoutRoute, meRoute } from '@chatsift/api';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { getDefaultStore } from 'jotai';
import { APIError } from '../error';
import { apiFetch } from '../fetch';
import { queryKeys } from '../queryClient';
import { store } from '../store';
import { lastExplicitLogoutAtAtom } from '../token';

type MeContract = InferRouteContract<typeof meRoute>;
Expand Down Expand Up @@ -58,7 +58,7 @@ export function useLogout() {
return useMutation({
mutationFn: async () => apiFetch<LogoutContract['response']>('post', '/v3/auth/logout'),
onSuccess() {
getDefaultStore().set(lastExplicitLogoutAtAtom, Date.now());
store.set(lastExplicitLogoutAtAtom, Date.now());

// Set directly rather than invalidating: `removeQueries`/`invalidateQueries` only refetch queries
// that are *actively observed*, and empirically that refetch doesn't reliably happen synchronously
Expand Down
10 changes: 10 additions & 0 deletions apps/website/src/api/store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { createStore } from 'jotai';

/**
* The single jotai store for the whole app. Written from outside React (`apiFetch`, `errorBanner.ts`,
* `NavGate.tsx`'s effect) via `.get()`/`.set()`, and read from components via `useAtomValue`/`useAtom` — both
* only see the same state if they share this exact instance. `<Provider store={store}>` (see `Providers.tsx`)
* must be given this same store; `Provider` silently creates its own separate store via `createStore()` if none
* is passed, which would desync from every `.get()`/`.set()` call below.
*/
export const store = createStore();
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { useParams, useRouter } from 'next/navigation';
import { useState } from 'react';
import { APIError } from '@/api/error';
import type { PossiblyMissingChannelInfo } from '@/api/routes/ama';
import { useAMA, useRepostPrompt, useUpdateAMA } from '@/api/routes/ama';
import type { GuildChannelInfo } from '@/api/routes/guilds';
Expand All @@ -24,6 +25,7 @@ export function AMADetails() {
const params = useParams<{ amaId: string; id: string }>();
const router = useRouter();
const [showEndConfirm, setShowEndConfirm] = useState(false);
const [actionError, setActionError] = useState<string | null>(null);

const { data: ama, isLoading } = useAMA(params.id, params.amaId);
const updateAMA = useUpdateAMA(params.id, params.amaId);
Expand Down Expand Up @@ -51,26 +53,41 @@ export function AMADetails() {
return;
}

setActionError(null);

try {
await updateAMA.mutateAsync({ ended: true });
router.push(`/dashboard/${params.id}/ama/amas`);
} catch (error) {
setActionError(error instanceof APIError ? error.message : 'Failed to end AMA. Please try again.');
console.error('Failed to end AMA:', error);
} finally {
setShowEndConfirm(false);
}
};

const handleRepostPrompt = async () => {
setActionError(null);

try {
await repostPrompt.mutateAsync();
} catch (error) {
setActionError(error instanceof APIError ? error.message : 'Failed to repost the prompt. Please try again.');
console.error('Failed to repost prompt:', error);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
};

return (
<div className="grid gap-6 lg:grid-cols-2">
{actionError && (
<p
className="rounded-lg border border-misc-danger bg-misc-danger/10 p-3 text-sm text-misc-danger lg:col-span-2"
role="alert"
>
{actionError}
</p>
)}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

{/* Session Information Card */}
<div className="rounded-lg border border-on-secondary bg-card p-6 dark:border-on-secondary-dark dark:bg-card-dark">
<h2 className="text-xl font-medium text-primary dark:text-primary-dark mb-4">Session Information</h2>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -155,14 +155,37 @@ export function CreateAMAForm() {
router.replace(`/dashboard/${guildId}/ama/amas`);
} catch (error) {
if (error instanceof APIError && error.statusCode === 422) {
if (promptMode === 'raw') {
setGeneralError('Invalid prompt data. Please check your JSON data and try again.');
} else {
setGeneralError(
'An unknown validation error occured. Please contact support in regards to this error. For the tech savvy, the request response includes more information.',
);
}
// `badData` from createAMA.ts — Discord rejected the composed message (only reachable in raw mode,
// since normal-mode prompts are always well-formed by construction).
setGeneralError('Invalid prompt data. Please check your JSON data and try again.');
return;
}

// A 400 here means the server's zod schema rejected the request even though our own client-side
// `validateForm` passed — map whatever field-level detail it returned back onto the same `errors` state
// `validateForm` uses, so it renders exactly like a client-side validation failure.
if (error instanceof APIError && error.statusCode === 400) {
const promptField = promptMode === 'raw' ? 'prompt_raw' : 'prompt';
const candidates: [keyof FormData, string | undefined][] = [
['title', error.fieldError('title')],
['answersChannelId', error.fieldError('answersChannelId')],
['promptChannelId', error.fieldError('promptChannelId')],
['modQueueId', error.fieldError('modQueueId')],
['flaggedQueueId', error.fieldError('flaggedQueueId')],
['guestQueueId', error.fieldError('guestQueueId')],
['allowedQuestionUploads', error.fieldError('allowedQuestionUploads')],
['description', error.fieldError(promptField, 'description')],
['plainText', error.fieldError(promptField, 'plainText')],
['imageURL', error.fieldError(promptField, 'imageURL')],
['thumbnailURL', error.fieldError(promptField, 'thumbnailURL')],
];

const newErrors: FormErrors = Object.fromEntries(
candidates.filter((entry): entry is [keyof FormData, string] => entry[1] !== undefined),
);

setErrors(newErrors);
setGeneralError(Object.keys(newErrors).length > 0 ? null : error.message);
return;
}

Expand Down
6 changes: 3 additions & 3 deletions apps/website/src/app/dashboard/[id]/ama/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import Link from 'next/link';
import { DashboardCrumbs } from '../../_components/DashboardCrumbs';
import { Heading } from '@/components/common/Heading';
import { SvgAMA } from '@/components/icons/SvgAMA';

export default async function AMAPage({ params }: PageProps<'/dashboard/[id]/ama/amas'>) {
const { id } = await params;
Expand All @@ -15,9 +16,8 @@ export default async function AMAPage({ params }: PageProps<'/dashboard/[id]/ama
href={`/dashboard/${id}/ama/amas`}
prefetch
>
{/* TODO */}
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-misc-accent text-2xl font-bold text-primary-dark">
Q
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-on-tertiary dark:bg-on-tertiary-dark">
<SvgAMA height={28} width={28} />
</div>
<div className="flex flex-col">
<p className="text-lg font-medium text-primary dark:text-primary-dark">Manage AMAs</p>
Expand Down
1 change: 0 additions & 1 deletion apps/website/src/app/dashboard/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,6 @@ export default function GuildPage() {
<div className="flex flex-col gap-3">
<SectionCard
href={`/dashboard/${guild.id}/settings`}
// TODO
icon={<FaWrench className="text-misc-accent h-6 w-6" />}
subtext="View and modify general settings related to your community"
text="General settings"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,17 @@ export function AddGrantCard({ guildId }: AddGrantCardProps) {
await createGrant.mutateAsync({ userId: userId.trim() });
setUserId('');
} catch (error) {
// Route sends 404 (user doesn't exist on Discord), 422 (`badData`, grant already exists), or 400
// (zod validation failed on `userId` itself, e.g. not a valid snowflake) — see createGrant.ts.
if (error instanceof APIError) {
if (error.statusCode === 404) {
setError('User not found');
} else if (error.statusCode === 400) {
setError('Grant already exists for this user');
} else if (error.statusCode === 422) {
setError('Invalid User ID');
setError('Grant already exists for this user');
} else if (error.statusCode === 400) {
setError(error.fieldError('userId') ?? 'Invalid User ID');
} else {
setError(error.message || 'Failed to add grant');
}

return;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Expand Down
51 changes: 51 additions & 0 deletions apps/website/src/components/common/ErrorBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
'use client';

import { useAtomValue } from 'jotai';
import { useEffect } from 'react';
import { FaExclamationTriangle, FaTimes } from 'react-icons/fa';
import { dismissErrorBanner, errorBannerMessagesAtom } from '@/api/errorBanner';

const AUTO_DISMISS_MS = 8_000;

function Banner({ id, message }: { readonly id: number; readonly message: string }) {
useEffect(() => {
const timeout = setTimeout(() => dismissErrorBanner(id), AUTO_DISMISS_MS);
return () => clearTimeout(timeout);
}, [id]);

return (
<div className="flex items-center gap-3 rounded-lg border-[1px] border-misc-danger bg-card p-3 shadow-lg dark:bg-card-dark">
<FaExclamationTriangle className="h-4 w-4 shrink-0 text-misc-danger" />
<p className="text-sm text-primary dark:text-primary-dark">{message}</p>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
<button
aria-label="Dismiss"
className="ml-auto text-secondary hover:text-primary dark:text-secondary-dark dark:hover:text-primary-dark"
onClick={() => dismissErrorBanner(id)}
type="button"
>
<FaTimes className="h-3.5 w-3.5" />
</button>
</div>
);
}

/**
* Surfaces background query failures (a refetch that failed while stale data is still on screen — see
* `queryClient.ts`'s `onError`). First-load errors are handled locally by `UserErrorHandler` instead, so this
* only needs to catch the "user is already looking at data and something broke quietly" case.
*/
export function ErrorBanner() {
const banners = useAtomValue(errorBannerMessagesAtom);

if (banners.length === 0) {
return null;
}

return (
<div className="fixed bottom-4 right-4 z-50 flex w-full max-w-sm flex-col gap-2" role="status">
{banners.map((banner) => (
<Banner id={banner.id} key={banner.id} message={banner.message} />
))}
</div>
);
}
Loading
Loading