From 3e4597294cc5bdf17ebe779f1c3272e89b2e10d1 Mon Sep 17 00:00:00 2001 From: isra el Date: Sun, 19 Jul 2026 04:19:35 +0300 Subject: [PATCH] refactor: turn on strict TypeScript and fix the 63 errors it found My earlier estimate of 34 errors was wrong, and the reason is worth recording: tsconfig carried "strictNullChecks": false as a duplicate key AFTER the strict flag, and an explicit option beats the strict umbrella even when --strict is passed on the CLI. So the measurement that produced 34 had silently excluded every null-safety error. The real number was 63. Both duplicate keys are gone; strict: true now stands alone. Most were implicit-any, but strict caught several genuine type lies: - SendSmsPayload.deviceId and WebhookData._id were optional while both are interpolated into request paths, so an absent value would have hit /gateway/devices/undefined/send-sms or PATCH /webhooks/undefined. - webhook-table's deviceName was typed string while buildDeviceLabel returns string | string[] and the Device cell already renders the array case. The type never described what the code produced. - webhooks-section read `webhooks?.data?.length > 0`, comparing undefined against 0 while the query was still in flight. - app-header declared a non-null Session while its own body guarded with session?.user throughout. Making the type honest surfaced four genuinely unguarded accesses. - api-keys kept a local ApiKeyRow duplicating the shared ApiKey type, so the list callback annotated rows as one type while the hook returned the other. ApiKeyRow is now an alias and the two extra fields moved onto ApiKey. - The notifications envelope typed its rows as unknown[], so the deliveries table's row type went entirely unchecked. Now a real WebhookNotification type. The react-hook-form cluster (20 of the 63) was one root cause: zod's .default() makes the input and output types differ, so z.infer (the output) is not what the resolver takes. Fixed by typing the forms with z.input and z.output separately, which changes nothing at runtime. Also bumped target es5 to ES2017, which fixes the Set-iteration error that made tsc --noEmit fail before any of this. Next compiles browser output via SWC and its own browserslist, so bundle targeting is unaffected. Added @types/papaparse and @types/react-syntax-highlighter, and a typecheck script, since next build does not check test files. No @ts-expect-error and no new any were used. Verified: typecheck clean, build clean, 0 lint errors (21 warnings, unchanged), 155 unit tests, 78 e2e. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../(auth)/(components)/register-form.tsx | 10 +++++-- web/app/(app)/checkout/[planName]/page.tsx | 20 ++++++++++---- .../(app)/dashboard/(components)/api-keys.tsx | 17 +++++------- .../(components)/usage-summary.test.tsx | 4 +-- .../(components)/webhooks-history/index.tsx | 6 ++--- .../webhooks/create-webhook-dialog.tsx | 14 +++++++--- .../webhooks/edit-webhook-dialog.tsx | 12 ++++++--- .../webhooks/webhooks-section.tsx | 15 +++++++---- .../webhooks/(components)/webhook-table.tsx | 24 +++++++++++------ web/components/ErrorBoundary.tsx | 4 +-- web/components/shared/analytics.tsx | 5 +++- web/components/shared/app-header.tsx | 17 +++++++----- web/lib/api/hooks.ts | 11 +++++--- web/lib/api/types.ts | 22 ++++++++++++++++ web/lib/auth.ts | 26 +++++++++++++++++-- web/lib/turnstile.ts | 3 ++- web/lib/types.ts | 4 ++- web/lib/utils/errorHandler.ts | 12 +++++++++ web/package.json | 3 +++ web/pnpm-lock.yaml | 20 ++++++++++++++ web/test/msw/handlers.ts | 6 ++--- web/tsconfig.json | 7 +++-- 22 files changed, 196 insertions(+), 66 deletions(-) diff --git a/web/app/(app)/(auth)/(components)/register-form.tsx b/web/app/(app)/(auth)/(components)/register-form.tsx index 30f7928..7c199d4 100644 --- a/web/app/(app)/(auth)/(components)/register-form.tsx +++ b/web/app/(app)/(auth)/(components)/register-form.tsx @@ -36,12 +36,18 @@ const registerSchema = z.object({ .min(1, { message: 'Please complete the bot verification' }), }) -type RegisterFormValues = z.infer +// marketingOptIn is `.optional().default(true)`, so zod's input and output +// types differ: optional going in, guaranteed coming out. The form holds the +// input shape and the submit handler receives the output shape, which is what +// useForm's third generic is for. Using z.infer (the output) for both made the +// resolver unassignable. +type RegisterFormInput = z.input +type RegisterFormValues = z.output export default function RegisterForm() { const router = useRouter() - const form = useForm({ + const form = useForm({ resolver: zodResolver(registerSchema), defaultValues: { name: '', diff --git a/web/app/(app)/checkout/[planName]/page.tsx b/web/app/(app)/checkout/[planName]/page.tsx index aa42862..4dc07f0 100644 --- a/web/app/(app)/checkout/[planName]/page.tsx +++ b/web/app/(app)/checkout/[planName]/page.tsx @@ -3,6 +3,7 @@ import { useState, useEffect, useCallback } from 'react' import httpBrowserClient from '@/lib/httpBrowserClient' import { ApiEndpoints } from '@/config/api' +import { apiErrorMessage } from '@/lib/utils/errorHandler' import { useSession } from 'next-auth/react' import { redirect } from 'next/navigation' import { Loader, CheckCircle, ArrowRight } from 'lucide-react' @@ -19,7 +20,11 @@ interface PlanChangePreview { const formatPlan = (plan: string, interval: string) => `${plan.charAt(0).toUpperCase() + plan.slice(1)} (${interval})` -export default function CheckoutPage({ params }) { +export default function CheckoutPage({ + params, +}: { + params: { planName: string } +}) { const [error, setError] = useState(null) const [planChange, setPlanChange] = useState(null) const [isConfirming, setIsConfirming] = useState(false) @@ -60,8 +65,12 @@ export default function CheckoutPage({ params }) { if (retries > 0) { initiateCheckout(retries - 1) } else { - setError(error.response?.data?.message || 'Failed to create checkout session. Please try again or contact billing@textbee.dev.') - console.error(error.response?.data?.message) + const serverMessage = apiErrorMessage(error) + setError( + serverMessage || + 'Failed to create checkout session. Please try again or contact billing@textbee.dev.' + ) + console.error(serverMessage) } } }, @@ -79,11 +88,12 @@ export default function CheckoutPage({ params }) { } catch (error) { // no auto-retry here: the request may have charged the card setPlanChange(null) + const serverMessage = apiErrorMessage(error) setError( - error.response?.data?.message || + serverMessage || 'Failed to change your plan. Please try again or contact billing@textbee.dev.', ) - console.error(error.response?.data?.message) + console.error(serverMessage) setIsConfirming(false) } } diff --git a/web/app/(app)/dashboard/(components)/api-keys.tsx b/web/app/(app)/dashboard/(components)/api-keys.tsx index fe056dc..d7054a3 100644 --- a/web/app/(app)/dashboard/(components)/api-keys.tsx +++ b/web/app/(app)/dashboard/(components)/api-keys.tsx @@ -26,6 +26,7 @@ import { useRenameApiKey, useRevokeApiKey, } from '@/lib/api' +import type { ApiKey } from '@/lib/api/types' import { Skeleton } from '@/components/ui/skeleton' import EmptyState from '@/components/shared/empty-state' import ErrorState from '@/components/shared/error-state' @@ -35,15 +36,10 @@ import GenerateApiKey, { } from './generate-api-key' import { Alert, AlertDescription } from '@/components/ui/alert' -type ApiKeyRow = { - _id: string - apiKey: string - name?: string - revokedAt?: string - createdAt: string - lastUsedAt?: string - usageCount?: number -} +// Was a local duplicate of the shared ApiKey type, which meant the list +// callback annotated its rows as one type while the hook returned the other. +// The two extra fields it carried now live on ApiKey itself. +type ApiKeyRow = ApiKey export default function ApiKeys() { const addApiKeyRef = useRef(null) @@ -438,7 +434,8 @@ export default function ApiKeys() { -
{session.user?.name}
+
{session?.user?.name}
- {session.user?.email} + {session?.user?.email}
diff --git a/web/lib/api/hooks.ts b/web/lib/api/hooks.ts index 4c7444a..156333a 100644 --- a/web/lib/api/hooks.ts +++ b/web/lib/api/hooks.ts @@ -15,6 +15,7 @@ import type { Plan, Subscription, User, + WebhookNotification, WebhookSubscription, } from './types' @@ -186,7 +187,7 @@ export type WebhookNotificationFilters = { // Raw body: { data: { data: rows[], meta: { totalPages, ... } } } export type WebhookNotificationsEnvelope = { data?: { - data?: unknown[] + data?: WebhookNotification[] meta?: { totalPages?: number; total?: number } } } @@ -223,10 +224,12 @@ export function useWebhookNotifications(filters: WebhookNotificationFilters) { // ---------- messaging ---------- -// Matches the zod-inferred SendSmsFormData shape (fields optional because the -// project runs without strict mode; validation happens via the schema). +// Matches the zod-inferred SendSmsFormData shape; validation happens via the +// schema before the payload reaches here. export type SendSmsPayload = { - deviceId?: string + // Required: it goes into the request path, so an absent one would POST to + // /gateway/devices/undefined/send-sms. Both callers validate it first. + deviceId: string recipients?: string[] message?: string simSubscriptionId?: number diff --git a/web/lib/api/types.ts b/web/lib/api/types.ts index 97b2e43..98662d8 100644 --- a/web/lib/api/types.ts +++ b/web/lib/api/types.ts @@ -49,6 +49,8 @@ export interface ApiKey { status?: 'active' | 'revoked' lastUsedAt?: string | null createdAt?: string + revokedAt?: string + usageCount?: number } export interface Plan { @@ -99,6 +101,26 @@ export interface Subscription { export type ApiKeyStatusFilter = 'active' | 'revoked' | 'all' +/** + * One webhook delivery attempt, as the notifications endpoint returns it. + * + * Previously typed as `unknown[]` on the envelope, which meant the deliveries + * table received `unknown[]` and its own row type went unchecked. + */ +export interface WebhookNotification { + _id?: string + event?: string + webhookEvent?: string + deliveryUrl?: string + webhookSubscription?: { deliveryUrl: string } + createdAt?: string + status: string + computedStatus?: string + deviceData?: { brand?: string; model?: string } + smsData?: { _id?: string } + payload?: unknown +} + export interface WebhookSubscription { _id: string name?: string diff --git a/web/lib/auth.ts b/web/lib/auth.ts index d5a3835..ae2e274 100644 --- a/web/lib/auth.ts +++ b/web/lib/auth.ts @@ -1,6 +1,6 @@ import CredentialsProvider from 'next-auth/providers/credentials' import { httpServerClient } from './httpServerClient' -import { DefaultSession } from 'next-auth' +import type { DefaultSession, NextAuthOptions } from 'next-auth' import { ApiEndpoints } from '@/config/api' import { Routes } from '@/config/routes' @@ -17,13 +17,29 @@ declare module 'next-auth' { } interface User { + // The backend returns Mongo documents, so the id arrives as _id and is + // copied onto the token below. + _id?: string + role?: string phone?: string avatar?: string accessToken?: string } } -export const authOptions = { +// The jwt/session callbacks read and write these, so the token has to declare +// them or every callback parameter falls back to an implicit any. +declare module 'next-auth/jwt' { + interface JWT { + id?: string + role?: string + phone?: string + avatar?: string + accessToken?: string + } +} + +export const authOptions: NextAuthOptions = { providers: [ CredentialsProvider({ id: 'email-password-login', @@ -34,6 +50,10 @@ export const authOptions = { turnstileToken: { label: 'Turnstile Token', type: 'text' }, }, async authorize(credentials) { + // NextAuth can invoke authorize with no credentials at all. There is + // nothing to authenticate in that case, so refuse rather than posting + // undefined fields to the login endpoint. + if (!credentials) return null const { email, password, turnstileToken } = credentials try { const res = await httpServerClient.post(ApiEndpoints.auth.login(), { @@ -67,6 +87,7 @@ export const authOptions = { turnstileToken: { label: 'Turnstile Token', type: 'text' }, }, async authorize(credentials) { + if (!credentials) return null const { email, password, name, phone, turnstileToken } = credentials try { const res = await httpServerClient.post( @@ -100,6 +121,7 @@ export const authOptions = { idToken: { label: 'idToken', type: 'text' }, }, async authorize(credentials) { + if (!credentials) return null const { idToken } = credentials try { const res = await httpServerClient.post( diff --git a/web/lib/turnstile.ts b/web/lib/turnstile.ts index 92751fa..9bd0095 100644 --- a/web/lib/turnstile.ts +++ b/web/lib/turnstile.ts @@ -71,7 +71,8 @@ type UseTurnstileOptions = { } type UseTurnstileResult = { - containerRef: RefObject + // Nullable: useRef starts at null and only holds the node once mounted. + containerRef: RefObject token: string error: string | null isReady: boolean diff --git a/web/lib/types.ts b/web/lib/types.ts index 74fe3b8..b32edc3 100644 --- a/web/lib/types.ts +++ b/web/lib/types.ts @@ -1,5 +1,7 @@ export interface WebhookData { - _id?: string + // Required: it goes into the update and delete request paths, so an absent + // one would PATCH /webhooks/undefined. Anything the API returns has one. + _id: string name?: string deliveryUrl: string events: string[] diff --git a/web/lib/utils/errorHandler.ts b/web/lib/utils/errorHandler.ts index 2a78388..bab99e8 100644 --- a/web/lib/utils/errorHandler.ts +++ b/web/lib/utils/errorHandler.ts @@ -76,6 +76,18 @@ export function formatError(error: unknown): FormattedError { } } +/** + * The `message` an API error carried, or undefined if it had none. + * + * Distinct from formatError, which always substitutes a generic message. + * Use this where the caller has its own fallback copy worth preserving, and + * needs to know whether the server actually said anything. + */ +export function apiErrorMessage(error: unknown): string | undefined { + const data = (error as AxiosError<{ message?: string }>)?.response?.data + return data?.message +} + /** * Checks if an error is a rate limit (429) error */ diff --git a/web/package.json b/web/package.json index d177d09..71db927 100644 --- a/web/package.json +++ b/web/package.json @@ -8,6 +8,7 @@ "vercel-build": "next build", "start": "next start", "lint": "eslint .", + "typecheck": "tsc --noEmit", "test": "vitest run", "test:watch": "vitest", "test:e2e": "playwright test", @@ -70,8 +71,10 @@ "@testing-library/react": "^16.1.0", "@testing-library/user-event": "^14.5.2", "@types/node": "^20.11.5", + "@types/papaparse": "^5.5.2", "@types/react": "^19.2.0", "@types/react-dom": "^19.2.0", + "@types/react-syntax-highlighter": "^15.5.13", "@typescript-eslint/eslint-plugin": "^8.18.0", "@typescript-eslint/parser": "^8.18.0", "@vitejs/plugin-react": "^4.3.4", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 30bf568..150d683 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -159,12 +159,18 @@ importers: '@types/node': specifier: ^20.11.5 version: 20.11.5 + '@types/papaparse': + specifier: ^5.5.2 + version: 5.5.2 '@types/react': specifier: ^19.2.0 version: 19.2.17 '@types/react-dom': specifier: ^19.2.0 version: 19.2.3(@types/react@19.2.17) + '@types/react-syntax-highlighter': + specifier: ^15.5.13 + version: 15.5.13 '@typescript-eslint/eslint-plugin': specifier: ^8.18.0 version: 8.63.0(@typescript-eslint/parser@8.63.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) @@ -1800,11 +1806,17 @@ packages: '@types/node@20.11.5': resolution: {integrity: sha512-g557vgQjUUfN76MZAN/dt1z3dzcUsimuysco0KeluHgrPdJXkP/XdAURgyO2W9fZWHRtRBiVKzKn8vyOAwlG+w==} + '@types/papaparse@5.5.2': + resolution: {integrity: sha512-gFnFp/JMzLHCwRf7tQHrNnfhN4eYBVYYI897CGX4MY1tzY9l2aLkVyx2IlKZ/SAqDbB3I1AOZW5gTMGGsqWliA==} + '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} peerDependencies: '@types/react': ^19.2.0 + '@types/react-syntax-highlighter@15.5.13': + resolution: {integrity: sha512-uLGJ87j6Sz8UaBAooU0T6lWJ0dBmjZgN1PZTrj05TNql2/XpC6+4HhMT5syIdFUUt+FASfCeLLv4kBygNU+8qA==} + '@types/react@19.2.17': resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} @@ -5720,10 +5732,18 @@ snapshots: dependencies: undici-types: 5.26.5 + '@types/papaparse@5.5.2': + dependencies: + '@types/node': 20.11.5 + '@types/react-dom@19.2.3(@types/react@19.2.17)': dependencies: '@types/react': 19.2.17 + '@types/react-syntax-highlighter@15.5.13': + dependencies: + '@types/react': 19.2.17 + '@types/react@19.2.17': dependencies: csstype: 3.2.3 diff --git a/web/test/msw/handlers.ts b/web/test/msw/handlers.ts index a6ad201..fa5d164 100644 --- a/web/test/msw/handlers.ts +++ b/web/test/msw/handlers.ts @@ -1,4 +1,4 @@ -import { http, HttpResponse } from 'msw' +import { http, HttpResponse, type JsonBodyType } from 'msw' import { ApiEndpoints } from '@/config/api' import { API_BASE_URL, @@ -19,8 +19,8 @@ import { const url = (path: string) => `${API_BASE_URL}${path.split('?')[0]}` // Envelope helpers matching the real API's response shapes. -const dataEnvelope = (data: unknown) => HttpResponse.json({ data }) -const raw = (body: unknown) => HttpResponse.json(body) +const dataEnvelope = (data: JsonBodyType) => HttpResponse.json({ data }) +const raw = (body: JsonBodyType) => HttpResponse.json(body) export const handlers = [ // --- auth --- diff --git a/web/tsconfig.json b/web/tsconfig.json index 2c932fd..d1dbdd9 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "target": "es5", + "target": "ES2017", "lib": [ "dom", "dom.iterable", @@ -14,7 +14,7 @@ }, "allowJs": true, "skipLibCheck": true, - "strict": false, + "strict": true, "forceConsistentCasingInFileNames": true, "noEmit": true, "esModuleInterop": true, @@ -28,8 +28,7 @@ { "name": "next" } - ], - "strictNullChecks": false + ] }, "include": [ "next-env.d.ts",