From 726c73f34507521efef1897a410d9ce5638cc31b Mon Sep 17 00:00:00 2001 From: TheHypnoo Date: Tue, 23 Jun 2026 11:44:06 +0200 Subject: [PATCH 1/3] Fix site settings schedule and session handling --- apps/web/src/lib/session-resolver.ts | 59 +++++++++++ apps/web/src/lib/session-server.test.ts | 12 +++ apps/web/src/lib/session-server.ts | 48 +-------- .../src/lib/site-settings-payloads.test.ts | 27 +++++ apps/web/src/lib/site-settings-payloads.ts | 19 ++++ apps/web/src/routes/__root.tsx | 4 +- .../src/routes/_authenticated/sites.$id.tsx | 98 +++++++++++++++---- .../server/src/sites/sites.controller.spec.ts | 5 +- packages/server/src/sites/sites.controller.ts | 6 ++ .../server/src/sites/sites.service.spec.ts | 14 +++ packages/server/src/sites/sites.service.ts | 18 ++++ 11 files changed, 244 insertions(+), 66 deletions(-) create mode 100644 apps/web/src/lib/session-resolver.ts create mode 100644 apps/web/src/lib/site-settings-payloads.test.ts create mode 100644 apps/web/src/lib/site-settings-payloads.ts diff --git a/apps/web/src/lib/session-resolver.ts b/apps/web/src/lib/session-resolver.ts new file mode 100644 index 0000000..e5e41f8 --- /dev/null +++ b/apps/web/src/lib/session-resolver.ts @@ -0,0 +1,59 @@ +import { createServerOnlyFn } from '@tanstack/react-start'; +import { getCookie, getRequestHeader } from '@tanstack/react-start/server'; + +import type { ServerSession } from './session-server'; + +// Server-only env: absolute URL to the API. The browser-facing VITE_API_URL +// is a relative path that flows through the Nitro proxy. +const API_BASE_URL = process.env.SERVER_API_URL ?? 'http://localhost:4000/api/v1'; +const REFRESH_COOKIE_NAME = import.meta.env.VITE_REFRESH_COOKIE_NAME ?? 'refresh_token'; + +const EMPTY: ServerSession = { user: null }; + +export const resolveServerSession = createServerOnlyFn(async (): Promise => { + const cookieHeader = getRequestHeader('cookie') ?? ''; + const refreshToken = + getCookie(REFRESH_COOKIE_NAME) ?? getCookieValue(cookieHeader, REFRESH_COOKIE_NAME); + if (!refreshToken) { + return EMPTY; + } + + // Forward the entire incoming Cookie header so the API receives the + // refresh_token (HttpOnly) it needs to validate the session. + let response: Response; + try { + response = await fetch(`${API_BASE_URL}/auth/session`, { + headers: { cookie: cookieHeader }, + method: 'GET', + }); + } catch { + return EMPTY; + } + + if (!response.ok) { + return EMPTY; + } + + let user: NonNullable; + try { + user = (await response.json()) as NonNullable; + } catch { + return EMPTY; + } + if (!user?.id) { + return EMPTY; + } + + return { user }; +}); + +function getCookieValue(cookieHeader: string, name: string) { + const cookies = cookieHeader.split(';'); + for (const cookie of cookies) { + const [rawName, ...rawValue] = cookie.trim().split('='); + if (rawName === name) { + return decodeURIComponent(rawValue.join('=')); + } + } + return undefined; +} diff --git a/apps/web/src/lib/session-server.test.ts b/apps/web/src/lib/session-server.test.ts index 461817d..5a2e300 100644 --- a/apps/web/src/lib/session-server.test.ts +++ b/apps/web/src/lib/session-server.test.ts @@ -15,6 +15,7 @@ vi.mock( createServerFn: () => ({ handler: (fn: (...args: Args) => R) => fn, }), + createServerOnlyFn: (fn: (...args: Args) => R) => fn, }) as unknown as Partial, ); @@ -84,6 +85,17 @@ describe('getServerSession (SSR)', () => { expect((init as RequestInit).method).toBe('GET'); }); + it('falls back to the Cookie header when getCookie misses the refresh token', async () => { + getCookie.mockReturnValue(undefined); + getRequestHeader.mockReturnValue('csrf_token=csrf-xyz; refresh_token=rt-from-header'); + fetchMock.mockResolvedValueOnce(jsonResponse(200, { id: 'u1', email: 'a@b.c', name: 'Alice' })); + + const result = await getServerSession(); + + expect(result).toStrictEqual({ user: { id: 'u1', email: 'a@b.c', name: 'Alice' } }); + expect(fetchMock).toHaveBeenCalledOnce(); + }); + it('uses an empty cookie header when the incoming Cookie header is missing', async () => { getCookie.mockReturnValue('rt-abc'); getRequestHeader.mockReturnValue(undefined); diff --git a/apps/web/src/lib/session-server.ts b/apps/web/src/lib/session-server.ts index 36ccac8..d9e36bb 100644 --- a/apps/web/src/lib/session-server.ts +++ b/apps/web/src/lib/session-server.ts @@ -1,18 +1,11 @@ import { createServerFn } from '@tanstack/react-start'; -import { getCookie, getRequestHeader } from '@tanstack/react-start/server'; -// Server-only env: absolute URL to the API. The browser-facing VITE_API_URL -// is a relative path that flows through the Nitro proxy. -const API_BASE_URL = - process.env.SERVER_API_URL ?? import.meta.env.VITE_API_URL ?? 'http://localhost:4000/api/v1'; -const REFRESH_COOKIE_NAME = import.meta.env.VITE_REFRESH_COOKIE_NAME ?? 'refresh_token'; +import { resolveServerSession } from './session-resolver'; export interface ServerSession { user: { id: string; email: string; name?: string } | null; } -const EMPTY: ServerSession = { user: null }; - /** * Resolve the visitor's session on the server WITHOUT rotating the refresh * token. Calls the backend's `GET /auth/session` endpoint, which only @@ -27,41 +20,4 @@ const EMPTY: ServerSession = { user: null }; * - SSR (root route's `beforeLoad`): determines the chrome to render. * - SPA navigation (RPC): re-validates the session before each route change. */ -export const getServerSession = createServerFn({ method: 'GET' }).handler( - async (): Promise => { - const refreshToken = getCookie(REFRESH_COOKIE_NAME); - if (!refreshToken) { - return EMPTY; - } - - // Forward the entire incoming Cookie header so the API receives the - // refresh_token (HttpOnly) it needs to validate the session. - const cookieHeader = getRequestHeader('cookie') ?? ''; - - let response: Response; - try { - response = await fetch(`${API_BASE_URL}/auth/session`, { - headers: { cookie: cookieHeader }, - method: 'GET', - }); - } catch { - return EMPTY; - } - - if (!response.ok) { - return EMPTY; - } - - let user: NonNullable; - try { - user = (await response.json()) as NonNullable; - } catch { - return EMPTY; - } - if (!user?.id) { - return EMPTY; - } - - return { user }; - }, -); +export const getServerSession = createServerFn({ method: 'GET' }).handler(resolveServerSession); diff --git a/apps/web/src/lib/site-settings-payloads.test.ts b/apps/web/src/lib/site-settings-payloads.test.ts new file mode 100644 index 0000000..c5375bf --- /dev/null +++ b/apps/web/src/lib/site-settings-payloads.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; + +import { toAlertRuleUpdatePayload } from './site-settings-payloads'; + +describe(toAlertRuleUpdatePayload, () => { + it('keeps only fields accepted by the regression alert update API', () => { + expect( + toAlertRuleUpdatePayload({ + id: 'rule-1', + siteId: 'site-1', + enabled: false, + notifyOnScoreDrop: true, + scoreDropThreshold: 7, + notifyOnNewCriticalIssues: false, + notifyOnIssueCountIncrease: true, + createdAt: '2026-06-23T00:00:00.000Z', + updatedAt: '2026-06-23T00:00:00.000Z', + }), + ).toStrictEqual({ + enabled: false, + notifyOnScoreDrop: true, + scoreDropThreshold: 7, + notifyOnNewCriticalIssues: false, + notifyOnIssueCountIncrease: true, + }); + }); +}); diff --git a/apps/web/src/lib/site-settings-payloads.ts b/apps/web/src/lib/site-settings-payloads.ts new file mode 100644 index 0000000..6012a68 --- /dev/null +++ b/apps/web/src/lib/site-settings-payloads.ts @@ -0,0 +1,19 @@ +export type AlertRuleUpdatePayload = { + enabled: boolean; + notifyOnScoreDrop: boolean; + scoreDropThreshold: number; + notifyOnNewCriticalIssues: boolean; + notifyOnIssueCountIncrease: boolean; +}; + +export function toAlertRuleUpdatePayload( + rule: AlertRuleUpdatePayload & Record, +): AlertRuleUpdatePayload { + return { + enabled: rule.enabled, + notifyOnScoreDrop: rule.notifyOnScoreDrop, + scoreDropThreshold: rule.scoreDropThreshold, + notifyOnNewCriticalIssues: rule.notifyOnNewCriticalIssues, + notifyOnIssueCountIncrease: rule.notifyOnIssueCountIncrease, + }; +} diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index d2c4a14..2556b01 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -12,6 +12,7 @@ import { ServiceWorkerRegistration } from '../components/service-worker-registra import { ApiClientError } from '../lib/api-client'; import { AuthProvider } from '../lib/auth-context'; import { ProjectProvider } from '../lib/project-context'; +import { resolveServerSession } from '../lib/session-resolver'; import { getServerSession } from '../lib/session-server'; import type { ServerSession } from '../lib/session-server'; import { toastBridge } from '../lib/toast-bridge'; @@ -29,7 +30,8 @@ export const Route = createRootRouteWithContext()({ // beforeLoad and exposing it through the router context lets every child // beforeLoad / loader read the same value with zero extra network calls. beforeLoad: async () => { - const session = await getServerSession(); + const session = + typeof window === 'undefined' ? await resolveServerSession() : await getServerSession(); return { session }; }, // Re-export the session as loader data so consumers using diff --git a/apps/web/src/routes/_authenticated/sites.$id.tsx b/apps/web/src/routes/_authenticated/sites.$id.tsx index e27eebd..ab4c49f 100644 --- a/apps/web/src/routes/_authenticated/sites.$id.tsx +++ b/apps/web/src/routes/_authenticated/sites.$id.tsx @@ -14,6 +14,7 @@ import { Play, Save, Settings2, + Trash2, TrendingDown, Workflow, } from 'lucide-react'; @@ -35,6 +36,7 @@ import type { PaginatedResponse } from '@seotracker/shared-types'; import { useAuth } from '../../lib/auth-context'; import { formatDisplayDate, formatDisplayDateTime } from '../../lib/date-format'; import { REFETCH_INTERVALS, pollWhileAnyAuditActive } from '../../lib/refetch-intervals'; +import { toAlertRuleUpdatePayload } from '../../lib/site-settings-payloads'; import { getTimezoneOptions } from '../../lib/timezones'; type AuditRun = { @@ -244,8 +246,10 @@ function ProjectDetailPage() { prevActiveCountRef.current = activeCount; }, [activeCount, id, queryClient]); + const scheduleQueryKey = useMemo(() => ['schedule', id] as const, [id]); + const schedule = useQuery({ - queryKey: ['schedule', id], + queryKey: scheduleQueryKey, queryFn: () => auth.api.get(`/sites/${id}/schedule`), enabled: Boolean(auth.accessToken), }); @@ -320,15 +324,58 @@ function ProjectDetailPage() { enabled: true, }), onSuccess: async () => { - await queryClient.invalidateQueries({ queryKey: ['schedule', id] }); + await queryClient.invalidateQueries({ queryKey: scheduleQueryKey }); setScheduleModalOpen(false); + toast.success('Programación guardada', 'La auditoría automática se ha actualizado.'); + }, + onError: (error) => { + toast.error( + 'No se pudo guardar la programación', + error instanceof Error ? error.message : undefined, + ); }, }); - const saveAlerts = useMutation({ - mutationFn: () => auth.api.put(`/sites/${id}/alerts`, alertState), + const deleteSchedule = useMutation({ + mutationFn: () => auth.api.delete(`/sites/${id}/schedule`), + onMutate: async () => { + await queryClient.cancelQueries({ queryKey: scheduleQueryKey }); + const previousSchedule = queryClient.getQueryData(scheduleQueryKey) ?? null; + queryClient.setQueryData(scheduleQueryKey, null); + return { previousSchedule }; + }, + onError: (error, _variables, context) => { + queryClient.setQueryData( + scheduleQueryKey, + context?.previousSchedule ?? null, + ); + toast.error( + 'No se pudo eliminar la programación', + error instanceof Error ? error.message : undefined, + ); + }, onSuccess: async () => { + await queryClient.cancelQueries({ queryKey: scheduleQueryKey }); + queryClient.setQueryData(scheduleQueryKey, null); + setScheduleModalOpen(false); + toast.success('Programación eliminada', 'La auditoría automática ha quedado desactivada.'); + await queryClient.invalidateQueries({ queryKey: scheduleQueryKey }); + queryClient.setQueryData(scheduleQueryKey, null); + }, + }); + + const saveAlerts = useMutation({ + mutationFn: () => auth.api.put(`/sites/${id}/alerts`, toAlertRuleUpdatePayload(alertState)), + onSuccess: async (savedRule) => { + queryClient.setQueryData(['alerts', id], savedRule); await queryClient.invalidateQueries({ queryKey: ['alerts', id] }); + toast.success('Alertas guardadas', 'La configuración de regresiones se ha actualizado.'); + }, + onError: (error) => { + toast.error( + 'No se pudieron guardar las alertas', + error instanceof Error ? error.message : undefined, + ); }, }); @@ -364,7 +411,7 @@ function ProjectDetailPage() { }, [alerts.data, saveAlerts.isPending, setAlertState]); useEffect(() => { - if (schedule.data && !saveSchedule.isPending) { + if (schedule.data && !saveSchedule.isPending && !deleteSchedule.isPending) { setScheduleForm({ frequency: schedule.data.frequency, dayOfWeek: String(schedule.data.dayOfWeek ?? 1), @@ -372,7 +419,7 @@ function ProjectDetailPage() { timezone: schedule.data.timezone, }); } - }, [saveSchedule.isPending, schedule.data, setScheduleForm]); + }, [deleteSchedule.isPending, saveSchedule.isPending, schedule.data, setScheduleForm]); const scheduleSummary = schedule.data ? describeSchedule(schedule.data) @@ -717,18 +764,33 @@ function ProjectDetailPage() { /> -
- - +
+ {schedule.data ? ( + + ) : ( + + )} +
+ + +
diff --git a/packages/server/src/sites/sites.controller.spec.ts b/packages/server/src/sites/sites.controller.spec.ts index d042dd2..5db0ad7 100644 --- a/packages/server/src/sites/sites.controller.spec.ts +++ b/packages/server/src/sites/sites.controller.spec.ts @@ -25,6 +25,7 @@ describe('sitesController', () => { delete: jest.fn().mockResolvedValue('deleted'), upsertSchedule: jest.fn().mockResolvedValue('scheduled'), getSchedule: jest.fn().mockResolvedValue('schedule'), + deleteSchedule: jest.fn().mockResolvedValue('scheduleDeleted'), }; crawlConfigService = { getForUser: jest.fn(), update: jest.fn() }; @@ -83,11 +84,13 @@ describe('sitesController', () => { expect(service.delete).toHaveBeenCalledWith('s1', 'u-1'); }); - it('upsertSchedule / getSchedule delegate', () => { + it('upsertSchedule / getSchedule / deleteSchedule delegate', () => { void controller.upsertSchedule(USER, 's1', { frequency: 'DAILY' } as never); void controller.getSchedule(USER, 's1'); + void controller.deleteSchedule(USER, 's1'); expect(service.upsertSchedule).toHaveBeenCalledWith('s1', 'u-1', { frequency: 'DAILY' }); expect(service.getSchedule).toHaveBeenCalledWith('s1', 'u-1'); + expect(service.deleteSchedule).toHaveBeenCalledWith('s1', 'u-1'); }); it('crawl config endpoints delegate', () => { diff --git a/packages/server/src/sites/sites.controller.ts b/packages/server/src/sites/sites.controller.ts index ad164c2..90b6860 100644 --- a/packages/server/src/sites/sites.controller.ts +++ b/packages/server/src/sites/sites.controller.ts @@ -96,6 +96,12 @@ export class SitesController { return this.sitesService.getSchedule(siteId, user.sub); } + @Delete(':siteId/schedule') + @ApiOperation({ summary: 'Eliminar schedule del proyecto' }) + deleteSchedule(@CurrentUser() user: { sub: string }, @Param('siteId') siteId: string) { + return this.sitesService.deleteSchedule(siteId, user.sub); + } + @Get(':siteId/crawl-config') @ApiOperation({ summary: 'Obtener config de crawler del sitio (resuelta con defaults)' }) getCrawlConfig(@CurrentUser() user: { sub: string }, @Param('siteId') siteId: string) { diff --git a/packages/server/src/sites/sites.service.spec.ts b/packages/server/src/sites/sites.service.spec.ts index fab1737..04f4bd9 100644 --- a/packages/server/src/sites/sites.service.spec.ts +++ b/packages/server/src/sites/sites.service.spec.ts @@ -429,6 +429,20 @@ describe('sitesService', () => { }); }); + describe('deleteSchedule', () => { + it('asserts schedule write access then deletes the schedule row', async () => { + db.where + .mockReturnValueOnce(thenable([{ id: 's1', projectId: 'p1' }])) + .mockResolvedValueOnce(undefined); + + const out = await service.deleteSchedule('s1', 'u1'); + + expect(projects.assertPermission).toHaveBeenCalledWith('p1', 'u1', expect.any(String)); + expect(db.delete).toHaveBeenCalledTimes(1); + expect(out).toStrictEqual({ success: true }); + }); + }); + describe('loadLatestRunsByProject', () => { it('returns empty rows without querying when no site ids are provided', async () => { await expect( diff --git a/packages/server/src/sites/sites.service.ts b/packages/server/src/sites/sites.service.ts index 1b6fab3..086bbf0 100644 --- a/packages/server/src/sites/sites.service.ts +++ b/packages/server/src/sites/sites.service.ts @@ -378,4 +378,22 @@ export class SitesService { .limit(1); return schedule ?? null; } + + async deleteSchedule(siteId: string, userId: string) { + const site = await this.getByIdWithPermission(siteId, userId, Permission.SCHEDULE_WRITE); + + await this.db.delete(siteSchedules).where(eq(siteSchedules.siteId, site.id)); + + this.emitActivity({ + projectId: site.projectId, + userId, + action: ActivityAction.SCHEDULE_UPDATED, + resourceType: 'schedule', + resourceId: site.id, + siteId: site.id, + metadata: { enabled: false }, + }); + + return { success: true }; + } } From 79758d0133ce8c3058dc12cb515826ec4591b51c Mon Sep 17 00:00:00 2001 From: TheHypnoo Date: Tue, 23 Jun 2026 11:53:40 +0200 Subject: [PATCH 2/3] Prevent site search loading flicker --- .../routes/_authenticated/projects.$id.sites.tsx | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/apps/web/src/routes/_authenticated/projects.$id.sites.tsx b/apps/web/src/routes/_authenticated/projects.$id.sites.tsx index 915f5fc..14093ef 100644 --- a/apps/web/src/routes/_authenticated/projects.$id.sites.tsx +++ b/apps/web/src/routes/_authenticated/projects.$id.sites.tsx @@ -1,6 +1,6 @@ import { useForm } from '@tanstack/react-form'; import { Link, createFileRoute } from '@tanstack/react-router'; -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { ChevronLeft, ChevronRight, Eye, Globe, Play, Plus, Search, Zap } from 'lucide-react'; import { useMemo, useState } from 'react'; import { Button } from '#/components/button'; @@ -90,6 +90,9 @@ function ProjectProjectsPage() { }, enabled: Boolean(auth.accessToken), refetchInterval: pollWhileAnyLatestAuditActive, + // Keep the previous page visible while typing/filtering so the grid does + // not flash skeletons or empty-state on every keystroke. + placeholderData: keepPreviousData, }); const projectItems = sites.data?.items ?? []; @@ -407,7 +410,7 @@ function ProjectProjectsPage() { - {sites.isLoading ? ( + {sites.isLoading && !sites.data ? (
    {['p1', 'p2', 'p3', 'p4', 'p5', 'p6'].map((slot) => (
  • @@ -428,7 +431,11 @@ function ProjectProjectsPage() {

    ) : ( -
      +
        {projectItems.map((site) => (
      • Date: Tue, 23 Jun 2026 11:57:58 +0200 Subject: [PATCH 3/3] Fix project sites bulk audit action --- .../_authenticated/projects.$id.sites.tsx | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/apps/web/src/routes/_authenticated/projects.$id.sites.tsx b/apps/web/src/routes/_authenticated/projects.$id.sites.tsx index 14093ef..8ccfd50 100644 --- a/apps/web/src/routes/_authenticated/projects.$id.sites.tsx +++ b/apps/web/src/routes/_authenticated/projects.$id.sites.tsx @@ -136,7 +136,9 @@ function ProjectProjectsPage() { }); const runAudit = useMutation({ - mutationFn: (siteId: string) => auth.api.post(`/sites/${siteId}/audits/run`), + mutationFn: async (siteIds: string[]) => { + await Promise.all(siteIds.map((siteId) => auth.api.post(`/sites/${siteId}/audits/run`))); + }, onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: ['sites', id] }); await queryClient.invalidateQueries({ @@ -188,16 +190,20 @@ function ProjectProjectsPage() { @@ -440,7 +446,7 @@ function ProjectProjectsPage() {
      • runAudit.mutate(site.id)} + onRun={() => runAudit.mutate([site.id])} running={runAudit.isPending} />