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}
/>