From da0d8fdbfcccccc0a39ba1f46ec787d193e51a3f Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Fri, 5 Jul 2024 15:19:43 +0800 Subject: [PATCH] feat: finished up wish integration --- .env.example | 3 + backend/package-lock.json | 29 +++++ backend/package.json | 1 + backend/src/@types/fastify.d.ts | 2 + backend/src/lib/config/env.ts | 4 +- backend/src/server/config/rateLimiter.ts | 6 + backend/src/server/routes/index.ts | 8 +- backend/src/server/routes/v1/index.ts | 2 + .../routes/v1/user-engagement-router.ts | 27 +++++ .../user-engagement-service.ts | 89 ++++++++++++++ .../src/hooks/api/userEngagement/index.ts | 1 + .../hooks/api/userEngagement/mutations.tsx | 14 +++ .../src/hooks/api/userEngagement/types.ts | 3 + frontend/src/layouts/AppLayout/AppLayout.tsx | 38 +----- .../components/WishForm/WishForm.tsx | 112 ++++++++++++++++++ 15 files changed, 303 insertions(+), 36 deletions(-) create mode 100644 backend/src/server/routes/v1/user-engagement-router.ts create mode 100644 backend/src/services/user-engagement/user-engagement-service.ts create mode 100644 frontend/src/hooks/api/userEngagement/index.ts create mode 100644 frontend/src/hooks/api/userEngagement/mutations.tsx create mode 100644 frontend/src/hooks/api/userEngagement/types.ts create mode 100644 frontend/src/layouts/AppLayout/components/WishForm/WishForm.tsx diff --git a/.env.example b/.env.example index 67110d69a..a6f134ace 100644 --- a/.env.example +++ b/.env.example @@ -67,3 +67,6 @@ CLIENT_SECRET_GITLAB_LOGIN= CAPTCHA_SECRET= NEXT_PUBLIC_CAPTCHA_SITE_KEY= + +PLAIN_API_KEY= +PLAIN_WISH_LABEL_IDS= diff --git a/backend/package-lock.json b/backend/package-lock.json index 20a5f7c21..22484e79a 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -30,6 +30,7 @@ "@peculiar/x509": "^1.10.0", "@serdnam/pino-cloudwatch-transport": "^1.0.4", "@sindresorhus/slugify": "^2.2.1", + "@team-plain/typescript-sdk": "^4.6.1", "@ucast/mongo2js": "^1.3.4", "ajv": "^8.12.0", "argon2": "^0.31.2", @@ -3913,6 +3914,14 @@ "yaml": "^2.2.2" } }, + "node_modules/@graphql-typed-document-node/core": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@graphql-typed-document-node/core/-/core-3.2.0.tgz", + "integrity": "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==", + "peerDependencies": { + "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, "node_modules/@hapi/bourne": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@hapi/bourne/-/bourne-2.1.0.tgz", @@ -6198,6 +6207,18 @@ "optional": true, "peer": true }, + "node_modules/@team-plain/typescript-sdk": { + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/@team-plain/typescript-sdk/-/typescript-sdk-4.6.1.tgz", + "integrity": "sha512-Uy9QJXu9U7bJb6WXL9sArGk7FXPpzdqBd6q8tAF1vexTm8fbTJRqcikTKxGtZmNADt+C2SapH3cApM4oHpO4lQ==", + "dependencies": { + "@graphql-typed-document-node/core": "^3.2.0", + "ajv": "^8.12.0", + "ajv-formats": "^2.1.1", + "graphql": "^16.6.0", + "zod": "3.22.4" + } + }, "node_modules/@tsconfig/node10": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.9.tgz", @@ -10314,6 +10335,14 @@ "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", "dev": true }, + "node_modules/graphql": { + "version": "16.9.0", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.9.0.tgz", + "integrity": "sha512-GGTKBX4SD7Wdb8mqeDLni2oaRGYQWjWHGKPQ24ZMnUtKfcsVoiv4uX8+LJr1K6U5VW2Lu1BwJnj7uiori0YtRw==", + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, "node_modules/gtoken": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-7.1.0.tgz", diff --git a/backend/package.json b/backend/package.json index b278be571..f2c554a19 100644 --- a/backend/package.json +++ b/backend/package.json @@ -91,6 +91,7 @@ "@peculiar/x509": "^1.10.0", "@serdnam/pino-cloudwatch-transport": "^1.0.4", "@sindresorhus/slugify": "^2.2.1", + "@team-plain/typescript-sdk": "^4.6.1", "@ucast/mongo2js": "^1.3.4", "ajv": "^8.12.0", "argon2": "^0.31.2", diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index c7e58fb09..166c5a3f9 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -65,6 +65,7 @@ import { TSuperAdminServiceFactory } from "@app/services/super-admin/super-admin import { TTelemetryServiceFactory } from "@app/services/telemetry/telemetry-service"; import { TUserDALFactory } from "@app/services/user/user-dal"; import { TUserServiceFactory } from "@app/services/user/user-service"; +import { TUserEngagementServiceFactory } from "@app/services/user-engagement/user-engagement-service"; import { TWebhookServiceFactory } from "@app/services/webhook/webhook-service"; declare module "fastify" { @@ -157,6 +158,7 @@ declare module "fastify" { identityProjectAdditionalPrivilege: TIdentityProjectAdditionalPrivilegeServiceFactory; secretSharing: TSecretSharingServiceFactory; rateLimit: TRateLimitServiceFactory; + userEngagement: TUserEngagementServiceFactory; }; // this is exclusive use for middlewares in which we need to inject data // everywhere else access using service layer diff --git a/backend/src/lib/config/env.ts b/backend/src/lib/config/env.ts index 1fe17dc4b..e57d0ce50 100644 --- a/backend/src/lib/config/env.ts +++ b/backend/src/lib/config/env.ts @@ -135,7 +135,9 @@ const envSchema = z .optional(), INFISICAL_CLOUD: zodStrBool.default("false"), MAINTENANCE_MODE: zodStrBool.default("false"), - CAPTCHA_SECRET: zpStr(z.string().optional()) + CAPTCHA_SECRET: zpStr(z.string().optional()), + PLAIN_API_KEY: zpStr(z.string().optional()), + PLAIN_WISH_LABEL_IDS: zpStr(z.string().optional()) }) .transform((data) => ({ ...data, diff --git a/backend/src/server/config/rateLimiter.ts b/backend/src/server/config/rateLimiter.ts index ad54a151a..79b709ee6 100644 --- a/backend/src/server/config/rateLimiter.ts +++ b/backend/src/server/config/rateLimiter.ts @@ -82,3 +82,9 @@ export const publicSecretShareCreationLimit: RateLimitOptions = { max: 5, keyGenerator: (req) => req.realIp }; + +export const userEngagementLimit: RateLimitOptions = { + timeWindow: 60 * 1000, + max: 5, + keyGenerator: (req) => req.realIp +}; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 54c5e51d6..e872c7363 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -164,6 +164,7 @@ import { telemetryServiceFactory } from "@app/services/telemetry/telemetry-servi import { userDALFactory } from "@app/services/user/user-dal"; import { userServiceFactory } from "@app/services/user/user-service"; import { userAliasDALFactory } from "@app/services/user-alias/user-alias-dal"; +import { userEngagementServiceFactory } from "@app/services/user-engagement/user-engagement-service"; import { webhookDALFactory } from "@app/services/webhook/webhook-dal"; import { webhookServiceFactory } from "@app/services/webhook/webhook-service"; @@ -924,6 +925,10 @@ export const registerRoutes = async ( oidcConfigDAL }); + const userEngagementService = userEngagementServiceFactory({ + userDAL + }); + await superAdminService.initServerCfg(); // // setup the communication with license key server @@ -995,7 +1000,8 @@ export const registerRoutes = async ( telemetry: telemetryService, projectUserAdditionalPrivilege: projectUserAdditionalPrivilegeService, identityProjectAdditionalPrivilege: identityProjectAdditionalPrivilegeService, - secretSharing: secretSharingService + secretSharing: secretSharingService, + userEngagement: userEngagementService }); const cronJobs: CronJob[] = []; diff --git a/backend/src/server/routes/v1/index.ts b/backend/src/server/routes/v1/index.ts index c2969b382..d5c5acbcf 100644 --- a/backend/src/server/routes/v1/index.ts +++ b/backend/src/server/routes/v1/index.ts @@ -25,6 +25,7 @@ import { registerSecretSharingRouter } from "./secret-sharing-router"; import { registerSecretTagRouter } from "./secret-tag-router"; import { registerSsoRouter } from "./sso-router"; import { registerUserActionRouter } from "./user-action-router"; +import { registerUserEngagementRouter } from "./user-engagement-router"; import { registerUserRouter } from "./user-router"; import { registerWebhookRouter } from "./webhook-router"; @@ -77,4 +78,5 @@ export const registerV1Routes = async (server: FastifyZodProvider) => { await server.register(registerWebhookRouter, { prefix: "/webhooks" }); await server.register(registerIdentityRouter, { prefix: "/identities" }); await server.register(registerSecretSharingRouter, { prefix: "/secret-sharing" }); + await server.register(registerUserEngagementRouter, { prefix: "/user-engagement" }); }; diff --git a/backend/src/server/routes/v1/user-engagement-router.ts b/backend/src/server/routes/v1/user-engagement-router.ts new file mode 100644 index 000000000..e3ce6532e --- /dev/null +++ b/backend/src/server/routes/v1/user-engagement-router.ts @@ -0,0 +1,27 @@ +import { z } from "zod"; + +import { userEngagementLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +export const registerUserEngagementRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + url: "/me/wish", + config: { + rateLimit: userEngagementLimit + }, + schema: { + body: z.object({ + text: z.string().min(1) + }), + response: { + 200: z.object({}) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + return server.services.userEngagement.createUserWish(req.permission.id, req.body.text); + } + }); +}; diff --git a/backend/src/services/user-engagement/user-engagement-service.ts b/backend/src/services/user-engagement/user-engagement-service.ts new file mode 100644 index 000000000..5d7b54929 --- /dev/null +++ b/backend/src/services/user-engagement/user-engagement-service.ts @@ -0,0 +1,89 @@ +import { PlainClient } from "@team-plain/typescript-sdk"; + +import { getConfig } from "@app/lib/config/env"; +import { InternalServerError } from "@app/lib/errors"; + +import { TUserDALFactory } from "../user/user-dal"; + +type TUserEngagementServiceFactoryDep = { + userDAL: Pick; +}; + +export type TUserEngagementServiceFactory = ReturnType; + +export const userEngagementServiceFactory = ({ userDAL }: TUserEngagementServiceFactoryDep) => { + const createUserWish = async (userId: string, text: string) => { + const user = await userDAL.findById(userId); + const appCfg = getConfig(); + + if (!appCfg.PLAIN_API_KEY) { + throw new InternalServerError({ + message: "Plain is not configured." + }); + } + + const client = new PlainClient({ + apiKey: appCfg.PLAIN_API_KEY + }); + + const customerUpsertRes = await client.upsertCustomer({ + identifier: { + emailAddress: user.email + }, + onCreate: { + fullName: `${user.firstName} ${user.lastName}`, + shortName: user.firstName, + email: { + email: user.email as string, + isVerified: user.isEmailVerified as boolean + }, + + externalId: user.id + }, + + onUpdate: { + fullName: { + value: `${user.firstName} ${user.lastName}` + }, + shortName: { + value: user.firstName + }, + email: { + email: user.email as string, + isVerified: user.isEmailVerified as boolean + }, + externalId: { + value: user.id + } + } + }); + + if (customerUpsertRes.error) { + throw new InternalServerError({ message: customerUpsertRes.error.message }); + } + + const createThreadRes = await client.createThread({ + title: "Wish", + customerIdentifier: { + externalId: customerUpsertRes.data.customer.externalId + }, + components: [ + { + componentText: { + text + } + } + ], + labelTypeIds: appCfg.PLAIN_WISH_LABEL_IDS?.split(",") + }); + + if (createThreadRes.error) { + throw new InternalServerError({ + message: createThreadRes.error.message + }); + } + }; + return { + createUserWish + }; +}; diff --git a/frontend/src/hooks/api/userEngagement/index.ts b/frontend/src/hooks/api/userEngagement/index.ts new file mode 100644 index 000000000..5a4c29fa8 --- /dev/null +++ b/frontend/src/hooks/api/userEngagement/index.ts @@ -0,0 +1 @@ +export { useCreateUserWish } from "./mutations"; diff --git a/frontend/src/hooks/api/userEngagement/mutations.tsx b/frontend/src/hooks/api/userEngagement/mutations.tsx new file mode 100644 index 000000000..d876e65c8 --- /dev/null +++ b/frontend/src/hooks/api/userEngagement/mutations.tsx @@ -0,0 +1,14 @@ +import { useMutation } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { TCreateUserWishDto } from "./types"; + +export const useCreateUserWish = () => { + return useMutation<{}, {}, TCreateUserWishDto>({ + mutationFn: async (dto) => { + const { data } = await apiRequest.post("/api/v1/user-engagement/me/wish", dto); + return data; + } + }); +}; diff --git a/frontend/src/hooks/api/userEngagement/types.ts b/frontend/src/hooks/api/userEngagement/types.ts new file mode 100644 index 000000000..ad94d03d2 --- /dev/null +++ b/frontend/src/hooks/api/userEngagement/types.ts @@ -0,0 +1,3 @@ +export type TCreateUserWishDto = { + text: string; +}; diff --git a/frontend/src/layouts/AppLayout/AppLayout.tsx b/frontend/src/layouts/AppLayout/AppLayout.tsx index 8bd51417d..a643e6d09 100644 --- a/frontend/src/layouts/AppLayout/AppLayout.tsx +++ b/frontend/src/layouts/AppLayout/AppLayout.tsx @@ -10,7 +10,7 @@ import { Controller, useForm } from "react-hook-form"; import { useTranslation } from "react-i18next"; import Link from "next/link"; import { useRouter } from "next/router"; -import { faGithub, faRocketchat, faSlack } from "@fortawesome/free-brands-svg-icons"; +import { faGithub, faSlack } from "@fortawesome/free-brands-svg-icons"; import { faStar } from "@fortawesome/free-regular-svg-icons"; import { faAngleDown, @@ -47,12 +47,8 @@ import { MenuItem, Modal, ModalContent, - Popover, - PopoverContent, - PopoverTrigger, Select, SelectItem, - TextArea, UpgradePlanModal } from "@app/components/v2"; import { UpgradeOverlay } from "@app/components/v2/UpgradeOverlay"; @@ -81,6 +77,8 @@ import { useGetUserProjectFavorites } from "@app/hooks/api/users/queries"; import { navigateUserToOrg } from "@app/views/Login/Login.utils"; import { CreateOrgModal } from "@app/views/Org/components"; +import { WishForm } from "./components/WishForm/WishForm"; + interface LayoutProps { children: React.ReactNode; } @@ -764,35 +762,7 @@ export const AppLayout = ({ children }: LayoutProps) => {
*/} - - -
- - Make a wish -
-
- - -