diff --git a/packages/web/src/actions.ts b/packages/web/src/actions.ts index 75c38760..1d6c81e1 100644 --- a/packages/web/src/actions.ts +++ b/packages/web/src/actions.ts @@ -4,13 +4,12 @@ import { getAuditService } from "@/ee/features/audit/factory"; import { env } from "@/env.mjs"; import { addUserToOrganization, orgHasAvailability } from "@/lib/authUtils"; import { ErrorCode } from "@/lib/errorCodes"; -import { notAuthenticated, notFound, orgNotFound, secretAlreadyExists, ServiceError, unexpectedError } from "@/lib/serviceError"; +import { notAuthenticated, notFound, orgNotFound, secretAlreadyExists, ServiceError } from "@/lib/serviceError"; import { CodeHostType, getOrgMetadata, isHttpError, isServiceError } from "@/lib/utils"; import { prisma } from "@/prisma"; import { render } from "@react-email/components"; -import * as Sentry from '@sentry/nextjs'; -import { decrypt, encrypt, generateApiKey, getTokenFromConfig, hashSecret } from "@sourcebot/crypto"; -import { ApiKey, ConnectionSyncStatus, Org, OrgRole, Prisma, RepoIndexingStatus, StripeSubscriptionStatus } from "@sourcebot/db"; +import { decrypt, encrypt, generateApiKey, getTokenFromConfig } from "@sourcebot/crypto"; +import { ConnectionSyncStatus, OrgRole, Prisma, RepoIndexingStatus, StripeSubscriptionStatus } from "@sourcebot/db"; import { createLogger } from "@sourcebot/logger"; import { azuredevopsSchema } from "@sourcebot/schemas/v3/azuredevops.schema"; import { bitbucketSchema } from "@sourcebot/schemas/v3/bitbucket.schema"; @@ -29,7 +28,6 @@ import { StatusCodes } from "http-status-codes"; import { cookies, headers } from "next/headers"; import { createTransport } from "nodemailer"; import { Octokit } from "octokit"; -import { auth } from "./auth"; import { getConnection } from "./data/connection"; import { getOrgFromDomain } from "./data/org"; import { decrementOrgSeatCount, getSubscriptionForOrg } from "./ee/features/billing/serverUtils"; @@ -37,9 +35,9 @@ import { IS_BILLING_ENABLED } from "./ee/features/billing/stripe"; import InviteUserEmail from "./emails/inviteUserEmail"; import JoinRequestApprovedEmail from "./emails/joinRequestApprovedEmail"; import JoinRequestSubmittedEmail from "./emails/joinRequestSubmittedEmail"; -import { AGENTIC_SEARCH_TUTORIAL_DISMISSED_COOKIE_NAME, MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME, SEARCH_MODE_COOKIE_NAME, SINGLE_TENANT_ORG_DOMAIN, SOURCEBOT_GUEST_USER_ID, SOURCEBOT_SUPPORT_EMAIL } from "./lib/constants"; +import { AGENTIC_SEARCH_TUTORIAL_DISMISSED_COOKIE_NAME, MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME, SEARCH_MODE_COOKIE_NAME, SOURCEBOT_SUPPORT_EMAIL } from "./lib/constants"; import { orgNameSchema, repositoryQuerySchema } from "./lib/schemas"; -import { ApiKeyPayload, TenancyMode } from "./lib/types"; +import { sew } from "./sew"; import { withAuthV2, withOptionalAuthV2 } from "./withAuthV2"; import { withMinimumOrgRole } from "./withMinimumOrgRole"; @@ -50,142 +48,6 @@ const ajv = new Ajv({ const logger = createLogger('web-actions'); const auditService = getAuditService(); -/** - * "Service Error Wrapper". - * - * Captures any thrown exceptions and converts them to a unexpected - * service error. Also logs them with Sentry. - */ -export const sew = async (fn: () => Promise): Promise => { - try { - return await fn(); - } catch (e) { - Sentry.captureException(e); - logger.error(e); - - if (e instanceof Error) { - return unexpectedError(e.message); - } - - return unexpectedError(`An unexpected error occurred. Please try again later.`); - } -} - -export const withAuth = async (fn: (userId: string, apiKeyHash: string | undefined) => Promise, allowAnonymousAccess: boolean = false, apiKey: ApiKeyPayload | undefined = undefined) => { - const session = await auth(); - - if (!session) { - // First we check if public access is enabled and supported. If not, then we check if an api key was provided. If not, - // then this is an invalid unauthed request and we return a 401. - const anonymousAccessEnabled = await getAnonymousAccessStatus(SINGLE_TENANT_ORG_DOMAIN); - if (apiKey) { - const apiKeyOrError = await verifyApiKey(apiKey); - if (isServiceError(apiKeyOrError)) { - logger.error(`Invalid API key: ${JSON.stringify(apiKey)}. Error: ${JSON.stringify(apiKeyOrError)}`); - return notAuthenticated(); - } - - const user = await prisma.user.findUnique({ - where: { - id: apiKeyOrError.apiKey.createdById, - }, - }); - - if (!user) { - logger.error(`No user found for API key: ${apiKey}`); - return notAuthenticated(); - } - - await prisma.apiKey.update({ - where: { - hash: apiKeyOrError.apiKey.hash, - }, - data: { - lastUsedAt: new Date(), - }, - }); - - return fn(user.id, apiKeyOrError.apiKey.hash); - } else if ( - allowAnonymousAccess && - !isServiceError(anonymousAccessEnabled) && - anonymousAccessEnabled - ) { - if (!hasEntitlement("anonymous-access")) { - const plan = getPlan(); - logger.error(`Anonymous access isn't supported in your current plan: ${plan}. For support, contact ${SOURCEBOT_SUPPORT_EMAIL}.`); - return notAuthenticated(); - } - - // To support anonymous access a guest user is created in initialize.ts, which we return here - return fn(SOURCEBOT_GUEST_USER_ID, undefined); - } - return notAuthenticated(); - } - return fn(session.user.id, undefined); -} - -export const withOrgMembership = async (userId: string, domain: string, fn: (params: { userRole: OrgRole, org: Org }) => Promise, minRequiredRole: OrgRole = OrgRole.MEMBER) => { - const org = await prisma.org.findUnique({ - where: { - domain, - }, - }); - - if (!org) { - return notFound("Organization not found"); - } - - const membership = await prisma.userToOrg.findUnique({ - where: { - orgId_userId: { - userId, - orgId: org.id, - } - }, - }); - - if (!membership) { - return notFound("User not a member of this organization"); - } - - const getAuthorizationPrecedence = (role: OrgRole): number => { - switch (role) { - case OrgRole.GUEST: - return 0; - case OrgRole.MEMBER: - return 1; - case OrgRole.OWNER: - return 2; - } - } - - - if (getAuthorizationPrecedence(membership.role) < getAuthorizationPrecedence(minRequiredRole)) { - return { - statusCode: StatusCodes.FORBIDDEN, - errorCode: ErrorCode.INSUFFICIENT_PERMISSIONS, - message: "You do not have sufficient permissions to perform this action.", - } satisfies ServiceError; - } - - return fn({ - org: org, - userRole: membership.role, - }); -} - -export const withTenancyModeEnforcement = async(mode: TenancyMode, fn: () => Promise) => { - if (env.SOURCEBOT_TENANCY_MODE !== mode) { - return { - statusCode: StatusCodes.FORBIDDEN, - errorCode: ErrorCode.ACTION_DISALLOWED_IN_TENANCY_MODE, - message: "This action is not allowed in the current tenancy mode.", - } satisfies ServiceError; - } - return fn(); -} - ////// Actions /////// export const updateOrgName = async (name: string) => sew(() => @@ -322,59 +184,6 @@ export const deleteSecret = async (key: string, domain: string): Promise<{ succe } })); -export const verifyApiKey = async (apiKeyPayload: ApiKeyPayload): Promise<{ apiKey: ApiKey } | ServiceError> => sew(async () => { - const parts = apiKeyPayload.apiKey.split("-"); - if (parts.length !== 2 || parts[0] !== "sourcebot") { - return { - statusCode: StatusCodes.BAD_REQUEST, - errorCode: ErrorCode.INVALID_API_KEY, - message: "Invalid API key", - } satisfies ServiceError; - } - - const hash = hashSecret(parts[1]) - const apiKey = await prisma.apiKey.findUnique({ - where: { - hash, - }, - }); - - if (!apiKey) { - return { - statusCode: StatusCodes.UNAUTHORIZED, - errorCode: ErrorCode.INVALID_API_KEY, - message: "Invalid API key", - } satisfies ServiceError; - } - - const apiKeyTargetOrg = await prisma.org.findUnique({ - where: { - domain: apiKeyPayload.domain, - }, - }); - - if (!apiKeyTargetOrg) { - return { - statusCode: StatusCodes.UNAUTHORIZED, - errorCode: ErrorCode.INVALID_API_KEY, - message: `Invalid API key payload. Provided domain ${apiKeyPayload.domain} does not exist.`, - } satisfies ServiceError; - } - - if (apiKey.orgId !== apiKeyTargetOrg.id) { - return { - statusCode: StatusCodes.UNAUTHORIZED, - errorCode: ErrorCode.INVALID_API_KEY, - message: `Invalid API key payload. Provided domain ${apiKeyPayload.domain} does not match the API key's org.`, - } satisfies ServiceError; - } - - return { - apiKey, - } -}); - - export const createApiKey = async (name: string, domain: string): Promise<{ key: string } | ServiceError> => sew(() => withAuthV2(async ({ user, org, prisma }) => { const userId = user.id; @@ -1236,10 +1045,9 @@ export const getMe = async () => sew(() => })); export const redeemInvite = async (inviteId: string): Promise<{ success: boolean } | ServiceError> => sew(() => - withAuth(async () => { - const user = await getMe(); - if (isServiceError(user)) { - return user; + withOptionalAuthV2(async ({ user, prisma }) => { + if (!user) { + return notAuthenticated(); } const invite = await prisma.invite.findUnique({ @@ -1315,10 +1123,9 @@ export const redeemInvite = async (inviteId: string): Promise<{ success: boolean })); export const getInviteInfo = async (inviteId: string) => sew(() => - withAuth(async () => { - const user = await getMe(); - if (isServiceError(user)) { - return user; + withOptionalAuthV2(async ({ user }) => { + if (!user) { + return notAuthenticated(); } const invite = await prisma.invite.findUnique({ @@ -1613,107 +1420,92 @@ export const getOrgAccountRequests = async (domain: string) => sew(() => })); })); -export const createAccountRequest = async (userId: string, domain: string) => sew(async () => { - const user = await prisma.user.findUnique({ - where: { - id: userId, - }, - }); - - if (!user) { - return notFound("User not found"); - } - - const org = await prisma.org.findUnique({ - where: { - domain, - }, - }); - - if (!org) { - return notFound("Organization not found"); - } - - const existingRequest = await prisma.accountRequest.findUnique({ - where: { - requestedById_orgId: { - requestedById: userId, - orgId: org.id, - }, - }, - }); - - if (existingRequest) { - logger.warn(`User ${userId} already has an account request for org ${org.id}. Skipping account request creation.`); - return { - success: true, - existingRequest: true, +export const createAccountRequest = async () => sew(() => + withOptionalAuthV2(async ({ user, org, prisma }) => { + if (!user) { + return notAuthenticated(); } - } - if (!existingRequest) { - await prisma.accountRequest.create({ - data: { - requestedById: userId, - orgId: org.id, + const existingRequest = await prisma.accountRequest.findUnique({ + where: { + requestedById_orgId: { + requestedById: user.id, + orgId: org.id, + }, }, }); - if (env.SMTP_CONNECTION_URL && env.EMAIL_FROM_ADDRESS) { - // TODO: This is needed because we can't fetch the origin from the request headers when this is called - // on user creation (the header isn't set when next-auth calls onCreateUser for some reason) - const deploymentUrl = env.AUTH_URL; + if (existingRequest) { + logger.warn(`User ${user.id} already has an account request for org ${org.id}. Skipping account request creation.`); + return { + success: true, + existingRequest: true, + } + } - const owner = await prisma.user.findFirst({ - where: { - orgs: { - some: { - orgId: org.id, - role: "OWNER", - }, - }, + if (!existingRequest) { + await prisma.accountRequest.create({ + data: { + requestedById: user.id, + orgId: org.id, }, }); - if (!owner) { - logger.error(`Failed to find owner for org ${org.id} when drafting email for account request from ${userId}`); - } else { - const html = await render(JoinRequestSubmittedEmail({ - baseUrl: deploymentUrl, - requestor: { - name: user.name ?? undefined, - email: user.email!, - avatarUrl: user.image ?? undefined, - }, - orgName: org.name, - orgDomain: org.domain, - orgImageUrl: org.imageUrl ?? undefined, - })); + if (env.SMTP_CONNECTION_URL && env.EMAIL_FROM_ADDRESS) { + // TODO: This is needed because we can't fetch the origin from the request headers when this is called + // on user creation (the header isn't set when next-auth calls onCreateUser for some reason) + const deploymentUrl = env.AUTH_URL; - const transport = createTransport(env.SMTP_CONNECTION_URL); - const result = await transport.sendMail({ - to: owner.email!, - from: env.EMAIL_FROM_ADDRESS, - subject: `New account request for ${org.name} on Sourcebot`, - html, - text: `New account request for ${org.name} on Sourcebot by ${user.name ?? user.email}`, + const owner = await prisma.user.findFirst({ + where: { + orgs: { + some: { + orgId: org.id, + role: "OWNER", + }, + }, + }, }); - const failed = result.rejected.concat(result.pending).filter(Boolean); - if (failed.length > 0) { - logger.error(`Failed to send account request email to ${owner.email}: ${failed}`); - } - } - } else { - logger.warn(`SMTP_CONNECTION_URL or EMAIL_FROM_ADDRESS not set. Skipping account request email to owner`); - } - } + if (!owner) { + logger.error(`Failed to find owner for org ${org.id} when drafting email for account request from ${user.id}`); + } else { + const html = await render(JoinRequestSubmittedEmail({ + baseUrl: deploymentUrl, + requestor: { + name: user.name ?? undefined, + email: user.email!, + avatarUrl: user.image ?? undefined, + }, + orgName: org.name, + orgDomain: org.domain, + orgImageUrl: org.imageUrl ?? undefined, + })); - return { - success: true, - existingRequest: false, - } -}); + const transport = createTransport(env.SMTP_CONNECTION_URL); + const result = await transport.sendMail({ + to: owner.email!, + from: env.EMAIL_FROM_ADDRESS, + subject: `New account request for ${org.name} on Sourcebot`, + html, + text: `New account request for ${org.name} on Sourcebot by ${user.name ?? user.email}`, + }); + + const failed = result.rejected.concat(result.pending).filter(Boolean); + if (failed.length > 0) { + logger.error(`Failed to send account request email to ${owner.email}: ${failed}`); + } + } + } else { + logger.warn(`SMTP_CONNECTION_URL or EMAIL_FROM_ADDRESS not set. Skipping account request email to owner`); + } + } + + return { + success: true, + existingRequest: false, + } + })); export const getMemberApprovalRequired = async (domain: string): Promise => sew(async () => { const org = await prisma.org.findUnique({ diff --git a/packages/web/src/app/[domain]/components/submitAccountRequestButton.tsx b/packages/web/src/app/[domain]/components/submitAccountRequestButton.tsx index 291e5f50..b76b42e3 100644 --- a/packages/web/src/app/[domain]/components/submitAccountRequestButton.tsx +++ b/packages/web/src/app/[domain]/components/submitAccountRequestButton.tsx @@ -10,17 +10,16 @@ import { useRouter } from "next/navigation" interface SubmitButtonProps { domain: string - userId: string } -export function SubmitAccountRequestButton({ domain, userId }: SubmitButtonProps) { +export function SubmitAccountRequestButton({ domain }: SubmitButtonProps) { const { toast } = useToast() const router = useRouter() const [isSubmitting, setIsSubmitting] = useState(false) const handleSubmit = async () => { setIsSubmitting(true) - const result = await createAccountRequest(userId, domain) + const result = await createAccountRequest(); if (!isServiceError(result)) { if (result.existingRequest) { toast({ diff --git a/packages/web/src/app/[domain]/components/submitJoinRequest.tsx b/packages/web/src/app/[domain]/components/submitJoinRequest.tsx index 7160a65c..39d3b266 100644 --- a/packages/web/src/app/[domain]/components/submitJoinRequest.tsx +++ b/packages/web/src/app/[domain]/components/submitJoinRequest.tsx @@ -1,6 +1,5 @@ import { LogoutEscapeHatch } from "@/app/components/logoutEscapeHatch" import { SourcebotLogo } from "@/app/components/sourcebotLogo" -import { auth } from "@/auth" import { SubmitAccountRequestButton } from "./submitAccountRequestButton" interface SubmitJoinRequestProps { @@ -8,13 +7,6 @@ interface SubmitJoinRequestProps { } export const SubmitJoinRequest = async ({ domain }: SubmitJoinRequestProps) => { - const session = await auth() - const userId = session?.user?.id - - if (!userId) { - return null - } - return (
@@ -45,7 +37,7 @@ export const SubmitJoinRequest = async ({ domain }: SubmitJoinRequestProps) => {
- +
diff --git a/packages/web/src/app/api/(server)/chat/route.ts b/packages/web/src/app/api/(server)/chat/route.ts index 2c9bced2..e1b0b022 100644 --- a/packages/web/src/app/api/(server)/chat/route.ts +++ b/packages/web/src/app/api/(server)/chat/route.ts @@ -1,4 +1,5 @@ -import { sew, withAuth, withOrgMembership } from "@/actions"; +import { sew } from "@/sew"; +import { withOptionalAuthV2 } from "@/withAuthV2"; import { _getConfiguredLanguageModelsFull, _getAISDKLanguageModelAndOptions, updateChatMessages } from "@/features/chat/actions"; import { createAgentStream } from "@/features/chat/agent"; import { additionalChatRequestParamsSchema, SBChatMessage, SearchScope } from "@/features/chat/types"; @@ -9,7 +10,6 @@ import { isServiceError } from "@/lib/utils"; import { prisma } from "@/prisma"; import { LanguageModelV2 as AISDKLanguageModelV2 } from "@ai-sdk/provider"; import * as Sentry from "@sentry/nextjs"; -import { OrgRole } from "@sourcebot/db"; import { createLogger } from "@sourcebot/logger"; import { createUIMessageStream, @@ -52,56 +52,54 @@ export async function POST(req: Request) { const { messages, id, selectedSearchScopes, languageModelId } = parsed.data; const response = await sew(() => - withAuth((userId) => - withOrgMembership(userId, domain, async ({ org }) => { - // Validate that the chat exists and is not readonly. - const chat = await prisma.chat.findUnique({ - where: { - orgId: org.id, - id, - }, - }); - - if (!chat) { - return notFound(); - } - - if (chat.isReadonly) { - return serviceErrorResponse({ - statusCode: StatusCodes.BAD_REQUEST, - errorCode: ErrorCode.INVALID_REQUEST_BODY, - message: "Chat is readonly and cannot be edited.", - }); - } - - // From the language model ID, attempt to find the - // corresponding config in `config.json`. - const languageModelConfig = - (await _getConfiguredLanguageModelsFull()) - .find((model) => model.model === languageModelId); - - if (!languageModelConfig) { - return serviceErrorResponse({ - statusCode: StatusCodes.BAD_REQUEST, - errorCode: ErrorCode.INVALID_REQUEST_BODY, - message: `Language model ${languageModelId} is not configured.`, - }); - } - - const { model, providerOptions } = await _getAISDKLanguageModelAndOptions(languageModelConfig, org.id); - - return createMessageStreamResponse({ - messages, - id, - selectedSearchScopes, - model, - modelName: languageModelConfig.displayName ?? languageModelConfig.model, - modelProviderOptions: providerOptions, - domain, + withOptionalAuthV2(async ({ org, prisma }) => { + // Validate that the chat exists and is not readonly. + const chat = await prisma.chat.findUnique({ + where: { orgId: org.id, + id, + }, + }); + + if (!chat) { + return notFound(); + } + + if (chat.isReadonly) { + return serviceErrorResponse({ + statusCode: StatusCodes.BAD_REQUEST, + errorCode: ErrorCode.INVALID_REQUEST_BODY, + message: "Chat is readonly and cannot be edited.", }); - }, /* minRequiredRole = */ OrgRole.GUEST), /* allowSingleTenantUnauthedAccess = */ true - ) + } + + // From the language model ID, attempt to find the + // corresponding config in `config.json`. + const languageModelConfig = + (await _getConfiguredLanguageModelsFull()) + .find((model) => model.model === languageModelId); + + if (!languageModelConfig) { + return serviceErrorResponse({ + statusCode: StatusCodes.BAD_REQUEST, + errorCode: ErrorCode.INVALID_REQUEST_BODY, + message: `Language model ${languageModelId} is not configured.`, + }); + } + + const { model, providerOptions } = await _getAISDKLanguageModelAndOptions(languageModelConfig, org.id); + + return createMessageStreamResponse({ + messages, + id, + selectedSearchScopes, + model, + modelName: languageModelConfig.displayName ?? languageModelConfig.model, + modelProviderOptions: providerOptions, + domain, + orgId: org.id, + }); + }) ) if (isServiceError(response)) { diff --git a/packages/web/src/app/invite/actions.ts b/packages/web/src/app/invite/actions.ts index d7e8b1ab..aacc901c 100644 --- a/packages/web/src/app/invite/actions.ts +++ b/packages/web/src/app/invite/actions.ts @@ -1,16 +1,20 @@ "use server"; -import { withAuth } from "@/actions"; import { isServiceError } from "@/lib/utils"; -import { orgNotFound, ServiceError } from "@/lib/serviceError"; -import { sew } from "@/actions"; +import { notAuthenticated, orgNotFound, ServiceError } from "@/lib/serviceError"; +import { sew } from "@/sew"; import { addUserToOrganization } from "@/lib/authUtils"; import { prisma } from "@/prisma"; import { StatusCodes } from "http-status-codes"; import { ErrorCode } from "@/lib/errorCodes"; +import { withOptionalAuthV2 } from "@/withAuthV2"; export const joinOrganization = async (orgId: number, inviteLinkId?: string) => sew(async () => - withAuth(async (userId) => { + withOptionalAuthV2(async ({ user }) => { + if (!user) { + return notAuthenticated(); + } + const org = await prisma.org.findUnique({ where: { id: orgId, @@ -40,7 +44,7 @@ export const joinOrganization = async (orgId: number, inviteLinkId?: string) => } } - const addUserToOrgRes = await addUserToOrganization(userId, org.id); + const addUserToOrgRes = await addUserToOrganization(user.id, org.id); if (isServiceError(addUserToOrgRes)) { return addUserToOrgRes; } diff --git a/packages/web/src/ee/features/analytics/actions.ts b/packages/web/src/ee/features/analytics/actions.ts index 28f07ec6..66f4c86a 100644 --- a/packages/web/src/ee/features/analytics/actions.ts +++ b/packages/web/src/ee/features/analytics/actions.ts @@ -1,103 +1,101 @@ 'use server'; -import { sew, withAuth, withOrgMembership } from "@/actions"; -import { OrgRole } from "@sourcebot/db"; -import { prisma } from "@/prisma"; +import { sew } from "@/sew"; +import { withAuthV2 } from "@/withAuthV2"; import { ServiceError } from "@/lib/serviceError"; import { AnalyticsResponse } from "./types"; import { hasEntitlement } from "@sourcebot/shared"; import { ErrorCode } from "@/lib/errorCodes"; import { StatusCodes } from "http-status-codes"; -export const getAnalytics = async (domain: string, apiKey: string | undefined = undefined): Promise => sew(() => - withAuth((userId, _apiKeyHash) => - withOrgMembership(userId, domain, async ({ org }) => { - if (!hasEntitlement("analytics")) { - return { - statusCode: StatusCodes.FORBIDDEN, - errorCode: ErrorCode.INSUFFICIENT_PERMISSIONS, - message: "Analytics is not available in your current plan", - } satisfies ServiceError; - } +export const getAnalytics = async (domain: string, _apiKey: string | undefined = undefined): Promise => sew(() => + withAuthV2(async ({ org, prisma }) => { + if (!hasEntitlement("analytics")) { + return { + statusCode: StatusCodes.FORBIDDEN, + errorCode: ErrorCode.INSUFFICIENT_PERMISSIONS, + message: "Analytics is not available in your current plan", + } satisfies ServiceError; + } - const rows = await prisma.$queryRaw` - WITH core AS ( - SELECT - date_trunc('day', "timestamp") AS day, - date_trunc('week', "timestamp") AS week, - date_trunc('month', "timestamp") AS month, - action, - "actorId" - FROM "Audit" - WHERE "orgId" = ${org.id} - AND action IN ( - 'user.performed_code_search', - 'user.performed_find_references', - 'user.performed_goto_definition' - ) - ), - - periods AS ( - SELECT unnest(array['day', 'week', 'month']) AS period - ), - - buckets AS ( - SELECT - generate_series( - date_trunc('day', (SELECT MIN("timestamp") FROM "Audit" WHERE "orgId" = ${org.id})), - date_trunc('day', CURRENT_DATE), - interval '1 day' - ) AS bucket, - 'day' AS period - UNION ALL - SELECT - generate_series( - date_trunc('week', (SELECT MIN("timestamp") FROM "Audit" WHERE "orgId" = ${org.id})), - date_trunc('week', CURRENT_DATE), - interval '1 week' - ), - 'week' - UNION ALL - SELECT - generate_series( - date_trunc('month', (SELECT MIN("timestamp") FROM "Audit" WHERE "orgId" = ${org.id})), - date_trunc('month', CURRENT_DATE), - interval '1 month' - ), - 'month' - ), - - aggregated AS ( - SELECT - b.period, - CASE b.period - WHEN 'day' THEN c.day - WHEN 'week' THEN c.week - ELSE c.month - END AS bucket, - COUNT(*) FILTER (WHERE c.action = 'user.performed_code_search') AS code_searches, - COUNT(*) FILTER (WHERE c.action IN ('user.performed_find_references', 'user.performed_goto_definition')) AS navigations, - COUNT(DISTINCT c."actorId") AS active_users - FROM core c - JOIN LATERAL ( - SELECT unnest(array['day', 'week', 'month']) AS period - ) b ON true - GROUP BY b.period, bucket - ) - + const rows = await prisma.$queryRaw` + WITH core AS ( + SELECT + date_trunc('day', "timestamp") AS day, + date_trunc('week', "timestamp") AS week, + date_trunc('month', "timestamp") AS month, + action, + "actorId" + FROM "Audit" + WHERE "orgId" = ${org.id} + AND action IN ( + 'user.performed_code_search', + 'user.performed_find_references', + 'user.performed_goto_definition' + ) + ), + + periods AS ( + SELECT unnest(array['day', 'week', 'month']) AS period + ), + + buckets AS ( + SELECT + generate_series( + date_trunc('day', (SELECT MIN("timestamp") FROM "Audit" WHERE "orgId" = ${org.id})), + date_trunc('day', CURRENT_DATE), + interval '1 day' + ) AS bucket, + 'day' AS period + UNION ALL + SELECT + generate_series( + date_trunc('week', (SELECT MIN("timestamp") FROM "Audit" WHERE "orgId" = ${org.id})), + date_trunc('week', CURRENT_DATE), + interval '1 week' + ), + 'week' + UNION ALL + SELECT + generate_series( + date_trunc('month', (SELECT MIN("timestamp") FROM "Audit" WHERE "orgId" = ${org.id})), + date_trunc('month', CURRENT_DATE), + interval '1 month' + ), + 'month' + ), + + aggregated AS ( SELECT b.period, - b.bucket, - COALESCE(a.code_searches, 0)::int AS code_searches, - COALESCE(a.navigations, 0)::int AS navigations, - COALESCE(a.active_users, 0)::int AS active_users - FROM buckets b - LEFT JOIN aggregated a - ON a.period = b.period AND a.bucket = b.bucket - ORDER BY b.period, b.bucket; - `; - + CASE b.period + WHEN 'day' THEN c.day + WHEN 'week' THEN c.week + ELSE c.month + END AS bucket, + COUNT(*) FILTER (WHERE c.action = 'user.performed_code_search') AS code_searches, + COUNT(*) FILTER (WHERE c.action IN ('user.performed_find_references', 'user.performed_goto_definition')) AS navigations, + COUNT(DISTINCT c."actorId") AS active_users + FROM core c + JOIN LATERAL ( + SELECT unnest(array['day', 'week', 'month']) AS period + ) b ON true + GROUP BY b.period, bucket + ) + + SELECT + b.period, + b.bucket, + COALESCE(a.code_searches, 0)::int AS code_searches, + COALESCE(a.navigations, 0)::int AS navigations, + COALESCE(a.active_users, 0)::int AS active_users + FROM buckets b + LEFT JOIN aggregated a + ON a.period = b.period AND a.bucket = b.bucket + ORDER BY b.period, b.bucket; + `; + - return rows; - }, /* minRequiredRole = */ OrgRole.MEMBER), /* allowAnonymousAccess = */ true, apiKey ? { apiKey, domain } : undefined) + return rows; + }) ); \ No newline at end of file diff --git a/packages/web/src/ee/features/audit/actions.ts b/packages/web/src/ee/features/audit/actions.ts index 57455cb0..0ec1d0bb 100644 --- a/packages/web/src/ee/features/audit/actions.ts +++ b/packages/web/src/ee/features/audit/actions.ts @@ -1,9 +1,10 @@ "use server"; -import { prisma } from "@/prisma"; import { ErrorCode } from "@/lib/errorCodes"; import { StatusCodes } from "http-status-codes"; -import { sew, withAuth, withOrgMembership } from "@/actions"; +import { sew } from "@/sew"; +import { withAuthV2 } from "@/withAuthV2"; +import { withMinimumOrgRole } from "@/withMinimumOrgRole"; import { OrgRole } from "@sourcebot/db"; import { createLogger } from "@sourcebot/logger"; import { ServiceError } from "@/lib/serviceError"; @@ -13,16 +14,15 @@ import { AuditEvent } from "./types"; const auditService = getAuditService(); const logger = createLogger('audit-utils'); -export const createAuditAction = async (event: Omit, domain: string) => sew(async () => - withAuth((userId) => - withOrgMembership(userId, domain, async ({ org }) => { - await auditService.createAudit({ ...event, orgId: org.id, actor: { id: userId, type: "user" }, target: { id: org.id.toString(), type: "org" } }) - }, /* minRequiredRole = */ OrgRole.MEMBER), /* allowAnonymousAccess = */ true) +export const createAuditAction = async (event: Omit, _domain: string) => sew(async () => + withAuthV2(async ({ user, org }) => { + await auditService.createAudit({ ...event, orgId: org.id, actor: { id: user.id, type: "user" }, target: { id: org.id.toString(), type: "org" } }) + }) ); -export const fetchAuditRecords = async (domain: string, apiKey: string | undefined = undefined) => sew(() => - withAuth((userId) => - withOrgMembership(userId, domain, async ({ org }) => { +export const fetchAuditRecords = async (domain: string, _apiKey: string | undefined = undefined) => sew(() => + withAuthV2(async ({ user, org, prisma, role }) => + withMinimumOrgRole(role, OrgRole.OWNER, async () => { try { const auditRecords = await prisma.audit.findMany({ where: { @@ -36,7 +36,7 @@ export const fetchAuditRecords = async (domain: string, apiKey: string | undefin await auditService.createAudit({ action: "audit.fetch", actor: { - id: userId, + id: user.id, type: "user" }, target: { @@ -55,5 +55,6 @@ export const fetchAuditRecords = async (domain: string, apiKey: string | undefin message: "Failed to fetch audit logs", } satisfies ServiceError; } - }, /* minRequiredRole = */ OrgRole.OWNER), /* allowAnonymousAccess = */ true, apiKey ? { apiKey, domain } : undefined) + }) + ) ); diff --git a/packages/web/src/ee/features/billing/actions.ts b/packages/web/src/ee/features/billing/actions.ts index 48b11581..da6d8ed1 100644 --- a/packages/web/src/ee/features/billing/actions.ts +++ b/packages/web/src/ee/features/billing/actions.ts @@ -1,9 +1,9 @@ 'use server'; -import { getMe, sew, withAuth } from "@/actions"; +import { sew } from "@/sew"; import { ServiceError, stripeClientNotInitialized, notFound } from "@/lib/serviceError"; -import { withOrgMembership } from "@/actions"; -import { prisma } from "@/prisma"; +import { withAuthV2 } from "@/withAuthV2"; +import { withMinimumOrgRole } from "@/withMinimumOrgRole"; import { OrgRole } from "@sourcebot/db"; import { stripeClient } from "./stripe"; import { isServiceError } from "@/lib/utils"; @@ -17,13 +17,8 @@ import { createLogger } from "@sourcebot/logger"; const logger = createLogger('billing-actions'); export const createOnboardingSubscription = async (domain: string) => sew(() => - withAuth(async (userId) => - withOrgMembership(userId, domain, async ({ org }) => { - const user = await getMe(); - if (isServiceError(user)) { - return user; - } - + withAuthV2(async ({ user, org, prisma, role }) => + withMinimumOrgRole(role, OrgRole.OWNER, async () => { if (!stripeClient) { return stripeClientNotInitialized(); } @@ -108,68 +103,65 @@ export const createOnboardingSubscription = async (domain: string) => sew(() => message: "Failed to create subscription", } satisfies ServiceError; } - }, /* minRequiredRole = */ OrgRole.OWNER) - )); + }))); export const createStripeCheckoutSession = async (domain: string) => sew(() => - withAuth((userId) => - withOrgMembership(userId, domain, async ({ org }) => { - if (!org.stripeCustomerId) { - return notFound(); - } + withAuthV2(async ({ org, prisma }) => { + if (!org.stripeCustomerId) { + return notFound(); + } - if (!stripeClient) { - return stripeClientNotInitialized(); - } + if (!stripeClient) { + return stripeClientNotInitialized(); + } - const orgMembers = await prisma.userToOrg.findMany({ - where: { - orgId: org.id, - }, - select: { - userId: true, + const orgMembers = await prisma.userToOrg.findMany({ + where: { + orgId: org.id, + }, + select: { + userId: true, + } + }); + const numOrgMembers = orgMembers.length; + + const origin = (await headers()).get('origin')!; + const prices = await stripeClient.prices.list({ + product: env.STRIPE_PRODUCT_ID, + expand: ['data.product'], + }); + + const stripeSession = await stripeClient.checkout.sessions.create({ + customer: org.stripeCustomerId as string, + payment_method_types: ['card'], + line_items: [ + { + price: prices.data[0].id, + quantity: numOrgMembers } - }); - const numOrgMembers = orgMembers.length; - - const origin = (await headers()).get('origin')!; - const prices = await stripeClient.prices.list({ - product: env.STRIPE_PRODUCT_ID, - expand: ['data.product'], - }); - - const stripeSession = await stripeClient.checkout.sessions.create({ - customer: org.stripeCustomerId as string, - payment_method_types: ['card'], - line_items: [ - { - price: prices.data[0].id, - quantity: numOrgMembers - } - ], - mode: 'subscription', - payment_method_collection: 'always', - success_url: `${origin}/${domain}/settings/billing`, - cancel_url: `${origin}/${domain}`, - }); - - if (!stripeSession.url) { - return { - statusCode: StatusCodes.INTERNAL_SERVER_ERROR, - errorCode: ErrorCode.STRIPE_CHECKOUT_ERROR, - message: "Failed to create checkout session", - } satisfies ServiceError; - } + ], + mode: 'subscription', + payment_method_collection: 'always', + success_url: `${origin}/${domain}/settings/billing`, + cancel_url: `${origin}/${domain}`, + }); + if (!stripeSession.url) { return { - url: stripeSession.url, - } - }) - )); + statusCode: StatusCodes.INTERNAL_SERVER_ERROR, + errorCode: ErrorCode.STRIPE_CHECKOUT_ERROR, + message: "Failed to create checkout session", + } satisfies ServiceError; + } + + return { + url: stripeSession.url, + } + })); export const getCustomerPortalSessionLink = async (domain: string): Promise => sew(() => - withAuth((userId) => - withOrgMembership(userId, domain, async ({ org }) => { + withAuthV2(async ({ org, role }) => + withMinimumOrgRole(role, OrgRole.OWNER, async () => { if (!org.stripeCustomerId) { return notFound(); } @@ -185,31 +177,28 @@ export const getCustomerPortalSessionLink = async (domain: string): Promise => sew(() => - withAuth(async (userId) => - withOrgMembership(userId, domain, async ({ org }) => { - if (!org.stripeCustomerId) { - return notFound(); - } +export const getSubscriptionBillingEmail = async (_domain: string): Promise => sew(() => + withAuthV2(async ({ org }) => { + if (!org.stripeCustomerId) { + return notFound(); + } - if (!stripeClient) { - return stripeClientNotInitialized(); - } + if (!stripeClient) { + return stripeClientNotInitialized(); + } - const customer = await stripeClient.customers.retrieve(org.stripeCustomerId); - if (!('email' in customer) || customer.deleted) { - return notFound(); - } - return customer.email!; - }) - )); + const customer = await stripeClient.customers.retrieve(org.stripeCustomerId); + if (!('email' in customer) || customer.deleted) { + return notFound(); + } + return customer.email!; + })); export const changeSubscriptionBillingEmail = async (domain: string, newEmail: string): Promise<{ success: boolean } | ServiceError> => sew(() => - withAuth((userId) => - withOrgMembership(userId, domain, async ({ org }) => { + withAuthV2(async ({ org, role }) => + withMinimumOrgRole(role, OrgRole.OWNER, async () => { if (!org.stripeCustomerId) { return notFound(); } @@ -225,24 +214,21 @@ export const changeSubscriptionBillingEmail = async (domain: string, newEmail: s return { success: true, } - }, /* minRequiredRole = */ OrgRole.OWNER) - )); + }))); -export const getSubscriptionInfo = async (domain: string) => sew(() => - withAuth(async (userId) => - withOrgMembership(userId, domain, async ({ org }) => { - const subscription = await getSubscriptionForOrg(org.id, prisma); +export const getSubscriptionInfo = async (_domain: string) => sew(() => + withAuthV2(async ({ org, prisma }) => { + const subscription = await getSubscriptionForOrg(org.id, prisma); - if (isServiceError(subscription)) { - return subscription; - } + if (isServiceError(subscription)) { + return subscription; + } - return { - status: subscription.status, - plan: "Team", - seats: subscription.items.data[0].quantity!, - perSeatPrice: subscription.items.data[0].price.unit_amount! / 100, - nextBillingDate: subscription.current_period_end!, - } - }) - )); + return { + status: subscription.status, + plan: "Team", + seats: subscription.items.data[0].quantity!, + perSeatPrice: subscription.items.data[0].price.unit_amount! / 100, + nextBillingDate: subscription.current_period_end!, + } + })); diff --git a/packages/web/src/features/chat/actions.ts b/packages/web/src/features/chat/actions.ts index 4f93ac1d..e26b1ea6 100644 --- a/packages/web/src/features/chat/actions.ts +++ b/packages/web/src/features/chat/actions.ts @@ -1,6 +1,7 @@ 'use server'; -import { sew, withAuth, withOrgMembership } from "@/actions"; +import { sew } from "@/sew"; +import { withAuthV2, withOptionalAuthV2 } from "@/withAuthV2"; import { env } from "@/env.mjs"; import { SOURCEBOT_GUEST_USER_ID } from "@/lib/constants"; import { ErrorCode } from "@/lib/errorCodes"; @@ -32,181 +33,177 @@ import path from 'path'; import { LanguageModelInfo, SBChatMessage } from "./types"; export const createChat = async (domain: string) => sew(() => - withAuth((userId) => - withOrgMembership(userId, domain, async ({ org }) => { + withOptionalAuthV2(async ({ user, org, prisma }) => { + const userId = user?.id ?? SOURCEBOT_GUEST_USER_ID; + const isGuestUser = userId === SOURCEBOT_GUEST_USER_ID; - const isGuestUser = userId === SOURCEBOT_GUEST_USER_ID; + const chat = await prisma.chat.create({ + data: { + orgId: org.id, + messages: [] as unknown as Prisma.InputJsonValue, + createdById: userId, + visibility: isGuestUser ? ChatVisibility.PUBLIC : ChatVisibility.PRIVATE, + }, + }); - const chat = await prisma.chat.create({ - data: { - orgId: org.id, - messages: [] as unknown as Prisma.InputJsonValue, - createdById: userId, - visibility: isGuestUser ? ChatVisibility.PUBLIC : ChatVisibility.PRIVATE, - }, - }); - - return { - id: chat.id, - } - }, /* minRequiredRole = */ OrgRole.GUEST), /* allowSingleTenantUnauthedAccess = */ true) + return { + id: chat.id, + } + }) ); export const getChatInfo = async ({ chatId }: { chatId: string }, domain: string) => sew(() => - withAuth((userId) => - withOrgMembership(userId, domain, async ({ org }) => { - const chat = await prisma.chat.findUnique({ - where: { - id: chatId, - orgId: org.id, - }, - }); + withOptionalAuthV2(async ({ user, org, prisma }) => { + const userId = user?.id ?? SOURCEBOT_GUEST_USER_ID; + const chat = await prisma.chat.findUnique({ + where: { + id: chatId, + orgId: org.id, + }, + }); - if (!chat) { - return notFound(); - } + if (!chat) { + return notFound(); + } - if (chat.visibility === ChatVisibility.PRIVATE && chat.createdById !== userId) { - return notFound(); - } + if (chat.visibility === ChatVisibility.PRIVATE && chat.createdById !== userId) { + return notFound(); + } - return { - messages: chat.messages as unknown as SBChatMessage[], - visibility: chat.visibility, - name: chat.name, - isReadonly: chat.isReadonly, - }; - }, /* minRequiredRole = */ OrgRole.GUEST), /* allowSingleTenantUnauthedAccess = */ true) + return { + messages: chat.messages as unknown as SBChatMessage[], + visibility: chat.visibility, + name: chat.name, + isReadonly: chat.isReadonly, + }; + }) ); export const updateChatMessages = async ({ chatId, messages }: { chatId: string, messages: SBChatMessage[] }, domain: string) => sew(() => - withAuth((userId) => - withOrgMembership(userId, domain, async ({ org }) => { - const chat = await prisma.chat.findUnique({ - where: { - id: chatId, - orgId: org.id, - }, - }); + withOptionalAuthV2(async ({ user, org, prisma }) => { + const userId = user?.id ?? SOURCEBOT_GUEST_USER_ID; + const chat = await prisma.chat.findUnique({ + where: { + id: chatId, + orgId: org.id, + }, + }); - if (!chat) { - return notFound(); + if (!chat) { + return notFound(); + } + + if (chat.visibility === ChatVisibility.PRIVATE && chat.createdById !== userId) { + return notFound(); + } + + if (chat.isReadonly) { + return chatIsReadonly(); + } + + await prisma.chat.update({ + where: { + id: chatId, + }, + data: { + messages: messages as unknown as Prisma.InputJsonValue, + }, + }); + + if (env.DEBUG_WRITE_CHAT_MESSAGES_TO_FILE) { + const chatDir = path.join(env.DATA_CACHE_DIR, 'chats'); + if (!fs.existsSync(chatDir)) { + fs.mkdirSync(chatDir, { recursive: true }); } - if (chat.visibility === ChatVisibility.PRIVATE && chat.createdById !== userId) { - return notFound(); - } + const chatFile = path.join(chatDir, `${chatId}.json`); + fs.writeFileSync(chatFile, JSON.stringify(messages, null, 2)); + } - if (chat.isReadonly) { - return chatIsReadonly(); - } - - await prisma.chat.update({ - where: { - id: chatId, - }, - data: { - messages: messages as unknown as Prisma.InputJsonValue, - }, - }); - - if (env.DEBUG_WRITE_CHAT_MESSAGES_TO_FILE) { - const chatDir = path.join(env.DATA_CACHE_DIR, 'chats'); - if (!fs.existsSync(chatDir)) { - fs.mkdirSync(chatDir, { recursive: true }); - } - - const chatFile = path.join(chatDir, `${chatId}.json`); - fs.writeFileSync(chatFile, JSON.stringify(messages, null, 2)); - } - - return { - success: true, - } - }, /* minRequiredRole = */ OrgRole.GUEST), /* allowSingleTenantUnauthedAccess = */ true) + return { + success: true, + } + }) ); export const getUserChatHistory = async (domain: string) => sew(() => - withAuth((userId) => - withOrgMembership(userId, domain, async ({ org }) => { - const chats = await prisma.chat.findMany({ - where: { - orgId: org.id, - createdById: userId, - }, - orderBy: { - updatedAt: 'desc', - }, - }); + withAuthV2(async ({ user, org, prisma }) => { + const chats = await prisma.chat.findMany({ + where: { + orgId: org.id, + createdById: user.id, + }, + orderBy: { + updatedAt: 'desc', + }, + }); - return chats.map((chat) => ({ - id: chat.id, - createdAt: chat.createdAt, - name: chat.name, - visibility: chat.visibility, - })) - }) - ) + return chats.map((chat) => ({ + id: chat.id, + createdAt: chat.createdAt, + name: chat.name, + visibility: chat.visibility, + })) + }) ); export const updateChatName = async ({ chatId, name }: { chatId: string, name: string }, domain: string) => sew(() => - withAuth((userId) => - withOrgMembership(userId, domain, async ({ org }) => { - const chat = await prisma.chat.findUnique({ - where: { - id: chatId, - orgId: org.id, - }, - }); + withOptionalAuthV2(async ({ user, org, prisma }) => { + const userId = user?.id ?? SOURCEBOT_GUEST_USER_ID; + const chat = await prisma.chat.findUnique({ + where: { + id: chatId, + orgId: org.id, + }, + }); - if (!chat) { - return notFound(); - } + if (!chat) { + return notFound(); + } - if (chat.visibility === ChatVisibility.PRIVATE && chat.createdById !== userId) { - return notFound(); - } + if (chat.visibility === ChatVisibility.PRIVATE && chat.createdById !== userId) { + return notFound(); + } - if (chat.isReadonly) { - return chatIsReadonly(); - } + if (chat.isReadonly) { + return chatIsReadonly(); + } - await prisma.chat.update({ - where: { - id: chatId, - orgId: org.id, - }, - data: { - name, - }, - }); + await prisma.chat.update({ + where: { + id: chatId, + orgId: org.id, + }, + data: { + name, + }, + }); - return { - success: true, - } - }, /* minRequiredRole = */ OrgRole.GUEST), /* allowSingleTenantUnauthedAccess = */ true) + return { + success: true, + } + }) ); export const generateAndUpdateChatNameFromMessage = async ({ chatId, languageModelId, message }: { chatId: string, languageModelId: string, message: string }, domain: string) => sew(() => - withAuth((userId) => - withOrgMembership(userId, domain, async ({ org }) => { - // From the language model ID, attempt to find the - // corresponding config in `config.json`. - const languageModelConfig = - (await _getConfiguredLanguageModelsFull()) - .find((model) => model.model === languageModelId); + withOptionalAuthV2(async ({ org }) => { + // From the language model ID, attempt to find the + // corresponding config in `config.json`. + const languageModelConfig = + (await _getConfiguredLanguageModelsFull()) + .find((model) => model.model === languageModelId); - if (!languageModelConfig) { - return serviceErrorResponse({ - statusCode: StatusCodes.BAD_REQUEST, - errorCode: ErrorCode.INVALID_REQUEST_BODY, - message: `Language model ${languageModelId} is not configured.`, - }); - } + if (!languageModelConfig) { + return serviceErrorResponse({ + statusCode: StatusCodes.BAD_REQUEST, + errorCode: ErrorCode.INVALID_REQUEST_BODY, + message: `Language model ${languageModelId} is not configured.`, + }); + } - const { model } = await _getAISDKLanguageModelAndOptions(languageModelConfig, org.id); + const { model } = await _getAISDKLanguageModelAndOptions(languageModelConfig, org.id); - const prompt = `Convert this question into a short topic title (max 50 characters). + const prompt = `Convert this question into a short topic title (max 50 characters). Rules: - Do NOT include question words (what, where, how, why, when, which) @@ -222,63 +219,61 @@ Examples: User question: ${message}`; - const result = await generateText({ - model, - prompt, - }); + const result = await generateText({ + model, + prompt, + }); - await updateChatName({ - chatId, - name: result.text, - }, domain); + await updateChatName({ + chatId, + name: result.text, + }, domain); - return { - success: true, - } - }, /* minRequiredRole = */ OrgRole.GUEST), /* allowSingleTenantUnauthedAccess = */ true - ) + return { + success: true, + } + }) ); export const deleteChat = async ({ chatId }: { chatId: string }, domain: string) => sew(() => - withAuth((userId) => - withOrgMembership(userId, domain, async ({ org }) => { - const chat = await prisma.chat.findUnique({ - where: { - id: chatId, - orgId: org.id, - }, - }); + withAuthV2(async ({ user, org, prisma }) => { + const userId = user.id; + const chat = await prisma.chat.findUnique({ + where: { + id: chatId, + orgId: org.id, + }, + }); - if (!chat) { - return notFound(); - } - - // Public chats cannot be deleted. - if (chat.visibility === ChatVisibility.PUBLIC) { - return { - statusCode: StatusCodes.FORBIDDEN, - errorCode: ErrorCode.UNEXPECTED_ERROR, - message: 'You are not allowed to delete this chat.', - } satisfies ServiceError; - } - - // Only the creator of a chat can delete it. - if (chat.createdById !== userId) { - return notFound(); - } - - await prisma.chat.delete({ - where: { - id: chatId, - orgId: org.id, - }, - }); + if (!chat) { + return notFound(); + } + // Public chats cannot be deleted. + if (chat.visibility === ChatVisibility.PUBLIC) { return { - success: true, - } - }) - ) + statusCode: StatusCodes.FORBIDDEN, + errorCode: ErrorCode.UNEXPECTED_ERROR, + message: 'You are not allowed to delete this chat.', + } satisfies ServiceError; + } + + // Only the creator of a chat can delete it. + if (chat.createdById !== userId) { + return notFound(); + } + + await prisma.chat.delete({ + where: { + id: chatId, + orgId: org.id, + }, + }); + + return { + success: true, + } + }) ); export const submitFeedback = async ({ @@ -290,54 +285,54 @@ export const submitFeedback = async ({ messageId: string, feedbackType: 'like' | 'dislike' }, domain: string) => sew(() => - withAuth((userId) => - withOrgMembership(userId, domain, async ({ org }) => { - const chat = await prisma.chat.findUnique({ - where: { - id: chatId, - orgId: org.id, - }, - }); + withOptionalAuthV2(async ({ user, org, prisma }) => { + const userId = user?.id ?? SOURCEBOT_GUEST_USER_ID; + const chat = await prisma.chat.findUnique({ + where: { + id: chatId, + orgId: org.id, + }, + }); - if (!chat) { - return notFound(); + if (!chat) { + return notFound(); + } + + // When a chat is private, only the creator can submit feedback. + if (chat.visibility === ChatVisibility.PRIVATE && chat.createdById !== userId) { + return notFound(); + } + + const messages = chat.messages as unknown as SBChatMessage[]; + const updatedMessages = messages.map(message => { + if (message.id === messageId && message.role === 'assistant') { + return { + ...message, + metadata: { + ...message.metadata, + feedback: [ + ...(message.metadata?.feedback ?? []), + { + type: feedbackType, + timestamp: new Date().toISOString(), + userId: userId, + } + ] + } + } satisfies SBChatMessage; } + return message; + }); - // When a chat is private, only the creator can submit feedback. - if (chat.visibility === ChatVisibility.PRIVATE && chat.createdById !== userId) { - return notFound(); - } + await prisma.chat.update({ + where: { id: chatId }, + data: { + messages: updatedMessages as unknown as Prisma.InputJsonValue, + }, + }); - const messages = chat.messages as unknown as SBChatMessage[]; - const updatedMessages = messages.map(message => { - if (message.id === messageId && message.role === 'assistant') { - return { - ...message, - metadata: { - ...message.metadata, - feedback: [ - ...(message.metadata?.feedback ?? []), - { - type: feedbackType, - timestamp: new Date().toISOString(), - userId: userId, - } - ] - } - } satisfies SBChatMessage; - } - return message; - }); - - await prisma.chat.update({ - where: { id: chatId }, - data: { - messages: updatedMessages as unknown as Prisma.InputJsonValue, - }, - }); - - return { success: true }; - }, /* minRequiredRole = */ OrgRole.GUEST), /* allowSingleTenantUnauthedAccess = */ true) + return { success: true }; + }) ); /** diff --git a/packages/web/src/features/codeNav/actions.ts b/packages/web/src/features/codeNav/actions.ts index b55cfa30..c3e1186e 100644 --- a/packages/web/src/features/codeNav/actions.ts +++ b/packages/web/src/features/codeNav/actions.ts @@ -1,13 +1,13 @@ 'use server'; -import { sew, withAuth, withOrgMembership } from "@/actions"; +import { sew } from "@/sew"; +import { withOptionalAuthV2 } from "@/withAuthV2"; import { searchResponseSchema } from "@/features/search/schemas"; import { search } from "@/features/search/searchApi"; import { isServiceError } from "@/lib/utils"; import { FindRelatedSymbolsResponse } from "./types"; import { ServiceError } from "@/lib/serviceError"; import { SearchResponse } from "../search/types"; -import { OrgRole } from "@sourcebot/db"; // The maximum number of matches to return from the search API. const MAX_REFERENCE_COUNT = 1000; @@ -20,28 +20,27 @@ export const findSearchBasedSymbolReferences = async ( }, domain: string, ): Promise => sew(() => - withAuth((session) => - withOrgMembership(session, domain, async () => { - const { - symbolName, - language, - revisionName = "HEAD", - } = props; + withOptionalAuthV2(async () => { + const { + symbolName, + language, + revisionName = "HEAD", + } = props; - const query = `\\b${symbolName}\\b rev:${revisionName} ${getExpandedLanguageFilter(language)} case:yes`; + const query = `\\b${symbolName}\\b rev:${revisionName} ${getExpandedLanguageFilter(language)} case:yes`; - const searchResult = await search({ - query, - matches: MAX_REFERENCE_COUNT, - contextLines: 0, - }); + const searchResult = await search({ + query, + matches: MAX_REFERENCE_COUNT, + contextLines: 0, + }); - if (isServiceError(searchResult)) { - return searchResult; - } + if (isServiceError(searchResult)) { + return searchResult; + } - return parseRelatedSymbolsSearchResponse(searchResult); - }, /* minRequiredRole = */ OrgRole.GUEST), /* allowAnonymousAccess = */ true) + return parseRelatedSymbolsSearchResponse(searchResult); + }) ); @@ -53,28 +52,27 @@ export const findSearchBasedSymbolDefinitions = async ( }, domain: string, ): Promise => sew(() => - withAuth((session) => - withOrgMembership(session, domain, async () => { - const { - symbolName, - language, - revisionName = "HEAD", - } = props; + withOptionalAuthV2(async () => { + const { + symbolName, + language, + revisionName = "HEAD", + } = props; - const query = `sym:\\b${symbolName}\\b rev:${revisionName} ${getExpandedLanguageFilter(language)}`; + const query = `sym:\\b${symbolName}\\b rev:${revisionName} ${getExpandedLanguageFilter(language)}`; - const searchResult = await search({ - query, - matches: MAX_REFERENCE_COUNT, - contextLines: 0, - }); + const searchResult = await search({ + query, + matches: MAX_REFERENCE_COUNT, + contextLines: 0, + }); - if (isServiceError(searchResult)) { - return searchResult; - } + if (isServiceError(searchResult)) { + return searchResult; + } - return parseRelatedSymbolsSearchResponse(searchResult); - }, /* minRequiredRole = */ OrgRole.GUEST), /* allowAnonymousAccess = */ true) + return parseRelatedSymbolsSearchResponse(searchResult); + }) ); const parseRelatedSymbolsSearchResponse = (searchResult: SearchResponse) => { diff --git a/packages/web/src/features/fileTree/actions.ts b/packages/web/src/features/fileTree/actions.ts index 003b82b2..2887c62f 100644 --- a/packages/web/src/features/fileTree/actions.ts +++ b/packages/web/src/features/fileTree/actions.ts @@ -1,6 +1,6 @@ 'use server'; -import { sew } from '@/actions'; +import { sew } from "@/sew"; import { env } from '@/env.mjs'; import { notFound, unexpectedError } from '@/lib/serviceError'; import { withOptionalAuthV2 } from '@/withAuthV2'; diff --git a/packages/web/src/features/search/fileSourceApi.ts b/packages/web/src/features/search/fileSourceApi.ts index 249abb42..23768e86 100644 --- a/packages/web/src/features/search/fileSourceApi.ts +++ b/packages/web/src/features/search/fileSourceApi.ts @@ -5,7 +5,7 @@ import { fileNotFound, ServiceError, unexpectedError } from "../../lib/serviceEr import { FileSourceRequest, FileSourceResponse } from "./types"; import { isServiceError } from "../../lib/utils"; import { search } from "./searchApi"; -import { sew } from "@/actions"; +import { sew } from "@/sew"; import { withOptionalAuthV2 } from "@/withAuthV2"; // @todo (bkellam) : We should really be using `git show :` to fetch file contents here. // This will allow us to support permalinks to files at a specific revision that may not be indexed diff --git a/packages/web/src/features/search/searchApi.ts b/packages/web/src/features/search/searchApi.ts index 60d04dec..85e87517 100644 --- a/packages/web/src/features/search/searchApi.ts +++ b/packages/web/src/features/search/searchApi.ts @@ -9,7 +9,7 @@ import { StatusCodes } from "http-status-codes"; import { zoektSearchResponseSchema } from "./zoektSchema"; import { SearchRequest, SearchResponse, SourceRange } from "./types"; import { PrismaClient, Repo } from "@sourcebot/db"; -import { sew } from "@/actions"; +import { sew } from "@/sew"; import { base64Decode } from "@sourcebot/shared"; import { withOptionalAuthV2 } from "@/withAuthV2"; diff --git a/packages/web/src/sew.ts b/packages/web/src/sew.ts new file mode 100644 index 00000000..1d0a9e96 --- /dev/null +++ b/packages/web/src/sew.ts @@ -0,0 +1,28 @@ +'use server'; +import * as Sentry from "@sentry/nextjs"; +import { ServiceError, unexpectedError } from "./lib/serviceError"; +import { createLogger } from "@sourcebot/logger"; + +const logger = createLogger('service-error-wrapper'); + +/** + * "Service Error Wrapper". + * + * Captures any thrown exceptions and converts them to a unexpected + * service error. Also logs them with Sentry. + */ + +export const sew = async (fn: () => Promise): Promise => { + try { + return await fn(); + } catch (e) { + Sentry.captureException(e); + logger.error(e); + + if (e instanceof Error) { + return unexpectedError(e.message); + } + + return unexpectedError(`An unexpected error occurred. Please try again later.`); + } +}; diff --git a/packages/web/src/withAuthV2.ts b/packages/web/src/withAuthV2.ts index 8ebee278..d8ae6218 100644 --- a/packages/web/src/withAuthV2.ts +++ b/packages/web/src/withAuthV2.ts @@ -5,8 +5,6 @@ import { headers } from "next/headers"; import { auth } from "./auth"; import { notAuthenticated, notFound, ServiceError } from "./lib/serviceError"; import { SINGLE_TENANT_ORG_ID } from "./lib/constants"; -import { StatusCodes } from "http-status-codes"; -import { ErrorCode } from "./lib/errorCodes"; import { getOrgMetadata, isServiceError } from "./lib/utils"; import { hasEntitlement } from "@sourcebot/shared";