Skip to content

Commit

Permalink
Improve/membership manipulation (#322)
Browse files Browse the repository at this point in the history
* improve: accept membership invitation

* add: leave entity endpoint
  • Loading branch information
LemonardoD authored Dec 27, 2024
1 parent 18a162e commit 7b6ff3e
Show file tree
Hide file tree
Showing 12 changed files with 136 additions and 60 deletions.
65 changes: 34 additions & 31 deletions backend/src/modules/general/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,10 @@ import { organizationsTable } from '#/db/schema/organizations';
import { type TokenModel, tokensTable } from '#/db/schema/tokens';
import { usersTable } from '#/db/schema/users';
import { getUserBy } from '#/db/util';
import { entityIdFields, entityTables } from '#/entity-config';
import { entityIdFields, entityTables, menuSections } from '#/entity-config';
import { errorResponse } from '#/lib/errors';
import { i18n } from '#/lib/i18n';
import { sendSSEToUsers } from '#/lib/sse';
import { isAuthenticated } from '#/middlewares/guard';
import { logEvent } from '#/middlewares/logger/log-event';
import { verifyUnsubscribeToken } from '#/modules/users/helpers/unsubscribe-token';
Expand Down Expand Up @@ -178,47 +179,49 @@ const generalRoutes = app
return errorResponse(ctx, 400, 'invalid_token_or_expired', 'warn');
}
const user = await getUserBy('email', token.email);
if (!user) {
return errorResponse(ctx, 404, 'not_found', 'warn', 'user', { email: token.email });
}
if (!user) return errorResponse(ctx, 404, 'not_found', 'warn', 'user', { email: token.email });

// If it is a system invitation, update user role
if (token.type === 'system_invitation') return ctx.json({ success: true }, 200);

if (token.type === 'membership_invitation') {
if (!token.organizationId) return errorResponse(ctx, 400, 'invalid_token', 'warn');

const [organization] = await db
.select()
.from(organizationsTable)
.where(and(eq(organizationsTable.id, token.organizationId)));
if (!token.organizationId) return errorResponse(ctx, 400, 'invalid_token', 'warn');

if (!organization) {
return errorResponse(ctx, 404, 'not_found', 'warn', 'organization', { organization: token.organizationId });
}
const [organization] = await db
.select()
.from(organizationsTable)
.where(and(eq(organizationsTable.id, token.organizationId)));

const [existingMembership] = await db
.select()
.from(membershipsTable)
.where(and(eq(membershipsTable.organizationId, organization.id), eq(membershipsTable.userId, user.id)));

if (existingMembership) {
if (existingMembership.role !== token.role) {
await db
.update(membershipsTable)
.set({ role: token.role as MembershipModel['role'] })
.where(and(eq(membershipsTable.organizationId, organization.id), eq(membershipsTable.userId, user.id)));
}
if (!organization) return errorResponse(ctx, 404, 'not_found', 'warn', 'organization', { organization: token.organizationId });

return ctx.json({ success: true }, 200);
const [existingMembership] = await db
.select()
.from(membershipsTable)
.where(and(eq(membershipsTable.organizationId, organization.id), eq(membershipsTable.userId, user.id)));

if (existingMembership) {
if (existingMembership.role !== token.role) {
await db
.update(membershipsTable)
.set({ role: token.role as MembershipModel['role'] })
.where(and(eq(membershipsTable.organizationId, organization.id), eq(membershipsTable.userId, user.id)));
}

// Insert membership
const role = token.role as MembershipModel['role'];
await insertMembership({ user, role, entity: organization });
return ctx.json({ success: true }, 200);
}

return ctx.json({ success: true }, 200);
// Insert membership
const role = token.role as MembershipModel['role'];
const membership = await insertMembership({ user, role, entity: organization });

const newMenuItem = {
newItem: { ...organization, membership },
sectionName: menuSections.find((el) => el.entityType === membership.type)?.name,
};

// SSE with entity data, to update user's menu
sendSSEToUsers([user.id], 'add_entity', newMenuItem);

return ctx.json({ success: true, data: newMenuItem }, 200);
})
/*
* Paddle webhook
Expand Down
4 changes: 2 additions & 2 deletions backend/src/modules/general/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { authRateLimiter, rateLimiter } from '#/middlewares/rate-limiter';
import { errorResponses, successWithDataSchema, successWithoutDataSchema } from '#/utils/schema/common-responses';
import { pageEntityTypeSchema, slugSchema, tokenSchema } from '#/utils/schema/common-schemas';
import { userUnsubscribeQuerySchema } from '../users/schema';
import { acceptInviteBodySchema, checkTokenSchema, inviteBodySchema, suggestionsSchema } from './schema';
import { acceptInviteBodySchema, acceptInviteResponseSchema, checkTokenSchema, inviteBodySchema, suggestionsSchema } from './schema';

class GeneralRoutesConfig {
public unsubscribeUser = createRouteConfig({
Expand Down Expand Up @@ -173,7 +173,7 @@ class GeneralRoutesConfig {
description: 'Invitation was accepted',
content: {
'application/json': {
schema: successWithoutDataSchema,
schema: successWithDataSchema(acceptInviteResponseSchema),
},
},
},
Expand Down
11 changes: 11 additions & 0 deletions backend/src/modules/general/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { z } from 'zod';
import { config } from 'config';
import { createSelectSchema } from 'drizzle-zod';
import { tokensTable } from '#/db/schema/tokens';
import { type MenuSectionName, menuSections } from '#/entity-config';
import {
contextEntityTypeSchema,
idOrSlugSchema,
Expand All @@ -14,6 +15,7 @@ import {
passwordSchema,
slugSchema,
} from '#/utils/schema/common-schemas';
import { menuItemSchema } from '../me/schema';
import { membershipInfoSchema } from '../memberships/schema';
import { userSchema } from '../users/schema';

Expand All @@ -37,6 +39,15 @@ export const acceptInviteBodySchema = z.object({
oauth: z.enum(config.enabledOauthProviders).optional(),
});

const sectionNames = menuSections.map((section) => section.name) as [MenuSectionName];

export const acceptInviteResponseSchema = z
.object({
newItem: menuItemSchema,
sectionName: z.enum(sectionNames),
})
.optional();

export const entitySuggestionSchema = z.object({
slug: slugSchema,
id: idSchema,
Expand Down
25 changes: 25 additions & 0 deletions backend/src/modules/me/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import { oauthAccountsTable } from '#/db/schema/oauth-accounts';
import { passkeysTable } from '#/db/schema/passkeys';
import { type MenuSection, entityIdFields, entityTables, menuSections } from '#/entity-config';
import { getContextUser, getMemberships } from '#/lib/context';
import { resolveEntity } from '#/lib/entity';
import { sendSSEToUsers } from '#/lib/sse';
import { getPreparedSessions } from './helpers/get-sessions';
import type { MenuItem, UserMenu } from './schema';

Expand Down Expand Up @@ -242,7 +244,30 @@ const meRoutes = app

return ctx.json({ success: true }, 200);
})
/*
* Delete current user (self) entity membership
*/
.openapi(meRoutesConfig.leaveEntity, async (ctx) => {
const user = getContextUser();
if (!user) return errorResponse(ctx, 404, 'not_found', 'warn', 'user', { user: 'self' });

const { entityType, idOrSlug } = ctx.req.valid('query');

const entity = await resolveEntity(entityType, idOrSlug);
if (!entity) return errorResponse(ctx, 404, 'not_found', 'warn', entityType);

const entityIdField = entityIdFields[entityType];

// Delete the memberships
await db
.delete(membershipsTable)
.where(and(eq(membershipsTable.userId, user.id), eq(membershipsTable.type, entityType), eq(membershipsTable[entityIdField], entity.id)));

sendSSEToUsers([user.id], 'remove_entity', { id: entity.id, entity: entity.entity });
logEvent('User leave entity', { user: user.id });

return ctx.json({ success: true }, 200);
})
/*
* Delete passkey of self
*/
Expand Down
27 changes: 26 additions & 1 deletion backend/src/modules/me/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { isAuthenticated } from '#/middlewares/guard';
import { errorResponses, successWithDataSchema, successWithErrorsSchema, successWithoutDataSchema } from '#/utils/schema/common-responses';
import { idsQuerySchema } from '#/utils/schema/common-schemas';
import { updateUserBodySchema, userSchema } from '../users/schema';
import { meUserSchema, signUpInfo, userMenuSchema } from './schema';
import { leaveEntityQuerySchema, meUserSchema, signUpInfo, userMenuSchema } from './schema';

class MeRoutesConfig {
public getSelf = createRouteConfig({
Expand Down Expand Up @@ -120,6 +120,31 @@ class MeRoutesConfig {
...errorResponses,
},
});

public leaveEntity = createRouteConfig({
method: 'delete',
path: '/leave',
guard: isAuthenticated,
tags: ['me'],
summary: 'Leave entity',
description: 'Leave any entity on your own.',
security: [],
request: {
query: leaveEntityQuerySchema,
},
responses: {
200: {
description: 'Passkey removed',
content: {
'application/json': {
schema: successWithoutDataSchema,
},
},
},
...errorResponses,
},
});

public deletePasskey = createRouteConfig({
method: 'delete',
path: '/passkey',
Expand Down
7 changes: 6 additions & 1 deletion backend/src/modules/me/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { z } from 'zod';

import { config } from 'config';
import { type MenuSectionName, menuSections } from '#/entity-config';
import { idSchema, imageUrlSchema, nameSchema, slugSchema } from '#/utils/schema/common-schemas';
import { contextEntityTypeSchema, idOrSlugSchema, idSchema, imageUrlSchema, nameSchema, slugSchema } from '#/utils/schema/common-schemas';
import { membershipInfoSchema } from '../memberships/schema';
import { userSchema } from '../users/schema';

Expand Down Expand Up @@ -63,3 +63,8 @@ export const userMenuSchema = z.object(
);

export type UserMenu = z.infer<typeof userMenuSchema>;

export const leaveEntityQuerySchema = z.object({
idOrSlug: idOrSlugSchema,
entityType: contextEntityTypeSchema,
});
6 changes: 3 additions & 3 deletions backend/src/modules/memberships/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,20 +225,20 @@ const membershipsRoutes = app

const errors: ErrorType[] = [];

const filters = and(eq(membershipsTable.type, entityType), or(eq(membershipsTable[entityIdField], entity.id)));
const filters = [eq(membershipsTable.type, entityType), eq(membershipsTable[entityIdField], entity.id)];

// Get user membership
const [currentUserMembership]: (MembershipModel | undefined)[] = await db
.select()
.from(membershipsTable)
.where(and(filters, eq(membershipsTable.userId, user.id)))
.where(and(...filters, eq(membershipsTable.userId, user.id)))
.limit(1);

// Get target memberships
const targets = await db
.select()
.from(membershipsTable)
.where(and(inArray(membershipsTable.userId, memberToDeleteIds), filters));
.where(and(inArray(membershipsTable.userId, memberToDeleteIds), ...filters));

// Check if membership exist
for (const id of memberToDeleteIds) {
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/api/general.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,5 +84,5 @@ export const acceptInvite = async ({ token, password, oauth }: AcceptInviteProps
});

const json = await handleResponse(response);
return json.success;
return json.data;
};
10 changes: 10 additions & 0 deletions frontend/src/api/me.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { config } from 'config';
import type { UpdateUserParams } from '~/api/users';
import type { ContextEntity } from '~/types/common';
import { meHc } from '#/modules/me/hc';
import { clientConfig, handleResponse } from '.';

Expand Down Expand Up @@ -54,3 +55,12 @@ export const deletePasskey = async () => {
const json = await handleResponse(response);
return json.success;
};
// Leave entity
export const leaveEntity = async (query: { idOrSlug: string; entityType: ContextEntity }) => {
const response = await client.leave.$delete({
query,
});

const json = await handleResponse(response);
return json.success;
};
4 changes: 3 additions & 1 deletion frontend/src/modules/common/accept-invite.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import Spinner from '~/modules/common/spinner';
import { SubmitButton, buttonVariants } from '~/modules/ui/button';
import { acceptInviteRoute } from '~/routes/general';
import { cn } from '~/utils/cn';
import { addMenuItem } from './nav-sheet/helpers/menu-operations';

type TokenData = z.infer<typeof checkTokenSchema>;

Expand All @@ -34,7 +35,8 @@ const AcceptInvite = () => {

const { mutate: acceptInvite, isPending } = useMutation({
mutationFn: baseAcceptInvite,
onSuccess: () => {
onSuccess: (data) => {
if (data) addMenuItem(data.newItem, data.sectionName);
toast.success(t('common:invitation_accepted'));
navigate({
to: tokenData?.organizationSlug ? `/${tokenData.organizationSlug}` : config.defaultRedirectPath,
Expand Down
33 changes: 14 additions & 19 deletions frontend/src/modules/organizations/leave-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,39 +4,34 @@ import { config } from 'config';
import { Check, UserRoundX } from 'lucide-react';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { leaveEntity } from '~/api/me';
import { useMutation } from '~/hooks/use-mutations';
import { showToast } from '~/lib/toasts';
import { useMembersDeleteMutation } from '~/modules/common/query-client-provider/mutations/members';
import { Button } from '~/modules/ui/button';
import { Command, CommandGroup, CommandItem, CommandList } from '~/modules/ui/command';
import { Popover, PopoverContent, PopoverTrigger } from '~/modules/ui/popover';
import { useUserStore } from '~/store/user';
import type { Organization } from '~/types/common';

const LeaveButton = ({ organization }: { organization: Organization }) => {
const { t } = useTranslation();
const { user } = useUserStore();
const navigate = useNavigate();
const [openPopover, setOpenPopover] = useState(false);

const { mutate: leave } = useMembersDeleteMutation();
const { mutate: leave } = useMutation({
mutationFn: () =>
leaveEntity({
idOrSlug: organization.slug,
entityType: 'organization',
}),
onSuccess: () => {
showToast(t('common:success.you_left_organization'), 'success');
navigate({ to: config.defaultRedirectPath, replace: true });
},
});

const onLeave = () => {
if (!onlineManager.isOnline()) return showToast(t('common:action.offline.text'), 'warning');

leave(
{
orgIdOrSlug: organization.id,
idOrSlug: organization.slug,
entityType: 'organization',
ids: [user.id],
},
{
onSuccess: () => {
showToast(t('common:success.you_left_organization'), 'success');
navigate({ to: config.defaultRedirectPath, replace: true });
},
},
);
leave();
};

return (
Expand Down
2 changes: 1 addition & 1 deletion locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,7 @@
"invite": "Invite",
"invite_by_email": "Invite with email addresses",
"invite_by_name": "Invite by searching for users",
"invite_create_account": " Sign up to accept invitation",
"invite_create_account": "Sign up to accept invitation",
"invite_members.text": "Invited members will receive an email to accept the invitation.",
"invite_members_search.text": "Search for users to invite within {{appName}}.",
"invite_only.text": "{{appName}} is currently invite-only. Can't wait? Contact us for an invite.",
Expand Down

0 comments on commit 7b6ff3e

Please sign in to comment.