Organize server actions (#30)

This commit is contained in:
Chris Estreich 2025-05-18 09:49:40 -07:00 committed by GitHub
parent 01e6eada6d
commit b0b85f5535
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 210 additions and 239 deletions

View file

@ -1,11 +1,12 @@
// pnpm test src/lib/server/__tests__/syncCurrentUser.test.ts
// pnpm test src/actions/__tests__/syncCurrentUser.test.ts
import { db } from '@/db';
import { users, orgs } from '@/db/schema';
import { eq } from 'drizzle-orm';
import { logger } from '@/lib/server/logger';
import { syncCurrentUser } from '../sync';
import { logger } from '../logger';
const testUserId = 'fake-user-id';
const testOrgId = 'fake-org-id';
@ -21,11 +22,8 @@ vi.mock('@clerk/nextjs/server', () => ({
}),
}));
vi.mock('../logger', () => ({
logger: {
info: vi.fn(),
error: vi.fn(),
},
vi.mock('@/lib/server/logger', () => ({
logger: { info: vi.fn(), error: vi.fn() },
}));
describe('syncCurrentUser', () => {

View file

@ -1,13 +1,13 @@
// pnpm test src/lib/server/__tests__/syncOrg.test.ts
// pnpm test src/actions/__tests__/syncOrg.test.ts
import { eq } from 'drizzle-orm';
import { clerkClient } from '@clerk/nextjs/server';
import { db } from '@/db';
import { orgs } from '@/db/schema';
import { logger } from '@/lib/server/logger';
import { syncOrg } from '../sync';
import { logger } from '../logger';
vi.mock('@clerk/nextjs/server', () => ({
clerkClient: vi.fn().mockResolvedValue({
@ -22,11 +22,8 @@ vi.mock('@clerk/nextjs/server', () => ({
}),
}));
vi.mock('../logger', () => ({
logger: {
info: vi.fn(),
error: vi.fn(),
},
vi.mock('@/lib/server/logger', () => ({
logger: { info: vi.fn(), error: vi.fn() },
}));
describe('syncOrg', () => {

View file

@ -1,10 +1,41 @@
'use server';
import { z } from 'zod';
import { TelemetryEventName } from '@roo-code/types';
import { type TimePeriod } from '@/schemas';
import { client } from '@/lib/server/analytics';
import {
TelemetryEventName,
type RooCodeTelemetryEvent,
} from '@roo-code/types';
import type { TimePeriod } from '@/types';
import { analytics } from '@/lib/server';
/**
* captureEvent
*/
type AnalyticsEvent = {
id: string;
orgId: string;
userId: string;
timestamp: number;
event: RooCodeTelemetryEvent;
};
export const captureEvent = async ({
event: { properties, ...cloudEvent },
...analyticsEvent
}: AnalyticsEvent) => {
// The destructuring here flattens the `AnalyticsEvent` to match the ClickHouse
// schema.
const value = { ...analyticsEvent, ...cloudEvent, ...properties };
await analytics.insert({
table: 'events',
values: [value],
format: 'JSONEachRow',
});
};
/**
* Usage
@ -38,7 +69,7 @@ export const getUsage = async ({
return {};
}
const resultSet = await client.query({
const resultSet = await analytics.query({
query: `
SELECT
type,

View file

@ -1,53 +0,0 @@
import { auth } from '@clerk/nextjs/server';
import { logger } from '@/lib/server/logger';
export type ApiResponse = {
success: boolean;
message?: string;
error?: string;
};
/**
* Validates user authentication and organization membership
* @returns User and organization IDs if authenticated, or error response if not
*/
export async function validateAuth(): Promise<
{ userId: string; orgId: string } | ApiResponse
> {
const { userId, orgId } = await auth();
if (!userId) {
return { success: false, error: 'Unauthorized: User required' };
}
if (!orgId) {
return { success: false, error: 'Unauthorized: Organization required' };
}
return { userId, orgId };
}
export function isAuthSuccess(
result: { userId: string; orgId: string } | ApiResponse,
): result is { userId: string; orgId: string } {
return !('error' in result);
}
/**
* Generic error handler for all operations
* @param error The caught error
* @param eventPrefix Prefix for logging events
* @returns Error response
*/
export function handleError(error: unknown, eventPrefix: string): ApiResponse {
logger.error({
event: `${eventPrefix}_update_error`,
error: error instanceof Error ? error.message : 'Unknown error',
});
return {
success: false,
error:
error instanceof Error ? error.message : 'An unexpected error occurred',
};
}

View file

@ -2,9 +2,9 @@
import { eq, gte, and, desc } from 'drizzle-orm';
import { db } from '@/db';
import { auditLogs, type AuditLog } from '@/db/schema';
import { logger } from '@/lib/server/logger';
import { logger } from '@/lib/server';
import { db, type DB_OR_TX } from '@/db';
import { auditLogs, type AuditLog, type CreateAuditLog } from '@/db/schema';
export const getAuditLogs = async ({
orgId,
@ -57,3 +57,27 @@ export const getAuditLogs = async ({
throw error;
}
};
export async function insertAuditLog(
db: DB_OR_TX,
values: CreateAuditLog,
): Promise<void> {
await db.insert(auditLogs).values(values);
const { userId, orgId, targetType } = values;
logger.info({ userId, orgId, targetType });
}
export async function createAuditLog(values: CreateAuditLog): Promise<{
success: boolean;
error?: string | Record<string, unknown>;
}> {
try {
await insertAuditLog(db, values);
return { success: true };
} catch (e) {
const error =
e instanceof Error ? e.message : 'An unexpected error occurred';
logger.error({ event: 'audit_log_creation_error', error });
return { success: false, error };
}
}

51
src/actions/auth.ts Normal file
View file

@ -0,0 +1,51 @@
'use server';
import { auth } from '@clerk/nextjs/server';
import type { ApiResponse } from '@/types';
import { Env, logger } from '@/lib/server';
export async function validateAuth(): Promise<
{ userId: string; orgId: string } | ApiResponse
> {
const { userId, orgId } = await auth();
if (!userId) {
return { success: false, error: 'Unauthorized: User required' };
}
if (!orgId) {
return { success: false, error: 'Unauthorized: Organization required' };
}
return { userId, orgId };
}
// Default expiration is 30 days (2592000 seconds).
export async function getSignInToken(
userId: string,
): Promise<string | undefined> {
const response = await fetch('https://api.clerk.com/v1/sign_in_tokens', {
method: 'POST',
headers: {
Authorization: `Bearer ${Env.CLERK_SECRET_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ user_id: userId }),
});
if (!response.ok) {
logger.error({
event: 'sign_in_token_creation_failed',
error: await response.json(),
userId,
});
throw new Error('Failed to create sign-in token');
}
// TODO: Validate response with a schema.
const data = await response.json();
logger.info({ event: 'sign_in_token_created', userId });
return data.token;
}

View file

@ -1,18 +1,15 @@
'use server';
import { sql } from 'drizzle-orm';
import { z } from 'zod';
import { type ApiResponse, ORGANIZATION_ALLOW_ALL } from '@/types';
import { db } from '@/db';
import { AuditLogTargetType, orgSettings } from '@/db/schema';
import { insertAuditLog } from '@/lib/server/auditLogs';
import {
handleError,
isAuthSuccess,
validateAuth,
type ApiResponse,
} from './apiUtils';
import { sql } from 'drizzle-orm';
import { ORGANIZATION_ALLOW_ALL } from '@/schemas';
import { isAuthSuccess, handleError } from '@/lib/server';
import { validateAuth } from './auth';
import { insertAuditLog } from './auditLogs';
const defaultParametersSchema = z.object({
experimentalPowerSteering: z.boolean().optional(),

View file

@ -1,24 +1,23 @@
'use server';
import { auth } from '@clerk/nextjs/server';
import { db } from '@/db';
import { AuditLogTargetType, orgSettings } from '@/db/schema';
import { eq, sql } from 'drizzle-orm';
import { auth } from '@clerk/nextjs/server';
import { z } from 'zod';
import {
type ApiResponse,
ORGANIZATION_ALLOW_ALL,
ORGANIZATION_DEFAULT,
type OrganizationSettings,
organizationAllowListSchema,
organizationDefaultSettingsSchema,
} from '@/schemas';
import { z } from 'zod';
import {
handleError,
isAuthSuccess,
validateAuth,
type ApiResponse,
} from './apiUtils';
import { insertAuditLog } from '@/lib/server/auditLogs';
} from '@/types';
import { db } from '@/db';
import { AuditLogTargetType, orgSettings } from '@/db/schema';
import { handleError, isAuthSuccess } from '@/lib/server';
import { validateAuth } from './auth';
import { insertAuditLog } from './auditLogs';
export async function getOrganizationSettings(): Promise<
OrganizationSettings | undefined

View file

@ -2,14 +2,12 @@
import { z } from 'zod';
import type { ApiResponse } from '@/types';
import { handleError, isAuthSuccess } from '@/lib/server';
import { AuditLogTargetType } from '@/db/schema';
import { createAuditLog } from '@/lib/server/auditLogs';
import {
handleError,
isAuthSuccess,
validateAuth,
type ApiResponse,
} from './apiUtils';
import { validateAuth } from './auth';
import { createAuditLog } from './auditLogs';
const allowAllProvidersSchema = z.object({
allowAllProviders: z.boolean(),

View file

@ -1,10 +1,12 @@
'use server';
import { eq } from 'drizzle-orm';
import { clerkClient, currentUser } from '@clerk/nextjs/server';
import { db } from '@/db';
import { users, type CreateUser, orgs, type CreateOrg } from '@/db/schema';
import { logger } from './logger';
import { logger } from '../lib/server/logger';
export async function syncCurrentUser({
userId,

View file

@ -4,8 +4,8 @@ import { useState } from 'react';
import { useAuth } from '@clerk/nextjs';
import { useQuery } from '@tanstack/react-query';
import { type TimePeriod, timePeriods } from '@/types';
import type { AuditLog } from '@/db/schema';
import { timePeriods, type TimePeriod } from '@/schemas';
import { getAuditLogs } from '@/actions/auditLogs';
import {
Button,

View file

@ -14,7 +14,7 @@ import {
import { updateDefaultParameters } from '@/actions/defaultParameters';
import { getOrganizationSettings } from '@/actions/organizationSettings';
import { type OrganizationSettings } from '@/schemas';
import { type OrganizationSettings } from '@/types';
import {
Button,
Checkbox,

View file

@ -3,19 +3,20 @@
import { useTranslations } from 'next-intl';
import { useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { type ProviderName } from '@roo-code/types';
import {
type OrganizationAllowList,
type OrganizationSettings,
ORGANIZATION_ALLOW_ALL,
} from '@/types';
import {
getOrganizationSettings,
updateOrganization,
} from '@/actions/organizationSettings';
import { Badge, Button, Checkbox, Label } from '@/components/ui';
import { toast } from 'sonner';
import {
ORGANIZATION_ALLOW_ALL,
type OrganizationAllowList,
type OrganizationSettings,
} from '@/schemas';
import { type ProviderName } from '@roo-code/types';
type ProviderSetting = {
allowAll: boolean;

View file

@ -4,7 +4,7 @@ import { v4 as uuidv4 } from 'uuid';
import { rooCodeTelemetryEventSchema } from '@roo-code/types';
import { captureEvent } from '@/lib/server/analytics';
import { captureEvent } from '@/actions/analytics';
export async function POST(request: NextRequest) {
const { userId, orgId } = await auth();

View file

@ -1,7 +1,8 @@
import { auth } from '@clerk/nextjs/server';
import { NextResponse } from 'next/server';
import { auth } from '@clerk/nextjs/server';
import { type OrganizationSettings, ORGANIZATION_ALLOW_ALL } from '@/types';
import { getOrganizationSettings } from '@/actions/organizationSettings';
import { ORGANIZATION_ALLOW_ALL, type OrganizationSettings } from '@/schemas';
export async function GET() {
try {

View file

@ -1,8 +1,8 @@
import { redirect } from 'next/navigation';
import { auth } from '@clerk/nextjs/server';
import { getSignInToken } from '@/lib/server/clerk';
import { Env } from '@/lib/server/env';
import { Env } from '@/lib/server';
import { getSignInToken } from '@/actions/auth';
import { DeepLink } from './DeepLink';

View file

@ -5,7 +5,7 @@ import { getLocale, getTranslations, setRequestLocale } from 'next-intl/server';
import { auth } from '@clerk/nextjs/server';
import { getClerkLocale } from '@/i18n/locale';
import { syncAuth } from '@/lib/server/sync';
import { syncAuth } from '@/actions/sync';
import { Toaster } from '@/components/ui';
import {
ThemeProvider,

View file

@ -10,7 +10,7 @@ import { ArrowRightIcon } from 'lucide-react';
import { TelemetryEventName } from '@roo-code/types';
import { type TimePeriod, timePeriods } from '@/schemas';
import { type TimePeriod, timePeriods } from '@/types';
import { getUsage } from '@/actions/analytics';
import { formatCurrency } from '@/lib/formatters';

View file

@ -1,7 +1,7 @@
import { drizzle } from 'drizzle-orm/node-postgres';
import { Pool } from 'pg';
import { Env } from '@/lib/server/env';
import { Env } from '@/lib/server';
const pool = new Pool({
connectionString: process.env.DATABASE_URL,

View file

@ -10,10 +10,10 @@ import {
import { relations } from 'drizzle-orm';
import {
ORGANIZATION_ALLOW_ALL,
type OrganizationDefaultSettings,
type OrganizationAllowList,
} from '@/schemas';
ORGANIZATION_ALLOW_ALL,
} from '@/types';
/**
* users

View file

@ -1,35 +1,9 @@
import { createClient } from '@clickhouse/client';
import type { RooCodeTelemetryEvent } from '@roo-code/types';
import { Env } from './env';
export const client = createClient({
export const analytics = createClient({
url: Env.CLICKHOUSE_URL,
username: Env.CLICKHOUSE_USERNAME,
password: Env.CLICKHOUSE_PASSWORD,
});
type AnalyticsEvent = {
id: string;
orgId: string;
userId: string;
timestamp: number;
event: RooCodeTelemetryEvent;
};
export const captureEvent = async ({
event: { properties, ...cloudEvent },
...analyticsEvent
}: AnalyticsEvent) => {
// The destructuring here flattens the `AnalyticsEvent` to match the ClickHouse
// schema.
const value = { ...analyticsEvent, ...cloudEvent, ...properties };
console.log(`captureEvent`, value);
await client.insert({
table: 'events',
values: [value],
format: 'JSONEachRow',
});
};

14
src/lib/server/api.ts Normal file
View file

@ -0,0 +1,14 @@
import type { ApiResponse } from '@/types';
import { logger } from '@/lib/server';
export function isAuthSuccess(
result: { userId: string; orgId: string } | ApiResponse,
): result is { userId: string; orgId: string } {
return !('error' in result);
}
export function handleError(e: unknown, eventPrefix: string): ApiResponse {
const error = e instanceof Error ? e.message : 'Unknown error';
logger.error({ event: `${eventPrefix}_error`, error });
return { success: false, error };
}

View file

@ -1,27 +0,0 @@
import { db, type DB_OR_TX } from '@/db';
import { auditLogs, type CreateAuditLog } from '@/db/schema';
import { logger } from '@/lib/server/logger';
export async function insertAuditLog(
db: DB_OR_TX,
values: CreateAuditLog,
): Promise<void> {
await db.insert(auditLogs).values(values);
const { userId, orgId, targetType } = values;
logger.info({ userId, orgId, targetType });
}
export async function createAuditLog(values: CreateAuditLog): Promise<{
success: boolean;
error?: string | Record<string, unknown>;
}> {
try {
await insertAuditLog(db, values);
return { success: true };
} catch (e) {
const error =
e instanceof Error ? e.message : 'An unexpected error occurred';
logger.error({ event: 'audit_log_creation_error', error });
return { success: false, error };
}
}

View file

@ -1,38 +0,0 @@
import { Env } from './env';
import { logger } from './logger';
export async function getSignInToken(
userId: string,
): Promise<string | undefined> {
const response = await fetch('https://api.clerk.com/v1/sign_in_tokens', {
method: 'POST',
headers: {
Authorization: `Bearer ${Env.CLERK_SECRET_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
user_id: userId,
// Default expiration is 30 days (2592000 seconds)
}),
});
if (!response.ok) {
const errorData = await response.json();
logger.error({
event: 'sign_in_token_creation_failed',
error: errorData,
userId,
});
throw new Error("Failed to create sign-in token");
}
const data = await response.json();
logger.info({
event: 'sign_in_token_created',
userId,
});
return data.token;
}

4
src/lib/server/index.ts Normal file
View file

@ -0,0 +1,4 @@
export { Env } from './env';
export { logger } from './logger';
export { isAuthSuccess, handleError } from './api';
export { analytics } from './analytics';

View file

@ -1,22 +1,17 @@
import type { DestinationStream } from 'pino';
import logtail from '@logtail/pino';
import pino from 'pino';
import pino, { type DestinationStream } from 'pino';
import pretty from 'pino-pretty';
import logtail from '@logtail/pino';
import { Env } from './env';
let stream: DestinationStream;
if (Env.LOGTAIL_SOURCE_TOKEN) {
stream = pino.multistream([
await logtail({
sourceToken: Env.LOGTAIL_SOURCE_TOKEN,
options: { sendLogsToBetterStack: true },
}),
{ stream: pretty() }, // Prints logs to the console.
]);
} else {
stream = pretty({ colorize: true });
}
const stream: DestinationStream = Env.LOGTAIL_SOURCE_TOKEN
? pino.multistream([
await logtail({
sourceToken: Env.LOGTAIL_SOURCE_TOKEN,
options: { sendLogsToBetterStack: true },
}),
{ stream: pretty() }, // Prints logs to the console.
])
: pretty({ colorize: true });
export const logger = pino({ base: undefined }, stream);

5
src/types/api.ts Normal file
View file

@ -0,0 +1,5 @@
export type ApiResponse = {
success: boolean;
message?: string;
error?: string;
};

3
src/types/index.ts Normal file
View file

@ -0,0 +1,3 @@
export * from './api';
export * from './org';
export * from './time-period';

View file

@ -1,14 +1,6 @@
/**
* TimePeriod
*/
import { providerNames } from '@roo-code/types';
import { z } from 'zod';
export const timePeriods = [7, 30, 90] as const;
export type TimePeriod = (typeof timePeriods)[number];
export const organizationAllowListSchema = z.object({
allowAll: z.boolean(),
providers: z.record(

3
src/types/time-period.ts Normal file
View file

@ -0,0 +1,3 @@
export const timePeriods = [7, 30, 90] as const;
export type TimePeriod = (typeof timePeriods)[number];

View file

@ -1,6 +1,6 @@
import { sql } from 'drizzle-orm';
import { Env } from '@/lib/server/env';
import { Env } from '@/lib/server';
import { testDb, disconnect } from '@/db';
async function resetTestDatabase() {