diff --git a/.claude/commands/boot.md b/.claude/commands/boot.md index c8a7e7b..b4060c4 100644 --- a/.claude/commands/boot.md +++ b/.claude/commands/boot.md @@ -28,6 +28,7 @@ There is no cost to re-running this. The cost of acting on stale disciplines is ## Step 3: Health check - Run `npx tsc --noEmit` — if types fail, note errors. +- Run `npm run lint` — if lint fails, note errors. - Run `npx vitest run` — if tests fail, note failures. - Report any issues to the user before proceeding. @@ -61,6 +62,7 @@ These are the checks the framework expects to run automatically. If you skip the **Before every commit (the pre-commit checklist from FRAMEWORK.md):** - Tests written for money/security/data/public-API/math? - `tsc --noEmit` passes +- `npm run lint` passes - `vitest run` passes - `npm run build` passes (if Next.js routing/layout/server components changed) - Comments audited — none restating the obvious diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ba3ce4d..45c59d8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -99,6 +99,9 @@ jobs: - name: Type check run: npx tsc --noEmit + - name: Lint + run: npm run lint + - name: Unit tests run: npx vitest run --coverage diff --git a/CLAUDE.md b/CLAUDE.md index f216113..4b4afb2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -176,7 +176,6 @@ This is a combined RMS (Resource Management System) backend + marketing site for - **Uploads aren't backed up:** `db-backup.yml` dumps Postgres only. The `uploads_data` volume (writable only since the Dockerfile `chown` fix) has no backup, so restoring a dump leaves `File` rows pointing at missing files. - **Root-level CMS pages sit behind login:** `proxy.ts` only exempts listed prefixes, so a published `CmsPage` served by `app/[slug]` redirects anonymous visitors to `/login`. Latent — no CMS pages are published. - **Prod DB password is the public dev default:** `docker-compose.yml` hardcodes `simplerms_dev` for dev *and* prod, and this repo is public. Mitigated by loopback-only port binding plus the cloud security group (only 22/80/443 reachable). Rotate it (move `DATABASE_URL` into the server's `.env.local`) if this box outlives the Holt migration. -- **Lint isn't clean or gated:** `npm run lint` reports 20 errors (internal `` instead of ``, JSX built inside try/catch, setState inside effects). It crashed outright until the October 2026 ESLint bump, and CI doesn't run it. Fix the errors, then add it to the `test` job. - **Held majors:** TypeScript stays on 6 (typescript-eslint doesn't support 7.0; support is tracked for 7.1). Stripe stays on SDK 22 and the `dahlia` API. Moving to 23 / `endive` means changing the pinned `apiVersion` in `server/stripe/index.ts` and the webhook route, plus re-testing payment links and webhooks in Stripe test mode. Dependabot ignores both majors. - **nodemailer forced via `overrides`:** `"nodemailer": "$nodemailer"` forces the whole tree to our direct nodemailer (now 10). next-auth's `@auth/core` declares an **optional** `nodemailer@^7` peer used only by its Email provider, which we don't use (Credentials-only in `server/auth/index.ts`) — runtime-safe, no `--legacy-peer-deps`. Side effect: `npm audit fix` and `npm update` crash on the `$` reference — raise the override floors in `package.json` by hand instead. @@ -184,6 +183,7 @@ This is a combined RMS (Resource Management System) backend + marketing site for - Tests: `npx vitest run` (invoice tax + utils, must pass before commit) - Type check: `npx tsc --noEmit` (must pass before commit) +- Lint: `npm run lint` (zero warnings; must pass before commit, gated in CI) - Deploy: SSH to VPS, `cd /opt/simplerms && git pull && bash deploy.sh` - Docker: app + db + migrator + mailpit services - Blog posts seed automatically via `deploy.sh` — it loops over `prisma/seed-blog-*.ts` after every deploy. All blog seeds use upsert with pinned `publishedAt` dates, so this is idempotent — no drift, picks up new posts and edits without manual steps diff --git a/app/(dashboard)/dashboard/crm/clients/[id]/page.tsx b/app/(dashboard)/dashboard/crm/clients/[id]/page.tsx index 335fb7c..09d8a41 100644 --- a/app/(dashboard)/dashboard/crm/clients/[id]/page.tsx +++ b/app/(dashboard)/dashboard/crm/clients/[id]/page.tsx @@ -13,10 +13,6 @@ export default async function ClientDetailPage({ params }: Props) { const { id } = await params; const caller = await createCachedCaller(); - try { - const client = await caller.crm.clientById(id); - return ; - } catch { - notFound(); - } + const client = await caller.crm.clientById(id).catch(() => notFound()); + return ; } diff --git a/app/(dashboard)/dashboard/invoices/[id]/edit/page.tsx b/app/(dashboard)/dashboard/invoices/[id]/edit/page.tsx index b276409..771bc31 100644 --- a/app/(dashboard)/dashboard/invoices/[id]/edit/page.tsx +++ b/app/(dashboard)/dashboard/invoices/[id]/edit/page.tsx @@ -13,36 +13,32 @@ export default async function EditInvoicePage({ params }: Props) { const { id } = await params; const caller = await createCachedCaller(); - try { - const invoice = await caller.invoices.byId(id); + const invoice = await caller.invoices.byId(id).catch(() => notFound()); - if (invoice.status !== "DRAFT") { - redirect(`/dashboard/invoices/${id}`); - } + if (invoice.status !== "DRAFT") { + redirect(`/dashboard/invoices/${id}`); + } - const [clients, taxRates] = await Promise.all([ - caller.crm.listClients({ page: 1, limit: 100 }), - caller.tax.list({ includeInactive: false }), - ]); + const [clients, taxRates] = await Promise.all([ + caller.crm.listClients({ page: 1, limit: 100 }), + caller.tax.list({ includeInactive: false }), + ]); - return ( -
-
-

- Edit {invoice.invoiceNumber} -

-

- Only draft invoices can be edited. -

-
- + return ( +
+
+

+ Edit {invoice.invoiceNumber} +

+

+ Only draft invoices can be edited. +

- ); - } catch { - notFound(); - } + +
+ ); } diff --git a/app/(dashboard)/dashboard/invoices/[id]/page.tsx b/app/(dashboard)/dashboard/invoices/[id]/page.tsx index 444427b..1383359 100644 --- a/app/(dashboard)/dashboard/invoices/[id]/page.tsx +++ b/app/(dashboard)/dashboard/invoices/[id]/page.tsx @@ -13,10 +13,6 @@ export default async function InvoiceDetailPage({ params }: Props) { const { id } = await params; const caller = await createCachedCaller(); - try { - const invoice = await caller.invoices.byId(id); - return ; - } catch { - notFound(); - } + const invoice = await caller.invoices.byId(id).catch(() => notFound()); + return ; } diff --git a/app/(dashboard)/dashboard/page.tsx b/app/(dashboard)/dashboard/page.tsx index 6f1b682..f2e2a2a 100644 --- a/app/(dashboard)/dashboard/page.tsx +++ b/app/(dashboard)/dashboard/page.tsx @@ -221,7 +221,7 @@ export default async function DashboardPage() { {stats.todayAppointments.length > 0 && (
-

Today's appointments

+

Today's appointments

View all
diff --git a/app/support/track/page.tsx b/app/support/track/page.tsx index f8dfe89..ad5d2e9 100644 --- a/app/support/track/page.tsx +++ b/app/support/track/page.tsx @@ -1,6 +1,7 @@ // app/support/track/page.tsx export const dynamic = "force-dynamic"; +import Link from "next/link"; import { db } from "@/server/db"; import { notFound } from "next/navigation"; import { SiteNav } from "@/components/site/site-nav"; @@ -117,9 +118,9 @@ export default async function TrackPage({ searchParams }: Props) { >

This ticket has been {ticket.status.toLowerCase()}. Need more help?{" "} - + Submit a new ticket - + .

diff --git a/app/terms/page.tsx b/app/terms/page.tsx index 6891871..8a15a90 100644 --- a/app/terms/page.tsx +++ b/app/terms/page.tsx @@ -1,5 +1,6 @@ // app/terms/page.tsx import type { Metadata } from "next"; +import Link from "next/link"; import { SiteNav } from "@/components/site/site-nav"; import { SiteFooter } from "@/components/site/site-footer"; @@ -65,12 +66,12 @@ export default function TermsPage() {
See our{" "} - Privacy Policy - {" "} + {" "} for how we handle your information.
diff --git a/components/reports/reports-dashboard.tsx b/components/reports/reports-dashboard.tsx index 509ff3e..f382a74 100644 --- a/components/reports/reports-dashboard.tsx +++ b/components/reports/reports-dashboard.tsx @@ -115,7 +115,7 @@ export function ReportsDashboard({ revenue, aging, topClients, ticketStats }: Pr

No paid invoices yet.

) : (
- {topClients.map((tc, i) => { + {topClients.map((tc) => { const pct = topClients[0].revenue > 0 ? (tc.revenue / topClients[0].revenue) * 100 : 0; return (
diff --git a/components/scheduling/availability-editor.tsx b/components/scheduling/availability-editor.tsx index eee913b..bc136d0 100644 --- a/components/scheduling/availability-editor.tsx +++ b/components/scheduling/availability-editor.tsx @@ -1,7 +1,7 @@ // components/scheduling/availability-editor.tsx "use client"; -import { useEffect, useState } from "react"; +import { useState } from "react"; import { trpc } from "@/lib/trpc/client"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -20,35 +20,40 @@ interface DayRow { const CLOSED: DayRow = { enabled: false, startTime: "09:00", endTime: "17:00" }; +type SavedWindow = { dayOfWeek: number; startTime: string; endTime: string }; + +function toRows(saved: SavedWindow[] | undefined): DayRow[] { + const rows = DAYS.map(() => ({ ...CLOSED })); + for (const a of saved ?? []) { + if (a.dayOfWeek >= 0 && a.dayOfWeek <= 6) { + rows[a.dayOfWeek] = { enabled: true, startTime: a.startTime, endTime: a.endTime }; + } + } + return rows; +} + export function AvailabilityEditor() { const services = trpc.scheduling.listServices.useQuery({ publicOnly: false }); - const [serviceId, setServiceId] = useState(""); + const [pickedServiceId, setPickedServiceId] = useState(""); // Availability is per-service, and availableSlots unions service-scoped rows // with global ones — so the editor always targets one explicit service rather // than writing "global" hours that would silently add slots on top. - useEffect(() => { - const first = services.data?.[0]; - if (!serviceId && first) setServiceId(first.id); - }, [services.data, serviceId]); + const serviceId = pickedServiceId || services.data?.[0]?.id || ""; const availability = trpc.scheduling.getAvailability.useQuery( { serviceId: serviceId || undefined }, { enabled: serviceId.length > 0 } ); - const [rows, setRows] = useState(() => DAYS.map(() => ({ ...CLOSED }))); - - useEffect(() => { - if (!availability.data) return; - const next = DAYS.map(() => ({ ...CLOSED })); - for (const a of availability.data) { - if (a.dayOfWeek >= 0 && a.dayOfWeek <= 6) { - next[a.dayOfWeek] = { enabled: true, startTime: a.startTime, endTime: a.endTime }; - } - } - setRows(next); - }, [availability.data]); + // A draft remembers which fetch it was edited from, so a refetch (after + // saving or switching service) shows the server's hours again. + const [draft, setDraft] = useState<{ + source: SavedWindow[] | undefined; + rows: DayRow[]; + } | null>(null); + const rows = + draft && draft.source === availability.data ? draft.rows : toRows(availability.data); const utils = trpc.useUtils(); const save = trpc.scheduling.setAvailability.useMutation({ @@ -56,7 +61,10 @@ export function AvailabilityEditor() { }); function update(index: number, patch: Partial) { - setRows((rs) => rs.map((r, i) => (i === index ? { ...r, ...patch } : r))); + setDraft({ + source: availability.data, + rows: rows.map((r, i) => (i === index ? { ...r, ...patch } : r)), + }); } // Lexical compare is safe on zero-padded HH:MM and catches the end-before-start @@ -81,7 +89,7 @@ export function AvailabilityEditor() { - ) -} - -function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) -} - -function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) -} - -function SidebarSeparator({ - className, - ...props -}: React.ComponentProps) { - return ( - - ) -} - -function SidebarContent({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) -} - -function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) -} - -function SidebarGroupLabel({ - className, - render, - ...props -}: useRender.ComponentProps<"div"> & React.ComponentProps<"div">) { - return useRender({ - defaultTagName: "div", - props: mergeProps<"div">( - { - className: cn( - "flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 ring-sidebar-ring outline-hidden transition-[margin,opacity] duration-200 ease-linear group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0", - className - ), - }, - props - ), - render, - state: { - slot: "sidebar-group-label", - sidebar: "group-label", - }, - }) -} - -function SidebarGroupAction({ - className, - render, - ...props -}: useRender.ComponentProps<"button"> & React.ComponentProps<"button">) { - return useRender({ - defaultTagName: "button", - props: mergeProps<"button">( - { - className: cn( - "absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0", - className - ), - }, - props - ), - render, - state: { - slot: "sidebar-group-action", - sidebar: "group-action", - }, - }) -} - -function SidebarGroupContent({ - className, - ...props -}: React.ComponentProps<"div">) { - return ( -
- ) -} - -function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) { - return ( -
    - ) -} - -function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) { - return ( -
  • - ) -} - -const sidebarMenuButtonVariants = cva( - "peer/menu-button group/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:font-medium data-active:text-sidebar-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate", - { - variants: { - variant: { - default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground", - outline: - "bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]", - }, - size: { - default: "h-8 text-sm", - sm: "h-7 text-xs", - lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!", - }, - }, - defaultVariants: { - variant: "default", - size: "default", - }, - } -) - -function SidebarMenuButton({ - render, - isActive = false, - variant = "default", - size = "default", - tooltip, - className, - ...props -}: useRender.ComponentProps<"button"> & - React.ComponentProps<"button"> & { - isActive?: boolean - tooltip?: string | React.ComponentProps - } & VariantProps) { - const { isMobile, state } = useSidebar() - const comp = useRender({ - defaultTagName: "button", - props: mergeProps<"button">( - { - className: cn(sidebarMenuButtonVariants({ variant, size }), className), - }, - props - ), - render: !tooltip ? render : , - state: { - slot: "sidebar-menu-button", - sidebar: "menu-button", - size, - active: isActive, - }, - }) - - if (!tooltip) { - return comp - } - - if (typeof tooltip === "string") { - tooltip = { - children: tooltip, - } - } - - return ( - - {comp} - - ) -} - -function SidebarMenuAction({ - className, - render, - showOnHover = false, - ...props -}: useRender.ComponentProps<"button"> & - React.ComponentProps<"button"> & { - showOnHover?: boolean - }) { - return useRender({ - defaultTagName: "button", - props: mergeProps<"button">( - { - className: cn( - "absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0", - showOnHover && - "group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-active/menu-button:text-sidebar-accent-foreground aria-expanded:opacity-100 md:opacity-0", - className - ), - }, - props - ), - render, - state: { - slot: "sidebar-menu-action", - sidebar: "menu-action", - }, - }) -} - -function SidebarMenuBadge({ - className, - ...props -}: React.ComponentProps<"div">) { - return ( -
    - ) -} - -function SidebarMenuSkeleton({ - className, - showIcon = false, - ...props -}: React.ComponentProps<"div"> & { - showIcon?: boolean -}) { - // Random width between 50 to 90%. - const [width] = React.useState(() => { - return `${Math.floor(Math.random() * 40) + 50}%` - }) - - return ( -
    - {showIcon && ( - - )} - -
    - ) -} - -function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) { - return ( -
      - ) -} - -function SidebarMenuSubItem({ - className, - ...props -}: React.ComponentProps<"li">) { - return ( -
    • - ) -} - -function SidebarMenuSubButton({ - render, - size = "md", - isActive = false, - className, - ...props -}: useRender.ComponentProps<"a"> & - React.ComponentProps<"a"> & { - size?: "sm" | "md" - isActive?: boolean - }) { - return useRender({ - defaultTagName: "a", - props: mergeProps<"a">( - { - className: cn( - "flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground ring-sidebar-ring outline-hidden group-data-[collapsible=icon]:hidden hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[size=md]:text-sm data-[size=sm]:text-xs data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground", - className - ), - }, - props - ), - render, - state: { - slot: "sidebar-menu-sub-button", - sidebar: "menu-sub-button", - size, - active: isActive, - }, - }) -} - -export { - Sidebar, - SidebarContent, - SidebarFooter, - SidebarGroup, - SidebarGroupAction, - SidebarGroupContent, - SidebarGroupLabel, - SidebarHeader, - SidebarInput, - SidebarInset, - SidebarMenu, - SidebarMenuAction, - SidebarMenuBadge, - SidebarMenuButton, - SidebarMenuItem, - SidebarMenuSkeleton, - SidebarMenuSub, - SidebarMenuSubButton, - SidebarMenuSubItem, - SidebarProvider, - SidebarRail, - SidebarSeparator, - SidebarTrigger, - useSidebar, -} diff --git a/docs/FRAMEWORK.md b/docs/FRAMEWORK.md index 09b2eb3..134d4a4 100644 --- a/docs/FRAMEWORK.md +++ b/docs/FRAMEWORK.md @@ -140,6 +140,7 @@ Middleware regex blocked all CUID tokens; tokens are CUIDs, not UUIDs. - [ ] **Tests:** if this commit touches money/security/data/public-API/math — is there a test that fails when the code is wrong? - [ ] **Type-check:** `npx tsc --noEmit` passes locally +- [ ] **Lint:** `npm run lint` passes locally (zero warnings) - [ ] **Tests pass:** `npx vitest run` passes locally - [ ] **Build:** if this changes Next.js routing, layouts, or server components — `npm run build` succeeds locally - [ ] **Comment audit:** are any comments restating what the code does? Delete them. diff --git a/docs/LESSONS.md b/docs/LESSONS.md index 5233314..7a3a6d6 100644 --- a/docs/LESSONS.md +++ b/docs/LESSONS.md @@ -326,6 +326,16 @@ Read at session start (loaded by `/boot`). Add to it whenever: --- +## 2026-10-27 — `redirect()` inside try/catch became a 404 + +**What happened:** The invoice edit page called `redirect()` inside a `try` whose `catch` called `notFound()`. Next's `redirect()` and `notFound()` work by throwing, so opening the edit page for a non-draft invoice showed a 404 instead of the invoice. Fixing the lint rule against JSX inside try/catch exposed it. + +**Lesson:** Keep `redirect()`/`notFound()` out of `try` blocks. Catch only the fetch (`await call().catch(() => notFound())`), then branch and render outside. + +**Where it applies:** Server components and route handlers under `app/`. + +--- + ## How to add to this file When you finish a task and a real lesson emerged, add an entry. Keep it terse. The point is to avoid repeating the mistake — not to write an essay. If the lesson is big enough to drive an architectural change, it goes in `docs/DECISIONS.md` instead. If it's about how the codebase works, update `CLAUDE.md`. If it's about how *we* work — it lives here. diff --git a/eslint.config.mjs b/eslint.config.mjs index 05e726d..64a5c94 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,3 +1,4 @@ +// eslint.config.mjs import { defineConfig, globalIgnores } from "eslint/config"; import nextVitals from "eslint-config-next/core-web-vitals"; import nextTs from "eslint-config-next/typescript"; @@ -5,6 +6,13 @@ import nextTs from "eslint-config-next/typescript"; const eslintConfig = defineConfig([ ...nextVitals, ...nextTs, + { + rules: { + // The image optimizer is disabled (DECISIONS 2026-09-30), so next/image + // would render the same plain this rule asks us to replace. + "@next/next/no-img-element": "off", + }, + }, // Override default ignores of eslint-config-next. globalIgnores([ // Default ignores of eslint-config-next: diff --git a/hooks/use-mobile.ts b/hooks/use-mobile.ts deleted file mode 100644 index 2b0fe1d..0000000 --- a/hooks/use-mobile.ts +++ /dev/null @@ -1,19 +0,0 @@ -import * as React from "react" - -const MOBILE_BREAKPOINT = 768 - -export function useIsMobile() { - const [isMobile, setIsMobile] = React.useState(undefined) - - React.useEffect(() => { - const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`) - const onChange = () => { - setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) - } - mql.addEventListener("change", onChange) - setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) - return () => mql.removeEventListener("change", onChange) - }, []) - - return !!isMobile -} diff --git a/package.json b/package.json index 8e26311..96c190e 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "eslint", + "lint": "eslint --max-warnings=0", "test": "vitest run", "test:watch": "vitest", "test:coverage": "vitest run --coverage", diff --git a/server/storage/local.ts b/server/storage/local.ts index 3091ade..3d0291f 100644 --- a/server/storage/local.ts +++ b/server/storage/local.ts @@ -19,8 +19,7 @@ function safeName(name: string): string { export async function saveFile( buffer: Buffer, - originalName: string, - _mimeType: string + originalName: string ): Promise<{ filename: string; storagePath: string; sizeBytes: number }> { const filename = `${randomBytes(12).toString("hex")}-${safeName(originalName)}`; const storagePath = join(monthPrefix(), filename);