diff --git a/migrations/20250205180205.sql b/migrations/20250205180205.sql deleted file mode 100644 index 3b3a95061..000000000 --- a/migrations/20250205180205.sql +++ /dev/null @@ -1,109 +0,0 @@ -/* -This migration adds team slugs and profile pictures to support user-friendly URLs and team branding. - -It performs the following steps: - -1. Adds two new columns to the teams table: - - slug: A URL-friendly version of the team name (e.g. "acme-inc") - - profile_picture_url: URL to the team's profile picture - -2. Creates a slug generation function that: - - Takes a team name and converts it to a URL-friendly format - - Removes special characters, accents, and spaces - - Handles email addresses by only using the part before @ - - Converts to lowercase and replaces spaces with hyphens - -3. Installs the unaccent PostgreSQL extension for proper accent handling - -4. Generates initial slugs for all existing teams: - - Uses the team name as base for the slug - - If multiple teams would have the same slug, appends part of the team ID - to ensure uniqueness - -5. Sets up automatic slug generation for new teams: - - Creates a trigger that runs before team insertion - - Generates a unique slug using random suffixes if needed - - Only generates a slug if one isn't explicitly provided - -6. Enforces slug uniqueness with a database constraint -*/ - -ALTER TABLE teams -ADD COLUMN slug TEXT, -ADD COLUMN profile_picture_url TEXT; - -CREATE OR REPLACE FUNCTION generate_team_slug(name TEXT) -RETURNS TEXT AS $$ -DECLARE - base_name TEXT; -BEGIN - base_name := SPLIT_PART(name, '@', 1); - - RETURN LOWER( - REGEXP_REPLACE( - REGEXP_REPLACE( - UNACCENT(TRIM(base_name)), - '[^a-zA-Z0-9\s-]', - '', - 'g' - ), - '\s+', - '-', - 'g' - ) - ); -END; -$$ LANGUAGE plpgsql; - -CREATE EXTENSION IF NOT EXISTS unaccent; - -WITH numbered_teams AS ( - SELECT - id, - name, - generate_team_slug(name) as base_slug, - ROW_NUMBER() OVER (PARTITION BY generate_team_slug(name) ORDER BY created_at) as slug_count - FROM teams - WHERE slug IS NULL -) -UPDATE teams -SET slug = - CASE - WHEN t.slug_count = 1 THEN t.base_slug - ELSE t.base_slug || '-' || SUBSTRING(teams.id::text, 1, 4) - END -FROM numbered_teams t -WHERE teams.id = t.id; - -CREATE OR REPLACE FUNCTION generate_team_slug_trigger() -RETURNS TRIGGER AS $$ -DECLARE - base_slug TEXT; - test_slug TEXT; - suffix TEXT; -BEGIN - IF NEW.slug IS NULL THEN - base_slug := generate_team_slug(NEW.name); - test_slug := base_slug; - - WHILE EXISTS (SELECT 1 FROM teams WHERE slug = test_slug) LOOP - suffix := SUBSTRING(gen_random_uuid()::text, 1, 4); - test_slug := base_slug || '-' || suffix; - END LOOP; - - NEW.slug := test_slug; - END IF; - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -CREATE TRIGGER team_slug_trigger -BEFORE INSERT ON teams -FOR EACH ROW -EXECUTE FUNCTION generate_team_slug_trigger(); - -ALTER TABLE teams -ADD CONSTRAINT teams_slug_unique UNIQUE (slug); - -ALTER TABLE teams -ALTER COLUMN slug SET NOT NULL; \ No newline at end of file diff --git a/migrations/20250311144556.sql b/migrations/20250311144556.sql deleted file mode 100644 index 16c4649f3..000000000 --- a/migrations/20250311144556.sql +++ /dev/null @@ -1,10 +0,0 @@ -CREATE OR REPLACE VIEW public.auth_users AS -SELECT - id, - email -FROM auth.users; - --- Revoke all permissions to ensure no public access -REVOKE ALL ON public.auth_users FROM PUBLIC; -REVOKE ALL ON public.auth_users FROM anon; -REVOKE ALL ON public.auth_users FROM authenticated; \ No newline at end of file diff --git a/migrations/20250314133234.sql b/migrations/20250314133234.sql deleted file mode 100644 index 0ce0801e8..000000000 --- a/migrations/20250314133234.sql +++ /dev/null @@ -1,14 +0,0 @@ --- Timestamp: 20250314133234 - --- Create env_defaults table with RLS enabled -CREATE TABLE "public"."env_defaults" ( - env_id TEXT PRIMARY KEY REFERENCES "public"."envs"(id), - description TEXT -); - --- Enable Row Level Security -ALTER TABLE "public"."env_defaults" ENABLE ROW LEVEL SECURITY; - --- Create an index on the foreign key for better performance -CREATE INDEX "env_defaults_env_id_idx" ON "public"."env_defaults"("env_id"); - diff --git a/migrations/20260212120822.sql b/migrations/20260212120822.sql deleted file mode 100644 index 7962bcd09..000000000 --- a/migrations/20260212120822.sql +++ /dev/null @@ -1,206 +0,0 @@ --- Timestamp: 20260212120822 - -BEGIN; - -CREATE OR REPLACE FUNCTION public.list_team_builds_rpc( - p_team_id uuid, - p_statuses text[] DEFAULT ARRAY['waiting', 'building', 'uploaded', 'failed']::text[], - p_limit integer DEFAULT 50, - p_cursor_created_at timestamptz DEFAULT NULL, - p_cursor_id uuid DEFAULT NULL, - p_build_id_or_template text DEFAULT NULL -) -RETURNS TABLE ( - id uuid, - status text, - reason jsonb, - created_at timestamptz, - finished_at timestamptz, - template_id text, - template_alias text -) -LANGUAGE sql -STABLE -SECURITY INVOKER -SET search_path = pg_catalog, public -AS $function$ -WITH params AS ( - SELECT - p_team_id AS team_id, - CASE - WHEN p_statuses IS NULL OR CARDINALITY(p_statuses) = 0 - THEN ARRAY['waiting', 'building', 'uploaded', 'failed']::text[] - ELSE p_statuses - END AS statuses, - GREATEST(1, LEAST(COALESCE(p_limit, 50), 100)) AS requested_limit, - p_cursor_created_at AS cursor_created_at, - p_cursor_id AS cursor_id, - NULLIF(BTRIM(p_build_id_or_template), '') AS search_term -), -resolved AS ( - SELECT - p.*, - CASE - WHEN p.search_term ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' - THEN p.search_term::uuid - ELSE NULL - END AS candidate_build_id, - ( - SELECT e.id - FROM public.envs e - WHERE e.team_id = p.team_id - AND e.id = p.search_term - LIMIT 1 - ) AS resolved_template_id_by_id - FROM params p -), -resolved_with_alias AS ( - SELECT - r.*, - COALESCE( - r.resolved_template_id_by_id, - ( - SELECT ea.env_id - FROM public.env_aliases ea - JOIN public.envs e ON e.id = ea.env_id - WHERE e.team_id = r.team_id - AND ea.alias = r.search_term - ORDER BY ea.id ASC - LIMIT 1 - ) - ) AS resolved_template_id - FROM resolved r -), -page_ids AS ( - SELECT DISTINCT ON (b.created_at, b.id) - b.id, - b.created_at, - a.env_id - FROM resolved_with_alias f - JOIN public.envs e - ON e.team_id = f.team_id - JOIN public.env_build_assignments a - ON a.env_id = e.id - JOIN public.env_builds b - ON b.id = a.build_id - WHERE b.status = ANY (f.statuses) - AND ( - f.cursor_created_at IS NULL - OR (f.cursor_id IS NULL AND b.created_at < f.cursor_created_at) - OR ( - f.cursor_id IS NOT NULL - AND (b.created_at, b.id) < (f.cursor_created_at, f.cursor_id) - ) - ) - AND ( - f.search_term IS NULL - OR ( - f.resolved_template_id IS NOT NULL - AND f.candidate_build_id IS NOT NULL - AND (a.env_id = f.resolved_template_id OR b.id = f.candidate_build_id) - ) - OR ( - f.resolved_template_id IS NOT NULL - AND f.candidate_build_id IS NULL - AND a.env_id = f.resolved_template_id - ) - OR ( - f.resolved_template_id IS NULL - AND f.candidate_build_id IS NOT NULL - AND b.id = f.candidate_build_id - ) - ) - ORDER BY - b.created_at DESC, - b.id DESC, - a.created_at DESC NULLS LAST, - a.id DESC - LIMIT (SELECT requested_limit + 1 FROM params) -), -page_data AS ( - SELECT - p.id, - b.status, - b.reason::jsonb AS reason, - p.created_at, - b.finished_at, - p.env_id AS template_id - FROM page_ids p - JOIN public.env_builds b - ON b.id = p.id -) -SELECT - d.id, - d.status, - d.reason, - d.created_at, - CASE - WHEN d.finished_at IS NULL THEN NULL::timestamptz - ELSE d.finished_at - END AS finished_at, - d.template_id, - CASE - WHEN ea.alias IS NULL THEN NULL::text - ELSE ea.alias - END AS template_alias -FROM page_data d -LEFT JOIN LATERAL ( - SELECT x.alias - FROM public.env_aliases x - WHERE x.env_id = d.template_id - ORDER BY x.id ASC - LIMIT 1 -) ea ON TRUE -ORDER BY d.created_at DESC, d.id DESC; -$function$; - -REVOKE ALL ON FUNCTION public.list_team_builds_rpc(uuid, text[], integer, timestamptz, uuid, text) FROM PUBLIC; -GRANT EXECUTE ON FUNCTION public.list_team_builds_rpc(uuid, text[], integer, timestamptz, uuid, text) TO service_role; - -CREATE OR REPLACE FUNCTION public.list_team_running_build_statuses_rpc( - p_team_id uuid, - p_build_ids uuid[] -) -RETURNS TABLE ( - id uuid, - status text, - reason jsonb, - finished_at timestamptz -) -LANGUAGE sql -STABLE -SECURITY INVOKER -SET search_path = pg_catalog, public -AS $function$ -WITH requested_builds AS ( - SELECT DISTINCT requested.build_id - FROM UNNEST(COALESCE(p_build_ids, ARRAY[]::uuid[])) AS requested(build_id) -), -authorized_builds AS ( - SELECT DISTINCT ON (a.build_id) - a.build_id - FROM requested_builds r - JOIN public.env_build_assignments a - ON a.build_id = r.build_id - JOIN public.envs e - ON e.id = a.env_id - WHERE e.team_id = p_team_id - ORDER BY - a.build_id ASC, - a.created_at DESC NULLS LAST, - a.id DESC -) -SELECT - b.id, - b.status, - b.reason::jsonb AS reason, - b.finished_at -FROM authorized_builds ab -JOIN public.env_builds b - ON b.id = ab.build_id; -$function$; - -REVOKE ALL ON FUNCTION public.list_team_running_build_statuses_rpc(uuid, uuid[]) FROM PUBLIC; -GRANT EXECUTE ON FUNCTION public.list_team_running_build_statuses_rpc(uuid, uuid[]) TO service_role; - -COMMIT; diff --git a/migrations/20260217145145.sql b/migrations/20260217145145.sql deleted file mode 100644 index 8f87f0c9d..000000000 --- a/migrations/20260217145145.sql +++ /dev/null @@ -1,107 +0,0 @@ --- Timestamp: 20260217145145 - -BEGIN; - -CREATE OR REPLACE FUNCTION public.list_team_builds_rpc( - p_team_id uuid, - p_statuses text[] DEFAULT ARRAY['waiting', 'building', 'uploaded', 'failed']::text[], - p_limit integer DEFAULT 50, - p_cursor_created_at timestamptz DEFAULT NULL, - p_cursor_id uuid DEFAULT NULL, - p_build_id_or_template text DEFAULT NULL -) -RETURNS TABLE ( - id uuid, - status text, - reason jsonb, - created_at timestamptz, - finished_at timestamptz, - template_id text, - template_alias text -) -LANGUAGE sql -STABLE -SECURITY INVOKER -SET search_path = pg_catalog, public -AS $function$ -WITH params AS ( - SELECT - p_team_id AS team_id, - CASE - WHEN p_statuses IS NULL OR CARDINALITY(p_statuses) = 0 - THEN ARRAY['waiting', 'building', 'uploaded', 'failed']::text[] - ELSE p_statuses - END AS statuses, - GREATEST(1, LEAST(COALESCE(p_limit, 50), 100)) AS requested_limit, - COALESCE(p_cursor_created_at, 'infinity'::timestamptz) AS cursor_created_at, - CASE - WHEN p_cursor_created_at IS NULL - THEN 'ffffffff-ffff-ffff-ffff-ffffffffffff'::uuid - WHEN p_cursor_id IS NULL - THEN '00000000-0000-0000-0000-000000000000'::uuid - ELSE p_cursor_id - END AS cursor_id, - NULLIF(BTRIM(p_build_id_or_template), '') AS search_term -), -resolved AS ( - SELECT - p.*, - CASE - WHEN p.search_term ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' - THEN p.search_term::uuid - ELSE NULL - END AS candidate_build_id, - COALESCE( - ( - SELECT e.id - FROM public.envs e - WHERE e.team_id = p.team_id - AND e.id = p.search_term - LIMIT 1 - ), - ( - SELECT ea.env_id - FROM public.env_aliases ea - JOIN public.envs e - ON e.id = ea.env_id - WHERE e.team_id = p.team_id - AND ea.alias = p.search_term - ORDER BY ea.alias ASC - LIMIT 1 - ) - ) AS resolved_template_id - FROM params p -) -SELECT - b.id, - b.status, - b.reason::jsonb AS reason, - b.created_at, - b.finished_at, - b.env_id AS template_id, - ea.alias AS template_alias -FROM public.env_builds b -JOIN resolved r - ON r.team_id = b.team_id -LEFT JOIN LATERAL ( - SELECT x.alias - FROM public.env_aliases x - WHERE x.env_id = b.env_id - ORDER BY x.alias ASC - LIMIT 1 -) ea ON TRUE -WHERE (b.created_at, b.id) < (r.cursor_created_at, r.cursor_id) - AND ( - r.search_term IS NULL - OR (r.candidate_build_id IS NOT NULL AND b.id = r.candidate_build_id) - OR (r.resolved_template_id IS NOT NULL AND b.env_id = r.resolved_template_id) - ) - AND b.status = ANY (r.statuses) -ORDER BY b.created_at DESC, b.id DESC -LIMIT (SELECT requested_limit + 1 FROM params) -$function$; - -REVOKE ALL ON FUNCTION public.list_team_builds_rpc(uuid, text[], integer, timestamptz, uuid, text) FROM PUBLIC; -GRANT EXECUTE ON FUNCTION public.list_team_builds_rpc(uuid, text[], integer, timestamptz, uuid, text) TO service_role; - -COMMIT; diff --git a/package.json b/package.json index c47670b10..e24fa2a43 100644 --- a/package.json +++ b/package.json @@ -20,10 +20,10 @@ "db:migrations:create": "bun run scripts:create-migration", "db:migrations:apply": "bun run scripts:apply-migrations", "<<<<<<< Gen": "", - "generate:infra": "bunx openapi-typescript ./spec/openapi.infra.yaml -o ./src/types/infra-api.types.ts", - "generate:dashboard-api": "bunx openapi-typescript ./spec/openapi.dashboard-api.yaml -o ./src/types/dashboard-api.types.ts", - "generate:argus": "bunx openapi-typescript ./spec/openapi.argus.yaml -o ./src/types/argus-api.types.ts", - "generate:supabase": "bunx supabase@latest gen types typescript --schema public > src/types/database.types.ts --project-id $SUPABASE_PROJECT_ID", + "generate:infra": "bunx openapi-typescript ./spec/openapi.infra.yaml -o ./src/core/shared/contracts/infra-api.types.ts", + "generate:dashboard-api": "bunx openapi-typescript ./spec/openapi.dashboard-api.yaml -o ./src/core/shared/contracts/dashboard-api.types.ts", + "generate:argus": "bunx openapi-typescript ./spec/openapi.argus.yaml -o ./src/core/shared/contracts/argus-api.types.ts", + "generate:supabase": "bunx supabase@latest gen types typescript --schema public > src/core/shared/contracts/database.types.ts --project-id $SUPABASE_PROJECT_ID", "<<<<<<< Scripts": "", "scripts:check-app-env": "bun scripts/check-app-env.ts", "scripts:check-e2e-env": "bun scripts/check-e2e-env.ts", diff --git a/src/__test__/integration/auth.test.ts b/src/__test__/integration/auth.test.ts index 8b9070347..429963f98 100644 --- a/src/__test__/integration/auth.test.ts +++ b/src/__test__/integration/auth.test.ts @@ -1,14 +1,14 @@ import { redirect } from 'next/navigation' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { AUTH_URLS, PROTECTED_URLS } from '@/configs/urls' -import { encodedRedirect } from '@/lib/utils/auth' import { forgotPasswordAction, signInAction, signInWithOAuthAction, signOutAction, signUpAction, -} from '@/server/auth/auth-actions' +} from '@/core/server/actions/auth-actions' +import { encodedRedirect } from '@/lib/utils/auth' // Create hoisted mock functions that can be used throughout the file const { validateEmail, shouldWarnAboutAlternateEmail } = vi.hoisted(() => ({ @@ -44,11 +44,11 @@ const mockSupabaseClient = { } // Mock dependencies -vi.mock('@/lib/clients/supabase/server', () => ({ +vi.mock('@/core/shared/clients/supabase/server', () => ({ createClient: vi.fn(() => mockSupabaseClient), })) -vi.mock('@/lib/clients/supabase/admin', () => ({ +vi.mock('@/core/shared/clients/supabase/admin', () => ({ supabaseAdmin: { auth: vi.fn(), }, @@ -77,7 +77,7 @@ vi.mock('@/lib/utils/auth', () => ({ })) // Use the hoisted mock functions in the module mock -vi.mock('@/server/auth/validate-email', () => ({ +vi.mock('@/core/server/functions/auth/validate-email', () => ({ validateEmail, shouldWarnAboutAlternateEmail, })) @@ -266,10 +266,9 @@ describe('Auth Actions - Integration Tests', () => { // Missing password and confirmPassword // Execute: Call the sign-up action - // @ts-expect-error - we want to test the validation errors const result = await signUpAction({ email: 'newuser@example.com', - }) + } as never) // Verify: Check that the result contains validation errors expect(result).toBeDefined() diff --git a/src/__test__/integration/dashboard-route.test.ts b/src/__test__/integration/dashboard-route.test.ts index 0dee60fce..97ee5c4a4 100644 --- a/src/__test__/integration/dashboard-route.test.ts +++ b/src/__test__/integration/dashboard-route.test.ts @@ -17,7 +17,7 @@ import { PROTECTED_URLS } from '@/configs/urls' * 3. Tab parameter routing - all valid tabs and fallbacks * 4. Error handling - auth errors and missing teams * 5. Cookie setting - team persistence - * 6. URL generation - correct paths for team slug/ID + * 6. URL generation - correct slug-backed paths */ // create hoisted mocks @@ -29,6 +29,7 @@ const { } = vi.hoisted(() => ({ mockSupabaseClient: { auth: { + getSession: vi.fn(), getUser: vi.fn(), signOut: vi.fn(), }, @@ -43,11 +44,11 @@ const { mockResolveUserTeam: vi.fn(), })) -vi.mock('@/lib/clients/supabase/server', () => ({ +vi.mock('@/core/shared/clients/supabase/server', () => ({ createClient: vi.fn(() => mockSupabaseClient), })) -vi.mock('@/lib/clients/supabase/admin', () => ({ +vi.mock('@/core/shared/clients/supabase/admin', () => ({ supabaseAdmin: mockSupabaseAdmin, })) @@ -63,7 +64,7 @@ vi.mock('@/lib/utils/auth', () => ({ }), })) -vi.mock('@/server/team/resolve-user-team', () => ({ +vi.mock('@/core/server/functions/team/resolve-user-team', () => ({ resolveUserTeam: mockResolveUserTeam, })) @@ -78,6 +79,9 @@ import { GET, TAB_URL_MAP } from '@/app/dashboard/route' describe('Dashboard Route - Team Resolution Integration Tests', () => { beforeEach(() => { vi.clearAllMocks() + mockSupabaseClient.auth.getSession.mockResolvedValue({ + data: { session: { access_token: 'session-token' } }, + }) }) afterEach(() => { @@ -117,8 +121,8 @@ describe('Dashboard Route - Team Resolution Integration Tests', () => { // execute const response = await GET(request) - // verify: resolveUserTeam was called with user ID - expect(mockResolveUserTeam).toHaveBeenCalledWith('user-123') + // verify: resolveUserTeam was called with session access token + expect(mockResolveUserTeam).toHaveBeenCalledWith('session-token') // verify: redirects to sandboxes page expect(response.status).toBe(307) // temporary redirect @@ -251,18 +255,11 @@ describe('Dashboard Route - Team Resolution Integration Tests', () => { expect(response.headers.get('location')).toContain(expectedPath) }) - it('should use team ID as fallback when slug is empty', async () => { - const testTeamId = 'team-id-only' - - mockResolveUserTeam.mockResolvedValue({ - id: testTeamId, - slug: '', - }) - - const request = createRequest({ tab: 'billing' }) + it('should default to sandboxes when tab matches an inherited object key', async () => { + const request = createRequest({ tab: 'constructor' }) const response = await GET(request) - const expectedPath = TAB_URL_MAP['billing']!(testTeamId) + const expectedPath = PROTECTED_URLS.SANDBOXES(testTeamSlug) expect(response.headers.get('location')).toContain(expectedPath) }) }) diff --git a/src/__test__/integration/inspect-sandbox.test.ts b/src/__test__/integration/inspect-sandbox.test.ts index c45c9981e..316df4547 100644 --- a/src/__test__/integration/inspect-sandbox.test.ts +++ b/src/__test__/integration/inspect-sandbox.test.ts @@ -9,42 +9,55 @@ import { AUTH_URLS, PROTECTED_URLS } from '@/configs/urls' // MOCKS SETUP // ============================================================================ -const mockSupabaseClient = { - auth: { - getUser: vi.fn(), - getSession: vi.fn(), +const { + mockSupabaseClient, + mockListUserTeams, + mockCreateUserTeamsRepository, + mockSetTeamCookies, +} = vi.hoisted(() => ({ + mockSupabaseClient: { + auth: { + getUser: vi.fn(), + getSession: vi.fn(), + }, }, -} + mockListUserTeams: vi.fn(), + mockCreateUserTeamsRepository: vi.fn(), + mockSetTeamCookies: vi.fn(), +})) -vi.mock('@/lib/clients/supabase/server', () => ({ +vi.mock('@/core/shared/clients/supabase/server', () => ({ createClient: vi.fn(() => Promise.resolve(mockSupabaseClient)), })) -vi.mock('@/lib/clients/supabase/admin', () => ({ - supabaseAdmin: { - from: vi.fn(), - }, +vi.mock('@/core/modules/teams/user-teams-repository.server', () => ({ + createUserTeamsRepository: mockCreateUserTeamsRepository, })) -vi.mock('@/lib/clients/kv', () => ({ +vi.mock('@/core/shared/clients/kv', () => ({ kv: { get: vi.fn(), set: vi.fn(), }, })) -vi.mock('@/lib/clients/api', () => ({ +vi.mock('@/core/shared/clients/api', () => ({ infra: { GET: vi.fn(), }, })) -vi.mock('@/lib/clients/logger/logger', () => ({ +vi.mock('@/core/shared/clients/logger/logger', () => ({ l: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), }, + serializeErrorForLog: vi.fn((error) => error), +})) + +vi.mock('@/lib/utils/cookies', () => ({ + setTeamCookies: mockSetTeamCookies, })) vi.mock('next/headers', () => ({ @@ -82,8 +95,8 @@ vi.mock('next/server', async () => { }) // Import mocked modules after mock setup -import { infra } from '@/lib/clients/api' -import { supabaseAdmin } from '@/lib/clients/supabase/admin' +import { infra } from '@/core/shared/clients/api' +import { setTeamCookies } from '@/lib/utils/cookies' // ============================================================================ // TEST HELPERS @@ -117,24 +130,29 @@ function setupAuthenticatedUser( } function setupUserTeams(teams: Array<{ id: string; slug: string | null }>) { - const mockTeamsData = teams.map((team) => ({ - teams: team, - is_default: false, - })) - - vi.mocked(supabaseAdmin.from).mockImplementation( - () => - ({ - select: vi.fn(() => ({ - eq: vi.fn(() => - Promise.resolve({ - data: mockTeamsData, - error: null, - }) - ), - })), - }) as any - ) + mockListUserTeams.mockResolvedValue({ + ok: true, + data: teams.map((team) => ({ + id: team.id, + slug: team.slug ?? '', + name: `Team ${team.id}`, + tier: 'pro', + email: `${team.id}@example.com`, + profilePictureUrl: null, + isBlocked: false, + isBanned: false, + blockedReason: null, + isDefault: false, + limits: { + maxLengthHours: 1, + concurrentSandboxes: 1, + concurrentTemplateBuilds: 1, + maxVcpu: 1, + maxRamMb: 512, + diskMb: 1024, + }, + })), + }) } function setupSandboxResponse( @@ -142,45 +160,53 @@ function setupSandboxResponse( sandboxId: string, found: boolean ) { - vi.mocked(infra.GET).mockImplementation((path: string, options: any) => { - if ( - path === '/sandboxes/{sandboxID}' && - options.params.path.sandboxID === sandboxId && - options.headers[SUPABASE_TEAM_HEADER] === teamId && - Boolean(options.headers[SUPABASE_TOKEN_HEADER]) - ) { - if (found) { - return Promise.resolve({ - response: { status: 200 }, - data: { - sandboxID: sandboxId, - templateID: 'template123', - clientID: 'client123', - startedAt: '2024-01-01T00:00:00Z', - endAt: '2024-01-02T00:00:00Z', - state: 'running', - cpuCount: 2, - memoryMB: 4096, - diskSizeMB: 10240, - envdVersion: '1.0.0', - }, - error: null, - }) - } else { - return Promise.resolve({ - response: { status: 404 }, - data: null, - error: { message: 'Sandbox not found' }, - }) + vi.mocked(infra.GET).mockImplementation( + ( + path: string, + options: { + params: { path: { sandboxID: string } } + headers: Record + } + ) => { + if ( + path === '/sandboxes/{sandboxID}' && + options.params.path.sandboxID === sandboxId && + options.headers[SUPABASE_TEAM_HEADER] === teamId && + Boolean(options.headers[SUPABASE_TOKEN_HEADER]) + ) { + if (found) { + return Promise.resolve({ + response: { status: 200 }, + data: { + sandboxID: sandboxId, + templateID: 'template123', + clientID: 'client123', + startedAt: '2024-01-01T00:00:00Z', + endAt: '2024-01-02T00:00:00Z', + state: 'running', + cpuCount: 2, + memoryMB: 4096, + diskSizeMB: 10240, + envdVersion: '1.0.0', + }, + error: null, + }) + } else { + return Promise.resolve({ + response: { status: 404 }, + data: null, + error: { message: 'Sandbox not found' }, + }) + } } - } - return Promise.resolve({ - response: { status: 404 }, - data: null, - error: { message: 'Not found' }, - }) - }) + return Promise.resolve({ + response: { status: 404 }, + data: null, + error: { message: 'Not found' }, + }) + } + ) } // ============================================================================ @@ -188,11 +214,17 @@ function setupSandboxResponse( // ============================================================================ describe('Sandbox Inspect Route - Integration Tests', () => { - let GET: any + let GET: ( + request: NextRequest, + context: { params: Promise<{ sandboxId: string }> } + ) => Promise beforeEach(async () => { vi.clearAllMocks() mockRedirectCalls = [] + mockCreateUserTeamsRepository.mockReturnValue({ + listUserTeams: mockListUserTeams, + }) // Dynamically import the route handler after mocks are set up const routeModule = await import( @@ -303,7 +335,7 @@ describe('Sandbox Inspect Route - Integration Tests', () => { * SECURITY TEST: Verifies session errors are handled properly */ it('redirects to sign-in when session is invalid', async () => { - setupAuthenticatedUser('user-123', null as any) + setupAuthenticatedUser('user-123') mockSupabaseClient.auth.getSession.mockResolvedValue({ data: { session: null }, @@ -450,6 +482,10 @@ describe('Sandbox Inspect Route - Integration Tests', () => { expect(mockRedirectCalls[0]?.url).toContain( PROTECTED_URLS.SANDBOX('new-team', 'sbx789') ) + expect(vi.mocked(setTeamCookies)).toHaveBeenCalledWith( + 'team-456', + 'new-team' + ) }) }) @@ -459,21 +495,10 @@ describe('Sandbox Inspect Route - Integration Tests', () => { */ it('handles database errors gracefully', async () => { setupAuthenticatedUser() - - // Setup: Database error when fetching teams - vi.mocked(supabaseAdmin.from).mockImplementation( - () => - ({ - select: vi.fn(() => ({ - eq: vi.fn(() => - Promise.resolve({ - data: null, - error: { message: 'Database connection error' }, - }) - ), - })), - }) as any - ) + mockListUserTeams.mockResolvedValue({ + ok: false, + error: new Error('Database connection error'), + }) const request = createMockRequest('sbx123') const params = createMockParams('sbx123') diff --git a/src/__test__/integration/proxy.test.ts b/src/__test__/integration/proxy.test.ts index fa970dff4..edc33fe78 100644 --- a/src/__test__/integration/proxy.test.ts +++ b/src/__test__/integration/proxy.test.ts @@ -15,10 +15,11 @@ vi.mock('@supabase/ssr', () => ({ })) // mock logger to avoid noise in tests -vi.mock('@/lib/clients/logger/logger', () => ({ +vi.mock('@/core/shared/clients/logger/logger', () => ({ l: { error: vi.fn(), }, + serializeErrorForLog: vi.fn((error) => error), })) // mock next/server to track redirects and responses diff --git a/src/__test__/integration/resolve-user-team.test.ts b/src/__test__/integration/resolve-user-team.test.ts index 8c26c2cdc..28b7a64b7 100644 --- a/src/__test__/integration/resolve-user-team.test.ts +++ b/src/__test__/integration/resolve-user-team.test.ts @@ -1,525 +1,209 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { COOKIE_KEYS } from '@/configs/cookies' -/** - * Integration tests for resolveUserTeam function - * - * This function is CRITICAL for security as it resolves which team a user should access. - * It MUST always resolve an authorized team for the user, even if: - * - User has cookies for a team they're not authorized to access - * - User has no cookies at all - * - User has invalid/stale cookies - * - * Test Coverage: - * 1. Valid authorized cookies - should use cookies - * 2. Invalid/unauthorized cookies - should fall back to DB (SECURITY CRITICAL) - * 3. No cookies - should use default team from DB - * 4. No cookies, no default - should use first team from DB - * 5. No teams - should return null - * 6. Partial cookies - should fall back to DB - * 7. Database errors - should handle gracefully - */ - -// create hoisted mocks -const { mockSupabaseAdmin, mockCookieStore, mockCheckUserTeamAuth } = - vi.hoisted(() => ({ - mockSupabaseAdmin: { - from: vi.fn(), - }, - mockCookieStore: { - get: vi.fn(), - }, - mockCheckUserTeamAuth: vi.fn(), - })) - -vi.mock('@/lib/clients/supabase/admin', () => ({ - supabaseAdmin: mockSupabaseAdmin, +const { + mockCookieStore, + mockListUserTeams, + mockResolveTeamBySlug, + mockCreateUserTeamsRepository, +} = vi.hoisted(() => ({ + mockCookieStore: { + get: vi.fn(), + }, + mockListUserTeams: vi.fn(), + mockResolveTeamBySlug: vi.fn(), + mockCreateUserTeamsRepository: vi.fn(), })) vi.mock('next/headers', () => ({ cookies: vi.fn(() => mockCookieStore), })) -vi.mock('@/server/auth/check-user-team-auth-cached', () => ({ - checkUserTeamAuth: mockCheckUserTeamAuth, +vi.mock('@/core/modules/teams/user-teams-repository.server', () => ({ + createUserTeamsRepository: mockCreateUserTeamsRepository, })) -// import after mocks are set up -import { resolveUserTeam } from '@/server/team/resolve-user-team' +import { resolveUserTeam } from '@/core/server/functions/team/resolve-user-team' -describe('resolveUserTeam - Authorization Integration Tests', () => { +function setupCookies(cookieValues: Record) { + mockCookieStore.get.mockImplementation((key: string) => { + const value = cookieValues[key] + return typeof value === 'string' ? { value } : undefined + }) +} + +function createTeam(overrides: Record = {}) { + return { + id: 'team-1', + slug: 'team-one', + isDefault: false, + name: 'Team One', + email: 'team-one@example.com', + ...overrides, + } +} + +describe('resolveUserTeam', () => { beforeEach(() => { vi.clearAllMocks() + mockCreateUserTeamsRepository.mockReturnValue({ + listUserTeams: mockListUserTeams, + resolveTeamBySlug: mockResolveTeamBySlug, + }) }) afterEach(() => { vi.resetAllMocks() }) - /** - * Helper to setup cookie mock responses - */ - function setupCookies(cookieValues: Record) { - mockCookieStore.get.mockImplementation((key: string) => { - const value = cookieValues[key] - return value ? { value } : undefined + it('returns the cookie-backed team when the cookie slug resolves', async () => { + setupCookies({ + [COOKIE_KEYS.SELECTED_TEAM_ID]: 'team-cookie-id', + [COOKIE_KEYS.SELECTED_TEAM_SLUG]: 'team-cookie-slug', }) - } - - /** - * Helper to setup database mock responses for users_teams query - */ - function setupDatabaseMock( - teams: Array<{ - team_id: string - is_default: boolean - team: { id: string; slug: string } | null - }> | null, - error: { message: string } | null = null - ) { - const selectMock = vi.fn().mockReturnThis() - const eqMock = vi.fn().mockResolvedValue({ - data: teams, - error, + mockResolveTeamBySlug.mockResolvedValue({ + ok: true, + data: { + id: 'team-cookie-id', + slug: 'team-cookie-slug', + }, }) - mockSupabaseAdmin.from.mockReturnValue({ - select: selectMock, - }) + const result = await resolveUserTeam('access-token') - selectMock.mockReturnValue({ - eq: eqMock, - }) - - return { selectMock, eqMock } - } - - describe('Valid Authorized Cookies', () => { - it('should use cookies when user is authorized for the team', async () => { - // setup: user has valid cookies - setupCookies({ - [COOKIE_KEYS.SELECTED_TEAM_ID]: 'team-456', - [COOKIE_KEYS.SELECTED_TEAM_SLUG]: 'my-team', - }) - - // setup: authorization check passes - mockCheckUserTeamAuth.mockResolvedValue(true) - - // execute - const result = await resolveUserTeam('user-123') - - // verify: authorization was checked - expect(mockCheckUserTeamAuth).toHaveBeenCalledWith('user-123', 'team-456') - - // verify: returns team from cookies - expect(result).toEqual({ - id: 'team-456', - slug: 'my-team', - }) - - // verify: database was NOT queried (cookies used) - expect(mockSupabaseAdmin.from).not.toHaveBeenCalled() - }) - - it('should use cookies for user with multiple teams', async () => { - setupCookies({ - [COOKIE_KEYS.SELECTED_TEAM_ID]: 'team-secondary', - [COOKIE_KEYS.SELECTED_TEAM_SLUG]: 'secondary-team', - }) - - mockCheckUserTeamAuth.mockResolvedValue(true) - - const result = await resolveUserTeam('user-123') - - expect(result).toEqual({ - id: 'team-secondary', - slug: 'secondary-team', - }) - expect(mockSupabaseAdmin.from).not.toHaveBeenCalled() + expect(result).toEqual({ + id: 'team-cookie-id', + slug: 'team-cookie-slug', }) + expect(mockResolveTeamBySlug).toHaveBeenCalledWith('team-cookie-slug') + expect(mockListUserTeams).not.toHaveBeenCalled() }) - describe('Invalid/Unauthorized Cookies', () => { - it('should fall back to database when user has cookies for unauthorized team', async () => { - // user has cookies for a team they shouldn't access - setupCookies({ - [COOKIE_KEYS.SELECTED_TEAM_ID]: 'team-unauthorized', - [COOKIE_KEYS.SELECTED_TEAM_SLUG]: 'unauthorized-team', - }) - - // setup: authorization check FAILS - mockCheckUserTeamAuth.mockResolvedValue(false) - - // setup: database returns user's actual authorized teams - setupDatabaseMock([ - { - team_id: 'team-authorized', - is_default: true, - team: { id: 'team-authorized', slug: 'authorized-team' }, - }, - ]) - - const result = await resolveUserTeam('user-123') - - // verify: authorization was checked - expect(mockCheckUserTeamAuth).toHaveBeenCalledWith( - 'user-123', - 'team-unauthorized' - ) - - // verify: returns authorized team from DB, NOT from cookies - expect(result).toEqual({ - id: 'team-authorized', - slug: 'authorized-team', - }) - - // verify: database was queried (fallback) - expect(mockSupabaseAdmin.from).toHaveBeenCalled() + it('returns the resolved team when the cookie id is stale but the slug is valid', async () => { + setupCookies({ + [COOKIE_KEYS.SELECTED_TEAM_ID]: 'stale-team-id', + [COOKIE_KEYS.SELECTED_TEAM_SLUG]: 'team-cookie-slug', }) - - it('should use first team when unauthorized cookies and no default team', async () => { - setupCookies({ - [COOKIE_KEYS.SELECTED_TEAM_ID]: 'team-unauthorized', - [COOKIE_KEYS.SELECTED_TEAM_SLUG]: 'unauthorized-team', - }) - - mockCheckUserTeamAuth.mockResolvedValue(false) - - // no default team, but has teams - setupDatabaseMock([ - { - team_id: 'team-first', - is_default: false, - team: { id: 'team-first', slug: 'first-team' }, - }, - { - team_id: 'team-second', - is_default: false, - team: { id: 'team-second', slug: 'second-team' }, - }, - ]) - - const result = await resolveUserTeam('user-123') - - // should use first team from list - expect(result).toEqual({ - id: 'team-first', - slug: 'first-team', - }) + mockResolveTeamBySlug.mockResolvedValue({ + ok: true, + data: { + id: 'team-cookie-id', + slug: 'team-cookie-slug', + }, }) - it('should return null when unauthorized cookies and no teams in DB', async () => { - setupCookies({ - [COOKIE_KEYS.SELECTED_TEAM_ID]: 'team-unauthorized', - [COOKIE_KEYS.SELECTED_TEAM_SLUG]: 'unauthorized-team', - }) - - mockCheckUserTeamAuth.mockResolvedValue(false) - - // user has no teams - setupDatabaseMock([]) - - const result = await resolveUserTeam('user-123') + const result = await resolveUserTeam('access-token') - expect(result).toBeNull() + expect(result).toEqual({ + id: 'team-cookie-id', + slug: 'team-cookie-slug', }) + expect(mockListUserTeams).not.toHaveBeenCalled() }) - describe('No Cookies', () => { - it('should use default team from database when no cookies exist', async () => { - // no cookies - setupCookies({}) - - // setup: database returns teams with a default - setupDatabaseMock([ - { - team_id: 'team-default', - is_default: true, - team: { id: 'team-default', slug: 'default-team' }, - }, - { - team_id: 'team-other', - is_default: false, - team: { id: 'team-other', slug: 'other-team' }, - }, - ]) - - const result = await resolveUserTeam('user-123') - - // verify: did not check authorization (no cookies to check) - expect(mockCheckUserTeamAuth).not.toHaveBeenCalled() - - // verify: returns default team - expect(result).toEqual({ - id: 'team-default', - slug: 'default-team', - }) + it('returns the default slug-backed team when cookies are missing', async () => { + setupCookies({}) + mockListUserTeams.mockResolvedValue({ + ok: true, + data: [ + createTeam({ id: 'team-a', slug: 'team-a' }), + createTeam({ id: 'team-b', slug: 'team-b', isDefault: true }), + ], }) - it('should use first team when no cookies and no default team', async () => { - setupCookies({}) - - // no default team - setupDatabaseMock([ - { - team_id: 'team-first', - is_default: false, - team: { id: 'team-first', slug: 'first-team' }, - }, - { - team_id: 'team-second', - is_default: false, - team: { id: 'team-second', slug: 'second-team' }, - }, - ]) - - const result = await resolveUserTeam('user-123') - - // should use first team from list - expect(result).toEqual({ - id: 'team-first', - slug: 'first-team', - }) - }) + const result = await resolveUserTeam('access-token') - it('should handle team without slug (use ID instead)', async () => { - setupCookies({}) - - // team has no slug (edge case) - setupDatabaseMock([ - { - team_id: 'team-id-only', - is_default: true, - team: { id: 'team-id-only', slug: '' }, - }, - ]) - - const result = await resolveUserTeam('user-123') - - // should fallback to ID when slug is empty - expect(result).toEqual({ - id: 'team-id-only', - slug: 'team-id-only', - }) + expect(result).toEqual({ + id: 'team-b', + slug: 'team-b', }) - - it('should return null when no cookies and no teams', async () => { - setupCookies({}) - - setupDatabaseMock([]) - - const result = await resolveUserTeam('user-123') - - expect(result).toBeNull() + expect(mockCreateUserTeamsRepository).toHaveBeenCalledWith({ + accessToken: 'access-token', }) + }) - it('should return null when database returns null', async () => { - setupCookies({}) - - setupDatabaseMock(null) + it('falls back to the first slug-backed team when the default has no slug', async () => { + setupCookies({}) + mockListUserTeams.mockResolvedValue({ + ok: true, + data: [ + createTeam({ id: 'team-default', slug: '', isDefault: true }), + createTeam({ id: 'team-slugged', slug: 'team-slugged' }), + ], + }) - const result = await resolveUserTeam('user-123') + const result = await resolveUserTeam('access-token') - expect(result).toBeNull() + expect(result).toEqual({ + id: 'team-slugged', + slug: 'team-slugged', }) }) - describe('Partial Cookies', () => { - it('should fall back to database when only team ID cookie exists', async () => { - // only team ID cookie, no slug - setupCookies({ - [COOKIE_KEYS.SELECTED_TEAM_ID]: 'team-456', - }) - - setupDatabaseMock([ - { - team_id: 'team-default', - is_default: true, - team: { id: 'team-default', slug: 'default-team' }, - }, - ]) - - const result = await resolveUserTeam('user-123') - - // verify: did not check authorization (incomplete cookies) - expect(mockCheckUserTeamAuth).not.toHaveBeenCalled() - - // verify: fell back to database - expect(result).toEqual({ - id: 'team-default', - slug: 'default-team', - }) + it('falls back to the repository when the cookie slug is no longer accessible', async () => { + setupCookies({ + [COOKIE_KEYS.SELECTED_TEAM_ID]: 'team-cookie-id', + [COOKIE_KEYS.SELECTED_TEAM_SLUG]: 'team-cookie-slug', }) - - it('should fall back to database when only team slug cookie exists', async () => { - // only team slug cookie, no ID - setupCookies({ - [COOKIE_KEYS.SELECTED_TEAM_SLUG]: 'my-team', - }) - - setupDatabaseMock([ - { - team_id: 'team-default', - is_default: true, - team: { id: 'team-default', slug: 'default-team' }, - }, - ]) - - const result = await resolveUserTeam('user-123') - - expect(mockCheckUserTeamAuth).not.toHaveBeenCalled() - expect(result).toEqual({ - id: 'team-default', - slug: 'default-team', - }) + mockResolveTeamBySlug.mockResolvedValue({ + ok: false, + error: new Error('Team not found'), + }) + mockListUserTeams.mockResolvedValue({ + ok: true, + data: [createTeam({ id: 'team-db', slug: 'team-db', isDefault: true })], }) - }) - - describe('Database Errors', () => { - it('should return null when database query fails', async () => { - setupCookies({}) - - setupDatabaseMock(null, { message: 'Database connection failed' }) - const result = await resolveUserTeam('user-123') + const result = await resolveUserTeam('access-token') - expect(result).toBeNull() + expect(result).toEqual({ + id: 'team-db', + slug: 'team-db', }) + }) - it('should fall back to database when authorization check fails with error', async () => { - setupCookies({ - [COOKIE_KEYS.SELECTED_TEAM_ID]: 'team-456', - [COOKIE_KEYS.SELECTED_TEAM_SLUG]: 'my-team', - }) - - // authorization check fails (returns false on error) - mockCheckUserTeamAuth.mockResolvedValue(false) - - setupDatabaseMock([ - { - team_id: 'team-default', - is_default: true, - team: { id: 'team-default', slug: 'default-team' }, - }, - ]) - - const result = await resolveUserTeam('user-123') - - // should still fall back to database - expect(result).toEqual({ - id: 'team-default', - slug: 'default-team', - }) + it('falls back to the repository when cookies are partial', async () => { + setupCookies({ + [COOKIE_KEYS.SELECTED_TEAM_ID]: 'team-cookie-id', + }) + mockListUserTeams.mockResolvedValue({ + ok: true, + data: [createTeam({ id: 'team-db', slug: 'team-db', isDefault: true })], }) - it('should return null when team relation is malformed', async () => { - setupCookies({}) - - // malformed data - team relation is null - setupDatabaseMock([ - { - team_id: 'team-123', - is_default: true, - team: null, // malformed! - }, - ]) - - const result = await resolveUserTeam('user-123') + const result = await resolveUserTeam('access-token') - expect(result).toBeNull() + expect(result).toEqual({ + id: 'team-db', + slug: 'team-db', }) + expect(mockResolveTeamBySlug).not.toHaveBeenCalled() }) - describe('Complex Scenarios', () => { - it('should prefer default team over first team when both exist', async () => { - setupCookies({}) - - setupDatabaseMock([ - { - team_id: 'team-first', - is_default: false, - team: { id: 'team-first', slug: 'first-team' }, - }, - { - team_id: 'team-default', - is_default: true, - team: { id: 'team-default', slug: 'default-team' }, - }, - { - team_id: 'team-third', - is_default: false, - team: { id: 'team-third', slug: 'third-team' }, - }, - ]) - - const result = await resolveUserTeam('user-123') - - // should use default team, not first team - expect(result).toEqual({ - id: 'team-default', - slug: 'default-team', - }) + it('returns null when no slug-backed team can be resolved', async () => { + setupCookies({}) + mockListUserTeams.mockResolvedValue({ + ok: true, + data: [ + createTeam({ id: 'team-a', slug: '', isDefault: true }), + createTeam({ id: 'team-b', slug: '' }), + ], }) - it('should handle user switching teams correctly', async () => { - // first call - user has cookies for team A - setupCookies({ - [COOKIE_KEYS.SELECTED_TEAM_ID]: 'team-a', - [COOKIE_KEYS.SELECTED_TEAM_SLUG]: 'team-a-slug', - }) - - mockCheckUserTeamAuth.mockResolvedValue(true) - - let result = await resolveUserTeam('user-123') + const result = await resolveUserTeam('access-token') - expect(result).toEqual({ - id: 'team-a', - slug: 'team-a-slug', - }) - - // second call - user switches to team B - vi.clearAllMocks() - setupCookies({ - [COOKIE_KEYS.SELECTED_TEAM_ID]: 'team-b', - [COOKIE_KEYS.SELECTED_TEAM_SLUG]: 'team-b-slug', - }) - - mockCheckUserTeamAuth.mockResolvedValue(true) - - result = await resolveUserTeam('user-123') + expect(result).toBeNull() + }) - expect(result).toEqual({ - id: 'team-b', - slug: 'team-b-slug', - }) + it('returns null when listing teams fails', async () => { + setupCookies({}) + mockListUserTeams.mockResolvedValue({ + ok: false, + error: new Error('Failed to fetch user teams'), }) - it('should prevent access when user loses team membership', async () => { - // user has cookies for a team they were previously a member of - setupCookies({ - [COOKIE_KEYS.SELECTED_TEAM_ID]: 'team-removed', - [COOKIE_KEYS.SELECTED_TEAM_SLUG]: 'removed-team', - }) - - // authorization check fails (no longer a member) - mockCheckUserTeamAuth.mockResolvedValue(false) - - // user has other teams - setupDatabaseMock([ - { - team_id: 'team-remaining', - is_default: true, - team: { id: 'team-remaining', slug: 'remaining-team' }, - }, - ]) - - const result = await resolveUserTeam('user-123') - - // should redirect to remaining team - expect(result).toEqual({ - id: 'team-remaining', - slug: 'remaining-team', - }) - }) + const result = await resolveUserTeam('access-token') + + expect(result).toBeNull() }) }) diff --git a/src/__test__/setup.ts b/src/__test__/setup.ts index 37226c0a9..551fdefc2 100644 --- a/src/__test__/setup.ts +++ b/src/__test__/setup.ts @@ -10,7 +10,7 @@ vi.mock('server-only', () => ({})) vi.mock('server-cli-only', () => ({})) // default mocks -vi.mock('@/lib/clients/logger', () => ({ +vi.mock('@/core/shared/clients/logger', () => ({ l: { error: console.error, info: console.info, diff --git a/src/__test__/unit/chart-utils.test.ts b/src/__test__/unit/chart-utils.test.ts index 6ff740a71..5b0ad4829 100644 --- a/src/__test__/unit/chart-utils.test.ts +++ b/src/__test__/unit/chart-utils.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' +import type { ClientTeamMetric } from '@/core/modules/sandboxes/models.client' import { transformMetrics } from '@/features/dashboard/sandboxes/monitoring/charts/team-metrics-chart/utils' import { calculateAxisMax } from '@/lib/utils/chart' -import type { ClientTeamMetric } from '@/types/sandboxes.types' describe('team-metrics-chart-utils', () => { describe('calculateYAxisMax', () => { diff --git a/src/__test__/unit/fill-metrics-with-zeros.test.ts b/src/__test__/unit/fill-metrics-with-zeros.test.ts index 40af27022..0afce30b5 100644 --- a/src/__test__/unit/fill-metrics-with-zeros.test.ts +++ b/src/__test__/unit/fill-metrics-with-zeros.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { fillTeamMetricsWithZeros } from '@/server/sandboxes/utils' -import type { ClientTeamMetrics } from '@/types/sandboxes.types' +import type { ClientTeamMetrics } from '@/core/modules/sandboxes/models.client' +import { fillTeamMetricsWithZeros } from '@/core/server/functions/sandboxes/utils' describe('fillTeamMetricsWithZeros', () => { describe('Empty data handling', () => { diff --git a/src/__test__/unit/sandbox-lifecycle.test.ts b/src/__test__/unit/sandbox-lifecycle.test.ts index 42f9d8f29..10fceb0a3 100644 --- a/src/__test__/unit/sandbox-lifecycle.test.ts +++ b/src/__test__/unit/sandbox-lifecycle.test.ts @@ -1,12 +1,12 @@ import { describe, expect, it } from 'vitest' import { deriveSandboxLifecycleFromEvents, - type SandboxEventDTO, -} from '@/server/api/models/sandboxes.models' + type SandboxEventModel, +} from '@/core/modules/sandboxes/models' function createLifecycleEvent( - overrides: Partial & Pick -): SandboxEventDTO { + overrides: Partial & Pick +): SandboxEventModel { return { id: overrides.id, version: 'v1', @@ -24,7 +24,7 @@ function createLifecycleEvent( describe('deriveSandboxLifecycleFromEvents', () => { it('derives createdAt, pausedAt and endedAt from a full lifecycle', () => { - const events: SandboxEventDTO[] = [ + const events: SandboxEventModel[] = [ createLifecycleEvent({ id: '5', type: 'sandbox.lifecycle.killed', @@ -68,7 +68,7 @@ describe('deriveSandboxLifecycleFromEvents', () => { }) it('keeps pausedAt when the sandbox is paused and not resumed yet', () => { - const events: SandboxEventDTO[] = [ + const events: SandboxEventModel[] = [ createLifecycleEvent({ id: '1', type: 'sandbox.lifecycle.created', @@ -89,7 +89,7 @@ describe('deriveSandboxLifecycleFromEvents', () => { }) it('uses first created event and the last killed event', () => { - const events: SandboxEventDTO[] = [ + const events: SandboxEventModel[] = [ createLifecycleEvent({ id: '1', type: 'sandbox.lifecycle.created', @@ -119,7 +119,7 @@ describe('deriveSandboxLifecycleFromEvents', () => { }) it('uses the last paused or killed event to constrain the lifecycle', () => { - const events: SandboxEventDTO[] = [ + const events: SandboxEventModel[] = [ createLifecycleEvent({ id: '1', type: 'sandbox.lifecycle.created', @@ -160,7 +160,7 @@ describe('deriveSandboxLifecycleFromEvents', () => { }) it('does not constrain when the last event is resumed', () => { - const events: SandboxEventDTO[] = [ + const events: SandboxEventModel[] = [ createLifecycleEvent({ id: '1', type: 'sandbox.lifecycle.created', @@ -186,7 +186,7 @@ describe('deriveSandboxLifecycleFromEvents', () => { }) it('does not constrain when the last event is updated', () => { - const events: SandboxEventDTO[] = [ + const events: SandboxEventModel[] = [ createLifecycleEvent({ id: '1', type: 'sandbox.lifecycle.created', @@ -212,7 +212,7 @@ describe('deriveSandboxLifecycleFromEvents', () => { }) it('ignores non-lifecycle events', () => { - const events: SandboxEventDTO[] = [ + const events: SandboxEventModel[] = [ createLifecycleEvent({ id: '1', type: 'sandbox.lifecycle.created', diff --git a/src/__test__/unit/sandbox-monitoring-chart-model.test.ts b/src/__test__/unit/sandbox-monitoring-chart-model.test.ts index 67d932d47..848a982ef 100644 --- a/src/__test__/unit/sandbox-monitoring-chart-model.test.ts +++ b/src/__test__/unit/sandbox-monitoring-chart-model.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from 'vitest' -import { buildMonitoringChartModel } from '@/features/dashboard/sandbox/monitoring/utils/chart-model' import type { - SandboxEventDTO, + SandboxEventModel, SandboxMetric, -} from '@/server/api/models/sandboxes.models' +} from '@/core/modules/sandboxes/models' +import { buildMonitoringChartModel } from '@/features/dashboard/sandbox/monitoring/utils/chart-model' const baseMetric = { timestamp: '1970-01-01T00:00:00.000Z', @@ -16,8 +16,8 @@ const baseMetric = { > function createLifecycleEvent( - overrides: Partial & Pick -): SandboxEventDTO { + overrides: Partial & Pick +): SandboxEventModel { return { id: overrides.id, version: 'v1', @@ -148,7 +148,7 @@ describe('buildMonitoringChartModel', () => { }, ] - const lifecycleEvents: SandboxEventDTO[] = [ + const lifecycleEvents: SandboxEventModel[] = [ createLifecycleEvent({ id: 'pause', type: 'sandbox.lifecycle.paused', @@ -233,7 +233,7 @@ describe('buildMonitoringChartModel', () => { }, ] - const lifecycleEvents: SandboxEventDTO[] = [ + const lifecycleEvents: SandboxEventModel[] = [ createLifecycleEvent({ id: 'pause-1', type: 'sandbox.lifecycle.paused', @@ -276,7 +276,7 @@ describe('buildMonitoringChartModel', () => { }) it('draws a synthetic dashed connector across an active lifecycle window when no metrics were collected', () => { - const lifecycleEvents: SandboxEventDTO[] = [ + const lifecycleEvents: SandboxEventModel[] = [ createLifecycleEvent({ id: 'created', type: 'sandbox.lifecycle.created', @@ -324,7 +324,7 @@ describe('buildMonitoringChartModel', () => { }) it('draws a dashed connector from created to the first metric when the range starts at created', () => { - const lifecycleEvents: SandboxEventDTO[] = [ + const lifecycleEvents: SandboxEventModel[] = [ createLifecycleEvent({ id: 'created', type: 'sandbox.lifecycle.created', @@ -357,7 +357,7 @@ describe('buildMonitoringChartModel', () => { }) it('draws a dashed connector from created to the first metric when the range starts before created', () => { - const lifecycleEvents: SandboxEventDTO[] = [ + const lifecycleEvents: SandboxEventModel[] = [ createLifecycleEvent({ id: 'created', type: 'sandbox.lifecycle.created', @@ -399,7 +399,7 @@ describe('buildMonitoringChartModel', () => { }) it('builds visible lifecycle event markers for created, paused, resumed, and killed only', () => { - const lifecycleEvents: SandboxEventDTO[] = [ + const lifecycleEvents: SandboxEventModel[] = [ createLifecycleEvent({ id: 'outside', type: 'sandbox.lifecycle.killed', diff --git a/src/__test__/unit/team-display-name.test.ts b/src/__test__/unit/team-display-name.test.ts new file mode 100644 index 000000000..377808f31 --- /dev/null +++ b/src/__test__/unit/team-display-name.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest' +import { + getTeamDisplayName, + getTransformedDefaultTeamName, +} from '@/core/modules/teams/utils' + +describe('team display name', () => { + it('transforms unchanged default team email names', () => { + const transformed = getTransformedDefaultTeamName({ + email: 'ben.fornefeld@gmail.com', + isDefault: true, + name: 'ben.fornefeld@gmail.com', + }) + + expect(transformed).toBe("Ben.fornefeld's Team") + expect( + getTeamDisplayName({ + email: 'ben.fornefeld@gmail.com', + isDefault: true, + name: 'ben.fornefeld@gmail.com', + }) + ).toBe("Ben.fornefeld's Team") + }) + + it('falls back to the raw team name otherwise', () => { + expect( + getTransformedDefaultTeamName({ + email: 'ben.fornefeld@gmail.com', + isDefault: true, + name: 'Platform Team', + }) + ).toBeNull() + + expect( + getTeamDisplayName({ + email: 'ben.fornefeld@gmail.com', + isDefault: false, + name: 'Platform Team', + }) + ).toBe('Platform Team') + }) +}) diff --git a/src/app/(auth)/auth/cli/page.tsx b/src/app/(auth)/auth/cli/page.tsx index d116b3c44..221daca26 100644 --- a/src/app/(auth)/auth/cli/page.tsx +++ b/src/app/(auth)/auth/cli/page.tsx @@ -1,13 +1,12 @@ import { CloudIcon, LaptopIcon, Link2Icon } from 'lucide-react' import { redirect } from 'next/navigation' import { Suspense } from 'react' -import { serializeError } from 'serialize-error' import { AUTH_URLS, PROTECTED_URLS } from '@/configs/urls' -import { l } from '@/lib/clients/logger/logger' -import { createClient } from '@/lib/clients/supabase/server' +import { createUserTeamsRepository } from '@/core/modules/teams/user-teams-repository.server' +import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger' +import { createClient } from '@/core/shared/clients/supabase/server' import { encodedRedirect } from '@/lib/utils/auth' import { generateE2BUserAccessToken } from '@/lib/utils/server' -import { getDefaultTeamRelation } from '@/server/auth/get-default-team' import { Alert, AlertDescription, AlertTitle } from '@/ui/primitives/alert' // Types @@ -21,7 +20,6 @@ type CLISearchParams = Promise<{ async function handleCLIAuth( next: string, - userId: string, userEmail: string, supabaseAccessToken: string ) { @@ -29,20 +27,31 @@ async function handleCLIAuth( throw new Error('Invalid redirect URL') } - try { - const defaultTeam = await getDefaultTeamRelation(userId) - const e2bAccessToken = await generateE2BUserAccessToken(supabaseAccessToken) + const teamsResult = await createUserTeamsRepository({ + accessToken: supabaseAccessToken, + }).listUserTeams() - const searchParams = new URLSearchParams({ - email: userEmail, - accessToken: e2bAccessToken.token, - defaultTeamId: defaultTeam.team_id, - }) + if (!teamsResult.ok) { + throw new Error('Failed to resolve default team') + } - return redirect(`${next}?${searchParams.toString()}`) - } catch (err) { - throw err + const defaultTeam = + teamsResult.data.find((team) => team.isDefault && team.slug) ?? + teamsResult.data.find((team) => team.slug) + + if (!defaultTeam) { + throw new Error('Failed to resolve default team') } + + const e2bAccessToken = await generateE2BUserAccessToken(supabaseAccessToken) + + const searchParams = new URLSearchParams({ + email: userEmail, + accessToken: e2bAccessToken.token, + defaultTeamId: defaultTeam.id, + }) + + return redirect(`${next}?${searchParams.toString()}`) } // UI Components @@ -131,12 +140,11 @@ export default async function CLIAuthPage({ throw new Error('No provider access token found') } - return await handleCLIAuth( - next, - user.id, - user.email!, - session.access_token - ) + if (!user.email) { + throw new Error('No user email found') + } + + return await handleCLIAuth(next, user.email, session.access_token) } catch (err) { if (err instanceof Error && err.message.includes('NEXT_REDIRECT')) { throw err @@ -145,7 +153,7 @@ export default async function CLIAuthPage({ l.error( { key: 'cli_auth:unexpected_error', - error: serializeError(err), + error: serializeErrorForLog(err), user_id: user?.id, context: { next, diff --git a/src/app/(auth)/confirm/page.tsx b/src/app/(auth)/confirm/page.tsx index 7cfaf88f9..07cd39f90 100644 --- a/src/app/(auth)/confirm/page.tsx +++ b/src/app/(auth)/confirm/page.tsx @@ -4,13 +4,13 @@ import { useMutation } from '@tanstack/react-query' import { useRouter, useSearchParams } from 'next/navigation' import { useMemo, useTransition } from 'react' import { AUTH_URLS } from '@/configs/urls' -import { AuthFormMessage } from '@/features/auth/form-message' import { type ConfirmEmailInput, ConfirmEmailInputSchema, type OtpType, OtpTypeSchema, -} from '@/server/api/models/auth.models' +} from '@/core/modules/auth/models' +import { AuthFormMessage } from '@/features/auth/form-message' import { Button } from '@/ui/primitives/button' const OTP_TYPE_LABELS: Record = { diff --git a/src/app/(auth)/forgot-password/page.tsx b/src/app/(auth)/forgot-password/page.tsx index dc470821e..3aa518ee9 100644 --- a/src/app/(auth)/forgot-password/page.tsx +++ b/src/app/(auth)/forgot-password/page.tsx @@ -9,9 +9,9 @@ import { getTimeoutMsFromUserMessage, USER_MESSAGES, } from '@/configs/user-messages' +import { forgotPasswordAction } from '@/core/server/actions/auth-actions' +import { forgotPasswordSchema } from '@/core/server/functions/auth/auth.types' import { AuthFormMessage, type AuthMessage } from '@/features/auth/form-message' -import { forgotPasswordSchema } from '@/server/auth/auth.types' -import { forgotPasswordAction } from '@/server/auth/auth-actions' import { Button } from '@/ui/primitives/button' import { Input } from '@/ui/primitives/input' import { Label } from '@/ui/primitives/label' diff --git a/src/app/(auth)/sign-in/page.tsx b/src/app/(auth)/sign-in/page.tsx index d091d7674..7bc10651f 100644 --- a/src/app/(auth)/sign-in/page.tsx +++ b/src/app/(auth)/sign-in/page.tsx @@ -7,10 +7,10 @@ import { useSearchParams } from 'next/navigation' import { Suspense, useEffect, useState } from 'react' import { AUTH_URLS } from '@/configs/urls' import { USER_MESSAGES } from '@/configs/user-messages' +import { signInAction } from '@/core/server/actions/auth-actions' +import { signInSchema } from '@/core/server/functions/auth/auth.types' import { AuthFormMessage, type AuthMessage } from '@/features/auth/form-message' import { OAuthProviders } from '@/features/auth/oauth-provider-buttons' -import { signInSchema } from '@/server/auth/auth.types' -import { signInAction } from '@/server/auth/auth-actions' import { Button } from '@/ui/primitives/button' import { Form, diff --git a/src/app/(auth)/sign-up/page.tsx b/src/app/(auth)/sign-up/page.tsx index 7fdad1eb8..e1022c484 100644 --- a/src/app/(auth)/sign-up/page.tsx +++ b/src/app/(auth)/sign-up/page.tsx @@ -11,12 +11,12 @@ import { getTimeoutMsFromUserMessage, USER_MESSAGES, } from '@/configs/user-messages' +import { signUpAction } from '@/core/server/actions/auth-actions' +import { signUpSchema } from '@/core/server/functions/auth/auth.types' import { AuthFormMessage, type AuthMessage } from '@/features/auth/form-message' import { OAuthProviders } from '@/features/auth/oauth-provider-buttons' import { TurnstileWidget } from '@/features/auth/turnstile-widget' import { useTurnstile } from '@/features/auth/use-turnstile' -import { signUpSchema } from '@/server/auth/auth.types' -import { signUpAction } from '@/server/auth/auth-actions' import { Button } from '@/ui/primitives/button' import { Form, diff --git a/src/app/(rewrites)/[[...slug]]/route.ts b/src/app/(rewrites)/[[...slug]]/route.ts index d2115a635..43fe9e572 100644 --- a/src/app/(rewrites)/[[...slug]]/route.ts +++ b/src/app/(rewrites)/[[...slug]]/route.ts @@ -1,10 +1,9 @@ import type { NextRequest } from 'next/server' -import { serializeError } from 'serialize-error' import { constructSitemap } from '@/app/sitemap' import { ALLOW_SEO_INDEXING } from '@/configs/flags' import { ROUTE_REWRITE_CONFIG } from '@/configs/rewrites' import { BASE_URL } from '@/configs/urls' -import { l } from '@/lib/clients/logger/logger' +import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger' import { getRewriteForPath, rewriteContentPagesHtml, @@ -103,7 +102,7 @@ export async function GET(request: NextRequest): Promise { } catch (error) { l.error({ key: 'url_rewrite:unexpected_error', - error: serializeError(error), + error: serializeErrorForLog(error), }) return new Response( diff --git a/src/app/api/auth/callback/route.ts b/src/app/api/auth/callback/route.ts index 241b2d414..1f5ff8a2b 100644 --- a/src/app/api/auth/callback/route.ts +++ b/src/app/api/auth/callback/route.ts @@ -1,8 +1,7 @@ import { redirect } from 'next/navigation' -import { serializeError } from 'serialize-error' import { AUTH_URLS, PROTECTED_URLS } from '@/configs/urls' -import { l } from '@/lib/clients/logger/logger' -import { createClient } from '@/lib/clients/supabase/server' +import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger' +import { createClient } from '@/core/shared/clients/supabase/server' import { encodedRedirect } from '@/lib/utils/auth' export async function GET(request: Request) { @@ -37,7 +36,7 @@ export async function GET(request: Request) { l.error( { key: 'auth_callback:supabase_error', - error: serializeError(error), + error: serializeErrorForLog(error), context: { code, origin, diff --git a/src/app/api/auth/confirm/route.ts b/src/app/api/auth/confirm/route.ts index ee0033f98..c622fec9d 100644 --- a/src/app/api/auth/confirm/route.ts +++ b/src/app/api/auth/confirm/route.ts @@ -2,9 +2,9 @@ import { redirect } from 'next/navigation' import type { NextRequest } from 'next/server' import { z } from 'zod' import { AUTH_URLS } from '@/configs/urls' -import { l } from '@/lib/clients/logger/logger' +import { OtpTypeSchema } from '@/core/modules/auth/models' +import { l } from '@/core/shared/clients/logger/logger' import { encodedRedirect, isExternalOrigin } from '@/lib/utils/auth' -import { OtpTypeSchema } from '@/server/api/models/auth.models' const confirmSchema = z.object({ token_hash: z.string().min(1), diff --git a/src/app/api/auth/email-callback/route.tsx b/src/app/api/auth/email-callback/route.tsx index 3fd46b5b4..24be4c0c3 100644 --- a/src/app/api/auth/email-callback/route.tsx +++ b/src/app/api/auth/email-callback/route.tsx @@ -1,6 +1,6 @@ import { redirect } from 'next/navigation' import { PROTECTED_URLS } from '@/configs/urls' -import { createClient } from '@/lib/clients/supabase/server' +import { createClient } from '@/core/shared/clients/supabase/server' import { encodedRedirect } from '@/lib/utils/auth' export async function GET(request: Request) { diff --git a/src/app/api/auth/verify-otp/route.ts b/src/app/api/auth/verify-otp/route.ts index 87f3cd02d..590361da0 100644 --- a/src/app/api/auth/verify-otp/route.ts +++ b/src/app/api/auth/verify-otp/route.ts @@ -1,13 +1,13 @@ import { type NextRequest, NextResponse } from 'next/server' import { flattenError } from 'zod' import { AUTH_URLS, PROTECTED_URLS } from '@/configs/urls' -import { l } from '@/lib/clients/logger/logger' -import { isExternalOrigin } from '@/lib/utils/auth' import { ConfirmEmailInputSchema, type OtpType, -} from '@/server/api/models/auth.models' -import { authRepo } from '@/server/api/repositories/auth.repository' +} from '@/core/modules/auth/models' +import { authRepository } from '@/core/modules/auth/repository.server' +import { l } from '@/core/shared/clients/logger/logger' +import { isExternalOrigin } from '@/lib/utils/auth' /** * Determines the redirect URL based on OTP type. @@ -109,7 +109,14 @@ export async function POST(request: NextRequest) { `verifying OTP token: ${token_hash.slice(0, 10)}` ) - await authRepo.verifyOtp(token_hash, type) + const verifyResult = await authRepository.verifyOtp(token_hash, type) + if (!verifyResult.ok) { + const errorRedirectUrl = buildErrorRedirectUrl( + origin, + verifyResult.error.message + ) + return NextResponse.json({ redirectUrl: errorRedirectUrl }) + } const redirectUrl = buildRedirectUrl(type, next, origin) diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts index 56f59379d..8fc3aea0e 100644 --- a/src/app/api/health/route.ts +++ b/src/app/api/health/route.ts @@ -1,20 +1,16 @@ import { NextResponse } from 'next/server' -import { serializeError } from 'serialize-error' -import { kv } from '@/lib/clients/kv' -import { l } from '@/lib/clients/logger/logger' -import { supabaseAdmin } from '@/lib/clients/supabase/admin' - -// NOTE - using cdn caching for rate limiting on db calls +import { api } from '@/core/shared/clients/api' +import { kv } from '@/core/shared/clients/kv' +import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger' export const maxDuration = 10 export async function GET() { const checks = { kv: false, - supabase: false, + dashboardApi: false, } - // check kv try { await kv.ping() checks.kv = true @@ -22,32 +18,36 @@ export async function GET() { l.error( { key: 'health_check:kv_error', - error: serializeError(error), + error: serializeErrorForLog(error), }, 'KV health check failed' ) } - // check supabase - const { data: _, error } = await supabaseAdmin - .from('teams') - .select('id') - .limit(1) - .single() - - if (!error) { - checks.supabase = true - } else { + try { + const { error } = await api.GET('/health', {}) + if (!error) { + checks.dashboardApi = true + } else { + l.error( + { + key: 'health_check:dashboard_api_error', + error, + }, + 'Dashboard API health check failed' + ) + } + } catch (error) { l.error( { - key: 'health_check:supabase_error', - error: serializeError(error), + key: 'health_check:dashboard_api_error', + error: serializeErrorForLog(error), }, - 'Supabase health check failed' + 'Dashboard API health check failed' ) } - const allHealthy = checks.kv && checks.supabase + const allHealthy = checks.kv && checks.dashboardApi return NextResponse.json( { diff --git a/src/app/api/teams/[teamId]/sandboxes/metrics/route.ts b/src/app/api/teams/[teamId]/sandboxes/metrics/route.ts deleted file mode 100644 index cb410c4fc..000000000 --- a/src/app/api/teams/[teamId]/sandboxes/metrics/route.ts +++ /dev/null @@ -1,76 +0,0 @@ -import 'server-cli-only' - -import { SUPABASE_AUTH_HEADERS } from '@/configs/api' -import { infra } from '@/lib/clients/api' -import { l } from '@/lib/clients/logger/logger' -import { handleDefaultInfraError } from '@/lib/utils/action' -import { getSessionInsecure } from '@/server/auth/get-session' -import { transformMetricsToClientMetrics } from '@/server/sandboxes/utils' -import { MetricsRequestSchema, type MetricsResponse } from './types' - -export async function POST( - request: Request, - { params }: { params: Promise<{ teamId: string }> } -) { - try { - const { teamId } = await params - - const { success, data } = MetricsRequestSchema.safeParse( - await request.json() - ) - - if (!success) { - return Response.json({ error: 'Invalid request' }, { status: 400 }) - } - - const { sandboxIds } = data - - // fine to use here, we only need a token for the infra api request. it will validate the token. - const session = await getSessionInsecure() - - if (!session) { - return Response.json({ error: 'Unauthenticated' }, { status: 401 }) - } - - const infraRes = await infra.GET('/sandboxes/metrics', { - params: { - query: { - sandbox_ids: sandboxIds, - }, - }, - headers: { - ...SUPABASE_AUTH_HEADERS(session.access_token, teamId), - }, - cache: 'no-store', - }) - - if (infraRes.error) { - const status = infraRes.response.status - - l.error( - { - key: 'get_team_sandboxes_metrics', - error: infraRes.error, - team_id: teamId, - user_id: session.user.id, - context: { - path: '/sandboxes/metrics', - status, - }, - }, - `Failed to get team sandbox metrics: ${infraRes.error.message}` - ) - - return Response.json( - { error: handleDefaultInfraError(status) }, - { status } - ) - } - - const metrics = transformMetricsToClientMetrics(infraRes.data.sandboxes) - - return Response.json({ metrics } satisfies MetricsResponse) - } catch (error) { - return Response.json({ error: 'Internal server error' }, { status: 500 }) - } -} diff --git a/src/app/api/teams/[teamId]/sandboxes/metrics/types.ts b/src/app/api/teams/[teamId]/sandboxes/metrics/types.ts deleted file mode 100644 index e2f6a0433..000000000 --- a/src/app/api/teams/[teamId]/sandboxes/metrics/types.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { z } from 'zod' -import type { ClientSandboxesMetrics } from '@/types/sandboxes.types' - -export const MetricsRequestSchema = z.object({ - sandboxIds: z.array(z.string()).min(1, 'Provide at least one sandbox id'), -}) - -export type MetricsRequest = z.infer - -export type MetricsResponse = { - metrics: ClientSandboxesMetrics -} diff --git a/src/app/api/teams/[teamId]/metrics/route.ts b/src/app/api/teams/[teamSlug]/metrics/route.ts similarity index 54% rename from src/app/api/teams/[teamId]/metrics/route.ts rename to src/app/api/teams/[teamSlug]/metrics/route.ts index 3223e779a..bf1d7915a 100644 --- a/src/app/api/teams/[teamId]/metrics/route.ts +++ b/src/app/api/teams/[teamSlug]/metrics/route.ts @@ -1,17 +1,18 @@ import 'server-cli-only' -import { serializeError } from 'serialize-error' -import { l } from '@/lib/clients/logger/logger' -import { getSessionInsecure } from '@/server/auth/get-session' -import { getTeamMetricsCore } from '@/server/sandboxes/get-team-metrics-core' +import { getSessionInsecure } from '@/core/server/functions/auth/get-session' +import { getTeamMetricsCore } from '@/core/server/functions/sandboxes/get-team-metrics-core' +import { getTeamIdFromSlug } from '@/core/server/functions/team/get-team-id-from-slug' +import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger' +import { getPublicErrorMessage } from '@/core/shared/errors' import { TeamMetricsRequestSchema, type TeamMetricsResponse } from './types' export async function POST( request: Request, - { params }: { params: Promise<{ teamId: string }> } + { params }: { params: Promise<{ teamSlug: string }> } ) { try { - const { teamId } = await params + const { teamSlug } = await params const parsedInput = TeamMetricsRequestSchema.safeParse(await request.json()) @@ -20,8 +21,8 @@ export async function POST( l.warn( { key: 'team_metrics_route_handler:invalid_request', - error: serializeError(parsedInput.error), - team_id: teamId, + error: serializeErrorForLog(parsedInput.error), + team_slug: teamSlug, context: { request: parsedInput.data, }, @@ -40,7 +41,7 @@ export async function POST( l.warn( { key: 'team_metrics_route_handler:unauthenticated', - team_id: teamId, + team_slug: teamSlug, }, 'team_metrics_route_handler: unauthenticated' ) @@ -48,6 +49,21 @@ export async function POST( return Response.json({ error: 'Unauthenticated' }, { status: 401 }) } + const teamId = await getTeamIdFromSlug(teamSlug, session.access_token) + + if (!teamId) { + l.warn( + { + key: 'team_metrics_route_handler:forbidden_team', + team_slug: teamSlug, + user_id: session.user.id, + }, + 'team_metrics_route_handler: forbidden team' + ) + + return Response.json({ error: 'Forbidden' }, { status: 403 }) + } + const result = await getTeamMetricsCore({ accessToken: session.access_token, teamId, @@ -57,15 +73,15 @@ export async function POST( }) if (result.error) { - // error already logged in core function - return Response.json({ error: result.error }, { status: result.status }) + const safeMessage = getPublicErrorMessage({ status: result.status }) + return Response.json({ error: safeMessage }, { status: result.status }) } return Response.json(result.data! satisfies TeamMetricsResponse) } catch (error) { l.error({ key: 'team_metrics_route_handler:unexpected_error', - error: serializeError(error), + error: serializeErrorForLog(error), }) return Response.json({ error: 'Internal server error' }, { status: 500 }) diff --git a/src/app/api/teams/[teamId]/metrics/types.ts b/src/app/api/teams/[teamSlug]/metrics/types.ts similarity index 93% rename from src/app/api/teams/[teamId]/metrics/types.ts rename to src/app/api/teams/[teamSlug]/metrics/types.ts index b1306f467..d32af3573 100644 --- a/src/app/api/teams/[teamId]/metrics/types.ts +++ b/src/app/api/teams/[teamSlug]/metrics/types.ts @@ -1,6 +1,6 @@ import { z } from 'zod' +import type { ClientTeamMetrics } from '@/core/modules/sandboxes/models.client' import { MAX_DAYS_AGO } from '@/features/dashboard/sandboxes/monitoring/time-picker/constants' -import type { ClientTeamMetrics } from '@/types/sandboxes.types' export const TeamMetricsRequestSchema = z .object({ diff --git a/src/app/api/teams/user/route.ts b/src/app/api/teams/user/route.ts deleted file mode 100644 index d2dbd6066..000000000 --- a/src/app/api/teams/user/route.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { createClient } from '@/lib/clients/supabase/server' -import getUserTeamsMemo from '@/server/team/get-user-teams-memo' -import type { UserTeamsResponse } from './types' - -export async function GET() { - try { - const supabase = await createClient() - const { - data: { user }, - error, - } = await supabase.auth.getUser() - - if (error || !user) { - return Response.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const teams = await getUserTeamsMemo(user) - - return Response.json({ teams } satisfies UserTeamsResponse) - } catch (error) { - if ( - error instanceof Error && - error.message.includes('During prerendering') - ) { - throw error - } - - console.error('Error fetching user teams:', error) - return Response.json({ error: 'Internal server error' }, { status: 500 }) - } -} diff --git a/src/app/api/teams/user/types.ts b/src/app/api/teams/user/types.ts deleted file mode 100644 index 60b1fc766..000000000 --- a/src/app/api/teams/user/types.ts +++ /dev/null @@ -1,3 +0,0 @@ -import type { ClientTeam } from '@/types/dashboard.types' - -export type UserTeamsResponse = { teams: ClientTeam[] } diff --git a/src/app/api/trpc/[trpc]/route.ts b/src/app/api/trpc/[trpc]/route.ts index 303157cac..ee154b2c6 100644 --- a/src/app/api/trpc/[trpc]/route.ts +++ b/src/app/api/trpc/[trpc]/route.ts @@ -1,8 +1,8 @@ import { fetchRequestHandler } from '@trpc/server/adapters/fetch' import type { NextRequest } from 'next/server' -import { createTRPCContext } from '@/server/api/init' -import { trpcAppRouter } from '@/server/api/routers' +import { trpcAppRouter } from '@/core/server/api/routers' +import { createTRPCContext } from '@/core/server/trpc/init' /** * This wraps the `createTRPCContext` helper and provides the required context for the tRPC API when diff --git a/src/app/dashboard/(resolvers)/inspect/sandbox/[sandboxId]/route.ts b/src/app/dashboard/(resolvers)/inspect/sandbox/[sandboxId]/route.ts index 08d788488..cc88a8310 100644 --- a/src/app/dashboard/(resolvers)/inspect/sandbox/[sandboxId]/route.ts +++ b/src/app/dashboard/(resolvers)/inspect/sandbox/[sandboxId]/route.ts @@ -1,14 +1,13 @@ import { cookies } from 'next/headers' import { type NextRequest, NextResponse } from 'next/server' -import { serializeError } from 'serialize-error' import { SUPABASE_AUTH_HEADERS } from '@/configs/api' import { COOKIE_KEYS } from '@/configs/cookies' import { AUTH_URLS, PROTECTED_URLS } from '@/configs/urls' -import { infra } from '@/lib/clients/api' -import { l } from '@/lib/clients/logger/logger' -import { supabaseAdmin } from '@/lib/clients/supabase/admin' -import { createClient } from '@/lib/clients/supabase/server' -import { SandboxIdSchema } from '@/lib/schemas/api' +import { createUserTeamsRepository } from '@/core/modules/teams/user-teams-repository.server' +import { infra } from '@/core/shared/clients/api' +import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger' +import { createClient } from '@/core/shared/clients/supabase/server' +import { SandboxIdSchema } from '@/core/shared/schemas/api' import { setTeamCookies } from '@/lib/utils/cookies' export const dynamic = 'force-dynamic' @@ -159,17 +158,16 @@ export async function GET( } const accessToken = sessionResponse.session.access_token - const { data: userTeamRows, error: teamQueryError } = await supabaseAdmin - .from('users_teams') - .select('teams!inner(id, slug)') - .eq('user_id', userId) + const teamsResult = await createUserTeamsRepository({ + accessToken, + }).listUserTeams() - if (teamQueryError || !userTeamRows || userTeamRows.length === 0) { + if (!teamsResult.ok || teamsResult.data.length === 0) { l.warn({ key: 'inspect_sandbox:teams_fetch_error', user_id: userId, sandbox_id: sandboxId, - error: teamQueryError, + error: !teamsResult.ok ? teamsResult.error : undefined, }) return redirectToDashboardWithWarning( @@ -182,9 +180,9 @@ export async function GET( ) } - const userTeams: UserTeam[] = userTeamRows.map((row) => ({ - id: row.teams.id, - slug: row.teams.slug, + const userTeams: UserTeam[] = teamsResult.data.map((team) => ({ + id: team.id, + slug: team.slug, })) const cookieStore = await cookies() @@ -231,7 +229,7 @@ export async function GET( return NextResponse.redirect(redirectUrl) } catch (error) { - const serializedError = serializeError(error) + const serializedError = serializeErrorForLog(error) const errorMessage = typeof serializedError === 'object' && serializedError !== null && diff --git a/src/app/dashboard/[teamIdOrSlug]/layout.tsx b/src/app/dashboard/[teamIdOrSlug]/layout.tsx deleted file mode 100644 index 624d2444a..000000000 --- a/src/app/dashboard/[teamIdOrSlug]/layout.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import { cookies } from 'next/headers' -import { redirect, unauthorized } from 'next/navigation' -import type { Metadata } from 'next/types' -import { serializeError } from 'serialize-error' -import { COOKIE_KEYS } from '@/configs/cookies' -import { METADATA } from '@/configs/metadata' -import { AUTH_URLS } from '@/configs/urls' -import { DashboardContextProvider } from '@/features/dashboard/context' -import DashboardLayoutView from '@/features/dashboard/layouts/layout' -import Sidebar from '@/features/dashboard/sidebar/sidebar' -import { l } from '@/lib/clients/logger/logger' -import { getSessionInsecure } from '@/server/auth/get-session' -import getUserByToken from '@/server/auth/get-user-by-token' -import { getTeam } from '@/server/team/get-team' -import { SidebarInset, SidebarProvider } from '@/ui/primitives/sidebar' - -export const metadata: Metadata = { - title: 'Dashboard - E2B', - description: METADATA.description, - openGraph: METADATA.openGraph, - twitter: METADATA.twitter, - robots: 'noindex, nofollow', -} - -export interface DashboardLayoutProps { - params: Promise<{ - teamIdOrSlug: string - }> - children: React.ReactNode -} - -export default async function DashboardLayout({ - children, - params, -}: DashboardLayoutProps) { - const cookieStore = await cookies() - const { teamIdOrSlug } = await params - - const session = await getSessionInsecure() - const { error, data } = await getUserByToken(session?.access_token) - - const sidebarState = cookieStore.get(COOKIE_KEYS.SIDEBAR_STATE)?.value - const defaultOpen = sidebarState === 'true' - - if (error || !data.user) { - throw redirect(AUTH_URLS.SIGN_IN) - } - - const teamRes = await getTeam({ teamIdOrSlug }) - const team = teamRes?.data - - if (!team) { - l.warn( - { - key: 'dashboard_layout:team_not_resolved', - user_id: data.user.id, - error: serializeError(teamRes?.serverError), - context: { - teamIdOrSlug, - }, - }, - `dashboard_layout:team_not_resolved - team not resolved for user (${data.user.id}) when accessing team (${teamIdOrSlug}) in dashboard layout` - ) - throw unauthorized() - } - - return ( - - -
-
- - - - {children} - - -
-
-
-
- ) -} diff --git a/src/app/dashboard/[teamIdOrSlug]/sandboxes/[sandboxId]/logs/page.tsx b/src/app/dashboard/[teamIdOrSlug]/sandboxes/[sandboxId]/logs/page.tsx deleted file mode 100644 index d42f68b53..000000000 --- a/src/app/dashboard/[teamIdOrSlug]/sandboxes/[sandboxId]/logs/page.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import SandboxLogsView from '@/features/dashboard/sandbox/logs/view' - -export default async function SandboxLogsPage({ - params, -}: { - params: Promise<{ teamIdOrSlug: string; sandboxId: string }> -}) { - const { teamIdOrSlug, sandboxId } = await params - - return -} diff --git a/src/app/dashboard/[teamIdOrSlug]/account/page.tsx b/src/app/dashboard/[teamSlug]/account/page.tsx similarity index 100% rename from src/app/dashboard/[teamIdOrSlug]/account/page.tsx rename to src/app/dashboard/[teamSlug]/account/page.tsx diff --git a/src/app/dashboard/[teamIdOrSlug]/billing/page.tsx b/src/app/dashboard/[teamSlug]/billing/page.tsx similarity index 63% rename from src/app/dashboard/[teamIdOrSlug]/billing/page.tsx rename to src/app/dashboard/[teamSlug]/billing/page.tsx index 72ab6dbd7..4c7111e69 100644 --- a/src/app/dashboard/[teamIdOrSlug]/billing/page.tsx +++ b/src/app/dashboard/[teamSlug]/billing/page.tsx @@ -6,13 +6,13 @@ import { HydrateClient, prefetch, trpc } from '@/trpc/server' export default async function BillingPage({ params, }: { - params: Promise<{ teamIdOrSlug: string }> + params: Promise<{ teamSlug: string }> }) { - const { teamIdOrSlug } = await params + const { teamSlug } = await params - prefetch(trpc.billing.getItems.queryOptions({ teamIdOrSlug })) - prefetch(trpc.billing.getUsage.queryOptions({ teamIdOrSlug })) - prefetch(trpc.billing.getInvoices.queryOptions({ teamIdOrSlug })) + prefetch(trpc.billing.getItems.queryOptions({ teamSlug })) + prefetch(trpc.billing.getUsage.queryOptions({ teamSlug })) + prefetch(trpc.billing.getInvoices.queryOptions({ teamSlug })) return ( diff --git a/src/app/dashboard/[teamIdOrSlug]/billing/plan/page.tsx b/src/app/dashboard/[teamSlug]/billing/plan/page.tsx similarity index 65% rename from src/app/dashboard/[teamIdOrSlug]/billing/plan/page.tsx rename to src/app/dashboard/[teamSlug]/billing/plan/page.tsx index 35d6b76b0..ee3f5f824 100644 --- a/src/app/dashboard/[teamIdOrSlug]/billing/plan/page.tsx +++ b/src/app/dashboard/[teamSlug]/billing/plan/page.tsx @@ -5,12 +5,11 @@ import { HydrateClient, prefetch, trpc } from '@/trpc/server' export default async function BillingPlanPage({ params, }: { - params: Promise<{ teamIdOrSlug: string }> + params: Promise<{ teamSlug: string }> }) { - const { teamIdOrSlug } = await params + const { teamSlug } = await params - prefetch(trpc.billing.getItems.queryOptions({ teamIdOrSlug })) - prefetch(trpc.billing.getTeamLimits.queryOptions({ teamIdOrSlug })) + prefetch(trpc.billing.getItems.queryOptions({ teamSlug })) return ( diff --git a/src/app/dashboard/[teamIdOrSlug]/billing/plan/select/page.tsx b/src/app/dashboard/[teamSlug]/billing/plan/select/page.tsx similarity index 69% rename from src/app/dashboard/[teamIdOrSlug]/billing/plan/select/page.tsx rename to src/app/dashboard/[teamSlug]/billing/plan/select/page.tsx index a3b3130ce..18a0014bb 100644 --- a/src/app/dashboard/[teamIdOrSlug]/billing/plan/select/page.tsx +++ b/src/app/dashboard/[teamSlug]/billing/plan/select/page.tsx @@ -4,11 +4,11 @@ import { HydrateClient, prefetch, trpc } from '@/trpc/server' export default async function BillingPlanSelectPage({ params, }: { - params: Promise<{ teamIdOrSlug: string }> + params: Promise<{ teamSlug: string }> }) { - const { teamIdOrSlug } = await params + const { teamSlug } = await params - prefetch(trpc.billing.getItems.queryOptions({ teamIdOrSlug })) + prefetch(trpc.billing.getItems.queryOptions({ teamSlug })) return ( diff --git a/src/app/dashboard/[teamIdOrSlug]/general/page.tsx b/src/app/dashboard/[teamSlug]/general/page.tsx similarity index 97% rename from src/app/dashboard/[teamIdOrSlug]/general/page.tsx rename to src/app/dashboard/[teamSlug]/general/page.tsx index 75a409635..60519292a 100644 --- a/src/app/dashboard/[teamIdOrSlug]/general/page.tsx +++ b/src/app/dashboard/[teamSlug]/general/page.tsx @@ -5,7 +5,7 @@ import Frame from '@/ui/frame' interface GeneralPageProps { params: Promise<{ - teamIdOrSlug: string + teamSlug: string }> } diff --git a/src/app/dashboard/[teamIdOrSlug]/keys/page.tsx b/src/app/dashboard/[teamSlug]/keys/page.tsx similarity index 98% rename from src/app/dashboard/[teamIdOrSlug]/keys/page.tsx rename to src/app/dashboard/[teamSlug]/keys/page.tsx index 9589fdc82..7acc106ef 100644 --- a/src/app/dashboard/[teamIdOrSlug]/keys/page.tsx +++ b/src/app/dashboard/[teamSlug]/keys/page.tsx @@ -13,7 +13,7 @@ import { interface KeysPageClientProps { params: Promise<{ - teamIdOrSlug: string + teamSlug: string }> } diff --git a/src/app/dashboard/[teamSlug]/layout.tsx b/src/app/dashboard/[teamSlug]/layout.tsx new file mode 100644 index 000000000..eaf3ac776 --- /dev/null +++ b/src/app/dashboard/[teamSlug]/layout.tsx @@ -0,0 +1,72 @@ +import { cookies } from 'next/headers' +import { redirect } from 'next/navigation' +import type { Metadata } from 'next/types' +import { DashboardTeamGate } from '@/app/dashboard/[teamSlug]/team-gate' +import { COOKIE_KEYS } from '@/configs/cookies' +import { METADATA } from '@/configs/metadata' +import { AUTH_URLS } from '@/configs/urls' +import { DASHBOARD_TEAMS_LIST_QUERY_OPTIONS } from '@/core/application/teams/queries' +import { getSessionInsecure } from '@/core/server/functions/auth/get-session' +import getUserByToken from '@/core/server/functions/auth/get-user-by-token' +import DashboardLayoutView from '@/features/dashboard/layouts/layout' +import Sidebar from '@/features/dashboard/sidebar/sidebar' +import { HydrateClient, prefetchAsync, trpc } from '@/trpc/server' +import { SidebarInset, SidebarProvider } from '@/ui/primitives/sidebar' + +export const metadata: Metadata = { + title: 'Dashboard - E2B', + description: METADATA.description, + openGraph: METADATA.openGraph, + twitter: METADATA.twitter, + robots: 'noindex, nofollow', +} + +export interface DashboardLayoutProps { + params: Promise<{ + teamSlug: string + }> + children: React.ReactNode +} + +export default async function DashboardLayout({ + children, + params, +}: DashboardLayoutProps) { + const cookieStore = await cookies() + const { teamSlug } = await params + + const session = await getSessionInsecure() + const { error, data } = await getUserByToken(session?.access_token) + + const sidebarState = cookieStore.get(COOKIE_KEYS.SIDEBAR_STATE)?.value + const defaultOpen = sidebarState === 'true' + + if (error || !data.user) { + throw redirect(AUTH_URLS.SIGN_IN) + } + + await prefetchAsync( + trpc.teams.list.queryOptions(undefined, DASHBOARD_TEAMS_LIST_QUERY_OPTIONS) + ) + + return ( + + + +
+
+ + + + {children} + + +
+
+
+
+
+ ) +} diff --git a/src/app/dashboard/[teamIdOrSlug]/limits/loading.tsx b/src/app/dashboard/[teamSlug]/limits/loading.tsx similarity index 100% rename from src/app/dashboard/[teamIdOrSlug]/limits/loading.tsx rename to src/app/dashboard/[teamSlug]/limits/loading.tsx diff --git a/src/app/dashboard/[teamIdOrSlug]/limits/page.tsx b/src/app/dashboard/[teamSlug]/limits/page.tsx similarity index 77% rename from src/app/dashboard/[teamIdOrSlug]/limits/page.tsx rename to src/app/dashboard/[teamSlug]/limits/page.tsx index e27873368..e97b7f7b9 100644 --- a/src/app/dashboard/[teamIdOrSlug]/limits/page.tsx +++ b/src/app/dashboard/[teamSlug]/limits/page.tsx @@ -3,13 +3,13 @@ import { HydrateClient, prefetch, trpc } from '@/trpc/server' import Frame from '@/ui/frame' interface LimitsPageProps { - params: Promise<{ teamIdOrSlug: string }> + params: Promise<{ teamSlug: string }> } export default async function LimitsPage({ params }: LimitsPageProps) { - const { teamIdOrSlug } = await params + const { teamSlug } = await params - prefetch(trpc.billing.getLimits.queryOptions({ teamIdOrSlug })) + prefetch(trpc.billing.getLimits.queryOptions({ teamSlug })) return ( diff --git a/src/app/dashboard/[teamIdOrSlug]/members/page.tsx b/src/app/dashboard/[teamSlug]/members/page.tsx similarity index 95% rename from src/app/dashboard/[teamIdOrSlug]/members/page.tsx rename to src/app/dashboard/[teamSlug]/members/page.tsx index c15f5ba6e..6ce32e938 100644 --- a/src/app/dashboard/[teamIdOrSlug]/members/page.tsx +++ b/src/app/dashboard/[teamSlug]/members/page.tsx @@ -3,7 +3,7 @@ import Frame from '@/ui/frame' interface MembersPageProps { params: Promise<{ - teamIdOrSlug: string + teamSlug: string }> } diff --git a/src/app/dashboard/[teamIdOrSlug]/not-found.tsx b/src/app/dashboard/[teamSlug]/not-found.tsx similarity index 100% rename from src/app/dashboard/[teamIdOrSlug]/not-found.tsx rename to src/app/dashboard/[teamSlug]/not-found.tsx diff --git a/src/app/dashboard/[teamIdOrSlug]/sandboxes/(tabs)/@list/default.tsx b/src/app/dashboard/[teamSlug]/sandboxes/(tabs)/@list/default.tsx similarity index 100% rename from src/app/dashboard/[teamIdOrSlug]/sandboxes/(tabs)/@list/default.tsx rename to src/app/dashboard/[teamSlug]/sandboxes/(tabs)/@list/default.tsx diff --git a/src/app/dashboard/[teamIdOrSlug]/sandboxes/(tabs)/@list/page.tsx b/src/app/dashboard/[teamSlug]/sandboxes/(tabs)/@list/page.tsx similarity index 81% rename from src/app/dashboard/[teamIdOrSlug]/sandboxes/(tabs)/@list/page.tsx rename to src/app/dashboard/[teamSlug]/sandboxes/(tabs)/@list/page.tsx index 2ecbacc8d..b33a23310 100644 --- a/src/app/dashboard/[teamIdOrSlug]/sandboxes/(tabs)/@list/page.tsx +++ b/src/app/dashboard/[teamSlug]/sandboxes/(tabs)/@list/page.tsx @@ -5,12 +5,12 @@ import { HydrateClient, prefetch, trpc } from '@/trpc/server' export default async function ListPage({ params, -}: PageProps<'/dashboard/[teamIdOrSlug]/sandboxes'>) { - const { teamIdOrSlug } = await params +}: PageProps<'/dashboard/[teamSlug]/sandboxes'>) { + const { teamSlug } = await params prefetch( trpc.sandboxes.getSandboxes.queryOptions({ - teamIdOrSlug, + teamSlug, }) ) diff --git a/src/app/dashboard/[teamIdOrSlug]/sandboxes/(tabs)/@monitoring/default.tsx b/src/app/dashboard/[teamSlug]/sandboxes/(tabs)/@monitoring/default.tsx similarity index 100% rename from src/app/dashboard/[teamIdOrSlug]/sandboxes/(tabs)/@monitoring/default.tsx rename to src/app/dashboard/[teamSlug]/sandboxes/(tabs)/@monitoring/default.tsx diff --git a/src/app/dashboard/[teamIdOrSlug]/sandboxes/(tabs)/@monitoring/page.tsx b/src/app/dashboard/[teamSlug]/sandboxes/(tabs)/@monitoring/page.tsx similarity index 92% rename from src/app/dashboard/[teamIdOrSlug]/sandboxes/(tabs)/@monitoring/page.tsx rename to src/app/dashboard/[teamSlug]/sandboxes/(tabs)/@monitoring/page.tsx index 498cf481b..a80be0b05 100644 --- a/src/app/dashboard/[teamIdOrSlug]/sandboxes/(tabs)/@monitoring/page.tsx +++ b/src/app/dashboard/[teamSlug]/sandboxes/(tabs)/@monitoring/page.tsx @@ -4,7 +4,7 @@ import SandboxesMonitoringHeader from '@/features/dashboard/sandboxes/monitoring export default async function MonitoringPage({ params, searchParams, -}: PageProps<'/dashboard/[teamIdOrSlug]/sandboxes'> & { +}: PageProps<'/dashboard/[teamSlug]/sandboxes'> & { searchParams: Promise<{ start?: string; end?: string }> }) { return ( diff --git a/src/app/dashboard/[teamIdOrSlug]/sandboxes/(tabs)/layout.tsx b/src/app/dashboard/[teamSlug]/sandboxes/(tabs)/layout.tsx similarity index 92% rename from src/app/dashboard/[teamIdOrSlug]/sandboxes/(tabs)/layout.tsx rename to src/app/dashboard/[teamSlug]/sandboxes/(tabs)/layout.tsx index bb7d6a5ce..ef434b39e 100644 --- a/src/app/dashboard/[teamIdOrSlug]/sandboxes/(tabs)/layout.tsx +++ b/src/app/dashboard/[teamSlug]/sandboxes/(tabs)/layout.tsx @@ -4,7 +4,7 @@ import { ListIcon, TrendIcon } from '@/ui/primitives/icons' export default function SandboxesLayout({ list, monitoring, -}: LayoutProps<'/dashboard/[teamIdOrSlug]/sandboxes'> & { +}: LayoutProps<'/dashboard/[teamSlug]/sandboxes'> & { list: React.ReactNode monitoring: React.ReactNode }) { diff --git a/src/app/dashboard/[teamIdOrSlug]/sandboxes/(tabs)/page.tsx b/src/app/dashboard/[teamSlug]/sandboxes/(tabs)/page.tsx similarity index 100% rename from src/app/dashboard/[teamIdOrSlug]/sandboxes/(tabs)/page.tsx rename to src/app/dashboard/[teamSlug]/sandboxes/(tabs)/page.tsx diff --git a/src/app/dashboard/[teamIdOrSlug]/sandboxes/[sandboxId]/filesystem/loading.tsx b/src/app/dashboard/[teamSlug]/sandboxes/[sandboxId]/filesystem/loading.tsx similarity index 100% rename from src/app/dashboard/[teamIdOrSlug]/sandboxes/[sandboxId]/filesystem/loading.tsx rename to src/app/dashboard/[teamSlug]/sandboxes/[sandboxId]/filesystem/loading.tsx diff --git a/src/app/dashboard/[teamIdOrSlug]/sandboxes/[sandboxId]/filesystem/page.tsx b/src/app/dashboard/[teamSlug]/sandboxes/[sandboxId]/filesystem/page.tsx similarity index 100% rename from src/app/dashboard/[teamIdOrSlug]/sandboxes/[sandboxId]/filesystem/page.tsx rename to src/app/dashboard/[teamSlug]/sandboxes/[sandboxId]/filesystem/page.tsx diff --git a/src/app/dashboard/[teamIdOrSlug]/sandboxes/[sandboxId]/layout.tsx b/src/app/dashboard/[teamSlug]/sandboxes/[sandboxId]/layout.tsx similarity index 85% rename from src/app/dashboard/[teamIdOrSlug]/sandboxes/[sandboxId]/layout.tsx rename to src/app/dashboard/[teamSlug]/sandboxes/[sandboxId]/layout.tsx index 8ee3d6715..238eb10af 100644 --- a/src/app/dashboard/[teamIdOrSlug]/sandboxes/[sandboxId]/layout.tsx +++ b/src/app/dashboard/[teamSlug]/sandboxes/[sandboxId]/layout.tsx @@ -6,18 +6,18 @@ import { prefetch, trpc } from '@/trpc/server' interface SandboxLayoutProps { children: React.ReactNode - params: Promise<{ teamIdOrSlug: string; sandboxId: string }> + params: Promise<{ teamSlug: string; sandboxId: string }> } export default async function SandboxLayout({ children, params, }: SandboxLayoutProps) { - const { teamIdOrSlug, sandboxId } = await params + const { teamSlug, sandboxId } = await params prefetch( trpc.sandbox.details.queryOptions( - { teamIdOrSlug, sandboxId }, + { teamSlug, sandboxId }, { retry: false, staleTime: Number.POSITIVE_INFINITY, diff --git a/src/app/dashboard/[teamIdOrSlug]/sandboxes/[sandboxId]/logs/loading.tsx b/src/app/dashboard/[teamSlug]/sandboxes/[sandboxId]/logs/loading.tsx similarity index 100% rename from src/app/dashboard/[teamIdOrSlug]/sandboxes/[sandboxId]/logs/loading.tsx rename to src/app/dashboard/[teamSlug]/sandboxes/[sandboxId]/logs/loading.tsx diff --git a/src/app/dashboard/[teamSlug]/sandboxes/[sandboxId]/logs/page.tsx b/src/app/dashboard/[teamSlug]/sandboxes/[sandboxId]/logs/page.tsx new file mode 100644 index 000000000..8b352b588 --- /dev/null +++ b/src/app/dashboard/[teamSlug]/sandboxes/[sandboxId]/logs/page.tsx @@ -0,0 +1,11 @@ +import SandboxLogsView from '@/features/dashboard/sandbox/logs/view' + +export default async function SandboxLogsPage({ + params, +}: { + params: Promise<{ teamSlug: string; sandboxId: string }> +}) { + const { teamSlug, sandboxId } = await params + + return +} diff --git a/src/app/dashboard/[teamIdOrSlug]/sandboxes/[sandboxId]/monitoring/loading.tsx b/src/app/dashboard/[teamSlug]/sandboxes/[sandboxId]/monitoring/loading.tsx similarity index 100% rename from src/app/dashboard/[teamIdOrSlug]/sandboxes/[sandboxId]/monitoring/loading.tsx rename to src/app/dashboard/[teamSlug]/sandboxes/[sandboxId]/monitoring/loading.tsx diff --git a/src/app/dashboard/[teamIdOrSlug]/sandboxes/[sandboxId]/monitoring/page.tsx b/src/app/dashboard/[teamSlug]/sandboxes/[sandboxId]/monitoring/page.tsx similarity index 81% rename from src/app/dashboard/[teamIdOrSlug]/sandboxes/[sandboxId]/monitoring/page.tsx rename to src/app/dashboard/[teamSlug]/sandboxes/[sandboxId]/monitoring/page.tsx index 2b0dbc4b2..4d8755ee6 100644 --- a/src/app/dashboard/[teamIdOrSlug]/sandboxes/[sandboxId]/monitoring/page.tsx +++ b/src/app/dashboard/[teamSlug]/sandboxes/[sandboxId]/monitoring/page.tsx @@ -3,7 +3,7 @@ import SandboxMonitoringView from '@/features/dashboard/sandbox/monitoring/compo export default async function SandboxMonitoringPage({ params, }: { - params: Promise<{ teamIdOrSlug: string; sandboxId: string }> + params: Promise<{ teamSlug: string; sandboxId: string }> }) { const { sandboxId } = await params diff --git a/src/app/dashboard/[teamSlug]/team-gate.tsx b/src/app/dashboard/[teamSlug]/team-gate.tsx new file mode 100644 index 000000000..8e4cca6f4 --- /dev/null +++ b/src/app/dashboard/[teamSlug]/team-gate.tsx @@ -0,0 +1,47 @@ +'use client' + +import type { User } from '@supabase/supabase-js' +import { useQuery } from '@tanstack/react-query' +import { DASHBOARD_TEAMS_LIST_QUERY_OPTIONS } from '@/core/application/teams/queries' +import { DashboardContextProvider } from '@/features/dashboard/context' +import LoadingLayout from '@/features/dashboard/loading-layout' +import { useTRPC } from '@/trpc/client' +import Unauthorized from '../unauthorized' + +interface DashboardTeamGateProps { + teamSlug: string + user: User + children: React.ReactNode +} + +export function DashboardTeamGate({ + teamSlug, + user, + children, +}: DashboardTeamGateProps) { + const trpc = useTRPC() + + const { data: teams, isPending } = useQuery( + trpc.teams.list.queryOptions(undefined, DASHBOARD_TEAMS_LIST_QUERY_OPTIONS) + ) + + if (isPending) { + return + } + + const team = teams?.find((candidate) => candidate.slug === teamSlug) + + if (!team || !teams) { + return + } + + return ( + + {children} + + ) +} diff --git a/src/app/dashboard/[teamIdOrSlug]/templates/(tabs)/@builds/default.tsx b/src/app/dashboard/[teamSlug]/templates/(tabs)/@builds/default.tsx similarity index 100% rename from src/app/dashboard/[teamIdOrSlug]/templates/(tabs)/@builds/default.tsx rename to src/app/dashboard/[teamSlug]/templates/(tabs)/@builds/default.tsx diff --git a/src/app/dashboard/[teamIdOrSlug]/templates/(tabs)/@builds/page.tsx b/src/app/dashboard/[teamSlug]/templates/(tabs)/@builds/page.tsx similarity index 100% rename from src/app/dashboard/[teamIdOrSlug]/templates/(tabs)/@builds/page.tsx rename to src/app/dashboard/[teamSlug]/templates/(tabs)/@builds/page.tsx diff --git a/src/app/dashboard/[teamIdOrSlug]/templates/(tabs)/@list/default.tsx b/src/app/dashboard/[teamSlug]/templates/(tabs)/@list/default.tsx similarity index 100% rename from src/app/dashboard/[teamIdOrSlug]/templates/(tabs)/@list/default.tsx rename to src/app/dashboard/[teamSlug]/templates/(tabs)/@list/default.tsx diff --git a/src/app/dashboard/[teamIdOrSlug]/templates/(tabs)/@list/page.tsx b/src/app/dashboard/[teamSlug]/templates/(tabs)/@list/page.tsx similarity index 100% rename from src/app/dashboard/[teamIdOrSlug]/templates/(tabs)/@list/page.tsx rename to src/app/dashboard/[teamSlug]/templates/(tabs)/@list/page.tsx diff --git a/src/app/dashboard/[teamIdOrSlug]/templates/(tabs)/layout.tsx b/src/app/dashboard/[teamSlug]/templates/(tabs)/layout.tsx similarity index 92% rename from src/app/dashboard/[teamIdOrSlug]/templates/(tabs)/layout.tsx rename to src/app/dashboard/[teamSlug]/templates/(tabs)/layout.tsx index 4e7debefb..292e65e25 100644 --- a/src/app/dashboard/[teamIdOrSlug]/templates/(tabs)/layout.tsx +++ b/src/app/dashboard/[teamSlug]/templates/(tabs)/layout.tsx @@ -4,7 +4,7 @@ import { BuildIcon, ListIcon } from '@/ui/primitives/icons' export default function TemplatesLayout({ list, builds, -}: LayoutProps<'/dashboard/[teamIdOrSlug]/templates'> & { +}: LayoutProps<'/dashboard/[teamSlug]/templates'> & { list: React.ReactNode builds: React.ReactNode }) { diff --git a/src/app/dashboard/[teamIdOrSlug]/templates/[templateId]/builds/[buildId]/loading.tsx b/src/app/dashboard/[teamSlug]/templates/[templateId]/builds/[buildId]/loading.tsx similarity index 100% rename from src/app/dashboard/[teamIdOrSlug]/templates/[templateId]/builds/[buildId]/loading.tsx rename to src/app/dashboard/[teamSlug]/templates/[templateId]/builds/[buildId]/loading.tsx diff --git a/src/app/dashboard/[teamIdOrSlug]/templates/[templateId]/builds/[buildId]/page.tsx b/src/app/dashboard/[teamSlug]/templates/[templateId]/builds/[buildId]/page.tsx similarity index 87% rename from src/app/dashboard/[teamIdOrSlug]/templates/[templateId]/builds/[buildId]/page.tsx rename to src/app/dashboard/[teamSlug]/templates/[templateId]/builds/[buildId]/page.tsx index 6866a84d1..3dd525bce 100644 --- a/src/app/dashboard/[teamIdOrSlug]/templates/[templateId]/builds/[buildId]/page.tsx +++ b/src/app/dashboard/[teamSlug]/templates/[templateId]/builds/[buildId]/page.tsx @@ -12,8 +12,8 @@ const REFETCH_INTERVAL_MS = 1_500 export default function BuildPage({ params, -}: PageProps<'/dashboard/[teamIdOrSlug]/templates/[templateId]/builds/[buildId]'>) { - const { teamIdOrSlug, templateId, buildId } = use(params) +}: PageProps<'/dashboard/[teamSlug]/templates/[templateId]/builds/[buildId]'>) { + const { teamSlug, templateId, buildId } = use(params) const trpc = useTRPC() const { @@ -22,7 +22,7 @@ export default function BuildPage({ isPending, } = useQuery( trpc.builds.buildDetails.queryOptions( - { teamIdOrSlug, templateId, buildId }, + { teamSlug, templateId, buildId }, { refetchIntervalInBackground: false, refetchOnWindowFocus: ({ state }) => @@ -55,7 +55,7 @@ export default function BuildPage({ /> diff --git a/src/app/dashboard/[teamIdOrSlug]/usage/loading.tsx b/src/app/dashboard/[teamSlug]/usage/loading.tsx similarity index 100% rename from src/app/dashboard/[teamIdOrSlug]/usage/loading.tsx rename to src/app/dashboard/[teamSlug]/usage/loading.tsx diff --git a/src/app/dashboard/[teamIdOrSlug]/usage/page.tsx b/src/app/dashboard/[teamSlug]/usage/page.tsx similarity index 91% rename from src/app/dashboard/[teamIdOrSlug]/usage/page.tsx rename to src/app/dashboard/[teamSlug]/usage/page.tsx index 220c0c330..aa80e4d88 100644 --- a/src/app/dashboard/[teamIdOrSlug]/usage/page.tsx +++ b/src/app/dashboard/[teamSlug]/usage/page.tsx @@ -1,16 +1,16 @@ +import { getUsage } from '@/core/server/functions/usage/get-usage' import { UsageChartsProvider } from '@/features/dashboard/usage/usage-charts-context' import { UsageMetricChart } from '@/features/dashboard/usage/usage-metric-chart' -import { getUsage } from '@/server/usage/get-usage' import ErrorBoundary from '@/ui/error' import Frame from '@/ui/frame' export default async function UsagePage({ params, }: { - params: Promise<{ teamIdOrSlug: string }> + params: Promise<{ teamSlug: string }> }) { - const { teamIdOrSlug } = await params - const result = await getUsage({ teamIdOrSlug }) + const { teamSlug } = await params + const result = await getUsage({ teamSlug }) if (!result?.data || result.serverError || result.validationErrors) { return ( diff --git a/src/app/dashboard/[teamIdOrSlug]/webhooks/page.tsx b/src/app/dashboard/[teamSlug]/webhooks/page.tsx similarity index 98% rename from src/app/dashboard/[teamIdOrSlug]/webhooks/page.tsx rename to src/app/dashboard/[teamSlug]/webhooks/page.tsx index 911f43ab1..11146c781 100644 --- a/src/app/dashboard/[teamIdOrSlug]/webhooks/page.tsx +++ b/src/app/dashboard/[teamSlug]/webhooks/page.tsx @@ -14,7 +14,7 @@ import { interface WebhooksPageClientProps { params: Promise<{ - teamIdOrSlug: string + teamSlug: string }> } diff --git a/src/app/dashboard/account/route.ts b/src/app/dashboard/account/route.ts index 99b96ad44..0267ce345 100644 --- a/src/app/dashboard/account/route.ts +++ b/src/app/dashboard/account/route.ts @@ -1,9 +1,10 @@ import { type NextRequest, NextResponse } from 'next/server' import { AUTH_URLS, PROTECTED_URLS } from '@/configs/urls' -import { createClient } from '@/lib/clients/supabase/server' +import { getSessionInsecure } from '@/core/server/functions/auth/get-session' +import { resolveUserTeam } from '@/core/server/functions/team/resolve-user-team' +import { createClient } from '@/core/shared/clients/supabase/server' import { encodedRedirect } from '@/lib/utils/auth' import { setTeamCookies } from '@/lib/utils/cookies' -import { resolveUserTeam } from '@/server/team/resolve-user-team' export async function GET(request: NextRequest) { const supabase = await createClient() @@ -13,11 +14,15 @@ export async function GET(request: NextRequest) { return NextResponse.redirect(new URL(AUTH_URLS.SIGN_IN, request.url)) } - // Resolve team for the user - const team = await resolveUserTeam(data.user.id) + const session = await getSessionInsecure(supabase) + + if (!session) { + return NextResponse.redirect(new URL(AUTH_URLS.SIGN_IN, request.url)) + } + + const team = await resolveUserTeam(session.access_token) if (!team) { - // UNEXPECTED STATE - sign out and redirect to sign-in await supabase.auth.signOut() const signInUrl = new URL(AUTH_URLS.SIGN_IN, request.url) @@ -29,16 +34,11 @@ export async function GET(request: NextRequest) { ) } - // Set team cookies for persistence await setTeamCookies(team.id, team.slug) - // Build redirect URL with team - const redirectPath = PROTECTED_URLS.RESOLVED_ACCOUNT_SETTINGS( - team.slug || team.id - ) + const redirectPath = PROTECTED_URLS.RESOLVED_ACCOUNT_SETTINGS(team.slug) const redirectUrl = new URL(redirectPath, request.url) - // Preserve query parameters (e.g., reauth=1) request.nextUrl.searchParams.forEach((value, key) => { redirectUrl.searchParams.set(key, value) }) diff --git a/src/app/dashboard/route.ts b/src/app/dashboard/route.ts index c0bc9617a..1af7ed56d 100644 --- a/src/app/dashboard/route.ts +++ b/src/app/dashboard/route.ts @@ -1,26 +1,38 @@ import { type NextRequest, NextResponse } from 'next/server' import { AUTH_URLS, PROTECTED_URLS } from '@/configs/urls' -import { createClient } from '@/lib/clients/supabase/server' +import { getSessionInsecure } from '@/core/server/functions/auth/get-session' +import { resolveUserTeam } from '@/core/server/functions/team/resolve-user-team' +import { createClient } from '@/core/shared/clients/supabase/server' import { encodedRedirect } from '@/lib/utils/auth' import { setTeamCookies } from '@/lib/utils/cookies' -import { resolveUserTeam } from '@/server/team/resolve-user-team' - -export const TAB_URL_MAP: Record string> = { - sandboxes: (teamId) => PROTECTED_URLS.SANDBOXES(teamId), - templates: (teamId) => PROTECTED_URLS.TEMPLATES(teamId), - usage: (teamId) => PROTECTED_URLS.USAGE(teamId), - billing: (teamId) => PROTECTED_URLS.BILLING(teamId), - limits: (teamId) => PROTECTED_URLS.LIMITS(teamId), - keys: (teamId) => PROTECTED_URLS.KEYS(teamId), - settings: (teamId) => PROTECTED_URLS.GENERAL(teamId), - team: (teamId) => PROTECTED_URLS.GENERAL(teamId), - general: (teamId) => PROTECTED_URLS.GENERAL(teamId), - members: (teamId) => PROTECTED_URLS.MEMBERS(teamId), + +export const TAB_URL_MAP: Record string> = { + sandboxes: (teamSlug) => PROTECTED_URLS.SANDBOXES(teamSlug), + templates: (teamSlug) => PROTECTED_URLS.TEMPLATES(teamSlug), + usage: (teamSlug) => PROTECTED_URLS.USAGE(teamSlug), + billing: (teamSlug) => PROTECTED_URLS.BILLING(teamSlug), + limits: (teamSlug) => PROTECTED_URLS.LIMITS(teamSlug), + keys: (teamSlug) => PROTECTED_URLS.KEYS(teamSlug), + settings: (teamSlug) => PROTECTED_URLS.GENERAL(teamSlug), + team: (teamSlug) => PROTECTED_URLS.GENERAL(teamSlug), + general: (teamSlug) => PROTECTED_URLS.GENERAL(teamSlug), + members: (teamSlug) => PROTECTED_URLS.MEMBERS(teamSlug), account: (_) => PROTECTED_URLS.ACCOUNT_SETTINGS, personal: (_) => PROTECTED_URLS.ACCOUNT_SETTINGS, - // back compatibility - budget: (teamId) => PROTECTED_URLS.LIMITS(teamId), + budget: (teamSlug) => PROTECTED_URLS.LIMITS(teamSlug), +} + +function getTabRedirectPath(tab: string | null, teamSlug: string) { + if (tab && Object.hasOwn(TAB_URL_MAP, tab)) { + const urlGenerator = TAB_URL_MAP[tab] + + if (urlGenerator) { + return urlGenerator(teamSlug) + } + } + + return PROTECTED_URLS.SANDBOXES(teamSlug) } export async function GET(request: NextRequest) { @@ -35,10 +47,15 @@ export async function GET(request: NextRequest) { return NextResponse.redirect(new URL('/sign-in', request.url)) } - const team = await resolveUserTeam(data.user.id) + const session = await getSessionInsecure(supabase) + + if (!session) { + return NextResponse.redirect(new URL('/sign-in', request.url)) + } + + const team = await resolveUserTeam(session.access_token) if (!team) { - // UNEXPECTED STATE - sign out and redirect to sign-in await supabase.auth.signOut() const signInUrl = new URL(AUTH_URLS.SIGN_IN, request.url) @@ -50,18 +67,12 @@ export async function GET(request: NextRequest) { ) } - // Set team cookies for persistence await setTeamCookies(team.id, team.slug) - // Determine redirect path based on tab parameter - const urlGenerator = tab ? TAB_URL_MAP[tab] : null - const redirectPath = urlGenerator - ? urlGenerator(team.slug || team.id) - : PROTECTED_URLS.SANDBOXES(team.slug || team.id) + const redirectPath = getTabRedirectPath(tab, team.slug) const redirectUrl = new URL(redirectPath, request.url) - // Forward ?support=true query param to auto-open the Contact Support dialog on the target page if (searchParams.get('support') === 'true') { redirectUrl.searchParams.set('support', 'true') } diff --git a/src/app/dashboard/unauthorized.tsx b/src/app/dashboard/unauthorized.tsx index 2d38b35b9..6a6a8fb8f 100644 --- a/src/app/dashboard/unauthorized.tsx +++ b/src/app/dashboard/unauthorized.tsx @@ -59,9 +59,9 @@ export default function Unauthorized() { {/* Background pattern */}
-
- - +
+ +
diff --git a/src/app/sbx/new/route.ts b/src/app/sbx/new/route.ts index 63e6cb3a5..2b655219c 100644 --- a/src/app/sbx/new/route.ts +++ b/src/app/sbx/new/route.ts @@ -1,12 +1,11 @@ import Sandbox from 'e2b' import { type NextRequest, NextResponse } from 'next/server' -import { serializeError } from 'serialize-error' import { SUPABASE_AUTH_HEADERS } from '@/configs/api' import { AUTH_URLS, PROTECTED_URLS } from '@/configs/urls' -import { l } from '@/lib/clients/logger/logger' -import { createClient } from '@/lib/clients/supabase/server' -import { getDefaultTeam } from '@/server/auth/get-default-team' -import { getSessionInsecure } from '@/server/auth/get-session' +import { getSessionInsecure } from '@/core/server/functions/auth/get-session' +import { resolveUserTeam } from '@/core/server/functions/team/resolve-user-team' +import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger' +import { createClient } from '@/core/shared/clients/supabase/server' export const GET = async (req: NextRequest) => { try { @@ -35,17 +34,21 @@ export const GET = async (req: NextRequest) => { ) } - const defaultTeam = await getDefaultTeam(data.user.id) + const team = await resolveUserTeam(session.access_token) + + if (!team) { + return NextResponse.redirect(new URL(req.url).origin) + } const sbx = await Sandbox.create('base', { domain: process.env.NEXT_PUBLIC_E2B_DOMAIN, headers: { - ...SUPABASE_AUTH_HEADERS(session.access_token, defaultTeam.id), + ...SUPABASE_AUTH_HEADERS(session.access_token, team.id), }, }) const filesystemUrl = PROTECTED_URLS.SANDBOX_FILESYSTEM( - defaultTeam.slug, + team.slug, sbx.sandboxId ) @@ -54,7 +57,7 @@ export const GET = async (req: NextRequest) => { l.warn( { key: 'sbx_new:unexpected_error', - error: serializeError(error), + error: serializeErrorForLog(error), }, `sbx_new: unexpected error` ) diff --git a/src/configs/cache.ts b/src/configs/cache.ts index ab0042cca..a37acf7e8 100644 --- a/src/configs/cache.ts +++ b/src/configs/cache.ts @@ -1,23 +1,7 @@ -/** - * Centralized cache tag configuration for Next.js "use cache" directive. - */ export const CACHE_TAGS = { - USER_TEAM_AUTHORIZATION: (userId: string, teamId: string) => - `user-team-authorization-${userId}-${teamId}`, - USER_TEAMS: (userId: string) => `user-teams-${userId}`, - - TEAM_ID_FROM_SEGMENT: (segment: string) => `team-id-from-segment-${segment}`, - TEAM_TIER_LIMITS: (teamId: string) => `team-tier-limits-${teamId}`, - TEAM_TEMPLATES: (teamId: string) => `team-templates-${teamId}`, - TEAM_SANDBOXES_LIST: (teamId: string) => `team-sandboxes-list-${teamId}`, + TEAM_ID_FROM_SLUG: (segment: string) => `team-id-from-slug-${segment}`, TEAM_USAGE: (teamId: string) => `team-usage-${teamId}`, TEAM_API_KEYS: (teamId: string) => `team-api-keys-${teamId}`, - TEAM_METRICS: (teamId: string, startMs: number, endMs: number) => - `team-metrics-${teamId}-${startMs}-${endMs}`, - - PASSWORD_SETTINGS_PAGE: (reauth: string) => - `password-settings-page-${reauth}`, DEFAULT_TEMPLATES: 'default-templates', - NOT_FOUND_PAGE: 'not-found-page', } as const diff --git a/src/configs/keys.ts b/src/configs/keys.ts index 901f99109..ab622ea60 100644 --- a/src/configs/keys.ts +++ b/src/configs/keys.ts @@ -3,8 +3,8 @@ * Note: Cookie keys have been moved to @/configs/cookies */ export const KV_KEYS = { - USER_TEAM_ACCESS: (userId: string, teamIdOrSlug: string) => - `user-team-access:${userId}:${teamIdOrSlug}`, + USER_TEAM_ACCESS: (userId: string, teamSlug: string) => + `user-team-access:${userId}:${teamSlug}`, TEAM_SLUG_TO_ID: (slug: string) => `team-slug:${slug}:id`, TEAM_ID_TO_SLUG: (teamId: string) => `team-id:${teamId}:slug`, WARNED_ALTERNATE_EMAIL: (email: string) => `warned-alternate-email:${email}`, @@ -15,20 +15,23 @@ export const KV_KEYS = { */ export const SWR_KEYS = { // team metrics keys - all components using the same key share the same cache - TEAM_METRICS_RECENT: (teamId: string) => - [`/api/teams/${teamId}/metrics`, teamId, 'recent'] as const, - TEAM_METRICS_MONITORING: (teamId: string, start: number, end: number) => - [`/api/teams/${teamId}/metrics`, teamId, 'monitoring', start, end] as const, - TEAM_METRICS_HISTORICAL: (teamId: string, days: number) => - [`/api/teams/${teamId}/metrics`, teamId, 'historical', days] as const, + TEAM_METRICS_RECENT: (teamSlug: string) => + [`/api/teams/${teamSlug}/metrics`, teamSlug, 'recent'] as const, + TEAM_METRICS_MONITORING: (teamSlug: string, start: number, end: number) => + [ + `/api/teams/${teamSlug}/metrics`, + teamSlug, + 'monitoring', + start, + end, + ] as const, + TEAM_METRICS_HISTORICAL: (teamSlug: string, days: number) => + [`/api/teams/${teamSlug}/metrics`, teamSlug, 'historical', days] as const, - // sandbox metrics keys - SANDBOX_METRICS: (teamId: string, sandboxIds: string[]) => - [`/api/teams/${teamId}/sandboxes/metrics`, sandboxIds] as const, SANDBOX_INFO: (sandboxId: string) => [`/api/sandbox/details`, sandboxId] as const, // sandboxes list keys - SANDBOXES_LIST: (teamId: string) => - [`/api/teams/${teamId}/sandboxes/list`] as const, + SANDBOXES_LIST: (teamSlug: string) => + [`/api/teams/${teamSlug}/sandboxes/list`] as const, } diff --git a/src/configs/layout.ts b/src/configs/layout.ts index 2ae8dc7d1..e7a4d6e6a 100644 --- a/src/configs/layout.ts +++ b/src/configs/layout.ts @@ -1,5 +1,5 @@ import micromatch from 'micromatch' -import { l } from '@/lib/clients/logger/logger' +import { l } from '@/core/shared/clients/logger/logger' import { PROTECTED_URLS } from './urls' export interface TitleSegment { @@ -30,14 +30,14 @@ const DASHBOARD_LAYOUT_CONFIGS: Record< }), '/dashboard/*/sandboxes/*/*': (pathname) => { const parts = pathname.split('/') - const teamIdOrSlug = parts[2]! + const teamSlug = parts[2]! const sandboxId = parts[4]! return { title: [ { label: 'Sandboxes', - href: PROTECTED_URLS.SANDBOXES_LIST(teamIdOrSlug), + href: PROTECTED_URLS.SANDBOXES_LIST(teamSlug), }, { label: sandboxId }, ], @@ -54,7 +54,7 @@ const DASHBOARD_LAYOUT_CONFIGS: Record< }), '/dashboard/*/templates/*/builds/*': (pathname) => { const parts = pathname.split('/') - const teamIdOrSlug = parts[2]! + const teamSlug = parts[2]! const buildId = parts.pop()! const buildIdSliced = `${buildId.slice(0, 6)}...${buildId.slice(-6)}` @@ -62,7 +62,7 @@ const DASHBOARD_LAYOUT_CONFIGS: Record< title: [ { label: 'Templates', - href: PROTECTED_URLS.TEMPLATES_BUILDS(teamIdOrSlug), + href: PROTECTED_URLS.TEMPLATES_BUILDS(teamSlug), }, { label: `Build ${buildIdSliced}` }, ], @@ -112,11 +112,11 @@ const DASHBOARD_LAYOUT_CONFIGS: Record< }), '/dashboard/*/billing/plan': (pathname) => { const parts = pathname.split('/') - const teamIdOrSlug = parts[2]! + const teamSlug = parts[2]! return { title: [ - { label: 'Billing', href: PROTECTED_URLS.BILLING(teamIdOrSlug) }, + { label: 'Billing', href: PROTECTED_URLS.BILLING(teamSlug) }, { label: 'Plan & Add-ons', }, @@ -126,14 +126,14 @@ const DASHBOARD_LAYOUT_CONFIGS: Record< }, '/dashboard/*/billing/plan/select': (pathname) => { const parts = pathname.split('/') - const teamIdOrSlug = parts[2]! + const teamSlug = parts[2]! return { title: [ - { label: 'Billing', href: PROTECTED_URLS.BILLING(teamIdOrSlug) }, + { label: 'Billing', href: PROTECTED_URLS.BILLING(teamSlug) }, { label: 'Plan & Add-ons', - href: PROTECTED_URLS.BILLING_PLAN(teamIdOrSlug), + href: PROTECTED_URLS.BILLING_PLAN(teamSlug), }, { label: 'Change Plan' }, ], diff --git a/src/configs/mock-data.ts b/src/configs/mock-data.ts index aa1937a5c..bd8d40b57 100644 --- a/src/configs/mock-data.ts +++ b/src/configs/mock-data.ts @@ -1,16 +1,11 @@ import { addHours, subHours } from 'date-fns' import { nanoid } from 'nanoid' -import type { MetricsResponse } from '@/app/api/teams/[teamId]/sandboxes/metrics/types' -import type { - DefaultTemplate, - Sandbox, - Sandboxes, - Template, -} from '@/types/api.types' +import type { Sandbox, Sandboxes } from '@/core/modules/sandboxes/models' import type { ClientSandboxesMetrics, ClientTeamMetrics, -} from '@/types/sandboxes.types' +} from '@/core/modules/sandboxes/models.client' +import type { DefaultTemplate, Template } from '@/core/modules/templates/models' const DEFAULT_TEMPLATES: DefaultTemplate[] = [ { @@ -931,7 +926,9 @@ function generateMockSandboxes(count: number): Sandboxes { return sandboxes } -function generateMockMetrics(sandboxes: Sandbox[]): MetricsResponse { +function generateMockMetrics(sandboxes: Sandbox[]): { + metrics: ClientSandboxesMetrics +} { const metrics: ClientSandboxesMetrics = {} // Define characteristics by template type diff --git a/src/configs/sidebar.ts b/src/configs/sidebar.ts index 65bf013f7..64f6102d6 100644 --- a/src/configs/sidebar.ts +++ b/src/configs/sidebar.ts @@ -15,7 +15,7 @@ import { INCLUDE_ARGUS, INCLUDE_BILLING } from './flags' import { PROTECTED_URLS } from './urls' type SidebarNavArgs = { - teamIdOrSlug?: string + teamSlug?: string } export type SidebarNavItem = { @@ -34,13 +34,13 @@ export const SIDEBAR_MAIN_LINKS: SidebarNavItem[] = [ // Base { label: 'Sandboxes', - href: (args) => PROTECTED_URLS.SANDBOXES(args.teamIdOrSlug!), + href: (args) => PROTECTED_URLS.SANDBOXES(args.teamSlug!), icon: Box, activeMatch: `/dashboard/*/sandboxes/**`, }, { label: 'Templates', - href: (args) => PROTECTED_URLS.TEMPLATES(args.teamIdOrSlug!), + href: (args) => PROTECTED_URLS.TEMPLATES(args.teamSlug!), icon: Container, activeMatch: `/dashboard/*/templates/**`, }, @@ -52,7 +52,7 @@ export const SIDEBAR_MAIN_LINKS: SidebarNavItem[] = [ label: 'Webhooks', group: 'integration', href: (args: SidebarNavArgs) => - PROTECTED_URLS.WEBHOOKS(args.teamIdOrSlug!), + PROTECTED_URLS.WEBHOOKS(args.teamSlug!), icon: WebhookIcon, activeMatch: `/dashboard/*/webhooks`, }, @@ -62,21 +62,21 @@ export const SIDEBAR_MAIN_LINKS: SidebarNavItem[] = [ // Team { label: 'General', - href: (args) => PROTECTED_URLS.GENERAL(args.teamIdOrSlug!), + href: (args) => PROTECTED_URLS.GENERAL(args.teamSlug!), icon: Settings, group: 'team', activeMatch: `/dashboard/*/general`, }, { label: 'API Keys', - href: (args) => PROTECTED_URLS.KEYS(args.teamIdOrSlug!), + href: (args) => PROTECTED_URLS.KEYS(args.teamSlug!), icon: Key, group: 'team', activeMatch: `/dashboard/*/keys`, }, { label: 'Members', - href: (args) => PROTECTED_URLS.MEMBERS(args.teamIdOrSlug!), + href: (args) => PROTECTED_URLS.MEMBERS(args.teamSlug!), icon: Users, group: 'team', activeMatch: `/dashboard/*/members`, @@ -87,16 +87,14 @@ export const SIDEBAR_MAIN_LINKS: SidebarNavItem[] = [ ? [ { label: 'Usage', - href: (args: SidebarNavArgs) => - PROTECTED_URLS.USAGE(args.teamIdOrSlug!), + href: (args: SidebarNavArgs) => PROTECTED_URLS.USAGE(args.teamSlug!), icon: Activity, group: 'billing', activeMatch: `/dashboard/*/usage/**`, }, { label: 'Limits', - href: (args: SidebarNavArgs) => - PROTECTED_URLS.LIMITS(args.teamIdOrSlug!), + href: (args: SidebarNavArgs) => PROTECTED_URLS.LIMITS(args.teamSlug!), group: 'billing', icon: GaugeIcon, activeMatch: `/dashboard/*/limits/**`, @@ -104,7 +102,7 @@ export const SIDEBAR_MAIN_LINKS: SidebarNavItem[] = [ { label: 'Billing', href: (args: SidebarNavArgs) => - PROTECTED_URLS.BILLING(args.teamIdOrSlug!), + PROTECTED_URLS.BILLING(args.teamSlug!), icon: CreditCard, group: 'billing', activeMatch: `/dashboard/*/billing/**`, diff --git a/src/configs/urls.ts b/src/configs/urls.ts index 07ddf2521..768833627 100644 --- a/src/configs/urls.ts +++ b/src/configs/urls.ts @@ -14,46 +14,45 @@ export const PROTECTED_URLS = { NEW_TEAM: '/dashboard/teams/new', TEAMS: '/dashboard/teams', - RESOLVED_ACCOUNT_SETTINGS: (teamIdOrSlug: string) => - `/dashboard/${teamIdOrSlug}/account`, + RESOLVED_ACCOUNT_SETTINGS: (teamSlug: string) => + `/dashboard/${teamSlug}/account`, - GENERAL: (teamIdOrSlug: string) => `/dashboard/${teamIdOrSlug}/general`, - KEYS: (teamIdOrSlug: string) => `/dashboard/${teamIdOrSlug}/keys`, - MEMBERS: (teamIdOrSlug: string) => `/dashboard/${teamIdOrSlug}/members`, + GENERAL: (teamSlug: string) => `/dashboard/${teamSlug}/general`, + KEYS: (teamSlug: string) => `/dashboard/${teamSlug}/keys`, + MEMBERS: (teamSlug: string) => `/dashboard/${teamSlug}/members`, - SANDBOXES: (teamIdOrSlug: string) => - `/dashboard/${teamIdOrSlug}/sandboxes?tab=monitoring`, - SANDBOXES_MONITORING: (teamIdOrSlug: string) => - `/dashboard/${teamIdOrSlug}/sandboxes?tab=monitoring`, - SANDBOXES_LIST: (teamIdOrSlug: string) => - `/dashboard/${teamIdOrSlug}/sandboxes?tab=list`, + SANDBOXES: (teamSlug: string) => + `/dashboard/${teamSlug}/sandboxes?tab=monitoring`, + SANDBOXES_MONITORING: (teamSlug: string) => + `/dashboard/${teamSlug}/sandboxes?tab=monitoring`, + SANDBOXES_LIST: (teamSlug: string) => + `/dashboard/${teamSlug}/sandboxes?tab=list`, - SANDBOX: (teamIdOrSlug: string, sandboxId: string) => - `/dashboard/${teamIdOrSlug}/sandboxes/${sandboxId}/monitoring`, - SANDBOX_MONITORING: (teamIdOrSlug: string, sandboxId: string) => - `/dashboard/${teamIdOrSlug}/sandboxes/${sandboxId}/monitoring`, - SANDBOX_LOGS: (teamIdOrSlug: string, sandboxId: string) => - `/dashboard/${teamIdOrSlug}/sandboxes/${sandboxId}/logs`, - SANDBOX_FILESYSTEM: (teamIdOrSlug: string, sandboxId: string) => - `/dashboard/${teamIdOrSlug}/sandboxes/${sandboxId}/filesystem`, + SANDBOX: (teamSlug: string, sandboxId: string) => + `/dashboard/${teamSlug}/sandboxes/${sandboxId}/monitoring`, + SANDBOX_MONITORING: (teamSlug: string, sandboxId: string) => + `/dashboard/${teamSlug}/sandboxes/${sandboxId}/monitoring`, + SANDBOX_LOGS: (teamSlug: string, sandboxId: string) => + `/dashboard/${teamSlug}/sandboxes/${sandboxId}/logs`, + SANDBOX_FILESYSTEM: (teamSlug: string, sandboxId: string) => + `/dashboard/${teamSlug}/sandboxes/${sandboxId}/filesystem`, - WEBHOOKS: (teamIdOrSlug: string) => `/dashboard/${teamIdOrSlug}/webhooks`, + WEBHOOKS: (teamSlug: string) => `/dashboard/${teamSlug}/webhooks`, - TEMPLATES: (teamIdOrSlug: string) => `/dashboard/${teamIdOrSlug}/templates`, - TEMPLATES_LIST: (teamIdOrSlug: string) => - `/dashboard/${teamIdOrSlug}/templates?tab=list`, - TEMPLATES_BUILDS: (teamIdOrSlug: string) => - `/dashboard/${teamIdOrSlug}/templates?tab=builds`, - TEMPLATE_BUILD: (teamIdOrSlug: string, templateId: string, buildId: string) => - `/dashboard/${teamIdOrSlug}/templates/${templateId}/builds/${buildId}`, + TEMPLATES: (teamSlug: string) => `/dashboard/${teamSlug}/templates`, + TEMPLATES_LIST: (teamSlug: string) => + `/dashboard/${teamSlug}/templates?tab=list`, + TEMPLATES_BUILDS: (teamSlug: string) => + `/dashboard/${teamSlug}/templates?tab=builds`, + TEMPLATE_BUILD: (teamSlug: string, templateId: string, buildId: string) => + `/dashboard/${teamSlug}/templates/${templateId}/builds/${buildId}`, - USAGE: (teamIdOrSlug: string) => `/dashboard/${teamIdOrSlug}/usage`, - BILLING: (teamIdOrSlug: string) => `/dashboard/${teamIdOrSlug}/billing`, - BILLING_PLAN: (teamIdOrSlug: string) => - `/dashboard/${teamIdOrSlug}/billing/plan`, - BILLING_PLAN_SELECT: (teamIdOrSlug: string) => - `/dashboard/${teamIdOrSlug}/billing/plan/select`, - LIMITS: (teamIdOrSlug: string) => `/dashboard/${teamIdOrSlug}/limits`, + USAGE: (teamSlug: string) => `/dashboard/${teamSlug}/usage`, + BILLING: (teamSlug: string) => `/dashboard/${teamSlug}/billing`, + BILLING_PLAN: (teamSlug: string) => `/dashboard/${teamSlug}/billing/plan`, + BILLING_PLAN_SELECT: (teamSlug: string) => + `/dashboard/${teamSlug}/billing/plan/select`, + LIMITS: (teamSlug: string) => `/dashboard/${teamSlug}/limits`, } export const RESOLVER_URLS = { diff --git a/src/core/application/teams/queries.ts b/src/core/application/teams/queries.ts new file mode 100644 index 000000000..a67699b1f --- /dev/null +++ b/src/core/application/teams/queries.ts @@ -0,0 +1,6 @@ +export const DASHBOARD_TEAMS_LIST_QUERY_OPTIONS = { + staleTime: 5 * 60 * 1000, + refetchOnMount: false, + refetchOnWindowFocus: false, + refetchOnReconnect: false, +} as const diff --git a/src/server/api/models/auth.models.ts b/src/core/modules/auth/models.ts similarity index 100% rename from src/server/api/models/auth.models.ts rename to src/core/modules/auth/models.ts diff --git a/src/core/modules/auth/repository.server.ts b/src/core/modules/auth/repository.server.ts new file mode 100644 index 000000000..0d6642361 --- /dev/null +++ b/src/core/modules/auth/repository.server.ts @@ -0,0 +1,79 @@ +import 'server-only' + +import type { OtpType } from '@/core/modules/auth/models' +import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger' +import { createClient } from '@/core/shared/clients/supabase/server' +import { repoErrorFromHttp } from '@/core/shared/errors' +import { err, ok, type RepoResult } from '@/core/shared/result' + +interface VerifyOtpResult { + userId: string +} + +type AuthRepositoryDeps = { + createSupabaseClient: typeof createClient +} + +export interface AuthRepository { + verifyOtp( + tokenHash: string, + type: OtpType + ): Promise> +} + +export function createAuthRepository(deps: AuthRepositoryDeps): AuthRepository { + return { + async verifyOtp(tokenHash, type) { + const supabase = await deps.createSupabaseClient() + + const { data, error } = await supabase.auth.verifyOtp({ + type, + token_hash: tokenHash, + }) + + if (error) { + l.error( + { + key: 'auth_repository:verify_otp:error', + error: serializeErrorForLog(error), + context: { + type, + token_hash_prefix: tokenHash.slice(0, 10), + error_code: error.code, + error_status: error.status, + }, + }, + `failed to verify OTP: ${error.message}` + ) + + if (error.status === 403 && error.code === 'otp_expired') { + return err( + repoErrorFromHttp( + 400, + 'Email link has expired. Please request a new one.', + error + ) + ) + } + + return err( + repoErrorFromHttp(400, 'Invalid or expired verification link.', error) + ) + } + + if (!data.user) { + return err( + repoErrorFromHttp(500, 'Verification failed. Please try again.') + ) + } + + return ok({ + userId: data.user.id, + }) + }, + } +} + +export const authRepository = createAuthRepository({ + createSupabaseClient: createClient, +}) diff --git a/src/types/billing.types.ts b/src/core/modules/billing/models.ts similarity index 56% rename from src/types/billing.types.ts rename to src/core/modules/billing/models.ts index e7318dbd1..e79e3233e 100644 --- a/src/types/billing.types.ts +++ b/src/core/modules/billing/models.ts @@ -1,6 +1,4 @@ -import type { ADDON_500_SANDBOXES_ID } from '@/features/dashboard/billing/constants' - -interface Invoice { +export interface Invoice { cost: number paid: boolean url: string @@ -8,16 +6,16 @@ interface Invoice { credits_used: number } -interface BillingLimit { +export interface BillingLimit { limit_amount_gte: number | null alert_amount_gte: number | null } -interface CustomerPortalResponse { +export interface CustomerPortalResponse { url: string } -interface UsageResponse { +export interface UsageResponse { credits: number day_usages: { date: string @@ -37,31 +35,31 @@ interface UsageResponse { }[] } -interface CreateTeamsResponse { +export interface CreateTeamsResponse { id: string slug: string } -interface AddOnOrderItem { - name: typeof ADDON_500_SANDBOXES_ID +export interface AddOnOrderItem { + name: string quantity: number } -interface AddOnOrderCreateResponse { +export interface AddOnOrderCreateResponse { id: string amount_due: number items: AddOnOrderItem[] } -interface AddOnOrderConfirmResponse { +export interface AddOnOrderConfirmResponse { client_secret: string } -interface PaymentMethodsCustomerSession { +export interface PaymentMethodsCustomerSession { client_secret: string } -interface TierLimits { +export interface TierLimits { sandbox_concurrency: number max_cpu: number max_ram_mib: number @@ -69,49 +67,31 @@ interface TierLimits { disk_size_mib: number } -interface TierInfo { +export interface TierInfo { id: string name: string price_cents: number limits?: TierLimits } -interface AddonInfo { - id: typeof ADDON_500_SANDBOXES_ID +export interface AddonInfo { + id: string name: string price_cents: number quantity?: number } -interface TeamAddons { +export interface TeamAddons { current: AddonInfo[] available: AddonInfo[] } -interface TeamTiers { +export interface TeamTiers { current: string available: TierInfo[] } -interface TeamItems { +export interface TeamItems { tiers: TeamTiers addons: TeamAddons } - -export type { - AddonInfo, - AddOnOrderConfirmResponse, - AddOnOrderCreateResponse, - AddOnOrderItem, - BillingLimit, - CreateTeamsResponse, - CustomerPortalResponse, - Invoice, - PaymentMethodsCustomerSession, - TeamAddons, - TeamItems, - TeamTiers, - TierInfo, - TierLimits, - UsageResponse, -} diff --git a/src/core/modules/billing/repository.server.ts b/src/core/modules/billing/repository.server.ts new file mode 100644 index 000000000..a0b5b9277 --- /dev/null +++ b/src/core/modules/billing/repository.server.ts @@ -0,0 +1,265 @@ +import 'server-only' + +import { SUPABASE_AUTH_HEADERS } from '@/configs/api' +import type { + AddOnOrderConfirmResponse, + AddOnOrderCreateResponse, + BillingLimit, + CustomerPortalResponse, + Invoice, + PaymentMethodsCustomerSession, + TeamItems, + UsageResponse, +} from '@/core/modules/billing/models' +import { repoErrorFromHttp } from '@/core/shared/errors' +import type { TeamRequestScope } from '@/core/shared/repository-scope' +import { err, ok, type RepoResult } from '@/core/shared/result' + +type BillingRepositoryDeps = { + billingApiUrl: string +} + +export type BillingScope = TeamRequestScope + +export interface BillingRepository { + createCheckout(tierId: string): Promise> + createCustomerPortalSession( + origin?: string | null + ): Promise> + getItems(): Promise> + getUsage(): Promise> + getInvoices(): Promise> + getLimits(): Promise> + setLimit(key: string, value: number): Promise> + clearLimit(key: string): Promise> + createOrder(itemId: string): Promise> + confirmOrder(orderId: string): Promise> + getCustomerSession(): Promise> +} + +async function parseText(response: Response): Promise { + return (await response.text()) || 'Request failed' +} + +export function createBillingRepository( + scope: BillingScope, + deps: BillingRepositoryDeps = { + billingApiUrl: process.env.BILLING_API_URL ?? '', + } +): BillingRepository { + return { + async createCheckout(tierId) { + const res = await fetch(`${deps.billingApiUrl}/checkouts`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...SUPABASE_AUTH_HEADERS(scope.accessToken, scope.teamId), + }, + body: JSON.stringify({ + teamID: scope.teamId, + tierID: tierId, + }), + }) + + if (!res.ok) { + return err(repoErrorFromHttp(res.status, await parseText(res))) + } + + const data = (await res.json()) as { url: string; error?: string } + if (data.error) { + return err(repoErrorFromHttp(500, data.error)) + } + + return ok({ url: data.url }) + }, + async createCustomerPortalSession(origin) { + const res = await fetch(`${deps.billingApiUrl}/stripe/portal`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(origin ? { Origin: origin } : {}), + ...SUPABASE_AUTH_HEADERS(scope.accessToken, scope.teamId), + }, + }) + + if (!res.ok) { + return err(repoErrorFromHttp(res.status, await parseText(res))) + } + + return ok((await res.json()) as CustomerPortalResponse) + }, + async getItems() { + const res = await fetch( + `${deps.billingApiUrl}/teams/${scope.teamId}/items`, + { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + ...SUPABASE_AUTH_HEADERS(scope.accessToken, scope.teamId), + }, + } + ) + + if (!res.ok) { + return err(repoErrorFromHttp(res.status, await parseText(res))) + } + + return ok((await res.json()) as TeamItems) + }, + async getUsage() { + const res = await fetch( + `${deps.billingApiUrl}/v2/teams/${scope.teamId}/usage`, + { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + ...SUPABASE_AUTH_HEADERS(scope.accessToken, scope.teamId), + }, + } + ) + + if (!res.ok) { + return err(repoErrorFromHttp(res.status, await parseText(res))) + } + + const responseData = (await res.json()) as UsageResponse + return ok({ + ...responseData, + hour_usages: responseData.hour_usages.map((hour) => ({ + ...hour, + timestamp: hour.timestamp * 1000, + })), + }) + }, + async getInvoices() { + const res = await fetch( + `${deps.billingApiUrl}/teams/${scope.teamId}/invoices`, + { + headers: { + ...SUPABASE_AUTH_HEADERS(scope.accessToken, scope.teamId), + }, + } + ) + + if (!res.ok) { + return err(repoErrorFromHttp(res.status, await parseText(res))) + } + + return ok((await res.json()) as Invoice[]) + }, + async getLimits() { + const res = await fetch( + `${deps.billingApiUrl}/teams/${scope.teamId}/billing-limits`, + { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + ...SUPABASE_AUTH_HEADERS(scope.accessToken, scope.teamId), + }, + } + ) + + if (!res.ok) { + return err(repoErrorFromHttp(res.status, await parseText(res))) + } + + return ok((await res.json()) as BillingLimit) + }, + async setLimit(key, value) { + const res = await fetch( + `${deps.billingApiUrl}/teams/${scope.teamId}/billing-limits`, + { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + ...SUPABASE_AUTH_HEADERS(scope.accessToken, scope.teamId), + }, + body: JSON.stringify({ + [key]: value, + }), + } + ) + + if (!res.ok) { + return err(repoErrorFromHttp(res.status, await parseText(res))) + } + + return ok(undefined) + }, + async clearLimit(key) { + const res = await fetch( + `${deps.billingApiUrl}/teams/${scope.teamId}/billing-limits/${key}`, + { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + ...SUPABASE_AUTH_HEADERS(scope.accessToken, scope.teamId), + }, + } + ) + + if (!res.ok) { + return err(repoErrorFromHttp(res.status, await parseText(res))) + } + + return ok(undefined) + }, + async createOrder(itemId) { + const res = await fetch( + `${deps.billingApiUrl}/teams/${scope.teamId}/orders`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...SUPABASE_AUTH_HEADERS(scope.accessToken, scope.teamId), + }, + body: JSON.stringify({ + items: [{ name: itemId, quantity: 1 }], + }), + } + ) + + if (!res.ok) { + return err(repoErrorFromHttp(res.status, await parseText(res))) + } + + return ok((await res.json()) as AddOnOrderCreateResponse) + }, + async confirmOrder(orderId) { + const res = await fetch( + `${deps.billingApiUrl}/teams/${scope.teamId}/orders/${orderId}`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...SUPABASE_AUTH_HEADERS(scope.accessToken, scope.teamId), + }, + } + ) + + if (!res.ok) { + return err(repoErrorFromHttp(res.status, await parseText(res))) + } + + return ok((await res.json()) as AddOnOrderConfirmResponse) + }, + async getCustomerSession() { + const res = await fetch( + `${deps.billingApiUrl}/teams/${scope.teamId}/payment-methods/customer-session`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...SUPABASE_AUTH_HEADERS(scope.accessToken, scope.teamId), + }, + } + ) + + if (!res.ok) { + return err(repoErrorFromHttp(res.status, await parseText(res))) + } + + return ok((await res.json()) as PaymentMethodsCustomerSession) + }, + } +} diff --git a/src/core/modules/builds/constants.ts b/src/core/modules/builds/constants.ts new file mode 100644 index 000000000..dcaa92e49 --- /dev/null +++ b/src/core/modules/builds/constants.ts @@ -0,0 +1,7 @@ +import type { BuildStatus } from './models' + +export const INITIAL_BUILD_STATUSES: BuildStatus[] = [ + 'building', + 'failed', + 'success', +] diff --git a/src/server/api/models/builds.models.ts b/src/core/modules/builds/models.ts similarity index 75% rename from src/server/api/models/builds.models.ts rename to src/core/modules/builds/models.ts index 9d5d04e59..e94eb62c1 100644 --- a/src/server/api/models/builds.models.ts +++ b/src/core/modules/builds/models.ts @@ -1,6 +1,6 @@ import { z } from 'zod' -import type { components as dashboardComponents } from '@/types/dashboard-api.types' -import type { components as infraComponents } from '@/types/infra-api.types' +import type { components as dashboardComponents } from '@/contracts/dashboard-api' +import type { components as infraComponents } from '@/contracts/infra-api' export type BuildStatus = dashboardComponents['schemas']['BuildStatus'] @@ -22,7 +22,7 @@ type _BuildStatusExhaustiveCheck = AssertTrue< // TypeCheck: End export const BuildStatusSchema = z.enum(BUILD_STATUS_VALUES) -export interface ListedBuildDTO { +export interface ListedBuildModel { id: string // id or alias template: string @@ -33,25 +33,25 @@ export interface ListedBuildDTO { finishedAt: number | null } -export interface RunningBuildStatusDTO { +export interface RunningBuildStatusModel { id: string status: BuildStatus finishedAt: number | null statusMessage: string | null } -export interface BuildLogDTO { +export interface BuildLogModel { timestampUnix: number level: infraComponents['schemas']['LogLevel'] message: string } -export interface BuildLogsDTO { - logs: BuildLogDTO[] +export interface BuildLogsModel { + logs: BuildLogModel[] nextCursor: number | null } -export interface BuildDetailsDTO { +export interface BuildDetailsModel { templateNames: string[] | null // id or alias template: string diff --git a/src/core/modules/builds/repository.server.ts b/src/core/modules/builds/repository.server.ts new file mode 100644 index 000000000..6148dc772 --- /dev/null +++ b/src/core/modules/builds/repository.server.ts @@ -0,0 +1,331 @@ +import 'server-only' + +import { SUPABASE_AUTH_HEADERS } from '@/configs/api' +import type { components as InfraComponents } from '@/contracts/infra-api' +import { INITIAL_BUILD_STATUSES } from '@/core/modules/builds/constants' +import type { + BuildStatus, + ListedBuildModel, + RunningBuildStatusModel, +} from '@/core/modules/builds/models' +import { api, infra } from '@/core/shared/clients/api' +import { l } from '@/core/shared/clients/logger/logger' +import { repoErrorFromHttp } from '@/core/shared/errors' +import type { TeamRequestScope } from '@/core/shared/repository-scope' +import { err, ok, type RepoResult } from '@/core/shared/result' + +type BuildsRepositoryDeps = { + apiClient: typeof api + infraClient: typeof infra + authHeaders: typeof SUPABASE_AUTH_HEADERS +} + +export type BuildsScope = TeamRequestScope + +export interface BuildsRepository { + listBuilds( + buildIdOrTemplate?: string, + statuses?: BuildStatus[], + options?: ListBuildsOptions + ): Promise> + getRunningStatuses( + buildIds: string[] + ): Promise> + getBuildInfo(buildId: string): Promise> + getInfraBuildStatus( + templateId: string, + buildId: string + ): Promise> + getInfraBuildLogs( + templateId: string, + buildId: string, + options?: GetInfraBuildLogsOptions + ): Promise< + RepoResult + > +} + +const LIST_BUILDS_DEFAULT_LIMIT = 50 +const LIST_BUILDS_MIN_LIMIT = 1 +const LIST_BUILDS_MAX_LIMIT = 100 + +function normalizeListBuildsLimit(limit?: number): number { + return Math.max( + LIST_BUILDS_MIN_LIMIT, + Math.min(limit ?? LIST_BUILDS_DEFAULT_LIMIT, LIST_BUILDS_MAX_LIMIT) + ) +} + +interface ListBuildsOptions { + limit?: number + cursor?: string +} + +interface ListBuildsResult { + data: ListedBuildModel[] + nextCursor: string | null +} + +interface BuildInfoResult { + names: string[] | null + createdAt: number + finishedAt: number | null + status: ListedBuildModel['status'] + statusMessage: string | null +} + +export interface GetInfraBuildLogsOptions { + cursor?: number + limit?: number + direction?: 'forward' | 'backward' + level?: 'debug' | 'info' | 'warn' | 'error' +} + +export function createBuildsRepository( + scope: BuildsScope, + deps: BuildsRepositoryDeps = { + apiClient: api, + infraClient: infra, + authHeaders: SUPABASE_AUTH_HEADERS, + } +): BuildsRepository { + return { + async listBuilds( + buildIdOrTemplate, + statuses = INITIAL_BUILD_STATUSES, + options = {} + ): Promise> { + const limit = normalizeListBuildsLimit(options.limit) + const result = await deps.apiClient.GET('/builds', { + params: { + query: { + build_id_or_template: buildIdOrTemplate?.trim() || undefined, + statuses, + limit, + cursor: options.cursor, + }, + }, + headers: { + ...deps.authHeaders(scope.accessToken, scope.teamId), + }, + }) + + if (!result.response.ok || result.error) { + l.error({ + key: 'repositories:builds:list_builds:dashboard_api_error', + error: result.error, + team_id: scope.teamId, + context: { + status: result.response.status, + path: '/builds', + }, + }) + return err( + repoErrorFromHttp( + result.response.status, + result.error?.message ?? 'Failed to fetch builds', + result.error + ) + ) + } + + const builds = result.data?.data ?? [] + if (builds.length === 0) { + return ok({ + data: [], + nextCursor: null, + }) + } + + return ok({ + data: builds.map( + (build): ListedBuildModel => ({ + id: build.id, + template: build.template, + templateId: build.templateId, + status: build.status, + statusMessage: build.statusMessage, + createdAt: new Date(build.createdAt).getTime(), + finishedAt: build.finishedAt + ? new Date(build.finishedAt).getTime() + : null, + }) + ), + nextCursor: result.data?.nextCursor ?? null, + }) + }, + async getRunningStatuses(buildIds) { + if (buildIds.length === 0) { + return ok([]) + } + + const result = await deps.apiClient.GET('/builds/statuses', { + params: { + query: { + build_ids: buildIds, + }, + }, + headers: { + ...deps.authHeaders(scope.accessToken, scope.teamId), + }, + }) + + if (!result.response.ok || result.error) { + l.error({ + key: 'repositories:builds:get_running_statuses:dashboard_api_error', + error: result.error, + team_id: scope.teamId, + context: { + status: result.response.status, + path: '/builds/statuses', + }, + }) + return err( + repoErrorFromHttp( + result.response.status, + result.error?.message ?? 'Failed to fetch build statuses', + result.error + ) + ) + } + + return ok( + (result.data?.buildStatuses ?? []).map((row) => ({ + id: row.id, + status: row.status, + finishedAt: row.finishedAt + ? new Date(row.finishedAt).getTime() + : null, + statusMessage: row.statusMessage, + })) + ) + }, + async getBuildInfo(buildId) { + const result = await deps.apiClient.GET('/builds/{build_id}', { + params: { + path: { + build_id: buildId, + }, + }, + headers: { + ...deps.authHeaders(scope.accessToken, scope.teamId), + }, + }) + + if (!result.response.ok || result.error) { + l.error({ + key: 'repositories:builds:get_build_info:dashboard_api_error', + error: result.error, + team_id: scope.teamId, + context: { + status: result.response.status, + path: '/builds/{build_id}', + build_id: buildId, + }, + }) + return err( + repoErrorFromHttp( + result.response.status, + result.error?.message ?? 'Failed to fetch build info', + result.error + ) + ) + } + + const data = result.data + + return ok({ + names: data.names ?? null, + createdAt: new Date(data.createdAt).getTime(), + finishedAt: data.finishedAt + ? new Date(data.finishedAt).getTime() + : null, + status: data.status, + statusMessage: data.statusMessage, + }) + }, + async getInfraBuildStatus(templateId, buildId) { + const result = await deps.infraClient.GET( + '/templates/{templateID}/builds/{buildID}/status', + { + params: { + path: { + templateID: templateId, + buildID: buildId, + }, + query: { + limit: 0, + }, + }, + headers: { + ...deps.authHeaders(scope.accessToken, scope.teamId), + }, + } + ) + + if (!result.response.ok || result.error) { + l.error({ + key: 'repositories:builds:get_build_status:infra_error', + error: result.error, + team_id: scope.teamId, + context: { + status: result.response.status, + path: '/templates/{templateID}/builds/{buildID}/status', + }, + }) + return err( + repoErrorFromHttp( + result.response.status, + result.error?.message ?? 'Failed to fetch build status', + result.error + ) + ) + } + + return ok(result.data) + }, + async getInfraBuildLogs(templateId, buildId, options = {}) { + const result = await deps.infraClient.GET( + '/templates/{templateID}/builds/{buildID}/logs', + { + params: { + path: { + templateID: templateId, + buildID: buildId, + }, + query: { + cursor: options.cursor, + limit: options.limit, + direction: options.direction, + level: options.level, + }, + }, + headers: { + ...deps.authHeaders(scope.accessToken, scope.teamId), + }, + } + ) + + if (!result.response.ok || result.error) { + l.error({ + key: 'repositories:builds:get_build_logs:infra_error', + error: result.error, + team_id: scope.teamId, + context: { + status: result.response.status, + path: '/templates/{templateID}/builds/{buildID}/logs', + }, + }) + return err( + repoErrorFromHttp( + result.response.status, + result.error?.message ?? 'Failed to fetch build logs', + result.error + ) + ) + } + + return ok(result.data) + }, + } +} diff --git a/src/core/modules/keys/models.ts b/src/core/modules/keys/models.ts new file mode 100644 index 000000000..ce1e5094e --- /dev/null +++ b/src/core/modules/keys/models.ts @@ -0,0 +1,4 @@ +import type { components as InfraComponents } from '@/contracts/infra-api' + +export type CreatedTeamAPIKey = InfraComponents['schemas']['CreatedTeamAPIKey'] +export type TeamAPIKey = InfraComponents['schemas']['TeamAPIKey'] diff --git a/src/core/modules/keys/repository.server.ts b/src/core/modules/keys/repository.server.ts new file mode 100644 index 000000000..030b2c4c7 --- /dev/null +++ b/src/core/modules/keys/repository.server.ts @@ -0,0 +1,97 @@ +import 'server-only' + +import { SUPABASE_AUTH_HEADERS } from '@/configs/api' +import type { CreatedTeamAPIKey, TeamAPIKey } from '@/core/modules/keys/models' +import { infra } from '@/core/shared/clients/api' +import { repoErrorFromHttp } from '@/core/shared/errors' +import type { TeamRequestScope } from '@/core/shared/repository-scope' +import { err, ok, type RepoResult } from '@/core/shared/result' + +type KeysRepositoryDeps = { + infraClient: typeof infra + authHeaders: typeof SUPABASE_AUTH_HEADERS +} + +export type KeysScope = TeamRequestScope + +export interface KeysRepository { + listTeamApiKeys(): Promise> + createApiKey(name: string): Promise> + deleteApiKey(apiKeyId: string): Promise> +} + +export function createKeysRepository( + scope: KeysScope, + deps: KeysRepositoryDeps = { + infraClient: infra, + authHeaders: SUPABASE_AUTH_HEADERS, + } +): KeysRepository { + return { + async listTeamApiKeys() { + const res = await deps.infraClient.GET('/api-keys', { + headers: { + ...deps.authHeaders(scope.accessToken, scope.teamId), + }, + }) + + if (!res.response.ok || res.error) { + return err( + repoErrorFromHttp( + res.response.status, + res.error?.message ?? 'Failed to get API keys', + res.error + ) + ) + } + + return ok(res.data ?? []) + }, + async createApiKey(name) { + const res = await deps.infraClient.POST('/api-keys', { + body: { + name, + }, + headers: { + ...deps.authHeaders(scope.accessToken, scope.teamId), + }, + }) + + if (!res.response.ok || res.error) { + return err( + repoErrorFromHttp( + res.response.status, + res.error?.message ?? 'Failed to create API key', + res.error + ) + ) + } + + return ok(res.data) + }, + async deleteApiKey(apiKeyId) { + const res = await deps.infraClient.DELETE('/api-keys/{apiKeyID}', { + headers: { + ...deps.authHeaders(scope.accessToken, scope.teamId), + }, + params: { + path: { + apiKeyID: apiKeyId, + }, + }, + }) + + if (!res.response.ok || res.error) { + return err( + repoErrorFromHttp( + res.response.status, + res.error?.message ?? 'Failed to delete API key', + res.error + ) + ) + } + + return ok(undefined) + }, + } +} diff --git a/src/types/sandboxes.types.ts b/src/core/modules/sandboxes/models.client.ts similarity index 80% rename from src/types/sandboxes.types.ts rename to src/core/modules/sandboxes/models.client.ts index fd3382d9c..0766be2f9 100644 --- a/src/types/sandboxes.types.ts +++ b/src/core/modules/sandboxes/models.client.ts @@ -1,4 +1,4 @@ -import type { TeamMetric } from './api.types' +import type { TeamMetric } from './models' export type ClientSandboxMetric = { cpuCount: number @@ -16,7 +16,7 @@ export type ClientTeamMetric = Pick< TeamMetric, 'concurrentSandboxes' | 'sandboxStartRate' > & { - timestamp: number // unix timestamp in milliseconds + timestamp: number } export type ClientTeamMetrics = Array diff --git a/src/server/api/models/sandboxes.models.ts b/src/core/modules/sandboxes/models.ts similarity index 70% rename from src/server/api/models/sandboxes.models.ts rename to src/core/modules/sandboxes/models.ts index ef5afe02f..36044f9b3 100644 --- a/src/server/api/models/sandboxes.models.ts +++ b/src/core/modules/sandboxes/models.ts @@ -1,10 +1,16 @@ -import type { components as ArgusComponents } from '@/types/argus-api.types' -import type { components as DashboardComponents } from '@/types/dashboard-api.types' -import type { components as InfraComponents } from '@/types/infra-api.types' +import type { components as ArgusComponents } from '@/contracts/argus-api' +import type { components as DashboardComponents } from '@/contracts/dashboard-api' +import type { components as InfraComponents } from '@/contracts/infra-api' export type SandboxLogLevel = InfraComponents['schemas']['LogLevel'] - -interface SandboxDetailsBaseDTO { +export type Sandbox = InfraComponents['schemas']['ListedSandbox'] +export type Sandboxes = InfraComponents['schemas']['ListedSandbox'][] +export type SandboxesMetricsRecord = + InfraComponents['schemas']['SandboxesWithMetrics']['sandboxes'] +export type TeamMetric = InfraComponents['schemas']['TeamMetric'] +export type SandboxInfo = InfraComponents['schemas']['SandboxDetail'] + +interface SandboxDetailsBaseModel { templateID: string alias?: string sandboxID: string @@ -13,10 +19,10 @@ interface SandboxDetailsBaseDTO { cpuCount: number memoryMB: number diskSizeMB: number - lifecycle?: SandboxLifecycleDTO + lifecycle?: SandboxLifecycleModel } -interface ActiveSandboxDetailsDTO extends SandboxDetailsBaseDTO { +interface ActiveSandboxDetailsModel extends SandboxDetailsBaseModel { endAt: string envdVersion: string envdAccessToken?: string @@ -24,35 +30,35 @@ interface ActiveSandboxDetailsDTO extends SandboxDetailsBaseDTO { state: InfraComponents['schemas']['SandboxState'] } -interface KilledSandboxDetailsDTO extends SandboxDetailsBaseDTO { +interface KilledSandboxDetailsModel extends SandboxDetailsBaseModel { endAt: string | null stoppedAt: string | null state: 'killed' } -export type SandboxDetailsDTO = - | ActiveSandboxDetailsDTO - | KilledSandboxDetailsDTO +export type SandboxDetailsModel = + | ActiveSandboxDetailsModel + | KilledSandboxDetailsModel -export interface SandboxLogDTO { +export interface SandboxLogModel { timestampUnix: number level: SandboxLogLevel message: string } -export interface SandboxLogsDTO { - logs: SandboxLogDTO[] +export interface SandboxLogsModel { + logs: SandboxLogModel[] nextCursor: number | null } export type SandboxMetric = InfraComponents['schemas']['SandboxMetric'] -export type SandboxEventDTO = ArgusComponents['schemas']['SandboxEvent'] +export type SandboxEventModel = ArgusComponents['schemas']['SandboxEvent'] -export interface SandboxLifecycleDTO { +export interface SandboxLifecycleModel { createdAt: string | null pausedAt: string | null endedAt: string | null - events: SandboxEventDTO[] + events: SandboxEventModel[] } const SANDBOX_LIFECYCLE_EVENT_PREFIX = 'sandbox.lifecycle.' @@ -74,7 +80,9 @@ function parseEventTimestampMs(value: string): number | null { return timestampMs } -function sortEventsByTimestamp(events: SandboxEventDTO[]): SandboxEventDTO[] { +function sortEventsByTimestamp( + events: SandboxEventModel[] +): SandboxEventModel[] { return [...events].sort((a, b) => { const timestampA = parseEventTimestampMs(a.timestamp) ?? Number.MAX_SAFE_INTEGER @@ -89,8 +97,8 @@ function sortEventsByTimestamp(events: SandboxEventDTO[]): SandboxEventDTO[] { } export function deriveSandboxLifecycleFromEvents( - events: SandboxEventDTO[] -): SandboxLifecycleDTO { + events: SandboxEventModel[] +): SandboxLifecycleModel { const lifecycleEvents = sortEventsByTimestamp( events.filter((event) => event.type.startsWith(SANDBOX_LIFECYCLE_EVENT_PREFIX) @@ -100,7 +108,7 @@ export function deriveSandboxLifecycleFromEvents( let createdAt: string | null = null let pausedAt: string | null = null let endedAt: string | null = null - let lastEvent: SandboxEventDTO | null = null + let lastEvent: SandboxEventModel | null = null for (const event of lifecycleEvents) { const timestampMs = parseEventTimestampMs(event.timestamp) @@ -133,9 +141,9 @@ export function deriveSandboxLifecycleFromEvents( // mappings -export function mapInfraSandboxLogToDTO( +export function mapInfraSandboxLogToModel( log: InfraComponents['schemas']['SandboxLogEntry'] -): SandboxLogDTO { +): SandboxLogModel { return { timestampUnix: new Date(log.timestamp).getTime(), level: log.level, @@ -143,9 +151,9 @@ export function mapInfraSandboxLogToDTO( } } -export function mapInfraSandboxDetailsToDTO( +export function mapInfraSandboxDetailsToModel( sandbox: InfraComponents['schemas']['SandboxDetail'] -): SandboxDetailsDTO { +): SandboxDetailsModel { return { templateID: sandbox.templateID, alias: sandbox.alias, @@ -163,9 +171,9 @@ export function mapInfraSandboxDetailsToDTO( } } -export function mapApiSandboxRecordToDTO( +export function mapApiSandboxRecordToModel( sandbox: DashboardComponents['schemas']['SandboxRecord'] -): KilledSandboxDetailsDTO { +): KilledSandboxDetailsModel { const stoppedAt = sandbox.stoppedAt ?? null return { diff --git a/src/core/modules/sandboxes/repository.server.ts b/src/core/modules/sandboxes/repository.server.ts new file mode 100644 index 000000000..2f4ad1aba --- /dev/null +++ b/src/core/modules/sandboxes/repository.server.ts @@ -0,0 +1,506 @@ +import 'server-only' + +import { SUPABASE_AUTH_HEADERS } from '@/configs/api' +import type { components as DashboardComponents } from '@/contracts/dashboard-api' +import type { components as InfraComponents } from '@/contracts/infra-api' +import type { + SandboxEventModel, + Sandboxes, + SandboxesMetricsRecord, + TeamMetric, +} from '@/core/modules/sandboxes/models' +import { api, infra } from '@/core/shared/clients/api' +import { l } from '@/core/shared/clients/logger/logger' +import { repoErrorFromHttp } from '@/core/shared/errors' +import type { TeamRequestScope } from '@/core/shared/repository-scope' +import { err, ok, type RepoResult } from '@/core/shared/result' + +type SandboxesRepositoryDeps = { + apiClient: typeof api + infraClient: typeof infra + authHeaders: typeof SUPABASE_AUTH_HEADERS +} + +export type SandboxesRequestScope = TeamRequestScope + +export interface GetSandboxLogsOptions { + cursor?: number + limit?: number + direction?: 'forward' | 'backward' + level?: 'debug' | 'info' | 'warn' | 'error' + search?: string +} + +export interface GetSandboxMetricsOptions { + startUnixMs: number + endUnixMs: number +} + +export interface SandboxesRepository { + getSandboxLogs( + sandboxId: string, + options?: GetSandboxLogsOptions + ): Promise> + getSandboxDetails(sandboxId: string): Promise< + RepoResult< + | { + source: 'infra' + details: InfraComponents['schemas']['SandboxDetail'] + } + | { + source: 'database-record' + details: DashboardComponents['schemas']['SandboxRecord'] + } + > + > + getSandboxLifecycleEvents( + sandboxId: string + ): Promise> + getSandboxMetrics( + sandboxId: string, + options: GetSandboxMetricsOptions + ): Promise> + listSandboxes(): Promise> + getSandboxesMetrics( + sandboxIds: string[] + ): Promise> + getTeamMetricsRange( + startUnixSeconds: number, + endUnixSeconds: number + ): Promise> + getTeamMetricsMax( + startUnixSeconds: number, + endUnixSeconds: number, + metric: 'concurrent_sandboxes' | 'sandbox_start_rate' + ): Promise> +} + +const SANDBOX_NOT_FOUND_MESSAGE = + "Sandbox not found or you don't have access to it" +const SANDBOX_EVENTS_PAGE_SIZE = 100 +const SANDBOX_EVENTS_MAX_PAGES = 50 +const SANDBOX_LIFECYCLE_EVENT_PREFIX = 'sandbox.lifecycle.' + +export function createSandboxesRepository( + scope: SandboxesRequestScope, + deps: SandboxesRepositoryDeps = { + apiClient: api, + infraClient: infra, + authHeaders: SUPABASE_AUTH_HEADERS, + } +): SandboxesRepository { + return { + async getSandboxLogs(sandboxId, options = {}) { + const result = await deps.infraClient.GET( + '/v2/sandboxes/{sandboxID}/logs', + { + params: { + path: { + sandboxID: sandboxId, + }, + query: { + cursor: options.cursor, + limit: options.limit, + direction: options.direction, + level: options.level, + search: options.search, + }, + }, + headers: { + ...deps.authHeaders(scope.accessToken, scope.teamId), + }, + } + ) + + if (!result.response.ok || result.error) { + const status = result.response.status + + l.error( + { + key: 'repositories:sandboxes:get_sandbox_logs:infra_error', + error: result.error, + team_id: scope.teamId, + context: { + status, + path: '/v2/sandboxes/{sandboxID}/logs', + sandbox_id: sandboxId, + }, + }, + `failed to fetch /v2/sandboxes/{sandboxID}/logs: ${result.error?.message || 'Unknown error'}` + ) + + return err( + repoErrorFromHttp( + status, + status === 404 + ? SANDBOX_NOT_FOUND_MESSAGE + : (result.error?.message ?? 'Failed to fetch sandbox logs'), + result.error + ) + ) + } + + return ok(result.data) + }, + async getSandboxDetails(sandboxId) { + const infraResult = await deps.infraClient.GET('/sandboxes/{sandboxID}', { + params: { + path: { + sandboxID: sandboxId, + }, + }, + headers: { + ...deps.authHeaders(scope.accessToken, scope.teamId), + }, + cache: 'no-store', + }) + + if (infraResult.response.ok && infraResult.data) { + return ok({ + source: 'infra' as const, + details: infraResult.data, + }) + } + + const infraStatus = infraResult.response.status + + if (infraStatus !== 404) { + l.error({ + key: 'repositories:sandboxes:get_sandbox_details:infra_error', + error: infraResult.error, + team_id: scope.teamId, + context: { + status: infraStatus, + path: '/sandboxes/{sandboxID}', + sandbox_id: sandboxId, + }, + }) + return err( + repoErrorFromHttp( + infraStatus, + infraResult.error?.message ?? 'Failed to fetch sandbox details', + infraResult.error + ) + ) + } + + const dashboardResult = await deps.apiClient.GET( + '/sandboxes/{sandboxID}/record', + { + params: { + path: { + sandboxID: sandboxId, + }, + }, + headers: { + ...deps.authHeaders(scope.accessToken, scope.teamId), + }, + cache: 'no-store', + } + ) + + if (dashboardResult.response.ok && dashboardResult.data) { + return ok({ + source: 'database-record' as const, + details: dashboardResult.data, + }) + } + + const dashboardStatus = dashboardResult.response.status + + if (dashboardStatus === 404) { + return err(repoErrorFromHttp(404, SANDBOX_NOT_FOUND_MESSAGE)) + } + + l.error({ + key: 'repositories:sandboxes:get_sandbox_details:fallback_error', + error: dashboardResult.error, + team_id: scope.teamId, + context: { + status: dashboardStatus, + path: '/sandboxes/{sandboxID}/record', + infra_status: infraStatus, + sandbox_id: sandboxId, + }, + }) + return err( + repoErrorFromHttp( + dashboardStatus, + dashboardResult.error?.message ?? 'Failed to fetch sandbox details', + dashboardResult.error + ) + ) + }, + async getSandboxLifecycleEvents(sandboxId) { + const lifecycleEvents: SandboxEventModel[] = [] + + for ( + let pageIndex = 0, offset = 0; + pageIndex < SANDBOX_EVENTS_MAX_PAGES; + pageIndex += 1, offset += SANDBOX_EVENTS_PAGE_SIZE + ) { + try { + const result = await deps.infraClient.GET( + '/events/sandboxes/{sandboxID}', + { + params: { + path: { + sandboxID: sandboxId, + }, + query: { + offset, + limit: SANDBOX_EVENTS_PAGE_SIZE, + orderAsc: true, + }, + }, + headers: { + ...deps.authHeaders(scope.accessToken, scope.teamId), + }, + cache: 'no-store', + } + ) + + if (!result.response.ok || result.error) { + l.warn({ + key: 'repositories:sandboxes:get_sandbox_lifecycle_events:infra_error', + error: result.error, + team_id: scope.teamId, + context: { + status: result.response.status, + path: '/events/sandboxes/{sandboxID}', + sandbox_id: sandboxId, + offset, + limit: SANDBOX_EVENTS_PAGE_SIZE, + }, + }) + break + } + + const page = result.data ?? [] + lifecycleEvents.push( + ...page.filter((event) => + event.type.startsWith(SANDBOX_LIFECYCLE_EVENT_PREFIX) + ) + ) + + if (page.length < SANDBOX_EVENTS_PAGE_SIZE) { + break + } + } catch (error) { + l.warn({ + key: 'repositories:sandboxes:get_sandbox_lifecycle_events:infra_exception', + error, + team_id: scope.teamId, + context: { + path: '/events/sandboxes/{sandboxID}', + sandbox_id: sandboxId, + offset, + limit: SANDBOX_EVENTS_PAGE_SIZE, + }, + }) + break + } + } + + return ok(lifecycleEvents) + }, + async getSandboxMetrics(sandboxId, options) { + const startUnixSeconds = Math.floor(options.startUnixMs / 1000) + const endUnixSeconds = Math.floor(options.endUnixMs / 1000) + + const result = await deps.infraClient.GET( + '/sandboxes/{sandboxID}/metrics', + { + params: { + path: { + sandboxID: sandboxId, + }, + query: { + start: startUnixSeconds, + end: endUnixSeconds, + }, + }, + headers: { + ...deps.authHeaders(scope.accessToken, scope.teamId), + }, + } + ) + + if (!result.response.ok || result.error) { + const status = result.response.status + + l.error( + { + key: 'repositories:sandboxes:get_sandbox_metrics:infra_error', + error: result.error, + team_id: scope.teamId, + context: { + status, + path: '/sandboxes/{sandboxID}/metrics', + sandbox_id: sandboxId, + }, + }, + `failed to fetch /sandboxes/{sandboxID}/metrics: ${result.error?.message || 'Unknown error'}` + ) + + return err( + repoErrorFromHttp( + status, + status === 404 + ? SANDBOX_NOT_FOUND_MESSAGE + : (result.error?.message ?? 'Failed to fetch sandbox metrics'), + result.error + ) + ) + } + + return ok(result.data) + }, + async listSandboxes() { + const result = await deps.infraClient.GET('/sandboxes', { + headers: { + ...deps.authHeaders(scope.accessToken, scope.teamId), + }, + cache: 'no-store', + }) + + if (!result.response.ok || result.error) { + l.error({ + key: 'repositories:sandboxes:list_sandboxes:infra_error', + error: result.error, + team_id: scope.teamId, + context: { + status: result.response.status, + path: '/sandboxes', + }, + }) + return err( + repoErrorFromHttp( + result.response.status, + result.error?.message ?? 'Failed to list sandboxes', + result.error + ) + ) + } + + return ok(result.data) + }, + async getSandboxesMetrics(sandboxIds) { + const result = await deps.infraClient.GET('/sandboxes/metrics', { + params: { + query: { + sandbox_ids: sandboxIds, + }, + }, + headers: { + ...deps.authHeaders(scope.accessToken, scope.teamId), + }, + cache: 'no-store', + }) + + if (!result.response.ok || result.error) { + l.error({ + key: 'repositories:sandboxes:get_sandboxes_metrics:infra_error', + error: result.error, + team_id: scope.teamId, + context: { + status: result.response.status, + path: '/sandboxes/metrics', + sandbox_ids: sandboxIds, + }, + }) + return err( + repoErrorFromHttp( + result.response.status, + result.error?.message ?? 'Failed to fetch sandboxes metrics', + result.error + ) + ) + } + + return ok(result.data.sandboxes) + }, + async getTeamMetricsRange(startUnixSeconds, endUnixSeconds) { + const result = await deps.infraClient.GET('/teams/{teamID}/metrics', { + params: { + path: { + teamID: scope.teamId, + }, + query: { + start: startUnixSeconds, + end: endUnixSeconds, + }, + }, + headers: { + ...deps.authHeaders(scope.accessToken, scope.teamId), + }, + cache: 'no-store', + }) + + if (!result.response.ok || result.error) { + l.error({ + key: 'repositories:sandboxes:get_team_metrics:infra_error', + error: result.error, + team_id: scope.teamId, + context: { + status: result.response.status, + path: '/teams/{teamID}/metrics', + start_unix_seconds: startUnixSeconds, + end_unix_seconds: endUnixSeconds, + }, + }) + return err( + repoErrorFromHttp( + result.response.status, + result.error?.message ?? 'Failed to fetch team metrics', + result.error + ) + ) + } + + return ok(result.data) + }, + async getTeamMetricsMax(startUnixSeconds, endUnixSeconds, metric) { + const result = await deps.infraClient.GET('/teams/{teamID}/metrics/max', { + params: { + path: { + teamID: scope.teamId, + }, + query: { + start: startUnixSeconds, + end: endUnixSeconds, + metric, + }, + }, + headers: { + ...deps.authHeaders(scope.accessToken, scope.teamId), + }, + cache: 'no-store', + }) + + if (!result.response.ok || result.error) { + l.error({ + key: 'repositories:sandboxes:get_team_metrics_max:infra_error', + error: result.error, + team_id: scope.teamId, + context: { + status: result.response.status, + path: '/teams/{teamID}/metrics/max', + start_unix_seconds: startUnixSeconds, + end_unix_seconds: endUnixSeconds, + metric, + }, + }) + return err( + repoErrorFromHttp( + result.response.status, + result.error?.message ?? 'Failed to fetch team metrics max', + result.error + ) + ) + } + + return ok(result.data) + }, + } +} diff --git a/src/server/api/schemas/sandboxes.ts b/src/core/modules/sandboxes/schemas.ts similarity index 100% rename from src/server/api/schemas/sandboxes.ts rename to src/core/modules/sandboxes/schemas.ts diff --git a/src/core/modules/support/repository.server.ts b/src/core/modules/support/repository.server.ts new file mode 100644 index 000000000..91fa45a97 --- /dev/null +++ b/src/core/modules/support/repository.server.ts @@ -0,0 +1,264 @@ +import 'server-only' + +import { AttachmentType, PlainClient } from '@team-plain/typescript-sdk' +import { createUserTeamsRepository } from '@/core/modules/teams/user-teams-repository.server' +import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger' +import { repoErrorFromHttp } from '@/core/shared/errors' +import type { TeamRequestScope } from '@/core/shared/repository-scope' +import { err, ok, type RepoResult } from '@/core/shared/result' + +const MAX_FILE_SIZE = 10 * 1024 * 1024 +const MAX_FILES = 5 + +interface FileInput { + name: string + type: string + base64: string +} + +type SupportRepositoryDeps = { + createPlainClient: () => PlainClient +} + +export type SupportScope = TeamRequestScope + +export interface SupportRepository { + getTeamSupportData(): Promise< + RepoResult<{ + name: string + email: string + tier: string + }> + > + createSupportThread(input: { + description: string + files?: FileInput[] + teamId: string + teamName: string + customerEmail: string + accountOwnerEmail: string + customerTier: string + }): Promise> +} + +function formatThreadText(input: { + description: string + teamId: string + customerEmail: string + accountOwnerEmail: string + customerTier: string +}): string { + const { + description, + teamId, + customerEmail, + accountOwnerEmail, + customerTier, + } = input + + const header = [ + '########', + `Customer: ${customerEmail}`, + `Account Owner: ${accountOwnerEmail}`, + `Tier: ${customerTier}`, + `TeamID: ${teamId}`, + `Orbit: https://orbit.e2b.dev/teams/${teamId}/users`, + '########', + ].join('\n') + + const truncatedDescription = description.slice(0, 10000) + + return `${header}\n\n${truncatedDescription}` +} + +async function uploadAttachmentToPlain( + client: PlainClient, + customerId: string, + file: FileInput +): Promise { + const buffer = Buffer.from(file.base64, 'base64') + + if (buffer.byteLength > MAX_FILE_SIZE) { + throw new Error(`File ${file.name} exceeds 10MB limit`) + } + + const uploadUrlResult = await client.createAttachmentUploadUrl({ + customerId, + fileName: file.name, + fileSizeBytes: buffer.byteLength, + attachmentType: AttachmentType.CustomTimelineEntry, + }) + + if (uploadUrlResult.error) { + throw new Error( + `Failed to create upload URL for ${file.name}: ${uploadUrlResult.error.message}` + ) + } + + const { uploadFormUrl, uploadFormData, attachment } = uploadUrlResult.data + const formData = new FormData() + for (const { key, value } of uploadFormData) { + formData.append(key, value) + } + formData.append('file', new Blob([buffer], { type: file.type }), file.name) + + const uploadResponse = await fetch(uploadFormUrl, { + method: 'POST', + body: formData, + }) + + if (!uploadResponse.ok) { + throw new Error( + `Failed to upload ${file.name}: ${uploadResponse.status} ${uploadResponse.statusText}` + ) + } + + return attachment.id +} + +export function createSupportRepository( + scope: SupportScope, + deps: SupportRepositoryDeps = { + createPlainClient: () => + new PlainClient({ + apiKey: process.env.PLAIN_API_KEY ?? '', + }), + } +): SupportRepository { + return { + async getTeamSupportData() { + const teamsResult = await createUserTeamsRepository({ + accessToken: scope.accessToken, + }).listUserTeams() + + if (!teamsResult.ok) { + l.error( + { + key: 'repositories:support:fetch_team_error', + error: teamsResult.error, + team_id: scope.teamId, + }, + 'failed to fetch team data' + ) + return err(teamsResult.error) + } + + const team = teamsResult.data.find( + (candidate) => candidate.id === scope.teamId + ) + + if (!team) { + return err( + repoErrorFromHttp(403, 'Team not found or access denied', { + teamId: scope.teamId, + }) + ) + } + + return ok({ name: team.name, email: team.email, tier: team.tier }) + }, + async createSupportThread(input) { + if (!process.env.PLAIN_API_KEY) { + return err(repoErrorFromHttp(500, 'Support API not configured')) + } + + const { + description, + files, + teamId, + teamName, + customerEmail, + accountOwnerEmail, + customerTier, + } = input + + const client = deps.createPlainClient() + const customerResult = await client.upsertCustomer({ + identifier: { + emailAddress: customerEmail, + }, + onCreate: { + email: { + email: customerEmail, + isVerified: true, + }, + fullName: customerEmail, + }, + onUpdate: {}, + }) + + if (customerResult.error) { + return err( + repoErrorFromHttp( + 500, + 'Failed to create support ticket', + customerResult.error + ) + ) + } + + const customerId = customerResult.data.customer.id + const attachmentIds: string[] = [] + const validFiles = (files ?? []).slice(0, MAX_FILES) + + for (const file of validFiles) { + try { + const attachmentId = await uploadAttachmentToPlain( + client, + customerId, + file + ) + attachmentIds.push(attachmentId) + } catch (error) { + l.error( + { + key: 'repositories:support:attachment_upload_error', + error: serializeErrorForLog(error), + team_id: teamId, + context: { + file_name: file.name, + }, + }, + 'failed to upload support ticket attachment' + ) + } + } + + const title = `Support Request [${teamName}]` + const threadText = formatThreadText({ + description, + teamId, + customerEmail, + accountOwnerEmail, + customerTier, + }) + + const result = await client.createThread({ + title, + customerIdentifier: { + customerId, + }, + components: [ + { + componentText: { + text: threadText, + }, + }, + ], + ...(attachmentIds.length > 0 ? { attachmentIds } : {}), + }) + + if (result.error) { + return err( + repoErrorFromHttp( + 500, + 'Failed to create support ticket', + result.error + ) + ) + } + + return ok({ threadId: result.data.id }) + }, + } +} diff --git a/src/core/modules/teams/models.ts b/src/core/modules/teams/models.ts new file mode 100644 index 000000000..b77242521 --- /dev/null +++ b/src/core/modules/teams/models.ts @@ -0,0 +1,27 @@ +import type { components as DashboardComponents } from '@/contracts/dashboard-api' + +export type TeamModel = DashboardComponents['schemas']['UserTeam'] +export type TeamLimits = DashboardComponents['schemas']['UserTeamLimits'] + +export type TeamMemberInfo = { + id: string + email: string + name?: string + avatar_url?: string + providers?: string[] +} + +export type TeamMemberRelation = { + added_by: string | null + is_default: boolean +} + +export type TeamMember = { + info: TeamMemberInfo + relation: TeamMemberRelation +} + +export type ResolvedTeam = { + id: string + slug: string +} diff --git a/src/core/modules/teams/schemas.ts b/src/core/modules/teams/schemas.ts new file mode 100644 index 000000000..91ea1d39f --- /dev/null +++ b/src/core/modules/teams/schemas.ts @@ -0,0 +1,23 @@ +import { z } from 'zod' +import { TeamSlugSchema } from '@/core/shared/schemas/team' + +export { TeamSlugSchema } + +export const TeamNameSchema = z + .string() + .trim() + .min(1, { message: 'Team name cannot be empty' }) + .max(32, { message: 'Team name cannot be longer than 32 characters' }) + .regex(/^[a-zA-Z0-9]+(?:[ _.-][a-zA-Z0-9]+)*$/, { + message: + 'Names can only contain letters and numbers, separated by spaces, underscores, hyphens, or dots', + }) + +export const UpdateTeamNameSchema = z.object({ + teamSlug: TeamSlugSchema, + name: TeamNameSchema, +}) + +export const CreateTeamSchema = z.object({ + name: TeamNameSchema, +}) diff --git a/src/core/modules/teams/teams-repository.server.ts b/src/core/modules/teams/teams-repository.server.ts new file mode 100644 index 000000000..a3a5ef9df --- /dev/null +++ b/src/core/modules/teams/teams-repository.server.ts @@ -0,0 +1,197 @@ +import 'server-only' + +import type { User } from '@supabase/supabase-js' +import { SUPABASE_AUTH_HEADERS } from '@/configs/api' +import type { components as DashboardComponents } from '@/contracts/dashboard-api' +import { api } from '@/core/shared/clients/api' +import { supabaseAdmin } from '@/core/shared/clients/supabase/admin' +import { repoErrorFromHttp } from '@/core/shared/errors' +import type { TeamRequestScope } from '@/core/shared/repository-scope' +import { err, ok, type RepoResult } from '@/core/shared/result' +import type { TeamMember } from './models' + +type TeamsRepositoryDeps = { + apiClient: typeof api + authHeaders: typeof SUPABASE_AUTH_HEADERS + adminClient: typeof supabaseAdmin +} + +export type TeamsRequestScope = TeamRequestScope + +export interface TeamsRepository { + listTeamMembers(): Promise> + updateTeamName( + name: string + ): Promise> + addTeamMember(email: string): Promise> + removeTeamMember(userId: string): Promise> + updateTeamProfilePictureUrl( + profilePictureUrl: string + ): Promise> +} + +function extractSignInProviders(user: User | null | undefined): string[] { + const appProviders = Array.isArray(user?.app_metadata?.providers) + ? user.app_metadata.providers.filter( + (provider): provider is string => typeof provider === 'string' + ) + : [] + const identityProviders = + user?.identities + ?.map((identity) => identity.provider) + .filter((provider): provider is string => typeof provider === 'string') ?? + [] + + return [...new Set([...appProviders, ...identityProviders])] +} + +export function createTeamsRepository( + scope: TeamsRequestScope, + deps: TeamsRepositoryDeps = { + apiClient: api, + authHeaders: SUPABASE_AUTH_HEADERS, + adminClient: supabaseAdmin, + } +): TeamsRepository { + return { + async listTeamMembers(): Promise> { + const { data, error, response } = await deps.apiClient.GET( + '/teams/{teamID}/members', + { + params: { path: { teamID: scope.teamId } }, + headers: deps.authHeaders(scope.accessToken, scope.teamId), + } + ) + + if (!response.ok || error) { + return err( + repoErrorFromHttp( + response.status, + error?.message ?? 'Failed to fetch team members', + error + ) + ) + } + + const members = data?.members ?? [] + const enrichedMembers = await Promise.all( + members.map(async (member) => { + const { data: userData } = + await deps.adminClient.auth.admin.getUserById(member.id) + const user = userData.user + + return { + info: { + id: member.id, + email: member.email, + name: user?.user_metadata?.name, + avatar_url: user?.user_metadata?.avatar_url, + providers: extractSignInProviders(user), + }, + relation: { + added_by: member.addedBy ?? null, + is_default: member.isDefault, + }, + } satisfies TeamMember + }) + ) + + return ok(enrichedMembers) + }, + async updateTeamName( + name + ): Promise< + RepoResult + > { + const { data, error, response } = await deps.apiClient.PATCH( + '/teams/{teamID}', + { + params: { path: { teamID: scope.teamId } }, + headers: deps.authHeaders(scope.accessToken, scope.teamId), + body: { name }, + } + ) + + if (!response.ok || error || !data) { + return err( + repoErrorFromHttp( + response.status, + error?.message ?? 'Failed to update team name', + error + ) + ) + } + + return ok(data) + }, + async addTeamMember(email): Promise> { + const { error, response } = await deps.apiClient.POST( + '/teams/{teamID}/members', + { + params: { path: { teamID: scope.teamId } }, + headers: deps.authHeaders(scope.accessToken, scope.teamId), + body: { email }, + } + ) + + if (!response.ok || error) { + return err( + repoErrorFromHttp( + response.status, + error?.message ?? 'Failed to add team member', + error + ) + ) + } + + return ok(undefined) + }, + async removeTeamMember(userId): Promise> { + const { error, response } = await deps.apiClient.DELETE( + '/teams/{teamID}/members/{userId}', + { + params: { path: { teamID: scope.teamId, userId } }, + headers: deps.authHeaders(scope.accessToken, scope.teamId), + } + ) + + if (!response.ok || error) { + return err( + repoErrorFromHttp( + response.status, + error?.message ?? 'Failed to remove team member', + error + ) + ) + } + + return ok(undefined) + }, + async updateTeamProfilePictureUrl( + profilePictureUrl + ): Promise< + RepoResult + > { + const { data, error, response } = await deps.apiClient.PATCH( + '/teams/{teamID}', + { + params: { path: { teamID: scope.teamId } }, + headers: deps.authHeaders(scope.accessToken, scope.teamId), + body: { profilePictureUrl }, + } + ) + + if (!response.ok || error || !data) { + return err( + repoErrorFromHttp( + response.status, + error?.message ?? 'Failed to update team profile picture', + error + ) + ) + } + + return ok(data) + }, + } +} diff --git a/src/core/modules/teams/user-teams-repository.server.ts b/src/core/modules/teams/user-teams-repository.server.ts new file mode 100644 index 000000000..5e4277332 --- /dev/null +++ b/src/core/modules/teams/user-teams-repository.server.ts @@ -0,0 +1,93 @@ +import 'server-only' + +import { secondsInDay, secondsInMinute } from 'date-fns/constants' +import { SUPABASE_AUTH_HEADERS } from '@/configs/api' +import { api } from '@/core/shared/clients/api' +import { repoErrorFromHttp } from '@/core/shared/errors' +import type { RequestScope } from '@/core/shared/repository-scope' +import { err, ok, type RepoResult } from '@/core/shared/result' +import type { ResolvedTeam, TeamModel } from './models' + +type UserTeamsRepositoryDeps = { + apiClient: typeof api + authHeaders: typeof SUPABASE_AUTH_HEADERS +} + +export type UserTeamsRequestScope = RequestScope + +export interface UserTeamsRepository { + listUserTeams(): Promise> + resolveTeamBySlug( + slug: string, + next?: { tags?: string[] } + ): Promise> +} + +export function createUserTeamsRepository( + scope: UserTeamsRequestScope, + deps: UserTeamsRepositoryDeps = { + apiClient: api, + authHeaders: SUPABASE_AUTH_HEADERS, + } +): UserTeamsRepository { + const listApiUserTeams = async (): Promise> => { + const { data, error, response } = await deps.apiClient.GET('/teams', { + headers: deps.authHeaders(scope.accessToken), + }) + + if (!response.ok || error || !data?.teams) { + return err( + repoErrorFromHttp( + response.status, + error?.message ?? 'Failed to fetch user teams', + error + ) + ) + } + + return ok(data.teams) + } + + return { + async listUserTeams(): Promise> { + const teamsResult = await listApiUserTeams() + + if (!teamsResult.ok) { + return teamsResult + } + + return ok(teamsResult.data) + }, + async resolveTeamBySlug( + slug: string, + next?: { tags?: string[] } + ): Promise> { + const { data, error, response } = await deps.apiClient.GET( + '/teams/resolve', + { + params: { query: { slug } }, + headers: deps.authHeaders(scope.accessToken), + next: { + revalidate: secondsInMinute * 5, + ...next, + }, + } + ) + + if (!response.ok || error || !data) { + return err( + repoErrorFromHttp( + response.status, + error?.message ?? 'Failed to resolve team', + error + ) + ) + } + + return ok({ + id: data.id, + slug: data.slug, + }) + }, + } +} diff --git a/src/core/modules/teams/utils.ts b/src/core/modules/teams/utils.ts new file mode 100644 index 000000000..702eefc9b --- /dev/null +++ b/src/core/modules/teams/utils.ts @@ -0,0 +1,22 @@ +import type { TeamModel } from './models' + +export function getTransformedDefaultTeamName( + team: Pick +): string | null { + if (!team.isDefault || team.name !== team.email) { + return null + } + + const [username] = team.email.split('@') + if (!username) { + return null + } + + return `${username.charAt(0).toUpperCase()}${username.slice(1)}'s Team` +} + +export function getTeamDisplayName( + team: Pick +): string { + return getTransformedDefaultTeamName(team) ?? team.name +} diff --git a/src/core/modules/templates/models.ts b/src/core/modules/templates/models.ts new file mode 100644 index 000000000..aa08bebab --- /dev/null +++ b/src/core/modules/templates/models.ts @@ -0,0 +1,25 @@ +import type { components as InfraComponents } from '@/contracts/infra-api' + +export type Template = Pick< + InfraComponents['schemas']['Template'], + | 'templateID' + | 'buildID' + | 'cpuCount' + | 'memoryMB' + | 'diskSizeMB' + | 'public' + | 'aliases' + | 'names' + | 'createdAt' + | 'updatedAt' + | 'createdBy' + | 'lastSpawnedAt' + | 'spawnCount' + | 'buildCount' + | 'envdVersion' +> + +export type DefaultTemplate = Template & { + isDefault: true + defaultDescription?: string +} diff --git a/src/core/modules/templates/repository.server.ts b/src/core/modules/templates/repository.server.ts new file mode 100644 index 000000000..c432b6a57 --- /dev/null +++ b/src/core/modules/templates/repository.server.ts @@ -0,0 +1,198 @@ +import 'server-only' + +import { SUPABASE_AUTH_HEADERS } from '@/configs/api' +import { CACHE_TAGS } from '@/configs/cache' +import { USE_MOCK_DATA } from '@/configs/flags' +import { + MOCK_DEFAULT_TEMPLATES_DATA, + MOCK_TEMPLATES_DATA, +} from '@/configs/mock-data' +import type { DefaultTemplate, Template } from '@/core/modules/templates/models' +import { api, infra } from '@/core/shared/clients/api' +import { repoErrorFromHttp } from '@/core/shared/errors' +import type { + RequestScope, + TeamRequestScope, +} from '@/core/shared/repository-scope' +import { err, ok, type RepoResult } from '@/core/shared/result' + +type TemplatesRepositoryDeps = { + apiClient: typeof api + infraClient: typeof infra + authHeaders: typeof SUPABASE_AUTH_HEADERS +} + +export interface TeamTemplatesRepository { + getTeamTemplates(): Promise> + deleteTemplate(templateId: string): Promise> + updateTemplateVisibility( + templateId: string, + isPublic: boolean + ): Promise> +} + +export interface DefaultTemplatesRepository { + getDefaultTemplatesCached(): Promise< + RepoResult<{ templates: DefaultTemplate[] }> + > +} + +export function createTemplatesRepository( + scope: TeamRequestScope, + deps: TemplatesRepositoryDeps = { + apiClient: api, + infraClient: infra, + authHeaders: SUPABASE_AUTH_HEADERS, + } +): TeamTemplatesRepository { + return { + async getTeamTemplates() { + if (USE_MOCK_DATA) { + return ok({ + templates: MOCK_TEMPLATES_DATA, + }) + } + const res = await deps.infraClient.GET('/templates', { + params: { + query: { + teamID: scope.teamId, + }, + }, + headers: { + ...deps.authHeaders(scope.accessToken, scope.teamId), + }, + }) + + if (!res.response.ok || res.error) { + return err( + repoErrorFromHttp( + res.response.status, + res.error?.message ?? 'Failed to fetch templates', + res.error + ) + ) + } + + return ok({ + templates: res.data, + }) + }, + async deleteTemplate(templateId) { + const res = await deps.infraClient.DELETE('/templates/{templateID}', { + params: { + path: { + templateID: templateId, + }, + }, + headers: { + ...deps.authHeaders(scope.accessToken, scope.teamId), + }, + }) + + if (!res.response.ok || res.error) { + return err( + repoErrorFromHttp( + res.response.status, + res.error?.message ?? 'Failed to delete template', + res.error + ) + ) + } + + return ok({ success: true as const }) + }, + async updateTemplateVisibility(templateId, isPublic) { + const res = await deps.infraClient.PATCH('/v2/templates/{templateID}', { + body: { + public: isPublic, + }, + params: { + path: { + templateID: templateId, + }, + }, + headers: { + ...deps.authHeaders(scope.accessToken, scope.teamId), + }, + }) + + if (!res.response.ok || res.error) { + return err( + repoErrorFromHttp( + res.response.status, + res.error?.message ?? 'Failed to update template', + res.error + ) + ) + } + + return ok({ success: true as const, public: isPublic }) + }, + } +} + +export function createDefaultTemplatesRepository( + scope: RequestScope, + deps: Pick = { + apiClient: api, + authHeaders: SUPABASE_AUTH_HEADERS, + } +): DefaultTemplatesRepository { + return { + async getDefaultTemplatesCached() { + if (USE_MOCK_DATA) { + return ok({ + templates: MOCK_DEFAULT_TEMPLATES_DATA, + }) + } + + const { data, error, response } = await deps.apiClient.GET( + '/templates/defaults', + { + headers: deps.authHeaders(scope.accessToken), + next: { tags: [CACHE_TAGS.DEFAULT_TEMPLATES] }, + } + ) + + if (!response.ok || error) { + return err( + repoErrorFromHttp( + response.status, + error?.message ?? 'Failed to fetch default templates', + error + ) + ) + } + + if (!data?.templates || data.templates.length === 0) { + return ok({ templates: [] }) + } + + const templates: DefaultTemplate[] = data.templates.map((t) => ({ + templateID: t.id, + buildID: t.buildId, + cpuCount: t.vcpu, + memoryMB: t.ramMb, + diskSizeMB: t.totalDiskSizeMb ?? 0, + envdVersion: t.envdVersion ?? '', + public: t.public, + aliases: t.aliases.map((a) => a.alias), + names: t.aliases.map((a) => { + if (a.namespace && a.namespace.length > 0) { + return `${a.namespace}/${a.alias}` + } + return a.alias + }), + createdAt: t.createdAt, + updatedAt: t.createdAt, + createdBy: null, + lastSpawnedAt: t.createdAt, + spawnCount: t.spawnCount, + buildCount: t.buildCount, + isDefault: true as const, + })) + + return ok({ templates }) + }, + } +} diff --git a/src/core/modules/webhooks/repository.server.ts b/src/core/modules/webhooks/repository.server.ts new file mode 100644 index 000000000..f7188d7df --- /dev/null +++ b/src/core/modules/webhooks/repository.server.ts @@ -0,0 +1,166 @@ +import 'server-only' + +import { SUPABASE_AUTH_HEADERS } from '@/configs/api' +import { infra } from '@/core/shared/clients/api' +import type { components as ArgusComponents } from '@/core/shared/contracts/argus-api.types' +import { repoErrorFromHttp } from '@/core/shared/errors' +import type { TeamRequestScope } from '@/core/shared/repository-scope' +import { err, ok, type RepoResult } from '@/core/shared/result' + +type WebhooksRepositoryDeps = { + infraClient: typeof infra + authHeaders: typeof SUPABASE_AUTH_HEADERS +} + +export type WebhooksScope = TeamRequestScope + +export interface UpsertWebhookInput { + mode: 'create' | 'edit' + webhookId?: string + name: string + url: string + events: string[] + signatureSecret?: string + enabled: boolean +} + +export interface WebhooksRepository { + listWebhooks(): Promise< + RepoResult + > + upsertWebhook(input: UpsertWebhookInput): Promise> + deleteWebhook(webhookId: string): Promise> + updateWebhookSecret( + webhookId: string, + signatureSecret: string + ): Promise> +} + +export function createWebhooksRepository( + scope: WebhooksScope, + deps: WebhooksRepositoryDeps = { + infraClient: infra, + authHeaders: SUPABASE_AUTH_HEADERS, + } +): WebhooksRepository { + return { + async listWebhooks() { + const response = await deps.infraClient.GET('/events/webhooks', { + headers: { + ...deps.authHeaders(scope.accessToken, scope.teamId), + }, + }) + + if (!response.response.ok || response.error) { + if (response.response.status === 404) { + return ok([]) + } + + return err( + repoErrorFromHttp( + response.response.status, + response.error?.message ?? 'Failed to list webhooks', + response.error + ) + ) + } + + return ok(response.data ?? []) + }, + async upsertWebhook(input) { + const response = + input.mode === 'edit' + ? await deps.infraClient.PATCH('/events/webhooks/{webhookID}', { + headers: { + ...deps.authHeaders(scope.accessToken, scope.teamId), + }, + params: { + path: { webhookID: input.webhookId ?? '' }, + }, + body: { + name: input.name, + url: input.url, + events: input.events, + enabled: input.enabled, + }, + }) + : await deps.infraClient.POST('/events/webhooks', { + headers: { + ...deps.authHeaders(scope.accessToken, scope.teamId), + }, + body: { + name: input.name, + url: input.url, + events: input.events, + enabled: input.enabled, + signatureSecret: input.signatureSecret ?? '', + }, + }) + + if (!response.response.ok || response.error) { + return err( + repoErrorFromHttp( + response.response.status, + response.error?.message ?? 'Failed to upsert webhook', + response.error + ) + ) + } + + return ok(undefined) + }, + async deleteWebhook(webhookId) { + const response = await deps.infraClient.DELETE( + '/events/webhooks/{webhookID}', + { + headers: { + ...deps.authHeaders(scope.accessToken, scope.teamId), + }, + params: { + path: { webhookID: webhookId }, + }, + } + ) + + if (!response.response.ok || response.error) { + return err( + repoErrorFromHttp( + response.response.status, + response.error?.message ?? 'Failed to delete webhook', + response.error + ) + ) + } + + return ok(undefined) + }, + async updateWebhookSecret(webhookId, signatureSecret) { + const response = await deps.infraClient.PATCH( + '/events/webhooks/{webhookID}', + { + headers: { + ...deps.authHeaders(scope.accessToken, scope.teamId), + }, + params: { + path: { webhookID: webhookId }, + }, + body: { + signatureSecret, + }, + } + ) + + if (!response.response.ok || response.error) { + return err( + repoErrorFromHttp( + response.response.status, + response.error?.message ?? 'Failed to update webhook secret', + response.error + ) + ) + } + + return ok(undefined) + }, + } +} diff --git a/src/server/auth/auth-actions.ts b/src/core/server/actions/auth-actions.ts similarity index 94% rename from src/server/auth/auth-actions.ts rename to src/core/server/actions/auth-actions.ts index ed00d4e10..84b7353be 100644 --- a/src/server/auth/auth-actions.ts +++ b/src/core/server/actions/auth-actions.ts @@ -7,18 +7,22 @@ import { z } from 'zod' import { CAPTCHA_REQUIRED_SERVER } from '@/configs/flags' import { AUTH_URLS, PROTECTED_URLS } from '@/configs/urls' import { USER_MESSAGES } from '@/configs/user-messages' -import { verifyTurnstileToken } from '@/lib/captcha/turnstile' -import { actionClient } from '@/lib/clients/action' -import { l } from '@/lib/clients/logger/logger' -import { createClient } from '@/lib/clients/supabase/server' -import { relativeUrlSchema } from '@/lib/schemas/url' -import { returnServerError } from '@/lib/utils/action' -import { encodedRedirect } from '@/lib/utils/auth' +import { actionClient } from '@/core/server/actions/client' +import { returnServerError } from '@/core/server/actions/utils' +import { + forgotPasswordSchema, + signInSchema, + signUpSchema, +} from '@/core/server/functions/auth/auth.types' import { shouldWarnAboutAlternateEmail, validateEmail, -} from '@/server/auth/validate-email' -import { forgotPasswordSchema, signInSchema, signUpSchema } from './auth.types' +} from '@/core/server/functions/auth/validate-email' +import { l } from '@/core/shared/clients/logger/logger' +import { createClient } from '@/core/shared/clients/supabase/server' +import { relativeUrlSchema } from '@/core/shared/schemas/url' +import { verifyTurnstileToken } from '@/lib/captcha/turnstile' +import { encodedRedirect } from '@/lib/utils/auth' async function validateCaptcha(captchaToken: string | undefined) { if (!CAPTCHA_REQUIRED_SERVER) { diff --git a/src/core/server/actions/client.ts b/src/core/server/actions/client.ts new file mode 100644 index 000000000..c57fe4b92 --- /dev/null +++ b/src/core/server/actions/client.ts @@ -0,0 +1,310 @@ +import { context, SpanStatusCode, trace } from '@opentelemetry/api' +import type { Session, User } from '@supabase/supabase-js' +import { unauthorized } from 'next/navigation' +import { createMiddleware, createSafeActionClient } from 'next-safe-action' +import { z } from 'zod' +import { + getObservedError, + getObservedErrorMessage, + getObservedException, +} from '@/core/server/adapters/errors' +import { getSessionInsecure } from '@/core/server/functions/auth/get-session' +import getUserByToken from '@/core/server/functions/auth/get-user-by-token' +import { getTeamIdFromSlug } from '@/core/server/functions/team/get-team-id-from-slug' +import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger' +import { createClient } from '@/core/shared/clients/supabase/server' +import { getTracer } from '@/core/shared/clients/tracer' +import { UnauthenticatedError, UnknownError } from '@/core/shared/errors' +import type { + RequestScope, + TeamRequestScope, +} from '@/core/shared/repository-scope' +import { ActionError, flattenClientInputValue } from './utils' + +type SupabaseServerClient = Awaited> + +export interface AuthActionContext { + user: User + session: Session + supabase: SupabaseServerClient +} + +export interface TeamActionContext extends AuthActionContext { + teamId: string +} + +export const actionClient = createSafeActionClient({ + handleServerError(e) { + const s = trace.getActiveSpan() + const observedError = getObservedError(e) + const observedErrorMessage = getObservedErrorMessage(e) + + s?.setStatus({ + code: SpanStatusCode.ERROR, + message: observedErrorMessage, + }) + s?.recordException(getObservedException(e)) + + if (e instanceof ActionError) { + const payload = { + key: e.expected + ? 'action_client:expected_server_error' + : 'action_client:unexpected_server_error', + public_message: e.message, + error: serializeErrorForLog(observedError), + transport_error: + observedError === e ? undefined : serializeErrorForLog(e), + } + + if (e.expected) { + l.warn(payload, observedErrorMessage) + } else { + l.error(payload, observedErrorMessage) + } + + return e.message + } + + const sE = serializeErrorForLog(observedError) as { + code?: string + name?: string + message?: string + } + + l.error( + { + key: 'action_client:unexpected_server_error', + error: sE, + }, + `${sE.name && `${sE.name}: `} ${observedErrorMessage}` + ) + + return UnknownError().message + }, + defineMetadataSchema() { + return z + .object({ + actionName: z.string().optional(), + serverFunctionName: z.string().optional(), + }) + .refine((data) => { + if (!data.actionName && !data.serverFunctionName) { + return 'actionName or serverFunctionName is required in definition metadata' + } + return true + }) + }, + defaultValidationErrorsShape: 'flattened', +}).use(async ({ next, clientInput, metadata }) => { + const t = getTracer() + + const actionOrFunctionName = + metadata?.serverFunctionName || metadata?.actionName || 'Unknown action' + + const type = metadata?.serverFunctionName ? 'function' : 'action' + const name = actionOrFunctionName + + const s = t.startSpan(`${type}:${name}`) + + const startTime = performance.now() + + const result = await context.with( + trace.setSpan(context.active(), s), + async () => { + return next() + } + ) + + const duration = performance.now() - startTime + + const baseLogPayload = { + server_function_type: type, + server_function_name: name, + server_function_input: clientInput, + server_function_duration_ms: duration.toFixed(3), + team_id: flattenClientInputValue(clientInput, 'teamId'), + template_id: flattenClientInputValue(clientInput, 'templateId'), + sandbox_id: flattenClientInputValue(clientInput, 'sandboxId'), + user_id: flattenClientInputValue(clientInput, 'userId'), + } + + s.setAttribute('server_function_type', type) + s.setAttribute('server_function_name', name) + s.setAttribute( + 'server_function_duration_ms', + baseLogPayload.server_function_duration_ms + ) + if (baseLogPayload.team_id) { + s.setAttribute('team_id', baseLogPayload.team_id) + } + if (baseLogPayload.template_id) { + s.setAttribute('template_id', baseLogPayload.template_id) + } + if (baseLogPayload.sandbox_id) { + s.setAttribute('sandbox_id', baseLogPayload.sandbox_id) + } + if (baseLogPayload.user_id) { + s.setAttribute('user_id', baseLogPayload.user_id) + } + + const serverError = result.serverError + const validationErrors = result.validationErrors + const error = serverError || validationErrors + + if (error) { + s.setStatus({ code: SpanStatusCode.ERROR }) + s.recordException(getObservedException(error)) + + const sE = serializeErrorForLog(error) as + | string + | { + code?: string + name?: string + message?: string + } + + l.warn( + { + key: validationErrors + ? 'action_client:validation_failure' + : 'action_client:failure', + ...baseLogPayload, + error: sE, + }, + `${type} ${name} failed in ${baseLogPayload.server_function_duration_ms}ms: ${typeof sE === 'string' ? sE : ((sE.name || sE.code) && `${sE.name || sE.code}: ${sE.message}`) || 'Unknown error'}` + ) + } else { + s.setStatus({ code: SpanStatusCode.OK }) + + l.info( + { + key: `action_client:success`, + ...baseLogPayload, + }, + `${type} ${name} succeeded in ${baseLogPayload.server_function_duration_ms}ms` + ) + } + + s.end() + + return result +}) + +export const authActionClient = actionClient.use(async ({ next }) => { + const supabase = await createClient() + const session = await getSessionInsecure(supabase) + + if (!session) { + throw UnauthenticatedError() + } + + const { + data: { user }, + } = await getUserByToken(session.access_token) + + if (!user || !session) { + throw UnauthenticatedError() + } + + return next({ + ctx: { + user, + session, + supabase, + }, + }) +}) + +export const withTeamSlugResolution = createMiddleware<{ + ctx: AuthActionContext +}>().define(async ({ next, clientInput, ctx }) => { + if ( + !clientInput || + typeof clientInput !== 'object' || + !('teamSlug' in clientInput) + ) { + l.error( + { + key: 'with_team_slug_resolution:missing_team_slug', + context: { + teamSlug: (clientInput as { teamSlug?: string })?.teamSlug, + }, + }, + 'Missing teamSlug when using withTeamSlugResolution middleware' + ) + + throw new Error( + 'teamSlug is required when using withTeamSlugResolution middleware' + ) + } + + const teamId = await getTeamIdFromSlug( + clientInput.teamSlug as string, + ctx.session.access_token + ) + + if (!teamId) { + l.warn( + { + key: 'with_team_slug_resolution:invalid_team_slug', + context: { + teamSlug: clientInput.teamSlug, + }, + }, + `with_team_slug_resolution:invalid_team_slug - invalid team slug provided through withTeamSlugResolution middleware: ${clientInput.teamSlug}` + ) + + throw unauthorized() + } + + return next({ + ctx: { + teamId, + }, + }) +}) + +export function withAuthedRequestRepository< + TRepository, + TContextExtension extends object, +>( + createRepository: (scope: RequestScope) => TRepository, + extendContext: (repository: TRepository) => TContextExtension +) { + return createMiddleware<{ + ctx: AuthActionContext + }>().define(async ({ next, ctx }) => { + const repository = createRepository({ + accessToken: ctx.session.access_token, + }) + + return next({ + ctx: { + ...extendContext(repository), + }, + }) + }) +} + +export function withTeamAuthedRequestRepository< + TRepository, + TContextExtension extends object, +>( + createRepository: (scope: TeamRequestScope) => TRepository, + extendContext: (repository: TRepository) => TContextExtension +) { + return createMiddleware<{ + ctx: TeamActionContext + }>().define(async ({ next, ctx }) => { + const repository = createRepository({ + accessToken: ctx.session.access_token, + teamId: ctx.teamId, + }) + + return next({ + ctx: { + ...extendContext(repository), + }, + }) + }) +} diff --git a/src/core/server/actions/key-actions.ts b/src/core/server/actions/key-actions.ts new file mode 100644 index 000000000..5c5c1bb73 --- /dev/null +++ b/src/core/server/actions/key-actions.ts @@ -0,0 +1,100 @@ +'use server' + +import { revalidatePath, updateTag } from 'next/cache' +import { z } from 'zod' +import { CACHE_TAGS } from '@/configs/cache' +import { createKeysRepository } from '@/core/modules/keys/repository.server' +import { + authActionClient, + withTeamAuthedRequestRepository, + withTeamSlugResolution, +} from '@/core/server/actions/client' +import { returnServerError } from '@/core/server/actions/utils' +import { l } from '@/core/shared/clients/logger/logger' +import { TeamSlugSchema } from '@/core/shared/schemas/team' + +const withKeysRepository = withTeamAuthedRequestRepository( + createKeysRepository, + (keysRepository) => ({ + keysRepository, + }) +) + +// Create API Key + +const CreateApiKeySchema = z.object({ + teamSlug: TeamSlugSchema, + name: z + .string({ error: 'Name is required' }) + .min(1, 'Name cannot be empty') + .max(50, 'Name cannot be longer than 50 characters') + .trim(), +}) + +export const createApiKeyAction = authActionClient + .schema(CreateApiKeySchema) + .metadata({ actionName: 'createApiKey' }) + .use(withTeamSlugResolution) + .use(withKeysRepository) + .action(async ({ parsedInput, ctx }) => { + const { name } = parsedInput + + const result = await ctx.keysRepository.createApiKey(name) + + if (!result.ok) { + l.error({ + key: 'create_api_key:error', + message: result.error.message, + error: result.error, + team_id: ctx.teamId, + user_id: ctx.session.user.id, + context: { + name, + }, + }) + + return returnServerError('Failed to create API Key') + } + + updateTag(CACHE_TAGS.TEAM_API_KEYS(ctx.teamId)) + revalidatePath(`/dashboard/${parsedInput.teamSlug}/keys`, 'page') + + return { + createdApiKey: result.data, + } + }) + +// Delete API Key + +const DeleteApiKeySchema = z.object({ + teamSlug: TeamSlugSchema, + apiKeyId: z.uuid(), +}) + +export const deleteApiKeyAction = authActionClient + .schema(DeleteApiKeySchema) + .metadata({ actionName: 'deleteApiKey' }) + .use(withTeamSlugResolution) + .use(withKeysRepository) + .action(async ({ parsedInput, ctx }) => { + const { apiKeyId } = parsedInput + const result = await ctx.keysRepository.deleteApiKey(apiKeyId) + + if (!result.ok) { + l.error({ + key: 'delete_api_key_action:error', + message: result.error.message, + error: result.error, + team_id: ctx.teamId, + user_id: ctx.session.user.id, + context: { + apiKeyId, + }, + }) + + return returnServerError('Failed to delete API Key') + } + + updateTag(CACHE_TAGS.TEAM_API_KEYS(ctx.teamId)) + revalidatePath(`/dashboard/${parsedInput.teamSlug}/keys`, 'page') + }) diff --git a/src/server/sandboxes/sandbox-actions.ts b/src/core/server/actions/sandbox-actions.ts similarity index 63% rename from src/server/sandboxes/sandbox-actions.ts rename to src/core/server/actions/sandbox-actions.ts index c01315e4b..b0c35cb27 100644 --- a/src/server/sandboxes/sandbox-actions.ts +++ b/src/core/server/actions/sandbox-actions.ts @@ -4,21 +4,24 @@ import { updateTag } from 'next/cache' import { z } from 'zod' import { SUPABASE_AUTH_HEADERS } from '@/configs/api' import { CACHE_TAGS } from '@/configs/cache' -import { authActionClient, withTeamIdResolution } from '@/lib/clients/action' -import { infra } from '@/lib/clients/api' -import { l } from '@/lib/clients/logger/logger' -import { TeamIdOrSlugSchema } from '@/lib/schemas/team' -import { returnServerError } from '@/lib/utils/action' +import { + authActionClient, + withTeamSlugResolution, +} from '@/core/server/actions/client' +import { returnServerError } from '@/core/server/actions/utils' +import { infra } from '@/core/shared/clients/api' +import { l } from '@/core/shared/clients/logger/logger' +import { TeamSlugSchema } from '@/core/shared/schemas/team' const KillSandboxSchema = z.object({ - teamIdOrSlug: TeamIdOrSlugSchema, + teamSlug: TeamSlugSchema, sandboxId: z.string().min(1, 'Sandbox ID is required'), }) export const killSandboxAction = authActionClient .schema(KillSandboxSchema) .metadata({ actionName: 'killSandbox' }) - .use(withTeamIdResolution) + .use(withTeamSlugResolution) .action(async ({ parsedInput, ctx }) => { const { sandboxId } = parsedInput const { session, teamId } = ctx @@ -58,17 +61,3 @@ export const killSandboxAction = authActionClient return returnServerError('Failed to kill sandbox') } }) - -const RevalidateSandboxesSchema = z.object({ - teamIdOrSlug: TeamIdOrSlugSchema, -}) - -export const revalidateSandboxes = authActionClient - .metadata({ serverFunctionName: 'revalidateSandboxes' }) - .inputSchema(RevalidateSandboxesSchema) - .use(withTeamIdResolution) - .action(async ({ parsedInput }) => { - const { teamIdOrSlug } = parsedInput - - updateTag(CACHE_TAGS.TEAM_SANDBOXES_LIST(teamIdOrSlug)) - }) diff --git a/src/core/server/actions/team-actions.ts b/src/core/server/actions/team-actions.ts new file mode 100644 index 000000000..16044dca7 --- /dev/null +++ b/src/core/server/actions/team-actions.ts @@ -0,0 +1,222 @@ +'use server' + +import { fileTypeFromBuffer } from 'file-type' +import { revalidatePath } from 'next/cache' +import { after } from 'next/server' +import { returnValidationErrors } from 'next-safe-action' +import { z } from 'zod' +import { zfd } from 'zod-form-data' +import { SUPABASE_AUTH_HEADERS } from '@/configs/api' +import type { CreateTeamsResponse } from '@/core/modules/billing/models' +import { + CreateTeamSchema, + UpdateTeamNameSchema, +} from '@/core/modules/teams/schemas' +import { createTeamsRepository } from '@/core/modules/teams/teams-repository.server' +import { + authActionClient, + withTeamAuthedRequestRepository, + withTeamSlugResolution, +} from '@/core/server/actions/client' +import { + handleDefaultInfraError, + returnServerError, +} from '@/core/server/actions/utils' +import { toActionErrorFromRepoError } from '@/core/server/adapters/errors' +import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger' +import { deleteFile, getFiles, uploadFile } from '@/core/shared/clients/storage' +import { TeamSlugSchema } from '@/core/shared/schemas/team' + +const withTeamsRepository = withTeamAuthedRequestRepository( + createTeamsRepository, + (teamsRepository) => ({ teamsRepository }) +) + +export const updateTeamNameAction = authActionClient + .schema(UpdateTeamNameSchema) + .metadata({ actionName: 'updateTeamName' }) + .use(withTeamSlugResolution) + .use(withTeamsRepository) + .action(async ({ parsedInput, ctx }) => { + const { name, teamSlug } = parsedInput + const result = await ctx.teamsRepository.updateTeamName(name) + + if (!result.ok) { + return toActionErrorFromRepoError(result.error) + } + + revalidatePath(`/dashboard/${teamSlug}/general`, 'page') + + return result.data + }) + +const AddTeamMemberSchema = z.object({ + teamSlug: TeamSlugSchema, + email: z.email(), +}) + +export const addTeamMemberAction = authActionClient + .schema(AddTeamMemberSchema) + .metadata({ actionName: 'addTeamMember' }) + .use(withTeamSlugResolution) + .use(withTeamsRepository) + .action(async ({ parsedInput, ctx }) => { + const { email, teamSlug } = parsedInput + const result = await ctx.teamsRepository.addTeamMember(email) + + if (!result.ok) { + return toActionErrorFromRepoError(result.error) + } + + revalidatePath(`/dashboard/${teamSlug}/general`, 'page') + }) + +const RemoveTeamMemberSchema = z.object({ + teamSlug: TeamSlugSchema, + userId: z.uuid(), +}) + +export const removeTeamMemberAction = authActionClient + .schema(RemoveTeamMemberSchema) + .metadata({ actionName: 'removeTeamMember' }) + .use(withTeamSlugResolution) + .use(withTeamsRepository) + .action(async ({ parsedInput, ctx }) => { + const { userId, teamSlug } = parsedInput + const result = await ctx.teamsRepository.removeTeamMember(userId) + + if (!result.ok) { + return toActionErrorFromRepoError(result.error) + } + + revalidatePath(`/dashboard/${teamSlug}/general`, 'page') + }) + +export const createTeamAction = authActionClient + .schema(CreateTeamSchema) + .metadata({ actionName: 'createTeam' }) + .action(async ({ parsedInput, ctx }) => { + const { name } = parsedInput + const { session } = ctx + + const response = await fetch(`${process.env.BILLING_API_URL}/teams`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...SUPABASE_AUTH_HEADERS(session.access_token), + }, + body: JSON.stringify({ name }), + }) + + if (!response.ok) { + const status = response.status + const error = await response.json() + + if (status === 400) { + return returnServerError(error?.message ?? 'Failed to create team') + } + + return handleDefaultInfraError(status, error) + } + + const data = (await response.json()) as CreateTeamsResponse + + return data + }) + +const UploadTeamProfilePictureSchema = zfd.formData( + z.object({ + teamSlug: zfd.text(), + image: zfd.file(), + }) +) + +export const uploadTeamProfilePictureAction = authActionClient + .schema(UploadTeamProfilePictureSchema) + .metadata({ actionName: 'uploadTeamProfilePicture' }) + .use(withTeamSlugResolution) + .use(withTeamsRepository) + .action(async ({ parsedInput, ctx }) => { + const { image, teamSlug } = parsedInput + const { teamId, teamsRepository } = ctx + + const allowedTypes = ['image/jpeg', 'image/png'] + + if (!allowedTypes.includes(image.type)) { + return returnValidationErrors(UploadTeamProfilePictureSchema, { + image: { _errors: ['File must be JPG or PNG format'] }, + }) + } + + const MAX_FILE_SIZE = 5 * 1024 * 1024 + + if (image.size > MAX_FILE_SIZE) { + return returnValidationErrors(UploadTeamProfilePictureSchema, { + image: { _errors: ['File size must be less than 5MB'] }, + }) + } + + const arrayBuffer = await image.arrayBuffer() + const buffer = Buffer.from(arrayBuffer) + + const fileType = await fileTypeFromBuffer(buffer) + + if (!fileType) { + return returnValidationErrors(UploadTeamProfilePictureSchema, { + image: { _errors: ['Unable to determine file type'] }, + }) + } + + const allowedMimeTypes = ['image/jpeg', 'image/png'] + if (!allowedMimeTypes.includes(fileType.mime)) { + return returnValidationErrors(UploadTeamProfilePictureSchema, { + image: { + _errors: [ + 'Invalid file type. Only JPEG and PNG images are allowed. File appears to be: ' + + fileType.mime, + ], + }, + }) + } + + const extension = fileType.ext + const fileName = `${Date.now()}.${extension}` + const storagePath = `teams/${teamId}/${fileName}` + + const publicUrl = await uploadFile(buffer, storagePath, fileType.mime) + + const result = await teamsRepository.updateTeamProfilePictureUrl(publicUrl) + if (!result.ok) { + return toActionErrorFromRepoError(result.error) + } + + after(async () => { + try { + const currentFileName = fileName + const folderPath = `teams/${teamId}` + const files = await getFiles(folderPath) + + for (const file of files) { + const filePath = file.name + if (filePath === `${folderPath}/${currentFileName}`) { + continue + } + + await deleteFile(filePath) + } + } catch (cleanupError) { + l.warn({ + key: 'upload_team_profile_picture_action:cleanup_error', + error: serializeErrorForLog(cleanupError), + team_id: teamId, + context: { + image: image.name, + }, + }) + } + }) + + revalidatePath(`/dashboard/${teamSlug}/general`, 'page') + + return result.data + }) diff --git a/src/server/user/user-actions.ts b/src/core/server/actions/user-actions.ts similarity index 98% rename from src/server/user/user-actions.ts rename to src/core/server/actions/user-actions.ts index dd230abd5..5ee67e6ff 100644 --- a/src/server/user/user-actions.ts +++ b/src/core/server/actions/user-actions.ts @@ -4,7 +4,7 @@ import { revalidatePath } from 'next/cache' import { headers } from 'next/headers' import { returnValidationErrors } from 'next-safe-action' import { z } from 'zod' -import { authActionClient } from '@/lib/clients/action' +import { authActionClient } from '@/core/server/actions/client' import { generateE2BUserAccessToken } from '@/lib/utils/server' const UpdateUserSchema = z diff --git a/src/core/server/actions/utils.ts b/src/core/server/actions/utils.ts new file mode 100644 index 000000000..a5cb0df5a --- /dev/null +++ b/src/core/server/actions/utils.ts @@ -0,0 +1,46 @@ +import { getPublicErrorMessage } from '@/core/shared/errors' + +type ActionErrorOptions = { + cause?: unknown + expected?: boolean +} + +export class ActionError extends Error { + public expected: boolean + public override cause?: unknown + + constructor(message: string, options: ActionErrorOptions = {}) { + super(message) + this.name = 'ActionError' + this.expected = options.expected ?? true + this.cause = options.cause + } +} + +export const returnServerError = ( + message: string, + options?: ActionErrorOptions +) => { + throw new ActionError(message, options) +} + +export function handleDefaultInfraError( + status: number, + cause?: unknown +): never { + return returnServerError(getPublicErrorMessage({ status }), { + cause, + expected: status < 500, + }) +} + +export const flattenClientInputValue = ( + clientInput: unknown, + key: string +): string | undefined => { + if (typeof clientInput === 'object' && clientInput && key in clientInput) { + return clientInput[key as keyof typeof clientInput] + } + + return undefined +} diff --git a/src/core/server/actions/webhooks-actions.ts b/src/core/server/actions/webhooks-actions.ts new file mode 100644 index 000000000..3f517ddc1 --- /dev/null +++ b/src/core/server/actions/webhooks-actions.ts @@ -0,0 +1,157 @@ +'use server' + +import { revalidatePath } from 'next/cache' +import { createWebhooksRepository } from '@/core/modules/webhooks/repository.server' +import { + authActionClient, + withTeamAuthedRequestRepository, + withTeamSlugResolution, +} from '@/core/server/actions/client' +import { handleDefaultInfraError } from '@/core/server/actions/utils' +import { + DeleteWebhookSchema, + UpdateWebhookSecretSchema, + UpsertWebhookSchema, +} from '@/core/server/functions/webhooks/schema' +import { l } from '@/core/shared/clients/logger/logger' + +const withWebhooksRepository = withTeamAuthedRequestRepository( + createWebhooksRepository, + (webhooksRepository) => ({ webhooksRepository }) +) + +export const upsertWebhookAction = authActionClient + .schema(UpsertWebhookSchema) + .metadata({ actionName: 'upsertWebhook' }) + .use(withTeamSlugResolution) + .use(withWebhooksRepository) + .action(async ({ parsedInput, ctx }) => { + const { + mode, + teamSlug, + webhookId, + name, + url, + events, + signatureSecret, + enabled, + } = parsedInput + const { session, teamId } = ctx + + const response = await ctx.webhooksRepository.upsertWebhook({ + mode: mode === 'add' ? 'create' : 'edit', + webhookId: webhookId ?? undefined, + name, + url, + events, + signatureSecret: signatureSecret ?? undefined, + enabled, + }) + + if (!response.ok) { + const status = response.error.status + + l.error( + { + key: + mode === 'edit' + ? 'update_webhook:infra_error' + : 'create_webhook:infra_error', + error: response.error, + team_id: teamId, + user_id: session.user.id, + context: { + status, + teamId, + mode, + name, + url, + events, + }, + }, + `Failed to ${mode === 'edit' ? 'update' : 'create'} webhook: ${status}: ${response.error.message}` + ) + + return handleDefaultInfraError(status, response.error) + } + + revalidatePath(`/dashboard/${teamSlug}/webhooks`, 'page') + + return { success: true } + }) + +export const deleteWebhookAction = authActionClient + .schema(DeleteWebhookSchema) + .metadata({ actionName: 'deleteWebhook' }) + .use(withTeamSlugResolution) + .use(withWebhooksRepository) + .action(async ({ parsedInput, ctx }) => { + const { teamSlug, webhookId } = parsedInput + const { session, teamId } = ctx + + const response = await ctx.webhooksRepository.deleteWebhook(webhookId) + + if (!response.ok) { + const status = response.error.status + + l.error( + { + key: 'delete_webhook:infra_error', + status, + error: response.error, + team_id: teamId, + user_id: session.user.id, + context: { + teamId, + }, + }, + `Failed to delete webhook: ${status}: ${response.error.message}` + ) + + return handleDefaultInfraError(status, response.error) + } + + revalidatePath(`/dashboard/${teamSlug}/webhooks`, 'page') + + return { success: true } + }) + +export const updateWebhookSecretAction = authActionClient + .schema(UpdateWebhookSecretSchema) + .metadata({ actionName: 'updateWebhookSecret' }) + .use(withTeamSlugResolution) + .use(withWebhooksRepository) + .action(async ({ parsedInput, ctx }) => { + const { teamSlug, webhookId, signatureSecret } = parsedInput + const { session, teamId } = ctx + + const response = await ctx.webhooksRepository.updateWebhookSecret( + webhookId, + signatureSecret + ) + + if (!response.ok) { + const status = response.error.status + + l.error( + { + key: 'update_webhook_secret:infra_error', + error: response.error, + team_id: teamId, + user_id: session.user.id, + context: { + status, + teamId, + webhookId, + }, + }, + `Failed to update webhook secret: ${status}: ${response.error.message}` + ) + + return handleDefaultInfraError(status, response.error) + } + + revalidatePath(`/dashboard/${teamSlug}/webhooks`, 'page') + + return { success: true } + }) diff --git a/src/core/server/adapters/errors.ts b/src/core/server/adapters/errors.ts new file mode 100644 index 000000000..2a367419a --- /dev/null +++ b/src/core/server/adapters/errors.ts @@ -0,0 +1,177 @@ +import { SpanStatusCode, trace } from '@opentelemetry/api' +import { TRPCError } from '@trpc/server' +import { ActionError } from '@/core/server/actions/utils' +import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger' +import { + getPublicRepoErrorMessage, + PUBLIC_ERROR_MESSAGE_FORBIDDEN_TEAM, + PUBLIC_ERROR_MESSAGE_INTERNAL, + PUBLIC_ERROR_MESSAGE_UNAUTHENTICATED, +} from '@/core/shared/errors' +import type { RepoError } from '@/core/shared/result' + +export function getObservedError(error: unknown): unknown { + if ( + typeof error === 'object' && + error !== null && + 'cause' in error && + error.cause !== undefined && + error.cause !== null + ) { + return error.cause + } + + return error +} + +export function getObservedErrorMessage(error: unknown): string { + const observedError = getObservedError(error) + + if (typeof observedError === 'string') { + return observedError + } + + if ( + typeof observedError === 'object' && + observedError !== null && + 'message' in observedError && + typeof observedError.message === 'string' + ) { + return observedError.message + } + + return 'Unknown error' +} + +export function getObservedException(error: unknown): Error { + const observedError = getObservedError(error) + + if (observedError instanceof Error) { + return observedError + } + + return new Error(getObservedErrorMessage(observedError)) +} + +export function isExpectedRepoError(error: RepoError): boolean { + switch (error.code) { + case 'unauthorized': + case 'forbidden': + case 'not_found': + case 'validation': + case 'conflict': + return true + default: + return false + } +} + +export function isExpectedTRPCError(error: TRPCError): boolean { + switch (error.code) { + case 'UNAUTHORIZED': + case 'FORBIDDEN': + case 'NOT_FOUND': + case 'BAD_REQUEST': + case 'CONFLICT': + return true + default: + return false + } +} + +export const forbiddenTeamAccessError = () => + new TRPCError({ + code: 'FORBIDDEN', + message: PUBLIC_ERROR_MESSAGE_FORBIDDEN_TEAM, + }) + +export const unauthorizedUserError = () => + new TRPCError({ + code: 'UNAUTHORIZED', + message: PUBLIC_ERROR_MESSAGE_UNAUTHENTICATED, + }) + +export const internalServerError = () => + new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: PUBLIC_ERROR_MESSAGE_INTERNAL, + }) + +function trpcCodeFromRepoError(code: RepoError['code']): TRPCError['code'] { + switch (code) { + case 'unauthorized': + return 'UNAUTHORIZED' + case 'forbidden': + return 'FORBIDDEN' + case 'not_found': + return 'NOT_FOUND' + case 'validation': + return 'BAD_REQUEST' + case 'conflict': + return 'CONFLICT' + default: + return 'INTERNAL_SERVER_ERROR' + } +} + +function logObfuscatedRepoError( + transport: 'trpc' | 'action' | 'route', + error: RepoError +) { + const publicMessage = getPublicRepoErrorMessage(error) + if (publicMessage === error.message) { + return + } + + const observedMessage = getObservedErrorMessage(error) + const span = trace.getActiveSpan() + + span?.setStatus({ + code: SpanStatusCode.ERROR, + message: observedMessage, + }) + span?.recordException(getObservedException(error)) + + const payload = { + key: `transport:${transport}:repo_error`, + repo_error_code: error.code, + repo_error_status: error.status, + public_message: publicMessage, + error: serializeErrorForLog(error), + } + + if (isExpectedRepoError(error)) { + l.warn(payload, `[${transport}] ${error.code}: ${observedMessage}`) + return + } + + l.error(payload, `[${transport}] ${error.code}: ${observedMessage}`) +} + +export function throwTRPCErrorFromRepoError(error: RepoError): never { + logObfuscatedRepoError('trpc', error) + + throw new TRPCError({ + code: trpcCodeFromRepoError(error.code), + message: getPublicRepoErrorMessage(error), + cause: error, + }) +} + +export function toActionErrorFromRepoError(error: RepoError): never { + logObfuscatedRepoError('action', error) + + throw new ActionError(getPublicRepoErrorMessage(error), { + cause: error, + expected: isExpectedRepoError(error), + }) +} + +export function toRouteErrorResponse(error: RepoError): Response { + logObfuscatedRepoError('route', error) + + return Response.json( + { error: getPublicRepoErrorMessage(error) }, + { status: error.status } + ) +} diff --git a/src/server/api/middlewares/auth.ts b/src/core/server/api/middlewares/auth.ts similarity index 81% rename from src/server/api/middlewares/auth.ts rename to src/core/server/api/middlewares/auth.ts index 9c7ec3e8b..76b4fb2a2 100644 --- a/src/server/api/middlewares/auth.ts +++ b/src/core/server/api/middlewares/auth.ts @@ -4,11 +4,11 @@ import { parseCookieHeader, serializeCookieHeader, } from '@supabase/ssr' -import { getTracer } from '@/lib/clients/tracer' -import { getSessionInsecure } from '@/server/auth/get-session' -import getUserByToken from '@/server/auth/get-user-by-token' -import { unauthorizedUserError } from '../errors' -import { t } from '../init' +import { unauthorizedUserError } from '@/core/server/adapters/errors' +import { getSessionInsecure } from '@/core/server/functions/auth/get-session' +import getUserByToken from '@/core/server/functions/auth/get-user-by-token' +import { t } from '@/core/server/trpc/init' +import { getTracer } from '@/core/shared/clients/tracer' const createSupabaseServerClient = (headers: Headers) => { return createServerClient( @@ -20,12 +20,12 @@ const createSupabaseServerClient = (headers: Headers) => { return parseCookieHeader(headers.get('cookie') ?? '') }, setAll(cookiesToSet) { - cookiesToSet.forEach(({ name, value, options }) => + cookiesToSet.forEach(({ name, value, options }) => { headers.append( 'Set-Cookie', serializeCookieHeader(name, value, options) ) - ) + }) }, }, } diff --git a/src/core/server/api/middlewares/repository.ts b/src/core/server/api/middlewares/repository.ts new file mode 100644 index 000000000..5d1b7b073 --- /dev/null +++ b/src/core/server/api/middlewares/repository.ts @@ -0,0 +1,77 @@ +import { + forbiddenTeamAccessError, + unauthorizedUserError, +} from '@/core/server/adapters/errors' +import { t } from '@/core/server/trpc/init' +import type { + RequestScope, + TeamRequestScope, +} from '@/core/shared/repository-scope' + +export function withAuthedRequestRepository< + TRepository, + TContextExtension extends object, +>( + createRepository: (scope: RequestScope) => TRepository, + extendContext: (repository: TRepository) => TContextExtension +) { + return t.middleware(({ ctx, next }) => { + if (!ctx.session) { + throw unauthorizedUserError() + } + + if (!ctx.user) { + throw unauthorizedUserError() + } + + const repository = createRepository({ + accessToken: ctx.session.access_token, + }) + + return next({ + ctx: { + ...ctx, + session: ctx.session, + user: ctx.user, + ...extendContext(repository), + }, + }) + }) +} + +export function withTeamAuthedRequestRepository< + TRepository, + TContextExtension extends object, +>( + createRepository: (scope: TeamRequestScope) => TRepository, + extendContext: (repository: TRepository) => TContextExtension +) { + return t.middleware(({ ctx, next }) => { + if (!ctx.session) { + throw unauthorizedUserError() + } + + if (!ctx.user) { + throw unauthorizedUserError() + } + + if (!ctx.teamId) { + throw forbiddenTeamAccessError() + } + + const repository = createRepository({ + accessToken: ctx.session.access_token, + teamId: ctx.teamId, + }) + + return next({ + ctx: { + ...ctx, + session: ctx.session, + user: ctx.user, + teamId: ctx.teamId, + ...extendContext(repository), + }, + }) + }) +} diff --git a/src/server/api/middlewares/telemetry.ts b/src/core/server/api/middlewares/telemetry.ts similarity index 83% rename from src/server/api/middlewares/telemetry.ts rename to src/core/server/api/middlewares/telemetry.ts index 573a0a53b..67011e9cd 100644 --- a/src/server/api/middlewares/telemetry.ts +++ b/src/core/server/api/middlewares/telemetry.ts @@ -8,13 +8,18 @@ import { } from '@opentelemetry/api' import type { User } from '@supabase/supabase-js' import { TRPCError } from '@trpc/server' -import { serializeError } from 'serialize-error' -import { l } from '@/lib/clients/logger/logger' -import { getMeter } from '@/lib/clients/meter' -import { getTracer } from '@/lib/clients/tracer' -import { flattenClientInputValue } from '@/lib/utils/action' -import { internalServerError } from '../errors' -import { t } from '../init' +import { flattenClientInputValue } from '@/core/server/actions/utils' +import { + getObservedError, + getObservedErrorMessage, + getObservedException, + internalServerError, + isExpectedTRPCError, +} from '@/core/server/adapters/errors' +import { t } from '@/core/server/trpc/init' +import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger' +import { getMeter } from '@/core/shared/clients/meter' +import { getTracer } from '@/core/shared/clients/tracer' /** * Telemetry State @@ -198,6 +203,8 @@ export const endTelemetryMiddleware = t.middleware( if (!result.ok) { const error = result.error + const observedError = getObservedError(error) + const observedErrorMessage = getObservedErrorMessage(error) metrics.errorCounter.add(1, { ...metrics.attrs, @@ -207,54 +214,46 @@ export const endTelemetryMiddleware = t.middleware( span.setStatus({ code: SpanStatusCode.ERROR, - message: error.message, + message: observedErrorMessage, }) - span.recordException(error) + span.recordException(getObservedException(error)) - // internal errors are mostly unexpected - log as error and potentially obfuscate - if (error.code === 'INTERNAL_SERVER_ERROR') { - l.error( - { - key: 'trpc:unexpected_error', + const payload = { + ...contextAttrs, - ...contextAttrs, + 'trpc.router.name': routerName, + 'trpc.procedure.type': procedureType, + 'trpc.procedure.name': procedureName, + 'trpc.procedure.input': rawInput, + 'trpc.procedure.duration_ms': durationMs, - 'trpc.router.name': routerName, - 'trpc.procedure.type': procedureType, - 'trpc.procedure.name': procedureName, - 'trpc.procedure.input': rawInput, - 'trpc.procedure.duration_ms': durationMs, + error: serializeErrorForLog(observedError), + transport_error: + observedError === error ? undefined : serializeErrorForLog(error), + } - error: serializeError(error), + if (!isExpectedTRPCError(error)) { + l.error( + { + key: 'trpc:unexpected_error', + ...payload, }, - `[tRPC] ${routerName}.${procedureName}: ${error.code} ${error?.cause?.message || error.message}` + `[tRPC] ${routerName}.${procedureName}: ${error.code} ${observedErrorMessage}` ) - // when it's internal error AND has a cause (unhandled errors), obfuscate if (error.cause) { throw internalServerError() } - // otherwise return as is return result } - // expected errors (validation, not found, etc) - log as warning l.warn( { key: 'trpc:procedure_failure', - - ...contextAttrs, - - 'trpc.router.name': routerName, - 'trpc.procedure.type': procedureType, - 'trpc.procedure.name': procedureName, - 'trpc.procedure.input': rawInput, - 'trpc.procedure.duration_ms': durationMs, - - error: serializeError(error), + ...payload, }, - `[tRPC] ${routerName}.${procedureName}: ${error.code} ${error.message}` + `[tRPC] ${routerName}.${procedureName}: ${error.code} ${observedErrorMessage}` ) return result @@ -293,7 +292,7 @@ export const endTelemetryMiddleware = t.middleware( 'trpc.router.name': routerName, 'trpc.procedure.name': procedureName, - error: serializeError(error), + error: serializeErrorForLog(error), }, `[tRPC] telemetry error in ${routerName}.${procedureName}${error && typeof error === 'object' && error !== null && 'message' in error && typeof error.message === 'string' ? `: ${error.message}` : ''}` ) diff --git a/src/core/server/api/routers/billing.ts b/src/core/server/api/routers/billing.ts new file mode 100644 index 000000000..2419c7ef1 --- /dev/null +++ b/src/core/server/api/routers/billing.ts @@ -0,0 +1,139 @@ +import { TRPCError } from '@trpc/server' +import { headers } from 'next/headers' +import { z } from 'zod' +import { createBillingRepository } from '@/core/modules/billing/repository.server' +import { createTeamsRepository } from '@/core/modules/teams/teams-repository.server' +import { throwTRPCErrorFromRepoError } from '@/core/server/adapters/errors' +import { withTeamAuthedRequestRepository } from '@/core/server/api/middlewares/repository' +import { createTRPCRouter } from '@/core/server/trpc/init' +import { protectedTeamProcedure } from '@/core/server/trpc/procedures' +import { + ADDON_500_SANDBOXES_ID, + ADDON_PURCHASE_ACTION_ERRORS, +} from '@/features/dashboard/billing/constants' + +function limitTypeToKey(type: 'limit' | 'alert') { + return type === 'limit' ? 'limit_amount_gte' : 'alert_amount_gte' +} + +const billingRepositoryProcedure = protectedTeamProcedure.use( + withTeamAuthedRequestRepository( + createBillingRepository, + (billingRepository) => ({ + billingRepository, + }) + ) +) + +const billingAndTeamsRepositoryProcedure = billingRepositoryProcedure.use( + withTeamAuthedRequestRepository(createTeamsRepository, (teamsRepository) => ({ + teamsRepository, + })) +) + +export const billingRouter = createTRPCRouter({ + createCheckout: billingRepositoryProcedure + .input(z.object({ tierId: z.string() })) + .mutation(async ({ ctx, input }) => { + const result = await ctx.billingRepository.createCheckout(input.tierId) + if (!result.ok) throwTRPCErrorFromRepoError(result.error) + return result.data + }), + + createCustomerPortalSession: billingRepositoryProcedure.mutation( + async ({ ctx }) => { + const origin = (await headers()).get('origin') + const result = + await ctx.billingRepository.createCustomerPortalSession(origin) + if (!result.ok) throwTRPCErrorFromRepoError(result.error) + return { url: result.data.url } + } + ), + + getItems: billingRepositoryProcedure.query(async ({ ctx }) => { + const result = await ctx.billingRepository.getItems() + if (!result.ok) throwTRPCErrorFromRepoError(result.error) + return result.data + }), + + getUsage: billingRepositoryProcedure.query(async ({ ctx }) => { + const result = await ctx.billingRepository.getUsage() + if (!result.ok) throwTRPCErrorFromRepoError(result.error) + return result.data + }), + + getInvoices: billingRepositoryProcedure.query(async ({ ctx }) => { + const result = await ctx.billingRepository.getInvoices() + if (!result.ok) throwTRPCErrorFromRepoError(result.error) + return result.data + }), + + getLimits: billingRepositoryProcedure.query(async ({ ctx }) => { + const result = await ctx.billingRepository.getLimits() + if (!result.ok) throwTRPCErrorFromRepoError(result.error) + return result.data + }), + + setLimit: billingRepositoryProcedure + .input( + z.object({ + type: z.enum(['limit', 'alert']), + value: z.number().min(1), + }) + ) + .mutation(async ({ ctx, input }) => { + const { type, value } = input + const result = await ctx.billingRepository.setLimit( + limitTypeToKey(type), + value + ) + if (!result.ok) throwTRPCErrorFromRepoError(result.error) + }), + + clearLimit: billingRepositoryProcedure + .input(z.object({ type: z.enum(['limit', 'alert']) })) + .mutation(async ({ ctx, input }) => { + const { type } = input + const result = await ctx.billingRepository.clearLimit( + limitTypeToKey(type) + ) + if (!result.ok) throwTRPCErrorFromRepoError(result.error) + }), + + createOrder: billingRepositoryProcedure + .input(z.object({ itemId: z.literal(ADDON_500_SANDBOXES_ID) })) + .mutation(async ({ ctx, input }) => { + const { itemId } = input + const result = await ctx.billingRepository.createOrder(itemId) + if (!result.ok) throwTRPCErrorFromRepoError(result.error) + return result.data + }), + + confirmOrder: billingRepositoryProcedure + .input(z.object({ orderId: z.string().uuid() })) + .mutation(async ({ ctx, input }) => { + const { orderId } = input + const result = await ctx.billingRepository.confirmOrder(orderId) + if (!result.ok) { + if ( + result.error.message.includes( + 'Missing payment method, please update your payment information' + ) + ) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: ADDON_PURCHASE_ACTION_ERRORS.missingPaymentMethod, + }) + } + throwTRPCErrorFromRepoError(result.error) + } + + return result.data + }), + + getCustomerSession: billingRepositoryProcedure.mutation(async ({ ctx }) => { + const result = await ctx.billingRepository.getCustomerSession() + if (!result.ok) throwTRPCErrorFromRepoError(result.error) + return result.data + }), +}) diff --git a/src/server/api/routers/builds.ts b/src/core/server/api/routers/builds.ts similarity index 53% rename from src/server/api/routers/builds.ts rename to src/core/server/api/routers/builds.ts index 327447c4a..98aab5bf8 100644 --- a/src/server/api/routers/builds.ts +++ b/src/core/server/api/routers/builds.ts @@ -1,19 +1,30 @@ import { z } from 'zod' -import { LOG_RETENTION_MS } from '@/features/dashboard/templates/builds/constants' -import { buildsRepo } from '@/server/api/repositories/builds.repository' -import { createTRPCRouter } from '../init' import { - type BuildDetailsDTO, - type BuildLogDTO, - type BuildLogsDTO, + type BuildDetailsModel, + type BuildLogModel, + type BuildLogsModel, BuildStatusSchema, -} from '../models/builds.models' -import { protectedTeamProcedure } from '../procedures' +} from '@/core/modules/builds/models' +import { createBuildsRepository } from '@/core/modules/builds/repository.server' +import { throwTRPCErrorFromRepoError } from '@/core/server/adapters/errors' +import { withTeamAuthedRequestRepository } from '@/core/server/api/middlewares/repository' +import { createTRPCRouter } from '@/core/server/trpc/init' +import { protectedTeamProcedure } from '@/core/server/trpc/procedures' +import { LOG_RETENTION_MS } from '@/features/dashboard/templates/builds/constants' + +const buildsRepositoryProcedure = protectedTeamProcedure.use( + withTeamAuthedRequestRepository( + createBuildsRepository, + (buildsRepository) => ({ + buildsRepository, + }) + ) +) export const buildsRouter = createTRPCRouter({ // QUERIES - list: protectedTeamProcedure + list: buildsRepositoryProcedure .input( z.object({ buildIdOrTemplate: z.string().optional(), @@ -23,36 +34,41 @@ export const buildsRouter = createTRPCRouter({ }) ) .query(async ({ ctx, input }) => { - const { teamId } = ctx const { buildIdOrTemplate, statuses, limit, cursor } = input - return await buildsRepo.listBuilds( - ctx.session.access_token, - teamId, + const result = await ctx.buildsRepository.listBuilds( buildIdOrTemplate, statuses, - { limit, cursor } + { + limit, + cursor, + } ) + if (!result.ok) { + throwTRPCErrorFromRepoError(result.error) + } + + return result.data }), - runningStatuses: protectedTeamProcedure + runningStatuses: buildsRepositoryProcedure .input( z.object({ buildIds: z.array(z.string()).max(100), }) ) .query(async ({ ctx, input }) => { - const { teamId } = ctx const { buildIds } = input - return await buildsRepo.getRunningStatuses( - ctx.session.access_token, - teamId, - buildIds - ) + const result = await ctx.buildsRepository.getRunningStatuses(buildIds) + if (!result.ok) { + throwTRPCErrorFromRepoError(result.error) + } + + return result.data }), - buildDetails: protectedTeamProcedure + buildDetails: buildsRepositoryProcedure .input( z.object({ templateId: z.string(), @@ -60,16 +76,15 @@ export const buildsRouter = createTRPCRouter({ }) ) .query(async ({ ctx, input }) => { - const { teamId } = ctx const { buildId, templateId } = input - const buildInfo = await buildsRepo.getBuildInfo( - ctx.session.access_token, - buildId, - teamId - ) + const buildInfoResult = await ctx.buildsRepository.getBuildInfo(buildId) + if (!buildInfoResult.ok) { + throwTRPCErrorFromRepoError(buildInfoResult.error) + } + const buildInfo = buildInfoResult.data - const result: BuildDetailsDTO = { + const result: BuildDetailsModel = { templateNames: buildInfo.names, template: buildInfo.names?.[0] ?? templateId, startedAt: buildInfo.createdAt, @@ -82,7 +97,7 @@ export const buildsRouter = createTRPCRouter({ return result }), - buildLogsBackwardsReversed: protectedTeamProcedure + buildLogsBackwardsReversed: buildsRepositoryProcedure .input( z.object({ templateId: z.string(), @@ -92,24 +107,25 @@ export const buildsRouter = createTRPCRouter({ }) ) .query(async ({ ctx, input }) => { - const { teamId } = ctx const { buildId, templateId, level } = input let { cursor } = input - cursor ??= new Date().getTime() + cursor ??= Date.now() const direction = 'backward' const limit = 100 - const buildLogs = await buildsRepo.getInfraBuildLogs( - ctx.session.access_token, - teamId, + const buildLogsResult = await ctx.buildsRepository.getInfraBuildLogs( templateId, buildId, { cursor, limit, direction, level } ) + if (!buildLogsResult.ok) { + throwTRPCErrorFromRepoError(buildLogsResult.error) + } + const buildLogs = buildLogsResult.data - const logs: BuildLogDTO[] = buildLogs.logs + const logs: BuildLogModel[] = buildLogs.logs .map((log) => ({ timestampUnix: new Date(log.timestamp).getTime(), level: log.level, @@ -121,7 +137,7 @@ export const buildsRouter = createTRPCRouter({ const cursorLog = logs[0] const nextCursor = hasMore ? (cursorLog?.timestampUnix ?? null) : null - const result: BuildLogsDTO = { + const result: BuildLogsModel = { logs, nextCursor, } @@ -129,7 +145,7 @@ export const buildsRouter = createTRPCRouter({ return result }), - buildLogsForward: protectedTeamProcedure + buildLogsForward: buildsRepositoryProcedure .input( z.object({ templateId: z.string(), @@ -139,33 +155,40 @@ export const buildsRouter = createTRPCRouter({ }) ) .query(async ({ ctx, input }) => { - const { teamId } = ctx const { buildId, templateId, level } = input let { cursor } = input - cursor ??= new Date().getTime() + cursor ??= Date.now() const direction = 'forward' const limit = 100 - const buildLogs = await buildsRepo.getInfraBuildLogs( - ctx.session.access_token, - teamId, + const buildLogsResult = await ctx.buildsRepository.getInfraBuildLogs( templateId, buildId, { cursor, limit, direction, level } ) - - const logs: BuildLogDTO[] = buildLogs.logs.map((log) => ({ - timestampUnix: new Date(log.timestamp).getTime(), - level: log.level, - message: log.message, - })) + if (!buildLogsResult.ok) { + throwTRPCErrorFromRepoError(buildLogsResult.error) + } + const buildLogs = buildLogsResult.data + + const logs: BuildLogModel[] = buildLogs.logs.map( + (log: { + timestamp: string + level: BuildLogModel['level'] + message: string + }) => ({ + timestampUnix: new Date(log.timestamp).getTime(), + level: log.level, + message: log.message, + }) + ) const newestLog = logs[logs.length - 1] const nextCursor = newestLog?.timestampUnix ?? cursor - const result: BuildLogsDTO = { + const result: BuildLogsModel = { logs, nextCursor, } @@ -175,5 +198,5 @@ export const buildsRouter = createTRPCRouter({ }) function checkIfBuildStillHasLogs(createdAt: number): boolean { - return new Date().getTime() - createdAt < LOG_RETENTION_MS + return Date.now() - createdAt < LOG_RETENTION_MS } diff --git a/src/server/api/routers/index.ts b/src/core/server/api/routers/index.ts similarity index 84% rename from src/server/api/routers/index.ts rename to src/core/server/api/routers/index.ts index 27b9704d8..530503282 100644 --- a/src/server/api/routers/index.ts +++ b/src/core/server/api/routers/index.ts @@ -1,9 +1,10 @@ -import { createCallerFactory, createTRPCRouter } from '../init' +import { createCallerFactory, createTRPCRouter } from '@/core/server/trpc/init' import { billingRouter } from './billing' import { buildsRouter } from './builds' import { sandboxRouter } from './sandbox' import { sandboxesRouter } from './sandboxes' import { supportRouter } from './support' +import { teamsRouter } from './teams' import { templatesRouter } from './templates' export const trpcAppRouter = createTRPCRouter({ @@ -13,6 +14,7 @@ export const trpcAppRouter = createTRPCRouter({ builds: buildsRouter, billing: billingRouter, support: supportRouter, + teams: teamsRouter, }) export type TRPCAppRouter = typeof trpcAppRouter diff --git a/src/server/api/routers/sandbox.ts b/src/core/server/api/routers/sandbox.ts similarity index 57% rename from src/server/api/routers/sandbox.ts rename to src/core/server/api/routers/sandbox.ts index 649822fbc..948367c59 100644 --- a/src/server/api/routers/sandbox.ts +++ b/src/core/server/api/routers/sandbox.ts @@ -1,50 +1,62 @@ import { millisecondsInDay } from 'date-fns/constants' import { z } from 'zod' -import { SANDBOX_MONITORING_METRICS_RETENTION_MS } from '@/features/dashboard/sandbox/monitoring/utils/constants' -import { SandboxIdSchema } from '@/lib/schemas/api' -import { createTRPCRouter } from '../init' import { deriveSandboxLifecycleFromEvents, - mapApiSandboxRecordToDTO, - mapInfraSandboxDetailsToDTO, - mapInfraSandboxLogToDTO, - type SandboxDetailsDTO, - type SandboxLogDTO, - type SandboxLogsDTO, -} from '../models/sandboxes.models' -import { protectedTeamProcedure } from '../procedures' -import { sandboxesRepo } from '../repositories/sandboxes.repository' + mapApiSandboxRecordToModel, + mapInfraSandboxDetailsToModel, + mapInfraSandboxLogToModel, + type SandboxDetailsModel, + type SandboxLogModel, + type SandboxLogsModel, +} from '@/core/modules/sandboxes/models' +import { createSandboxesRepository } from '@/core/modules/sandboxes/repository.server' +import { throwTRPCErrorFromRepoError } from '@/core/server/adapters/errors' +import { withTeamAuthedRequestRepository } from '@/core/server/api/middlewares/repository' +import { createTRPCRouter } from '@/core/server/trpc/init' +import { protectedTeamProcedure } from '@/core/server/trpc/procedures' +import { SandboxIdSchema } from '@/core/shared/schemas/api' +import { SANDBOX_MONITORING_METRICS_RETENTION_MS } from '@/features/dashboard/sandbox/monitoring/utils/constants' + +const sandboxRepositoryProcedure = protectedTeamProcedure.use( + withTeamAuthedRequestRepository( + createSandboxesRepository, + (sandboxesRepository) => ({ + sandboxesRepository, + }) + ) +) export const sandboxRouter = createTRPCRouter({ // QUERIES - details: protectedTeamProcedure + details: sandboxRepositoryProcedure .input( z.object({ sandboxId: SandboxIdSchema, }) ) .query(async ({ ctx, input }) => { - const { teamId, session } = ctx const { sandboxId } = input - const detailsResult = await sandboxesRepo.getSandboxDetails( - session.access_token, - teamId, - sandboxId - ) + const detailsResult = + await ctx.sandboxesRepository.getSandboxDetails(sandboxId) + if (!detailsResult.ok) { + throwTRPCErrorFromRepoError(detailsResult.error) + } - const mappedDetails: SandboxDetailsDTO = - detailsResult.source === 'infra' - ? mapInfraSandboxDetailsToDTO(detailsResult.details) - : mapApiSandboxRecordToDTO(detailsResult.details) + const mappedDetails: SandboxDetailsModel = + detailsResult.data.source === 'infra' + ? mapInfraSandboxDetailsToModel(detailsResult.data.details) + : mapApiSandboxRecordToModel(detailsResult.data.details) - const lifecycleEvents = await sandboxesRepo.getSandboxLifecycleEvents( - session.access_token, - teamId, - sandboxId + const lifecycleEventsResult = + await ctx.sandboxesRepository.getSandboxLifecycleEvents(sandboxId) + if (!lifecycleEventsResult.ok) { + throwTRPCErrorFromRepoError(lifecycleEventsResult.error) + } + const derivedLifecycle = deriveSandboxLifecycleFromEvents( + lifecycleEventsResult.data ) - const derivedLifecycle = deriveSandboxLifecycleFromEvents(lifecycleEvents) const fallbackPausedAt = mappedDetails.state === 'paused' ? mappedDetails.endAt : null const fallbackEndedAt = @@ -63,7 +75,7 @@ export const sandboxRouter = createTRPCRouter({ } }), - logsBackwardsReversed: protectedTeamProcedure + logsBackwardsReversed: sandboxRepositoryProcedure .input( z.object({ sandboxId: SandboxIdSchema, @@ -73,7 +85,6 @@ export const sandboxRouter = createTRPCRouter({ }) ) .query(async ({ ctx, input }) => { - const { teamId, session } = ctx const { sandboxId, level, search } = input let { cursor } = input @@ -82,22 +93,24 @@ export const sandboxRouter = createTRPCRouter({ const direction = 'backward' const limit = 100 - const sandboxLogs = await sandboxesRepo.getSandboxLogs( - session.access_token, - teamId, + const sandboxLogsResult = await ctx.sandboxesRepository.getSandboxLogs( sandboxId, { cursor, limit, direction, level, search } ) + if (!sandboxLogsResult.ok) { + throwTRPCErrorFromRepoError(sandboxLogsResult.error) + } + const sandboxLogs = sandboxLogsResult.data - const logs: SandboxLogDTO[] = sandboxLogs.logs - .map(mapInfraSandboxLogToDTO) + const logs: SandboxLogModel[] = sandboxLogs.logs + .map(mapInfraSandboxLogToModel) .reverse() const hasMore = logs.length === limit const cursorLog = logs[0] const nextCursor = hasMore ? (cursorLog?.timestampUnix ?? null) : null - const result: SandboxLogsDTO = { + const result: SandboxLogsModel = { logs, nextCursor, } @@ -105,7 +118,7 @@ export const sandboxRouter = createTRPCRouter({ return result }), - logsForward: protectedTeamProcedure + logsForward: sandboxRepositoryProcedure .input( z.object({ sandboxId: SandboxIdSchema, @@ -115,7 +128,6 @@ export const sandboxRouter = createTRPCRouter({ }) ) .query(async ({ ctx, input }) => { - const { teamId, session } = ctx const { sandboxId, level, search } = input let { cursor } = input @@ -124,21 +136,23 @@ export const sandboxRouter = createTRPCRouter({ const direction = 'forward' const limit = 100 - const sandboxLogs = await sandboxesRepo.getSandboxLogs( - session.access_token, - teamId, + const sandboxLogsResult = await ctx.sandboxesRepository.getSandboxLogs( sandboxId, { cursor, limit, direction, level, search } ) + if (!sandboxLogsResult.ok) { + throwTRPCErrorFromRepoError(sandboxLogsResult.error) + } + const sandboxLogs = sandboxLogsResult.data - const logs: SandboxLogDTO[] = sandboxLogs.logs.map( - mapInfraSandboxLogToDTO + const logs: SandboxLogModel[] = sandboxLogs.logs.map( + mapInfraSandboxLogToModel ) const newestLog = logs[logs.length - 1] const nextCursor = newestLog?.timestampUnix ?? cursor - const result: SandboxLogsDTO = { + const result: SandboxLogsModel = { logs, nextCursor, } @@ -146,7 +160,7 @@ export const sandboxRouter = createTRPCRouter({ return result }), - resourceMetrics: protectedTeamProcedure + resourceMetrics: sandboxRepositoryProcedure .input( z .object({ @@ -172,19 +186,20 @@ export const sandboxRouter = createTRPCRouter({ ) ) .query(async ({ ctx, input }) => { - const { teamId, session } = ctx const { sandboxId } = input const { startMs, endMs } = input - const metrics = await sandboxesRepo.getSandboxMetrics( - session.access_token, - teamId, + const metricsResult = await ctx.sandboxesRepository.getSandboxMetrics( sandboxId, { startUnixMs: startMs, endUnixMs: endMs, } ) + if (!metricsResult.ok) { + throwTRPCErrorFromRepoError(metricsResult.error) + } + const metrics = metricsResult.data return metrics }), diff --git a/src/core/server/api/routers/sandboxes.ts b/src/core/server/api/routers/sandboxes.ts new file mode 100644 index 000000000..68b53f99b --- /dev/null +++ b/src/core/server/api/routers/sandboxes.ts @@ -0,0 +1,184 @@ +import { z } from 'zod' +import { USE_MOCK_DATA } from '@/configs/flags' +import { + calculateTeamMetricsStep, + MOCK_SANDBOXES_DATA, + MOCK_TEAM_METRICS_DATA, + MOCK_TEAM_METRICS_MAX_DATA, +} from '@/configs/mock-data' +import { createSandboxesRepository } from '@/core/modules/sandboxes/repository.server' +import { + GetTeamMetricsMaxSchema, + GetTeamMetricsSchema, +} from '@/core/modules/sandboxes/schemas' +import { throwTRPCErrorFromRepoError } from '@/core/server/adapters/errors' +import { withTeamAuthedRequestRepository } from '@/core/server/api/middlewares/repository' +import { + fillTeamMetricsWithZeros, + transformMetricsToClientMetrics, +} from '@/core/server/functions/sandboxes/utils' +import { createTRPCRouter } from '@/core/server/trpc/init' +import { protectedTeamProcedure } from '@/core/server/trpc/procedures' + +const sandboxesRepositoryProcedure = protectedTeamProcedure.use( + withTeamAuthedRequestRepository( + createSandboxesRepository, + (sandboxesRepository) => ({ + sandboxesRepository, + }) + ) +) + +export const sandboxesRouter = createTRPCRouter({ + // QUERIES + getSandboxes: sandboxesRepositoryProcedure.query(async ({ ctx }) => { + if (USE_MOCK_DATA) { + await new Promise((resolve) => setTimeout(resolve, 200)) + + const sandboxes = MOCK_SANDBOXES_DATA() + + return { + sandboxes, + } + } + + const sandboxesResult = await ctx.sandboxesRepository.listSandboxes() + if (!sandboxesResult.ok) { + throwTRPCErrorFromRepoError(sandboxesResult.error) + } + + return { + sandboxes: sandboxesResult.data, + } + }), + + getSandboxesMetrics: sandboxesRepositoryProcedure + .input( + z.object({ + sandboxIds: z.array(z.string()), + }) + ) + .query(async ({ ctx, input }) => { + const { sandboxIds } = input + + if (sandboxIds.length === 0 || USE_MOCK_DATA) { + return { + metrics: {}, + } + } + + const metricsDataResult = + await ctx.sandboxesRepository.getSandboxesMetrics(sandboxIds) + if (!metricsDataResult.ok) { + throwTRPCErrorFromRepoError(metricsDataResult.error) + } + const metricsData = metricsDataResult.data + const metrics = transformMetricsToClientMetrics(metricsData) + + return { + metrics, + } + }), + + getTeamMetrics: sandboxesRepositoryProcedure + .input(GetTeamMetricsSchema) + .query(async ({ ctx, input }) => { + const { startDate: startDateMs, endDate: endDateMs } = input + + // use mock data if enabled + if (USE_MOCK_DATA) { + const mockData = MOCK_TEAM_METRICS_DATA(startDateMs, endDateMs) + const filledMetrics = fillTeamMetricsWithZeros( + mockData.metrics, + startDateMs, + endDateMs, + mockData.step + ) + return { + metrics: filledMetrics, + step: mockData.step, + } + } + + const startS = Math.floor(startDateMs / 1000) + const endS = Math.floor(endDateMs / 1000) + + // calculate step to determine overfetch amount + const stepMs = calculateTeamMetricsStep(startDateMs, endDateMs) + + // overfetch by one step + // the overfetch is accounted for when post-processing the data using fillTeamMetricsWithZeros + const overfetchS = Math.ceil(stepMs / 1000) + + const metricDataResult = + await ctx.sandboxesRepository.getTeamMetricsRange( + startS, + endS + overfetchS + ) + if (!metricDataResult.ok) { + throwTRPCErrorFromRepoError(metricDataResult.error) + } + const metricData = metricDataResult.data + + // transform timestamps from seconds to milliseconds + const metrics = metricData.map( + (d: { + concurrentSandboxes: number + sandboxStartRate: number + timestampUnix: number + }) => ({ + concurrentSandboxes: d.concurrentSandboxes, + sandboxStartRate: d.sandboxStartRate, + timestamp: d.timestampUnix * 1000, + }) + ) + + // fill gaps with zeros for smooth visualization + const filledMetrics = fillTeamMetricsWithZeros( + metrics, + startDateMs, + endDateMs, + stepMs + ) + + return { + metrics: filledMetrics, + step: stepMs, + } + }), + + getTeamMetricsMax: sandboxesRepositoryProcedure + .input(GetTeamMetricsMaxSchema) + .query(async ({ ctx, input }) => { + const { startDate: startDateMs, endDate: endDateMs, metric } = input + + if (USE_MOCK_DATA) { + return MOCK_TEAM_METRICS_MAX_DATA(startDateMs, endDateMs, metric) + } + + // convert milliseconds to seconds for the API + const startS = Math.floor(startDateMs / 1000) + const endS = Math.floor(endDateMs / 1000) + + const maxMetricResult = await ctx.sandboxesRepository.getTeamMetricsMax( + startS, + endS, + metric + ) + if (!maxMetricResult.ok) { + throwTRPCErrorFromRepoError(maxMetricResult.error) + } + const maxMetric = maxMetricResult.data + + // since javascript timestamps are in milliseconds, we want to convert the timestamp back to milliseconds + const timestampMs = maxMetric.timestampUnix * 1000 + + return { + timestamp: timestampMs, + value: maxMetric.value, + metric, + } + }), + + // MUTATIONS +}) diff --git a/src/core/server/api/routers/support.ts b/src/core/server/api/routers/support.ts new file mode 100644 index 000000000..5b3443898 --- /dev/null +++ b/src/core/server/api/routers/support.ts @@ -0,0 +1,70 @@ +import { TRPCError } from '@trpc/server' +import { z } from 'zod' +import { createSupportRepository } from '@/core/modules/support/repository.server' +import { throwTRPCErrorFromRepoError } from '@/core/server/adapters/errors' +import { withTeamAuthedRequestRepository } from '@/core/server/api/middlewares/repository' +import { createTRPCRouter } from '@/core/server/trpc/init' +import { protectedTeamProcedure } from '@/core/server/trpc/procedures' + +const E2B_API_KEY_REGEX = /e2b_[a-f0-9]{40}/i + +const fileSchema = z.object({ + name: z.string(), + type: z.string(), + base64: z.string(), +}) + +const supportRepositoryProcedure = protectedTeamProcedure.use( + withTeamAuthedRequestRepository( + createSupportRepository, + (supportRepository) => ({ + supportRepository, + }) + ) +) + +export const supportRouter = createTRPCRouter({ + contactSupport: supportRepositoryProcedure + .input( + z.object({ + description: z.string().min(1), + files: z.array(fileSchema).max(5).optional(), + }) + ) + .mutation(async ({ ctx, input }) => { + const { teamId, user } = ctx + const email = user.email + + if (!email) { + throw new Error('Email not found') + } + + if (E2B_API_KEY_REGEX.test(input.description)) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: + 'Your message contains an API key. Please remove it before sending.', + }) + } + + const teamResult = await ctx.supportRepository.getTeamSupportData() + if (!teamResult.ok) { + throwTRPCErrorFromRepoError(teamResult.error) + } + + const createResult = await ctx.supportRepository.createSupportThread({ + description: input.description, + files: input.files, + teamId, + teamName: teamResult.data.name, + customerEmail: email, + accountOwnerEmail: teamResult.data.email, + customerTier: teamResult.data.tier, + }) + if (!createResult.ok) { + throwTRPCErrorFromRepoError(createResult.error) + } + + return createResult.data + }), +}) diff --git a/src/core/server/api/routers/teams.ts b/src/core/server/api/routers/teams.ts new file mode 100644 index 000000000..03a2d6a88 --- /dev/null +++ b/src/core/server/api/routers/teams.ts @@ -0,0 +1,23 @@ +import { createUserTeamsRepository } from '@/core/modules/teams/user-teams-repository.server' +import { throwTRPCErrorFromRepoError } from '@/core/server/adapters/errors' +import { withAuthedRequestRepository } from '@/core/server/api/middlewares/repository' +import { createTRPCRouter } from '@/core/server/trpc/init' +import { protectedProcedure } from '@/core/server/trpc/procedures' + +const teamsRepositoryProcedure = protectedProcedure.use( + withAuthedRequestRepository(createUserTeamsRepository, (teamsRepository) => ({ + teamsRepository, + })) +) + +export const teamsRouter = createTRPCRouter({ + list: teamsRepositoryProcedure.query(async ({ ctx }) => { + const teamsResult = await ctx.teamsRepository.listUserTeams() + + if (!teamsResult.ok) { + throwTRPCErrorFromRepoError(teamsResult.error) + } + + return teamsResult.data + }), +}) diff --git a/src/core/server/api/routers/templates.ts b/src/core/server/api/routers/templates.ts new file mode 100644 index 000000000..d063c251c --- /dev/null +++ b/src/core/server/api/routers/templates.ts @@ -0,0 +1,103 @@ +import { TRPCError } from '@trpc/server' +import { z } from 'zod' +import { + createDefaultTemplatesRepository, + createTemplatesRepository, +} from '@/core/modules/templates/repository.server' +import { throwTRPCErrorFromRepoError } from '@/core/server/adapters/errors' +import { + withAuthedRequestRepository, + withTeamAuthedRequestRepository, +} from '@/core/server/api/middlewares/repository' +import { createTRPCRouter } from '@/core/server/trpc/init' +import { + protectedProcedure, + protectedTeamProcedure, +} from '@/core/server/trpc/procedures' + +const templatesRepositoryProcedure = protectedProcedure.use( + withAuthedRequestRepository( + createDefaultTemplatesRepository, + (templatesRepository) => ({ + templatesRepository, + }) + ) +) + +const teamTemplatesRepositoryProcedure = protectedTeamProcedure.use( + withTeamAuthedRequestRepository( + createTemplatesRepository, + (templatesRepository) => ({ + templatesRepository, + }) + ) +) + +export const templatesRouter = createTRPCRouter({ + // QUERIES + + getTemplates: teamTemplatesRepositoryProcedure.query(async ({ ctx }) => { + const result = await ctx.templatesRepository.getTeamTemplates() + if (!result.ok) throwTRPCErrorFromRepoError(result.error) + return result.data + }), + + getDefaultTemplatesCached: templatesRepositoryProcedure.query( + async ({ ctx }) => { + const result = await ctx.templatesRepository.getDefaultTemplatesCached() + if (!result.ok) throwTRPCErrorFromRepoError(result.error) + return result.data + } + ), + + // MUTATIONS + + deleteTemplate: teamTemplatesRepositoryProcedure + .input( + z.object({ + templateId: z.string(), + }) + ) + .mutation(async ({ ctx, input }) => { + const { templateId } = input + + const result = await ctx.templatesRepository.deleteTemplate(templateId) + + if (!result.ok) { + if ( + result.error.status === 400 && + result.error.message.includes( + 'because there are paused sandboxes using it' + ) + ) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: + 'Cannot delete template because there are paused sandboxes using it', + }) + } + throwTRPCErrorFromRepoError(result.error) + } + + return result.data + }), + + updateTemplate: teamTemplatesRepositoryProcedure + .input( + z.object({ + templateId: z.string(), + public: z.boolean(), + }) + ) + .mutation(async ({ ctx, input }) => { + const { templateId, public: isPublic } = input + + const result = await ctx.templatesRepository.updateTemplateVisibility( + templateId, + isPublic + ) + if (!result.ok) throwTRPCErrorFromRepoError(result.error) + + return result.data + }), +}) diff --git a/src/server/auth/auth.types.ts b/src/core/server/functions/auth/auth.types.ts similarity index 94% rename from src/server/auth/auth.types.ts rename to src/core/server/functions/auth/auth.types.ts index 98f0e3a81..42786c86b 100644 --- a/src/server/auth/auth.types.ts +++ b/src/core/server/functions/auth/auth.types.ts @@ -1,6 +1,6 @@ import { z } from 'zod' import { CAPTCHA_REQUIRED_CLIENT } from '@/configs/flags' -import { relativeUrlSchema } from '@/lib/schemas/url' +import { relativeUrlSchema } from '@/core/shared/schemas/url' export const emailSchema = z.email('Valid email is required') diff --git a/src/server/auth/get-session.ts b/src/core/server/functions/auth/get-session.ts similarity index 96% rename from src/server/auth/get-session.ts rename to src/core/server/functions/auth/get-session.ts index 3623d6459..eac071784 100644 --- a/src/server/auth/get-session.ts +++ b/src/core/server/functions/auth/get-session.ts @@ -1,4 +1,4 @@ -import { createClient } from '@/lib/clients/supabase/server' +import { createClient } from '@/core/shared/clients/supabase/server' import 'server-cli-only' /** diff --git a/src/server/auth/get-user-by-token.ts b/src/core/server/functions/auth/get-user-by-token.ts similarity index 94% rename from src/server/auth/get-user-by-token.ts rename to src/core/server/functions/auth/get-user-by-token.ts index 02d43d4ce..ce24da1a7 100644 --- a/src/server/auth/get-user-by-token.ts +++ b/src/core/server/functions/auth/get-user-by-token.ts @@ -2,7 +2,7 @@ import 'server-only' import { AuthSessionMissingError } from '@supabase/supabase-js' import { cache } from 'react' -import { supabaseAdmin } from '@/lib/clients/supabase/admin' +import { supabaseAdmin } from '@/core/shared/clients/supabase/admin' /** * Retrieves a user from Supabase using their access token. diff --git a/src/server/auth/validate-email.ts b/src/core/server/functions/auth/validate-email.ts similarity index 96% rename from src/server/auth/validate-email.ts rename to src/core/server/functions/auth/validate-email.ts index bf32b9f42..eb27bb83e 100644 --- a/src/server/auth/validate-email.ts +++ b/src/core/server/functions/auth/validate-email.ts @@ -1,7 +1,6 @@ import { kv } from '@vercel/kv' -import { serializeError } from 'serialize-error' import { KV_KEYS } from '@/configs/keys' -import { l } from '@/lib/clients/logger/logger' +import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger' /** * Response type from the ZeroBounce email validation API @@ -90,7 +89,7 @@ export async function validateEmail( } catch (error) { l.error({ key: 'validate_email:error', - error: serializeError(error), + error: serializeErrorForLog(error), context: { email, }, diff --git a/src/core/server/functions/keys/get-api-keys.ts b/src/core/server/functions/keys/get-api-keys.ts new file mode 100644 index 000000000..8ec35cf7a --- /dev/null +++ b/src/core/server/functions/keys/get-api-keys.ts @@ -0,0 +1,49 @@ +import { cacheLife, cacheTag } from 'next/cache' +import { z } from 'zod' +import { CACHE_TAGS } from '@/configs/cache' +import { createKeysRepository } from '@/core/modules/keys/repository.server' +import { + authActionClient, + withTeamSlugResolution, +} from '@/core/server/actions/client' +import { handleDefaultInfraError } from '@/core/server/actions/utils' +import { l } from '@/core/shared/clients/logger/logger' +import { TeamSlugSchema } from '@/core/shared/schemas/team' + +const GetApiKeysSchema = z.object({ + teamSlug: TeamSlugSchema, +}) + +export const getTeamApiKeys = authActionClient + .schema(GetApiKeysSchema) + .metadata({ serverFunctionName: 'getTeamApiKeys' }) + .use(withTeamSlugResolution) + .action(async ({ ctx }) => { + 'use cache' + cacheLife('default') + const { session, teamId } = ctx + cacheTag(CACHE_TAGS.TEAM_API_KEYS(teamId)) + + const result = await createKeysRepository({ + accessToken: session.access_token, + teamId, + }).listTeamApiKeys() + + if (!result.ok) { + const status = result.error.status + + l.error({ + key: 'get_team_api_keys:error', + error: result.error, + team_id: teamId, + user_id: session.user.id, + context: { + status, + }, + }) + + return handleDefaultInfraError(status, result.error) + } + + return { apiKeys: result.data } + }) diff --git a/src/server/keys/types.ts b/src/core/server/functions/keys/types.ts similarity index 100% rename from src/server/keys/types.ts rename to src/core/server/functions/keys/types.ts diff --git a/src/server/sandboxes/get-team-metrics-core.ts b/src/core/server/functions/sandboxes/get-team-metrics-core.ts similarity index 90% rename from src/server/sandboxes/get-team-metrics-core.ts rename to src/core/server/functions/sandboxes/get-team-metrics-core.ts index 67262e638..2457b305a 100644 --- a/src/server/sandboxes/get-team-metrics-core.ts +++ b/src/core/server/functions/sandboxes/get-team-metrics-core.ts @@ -7,11 +7,11 @@ import { calculateTeamMetricsStep, MOCK_TEAM_METRICS_DATA, } from '@/configs/mock-data' -import { infra } from '@/lib/clients/api' -import { l } from '@/lib/clients/logger/logger' -import { handleDefaultInfraError } from '@/lib/utils/action' -import { fillTeamMetricsWithZeros } from '@/server/sandboxes/utils' -import type { ClientTeamMetrics } from '@/types/sandboxes.types' +import type { ClientTeamMetrics } from '@/core/modules/sandboxes/models.client' +import { handleDefaultInfraError } from '@/core/server/actions/utils' +import { fillTeamMetricsWithZeros } from '@/core/server/functions/sandboxes/utils' +import { infra } from '@/core/shared/clients/api' +import { l } from '@/core/shared/clients/logger/logger' interface GetTeamMetricsCoreParams { accessToken: string @@ -115,7 +115,7 @@ export const getTeamMetricsCore = cache( ) return { - error: handleDefaultInfraError(status), + error: handleDefaultInfraError(status, res.error), status, } } diff --git a/src/server/sandboxes/get-team-metrics-max.ts b/src/core/server/functions/sandboxes/get-team-metrics-max.ts similarity index 86% rename from src/server/sandboxes/get-team-metrics-max.ts rename to src/core/server/functions/sandboxes/get-team-metrics-max.ts index 74d629e6d..0a2078f8a 100644 --- a/src/server/sandboxes/get-team-metrics-max.ts +++ b/src/core/server/functions/sandboxes/get-team-metrics-max.ts @@ -4,16 +4,19 @@ import { z } from 'zod' import { SUPABASE_AUTH_HEADERS } from '@/configs/api' import { USE_MOCK_DATA } from '@/configs/flags' import { MOCK_TEAM_METRICS_MAX_DATA } from '@/configs/mock-data' +import { + authActionClient, + withTeamSlugResolution, +} from '@/core/server/actions/client' +import { handleDefaultInfraError } from '@/core/server/actions/utils' +import { infra } from '@/core/shared/clients/api' +import { l } from '@/core/shared/clients/logger/logger' +import { TeamSlugSchema } from '@/core/shared/schemas/team' import { MAX_DAYS_AGO } from '@/features/dashboard/sandboxes/monitoring/time-picker/constants' -import { authActionClient, withTeamIdResolution } from '@/lib/clients/action' -import { infra } from '@/lib/clients/api' -import { l } from '@/lib/clients/logger/logger' -import { TeamIdOrSlugSchema } from '@/lib/schemas/team' -import { handleDefaultInfraError } from '@/lib/utils/action' export const GetTeamMetricsMaxSchema = z .object({ - teamIdOrSlug: TeamIdOrSlugSchema, + teamSlug: TeamSlugSchema, startDate: z .number() .int() @@ -51,7 +54,7 @@ export const GetTeamMetricsMaxSchema = z export const getTeamMetricsMax = authActionClient .metadata({ serverFunctionName: 'getTeamMetricsMax' }) .schema(GetTeamMetricsMaxSchema) - .use(withTeamIdResolution) + .use(withTeamSlugResolution) .action(async ({ parsedInput, ctx }) => { const { session, teamId } = ctx const { startDate: startDateMs, endDate: endDateMs, metric } = parsedInput @@ -100,7 +103,7 @@ export const getTeamMetricsMax = authActionClient `Failed to get team metrics max: ${res.error.message}` ) - return handleDefaultInfraError(status) + return handleDefaultInfraError(status, res.error) } // since javascript timestamps are in milliseconds, we want to convert the timestamp back to milliseconds diff --git a/src/server/sandboxes/get-team-metrics.ts b/src/core/server/functions/sandboxes/get-team-metrics.ts similarity index 79% rename from src/server/sandboxes/get-team-metrics.ts rename to src/core/server/functions/sandboxes/get-team-metrics.ts index ec99273a7..cc90ddacf 100644 --- a/src/server/sandboxes/get-team-metrics.ts +++ b/src/core/server/functions/sandboxes/get-team-metrics.ts @@ -1,15 +1,19 @@ import 'server-only' import { z } from 'zod' +import { + authActionClient, + withTeamSlugResolution, +} from '@/core/server/actions/client' +import { returnServerError } from '@/core/server/actions/utils' +import { getPublicErrorMessage } from '@/core/shared/errors' +import { TeamSlugSchema } from '@/core/shared/schemas/team' import { MAX_DAYS_AGO } from '@/features/dashboard/sandboxes/monitoring/time-picker/constants' -import { authActionClient, withTeamIdResolution } from '@/lib/clients/action' -import { TeamIdOrSlugSchema } from '@/lib/schemas/team' -import { returnServerError } from '@/lib/utils/action' import { getTeamMetricsCore } from './get-team-metrics-core' export const GetTeamMetricsSchema = z .object({ - teamIdOrSlug: TeamIdOrSlugSchema, + teamSlug: TeamSlugSchema, startDate: z .number() .int() @@ -46,7 +50,7 @@ export const GetTeamMetricsSchema = z export const getTeamMetrics = authActionClient .schema(GetTeamMetricsSchema) .metadata({ serverFunctionName: 'getTeamMetrics' }) - .use(withTeamIdResolution) + .use(withTeamSlugResolution) .action(async ({ parsedInput, ctx }) => { const { session, teamId } = ctx @@ -61,8 +65,7 @@ export const getTeamMetrics = authActionClient }) if (result.error) { - // error already logged in core function - return returnServerError(result.error) + return returnServerError(getPublicErrorMessage({ status: result.status })) } return result.data diff --git a/src/server/sandboxes/utils.ts b/src/core/server/functions/sandboxes/utils.ts similarity index 98% rename from src/server/sandboxes/utils.ts rename to src/core/server/functions/sandboxes/utils.ts index 96e5622b7..4d0d2fda0 100644 --- a/src/server/sandboxes/utils.ts +++ b/src/core/server/functions/sandboxes/utils.ts @@ -1,9 +1,9 @@ import { TEAM_METRICS_BACKEND_COLLECTION_INTERVAL_MS } from '@/configs/intervals' -import type { SandboxesMetricsRecord } from '@/types/api.types' +import type { SandboxesMetricsRecord } from '@/core/modules/sandboxes/models' import type { ClientSandboxesMetrics, ClientTeamMetrics, -} from '@/types/sandboxes.types' +} from '@/core/modules/sandboxes/models.client' export function transformMetricsToClientMetrics( metrics: SandboxesMetricsRecord diff --git a/src/core/server/functions/team/get-team-id-from-slug.ts b/src/core/server/functions/team/get-team-id-from-slug.ts new file mode 100644 index 000000000..1ce9aa68e --- /dev/null +++ b/src/core/server/functions/team/get-team-id-from-slug.ts @@ -0,0 +1,43 @@ +import 'server-only' + +import { CACHE_TAGS } from '@/configs/cache' +import { createUserTeamsRepository } from '@/core/modules/teams/user-teams-repository.server' +import { l } from '@/core/shared/clients/logger/logger' +import { TeamSlugSchema } from '@/core/shared/schemas/team' + +export const getTeamIdFromSlug = async ( + teamSlug: string, + accessToken: string +) => { + if (!TeamSlugSchema.safeParse(teamSlug).success) { + l.warn( + { + key: 'get_team_id_from_slug:invalid_team_slug', + context: { teamSlug }, + }, + 'get_team_id_from_slug - invalid team slug' + ) + + return null + } + + const resolvedTeam = await createUserTeamsRepository({ + accessToken, + }).resolveTeamBySlug(teamSlug, { + tags: [CACHE_TAGS.TEAM_ID_FROM_SLUG(teamSlug)], + }) + + if (!resolvedTeam.ok) { + l.warn( + { + key: 'get_team_id_from_slug:resolve_failed', + context: { teamSlug }, + }, + 'get_team_id_from_slug - failed to resolve' + ) + + return null + } + + return resolvedTeam.data.id +} diff --git a/src/core/server/functions/team/get-team-members.ts b/src/core/server/functions/team/get-team-members.ts new file mode 100644 index 000000000..d8b8fffd4 --- /dev/null +++ b/src/core/server/functions/team/get-team-members.ts @@ -0,0 +1,33 @@ +import 'server-only' + +import { z } from 'zod' +import { createTeamsRepository } from '@/core/modules/teams/teams-repository.server' +import { + authActionClient, + withTeamAuthedRequestRepository, + withTeamSlugResolution, +} from '@/core/server/actions/client' +import { toActionErrorFromRepoError } from '@/core/server/adapters/errors' +import { TeamSlugSchema } from '@/core/shared/schemas/team' + +const withTeamsRepository = withTeamAuthedRequestRepository( + createTeamsRepository, + (teamsRepository) => ({ teamsRepository }) +) + +const GetTeamMembersSchema = z.object({ + teamSlug: TeamSlugSchema, +}) + +export const getTeamMembers = authActionClient + .schema(GetTeamMembersSchema) + .metadata({ serverFunctionName: 'getTeamMembers' }) + .use(withTeamSlugResolution) + .use(withTeamsRepository) + .action(async ({ ctx }) => { + const result = await ctx.teamsRepository.listTeamMembers() + if (!result.ok) { + return toActionErrorFromRepoError(result.error) + } + return result.data + }) diff --git a/src/core/server/functions/team/resolve-user-team.ts b/src/core/server/functions/team/resolve-user-team.ts new file mode 100644 index 000000000..e59375fcf --- /dev/null +++ b/src/core/server/functions/team/resolve-user-team.ts @@ -0,0 +1,84 @@ +import 'server-only' + +import { cookies } from 'next/headers' +import { COOKIE_KEYS } from '@/configs/cookies' +import type { ResolvedTeam } from '@/core/modules/teams/models' +import { createUserTeamsRepository } from '@/core/modules/teams/user-teams-repository.server' +import { l } from '@/core/shared/clients/logger/logger' + +export async function resolveUserTeam( + accessToken: string +): Promise { + const cookieStore = await cookies() + const teamsRepository = createUserTeamsRepository({ + accessToken, + }) + + const cookieTeamId = cookieStore.get(COOKIE_KEYS.SELECTED_TEAM_ID)?.value + const cookieTeamSlug = cookieStore.get(COOKIE_KEYS.SELECTED_TEAM_SLUG)?.value + + if (cookieTeamSlug) { + const resolvedCookieTeam = + await teamsRepository.resolveTeamBySlug(cookieTeamSlug) + + if (resolvedCookieTeam.ok) { + if (cookieTeamId && cookieTeamId !== resolvedCookieTeam.data.id) { + l.warn( + { + key: 'resolve_user_team:cookie_team_id_mismatch', + team_id: cookieTeamId, + context: { + resolved_team_id: resolvedCookieTeam.data.id, + team_slug: cookieTeamSlug, + }, + }, + 'Selected team cookie id did not match the resolved team' + ) + } + + return resolvedCookieTeam.data + } + + l.warn( + { + key: 'resolve_user_team:stale_cookie_team', + team_id: cookieTeamId, + context: { + team_slug: cookieTeamSlug, + }, + }, + 'Selected team cookie could not be resolved for the current user' + ) + } + + const teamsResult = await teamsRepository.listUserTeams() + + if (!teamsResult.ok) { + l.error( + { + key: 'resolve_user_team:api_error', + }, + 'Failed to fetch user teams' + ) + return null + } + + if (teamsResult.data.length === 0) { + return null + } + + const defaultTeam = teamsResult.data.find( + (team) => team.isDefault && team.slug + ) + const team = + defaultTeam ?? teamsResult.data.find((candidate) => candidate.slug) + + if (!team) { + return null + } + + return { + id: team.id, + slug: team.slug, + } +} diff --git a/src/core/server/functions/team/types.ts b/src/core/server/functions/team/types.ts new file mode 100644 index 000000000..616cb87a9 --- /dev/null +++ b/src/core/server/functions/team/types.ts @@ -0,0 +1,11 @@ +export type { + ResolvedTeam, + TeamMember, + TeamMemberInfo, + TeamMemberRelation, +} from '@/core/modules/teams/models' +export { + CreateTeamSchema, + TeamNameSchema, + UpdateTeamNameSchema, +} from '@/core/modules/teams/schemas' diff --git a/src/core/server/functions/usage/get-usage.ts b/src/core/server/functions/usage/get-usage.ts new file mode 100644 index 000000000..e01c4aeaa --- /dev/null +++ b/src/core/server/functions/usage/get-usage.ts @@ -0,0 +1,41 @@ +import 'server-only' + +import { cacheLife, cacheTag } from 'next/cache' +import { z } from 'zod' +import { CACHE_TAGS } from '@/configs/cache' +import { createBillingRepository } from '@/core/modules/billing/repository.server' +import { + authActionClient, + withTeamSlugResolution, +} from '@/core/server/actions/client' +import { returnServerError } from '@/core/server/actions/utils' +import { getPublicRepoErrorMessage } from '@/core/shared/errors' +import { TeamSlugSchema } from '@/core/shared/schemas/team' + +const GetUsageAuthActionSchema = z.object({ + teamSlug: TeamSlugSchema, +}) + +export const getUsage = authActionClient + .schema(GetUsageAuthActionSchema) + .metadata({ serverFunctionName: 'getUsage' }) + .use(withTeamSlugResolution) + .action(async ({ ctx }) => { + 'use cache' + + const { teamId } = ctx + + cacheLife('hours') + cacheTag(CACHE_TAGS.TEAM_USAGE(teamId)) + + const result = await createBillingRepository({ + accessToken: ctx.session.access_token, + teamId, + }).getUsage() + + if (!result.ok) { + return returnServerError(getPublicRepoErrorMessage(result.error)) + } + + return result.data + }) diff --git a/src/core/server/functions/webhooks/get-webhooks.ts b/src/core/server/functions/webhooks/get-webhooks.ts new file mode 100644 index 000000000..aa5da2408 --- /dev/null +++ b/src/core/server/functions/webhooks/get-webhooks.ts @@ -0,0 +1,50 @@ +import 'server-only' + +import { z } from 'zod' +import { createWebhooksRepository } from '@/core/modules/webhooks/repository.server' +import { + authActionClient, + withTeamAuthedRequestRepository, + withTeamSlugResolution, +} from '@/core/server/actions/client' +import { handleDefaultInfraError } from '@/core/server/actions/utils' +import { l } from '@/core/shared/clients/logger/logger' +import { TeamSlugSchema } from '@/core/shared/schemas/team' + +const GetWebhooksSchema = z.object({ + teamSlug: TeamSlugSchema, +}) + +const withWebhooksRepository = withTeamAuthedRequestRepository( + createWebhooksRepository, + (webhooksRepository) => ({ webhooksRepository }) +) + +export const getWebhooks = authActionClient + .schema(GetWebhooksSchema) + .metadata({ serverFunctionName: 'getWebhooks' }) + .use(withTeamSlugResolution) + .use(withWebhooksRepository) + .action(async ({ ctx }) => { + const { session, teamId } = ctx + + const result = await ctx.webhooksRepository.listWebhooks() + + if (!result.ok) { + const status = result.error.status + l.error( + { + key: 'get_webhooks:infra_error', + status, + error: result.error, + team_id: teamId, + user_id: session.user.id, + }, + `Failed to get webhooks: ${status}: ${result.error.message}` + ) + + return handleDefaultInfraError(status, result.error) + } + + return { webhooks: result.data } + }) diff --git a/src/server/webhooks/schema.ts b/src/core/server/functions/webhooks/schema.ts similarity index 88% rename from src/server/webhooks/schema.ts rename to src/core/server/functions/webhooks/schema.ts index eda667cb7..5d61057d1 100644 --- a/src/server/webhooks/schema.ts +++ b/src/core/server/functions/webhooks/schema.ts @@ -1,5 +1,5 @@ import { z } from 'zod' -import { TeamIdOrSlugSchema } from '@/lib/schemas/team' +import { TeamSlugSchema } from '@/core/shared/schemas/team' const WebhookUrlSchema = z.httpUrl('Must be a valid URL').trim() const WebhookSecretSchema = z @@ -9,7 +9,7 @@ const WebhookSecretSchema = z export const UpsertWebhookSchema = z .object({ - teamIdOrSlug: TeamIdOrSlugSchema, + teamSlug: TeamSlugSchema, mode: z.enum(['add', 'edit']), webhookId: z.uuid().optional(), name: z.string().min(1, 'Name is required').trim(), @@ -33,12 +33,12 @@ export const UpsertWebhookSchema = z ) export const DeleteWebhookSchema = z.object({ - teamIdOrSlug: TeamIdOrSlugSchema, + teamSlug: TeamSlugSchema, webhookId: z.uuid(), }) export const UpdateWebhookSecretSchema = z.object({ - teamIdOrSlug: TeamIdOrSlugSchema, + teamSlug: TeamSlugSchema, webhookId: z.uuid(), signatureSecret: WebhookSecretSchema, }) diff --git a/src/server/proxy.ts b/src/core/server/http/proxy.ts similarity index 100% rename from src/server/proxy.ts rename to src/core/server/http/proxy.ts diff --git a/src/server/api/init.ts b/src/core/server/trpc/init.ts similarity index 80% rename from src/server/api/init.ts rename to src/core/server/trpc/init.ts index 44f9523dc..016d01b65 100644 --- a/src/server/api/init.ts +++ b/src/core/server/trpc/init.ts @@ -1,3 +1,4 @@ +import type { Session, User } from '@supabase/supabase-js' import { initTRPC } from '@trpc/server' import superjson from 'superjson' import { flattenError, ZodError } from 'zod' @@ -10,6 +11,9 @@ import { flattenError, ZodError } from 'zod' export const createTRPCContext = async (opts: { headers: Headers }) => { return { ...opts, + session: undefined as Session | undefined, + user: undefined as User | undefined, + teamId: undefined as string | undefined, } } diff --git a/src/server/api/procedures.ts b/src/core/server/trpc/procedures.ts similarity index 66% rename from src/server/api/procedures.ts rename to src/core/server/trpc/procedures.ts index e95e777b9..94c03bb09 100644 --- a/src/server/api/procedures.ts +++ b/src/core/server/trpc/procedures.ts @@ -1,16 +1,15 @@ import { context, SpanStatusCode, trace } from '@opentelemetry/api' import z from 'zod' -import { getTracer } from '@/lib/clients/tracer' -import { TeamIdOrSlugSchema } from '@/lib/schemas/team' -import checkUserTeamAuthCached from '../auth/check-user-team-auth-cached' -import { getTeamIdFromSegment } from '../team/get-team-id-from-segment' -import { forbiddenTeamAccessError } from './errors' -import { t } from './init' -import { authMiddleware } from './middlewares/auth' +import { forbiddenTeamAccessError } from '@/core/server/adapters/errors' +import { authMiddleware } from '@/core/server/api/middlewares/auth' import { endTelemetryMiddleware, startTelemetryMiddleware, -} from './middlewares/telemetry' +} from '@/core/server/api/middlewares/telemetry' +import { getTeamIdFromSlug } from '@/core/server/functions/team/get-team-id-from-slug' +import { getTracer } from '@/core/shared/clients/tracer' +import { TeamSlugSchema } from '@/core/shared/schemas/team' +import { t } from './init' /** * IMPORTANT: Telemetry Middleware Usage @@ -56,7 +55,7 @@ export const protectedProcedure = t.procedure /** * Protected Team Procedure * - * Used to create protected routes that require authentication and a team authorization, via teamIdOrSlug. + * Used to create protected routes that require authentication and a team authorization, via teamSlug. * */ export const protectedTeamProcedure = t.procedure @@ -64,7 +63,7 @@ export const protectedTeamProcedure = t.procedure .use(authMiddleware) .input( z.object({ - teamIdOrSlug: TeamIdOrSlugSchema, + teamSlug: TeamSlugSchema, }) ) .use(async ({ ctx, next, input }) => { @@ -76,31 +75,17 @@ export const protectedTeamProcedure = t.procedure const teamId = await context.with( trace.setSpan(context.active(), span), async () => { - return await getTeamIdFromSegment(input.teamIdOrSlug) + return await getTeamIdFromSlug( + input.teamSlug, + ctx.session.access_token + ) } ) if (!teamId) { span.setStatus({ code: SpanStatusCode.ERROR, - message: `teamId not found for teamIdOrSlug (${input.teamIdOrSlug})`, - }) - - // the actual error should be 400, but we want to prevent leaking information to bad actors - throw forbiddenTeamAccessError() - } - - const isAuthorized = await context.with( - trace.setSpan(context.active(), span), - async () => { - return await checkUserTeamAuthCached(ctx.user.id, teamId) - } - ) - - if (!isAuthorized) { - span.setStatus({ - code: SpanStatusCode.ERROR, - message: `user (${ctx.user.id}) not authorized to access team (${teamId})`, + message: `teamId not found for teamSlug (${input.teamSlug})`, }) throw forbiddenTeamAccessError() diff --git a/src/lib/clients/api.ts b/src/core/shared/clients/api.ts similarity index 80% rename from src/lib/clients/api.ts rename to src/core/shared/clients/api.ts index 60b906e96..181446a68 100644 --- a/src/lib/clients/api.ts +++ b/src/core/shared/clients/api.ts @@ -1,7 +1,7 @@ import createClient from 'openapi-fetch' -import type { paths as ArgusPaths } from '@/types/argus-api.types' -import type { paths as DashboardPaths } from '@/types/dashboard-api.types' -import type { paths as InfraPaths } from '@/types/infra-api.types' +import type { paths as ArgusPaths } from '@/core/shared/contracts/argus-api.types' +import type { paths as DashboardPaths } from '@/core/shared/contracts/dashboard-api.types' +import type { paths as InfraPaths } from '@/core/shared/contracts/infra-api.types' type CombinedPaths = InfraPaths & ArgusPaths diff --git a/src/lib/clients/kv.ts b/src/core/shared/clients/kv.ts similarity index 100% rename from src/lib/clients/kv.ts rename to src/core/shared/clients/kv.ts diff --git a/src/lib/clients/logger/logger.node.ts b/src/core/shared/clients/logger/logger.node.ts similarity index 100% rename from src/lib/clients/logger/logger.node.ts rename to src/core/shared/clients/logger/logger.node.ts diff --git a/src/lib/clients/logger/logger.ts b/src/core/shared/clients/logger/logger.ts similarity index 62% rename from src/lib/clients/logger/logger.ts rename to src/core/shared/clients/logger/logger.ts index 8bbe83528..4ed1f8f0e 100644 --- a/src/lib/clients/logger/logger.ts +++ b/src/core/shared/clients/logger/logger.ts @@ -1,48 +1,21 @@ -/** - * Universal logger that picks the correct implementation for the current runtime - * (Node, Edge, Browser) and exposes an API compatible with `pino`. - * - * In Node & Browser we return the real pino instance. - * In Edge we fall back to the minimal JSON logger implemented in `logger.edge.ts`. - */ - import pino, { type Logger } from 'pino' -import type { ErrorObject } from 'serialize-error' +import { type ErrorObject, serializeError } from 'serialize-error' -/** - * Represents platform-specific metadata that can be included in logs. - * These are top-level properties that provide structured metadata for log filtering and analysis. - */ interface PlatformContextKeys { - /** Identifier for the team */ team_id?: string - /** Identifier for the user */ user_id?: string - /** Identifier for the sandbox */ - sandbox_id?: string - /** Identifier for the template */ template_id?: string } -/** - * Context data structure for logging entries. - * Extends platform-specific metadata with additional fields. - */ interface ILoggerContext extends Record, PlatformContextKeys { - /** Key to help identify log entry in-code */ key: string - - /** Should contain Error */ error?: ErrorObject | unknown - - /** Should contain context around the log */ context?: Record } interface ILogger { child(bindings: Record): Logger - fatal(context: ILoggerContext, message?: string, ...args: unknown[]): void error(context: ILoggerContext, message?: string, ...args: unknown[]): void warn(context: ILoggerContext, message?: string, ...args: unknown[]): void @@ -75,6 +48,34 @@ const REDACTION_PATHS = [ '*.*.key', ] +const stripStackFields = ( + value: unknown, + seen = new WeakSet() +): unknown => { + if (Array.isArray(value)) { + return value.map((item) => stripStackFields(item, seen)) + } + + if (typeof value !== 'object' || value === null) { + return value + } + + if (seen.has(value)) { + return '[Circular]' + } + + seen.add(value) + + const entries = Object.entries(value).filter(([key]) => key !== 'stack') + + return Object.fromEntries( + entries.map(([key, item]) => [key, stripStackFields(item, seen)]) + ) +} + +export const serializeErrorForLog = (error: unknown): ErrorObject | unknown => + stripStackFields(serializeError(error)) as ErrorObject + const createLogger = () => { const baseConfig = { redact: { @@ -88,6 +89,5 @@ const createLogger = () => { } export const logger: ILogger = createLogger() - export const l = logger export default logger diff --git a/src/lib/clients/meter.ts b/src/core/shared/clients/meter.ts similarity index 100% rename from src/lib/clients/meter.ts rename to src/core/shared/clients/meter.ts diff --git a/src/lib/clients/storage.ts b/src/core/shared/clients/storage.ts similarity index 67% rename from src/lib/clients/storage.ts rename to src/core/shared/clients/storage.ts index 91618c883..a33877528 100644 --- a/src/lib/clients/storage.ts +++ b/src/core/shared/clients/storage.ts @@ -2,19 +2,12 @@ import type { FileObject } from '@supabase/storage-js' import { STORAGE_BUCKET_NAME } from '@/configs/storage' import { supabaseAdmin } from './supabase/admin' -/** - * Upload a file to Supabase Storage - * @param fileBuffer - The file buffer to upload - * @param destination - The destination path in the bucket - * @param contentType - The content type of the file - * @returns The public URL of the uploaded file - */ export async function uploadFile( fileBuffer: Buffer, destination: string, contentType: string ): Promise { - const { data, error } = await supabaseAdmin.storage + const { error } = await supabaseAdmin.storage .from(STORAGE_BUCKET_NAME) .upload(destination, fileBuffer, { contentType, @@ -33,11 +26,6 @@ export async function uploadFile( return urlData.publicUrl } -/** - * Get a list of files from Supabase Storage - * @param folderPath - The path of the folder in the bucket - * @returns The list of files - */ export async function getFiles(folderPath: string): Promise { const { data, error } = await supabaseAdmin.storage .from(STORAGE_BUCKET_NAME) @@ -51,10 +39,7 @@ export async function getFiles(folderPath: string): Promise { return data } -/** - * Delete a file from Supabase Storage - * @param filePath - The path of the file in the bucket - */ + export async function deleteFile(filePath: string): Promise { const { error } = await supabaseAdmin.storage .from(STORAGE_BUCKET_NAME) @@ -65,12 +50,6 @@ export async function deleteFile(filePath: string): Promise { } } -/** - * Get a signed URL for a file (for temporary access) - * @param filePath - The path of the file in the bucket - * @param expiresInMinutes - How long the URL should be valid for (in minutes) - * @returns The signed URL - */ export async function getSignedUrl( filePath: string, expiresInMinutes = 15 diff --git a/src/lib/clients/supabase/admin.ts b/src/core/shared/clients/supabase/admin.ts similarity index 81% rename from src/lib/clients/supabase/admin.ts rename to src/core/shared/clients/supabase/admin.ts index 08df51274..0d9b8a887 100644 --- a/src/lib/clients/supabase/admin.ts +++ b/src/core/shared/clients/supabase/admin.ts @@ -1,7 +1,7 @@ import 'server-cli-only' import { createClient } from '@supabase/supabase-js' -import type { Database } from '@/types/database.types' +import type { Database } from '@/core/shared/contracts/database.types' export const supabaseAdmin = createClient( process.env.NEXT_PUBLIC_SUPABASE_URL, diff --git a/src/lib/clients/supabase/client.ts b/src/core/shared/clients/supabase/client.ts similarity index 74% rename from src/lib/clients/supabase/client.ts rename to src/core/shared/clients/supabase/client.ts index fad5b74c2..565be79c4 100644 --- a/src/lib/clients/supabase/client.ts +++ b/src/core/shared/clients/supabase/client.ts @@ -1,7 +1,7 @@ 'use client' import { createBrowserClient } from '@supabase/ssr' -import type { Database } from '@/types/database.types' +import type { Database } from '@/core/shared/contracts/database.types' export const supabase = createBrowserClient( process.env.NEXT_PUBLIC_SUPABASE_URL, diff --git a/src/lib/clients/supabase/server.ts b/src/core/shared/clients/supabase/server.ts similarity index 69% rename from src/lib/clients/supabase/server.ts rename to src/core/shared/clients/supabase/server.ts index a1a9fe844..d82092614 100644 --- a/src/lib/clients/supabase/server.ts +++ b/src/core/shared/clients/supabase/server.ts @@ -1,6 +1,6 @@ import { createServerClient } from '@supabase/ssr' import { cookies } from 'next/headers' -import type { Database } from '@/types/database.types' +import type { Database } from '@/core/shared/contracts/database.types' export const createClient = async () => { const cookieStore = await cookies() @@ -18,11 +18,7 @@ export const createClient = async () => { cookiesToSet.forEach(({ name, value, options }) => { cookieStore.set(name, value, options) }) - } catch (error) { - // The `set` method was called from a Server Component. - // This can be ignored since we have middleware refreshing - // user sessions. - } + } catch (_error) {} }, }, } diff --git a/src/lib/clients/tracer.ts b/src/core/shared/clients/tracer.ts similarity index 100% rename from src/lib/clients/tracer.ts rename to src/core/shared/clients/tracer.ts diff --git a/src/types/argus-api.types.ts b/src/core/shared/contracts/argus-api.types.ts similarity index 100% rename from src/types/argus-api.types.ts rename to src/core/shared/contracts/argus-api.types.ts diff --git a/src/types/dashboard-api.types.ts b/src/core/shared/contracts/dashboard-api.types.ts similarity index 100% rename from src/types/dashboard-api.types.ts rename to src/core/shared/contracts/dashboard-api.types.ts diff --git a/src/types/database.types.ts b/src/core/shared/contracts/database.types.ts similarity index 100% rename from src/types/database.types.ts rename to src/core/shared/contracts/database.types.ts diff --git a/src/types/infra-api.types.ts b/src/core/shared/contracts/infra-api.types.ts similarity index 100% rename from src/types/infra-api.types.ts rename to src/core/shared/contracts/infra-api.types.ts diff --git a/src/core/shared/errors.ts b/src/core/shared/errors.ts new file mode 100644 index 000000000..23f0566e1 --- /dev/null +++ b/src/core/shared/errors.ts @@ -0,0 +1,135 @@ +import type { RepoError, RepoErrorCode } from './result' + +export const PUBLIC_ERROR_MESSAGE_UNAUTHORIZED = 'Unauthorized' +export const PUBLIC_ERROR_MESSAGE_FORBIDDEN = + 'You are not authorized to access this resource' +export const PUBLIC_ERROR_MESSAGE_FORBIDDEN_TEAM = + 'You are not authorized to access this team' +export const PUBLIC_ERROR_MESSAGE_UNAUTHENTICATED = 'User not authenticated' +export const PUBLIC_ERROR_MESSAGE_INTERNAL = + 'An Unexpected Error Occurred, please try again. If the problem persists, please contact support.' + +export type E2BErrorCode = + | 'UNAUTHENTICATED' + | 'UNAUTHORIZED' + | 'INVALID_PARAMETERS' + | 'INTERNAL_SERVER_ERROR' + | 'API_ERROR' + | 'UNKNOWN' + | string + +export class E2BError extends Error { + public code: E2BErrorCode + + constructor(code: E2BErrorCode, message: string) { + super(message) + this.name = 'E2BError' + this.code = code + } +} + +export const UnauthenticatedError = () => + new E2BError('UNAUTHENTICATED', PUBLIC_ERROR_MESSAGE_UNAUTHENTICATED) + +export const UnauthorizedError = (message: string) => + new E2BError('UNAUTHORIZED', message) + +export const InvalidApiKeyError = (message: string) => + new E2BError('INVALID_API_KEY', message) + +export const InvalidParametersError = (message: string) => + new E2BError('INVALID_PARAMETERS', message) + +export const ApiError = (message: string) => new E2BError('API_ERROR', message) + +export const UnknownError = (message?: string) => + new E2BError('UNKNOWN', message ?? PUBLIC_ERROR_MESSAGE_INTERNAL) + +export function createRepoError(input: { + code: RepoErrorCode + status: number + message: string + cause?: unknown +}): RepoError { + return { + code: input.code, + status: input.status, + message: input.message, + cause: input.cause, + } +} + +export function getPublicErrorMessage(input: { + code?: RepoErrorCode | string + status?: number +}): string { + const { code, status } = input + + if (code === 'unauthorized' || status === 401) + return PUBLIC_ERROR_MESSAGE_UNAUTHORIZED + if (code === 'forbidden' || status === 403) + return PUBLIC_ERROR_MESSAGE_FORBIDDEN + if ( + code === 'internal' || + code === 'unavailable' || + (status !== undefined && status >= 500) + ) + return PUBLIC_ERROR_MESSAGE_INTERNAL + + return PUBLIC_ERROR_MESSAGE_INTERNAL +} + +export function getPublicRepoErrorMessage(error: RepoError): string { + switch (error.code) { + case 'not_found': + case 'validation': + case 'conflict': + return error.message + default: + return getPublicErrorMessage({ code: error.code, status: error.status }) + } +} + +export function repoErrorFromHttp( + status: number, + message: string, + cause?: unknown +): RepoError { + switch (status) { + case 401: + return createRepoError({ + code: 'unauthorized', + status, + message, + cause, + }) + case 403: + return createRepoError({ + code: 'forbidden', + status, + message, + cause, + }) + case 404: + return createRepoError({ + code: 'not_found', + status, + message, + cause, + }) + case 409: + return createRepoError({ + code: 'conflict', + status, + message, + cause, + }) + default: + return createRepoError({ + code: status >= 500 ? 'unavailable' : 'internal', + status, + message, + cause, + }) + } +} diff --git a/src/core/shared/repository-scope.ts b/src/core/shared/repository-scope.ts new file mode 100644 index 000000000..03af7669e --- /dev/null +++ b/src/core/shared/repository-scope.ts @@ -0,0 +1,7 @@ +export interface RequestScope { + accessToken: string +} + +export interface TeamRequestScope extends RequestScope { + teamId: string +} diff --git a/src/core/shared/result.ts b/src/core/shared/result.ts new file mode 100644 index 000000000..ea022443e --- /dev/null +++ b/src/core/shared/result.ts @@ -0,0 +1,37 @@ +export type RepoResult = + | { + ok: true + data: T + error?: undefined + } + | { + ok: false + data?: undefined + error: E + } + +export type RepoErrorCode = + | 'unauthorized' + | 'forbidden' + | 'not_found' + | 'validation' + | 'conflict' + | 'internal' + | 'unavailable' + +export type RepoError = { + code: RepoErrorCode + status: number + message: string + cause?: unknown +} + +export const ok = (data: T): RepoResult => ({ + ok: true, + data, +}) + +export const err = (error: E): RepoResult => ({ + ok: false, + error, +}) diff --git a/src/core/shared/schemas/api.ts b/src/core/shared/schemas/api.ts new file mode 100644 index 000000000..0440ef1b1 --- /dev/null +++ b/src/core/shared/schemas/api.ts @@ -0,0 +1,7 @@ +import z from 'zod' + +export const SandboxIdSchema = z + .string() + .min(1, 'Sandbox ID is required') + .max(100, 'Sandbox ID too long') + .regex(/^[a-z0-9]+$/, 'Invalid sandbox ID format') diff --git a/src/core/shared/schemas/team.ts b/src/core/shared/schemas/team.ts new file mode 100644 index 000000000..45a4beb53 --- /dev/null +++ b/src/core/shared/schemas/team.ts @@ -0,0 +1,3 @@ +import { z } from 'zod' + +export const TeamSlugSchema = z.string().trim().min(1) diff --git a/src/lib/schemas/url.ts b/src/core/shared/schemas/url.ts similarity index 100% rename from src/lib/schemas/url.ts rename to src/core/shared/schemas/url.ts diff --git a/src/features/auth/oauth-provider-buttons.tsx b/src/features/auth/oauth-provider-buttons.tsx index b6f4e4896..a7fd7855d 100644 --- a/src/features/auth/oauth-provider-buttons.tsx +++ b/src/features/auth/oauth-provider-buttons.tsx @@ -2,7 +2,7 @@ import { useSearchParams } from 'next/navigation' import { useAction } from 'next-safe-action/hooks' -import { signInWithOAuthAction } from '@/server/auth/auth-actions' +import { signInWithOAuthAction } from '@/core/server/actions/auth-actions' import { Button } from '@/ui/primitives/button' export function OAuthProviders() { diff --git a/src/features/dashboard/account/email-settings.tsx b/src/features/dashboard/account/email-settings.tsx index 0bf3bee9c..af10e9441 100644 --- a/src/features/dashboard/account/email-settings.tsx +++ b/src/features/dashboard/account/email-settings.tsx @@ -7,6 +7,7 @@ import { useEffect, useMemo } from 'react' import { useForm } from 'react-hook-form' import { z } from 'zod' import { USER_MESSAGES } from '@/configs/user-messages' +import { updateUserAction } from '@/core/server/actions/user-actions' import { defaultErrorToast, defaultSuccessToast, @@ -14,7 +15,6 @@ import { } from '@/lib/hooks/use-toast' import { cn } from '@/lib/utils' import { getUserProviders } from '@/lib/utils/auth' -import { updateUserAction } from '@/server/user/user-actions' import { Button } from '@/ui/primitives/button' import { Card, diff --git a/src/features/dashboard/account/name-settings.tsx b/src/features/dashboard/account/name-settings.tsx index 92f4818cf..95307e129 100644 --- a/src/features/dashboard/account/name-settings.tsx +++ b/src/features/dashboard/account/name-settings.tsx @@ -5,13 +5,13 @@ import { useAction } from 'next-safe-action/hooks' import { useForm } from 'react-hook-form' import { z } from 'zod' import { USER_MESSAGES } from '@/configs/user-messages' +import { updateUserAction } from '@/core/server/actions/user-actions' import { defaultErrorToast, defaultSuccessToast, useToast, } from '@/lib/hooks/use-toast' import { cn } from '@/lib/utils' -import { updateUserAction } from '@/server/user/user-actions' import { Button } from '@/ui/primitives/button' import { Card, diff --git a/src/features/dashboard/account/password-settings-server.tsx b/src/features/dashboard/account/password-settings-server.tsx index ea74e0227..a92c9e2f8 100644 --- a/src/features/dashboard/account/password-settings-server.tsx +++ b/src/features/dashboard/account/password-settings-server.tsx @@ -1,4 +1,4 @@ -import type { AccountPageSearchParams } from '@/app/dashboard/[teamIdOrSlug]/account/page' +import type { AccountPageSearchParams } from '@/app/dashboard/[teamSlug]/account/page' import { PasswordSettings } from './password-settings' interface PasswordSettingsServerProps { diff --git a/src/features/dashboard/account/password-settings.tsx b/src/features/dashboard/account/password-settings.tsx index 4b54f6943..8884ab479 100644 --- a/src/features/dashboard/account/password-settings.tsx +++ b/src/features/dashboard/account/password-settings.tsx @@ -6,6 +6,7 @@ import { useEffect, useMemo, useState } from 'react' import { useForm } from 'react-hook-form' import { z } from 'zod' import { USER_MESSAGES } from '@/configs/user-messages' +import { updateUserAction } from '@/core/server/actions/user-actions' import { defaultErrorToast, defaultSuccessToast, @@ -13,7 +14,6 @@ import { } from '@/lib/hooks/use-toast' import { cn } from '@/lib/utils' import { getUserProviders } from '@/lib/utils/auth' -import { updateUserAction } from '@/server/user/user-actions' import { Button } from '@/ui/primitives/button' import { Card, diff --git a/src/features/dashboard/account/reauth-dialog.tsx b/src/features/dashboard/account/reauth-dialog.tsx index 810114dc0..37c0a0f4e 100644 --- a/src/features/dashboard/account/reauth-dialog.tsx +++ b/src/features/dashboard/account/reauth-dialog.tsx @@ -1,7 +1,7 @@ 'use client' import { PROTECTED_URLS } from '@/configs/urls' -import { signOutAction } from '@/server/auth/auth-actions' +import { signOutAction } from '@/core/server/actions/auth-actions' import { AlertDialog } from '@/ui/alert-dialog' interface ReauthDialogProps { diff --git a/src/features/dashboard/account/user-access-token.tsx b/src/features/dashboard/account/user-access-token.tsx index 159c0d405..e95070e22 100644 --- a/src/features/dashboard/account/user-access-token.tsx +++ b/src/features/dashboard/account/user-access-token.tsx @@ -3,8 +3,8 @@ import { Eye, EyeOff } from 'lucide-react' import { useAction } from 'next-safe-action/hooks' import { useState } from 'react' +import { getUserAccessTokenAction } from '@/core/server/actions/user-actions' import { defaultErrorToast, useToast } from '@/lib/hooks/use-toast' -import { getUserAccessTokenAction } from '@/server/user/user-actions' import CopyButton from '@/ui/copy-button' import { Button } from '@/ui/primitives/button' import { Input } from '@/ui/primitives/input' diff --git a/src/features/dashboard/billing/addons.tsx b/src/features/dashboard/billing/addons.tsx index f67873ca5..eb12e5f6c 100644 --- a/src/features/dashboard/billing/addons.tsx +++ b/src/features/dashboard/billing/addons.tsx @@ -4,11 +4,11 @@ import { useMutation } from '@tanstack/react-query' import Link from 'next/link' import { useState } from 'react' import { PROTECTED_URLS } from '@/configs/urls' +import type { AddonInfo } from '@/core/modules/billing/models' import { useRouteParams } from '@/lib/hooks/use-route-params' import { defaultErrorToast, useToast } from '@/lib/hooks/use-toast' import { formatCurrency } from '@/lib/utils/formatting' import { useTRPC } from '@/trpc/client' -import type { AddonInfo } from '@/types/billing.types' import HelpTooltip from '@/ui/help-tooltip' import { Badge } from '@/ui/primitives/badge' import { Button } from '@/ui/primitives/button' @@ -19,7 +19,7 @@ import { Skeleton } from '@/ui/primitives/skeleton' import { useDashboard } from '../context' import { ConcurrentSandboxAddOnPurchaseDialog } from './concurrent-sandboxes-addon-dialog' import { ADDON_500_SANDBOXES_ID, TIER_PRO_ID } from './constants' -import { useBillingItems, useTeamLimits } from './hooks' +import { useBillingItems } from './hooks' import { formatAddonQuantity } from './utils' interface AddonItemProps { @@ -164,8 +164,7 @@ function AddonsLoading() { } function AddonsUpgradePlaceholder() { - const { teamIdOrSlug } = - useRouteParams<'/dashboard/[teamIdOrSlug]/billing/plan'>() + const { teamSlug } = useRouteParams<'/dashboard/[teamSlug]/billing/plan'>() return (
@@ -175,7 +174,7 @@ function AddonsUpgradePlaceholder() { Upgrade to Pro to purchase add-ons for higher concurrency limits.

@@ -130,14 +131,14 @@ function PlanDetails({ <> {isBaseTier ? ( ) : ( @@ -213,7 +214,7 @@ function PlanFeatures({ teamLimits, isLoading }: PlanFeaturesProps) { const features = [ { icon: , - label: `${teamLimits.concurrentInstances} concurrent sandboxes`, + label: `${teamLimits.concurrentSandboxes} concurrent sandboxes`, }, { icon: , diff --git a/src/features/dashboard/billing/types.ts b/src/features/dashboard/billing/types.ts index e94d5092d..45fd3d40e 100644 --- a/src/features/dashboard/billing/types.ts +++ b/src/features/dashboard/billing/types.ts @@ -1,5 +1,5 @@ -import type { TeamLimits } from '@/server/team/get-team-limits' -import type { TeamItems } from '@/types/billing.types' +import type { TeamItems } from '@/core/modules/billing/models' +import type { TeamLimits } from '@/core/modules/teams/models' export interface BillingData { items: TeamItems diff --git a/src/features/dashboard/billing/utils.ts b/src/features/dashboard/billing/utils.ts index 7768efa4c..a0aaa4bed 100644 --- a/src/features/dashboard/billing/utils.ts +++ b/src/features/dashboard/billing/utils.ts @@ -1,5 +1,5 @@ -import { l } from '@/lib/clients/logger/logger' -import type { TeamItems } from '@/types/billing.types' +import type { TeamItems } from '@/core/modules/billing/models' +import { l } from '@/core/shared/clients/logger/logger' import { ADDON_500_SANDBOXES_ID, TIER_BASE_ID, TIER_PRO_ID } from './constants' import type { BillingAddonData, BillingTierData } from './types' diff --git a/src/features/dashboard/build/build-logs-store.ts b/src/features/dashboard/build/build-logs-store.ts index a479ab184..9d5b72d10 100644 --- a/src/features/dashboard/build/build-logs-store.ts +++ b/src/features/dashboard/build/build-logs-store.ts @@ -2,7 +2,7 @@ import { create } from 'zustand' import { immer } from 'zustand/middleware/immer' -import type { BuildLogDTO } from '@/server/api/models/builds.models' +import type { BuildLogModel } from '@/core/modules/builds/models' import type { useTRPCClient } from '@/trpc/client' import { countLeadingAtTimestamp, @@ -15,7 +15,7 @@ import type { LogLevelFilter } from './logs-filter-params' const EMPTY_INIT_FORWARD_LOOKBACK_MS = 5_000 interface BuildLogsParams { - teamIdOrSlug: string + teamSlug: string templateId: string buildId: string } @@ -23,7 +23,7 @@ interface BuildLogsParams { type TRPCClient = ReturnType interface BuildLogsState { - logs: BuildLogDTO[] + logs: BuildLogModel[] hasMoreBackwards: boolean isLoadingBackwards: boolean isLoadingForwards: boolean @@ -95,7 +95,7 @@ export const createBuildLogsStore = () => const paramsChanged = state._params?.buildId !== params.buildId || state._params?.templateId !== params.templateId || - state._params?.teamIdOrSlug !== params.teamIdOrSlug + state._params?.teamSlug !== params.teamSlug const levelChanged = state.level !== level if (paramsChanged || levelChanged || !state.isInitialized) { @@ -118,7 +118,7 @@ export const createBuildLogsStore = () => const result = await trpcClient.builds.buildLogsBackwardsReversed.query({ - teamIdOrSlug: params.teamIdOrSlug, + teamSlug: params.teamSlug, templateId: params.templateId, buildId: params.buildId, level: level ?? undefined, @@ -193,7 +193,7 @@ export const createBuildLogsStore = () => const result = await state._trpcClient.builds.buildLogsBackwardsReversed.query({ - teamIdOrSlug: state._params.teamIdOrSlug, + teamSlug: state._params.teamSlug, templateId: state._params.templateId, buildId: state._params.buildId, level: state.level ?? undefined, @@ -253,7 +253,7 @@ export const createBuildLogsStore = () => const seenAtCursor = state.forwardSeenAtCursor const result = await state._trpcClient.builds.buildLogsForward.query({ - teamIdOrSlug: state._params.teamIdOrSlug, + teamSlug: state._params.teamSlug, templateId: state._params.templateId, buildId: state._params.buildId, level: state.level ?? undefined, diff --git a/src/features/dashboard/build/header-cells.tsx b/src/features/dashboard/build/header-cells.tsx index e4628638f..ede75eb91 100644 --- a/src/features/dashboard/build/header-cells.tsx +++ b/src/features/dashboard/build/header-cells.tsx @@ -23,8 +23,7 @@ export function Template({ className?: string }) { const router = useRouter() - const { teamIdOrSlug } = - useRouteParams<'/dashboard/[teamIdOrSlug]/templates'>() + const { teamSlug } = useRouteParams<'/dashboard/[teamSlug]/templates'>() return ( - } - /> -
- -
-
-

Delete Team

-

- Permanently delete this team and all of its data -

-
- -
- - ) -} diff --git a/src/features/dashboard/members/member-card.tsx b/src/features/dashboard/members/member-card.tsx index f288d3d42..8df8f25be 100644 --- a/src/features/dashboard/members/member-card.tsx +++ b/src/features/dashboard/members/member-card.tsx @@ -10,7 +10,7 @@ import MemberTable from './member-table' interface MemberCardProps { params: Promise<{ - teamIdOrSlug: string + teamSlug: string }> className?: string } diff --git a/src/features/dashboard/members/member-table-body.tsx b/src/features/dashboard/members/member-table-body.tsx index 4eace9b97..ab218c0ca 100644 --- a/src/features/dashboard/members/member-table-body.tsx +++ b/src/features/dashboard/members/member-table-body.tsx @@ -1,4 +1,4 @@ -import { getTeamMembers } from '@/server/team/get-team-members' +import { getTeamMembers } from '@/core/server/functions/team/get-team-members' import { ErrorIndicator } from '@/ui/error-indicator' import { Alert, AlertDescription, AlertTitle } from '@/ui/primitives/alert' import { TableCell, TableRow } from '@/ui/primitives/table' @@ -6,17 +6,17 @@ import MemberTableRow from './member-table-row' interface TableBodyContentProps { params: Promise<{ - teamIdOrSlug: string + teamSlug: string }> } export default async function MemberTableBody({ params, }: TableBodyContentProps) { - const { teamIdOrSlug } = await params + const { teamSlug } = await params try { - const result = await getTeamMembers({ teamIdOrSlug }) + const result = await getTeamMembers({ teamSlug }) if (!result?.data || result.serverError || result.validationErrors) { throw new Error(result?.serverError || 'Unknown error') @@ -27,7 +27,7 @@ export default async function MemberTableBody({ if (members.length === 0) { return ( - + No Members No team members found. @@ -55,7 +55,7 @@ export default async function MemberTableBody({ } catch (error) { return ( - + { removeMember({ - teamIdOrSlug: team.id, + teamSlug: team.slug, userId, }) } + const providers = + member.info.providers + ?.map(toMemberProvider) + .filter((provider): provider is MemberProvider => provider !== null) ?? [] + return ( @@ -79,6 +116,24 @@ export default function MemberTableRow({ : (member.info.name ?? 'Anonymous')} {member.info.email} + + {providers.length > 0 ? ( +
+ {providers.map(({ key, label, Icon }) => ( + + + {label} + + ))} +
+ ) : ( + - + )} +
{member.relation.added_by === user?.id ? 'You' : (addedByEmail ?? '')} diff --git a/src/features/dashboard/members/member-table.tsx b/src/features/dashboard/members/member-table.tsx index 02bdff36d..4b08a33cb 100644 --- a/src/features/dashboard/members/member-table.tsx +++ b/src/features/dashboard/members/member-table.tsx @@ -14,19 +14,20 @@ import MemberTableBody from './member-table-body' interface MemberTableProps { params: Promise<{ - teamIdOrSlug: string + teamSlug: string }> className?: string } const MemberTable: FC = ({ params, className }) => { return ( - +
NameE-Mail + ProvidersAdded By @@ -35,7 +36,7 @@ const MemberTable: FC = ({ params, className }) => { - + diff --git a/src/features/dashboard/navbar/dashboard-survey-popover.tsx b/src/features/dashboard/navbar/dashboard-survey-popover.tsx index 29cc269ac..d1992d1f4 100644 --- a/src/features/dashboard/navbar/dashboard-survey-popover.tsx +++ b/src/features/dashboard/navbar/dashboard-survey-popover.tsx @@ -5,8 +5,8 @@ import { usePostHog } from 'posthog-js/react' import { useCallback, useState } from 'react' import { toast } from 'sonner' import { INCLUDE_DASHBOARD_FEEDBACK_SURVEY } from '@/configs/flags' +import { l } from '@/core/shared/clients/logger/logger' import { useAppPostHogProvider } from '@/features/posthog-provider' -import { l } from '@/lib/clients/logger/logger' import { Popover, PopoverContent } from '@/ui/primitives/popover' import { SurveyContent } from '@/ui/survey' diff --git a/src/features/dashboard/navbar/report-issue-dialog.tsx b/src/features/dashboard/navbar/report-issue-dialog.tsx index ce46520ed..5ff222a02 100644 --- a/src/features/dashboard/navbar/report-issue-dialog.tsx +++ b/src/features/dashboard/navbar/report-issue-dialog.tsx @@ -181,7 +181,7 @@ export default function ContactSupportDialog({ ) contactSupportMutation.mutate({ - teamIdOrSlug: team.id, + teamSlug: team.slug, description, files: filePayloads.length > 0 ? filePayloads : undefined, }) diff --git a/src/features/dashboard/sandbox/context.tsx b/src/features/dashboard/sandbox/context.tsx index ad15487b5..1ccf8794a 100644 --- a/src/features/dashboard/sandbox/context.tsx +++ b/src/features/dashboard/sandbox/context.tsx @@ -4,13 +4,13 @@ import { useQuery } from '@tanstack/react-query' import type { ReactNode } from 'react' import { createContext, useCallback, useContext, useMemo } from 'react' import { SANDBOXES_METRICS_POLLING_MS } from '@/configs/intervals' +import type { + SandboxDetailsModel, + SandboxEventModel, +} from '@/core/modules/sandboxes/models' import { useAlignedRefetchInterval } from '@/lib/hooks/use-aligned-refetch-interval' import { useRouteParams } from '@/lib/hooks/use-route-params' import { isNotFoundError } from '@/lib/utils/trpc-errors' -import type { - SandboxDetailsDTO, - SandboxEventDTO, -} from '@/server/api/models/sandboxes.models' import { useTRPC } from '@/trpc/client' import { SANDBOX_LIFECYCLE_EVENT_KILLED } from './monitoring/utils/constants' @@ -18,11 +18,11 @@ export interface SandboxLifecycleState { createdAt: string | null pausedAt: string | null endedAt: string | null - events: SandboxEventDTO[] + events: SandboxEventModel[] } interface SandboxContextValue { - sandboxInfo?: SandboxDetailsDTO + sandboxInfo?: SandboxDetailsModel sandboxLifecycle: SandboxLifecycleState | null isRunning: boolean isSandboxNotFound: boolean @@ -46,7 +46,7 @@ interface SandboxProviderProps { } function buildSandboxLifecycle( - sandboxInfo: SandboxDetailsDTO | undefined + sandboxInfo: SandboxDetailsModel | undefined ): SandboxLifecycleState | null { if (!sandboxInfo) { return null @@ -68,8 +68,8 @@ function buildSandboxLifecycle( } export function SandboxProvider({ children }: SandboxProviderProps) { - const { teamIdOrSlug, sandboxId } = - useRouteParams<'/dashboard/[teamIdOrSlug]/sandboxes/[sandboxId]'>() + const { teamSlug, sandboxId } = + useRouteParams<'/dashboard/[teamSlug]/sandboxes/[sandboxId]'>() const trpc = useTRPC() const getAlignedRefetchInterval = useAlignedRefetchInterval({ @@ -84,11 +84,13 @@ export function SandboxProvider({ children }: SandboxProviderProps) { refetch, } = useQuery( trpc.sandbox.details.queryOptions( - { teamIdOrSlug, sandboxId }, + { teamSlug, sandboxId }, { retry: false, refetchInterval: (query) => { - const sandboxInfo = query.state.data as SandboxDetailsDTO | undefined + const sandboxInfo = query.state.data as + | SandboxDetailsModel + | undefined const state = sandboxInfo?.state // Keep polling when killed but the killed lifecycle event hasn't diff --git a/src/features/dashboard/sandbox/header/kill-button.tsx b/src/features/dashboard/sandbox/header/kill-button.tsx index 5332d5dbb..e8c66bcaf 100644 --- a/src/features/dashboard/sandbox/header/kill-button.tsx +++ b/src/features/dashboard/sandbox/header/kill-button.tsx @@ -3,8 +3,8 @@ import { useAction } from 'next-safe-action/hooks' import { useState } from 'react' import { toast } from 'sonner' +import { killSandboxAction } from '@/core/server/actions/sandbox-actions' import { cn } from '@/lib/utils/ui' -import { killSandboxAction } from '@/server/sandboxes/sandbox-actions' import { AlertPopover } from '@/ui/alert-popover' import { Button } from '@/ui/primitives/button' import { TrashIcon } from '@/ui/primitives/icons' @@ -40,7 +40,7 @@ export default function KillButton({ className }: KillButtonProps) { if (!canKill || !sandboxInfo?.sandboxID) return execute({ - teamIdOrSlug: team.id, + teamSlug: team.slug, sandboxId: sandboxInfo.sandboxID, }) } diff --git a/src/features/dashboard/sandbox/inspect/context.tsx b/src/features/dashboard/sandbox/inspect/context.tsx index d9dd66414..e15acc0d7 100644 --- a/src/features/dashboard/sandbox/inspect/context.tsx +++ b/src/features/dashboard/sandbox/inspect/context.tsx @@ -13,7 +13,7 @@ import { } from 'react' import { SUPABASE_AUTH_HEADERS } from '@/configs/api' import { AUTH_URLS } from '@/configs/urls' -import { supabase } from '@/lib/clients/supabase/client' +import { supabase } from '@/core/shared/clients/supabase/client' import { useSandboxInspectAnalytics } from '@/lib/hooks/use-analytics' import { getParentPath, normalizePath } from '@/lib/utils/filesystem' import { useDashboard } from '../../context' diff --git a/src/features/dashboard/sandbox/inspect/incompatible.tsx b/src/features/dashboard/sandbox/inspect/incompatible.tsx index 18a2639b2..9f94be982 100644 --- a/src/features/dashboard/sandbox/inspect/incompatible.tsx +++ b/src/features/dashboard/sandbox/inspect/incompatible.tsx @@ -21,30 +21,30 @@ import { interface SandboxInspectIncompatibleProps { templateNameOrId?: string - teamIdOrSlug: string + teamSlug: string } export default function SandboxInspectIncompatible({ templateNameOrId, - teamIdOrSlug, + teamSlug, }: SandboxInspectIncompatibleProps) { const codeClassNames = 'mx-0.5 h-5.5 rounded-none align-middle' const { trackInteraction } = useSandboxInspectAnalytics() useEffect(() => { - if (!templateNameOrId || !teamIdOrSlug) return + if (!templateNameOrId || !teamSlug) return trackInteraction('viewed_incompatible', { - team_id: teamIdOrSlug, + team_id: teamSlug, template_name_or_id: templateNameOrId, }) - }, [trackInteraction, teamIdOrSlug]) + }, [trackInteraction, teamSlug]) return (
-
- - +
+ +
- + Back to sandboxes diff --git a/src/features/dashboard/sandbox/inspect/not-found.tsx b/src/features/dashboard/sandbox/inspect/not-found.tsx index 55acaa472..884175ba2 100644 --- a/src/features/dashboard/sandbox/inspect/not-found.tsx +++ b/src/features/dashboard/sandbox/inspect/not-found.tsx @@ -3,9 +3,8 @@ import { ArrowLeft, ArrowUp, Home, RefreshCw } from 'lucide-react' import { useParams, useRouter } from 'next/navigation' import { useCallback, useEffect, useState, useTransition } from 'react' -import { serializeError } from 'serialize-error' import { PROTECTED_URLS } from '@/configs/urls' -import { l } from '@/lib/clients/logger/logger' +import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger' import { useSandboxInspectAnalytics } from '@/lib/hooks/use-analytics' import { cn } from '@/lib/utils' import { Button } from '@/ui/primitives/button' @@ -15,9 +14,8 @@ import SandboxInspectEmptyFrame from './empty' export default function SandboxInspectNotFound() { const router = useRouter() const { isRunning } = useSandboxContext() - const { trackInteraction } = useSandboxInspectAnalytics() - const { teamIdOrSlug } = useParams() + const { teamSlug } = useParams() const [pendingPath, setPendingPath] = useState(undefined) const [isPending, startTransition] = useTransition() @@ -34,7 +32,7 @@ export default function SandboxInspectNotFound() { l.error( { key: 'sandbox_inspect_not_found:save_root_path_failed', - error: serializeError(error), + error: serializeErrorForLog(error), }, `${error instanceof Error ? error.message : 'Failed to save root path'}` ) @@ -105,9 +103,7 @@ export default function SandboxInspectNotFound() { ) : ( = ({ children }) => { 'use no memo' - const { teamIdOrSlug } = useParams() as { teamIdOrSlug: string } + const { teamSlug } = useParams() as { teamSlug: string } const [open, setOpen] = useState(false) const [createdApiKey, setCreatedApiKey] = useState(null) @@ -98,7 +98,7 @@ const CreateApiKeyDialog: FC = ({ children }) => {
- createApiKey({ teamIdOrSlug, name: values.name }) + createApiKey({ teamSlug, name: values.name }) )} className="flex flex-col gap-6" > diff --git a/src/features/dashboard/settings/keys/table-body.tsx b/src/features/dashboard/settings/keys/table-body.tsx index 2a7a0f706..8843f55fb 100644 --- a/src/features/dashboard/settings/keys/table-body.tsx +++ b/src/features/dashboard/settings/keys/table-body.tsx @@ -1,20 +1,20 @@ import { CLI_GENERATED_KEY_NAME } from '@/configs/api' -import { getTeamApiKeys } from '@/server/keys/get-api-keys' +import { getTeamApiKeys } from '@/core/server/functions/keys/get-api-keys' import { ErrorIndicator } from '@/ui/error-indicator' import { Alert, AlertDescription, AlertTitle } from '@/ui/primitives/alert' import { TableCell, TableRow } from '@/ui/primitives/table' import ApiKeyTableRow from './table-row' interface TableBodyContentProps { - params: Promise<{ teamIdOrSlug: string }> + params: Promise<{ teamSlug: string }> } export default async function TableBodyContent({ params, }: TableBodyContentProps) { - const { teamIdOrSlug } = await params + const { teamSlug } = await params - const result = await getTeamApiKeys({ teamIdOrSlug }) + const result = await getTeamApiKeys({ teamSlug }) if (!result?.data || result.serverError || result.validationErrors) { return ( diff --git a/src/features/dashboard/settings/keys/table-row.tsx b/src/features/dashboard/settings/keys/table-row.tsx index fa81952ee..86f2467c4 100644 --- a/src/features/dashboard/settings/keys/table-row.tsx +++ b/src/features/dashboard/settings/keys/table-row.tsx @@ -5,6 +5,8 @@ import { motion } from 'motion/react' import { useAction } from 'next-safe-action/hooks' import { useState } from 'react' import { API_KEYS_LAST_USED_FIRST_COLLECTION_DATE } from '@/configs/versioning' +import type { TeamAPIKey } from '@/core/modules/keys/models' +import { deleteApiKeyAction } from '@/core/server/actions/key-actions' import { useDashboard } from '@/features/dashboard/context' import { defaultErrorToast, @@ -12,8 +14,6 @@ import { useToast, } from '@/lib/hooks/use-toast' import { exponentialSmoothing } from '@/lib/utils' -import { deleteApiKeyAction } from '@/server/keys/key-actions' -import type { TeamAPIKey } from '@/types/api.types' import { AlertDialog } from '@/ui/alert-dialog' import { Button } from '@/ui/primitives/button' import { @@ -63,7 +63,7 @@ export default function ApiKeyTableRow({ const deleteKey = () => { executeDeleteKey({ - teamIdOrSlug: team.id, + teamSlug: team.slug, apiKeyId: apiKey.id, }) } diff --git a/src/features/dashboard/settings/keys/table.tsx b/src/features/dashboard/settings/keys/table.tsx index 989e29d4c..28fa4645d 100644 --- a/src/features/dashboard/settings/keys/table.tsx +++ b/src/features/dashboard/settings/keys/table.tsx @@ -11,7 +11,7 @@ import { TableLoader } from '@/ui/table-loader' import TableBodyContent from './table-body' interface ApiKeysTableProps { - params: Promise<{ teamIdOrSlug: string }> + params: Promise<{ teamSlug: string }> className?: string } diff --git a/src/features/dashboard/settings/webhooks/add-edit-dialog-steps.tsx b/src/features/dashboard/settings/webhooks/add-edit-dialog-steps.tsx index 96f129ba6..a4620b702 100644 --- a/src/features/dashboard/settings/webhooks/add-edit-dialog-steps.tsx +++ b/src/features/dashboard/settings/webhooks/add-edit-dialog-steps.tsx @@ -5,7 +5,7 @@ import { useEffect, useMemo, useState } from 'react' import type { UseFormReturn } from 'react-hook-form' import ShikiHighlighter from 'react-shiki' import { useShikiTheme } from '@/configs/shiki' -import type { UpsertWebhookSchemaType } from '@/server/webhooks/schema' +import type { UpsertWebhookSchemaType } from '@/core/server/functions/webhooks/schema' import { Button } from '@/ui/primitives/button' import { Checkbox } from '@/ui/primitives/checkbox' import { diff --git a/src/features/dashboard/settings/webhooks/add-edit-dialog.tsx b/src/features/dashboard/settings/webhooks/add-edit-dialog.tsx index 0c40d6de3..baf9fa112 100644 --- a/src/features/dashboard/settings/webhooks/add-edit-dialog.tsx +++ b/src/features/dashboard/settings/webhooks/add-edit-dialog.tsx @@ -4,13 +4,13 @@ import { zodResolver } from '@hookform/resolvers/zod' import { useHookFormAction } from '@next-safe-action/adapter-react-hook-form/hooks' import { PlusIcon } from 'lucide-react' import { useState } from 'react' +import { upsertWebhookAction } from '@/core/server/actions/webhooks-actions' +import { UpsertWebhookSchema } from '@/core/server/functions/webhooks/schema' import { defaultErrorToast, defaultSuccessToast, toast, } from '@/lib/hooks/use-toast' -import { UpsertWebhookSchema } from '@/server/webhooks/schema' -import { upsertWebhookAction } from '@/server/webhooks/webhooks-actions' import { Button } from '@/ui/primitives/button' import { Dialog, @@ -62,9 +62,9 @@ export default function WebhookAddEditDialog({ } = useHookFormAction(upsertWebhookAction, zodResolver(UpsertWebhookSchema), { formProps: { mode: 'onChange', - disabled: !team.id, + disabled: !team.slug, defaultValues: { - teamIdOrSlug: team.id, + teamSlug: team.slug, webhookId: isEditMode ? webhook?.id : undefined, mode, name: webhook?.name || '', @@ -74,7 +74,7 @@ export default function WebhookAddEditDialog({ ...(isEditMode ? {} : { signatureSecret: '' }), }, values: { - teamIdOrSlug: team.id, + teamSlug: team.slug, webhookId: isEditMode ? webhook?.id : undefined, mode, name: webhook?.name || '', @@ -153,7 +153,7 @@ export default function WebhookAddEditDialog({ if (currentEvents.includes(event)) { form.setValue( 'events', - currentEvents.filter((e) => e !== event) + currentEvents.filter((eventName: string) => eventName !== event) ) } else { form.setValue('events', [...currentEvents, event]) @@ -198,7 +198,7 @@ export default function WebhookAddEditDialog({ {/* Hidden fields */} - +
{ executeDeleteWebhook({ - teamIdOrSlug: webhook.teamId, + teamSlug: team.slug, webhookId: webhook.id, }) }} diff --git a/src/features/dashboard/settings/webhooks/edit-secret-dialog.tsx b/src/features/dashboard/settings/webhooks/edit-secret-dialog.tsx index a900f3461..cb52a6a1e 100644 --- a/src/features/dashboard/settings/webhooks/edit-secret-dialog.tsx +++ b/src/features/dashboard/settings/webhooks/edit-secret-dialog.tsx @@ -3,13 +3,14 @@ import { zodResolver } from '@hookform/resolvers/zod' import { useHookFormAction } from '@next-safe-action/adapter-react-hook-form/hooks' import { useState } from 'react' +import { updateWebhookSecretAction } from '@/core/server/actions/webhooks-actions' +import { UpdateWebhookSecretSchema } from '@/core/server/functions/webhooks/schema' +import { useDashboard } from '@/features/dashboard/context' import { defaultErrorToast, defaultSuccessToast, toast, } from '@/lib/hooks/use-toast' -import { UpdateWebhookSecretSchema } from '@/server/webhooks/schema' -import { updateWebhookSecretAction } from '@/server/webhooks/webhooks-actions' import { Button } from '@/ui/primitives/button' import { Dialog, @@ -42,6 +43,7 @@ export default function WebhookEditSecretDialog({ }: WebhookEditSecretDialogProps) { 'use no memo' + const { team } = useDashboard() const [open, setOpen] = useState(false) const webhookName = webhook.name @@ -58,7 +60,7 @@ export default function WebhookEditSecretDialog({ formProps: { mode: 'onChange', defaultValues: { - teamIdOrSlug: webhook.teamId, + teamSlug: team.slug, webhookId: webhook.id, signatureSecret: '', }, @@ -135,7 +137,7 @@ export default function WebhookEditSecretDialog({ {/* Hidden fields */} - +
diff --git a/src/features/dashboard/settings/webhooks/table-body.tsx b/src/features/dashboard/settings/webhooks/table-body.tsx index 9498c3446..fa436a298 100644 --- a/src/features/dashboard/settings/webhooks/table-body.tsx +++ b/src/features/dashboard/settings/webhooks/table-body.tsx @@ -1,19 +1,19 @@ -import { getWebhooks } from '@/server/webhooks/get-webhooks' +import { getWebhooks } from '@/core/server/functions/webhooks/get-webhooks' import { TableCell, TableRow } from '@/ui/primitives/table' import WebhooksEmpty from './empty' import WebhookTableRow from './table-row' interface TableBodyContentProps { params: Promise<{ - teamIdOrSlug: string + teamSlug: string }> } export default async function TableBodyContent({ params, }: TableBodyContentProps) { - const { teamIdOrSlug } = await params - const webhooksResult = await getWebhooks({ teamIdOrSlug }) + const { teamSlug } = await params + const webhooksResult = await getWebhooks({ teamSlug }) // undefined data indicates execution error so we disable the controls const hasError = webhooksResult?.data === undefined diff --git a/src/features/dashboard/settings/webhooks/table.tsx b/src/features/dashboard/settings/webhooks/table.tsx index 62384e58a..4c4342413 100644 --- a/src/features/dashboard/settings/webhooks/table.tsx +++ b/src/features/dashboard/settings/webhooks/table.tsx @@ -12,7 +12,7 @@ import TableBodyContent from './table-body' interface WebhooksTableProps { params: Promise<{ - teamIdOrSlug: string + teamSlug: string }> className?: string } diff --git a/src/features/dashboard/settings/webhooks/types.ts b/src/features/dashboard/settings/webhooks/types.ts index 974a80788..4a1ed63e4 100644 --- a/src/features/dashboard/settings/webhooks/types.ts +++ b/src/features/dashboard/settings/webhooks/types.ts @@ -1,3 +1,3 @@ -import type { components as ArgusComponents } from '@/types/argus-api.types' +import type { components as ArgusComponents } from '@/contracts/argus-api' export type Webhook = ArgusComponents['schemas']['WebhookDetail'] diff --git a/src/features/dashboard/sidebar/blocked-banner.tsx b/src/features/dashboard/sidebar/blocked-banner.tsx index d4e9fdd90..eab172897 100644 --- a/src/features/dashboard/sidebar/blocked-banner.tsx +++ b/src/features/dashboard/sidebar/blocked-banner.tsx @@ -20,9 +20,10 @@ export default function TeamBlockageAlert({ const router = useRouter() const isBillingLimit = useMemo( - () => team.blocked_reason?.toLowerCase().includes('billing limit'), - [team.blocked_reason] + () => team.blockedReason?.toLowerCase().includes('billing limit'), + [team.blockedReason] ) + const handleClick = () => { if (isBillingLimit) { router.push(PROTECTED_URLS.LIMITS(team.slug)) @@ -34,19 +35,17 @@ export default function TeamBlockageAlert({ return ( - {team.is_blocked && ( + {team.isBlocked && ( - +
Team is Blocked - {team.blocked_reason && ( + {team.blockedReason && ( - {team.blocked_reason} + {team.blockedReason} )}
diff --git a/src/features/dashboard/sidebar/command.tsx b/src/features/dashboard/sidebar/command.tsx index e151f166f..7c4968853 100644 --- a/src/features/dashboard/sidebar/command.tsx +++ b/src/features/dashboard/sidebar/command.tsx @@ -81,7 +81,7 @@ export default function DashboardSidebarCommand({ onSelect={() => { router.push( link.href({ - teamIdOrSlug: team.slug ?? team.id, + teamSlug: team.slug, }) ) setOpen(false) diff --git a/src/features/dashboard/sidebar/content.tsx b/src/features/dashboard/sidebar/content.tsx index cd9657f0e..2bcc25591 100644 --- a/src/features/dashboard/sidebar/content.tsx +++ b/src/features/dashboard/sidebar/content.tsx @@ -37,7 +37,7 @@ const createGroupedLinks = (links: SidebarNavItem[]): GroupedLinks => { export default function DashboardSidebarContent() { const { team } = useDashboard() - const selectedTeamIdentifier = team.slug ?? team.id + const selectedTeamSlug = team.slug const pathname = usePathname() const isMobile = useIsMobile() @@ -64,7 +64,7 @@ export default function DashboardSidebarContent() { {links.map((item) => { const href = item.href({ - teamIdOrSlug: selectedTeamIdentifier ?? undefined, + teamSlug: selectedTeamSlug, }) return ( diff --git a/src/features/dashboard/sidebar/create-team-dialog.tsx b/src/features/dashboard/sidebar/create-team-dialog.tsx index 5ba1759d7..0efc84d3f 100644 --- a/src/features/dashboard/sidebar/create-team-dialog.tsx +++ b/src/features/dashboard/sidebar/create-team-dialog.tsx @@ -4,13 +4,13 @@ import { zodResolver } from '@hookform/resolvers/zod' import { useHookFormAction } from '@next-safe-action/adapter-react-hook-form/hooks' import { useRouter } from 'next/navigation' import { PROTECTED_URLS } from '@/configs/urls' +import { createTeamAction } from '@/core/server/actions/team-actions' +import { CreateTeamSchema } from '@/core/server/functions/team/types' import { defaultErrorToast, defaultSuccessToast, toast, } from '@/lib/hooks/use-toast' -import { createTeamAction } from '@/server/team/team-actions' -import { CreateTeamSchema } from '@/server/team/types' import { Button } from '@/ui/primitives/button' import { Dialog, diff --git a/src/features/dashboard/sidebar/menu-teams.tsx b/src/features/dashboard/sidebar/menu-teams.tsx index d2fcda05c..1a41b6159 100644 --- a/src/features/dashboard/sidebar/menu-teams.tsx +++ b/src/features/dashboard/sidebar/menu-teams.tsx @@ -1,11 +1,9 @@ import Link from 'next/link' import { usePathname, useSearchParams } from 'next/navigation' import { useCallback } from 'react' -import useSWR from 'swr' -import type { UserTeamsResponse } from '@/app/api/teams/user/types' import { TEAM_SPECIFIC_RESOURCE_SEGMENTS } from '@/configs/urls' -import { useTeamCookieManager } from '@/lib/hooks/use-team' -import type { ClientTeam } from '@/types/dashboard.types' +import type { TeamModel } from '@/core/modules/teams/models' +import { getTeamDisplayName } from '@/core/modules/teams/utils' import { Avatar, AvatarFallback, AvatarImage } from '@/ui/primitives/avatar' import { DropdownMenuItem, @@ -13,7 +11,6 @@ import { DropdownMenuRadioGroup, DropdownMenuRadioItem, } from '@/ui/primitives/dropdown-menu' -import { Skeleton } from '@/ui/primitives/skeleton' import { useDashboard } from '../context' const PRESERVED_SEARCH_PARAMS = ['tab'] as const @@ -22,39 +19,10 @@ export default function DashboardSidebarMenuTeams() { const pathname = usePathname() const searchParams = useSearchParams() - const { user, team: selectedTeam } = useDashboard() - - useTeamCookieManager() - - const { data: teams, isLoading } = useSWR( - ['/api/teams/user', user?.id], - async ([url, userId]: [string, string | undefined]) => { - if (!userId) { - return null - } - - const response = await fetch(url, { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - }, - }) - - if (!response.ok) { - throw new Error(`Failed to fetch teams: ${response.status}`) - } - - const { teams } = (await response.json()) as UserTeamsResponse - - return teams - }, - { - keepPreviousData: true, - } - ) + const { user, team: selectedTeam, teams } = useDashboard() const getNextUrl = useCallback( - (team: ClientTeam) => { + (team: TeamModel) => { const splitPath = pathname.split('/') // splitPath: ["", "dashboard", teamIdOrSlug, section?, resourceId?, ...] const originalSlug = splitPath[2] @@ -90,44 +58,23 @@ export default function DashboardSidebarMenuTeams() { [pathname, searchParams] ) - if (isLoading) { - return ( - <> - {user?.email && ( - - - - )} - {[1, 2].map((i) => ( -
- - -
- ))} - - ) - } - return ( {user?.email && ( {user.email} )} - {teams && teams.length > 0 ? ( + {teams.length > 0 ? ( teams.map((team) => ( - + {team.name?.charAt(0).toUpperCase() || '?'} - {team.transformed_default_name || team.name} + {getTeamDisplayName(team)} diff --git a/src/features/dashboard/sidebar/menu.tsx b/src/features/dashboard/sidebar/menu.tsx index 2c5fa7158..8beb64593 100644 --- a/src/features/dashboard/sidebar/menu.tsx +++ b/src/features/dashboard/sidebar/menu.tsx @@ -4,8 +4,9 @@ import { ChevronsUpDown, LogOut, Plus, UserRoundCog } from 'lucide-react' import Link from 'next/link' import { useState } from 'react' import { PROTECTED_URLS } from '@/configs/urls' +import { getTeamDisplayName } from '@/core/modules/teams/utils' +import { signOutAction } from '@/core/server/actions/auth-actions' import { cn } from '@/lib/utils' -import { signOutAction } from '@/server/auth/auth-actions' import { Avatar, AvatarFallback, AvatarImage } from '@/ui/primitives/avatar' import { DropdownMenu, @@ -44,7 +45,7 @@ export default function DashboardSidebarMenu({ size="lg" className={cn( 'h-14 flex', - 'group-data-[collapsible=icon]:h-9 group-data-[collapsible=icon]:border-0 group-data-[collapsible=icon]:!px-0', + 'group-data-[collapsible=icon]:h-9 group-data-[collapsible=icon]:border-0 group-data-[collapsible=icon]:px-0!', className )} > @@ -53,12 +54,12 @@ export default function DashboardSidebarMenu({ 'shrink-0 transition-all duration-100 ease-in-out', 'group-data-[collapsible=icon]:block group-data-[collapsible=icon]:size-9 group-data-[collapsible=icon]:p-[5px]', { - 'drop-shadow-sm filter': team.profile_picture_url, + 'drop-shadow-sm filter': team.profilePictureUrl, } )} > @@ -70,7 +71,7 @@ export default function DashboardSidebarMenu({ TEAM - {team.transformed_default_name || team.name} + {getTeamDisplayName(team)}
diff --git a/src/features/dashboard/templates/builds/constants.ts b/src/features/dashboard/templates/builds/constants.ts index 1669ce53d..94cf4fa39 100644 --- a/src/features/dashboard/templates/builds/constants.ts +++ b/src/features/dashboard/templates/builds/constants.ts @@ -1,10 +1,5 @@ import { millisecondsInDay } from 'date-fns/constants' -import type { BuildStatus } from '@/server/api/models/builds.models' -export const LOG_RETENTION_MS = 7 * millisecondsInDay // 7 days +export { INITIAL_BUILD_STATUSES } from '@/core/modules/builds/constants' -export const INITIAL_BUILD_STATUSES: BuildStatus[] = [ - 'building', - 'failed', - 'success', -] +export const LOG_RETENTION_MS = 7 * millisecondsInDay // 7 days diff --git a/src/features/dashboard/templates/builds/header.tsx b/src/features/dashboard/templates/builds/header.tsx index 99f79d676..a0d74c57e 100644 --- a/src/features/dashboard/templates/builds/header.tsx +++ b/src/features/dashboard/templates/builds/header.tsx @@ -1,8 +1,8 @@ 'use client' import { useEffect, useState } from 'react' +import type { BuildStatus } from '@/core/modules/builds/models' import { cn } from '@/lib/utils' -import type { BuildStatus } from '@/server/api/models/builds.models' import { Button } from '@/ui/primitives/button' import { DropdownMenu, diff --git a/src/features/dashboard/templates/builds/table-cells.tsx b/src/features/dashboard/templates/builds/table-cells.tsx index 2d2c814b1..c57e63a61 100644 --- a/src/features/dashboard/templates/builds/table-cells.tsx +++ b/src/features/dashboard/templates/builds/table-cells.tsx @@ -4,6 +4,10 @@ import { ArrowUpRight } from 'lucide-react' import { useRouter } from 'next/navigation' import { useEffect, useState } from 'react' import { PROTECTED_URLS } from '@/configs/urls' +import type { + BuildStatus, + ListedBuildModel, +} from '@/core/modules/builds/models' import { useTemplateTableStore } from '@/features/dashboard/templates/list/stores/table-store' import { useRouteParams } from '@/lib/hooks/use-route-params' import { cn } from '@/lib/utils' @@ -11,10 +15,6 @@ import { formatDurationCompact, formatTimeAgoCompact, } from '@/lib/utils/formatting' -import type { - BuildStatus, - ListedBuildDTO, -} from '@/server/api/models/builds.models' import CopyButtonInline from '@/ui/copy-button-inline' import { Badge } from '@/ui/primitives/badge' import { Button } from '@/ui/primitives/button' @@ -42,8 +42,7 @@ export function Template({ className?: string }) { const router = useRouter() - const { teamIdOrSlug } = - useRouteParams<'/dashboard/[teamIdOrSlug]/templates'>() + const { teamSlug } = useRouteParams<'/dashboard/[teamSlug]/templates'>() return (