From 5926c4ad28845a5628c4f4dd699a8eab5036a2b0 Mon Sep 17 00:00:00 2001 From: Cybervoid Date: Sun, 14 Sep 2025 04:09:06 +0200 Subject: [PATCH 1/7] refactor: update report handling and form validation - Removed obsolete API routes and services related to inaccuracy reports. - Integrated new report fetching logic using async functions and Zod for validation. - Updated the ReportList component to utilize the new report fetching method and improved pagination handling. - Refactored the InaccuracyReport component to enhance form handling with Zod and improved UI components. - Cleaned up imports and improved overall code organization across several files. --- app/admin/reports/page.tsx | 85 ++---- app/admin/reports/report-table.tsx | 60 +++++ app/api/v2/report/getters.ts | 31 --- app/api/v2/report/route.ts | 75 ------ app/api/v2/report/setters.ts | 21 -- .../components/edit-fields/brand-edit.tsx | 89 ++++--- .../[slug]/components/inaccuracy-report.tsx | 245 ++++++++++-------- app/register/page.tsx | 66 +++-- app/signin/components/signin-form.tsx | 67 ++--- bun.lock | 143 +--------- .../elements/search-field/search-field.tsx | 162 ++++++------ lib/reports/actions.ts | 53 ++++ lib/reports/queries.ts | 34 +++ middleware.ts | 2 +- package.json | 5 +- providers/index.tsx | 11 +- services/fetch.ts | 22 +- utils/index.ts | 1 - utils/misc.ts | 21 -- 19 files changed, 503 insertions(+), 690 deletions(-) create mode 100644 app/admin/reports/report-table.tsx delete mode 100644 app/api/v2/report/getters.ts delete mode 100644 app/api/v2/report/route.ts delete mode 100644 app/api/v2/report/setters.ts create mode 100644 lib/reports/actions.ts create mode 100644 lib/reports/queries.ts delete mode 100644 utils/misc.ts diff --git a/app/admin/reports/page.tsx b/app/admin/reports/page.tsx index d43ad0c9..106c9c08 100644 --- a/app/admin/reports/page.tsx +++ b/app/admin/reports/page.tsx @@ -1,78 +1,25 @@ -'use client' - -import { useQuery } from '@tanstack/react-query' -import { createColumnHelper, getCoreRowModel, useReactTable } from '@tanstack/react-table' -import dayjs from 'dayjs' -import Link from 'next/link' -import { useSearchParams } from 'next/navigation' -import { Table } from '@/components/elements' import { Pagination } from '@/components/ui' -import { FrontRoutes } from '@/constants' -import { fetchReportList } from '@/services' -import type { InaccuracyReportListDTO } from '@/types' +import { getInaccuracyReports } from '@/lib/reports/queries' +import type { DynamicOptions } from '@/types' +import { ReportTable } from './report-table' + +export const dynamic: DynamicOptions = 'force-dynamic' +const itemsOnPage = 12 + +interface Props { + searchParams: Promise<{ page: string }> +} -const itemsOnPage = 6 +export default async function ReportList({ searchParams }: Props) { + const { page } = await searchParams + const pageNo = Number.parseInt(page, 10) || 1 + const { reports, totalPages } = await getInaccuracyReports(pageNo, itemsOnPage) -export default function ReportList() { - const searchParams = useSearchParams() - const page = searchParams.get('page') ?? '1' - const { data } = useQuery({ - queryKey: ['inaccuracyReport', page], - queryFn: () => - fetchReportList({ - page, - size: String(itemsOnPage), - }), - }) - const { accessor } = createColumnHelper() - const columns = [ - accessor('barometer.name', { - header: 'Barometer', - cell: info => ( - - {info.getValue()} - - ), - }), - accessor('reporterName', { - header: 'Name', - }), - accessor('reporterEmail', { - header: 'Email', - cell: info => ( - - {info.getValue()} - - ), - }), - accessor('createdAt', { - header: 'Created at', - cell: info => dayjs(info.getValue()).format('MMMM D, YYYY HH:mm'), - }), - accessor('description', { - header: 'Description', - cell: info =>
{info.getValue()}
, - }), - accessor('status', { - header: 'Status', - }), - ] - const table = useReactTable({ - columns, - data: data?.reports ?? [], - getCoreRowModel: getCoreRowModel(), - }) return ( <>

Inaccuracy Reports

- - + + ) } diff --git a/app/admin/reports/report-table.tsx b/app/admin/reports/report-table.tsx new file mode 100644 index 00000000..3e943b0a --- /dev/null +++ b/app/admin/reports/report-table.tsx @@ -0,0 +1,60 @@ +'use client' + +import { createColumnHelper, getCoreRowModel, useReactTable } from '@tanstack/react-table' +import dayjs from 'dayjs' +import Link from 'next/link' +import { Table } from '@/components/elements' +import { FrontRoutes } from '@/constants' +import type { InaccuracyReportsDTO } from '@/lib/reports/queries' + +interface Props { + reports: InaccuracyReportsDTO['reports'] +} + +export function ReportTable({ reports }: Props) { + const { accessor } = createColumnHelper() + const columns = [ + accessor('barometer.name', { + header: 'Barometer', + cell: info => ( + + {info.getValue()} + + ), + }), + accessor('reporterName', { + header: 'Name', + }), + accessor('reporterEmail', { + header: 'Email', + cell: info => ( + + {info.getValue()} + + ), + }), + accessor('createdAt', { + header: 'Created at', + cell: info => dayjs(info.getValue()).format('MMMM D, YYYY HH:mm'), + }), + accessor('description', { + header: 'Description', + cell: info =>
{info.getValue()}
, + }), + accessor('status', { + header: 'Status', + }), + ] + const table = useReactTable({ + columns, + data: reports ?? [], + getCoreRowModel: getCoreRowModel(), + }) + return
+} diff --git a/app/api/v2/report/getters.ts b/app/api/v2/report/getters.ts deleted file mode 100644 index 1a69a771..00000000 --- a/app/api/v2/report/getters.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { withPrisma } from '@/prisma/prismaClient' - -export const getInaccuracyReportList = withPrisma( - async (prisma, page: number, pageSize: number) => { - const [reports, totalItems] = await Promise.all([ - prisma.inaccuracyReport.findMany({ - skip: (page - 1) * pageSize, - take: pageSize, - orderBy: [{ createdAt: 'desc' }, { reporterName: 'desc' }], - include: { - barometer: { - select: { - name: true, - slug: true, - }, - }, - }, - }), - prisma.inaccuracyReport.count(), - ]) - return { - reports, - page, - totalItems, - pageSize, - totalPages: Math.ceil(totalItems / pageSize), - } - }, -) - -export type InaccuracyReportListDTO = Awaited> diff --git a/app/api/v2/report/route.ts b/app/api/v2/report/route.ts deleted file mode 100644 index a7bac795..00000000 --- a/app/api/v2/report/route.ts +++ /dev/null @@ -1,75 +0,0 @@ -import type { InaccuracyReport } from '@prisma/client' -import Redis from 'ioredis' -import { revalidatePath } from 'next/cache' -import { type NextRequest, NextResponse } from 'next/server' -import { FrontRoutes } from '@/constants/routes-front' -import { cleanObject, trimTrailingSlash } from '@/utils' -import { DEFAULT_PAGE_SIZE } from '../parameters' -import { getInaccuracyReportList } from './getters' -import { createReport } from './setters' - -// inaccuracy report TTL, minutes -const REPORT_COOL_DOWN = 10 -const REPORT_MAX_ATTEMPTS = 3 - -const redis = new Redis(process.env.REDIS_URL ?? '') - -/** - * Fetches a paginated list of inaccuracy reports for barometers. - * Returns 200 if reports are found, 404 if not, and 500 on error. - */ -export async function GET(req: NextRequest) { - try { - const { searchParams } = req.nextUrl - const size = Math.max(Number(searchParams.get('size')) || DEFAULT_PAGE_SIZE, 1) - const page = Math.max(Number(searchParams.get('page')) || 1, 1) - const dbResponse = await getInaccuracyReportList(page, size) - return NextResponse.json(dbResponse, { status: dbResponse.reports.length > 0 ? 200 : 404 }) - } catch (error) { - console.error('Error fetching inaccuracy report list:', error) - const message = error instanceof Error ? error.message : 'Could not get inaccuracy report list' - return NextResponse.json({ message }, { status: 500 }) - } -} - -/** - * Route for creating a new inaccuracy report for a barometer - * - Allows up to three reports to be submitted for a single key. - * - The key is generated using a combination of the user's IP address and the barometer ID (barometerId). - * - After exceeding the request limit, returns a 429 (Too Many Requests) error. - */ -export async function POST(req: NextRequest) { - try { - // getting sender IP address - const ip = - req.headers.get('x-forwarded-for')?.split(',')[0] || req.headers.get('remote-addr') || null - if (!ip) { - return NextResponse.json({ message: 'Could not determine IP address' }, { status: 400 }) - } - const body: Partial = await req.json() - const { reporterEmail, reporterName, description, barometerId } = cleanObject(body) - if (!reporterEmail || !reporterName || !description || !barometerId) - throw new Error('Report params are not complete') - const redisKey = `rate-limit:${ip}:${barometerId}` - const attempts = await redis.incr(redisKey) - if (attempts <= REPORT_MAX_ATTEMPTS) { - await redis.expire(redisKey, REPORT_COOL_DOWN * 60) - } else { - const ttl = await redis.ttl(redisKey) // TTL in seconds - const minutesLeft = Math.ceil(ttl / 60) - return NextResponse.json( - { - message: `Too many requests. Please try again after ${minutesLeft} minute(s).`, - }, - { status: 429 }, - ) - } - const { id } = await createReport(barometerId, reporterEmail, reporterName, description) - revalidatePath(trimTrailingSlash(FrontRoutes.Reports)) - return NextResponse.json({ message: 'Inaccuracy report created', id }, { status: 201 }) - } catch (error) { - console.error('Error sending inaccuracy report:', error) - const message = error instanceof Error ? error.message : 'Could not send inaccuracy report' - return NextResponse.json({ message }, { status: 500 }) - } -} diff --git a/app/api/v2/report/setters.ts b/app/api/v2/report/setters.ts deleted file mode 100644 index ad54b475..00000000 --- a/app/api/v2/report/setters.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { withPrisma } from '@/prisma/prismaClient' - -export const createReport = withPrisma( - async ( - prisma, - barometerId: string, - reporterEmail: string, - reporterName: string, - description: string, - ) => { - const newReport = await prisma.inaccuracyReport.create({ - data: { - barometerId, - reporterEmail, - reporterName, - description, - }, - }) - return { id: newReport.id } - }, -) diff --git a/app/collection/items/[slug]/components/edit-fields/brand-edit.tsx b/app/collection/items/[slug]/components/edit-fields/brand-edit.tsx index d935b4cf..8d7bdd62 100644 --- a/app/collection/items/[slug]/components/edit-fields/brand-edit.tsx +++ b/app/collection/items/[slug]/components/edit-fields/brand-edit.tsx @@ -1,15 +1,34 @@ 'use client' import { zodResolver } from '@hookform/resolvers/zod' -import { Edit } from 'lucide-react' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@radix-ui/react-select' import { useCallback, useEffect, useState, useTransition } from 'react' -import { useForm } from 'react-hook-form' +import { FormProvider, useForm } from 'react-hook-form' import { toast } from 'sonner' import { z } from 'zod' -import * as UI from '@/components/ui' +import { + Button, + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui' import { updateBarometer } from '@/lib/barometers/actions' import type { BarometerDTO } from '@/lib/barometers/queries' import type { AllBrandsDTO } from '@/lib/brands/queries' +import { EditButton } from './edit-button' interface Props { barometer: NonNullable @@ -60,57 +79,51 @@ function BrandEdit({ brands, barometer }: Props) { [barometer.manufacturerId, barometer.name], ) return ( - - - - - - - - + + + +
- - Change Brand - - Update the manufacturer for this barometer. - - + + Change Brand + Update the manufacturer for this barometer. +
- ( - - Brand - - - - - - + + Brand + + + + + )} />
- +
-
-
-
+ + + ) } diff --git a/app/collection/items/[slug]/components/inaccuracy-report.tsx b/app/collection/items/[slug]/components/inaccuracy-report.tsx index cd5169f3..0c2d646b 100644 --- a/app/collection/items/[slug]/components/inaccuracy-report.tsx +++ b/app/collection/items/[slug]/components/inaccuracy-report.tsx @@ -1,14 +1,30 @@ 'use client' -import { yupResolver } from '@hookform/resolvers/yup' -import { useMutation, useQueryClient } from '@tanstack/react-query' -import React from 'react' -import { useForm } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import React, { useEffect, useTransition } from 'react' +import { FormProvider, useForm } from 'react-hook-form' import { toast } from 'sonner' -import * as yup from 'yup' -import * as UI from '@/components/ui' +import { z } from 'zod' +import { + Button, + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + Input, + Textarea, + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/components/ui' import type { BarometerDTO } from '@/lib/barometers/queries' -import { createReport } from '@/services/fetch' +import { createReport } from '@/lib/reports/actions' interface Props extends React.ComponentProps<'button'> { barometer: NonNullable @@ -16,64 +32,61 @@ interface Props extends React.ComponentProps<'button'> { const maxFeedbackLen = 1000 -const validationSchema = yup.object({ - reporterName: yup +const validationSchema = z.object({ + reporterName: z .string() - .required('Name is required') + .min(1, 'Name is required') .min(2, 'Name must be at least 2 characters') .max(50, 'Name must be less than 50 characters'), - reporterEmail: yup + reporterEmail: z.email('Please enter a valid email address').min(1, 'Email is required'), + description: z .string() - .required('Email is required') - .email('Please enter a valid email address'), - description: yup - .string() - .required('Description is required') + .min(1, 'Description is required') .min(5, 'Description must be at least 5 characters') .max(maxFeedbackLen, `Description must be less than ${maxFeedbackLen} characters`), }) -type ReportForm = yup.InferType +type ReportForm = z.infer export function InaccuracyReport({ barometer, ...props }: Props) { - const queryClient = useQueryClient() const [isOpened, setIsOpened] = React.useState(false) - const { - register, - handleSubmit, - watch, - reset, - formState: { errors, isSubmitting }, - } = useForm({ - resolver: yupResolver(validationSchema), + const [isPending, startTransition] = useTransition() + + const form = useForm({ + mode: 'onSubmit', + reValidateMode: 'onChange', + resolver: zodResolver(validationSchema), defaultValues: { reporterName: '', reporterEmail: '', description: '' }, - mode: 'onBlur', }) - const { mutate, isPending } = useMutation({ - mutationFn: createReport, - onSuccess: ({ id }) => { - queryClient.invalidateQueries({ queryKey: ['inaccuracyReport'] }) - setIsOpened(false) - reset() - toast.success( - `Thank you! Your report was registered with ID ${id}. We will contact you at the provided email`, - ) - }, - onError: err => toast.error(err.message), - }) - const onSubmit = handleSubmit(values => { - mutate({ ...values, barometerId: barometer.id }) + const onSubmit = form.handleSubmit(values => { + startTransition(async () => { + try { + const result = await createReport({ ...values, barometerId: barometer.id }) + setIsOpened(false) + form.reset() + toast.success( + `Thank you! Your report was registered with ID ${result.id}. We will contact you at the provided email`, + ) + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Failed to submit report') + } + }) }) - const descriptionValue = watch('description') ?? '' + const descriptionValue = form.watch('description') ?? '' const symbolsLeft = maxFeedbackLen - descriptionValue.length + useEffect(() => { + if (!isOpened) return + form.reset() + }, [form.reset, isOpened]) + return ( <> - - - + + + + Report issues in the description of {barometer.name} - - - - - - - Report Inaccuracy in {barometer.name} - - - We will contact you using the provided email. - - -
-
- Name - + + + + + Report inaccuracy + + Found an error in the description of {barometer.name}? Let us know. + + + + + ( + + Name + + + + + + )} /> - {errors.reporterName && ( -

{errors.reporterName.message}

- )} -
-
- Email - ( + + Email + + + + + + )} /> - {errors.reporterEmail && ( -

- {errors.reporterEmail.message} -

- )} -
-
- Feedback - ( + + Feedback + +