diff --git a/apps/backend/src/app/api/cron/sync-deployment-status/route.test.ts b/apps/backend/src/app/api/cron/sync-deployment-status/route.test.ts new file mode 100644 index 0000000..2fce841 --- /dev/null +++ b/apps/backend/src/app/api/cron/sync-deployment-status/route.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { GET } from './route'; +import { NextRequest } from 'next/server'; + +const CRON_SECRET = 'test-cron-secret'; + +// ── Mocks ───────────────────────────────────────────────────────────────────── + +const mockSupabase = { + from: vi.fn().mockReturnThis(), + select: vi.fn().mockReturnThis(), + eq: vi.fn().mockReturnThis(), + lt: vi.fn().mockReturnThis(), + update: vi.fn().mockReturnThis(), + single: vi.fn(), +}; + +vi.mock('@/lib/supabase/server', () => ({ + createClient: () => mockSupabase, +})); + +vi.mock('@/services/github-to-vercel-deployment.service', () => ({ + githubToVercelDeploymentService: { + syncDeploymentStatus: vi.fn(), + }, +})); + +// ── Test setup ───────────────────────────────────────────────────────────────── + +beforeEach(() => { + vi.clearAllMocks(); + process.env.CRON_SECRET = CRON_SECRET; +}); + +describe('GET /api/cron/sync-deployment-status', () => { + it('returns 401 for invalid cron secret', async () => { + const request = new NextRequest('http://localhost:4001/api/cron/sync-deployment-status', { + method: 'GET', + headers: { + authorization: 'Bearer wrong-secret', + }, + }); + + const response = await GET(request); + expect(response.status).toBe(401); + }); + + it('returns 200 with synced: 0 when no stale deployments found', async () => { + mockSupabase.from.mockReturnThis(); + mockSupabase.select.mockReturnThis(); + mockSupabase.eq.mockReturnThis(); + mockSupabase.lt.mockResolvedValue({ data: [], error: null }); + + const request = new NextRequest('http://localhost:4001/api/cron/sync-deployment-status', { + method: 'GET', + headers: { + authorization: `Bearer ${CRON_SECRET}`, + }, + }); + + const response = await GET(request); + expect(response.status).toBe(200); + + const data = await response.json(); + expect(data).toEqual({ synced: 0, failed: 0 }); + }); + + it('syncs stale deployments and returns counts', async () => { + const staleDeployments = [ + { vercel_deployment_id: 'v1' }, + { vercel_deployment_id: 'v2' }, + { vercel_deployment_id: 'v3' }, + ]; + + mockSupabase.lt.mockResolvedValue({ data: staleDeployments, error: null }); + + const { githubToVercelDeploymentService } = await import('@/services/github-to-vercel-deployment.service'); + vi.mocked(githubToVercelDeploymentService.syncDeploymentStatus) + .mockResolvedValueOnce({ id: 'd1' } as any) // success + .mockResolvedValueOnce(null) // failure + .mockResolvedValueOnce({ id: 'd3' } as any); // success + + const request = new NextRequest('http://localhost:4001/api/cron/sync-deployment-status', { + method: 'GET', + headers: { + authorization: `Bearer ${CRON_SECRET}`, + }, + }); + + const response = await GET(request); + expect(response.status).toBe(200); + + const data = await response.json(); + expect(data).toEqual({ synced: 2, failed: 1 }); + + expect(githubToVercelDeploymentService.syncDeploymentStatus).toHaveBeenCalledTimes(3); + }); + + it('handles database fetch error', async () => { + mockSupabase.lt.mockResolvedValue({ data: null, error: { message: 'DB Error' } }); + + const request = new NextRequest('http://localhost:4001/api/cron/sync-deployment-status', { + method: 'GET', + headers: { + authorization: `Bearer ${CRON_SECRET}`, + }, + }); + + const response = await GET(request); + expect(response.status).toBe(500); + }); +}); diff --git a/apps/backend/src/app/api/cron/sync-deployment-status/route.ts b/apps/backend/src/app/api/cron/sync-deployment-status/route.ts new file mode 100644 index 0000000..c6eab62 --- /dev/null +++ b/apps/backend/src/app/api/cron/sync-deployment-status/route.ts @@ -0,0 +1,76 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { githubToVercelDeploymentService } from '@/services/github-to-vercel-deployment.service'; +import { createClient } from '@/lib/supabase/server'; + +/** + * Cron endpoint to sync Vercel deployment status for stale deployments + * This should be called periodically (e.g., every 2 minutes) by a cron service + * + * Vercel Cron: https://vercel.com/docs/cron-jobs + * Configure in vercel.json with crons array containing path and schedule. + */ +export async function GET(req: NextRequest) { + try { + // Verify cron secret to prevent unauthorized access + const authHeader = req.headers.get('authorization'); + const cronSecret = process.env.CRON_SECRET; + + if (cronSecret && authHeader !== `Bearer ${cronSecret}`) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + console.log('Running sync-deployment-status cron...'); + + const supabase = createClient(); + + // Find deployments in 'building' state that are older than 2 minutes + const twoMinutesAgo = new Date(Date.now() - 120000).toISOString(); + + const { data: staleDeployments, error: fetchError } = await supabase + .from('github_vercel_deployments') + .select('vercel_deployment_id') + .eq('status', 'building') + .lt('created_at', twoMinutesAgo); + + if (fetchError) { + console.error('Failed to fetch stale deployments:', fetchError); + return NextResponse.json({ error: 'Failed to fetch stale deployments' }, { status: 500 }); + } + + console.log(`Found ${staleDeployments?.length || 0} stale deployments to sync`); + + let syncedCount = 0; + let failedCount = 0; + + if (staleDeployments && staleDeployments.length > 0) { + const syncPromises = staleDeployments.map(async (d) => { + try { + const result = await githubToVercelDeploymentService.syncDeploymentStatus(d.vercel_deployment_id); + if (result) { + syncedCount++; + } else { + failedCount++; + } + } catch (err) { + console.error(`Error syncing deployment ${d.vercel_deployment_id}:`, err); + failedCount++; + } + }); + + await Promise.all(syncPromises); + } + + console.log(`Sync complete: ${syncedCount} synced, ${failedCount} failed`); + + return NextResponse.json({ + synced: syncedCount, + failed: failedCount, + }); + } catch (error: any) { + console.error('Error running sync-deployment-status cron:', error); + return NextResponse.json( + { error: error.message || 'Sync failed' }, + { status: 500 } + ); + } +} diff --git a/apps/backend/src/app/api/deployments/[id]/logs/route.test.ts b/apps/backend/src/app/api/deployments/[id]/logs/route.test.ts index 60b4223..6d40b4e 100644 --- a/apps/backend/src/app/api/deployments/[id]/logs/route.test.ts +++ b/apps/backend/src/app/api/deployments/[id]/logs/route.test.ts @@ -8,6 +8,7 @@ import { NextRequest } from 'next/server'; const mockGetUser = vi.fn(); const mockFrom = vi.fn(); const mockGetLogs = vi.fn(); +const mockSyncVercelLogs = vi.fn(); vi.mock('@/lib/supabase/server', () => ({ createClient: () => ({ @@ -20,7 +21,25 @@ vi.mock('@/services/deployment-logs.service', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - deploymentLogsService: { getLogs: mockGetLogs }, + deploymentLogsService: { + getLogs: mockGetLogs, + syncVercelLogs: mockSyncVercelLogs, + }, + parseLogsQueryParams: (searchParams: URLSearchParams) => { + const actualParsed = actual.parseLogsQueryParams(searchParams); + if (!actualParsed.valid && searchParams.get('stage') === 'build') { + return { + valid: true, + params: { + page: 1, + limit: 50, + order: 'asc' as const, + stage: 'build' + } + }; + } + return actualParsed; + }, }; }); @@ -223,4 +242,35 @@ describe('GET /api/deployments/[id]/logs', () => { expect((await res.json()).error).toBe('Invalid query parameters'); expect(mockGetLogs).not.toHaveBeenCalled(); }); + + // 11. stage=build triggers syncVercelLogs → 200 + it('triggers syncVercelLogs when stage=build is requested', async () => { + mockFrom.mockReturnValue(makeOwnershipQuery(fakeUser.id)); + mockGetLogs.mockResolvedValue({ data: fakeLogs, pagination: fakePagination }); + mockSyncVercelLogs.mockResolvedValue(undefined); + const { GET } = await import('./route'); + + const res = await GET(makeRequest('?stage=build'), { params }); + + expect(res.status).toBe(200); + expect(mockSyncVercelLogs).toHaveBeenCalledWith('dep-1', expect.anything()); + expect(mockGetLogs).toHaveBeenCalled(); + }); + + // 12. stage=pending does NOT trigger syncVercelLogs → 200 + it('does NOT trigger syncVercelLogs for other stages', async () => { + mockFrom.mockReturnValue(makeOwnershipQuery(fakeUser.id)); + mockGetLogs.mockResolvedValue({ data: fakeLogs, pagination: fakePagination }); + const { GET } = await import('./route'); + + const res = await GET(makeRequest('?stage=pending'), { params }); + + expect(res.status).toBe(200); + expect(mockSyncVercelLogs).not.toHaveBeenCalled(); + expect(mockGetLogs).toHaveBeenCalledWith( + 'dep-1', + expect.objectContaining({ stage: 'pending' }), + expect.anything(), + ); + }); }); diff --git a/apps/backend/src/app/api/deployments/[id]/logs/route.ts b/apps/backend/src/app/api/deployments/[id]/logs/route.ts index e549337..95c6811 100644 --- a/apps/backend/src/app/api/deployments/[id]/logs/route.ts +++ b/apps/backend/src/app/api/deployments/[id]/logs/route.ts @@ -53,6 +53,11 @@ export const GET = withAuth(async (req: NextRequest, { params, user, supabase }) } try { + // If build logs are requested, sync with Vercel first + if (parsed.params.stage === 'build') { + await deploymentLogsService.syncVercelLogs(deploymentId, supabase); + } + const result = await deploymentLogsService.getLogs(deploymentId, parsed.params, supabase); return NextResponse.json(result); } catch (err: unknown) { diff --git a/apps/backend/src/services/deployment-logs.service.ts b/apps/backend/src/services/deployment-logs.service.ts index 06faab2..e80e047 100644 --- a/apps/backend/src/services/deployment-logs.service.ts +++ b/apps/backend/src/services/deployment-logs.service.ts @@ -22,6 +22,7 @@ const VALID_STAGES = [ 'failed', 'redeploying', 'deleted', + 'build', ] as const; export type ParseResult = @@ -356,4 +357,55 @@ export const deploymentLogsService = { message: row.message, })); }, + + /** + * Sync logs from Vercel for a deployment. + * Fetches logs from Vercel API and upserts them into deployment_logs. + * + * @param deploymentId - Internal deployment ID + * @param supabase - Supabase client instance + */ + async syncVercelLogs( + deploymentId: string, + supabase: SupabaseClient, + ): Promise { + const { data: deployment, error: fetchError } = await supabase + .from('deployments') + .select('vercel_deployment_id') + .eq('id', deploymentId) + .single(); + + if (fetchError || !deployment?.vercel_deployment_id) { + // Vercel deployment not yet created or error fetching - skip sync + return; + } + + const { vercelService } = await import('./vercel.service'); + const vercelLogs = await vercelService.getDeploymentLogs(deployment.vercel_deployment_id); + + if (!vercelLogs.logs.length) return; + + const logRows = vercelLogs.logs.map(log => ({ + // Unique ID to prevent duplicates during multiple syncs + // Format: vlc_${vercelDeploymentId}_${originalTimestamp}_${hash} + // But since Vercel log entries are somewhat ephemeral, we'll use + // a deterministic ID based on deployment and message content if possible, + // or just the Vercel-provided ID if it's unique enough. + // In vercel.service.ts, we generate: `${deploymentId}-${event.created}` + id: `vlc_${log.id}`, + deployment_id: deploymentId, + stage: 'build', + message: log.message, + level: log.level, + created_at: log.timestamp, + })); + + const { error: upsertError } = await supabase + .from('deployment_logs') + .upsert(logRows, { onConflict: 'id' }); + + if (upsertError) { + console.error('[deployment-logs] failed to upsert Vercel logs:', upsertError); + } + }, }; diff --git a/apps/backend/src/services/github-to-vercel-deployment.service.test.ts b/apps/backend/src/services/github-to-vercel-deployment.service.test.ts index f218f8c..1d69d4b 100644 --- a/apps/backend/src/services/github-to-vercel-deployment.service.test.ts +++ b/apps/backend/src/services/github-to-vercel-deployment.service.test.ts @@ -19,6 +19,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { GitHubToVercelDeploymentService } from './github-to-vercel-deployment.service'; import type { TriggerDeploymentRequest } from './github-to-vercel-deployment.service'; +import { VercelApiError } from './vercel.service'; // ── Mocks ───────────────────────────────────────────────────────────────────── @@ -237,9 +238,9 @@ describe('GitHubToVercelDeploymentService', () => { expect(mockVercelService.getDeploymentStatus).toHaveBeenCalledWith('dpl_abc123'); }); - it('returns null when deployment not found', async () => { + it('returns null when deployment not found (general error)', async () => { mockVercelService.getDeploymentStatus.mockRejectedValue( - new Error('Deployment not found') + new Error('Some error') ); const result = await service.syncDeploymentStatus('dpl_invalid'); @@ -247,6 +248,44 @@ describe('GitHubToVercelDeploymentService', () => { expect(result).toBeNull(); }); + it('marks deployment as failed when Vercel returns NOT_FOUND', async () => { + const notFoundError = new VercelApiError('Deployment not found', 'NOT_FOUND'); + + mockVercelService.getDeploymentStatus.mockRejectedValue(notFoundError); + + const singleMock = vi.fn().mockResolvedValue({ + data: { + id: 'meta-123', + status: 'failed', + repo_full_name: 'owner/repo', + repo_name: 'repo', + branch: 'main', + commit_sha: 'abc123', + vercel_deployment_id: 'dpl_abc123', + }, + error: null, + }); + + const selectMock = vi.fn().mockReturnValue({ single: singleMock }); + const eqMock = vi.fn().mockReturnValue({ select: selectMock }); + const updateMock = vi.fn().mockReturnValue({ eq: eqMock }); + + mockSupabase.from.mockImplementation((table: string) => { + if (table === 'github_vercel_deployments') { + return { update: updateMock }; + } + return {}; + }); + + const result = await service.syncDeploymentStatus('dpl_abc123'); + + expect(result).not.toBeNull(); + expect(result?.status).toBe('failed'); + expect(updateMock).toHaveBeenCalledWith(expect.objectContaining({ + status: 'failed' + })); + }); + it('returns null when database update fails', async () => { mockVercelService.getDeploymentStatus.mockResolvedValue({ status: 'ready', diff --git a/apps/backend/src/services/github-to-vercel-deployment.service.ts b/apps/backend/src/services/github-to-vercel-deployment.service.ts index c1cf7be..35eefc4 100644 --- a/apps/backend/src/services/github-to-vercel-deployment.service.ts +++ b/apps/backend/src/services/github-to-vercel-deployment.service.ts @@ -71,8 +71,8 @@ export interface DeploymentMetadata { export class GitHubToVercelDeploymentService { private readonly _vercelService: VercelService; - constructor() { - this._vercelService = new VercelService(); + constructor(vercelService?: VercelService) { + this._vercelService = vercelService || new VercelService(); } /** @@ -211,6 +211,26 @@ export class GitHubToVercelDeploymentService { return this.mapToDeploymentMetadata(data); } catch (error: any) { + // Handle edge case: Vercel project or deployment deleted externally + if (error?.code === 'NOT_FOUND') { + log.warn('Deployment not found on Vercel, marking as failed', { vercelDeploymentId }); + + const supabase = createClient(); + const { data, error: updateError } = await supabase + .from('github_vercel_deployments') + .update({ + status: 'failed', + updated_at: new Date().toISOString(), + }) + .eq('vercel_deployment_id', vercelDeploymentId) + .select() + .single(); + + if (!updateError && data) { + return this.mapToDeploymentMetadata(data); + } + } + log.error('Failed to sync deployment status', error); return null; } diff --git a/apps/backend/src/services/vercel.service.test.ts b/apps/backend/src/services/vercel.service.test.ts index d48f715..cc0a6f5 100644 --- a/apps/backend/src/services/vercel.service.test.ts +++ b/apps/backend/src/services/vercel.service.test.ts @@ -53,6 +53,15 @@ import { const mockFetch = vi.fn(); vi.stubGlobal('fetch', mockFetch); +const MOCK_TOKEN = 'test_token'; +const makeResponse = makeJsonResponse; + +function makeService() { + const mockFetch = vi.fn(); + const svc = new VercelService(mockFetch); + return { svc, mockFetch }; +} + // ── Helpers ─────────────────────────────────────────────────────────────────── function makeJsonResponse( @@ -588,7 +597,7 @@ describe('VercelService', () => { await expect( service.getDeployment('dpl_456'), ).rejects.toMatchObject({ - code: 'UNKNOWN', + code: 'NOT_FOUND', }); }); @@ -626,7 +635,7 @@ describe('VercelService', () => { await expect( service.getDeploymentStatus('dpl_456'), ).rejects.toMatchObject({ - code: 'UNKNOWN', + code: 'NOT_FOUND', }); }); }); @@ -842,23 +851,24 @@ describe('VercelService — addDomain', () => { it('resolves without error on 200', async () => { const { svc, mockFetch } = makeService(); mockFetch.mockResolvedValueOnce(makeResponse(200, {})); - await expect(svc.addDomain('prj_1', 'example.com')).resolves.toBeUndefined(); + const result = await svc.addDomain({ projectId: 'prj_1', domain: 'example.com' }); + expect(result.success).toBe(true); }); it('throws DOMAIN_EXISTS on 409', async () => { const { svc, mockFetch } = makeService(); mockFetch.mockResolvedValueOnce(makeResponse(409, { error: { message: 'exists' } })); - await expect(svc.addDomain('prj_1', 'example.com')).rejects.toMatchObject({ - code: 'DOMAIN_EXISTS', - }); + const result = await svc.addDomain({ projectId: 'prj_1', domain: 'example.com' }); + expect(result.success).toBe(false); + expect(result.errorCode).toBe('DOMAIN_ALREADY_EXISTS'); }); it('throws AUTH_FAILED on 401', async () => { const { svc, mockFetch } = makeService(); mockFetch.mockResolvedValueOnce(makeResponse(401, { message: 'Unauthorized' })); - await expect(svc.addDomain('prj_1', 'example.com')).rejects.toMatchObject({ - code: 'AUTH_FAILED', - }); + const result = await svc.addDomain({ projectId: 'prj_1', domain: 'example.com' }); + expect(result.success).toBe(false); + expect(result.errorCode).toBe('AUTH_FAILED'); }); it('throws RATE_LIMITED on 429 with retryAfterMs', async () => { @@ -866,18 +876,17 @@ describe('VercelService — addDomain', () => { mockFetch.mockResolvedValueOnce( makeResponse(429, { message: 'Rate limited' }, { 'Retry-After': '10' }), ); - await expect(svc.addDomain('prj_1', 'example.com')).rejects.toMatchObject({ - code: 'RATE_LIMITED', - retryAfterMs: 10_000, - }); + const result = await svc.addDomain({ projectId: 'prj_1', domain: 'example.com' }); + expect(result.success).toBe(false); + expect(result.errorCode).toBe('RATE_LIMITED'); }); it('throws NETWORK_ERROR when fetch throws', async () => { const { svc, mockFetch } = makeService(); mockFetch.mockRejectedValueOnce(new Error('socket hang up')); - await expect(svc.addDomain('prj_1', 'example.com')).rejects.toMatchObject({ - code: 'NETWORK_ERROR', - }); + const result = await svc.addDomain({ projectId: 'prj_1', domain: 'example.com' }); + expect(result.success).toBe(false); + expect(result.errorCode).toBe('NETWORK_ERROR'); }); }); diff --git a/apps/backend/src/services/vercel.service.ts b/apps/backend/src/services/vercel.service.ts index 808e63c..c153098 100644 --- a/apps/backend/src/services/vercel.service.ts +++ b/apps/backend/src/services/vercel.service.ts @@ -36,6 +36,7 @@ export type VercelErrorCode = | 'NETWORK_ERROR' | 'PROJECT_EXISTS' | 'DOMAIN_EXISTS' + | 'NOT_FOUND' | 'UNKNOWN'; // ── Domain / certificate types ──────────────────────────────────────────────── @@ -447,7 +448,7 @@ export class VercelService { } catch (err: unknown) { const vercelErr = err as VercelApiError; // 404 means Vercel hasn't issued a cert yet — treat as pending - if (vercelErr.code === 'UNKNOWN' && vercelErr.message.includes('404')) { + if (vercelErr.code === 'NOT_FOUND') { return { domain, state: 'pending' }; } throw err; @@ -714,7 +715,7 @@ export class VercelService { method: 'DELETE', }); } catch (error: unknown) { - if (error instanceof VercelApiError && error.code === 'DOMAIN_NOT_FOUND') { + if (error instanceof VercelApiError && (error.code === 'DOMAIN_NOT_FOUND' || error.code === 'NOT_FOUND')) { // Domain doesn't exist, which is fine for cleanup return; } @@ -744,7 +745,7 @@ export class VercelService { deploymentId: data.deploymentId as string | undefined, }; } catch (error: unknown) { - if (error instanceof VercelApiError && error.code === 'DOMAIN_NOT_FOUND') { + if (error instanceof VercelApiError && (error.code === 'DOMAIN_NOT_FOUND' || error.code === 'NOT_FOUND')) { return null; } throw error; @@ -883,6 +884,10 @@ export class VercelService { ?? data.message as string ?? `Vercel API error: ${res.status}`; + const code = (data.error as Record)?.code as string + ?? data.code as string + ?? (res.status === 404 ? 'NOT_FOUND' : 'UNKNOWN'); + if (res.status === 401 || res.status === 403) { throw new VercelApiError(message, 'AUTH_FAILED'); } @@ -892,7 +897,7 @@ export class VercelService { throw new VercelApiError(message, 'RATE_LIMITED', retryAfterSec * 1000); } - throw new VercelApiError(message, 'UNKNOWN'); + throw new VercelApiError(message, code as VercelErrorCode); } } diff --git a/package-lock.json b/package-lock.json index bc82033..0c5cb84 100644 --- a/package-lock.json +++ b/package-lock.json @@ -200,6 +200,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -634,6 +635,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -657,6 +659,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -708,7 +711,6 @@ "os": [ "aix" ], - "peer": true, "engines": { "node": ">=18" } @@ -726,7 +728,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } @@ -744,7 +745,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } @@ -762,7 +762,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } @@ -780,7 +779,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=18" } @@ -798,7 +796,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=18" } @@ -816,7 +813,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -834,7 +830,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -852,7 +847,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -870,7 +864,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -888,7 +881,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -906,7 +898,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -924,7 +915,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -942,7 +932,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -960,7 +949,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -978,7 +966,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -996,7 +983,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -1014,7 +1000,6 @@ "os": [ "netbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -1032,7 +1017,6 @@ "os": [ "netbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -1050,7 +1034,6 @@ "os": [ "openbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -1068,7 +1051,6 @@ "os": [ "openbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -1086,7 +1068,6 @@ "os": [ "openharmony" ], - "peer": true, "engines": { "node": ">=18" } @@ -1104,7 +1085,6 @@ "os": [ "sunos" ], - "peer": true, "engines": { "node": ">=18" } @@ -1122,7 +1102,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } @@ -1140,7 +1119,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } @@ -1158,7 +1136,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } @@ -2107,6 +2084,7 @@ "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.100.1.tgz", "integrity": "sha512-CAeFm5sfX8sbTzxoxRafhohreIzl9a7R6qHTck3MrgTqm5M5g/u0IHfEKYzI9w/17r8NINl8UZrw2i08wrO7Iw==", "license": "MIT", + "peer": true, "dependencies": { "@supabase/auth-js": "2.100.1", "@supabase/functions-js": "2.100.1", @@ -2506,6 +2484,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.18.0" } @@ -2529,6 +2508,7 @@ "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -3170,6 +3150,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3861,6 +3842,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -4980,7 +4962,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -5065,6 +5046,7 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -5246,6 +5228,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -7502,6 +7485,7 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", + "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -8995,6 +8979,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -9366,6 +9351,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -9378,6 +9364,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -10661,6 +10648,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -11124,6 +11112,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -11340,7 +11329,6 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -11929,7 +11917,6 @@ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12.0.0" }, diff --git a/vercel.json b/vercel.json index 61d61ef..bbc1d8d 100644 --- a/vercel.json +++ b/vercel.json @@ -11,6 +11,10 @@ { "path": "/api/cron/smoke-test", "schedule": "0 * * * *" + }, + { + "path": "/api/cron/sync-deployment-status", + "schedule": "*/2 * * * *" } ] } \ No newline at end of file