diff --git a/apps/backend/package.json b/apps/backend/package.json index cc5a78be8..0d44a00ac 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -31,8 +31,8 @@ "@react-email/render": "^0.0.12", "@react-email/tailwind": "^0.0.14", "@sentry/nextjs": "^7.105.0", - "@stackframe/stack-shared": "workspace:*", "@stackframe/stack-emails": "workspace:*", + "@stackframe/stack-shared": "workspace:*", "@vercel/analytics": "^1.2.2", "bcrypt": "^5.1.1", "dotenv-cli": "^7.3.0", @@ -48,6 +48,7 @@ "react-email": "2.1.0", "server-only": "^0.0.1", "sharp": "^0.32.6", + "svix": "^1.25.0", "yaml": "^2.4.5", "yup": "^1.4.0" }, @@ -55,11 +56,11 @@ "@types/bcrypt": "^5.0.2", "@types/node": "^20.8.10", "@types/nodemailer": "^6.4.14", - "chokidar-cli": "^3.0.0", "@types/react": "^18.2.66", + "chokidar-cli": "^3.0.0", + "glob": "^10.4.1", "prisma": "^5.9.1", "rimraf": "^5.0.5", - "tsx": "^4.7.2", - "glob": "^10.4.1" + "tsx": "^4.7.2" } } diff --git a/apps/backend/src/app/api/v1/teams/crud.tsx b/apps/backend/src/app/api/v1/teams/crud.tsx index 6570f9bb3..de3a62825 100644 --- a/apps/backend/src/app/api/v1/teams/crud.tsx +++ b/apps/backend/src/app/api/v1/teams/crud.tsx @@ -1,5 +1,6 @@ import { ensureTeamMembershipExist } from "@/lib/db-checks"; import { isTeamSystemPermission, teamSystemPermissionStringToDBType } from "@/lib/permissions"; +import { sendWebhooks } from "@/lib/webhooks"; import { prismaClient } from "@/prisma-client"; import { createCrudHandlers } from "@/route-handlers/crud-handler"; import { getIdFromUserIdOrMe } from "@/route-handlers/utils"; @@ -9,7 +10,8 @@ import { teamsCrud } from "@stackframe/stack-shared/dist/interface/crud/teams"; import { userIdOrMeSchema, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; import { StatusError, throwErr } from "@stackframe/stack-shared/dist/utils/errors"; -function prismaToCrud(prisma: Prisma.TeamGetPayload<{}>) { + +export function teamPrismaToCrud(prisma: Prisma.TeamGetPayload<{}>) { return { id: prisma.teamId, display_name: prisma.displayName, @@ -71,7 +73,17 @@ export const teamsCrudHandlers = createCrudHandlers(teamsCrud, { return db; }); - return prismaToCrud(db); + await sendWebhooks({ + type: "team.created", + projectId: auth.project.id, + data: { + team_id: db.teamId, + display_name: db.displayName, + by_user_id: auth.user?.id, + }, + }); + + return teamPrismaToCrud(db); }, onRead: async ({ params, auth }) => { const db = await prismaClient.$transaction(async (tx) => { @@ -99,7 +111,7 @@ export const teamsCrudHandlers = createCrudHandlers(teamsCrud, { return db; }); - return prismaToCrud(db); + return teamPrismaToCrud(db); }, onUpdate: async ({ params, auth, data }) => { const db = await prismaClient.team.update({ @@ -115,10 +127,10 @@ export const teamsCrudHandlers = createCrudHandlers(teamsCrud, { }, }); - return prismaToCrud(db); + return teamPrismaToCrud(db); }, onDelete: async ({ params, auth }) => { - const db = await prismaClient.team.delete({ + await prismaClient.team.delete({ where: { projectId_teamId: { projectId: auth.project.id, @@ -126,6 +138,14 @@ export const teamsCrudHandlers = createCrudHandlers(teamsCrud, { }, }, }); + + await sendWebhooks({ + type: "team.deleted", + projectId: auth.project.id, + data: { + team_id: params.team_id, + }, + }); }, onList: async ({ query, auth }) => { const userId = getIdFromUserIdOrMe(query.user_id, auth.user); @@ -150,7 +170,7 @@ export const teamsCrudHandlers = createCrudHandlers(teamsCrud, { }); return { - items: db.map(prismaToCrud), + items: db.map(teamPrismaToCrud), is_paginated: false, }; } diff --git a/apps/backend/src/app/api/v1/users/crud.tsx b/apps/backend/src/app/api/v1/users/crud.tsx index b0a09ef02..42b7c759a 100644 --- a/apps/backend/src/app/api/v1/users/crud.tsx +++ b/apps/backend/src/app/api/v1/users/crud.tsx @@ -1,4 +1,3 @@ -import { getServerTeamFromDbType } from "@/lib/teams"; import { prismaClient } from "@/prisma-client"; import { createCrudHandlers } from "@/route-handlers/crud-handler"; import { BooleanTrue, Prisma } from "@prisma/client"; @@ -9,6 +8,7 @@ import { userIdOrMeSchema, yupObject, yupString } from "@stackframe/stack-shared import { StackAssertionError, throwErr } from "@stackframe/stack-shared/dist/utils/errors"; import { hashPassword } from "@stackframe/stack-shared/dist/utils/password"; import { createLazyProxy } from "@stackframe/stack-shared/dist/utils/proxies"; +import { teamPrismaToCrud } from "../teams/crud"; const fullInclude = { projectUserOAuthAccounts: true, @@ -46,7 +46,7 @@ const prismaToCrud = (prisma: Prisma.ProjectUserGetPayload<{ include: typeof ful email: a.email, })), selected_team_id: selectedTeamMembers[0]?.teamId ?? null, - selected_team: selectedTeamMembers[0] ? getServerTeamFromDbType(selectedTeamMembers[0]?.team) : null, + selected_team: selectedTeamMembers[0] ? teamPrismaToCrud(selectedTeamMembers[0]?.team) : null, }; }; @@ -177,7 +177,7 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC return prismaToCrud(db); }, onDelete: async ({ auth, params }) => { - const db = await prismaClient.projectUser.delete({ + await prismaClient.projectUser.delete({ where: { projectId_projectUserId: { projectId: auth.project.id, diff --git a/apps/backend/src/lib/teams.tsx b/apps/backend/src/lib/teams.tsx deleted file mode 100644 index fcfcf789a..000000000 --- a/apps/backend/src/lib/teams.tsx +++ /dev/null @@ -1,166 +0,0 @@ -// TODO remove and replace with CRUD handler - -import { prismaClient } from "@/prisma-client"; -import { Prisma } from "@prisma/client"; -import { TeamsCrud } from "@stackframe/stack-shared/dist/interface/crud/teams"; - -// TODO technically we can split this; listUserTeams only needs `team`, and listServerTeams only needs `projectUser`; listTeams needs neither -// note: this is a function to prevent circular dependencies between the teams and users file -export const createFullTeamMemberInclude = () => ({ - team: true, -} as const satisfies Prisma.TeamMemberInclude); - -export type ServerTeamMemberDB = Prisma.TeamMemberGetPayload<{ include: ReturnType }>; - -export async function listUserTeams(projectId: string, userId: string): Promise { - const members = await prismaClient.teamMember.findMany({ - where: { - projectId, - projectUserId: userId, - }, - include: createFullTeamMemberInclude(), - }); - - return members.map((member) => ({ - id: member.teamId, - display_name: member.team.displayName, - profile_image_url: member.team.profileImageUrl, - })); -} - -export async function listUserServerTeams(projectId: string, userId: string): Promise { - const members = await prismaClient.teamMember.findMany({ - where: { - projectId, - projectUserId: userId, - }, - include: createFullTeamMemberInclude(), - }); - - return members.map((member) => ({ - id: member.teamId, - display_name: member.team.displayName, - profile_image_url: member.team.profileImageUrl, - created_at_millis: member.team.createdAt.getTime(), - })); -} - -export async function listTeams(projectId: string): Promise { - const result = await prismaClient.team.findMany({ - where: { - projectId, - }, - }); - - return result.map(team => ({ - id: team.teamId, - display_name: team.displayName, - profile_image_url: team.profileImageUrl, - })); -} - -export async function listServerTeams(projectId: string): Promise { - const result = await prismaClient.team.findMany({ - where: { - projectId, - }, - }); - - return result.map(team => ({ - id: team.teamId, - display_name: team.displayName, - profile_image_url: team.profileImageUrl, - created_at_millis: team.createdAt.getTime(), - })); - -} - -export async function getTeam(projectId: string, teamId: string): Promise { - // TODO more efficient filtering - const teams = await listTeams(projectId); - return teams.find(team => team.id === teamId) || null; -} - -export async function getServerTeam(projectId: string, teamId: string): Promise { - // TODO more efficient filtering - const teams = await listServerTeams(projectId); - return teams.find(team => team.id === teamId) || null; -} - -export async function updateServerTeam(projectId: string, teamId: string, update: TeamsCrud["Server"]["Update"]): Promise { - await prismaClient.team.update({ - where: { - projectId_teamId: { - projectId, - teamId, - }, - }, - data: { - displayName: update.display_name, - profileImageUrl: update.profile_image_url, - }, - }); -} - -export async function createServerTeam(projectId: string, team: TeamsCrud["Client"]["Create"]): Promise { - const result = await prismaClient.team.create({ - data: { - projectId, - displayName: team.display_name, - }, - }); - return { - id: result.teamId, - display_name: result.displayName, - created_at_millis: result.createdAt.getTime(), - profile_image_url: result.profileImageUrl, - }; -} - -export async function deleteServerTeam(projectId: string, teamId: string): Promise { - const deleted = await prismaClient.team.delete({ - where: { - projectId_teamId: { - projectId, - teamId, - }, - }, - }); -} - -export async function addUserToTeam(projectId: string, teamId: string, userId: string): Promise { - await prismaClient.teamMember.create({ - data: { - projectId, - teamId, - projectUserId: userId, - }, - }); -} - -export async function removeUserFromTeam(projectId: string, teamId: string, userId: string): Promise { - await prismaClient.teamMember.deleteMany({ - where: { - projectId, - teamId, - projectUserId: userId, - }, - }); -} - -export function getClientTeamFromServerTeam(team: TeamsCrud["Server"]["Read"]): TeamsCrud["Client"]["Read"] { - return { - id: team.id, - display_name: team.display_name, - profile_image_url: team.profile_image_url, - }; -} - -export function getServerTeamFromDbType(team: Prisma.TeamGetPayload<{}>): TeamsCrud["Server"]["Read"] { - return { - id: team.teamId, - display_name: team.displayName, - created_at_millis: team.createdAt.getTime(), - profile_image_url: team.profileImageUrl, - }; -} diff --git a/apps/backend/src/lib/webhooks.tsx b/apps/backend/src/lib/webhooks.tsx new file mode 100644 index 000000000..582199f4c --- /dev/null +++ b/apps/backend/src/lib/webhooks.tsx @@ -0,0 +1,36 @@ +import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env"; +import { captureError } from "@stackframe/stack-shared/dist/utils/errors"; +import { Svix } from "svix"; + +export async function sendWebhooks(options: { + type: string, + projectId: string, + data: any, +}) { + try { + const dataString = getEnvVariable("STACK_WEBHOOK_DATA"); + const apiKey = getEnvVariable("STACK_SVIX_API_KEY"); + const svix = new Svix(apiKey); + + if (!dataString) { + return; + } + const data = JSON.parse(dataString); + for (const { url, projectId } of data) { + if (projectId !== options.projectId) { + continue; + } + + await svix.application.getOrCreate({ uid: projectId, name: projectId }); + await svix.endpoint.create(projectId, { url }); + await svix.message.create(projectId, { + eventType: options.type, + payload: { + data: options.data, + }, + }); + } + } catch (error) { + captureError("Failed to send webhook", error); + } +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 67873823a..dd60493f9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -152,6 +152,9 @@ importers: sharp: specifier: ^0.32.6 version: 0.32.6 + svix: + specifier: ^1.25.0 + version: 1.25.0 yaml: specifier: ^2.4.5 version: 2.4.5 @@ -3411,6 +3414,9 @@ packages: '@socket.io/component-emitter@3.1.2': resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==} + '@stablelib/base64@1.0.1': + resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==} + '@swc/core-darwin-arm64@1.3.101': resolution: {integrity: sha512-mNFK+uHNPRXSnfTOG34zJOeMl2waM4hF4a2NY7dkMXrPqw9CoJn4MwTXJcyMiSz1/BnNjjTCHF3Yhj0jPxmkzQ==} engines: {node: '>=10'} @@ -4809,6 +4815,9 @@ packages: resolution: {integrity: sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==} engines: {node: '>= 0.4'} + es6-promise@4.2.8: + resolution: {integrity: sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==} + esbuild@0.19.11: resolution: {integrity: sha512-HJ96Hev2hX/6i5cDVwcqiJBBtuo9+FeIJOtZ9W1kA5M6AMJRHUZlpYZ1/SbEwtO0ioNAW8rUooVpC/WehY2SfA==} engines: {node: '>=12'} @@ -5126,6 +5135,9 @@ packages: fast-safe-stringify@2.1.1: resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} + fast-sha256@1.3.0: + resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==} + fastq@1.17.1: resolution: {integrity: sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==} @@ -7600,6 +7612,12 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + svix-fetch@3.0.0: + resolution: {integrity: sha512-rcADxEFhSqHbraZIsjyZNh4TF6V+koloX1OzZ+AQuObX9mZ2LIMhm1buZeuc5BIZPftZpJCMBsSiBaeszo9tRw==} + + svix@1.25.0: + resolution: {integrity: sha512-uW9xnwLqxfiUGwoWphKfthnymp7m14D4tdl1S+7MrH6A++ov/E3ONqLKS/6okOarMre64twxMbsg/QlT0PpnLA==} + symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} @@ -8136,6 +8154,9 @@ packages: resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} engines: {node: '>=18'} + whatwg-fetch@3.6.20: + resolution: {integrity: sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==} + whatwg-mimetype@4.0.0: resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} engines: {node: '>=18'} @@ -10537,7 +10558,7 @@ snapshots: rollup: 2.78.0 stacktrace-parser: 0.1.10 optionalDependencies: - webpack: 5.92.0(@swc/core@1.3.101(@swc/helpers@0.5.11))(esbuild@0.19.11) + webpack: 5.92.0(@swc/core@1.3.101)(esbuild@0.21.5) transitivePeerDependencies: - encoding - supports-color @@ -10594,6 +10615,8 @@ snapshots: '@socket.io/component-emitter@3.1.2': {} + '@stablelib/base64@1.0.1': {} + '@swc/core-darwin-arm64@1.3.101': optional: true @@ -10923,7 +10946,7 @@ snapshots: dependencies: '@types/node': 20.14.2 tapable: 2.2.1 - webpack: 5.92.0(@swc/core@1.3.101(@swc/helpers@0.5.11))(esbuild@0.19.11) + webpack: 5.92.0(@swc/core@1.3.101)(esbuild@0.21.5) transitivePeerDependencies: - '@swc/core' - esbuild @@ -12161,6 +12184,8 @@ snapshots: is-date-object: 1.0.5 is-symbol: 1.0.4 + es6-promise@4.2.8: {} + esbuild@0.19.11: optionalDependencies: '@esbuild/aix-ppc64': 0.19.11 @@ -12654,6 +12679,8 @@ snapshots: fast-safe-stringify@2.1.1: {} + fast-sha256@1.3.0: {} + fastq@1.17.1: dependencies: reusify: 1.0.4 @@ -15703,6 +15730,23 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + svix-fetch@3.0.0: + dependencies: + node-fetch: 2.7.0 + whatwg-fetch: 3.6.20 + transitivePeerDependencies: + - encoding + + svix@1.25.0: + dependencies: + '@stablelib/base64': 1.0.1 + es6-promise: 4.2.8 + fast-sha256: 1.3.0 + svix-fetch: 3.0.0 + url-parse: 1.5.10 + transitivePeerDependencies: + - encoding + symbol-tree@3.2.4: {} tailwind-merge@2.2.0: @@ -15813,18 +15857,6 @@ snapshots: term-size@2.2.1: {} - terser-webpack-plugin@5.3.10(@swc/core@1.3.101(@swc/helpers@0.5.11))(esbuild@0.19.11)(webpack@5.92.0(@swc/core@1.3.101(@swc/helpers@0.5.11))(esbuild@0.19.11)): - dependencies: - '@jridgewell/trace-mapping': 0.3.25 - jest-worker: 27.5.1 - schema-utils: 3.3.0 - serialize-javascript: 6.0.2 - terser: 5.31.1 - webpack: 5.92.0(@swc/core@1.3.101(@swc/helpers@0.5.11))(esbuild@0.19.11) - optionalDependencies: - '@swc/core': 1.3.101(@swc/helpers@0.5.11) - esbuild: 0.19.11 - terser-webpack-plugin@5.3.10(@swc/core@1.3.101)(esbuild@0.21.5)(webpack@5.92.0(@swc/core@1.3.101)(esbuild@0.21.5)): dependencies: '@jridgewell/trace-mapping': 0.3.25 @@ -16314,37 +16346,6 @@ snapshots: webpack-sources@3.2.3: {} - webpack@5.92.0(@swc/core@1.3.101(@swc/helpers@0.5.11))(esbuild@0.19.11): - dependencies: - '@types/eslint-scope': 3.7.7 - '@types/estree': 1.0.5 - '@webassemblyjs/ast': 1.12.1 - '@webassemblyjs/wasm-edit': 1.12.1 - '@webassemblyjs/wasm-parser': 1.12.1 - acorn: 8.12.0 - acorn-import-attributes: 1.9.5(acorn@8.12.0) - browserslist: 4.23.1 - chrome-trace-event: 1.0.4 - enhanced-resolve: 5.17.0 - es-module-lexer: 1.5.3 - eslint-scope: 5.1.1 - events: 3.3.0 - glob-to-regexp: 0.4.1 - graceful-fs: 4.2.11 - json-parse-even-better-errors: 2.3.1 - loader-runner: 4.3.0 - mime-types: 2.1.35 - neo-async: 2.6.2 - schema-utils: 3.3.0 - tapable: 2.2.1 - terser-webpack-plugin: 5.3.10(@swc/core@1.3.101(@swc/helpers@0.5.11))(esbuild@0.19.11)(webpack@5.92.0(@swc/core@1.3.101(@swc/helpers@0.5.11))(esbuild@0.19.11)) - watchpack: 2.4.1 - webpack-sources: 3.2.3 - transitivePeerDependencies: - - '@swc/core' - - esbuild - - uglify-js - webpack@5.92.0(@swc/core@1.3.101)(esbuild@0.21.5): dependencies: '@types/eslint-scope': 3.7.7 @@ -16380,6 +16381,8 @@ snapshots: dependencies: iconv-lite: 0.6.3 + whatwg-fetch@3.6.20: {} + whatwg-mimetype@4.0.0: {} whatwg-url@14.0.0: