Skip to content

Commit

Permalink
feat: add user organization domain/route logic (#2327)
Browse files Browse the repository at this point in the history
Adds new `UserOrganizations` repository, with relevant methods for sending, accepting and declining invites and updating/removing users has been added to the domain layer. Respective routes for each have been included in a new `UserOrganizationsController`:

- Add `IUserOrganizationsRepository` with implementation
- Add `UserOrganizationController` with routes for inviting, accepting and declining user invites, modifying roles and removing users
- Add `UserOrganizationService` to route repository to controller
- Associated entity updates
- Add appropriate test coverage
  • Loading branch information
iamacook authored Feb 13, 2025
1 parent 8cb2b5e commit 349113c
Show file tree
Hide file tree
Showing 27 changed files with 2,619 additions and 34 deletions.
6 changes: 4 additions & 2 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ import {
} from '@/logging/logging.interface';
import { UsersModule } from '@/routes/users/users.module';
import { OrganizationsModule } from '@/routes/organizations/organizations.module';
import { UserOrganizationsModule } from '@/routes/organizations/user-organizations.module';

@Module({})
export class AppModule implements NestModule {
Expand Down Expand Up @@ -101,15 +102,16 @@ export class AppModule implements NestModule {
: [HooksModule]),
MessagesModule,
NotificationsModule,
...(isUsersFeatureEnabled ? [OrganizationsModule] : []),
...(isUsersFeatureEnabled
? [UsersModule, OrganizationsModule, UserOrganizationsModule]
: []),
OwnersModule,
RelayControllerModule,
RootModule,
SafeAppsModule,
SafesModule,
TargetedMessagingModule,
TransactionsModule,
...(isUsersFeatureEnabled ? [UsersModule] : []),
// common
CacheModule,
// Module for storing and reading from the async local storage
Expand Down
3 changes: 3 additions & 0 deletions src/config/entities/__tests__/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -284,4 +284,7 @@ export default (): ReturnType<typeof configuration> => ({
},
},
},
users: {
maxInvites: faker.number.int({ min: 5, max: 10 }),
},
});
3 changes: 3 additions & 0 deletions src/config/entities/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -431,4 +431,7 @@ export default () => ({
},
},
},
users: {
maxInvites: 50,
},
});
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Organization } from '@/datasources/organizations/entities/organizations
import { User } from '@/datasources/users/entities/users.entity.db';
import { databaseEnumTransformer } from '@/domain/common/utils/enum';
import {
UserOrganization as DomainUserOrganization,
UserOrganizationRole,
UserOrganizationStatus,
} from '@/domain/users/entities/user-organization.entity';
Expand All @@ -19,7 +20,7 @@ import {
@Unique('UQ_user_organizations', ['user', 'organization'])
@Index('idx_UO_name', ['name'])
@Index('idx_UO_role_status', ['role', 'status'])
export class UserOrganization {
export class UserOrganization implements DomainUserOrganization {
@PrimaryGeneratedColumn({
primaryKeyConstraintName: 'PK_UO_id',
})
Expand Down
8 changes: 5 additions & 3 deletions src/datasources/users/entities/users.entity.db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,22 +32,24 @@ export class User implements DomainUser {
wallets!: Array<Wallet>;

@Column({
name: 'created_at',
type: 'timestamp with time zone',
default: () => 'CURRENT_TIMESTAMP',
update: false,
})
created_at!: Date;
createdAt!: Date;

@Column({
name: 'updated_at',
type: 'timestamp with time zone',
default: () => 'CURRENT_TIMESTAMP',
update: false,
})
updated_at!: Date;
updatedAt!: Date;

@OneToMany(
() => UserOrganization,
(userOrganization: UserOrganization) => userOrganization.user,
)
user_organizations!: Array<UserOrganization>;
userOrganizations!: Array<UserOrganization>;
}
6 changes: 4 additions & 2 deletions src/datasources/wallets/entities/wallets.entity.db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,16 +42,18 @@ export class Wallet implements z.infer<typeof WalletSchema> {
address!: `0x${string}`;

@Column({
name: 'created_at',
type: 'timestamp with time zone',
default: () => 'CURRENT_TIMESTAMP',
update: false,
})
created_at!: Date;
createdAt!: Date;

@Column({
name: 'updated_at',
type: 'timestamp with time zone',
default: () => 'CURRENT_TIMESTAMP',
update: false,
})
updated_at!: Date;
updatedAt!: Date;
}
61 changes: 55 additions & 6 deletions src/domain/common/utils/enum.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,71 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import type { ValueTransformer } from 'typeorm';

export function getEnumKey<
T extends { [key: string]: number | string; [key: number]: string },
>(enumObj: T, value: number): keyof T {
/**
* Note: numeric enums have reverse mappings so we can't interact with them
* as we would normally do an object on both a type and runtime level.
* Object.keys will return all keys/values, as will keyof.
*/

type NumericEnum = { [key: string]: number | string; [key: number]: string };

export function getEnumKey<T extends NumericEnum>(
enumObj: T,
value: number,
): keyof T {
const key = enumObj[value];
if (typeof key !== 'string') {
throw new Error(`Invalid enum value: ${value}`);
}
return key;
}

export const databaseEnumTransformer = <
T extends { [key: string]: number | string; [key: number]: string },
>(
export const databaseEnumTransformer = <T extends NumericEnum>(
enumObj: T,
): ValueTransformer => {
return {
to: (value: keyof typeof enumObj) => enumObj[value],
from: (value: number): keyof T => getEnumKey(enumObj, value),
};
};

/**
* The following is a workaround to get only the string keys of a numeric enum
* as a tuple, as `keyof` will return all keys/values due to reverse mapping.
*
* Returned as a tuple, it is useful for zod enum validation, e.g. z.enum([...])
*
* @param enumObj - numeric enum object
* @returns string keys as a strictly typed tuple
*/
export function getStringEnumKeys<T extends NumericEnum>(
enumObj: T,
): NumericEnumKeysTuple<T> {
return Object.keys(enumObj).filter((key) =>
isNaN(Number(key)),
) as NumericEnumKeysTuple<T>;
}

type NumericEnumKeysTuple<T> = UnionToTuple<ExcludeNumberKeys<T>>;

type ExcludeNumberKeys<T> = Exclude<keyof T, `${number}`>;

// Recursively removes last element of union and pushes it to tuple
type UnionToTuple<T, R extends Array<any> = []> = [T] extends [never]
? R
: UnionToTuple<Exclude<T, LastOf<T>>, [LastOf<T>, ...R]>;

// Gets the last element of a union
type LastOf<T> =
UnionToIntersection<T extends any ? (x: T) => 0 : never> extends (
x: infer Last,
) => 0
? Last
: never;

// Converts a union to an intersection of functions
type UnionToIntersection<U> = (U extends any ? (x: U) => any : never) extends (
x: infer I,
) => any
? I
: never;
19 changes: 14 additions & 5 deletions src/domain/organizations/entities/organization.entity.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,24 @@
import { z } from 'zod';
import { RowSchema } from '@/datasources/db/v2/entities/row.entity';
import { UserOrganizationSchema } from '@/domain/users/entities/user-organization.entity';
import { getStringEnumKeys } from '@/domain/common/utils/enum';
import type { UserOrganization } from '@/domain/users/entities/user-organization.entity';

export enum OrganizationStatus {
ACTIVE = 1,
}
export const OrganizationStatusKeys = Object.keys(OrganizationStatus) as [
keyof typeof OrganizationStatus,
];

export type Organization = z.infer<typeof OrganizationSchema>;

export const OrganizationSchema = RowSchema.extend({
status: z.enum(OrganizationStatusKeys),
// We need explicitly define ZodType due to recursion
export const OrganizationSchema: z.ZodType<
z.infer<typeof RowSchema> & {
name: string;
status: keyof typeof OrganizationStatus;
userOrganizations: Array<UserOrganization>;
}
> = RowSchema.extend({
name: z.string().max(255),
status: z.enum(getStringEnumKeys(OrganizationStatus)),
userOrganizations: z.array(z.lazy(() => UserOrganizationSchema)),
});
10 changes: 10 additions & 0 deletions src/domain/users/entities/invitation.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import type { Organization } from '@/domain/organizations/entities/organization.entity';
import type { UserOrganization } from '@/domain/users/entities/user-organization.entity';
import type { User } from '@/domain/users/entities/user.entity';

export type Invitation = {
userId: User['id'];
orgId: Organization['id'];
role: UserOrganization['role'];
status: UserOrganization['status'];
};
30 changes: 29 additions & 1 deletion src/domain/users/entities/user-organization.entity.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,37 @@
import { z } from 'zod';
import { RowSchema } from '@/datasources/db/v2/entities/row.entity';
import { OrganizationSchema } from '@/domain/organizations/entities/organization.entity';
import { UserSchema } from '@/domain/users/entities/user.entity';
import { getStringEnumKeys } from '@/domain/common/utils/enum';
import type { Organization } from '@/domain/organizations/entities/organization.entity';
import type { User } from '@/domain/users/entities/user.entity';

export enum UserOrganizationRole {
ADMIN = 1,
MEMBER = 2,
}

export enum UserOrganizationStatus {
PENDING = 0,
INVITED = 0,
ACTIVE = 1,
DECLINED = 2,
}

// We need explicitly define ZodType due to recursion
export const UserOrganizationSchema: z.ZodType<
z.infer<typeof RowSchema> & {
user: User;
organization: Organization;
name: string | null;
role: keyof typeof UserOrganizationRole;
status: keyof typeof UserOrganizationStatus;
}
> = RowSchema.extend({
user: z.lazy(() => UserSchema),
organization: z.lazy(() => OrganizationSchema),
name: z.string().nullable(),
role: z.enum(getStringEnumKeys(UserOrganizationRole)),
status: z.enum(getStringEnumKeys(UserOrganizationStatus)),
});

export type UserOrganization = z.infer<typeof UserOrganizationSchema>;
24 changes: 18 additions & 6 deletions src/domain/users/entities/user.entity.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,27 @@
import { z } from 'zod';
import { RowSchema } from '@/datasources/db/v1/entities/row.entity';
import { RowSchema } from '@/datasources/db/v2/entities/row.entity';
import { WalletSchema } from '@/domain/wallets/entities/wallet.entity';
import { UserOrganizationSchema } from '@/domain/users/entities/user-organization.entity';
import { getStringEnumKeys } from '@/domain/common/utils/enum';
import type { Wallet } from '@/domain/wallets/entities/wallet.entity';
import type { UserOrganization } from '@/domain/users/entities/user-organization.entity';

export enum UserStatus {
PENDING = 0,
ACTIVE = 1,
}
export const UserStatusKeys = Object.keys(UserStatus) as [
keyof typeof UserStatus,
];

export type User = z.infer<typeof UserSchema>;

export const UserSchema = RowSchema.extend({
status: z.enum(UserStatusKeys),
// We need explicitly define ZodType due to recursion
export const UserSchema: z.ZodType<
z.infer<typeof RowSchema> & {
status: keyof typeof UserStatus;
wallets: Array<Wallet>;
userOrganizations: Array<UserOrganization>;
}
> = RowSchema.extend({
status: z.enum(getStringEnumKeys(UserStatus)),
wallets: z.array(WalletSchema),
userOrganizations: z.array(z.lazy(() => UserOrganizationSchema)),
});
76 changes: 76 additions & 0 deletions src/domain/users/user-organizations.repository.interface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import type { UserOrganization as DbUserOrganization } from '@/datasources/users/entities/user-organizations.entity.db';
import type { AuthPayload } from '@/domain/auth/entities/auth-payload.entity';
import type { Organization } from '@/domain/organizations/entities/organization.entity';
import type { Invitation } from '@/domain/users/entities/invitation.entity';
import type { UserOrganization } from '@/domain/users/entities/user-organization.entity';
import type { User } from '@/domain/users/entities/user.entity';
import type {
FindOptionsWhere,
FindOptionsRelations,
FindManyOptions,
} from 'typeorm';

export const IUsersOrganizationsRepository = Symbol(
'IUsersOrganizationsRepository',
);

export interface IUsersOrganizationsRepository {
findOneOrFail(
where:
| Array<FindOptionsWhere<UserOrganization>>
| FindOptionsWhere<UserOrganization>,
relations?: FindOptionsRelations<UserOrganization>,
): Promise<DbUserOrganization>;

findOne(
where:
| Array<FindOptionsWhere<UserOrganization>>
| FindOptionsWhere<UserOrganization>,
relations?: FindOptionsRelations<UserOrganization>,
): Promise<DbUserOrganization | null>;

findOrFail(
args?: FindManyOptions<DbUserOrganization>,
): Promise<[DbUserOrganization, ...Array<DbUserOrganization>]>;

find(
args?: FindManyOptions<DbUserOrganization>,
): Promise<Array<DbUserOrganization>>;

inviteUsers(args: {
authPayload: AuthPayload;
orgId: Organization['id'];
users: Array<{
address: `0x${string}`;
role: UserOrganization['role'];
}>;
}): Promise<Array<Invitation>>;

acceptInvite(args: {
authPayload: AuthPayload;
orgId: Organization['id'];
}): Promise<void>;

declineInvite(args: {
authPayload: AuthPayload;
orgId: Organization['id'];
}): Promise<void>;

findAuthorizedUserOrgsOrFail(args: {
authPayload: AuthPayload;
orgId: Organization['id'];
}): Promise<Array<UserOrganization>>;

updateRole(args: {
authPayload: AuthPayload;
orgId: Organization['id'];
userId: User['id'];
role: UserOrganization['role'];
}): Promise<void>;

removeUser(args: {
authPayload: AuthPayload;
orgId: Organization['id'];
userId: User['id'];
}): Promise<void>;
}
Loading

0 comments on commit 349113c

Please sign in to comment.