Skip to content

Commit

Permalink
feat: send test message (#44)
Browse files Browse the repository at this point in the history
* feat(api): rate limiter

* feat: send test message

* fix: optional chain
  • Loading branch information
waltergalvao authored Jan 30, 2025
1 parent 2b1e0b0 commit ad084c2
Show file tree
Hide file tree
Showing 20 changed files with 579 additions and 72 deletions.
1 change: 1 addition & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"@date-fns/tz": "^1.2.0",
"@date-fns/utc": "^2.1.0",
"@envelop/generic-auth": "^7.0.0",
"@envelop/rate-limiter": "^6.2.1",
"@graphql-tools/load-files": "^7.0.0",
"@graphql-tools/merge": "^9.0.0",
"@logtail/pino": "^0.4.15",
Expand Down
13 changes: 13 additions & 0 deletions apps/api/src/app/digests/services/digest.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,12 @@ import {
FindDigestByTypeArgs,
UpsertDigest,
Digest,
DigestWithRelations,
} from "./digest.types";
import { assign, isObject } from "radash";
import { DigestType } from "@prisma/client";
import { sendTeamMetricsDigest } from "./digest-team-metrics.service";
import { sendTeamWipDigest } from "./digest-team-wip.service";

export const findDigestByType = async <T extends DigestType>({
workspaceId,
Expand Down Expand Up @@ -100,3 +103,13 @@ export const upsertDigest = async ({

return updatedDigest as unknown as Digest;
};

export const sendDigest = async (digest: DigestWithRelations) => {
if (digest.type === DigestType.TEAM_METRICS) {
await sendTeamMetricsDigest(digest);
}

if (digest.type === DigestType.TEAM_WIP) {
await sendTeamWipDigest(digest);
}
};
12 changes: 2 additions & 10 deletions apps/api/src/app/digests/workers/digest-send.worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,8 @@ import { SweetQueue } from "../../../bull-mq/queues";
import { createWorker } from "../../../bull-mq/workers";
import { logger } from "../../../lib/logger";
import { DigestWithRelations } from "../services/digest.types";
import { DigestType } from "@prisma/client";
import { sendTeamWipDigest } from "../services/digest-team-wip.service";
import { sendTeamMetricsDigest } from "../services/digest-team-metrics.service";
import { InputValidationException } from "../../errors/exceptions/input-validation.exception";
import { sendDigest } from "../services/digest.service";

export const digestSendWorker = createWorker(
SweetQueue.DIGEST_SEND,
Expand All @@ -21,12 +19,6 @@ export const digestSendWorker = createWorker(
});
}

if (digest.type === DigestType.TEAM_METRICS) {
await sendTeamMetricsDigest(digest);
}

if (digest.type === DigestType.TEAM_WIP) {
await sendTeamWipDigest(digest);
}
await sendDigest(digest);
}
);
9 changes: 9 additions & 0 deletions apps/api/src/app/directives.schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export default /* GraphQL */ `
directive @rateLimit(
max: Int
window: String
message: String
identityArgs: [String]
arrayLengthField: String
) on FIELD_DEFINITION
`;
21 changes: 21 additions & 0 deletions apps/api/src/app/errors/exceptions/rate-limit.exception.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import {
BaseException,
BaseExceptionOptions,
ErrorCode,
} from "./base.exception";

export class RateLimitException extends BaseException {
name = "RateLimitException";

constructor(
message = "Rate limit exceeded",
options?: Partial<BaseExceptionOptions>
) {
super(message, {
code: ErrorCode.RATE_LIMIT_EXCEEDED,
userFacingMessage: "You have exceeded the rate limit.",
severity: "log",
...options,
});
}
}
12 changes: 12 additions & 0 deletions apps/api/src/app/integrations/resolvers/integrations.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,24 @@ export default /* GraphQL */ `
app: IntegrationApp!
}
input SendTestMessageInput {
workspaceId: SweetID!
app: IntegrationApp!
channel: String!
}
extend type Workspace {
integrations: [Integration!]!
}
type Mutation {
installIntegration(input: InstallIntegrationInput!): Void
removeIntegration(input: RemoveIntegrationInput!): Void
sendTestMessage(input: SendTestMessageInput!): Void
@rateLimit(
window: "60s"
max: 5
message: "You got rate limited. You can send 5 test messages per minute."
)
}
`;
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { z } from "zod";
import { createMutationResolver } from "../../../../lib/graphql";
import { logger } from "../../../../lib/logger";
import { validateInputOrThrow } from "../../../../lib/validate-input";
import { protectWithPaywall } from "../../../billing/services/billing.service";
import { authorizeWorkspaceOrThrow } from "../../../workspace-authorization.service";
import { sendTestMessage } from "../../slack/services/slack-integration.service";
import { IntegrationApp } from "@sweetr/graphql-types/api";

export const sendTestMessageMutation = createMutationResolver({
sendTestMessage: async (_, { input }, context) => {
logger.info("mutation.sendTestMessage", { input });

validateInputOrThrow(
z.object({
workspaceId: z.number(),
channel: z.string().max(80),
app: z.nativeEnum(IntegrationApp),
}),
input
);

await authorizeWorkspaceOrThrow({
workspaceId: input.workspaceId,
gitProfileId: context.currentToken.gitProfileId,
});

await protectWithPaywall(input.workspaceId);

await sendTestMessage(input.workspaceId, input.channel);

return null;
},
});
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,9 @@ export const joinSlackChannel = async (
const channel = await findSlackChannel(slackClient, channelName);

if (!channel?.id) {
throw new ResourceNotFoundException("Slack channel not found");
throw new ResourceNotFoundException("Slack channel not found", {
userFacingMessage: "Could not find Slack channel.",
});
}

if (!channel.is_member) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,11 @@ import {
authorizeSlackWorkspace,
getSlackClient,
getWorkspaceSlackClient,
joinSlackChannel,
sendSlackMessage,
uninstallSlackWorkspace,
} from "./slack-client.service";
import { ResourceNotFoundException } from "../../../errors/exceptions/resource-not-found.exception";

export const installIntegration = async (workspaceId: number, code: string) => {
let response: OauthV2AccessResponse;
Expand Down Expand Up @@ -122,3 +125,20 @@ export const getInstallUrl = (): string => {

return url.toString();
};

export const sendTestMessage = async (workspaceId: number, channel: string) => {
const { slackClient } = await getWorkspaceSlackClient(workspaceId);

const slackChannel = await joinSlackChannel(slackClient, channel);

if (!slackChannel?.id) {
throw new ResourceNotFoundException("Slack channel not found");
}

await sendSlackMessage(slackClient, {
channel: slackChannel.id,
text: "Sweet, it works! 🍧",
unfurl_links: false,
unfurl_media: false,
});
};
12 changes: 12 additions & 0 deletions apps/api/src/lib/rate-limiter.plugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { useRateLimiter } from "@envelop/rate-limiter";
import { GraphQLContext } from "@sweetr/graphql-types/shared";
import { RateLimitException } from "../app/errors/exceptions/rate-limit.exception";

export const rateLimiterPlugin = useRateLimiter({
identifyFn: (context: GraphQLContext) =>
context.workspaceId?.toString() ?? context.currentToken?.userId.toString(),
transformError: (message: string) =>
new RateLimitException("Rate limit exceeded", {
userFacingMessage: message,
}),
});
3 changes: 2 additions & 1 deletion apps/api/src/yoga.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { join } from "path";
import { authPlugin } from "./app/auth/resolvers/plugins/auth.plugin";
import { env } from "./env";
import { useSentry } from "./lib/use-sentry.plugin";
import { rateLimiterPlugin } from "./lib/rate-limiter.plugin";

const resolvers = loadFilesSync(
join(__dirname, "./**/*.(query|mutation|resolver).(js|ts)")
Expand All @@ -21,7 +22,7 @@ const schema = createSchema<GraphQLContext>({
export const yoga = createYoga<GraphQLContext>({
graphqlEndpoint: "/",
schema,
plugins: [authPlugin, useSentry()],
plugins: [authPlugin, useSentry(), rateLimiterPlugin],
graphiql: env.NODE_ENV === "development",
landingPage: false,
logging: false,
Expand Down
23 changes: 23 additions & 0 deletions apps/web/src/api/integrations.api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ import {
InstallIntegrationMutation,
MutationInstallIntegrationArgs,
MutationRemoveIntegrationArgs,
MutationSendTestMessageArgs,
RemoveIntegrationMutation,
SendTestMessageMutation,
WorkspaceIntegrationsQuery,
WorkspaceIntegrationsQueryVariables,
} from "@sweetr/graphql-types/frontend/graphql";
Expand Down Expand Up @@ -93,3 +95,24 @@ export const useRemoveIntegrationMutation = (
},
...options,
});

export const useSendTestMessageMutation = (
options?: UseMutationOptions<
SendTestMessageMutation,
unknown,
MutationSendTestMessageArgs,
unknown
>,
) =>
useMutation({
mutationFn: (args) =>
graphQLClient.request(
graphql(/* GraphQL */ `
mutation SendTestMessage($input: SendTestMessageInput!) {
sendTestMessage(input: $input)
}
`),
args,
),
...options,
});
Original file line number Diff line number Diff line change
Expand Up @@ -12,24 +12,32 @@ import {
Group,
Input,
Text,
Button,
Tooltip,
} from "@mantine/core";
import { DigestFrequency } from "@sweetr/graphql-types/api";
import { IconClock, IconInfoCircle, IconWorld } from "@tabler/icons-react";
import {
IconBrandSlack,
IconClock,
IconInfoCircle,
IconWorld,
} from "@tabler/icons-react";
import { BoxSetting } from "../../../../../../../components/box-setting";
import { SelectHour } from "../../../../../../../components/select-hour";
import { SelectTimezone } from "../../../../../../../components/select-timezone/select-timezone";
import { DayOfTheWeek } from "@sweetr/graphql-types/frontend/graphql";
import { useEffect, useRef } from "react";
import { useSendTestMessage } from "../../../../use-send-test-message";

interface DigestBaseFieldsProps {
form: UseFormReturnType<FormDigest>;
}

export const DigestBaseFields = ({ form }: DigestBaseFieldsProps) => {
const { sendTestMessage, isSendingTestMessage } = useSendTestMessage();
const isEnabled = form.values.enabled;

const channelRef = useRef<HTMLInputElement>(null);

useEffect(() => {
if (isEnabled && !form.values.channel) {
channelRef.current?.focus();
Expand Down Expand Up @@ -69,6 +77,18 @@ export const DigestBaseFields = ({ form }: DigestBaseFieldsProps) => {
</Input.Error>
)}
</Stack>
<Tooltip label="Send a test message to the channel" withArrow>
<Button
variant="default"
onClick={() => {
sendTestMessage(form.values.channel);
}}
leftSection={<IconBrandSlack size={16} stroke={1.5} />}
loading={isSendingTestMessage}
>
Test
</Button>
</Tooltip>
</Group>
</Input.Wrapper>
</>
Expand Down
39 changes: 39 additions & 0 deletions apps/web/src/app/teams/[id]/use-send-test-message.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { IntegrationApp } from "@sweetr/graphql-types/api";
import { showSuccessNotification } from "../../../providers/notification.provider";
import { getErrorMessage } from "../../../providers/error-message.provider";
import { useSendTestMessageMutation } from "../../../api/integrations.api";
import { useWorkspace } from "../../../providers/workspace.provider";
import { showErrorNotification } from "../../../providers/notification.provider";

export const useSendTestMessage = () => {
const { workspace } = useWorkspace();
const { mutate, isPending: isSendingTestMessage } =
useSendTestMessageMutation();

const sendTestMessage = (channel: string) => {
mutate(
{
input: {
workspaceId: workspace.id,
app: IntegrationApp.SLACK,
channel: channel,
},
},
{
onError: (error) => {
showErrorNotification({
message: getErrorMessage(error),
});
},
onSuccess: () => {
showSuccessNotification({ message: "Test message sent" });
},
},
);
};

return {
sendTestMessage,
isSendingTestMessage,
};
};
9 changes: 8 additions & 1 deletion apps/web/src/providers/error-message.provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,14 @@ export const getErrorMessage = (error: unknown) => {
}
| undefined;

if (extensions?.code === "INPUT_VALIDATION_FAILED") {
if (
extensions &&
[
"INPUT_VALIDATION_FAILED",
"RESOURCE_NOT_FOUND",
"RATE_LIMIT_EXCEEDED",
].includes(extensions.code)
) {
return extensions.userFacingMessage;
}
}
Expand Down
Loading

0 comments on commit ad084c2

Please sign in to comment.