0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-16 20:26:19 -05:00

Merge pull request #4755 from logto-io/gao-org-featured

feat: organization featured members
This commit is contained in:
Gao Sun 2023-10-25 21:11:57 -05:00 committed by GitHub
commit 9b1f0ced09
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 311 additions and 135 deletions

View file

@ -1,4 +1,4 @@
import { type Organization } from '@logto/schemas';
import { type OrganizationWithFeatured, RoleType } from '@logto/schemas';
import { joinPath } from '@silverhand/essentials';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
@ -11,6 +11,7 @@ import { defaultPageSize } from '@/consts';
import CopyToClipboard from '@/ds-components/CopyToClipboard';
import Table from '@/ds-components/Table';
import { type RequestError } from '@/hooks/use-api';
import AssignedEntities from '@/pages/Roles/components/AssignedEntities';
import { buildUrl } from '@/utils/url';
const pageSize = defaultPageSize;
@ -19,8 +20,9 @@ const apiPathname = 'api/organizations';
function OrganizationsTable() {
const [page, setPage] = useState(1);
const { data: response, error } = useSWR<[Organization[], number], RequestError>(
const { data: response, error } = useSWR<[OrganizationWithFeatured[], number], RequestError>(
buildUrl(apiPathname, {
showFeatured: '1',
page: String(page),
page_size: String(pageSize),
})
@ -53,7 +55,13 @@ function OrganizationsTable() {
{
title: t('organizations.members'),
dataIndex: 'members',
render: () => 'members', // TODO: render members
render: ({ usersCount, featuredUsers }) => (
<AssignedEntities
type={RoleType.User}
entities={featuredUsers ?? []}
count={usersCount ?? 0}
/>
),
},
]}
rowIndexKey="id"

View file

@ -163,14 +163,18 @@ export const createApplicationQueries = (pool: CommonQueryMethods) => {
`);
};
const findApplicationsByIds = async (applicationIds: string[]) =>
applicationIds.length > 0
? pool.any<Application>(sql`
select ${sql.join(Object.values(fields), sql`, `)}
from ${table}
where ${fields.id} in (${sql.join(applicationIds, sql`, `)})
`)
: [];
const findApplicationsByIds = async (
applicationIds: string[]
): Promise<readonly Application[]> => {
if (applicationIds.length === 0) {
return [];
}
return pool.any<Application>(sql`
select ${sql.join(Object.values(fields), sql`, `)}
from ${table}
where ${fields.id} in (${sql.join(applicationIds, sql`, `)})
`);
};
const deleteApplicationById = async (id: string) => {
const { rowCount } = await pool.query(sql`

View file

@ -15,6 +15,7 @@ import {
type OrganizationRoleWithScopes,
type OrganizationWithRoles,
type UserWithOrganizationRoles,
type FeaturedUser,
} from '@logto/schemas';
import { conditionalSql, convertToIdentifiers } from '@logto/shared';
import { sql, type CommonQueryMethods } from 'slonik';
@ -34,6 +35,35 @@ class UserRelationQueries extends TwoRelationsQueries<typeof Organizations, type
super(pool, OrganizationUserRelations.table, Organizations, Users);
}
async getFeatured(
organizationId: string
): Promise<[totalNumber: number, users: readonly FeaturedUser[]]> {
const users = convertToIdentifiers(Users, true);
const relations = convertToIdentifiers(OrganizationUserRelations, true);
const mainSql = sql`
from ${relations.table}
left join ${users.table}
on ${relations.fields.userId} = ${users.fields.id}
where ${relations.fields.organizationId} = ${organizationId}
`;
const [{ count }, data] = await Promise.all([
this.pool.one<{ count: string }>(sql`
select count(*)
${mainSql}
`),
this.pool.any<FeaturedUser>(sql`
select
${users.fields.id},
${users.fields.avatar},
${users.fields.name}
${mainSql}
limit 3
`),
]);
return [Number(count), data];
}
async getOrganizationsByUserId(userId: string): Promise<Readonly<OrganizationWithRoles[]>> {
const roles = convertToIdentifiers(OrganizationRoles, true);
const organizations = convertToIdentifiers(Organizations, true);
@ -62,7 +92,7 @@ class UserRelationQueries extends TwoRelationsQueries<typeof Organizations, type
organizationId: string,
{ limit, offset }: GetEntitiesOptions,
search?: SearchOptions<(typeof userSearchKeys)[number]>
): Promise<[totalCount: number, entities: Readonly<UserWithOrganizationRoles[]>]> {
): Promise<[totalNumber: number, entities: Readonly<UserWithOrganizationRoles[]>]> {
const roles = convertToIdentifiers(OrganizationRoles, true);
const users = convertToIdentifiers(Users, true);
const { fields } = convertToIdentifiers(OrganizationUserRelations, true);

View file

@ -1,5 +1,11 @@
import { OrganizationRoles, Organizations, userWithOrganizationRolesGuard } from '@logto/schemas';
import { type Optional, cond } from '@silverhand/essentials';
import {
OrganizationRoles,
type OrganizationWithFeatured,
Organizations,
featuredUserGuard,
userWithOrganizationRolesGuard,
} from '@logto/schemas';
import { type Optional, cond, yes } from '@silverhand/essentials';
import { z } from 'zod';
import { type SearchOptions } from '#src/database/utils.js';
@ -8,6 +14,7 @@ import koaGuard from '#src/middleware/koa-guard.js';
import koaPagination from '#src/middleware/koa-pagination.js';
import { userSearchKeys } from '#src/queries/user.js';
import SchemaRouter from '#src/utils/SchemaRouter.js';
import { parseSearchOptions } from '#src/utils/search.js';
import { type AuthedRouter, type RouterInitArgs } from '../types.js';
@ -22,11 +29,52 @@ export default function organizationRoutes<T extends AuthedRouter>(...args: Rout
queries: { organizations },
},
] = args;
const router = new SchemaRouter(Organizations, organizations, {
errorHandler,
searchFields: ['name'],
disabled: { get: true },
});
router.get(
'/',
koaPagination(),
koaGuard({
query: z.object({ q: z.string().optional(), showFeatured: z.string().optional() }),
response: (
Organizations.guard.merge(
// For `showFeatured` query
z
.object({
usersCount: z.number(),
featuredUsers: featuredUserGuard.array(),
})
.partial()
) satisfies z.ZodType<OrganizationWithFeatured>
).array(),
status: [200],
}),
async (ctx, next) => {
const { query } = ctx.guard;
const search = parseSearchOptions(['name'], query);
const { limit, offset } = ctx.pagination;
const [count, entities] = await organizations.findAll(limit, offset, search);
ctx.pagination.totalCount = count;
ctx.body = yes(query.showFeatured)
? await Promise.all(
entities.map(async (entity) => {
const [usersCount, featuredUsers] = await organizations.relations.users.getFeatured(
entity.id
);
return { ...entity, usersCount, featuredUsers };
})
)
: entities;
return next();
}
);
// MARK: Organization - user relation routes
router.addRelationRoutes(organizations.relations.users, undefined, { disabled: { get: true } });

View file

@ -101,8 +101,6 @@ describe('role routes', () => {
id: mockUser.id,
avatar: mockUser.avatar,
name: mockUser.name,
username: mockUser.username,
primaryEmail: mockUser.primaryEmail,
},
],
applicationsCount: 0,

View file

@ -1,7 +1,7 @@
import type { RoleResponse } from '@logto/schemas';
import { Applications, Roles, Users } from '@logto/schemas';
import { Roles, featuredApplicationGuard, featuredUserGuard } from '@logto/schemas';
import { generateStandardId } from '@logto/shared';
import { tryThat } from '@silverhand/essentials';
import { pickState, tryThat } from '@silverhand/essentials';
import { object, string, z, number } from 'zod';
import RequestError from '#src/errors/RequestError/index.js';
@ -53,20 +53,9 @@ export default function roleRoutes<T extends AuthedRouter>(...[router, tenant]:
.merge(
object({
usersCount: number(),
featuredUsers: Users.guard
.pick({
avatar: true,
id: true,
name: true,
})
.array(),
featuredUsers: featuredUserGuard.array(),
applicationsCount: number(),
featuredApplications: Applications.guard
.pick({
id: true,
name: true,
})
.array(),
featuredApplications: featuredApplicationGuard.array(),
})
)
.array(),
@ -113,15 +102,9 @@ export default function roleRoutes<T extends AuthedRouter>(...[router, tenant]:
return {
...role,
usersCount,
featuredUsers: users.map(({ id, avatar, name, username, primaryEmail }) => ({
id,
avatar,
name,
username,
primaryEmail,
})),
featuredUsers: users.map(pickState('id', 'avatar', 'name')),
applicationsCount,
featuredApplications: applications.map(({ id, name }) => ({ id, name })),
featuredApplications: applications.map(pickState('id', 'name', 'type')),
};
})
);

View file

@ -167,7 +167,7 @@ export default class RelationQueries<
* @param options Options for the query.
* @param options.limit The maximum number of entities to return.
* @param options.offset The number of entities to skip.
* @returns A Promise that resolves an array of entities of the given schema.
* @returns A Promise that resolves to the total number of entities and the entities.
*
* @example
* ```ts
@ -182,7 +182,7 @@ export default class RelationQueries<
forSchema: S,
where: CamelCaseIdObject<Exclude<Schemas[number]['tableSingular'], S['tableSingular']>>,
options?: GetEntitiesOptions
): Promise<[totalCount: number, entities: ReadonlyArray<InferSchema<S>>]> {
): Promise<[totalNumber: number, entities: ReadonlyArray<InferSchema<S>>]> {
const { limit, offset } = options ?? {};
const snakeCaseWhere = snakecaseKeys(where);
const forTable = sql.identifier([forSchema.table]);

View file

@ -1,6 +1,6 @@
import { type SchemaLike, type GeneratedSchema } from '@logto/schemas';
import { generateStandardId } from '@logto/shared';
import { cond, type Optional, type DeepPartial } from '@silverhand/essentials';
import { type DeepPartial } from '@silverhand/essentials';
import camelcase from 'camelcase';
import deepmerge from 'deepmerge';
import Router, { type IRouterParamContext } from 'koa-router';
@ -12,6 +12,7 @@ import koaPagination from '#src/middleware/koa-pagination.js';
import { type TwoRelationsQueries } from './RelationQueries.js';
import type SchemaQueries from './SchemaQueries.js';
import { parseSearchOptions } from './search.js';
/**
* Generate the pathname for from a table name.
@ -129,14 +130,7 @@ export default class SchemaRouter<
status: [200],
}),
async (ctx, next) => {
const { q } = ctx.guard.query;
const search: Optional<SearchOptions<Key>> = cond(
q &&
searchFields.length > 0 && {
fields: searchFields,
keyword: q,
}
);
const search = parseSearchOptions(searchFields, ctx.guard.query);
const { limit, offset } = ctx.pagination;
const [count, entities] = await queries.findAll(limit, offset, search);

View file

@ -1,9 +1,11 @@
import { SearchJointMode, SearchMatchMode } from '@logto/schemas';
import type { Nullable, Optional } from '@silverhand/essentials';
import { yes, conditionalString, conditional } from '@silverhand/essentials';
import { yes, conditionalString, conditional, cond } from '@silverhand/essentials';
import { sql } from 'slonik';
import { snakeCase } from 'snake-case';
import { type SearchOptions } from '#src/database/utils.js';
import assertThat from './assert-that.js';
import { isEnum } from './type.js';
@ -281,3 +283,27 @@ export const buildConditionsFromSearch = (
return sql.join(conditions, getJointModeSql(joint));
};
/**
* Parse the search query from the request query string and build the search options
* for certain search fields.
*
* @param searchFields Search fields to be included in the search options.
* @param guardedQuery The guarded query key-value object.
* @returns The search options object, or `undefined` if no search query is found.
*/
export const parseSearchOptions = <Key extends string>(
searchFields: readonly Key[],
guardedQuery: {
q?: string;
}
): Optional<SearchOptions<Key>> => {
const { q } = guardedQuery;
return cond(
q &&
searchFields.length > 0 && {
fields: searchFields,
keyword: q,
}
);
};

View file

@ -3,6 +3,7 @@ import {
type Organization,
type OrganizationWithRoles,
type UserWithOrganizationRoles,
type OrganizationWithFeatured,
} from '@logto/schemas';
import { authedAdminApi } from './api.js';
@ -22,6 +23,11 @@ export class OrganizationApi extends ApiFactory<
super('organizations');
}
override async getList(query?: URLSearchParams): Promise<OrganizationWithFeatured[]> {
// eslint-disable-next-line no-restricted-syntax -- This API has different response type
return super.getList(query) as Promise<OrganizationWithFeatured[]>;
}
async addUsers(id: string, userIds: string[]): Promise<void> {
await authedAdminApi.post(`${this.path}/${id}/users`, { json: { userIds } });
}

View file

@ -1,90 +1,127 @@
import { generateStandardId } from '@logto/shared';
import { HTTPError } from 'got';
import { OrganizationApiTest } from '#src/helpers/organization.js';
const randomId = () => generateStandardId(4);
import { UserApiTest } from '#src/helpers/user.js';
// Add additional layer of describe to run tests in band
describe('organization APIs', () => {
describe('organizations', () => {
const organizationApi = new OrganizationApiTest();
const organizationApi = new OrganizationApiTest();
const userApi = new UserApiTest();
afterEach(async () => {
await organizationApi.cleanUp();
afterEach(async () => {
await Promise.all([organizationApi.cleanUp(), userApi.cleanUp()]);
});
it('should get organizations successfully', async () => {
await organizationApi.create({ name: 'test', description: 'A test organization.' });
await organizationApi.create({ name: 'test2' });
const organizations = await organizationApi.getList();
expect(organizations).toContainEqual(
expect.objectContaining({ name: 'test', description: 'A test organization.' })
);
expect(organizations).toContainEqual(
expect.objectContaining({ name: 'test2', description: null })
);
for (const organization of organizations) {
expect(organization).not.toHaveProperty('usersCount');
expect(organization).not.toHaveProperty('featuredUsers');
}
});
it('should get organizations with featured users', async () => {
const [organization1, organization2] = await Promise.all([
organizationApi.create({ name: 'test' }),
organizationApi.create({ name: 'test' }),
]);
const createdUsers = await Promise.all(
Array.from({ length: 5 }).map(async () => userApi.create({ name: 'featured' }))
);
await organizationApi.addUsers(
organization1.id,
createdUsers.map((user) => user.id)
);
const organizations = await organizationApi.getList(
new URLSearchParams({
showFeatured: '1',
})
);
expect(organizations).toContainEqual(expect.objectContaining({ id: organization1.id }));
expect(organizations).toContainEqual(expect.objectContaining({ id: organization2.id }));
for (const organization of organizations) {
expect(organization).toHaveProperty('usersCount');
expect(organization).toHaveProperty('featuredUsers');
if (organization.id === organization1.id) {
expect(organization.usersCount).toBe(5);
expect(organization.featuredUsers).toHaveLength(3);
}
if (organization.id === organization2.id) {
expect(organization.usersCount).toBe(0);
expect(organization.featuredUsers).toHaveLength(0);
}
}
});
it('should get organizations with pagination', async () => {
// Add organizations to exceed the default page size
await Promise.all(
Array.from({ length: 30 }).map(async () => organizationApi.create({ name: 'test' }))
);
const organizations = await organizationApi.getList();
expect(organizations).toHaveLength(20);
const organizations2 = await organizationApi.getList(
new URLSearchParams({
page: '2',
page_size: '10',
})
);
expect(organizations2.length).toBeGreaterThanOrEqual(10);
expect(organizations2[0]?.id).not.toBeFalsy();
expect(organizations2[0]?.id).toBe(organizations[10]?.id);
});
it('should be able to create and get organizations by id', async () => {
const createdOrganization = await organizationApi.create({ name: 'test' });
const organization = await organizationApi.get(createdOrganization.id);
expect(organization).toStrictEqual(createdOrganization);
});
it('should fail when try to get an organization that does not exist', async () => {
const response = await organizationApi.get('0').catch((error: unknown) => error);
expect(response instanceof HTTPError && response.response.statusCode).toBe(404);
});
it('should be able to update organization', async () => {
const createdOrganization = await organizationApi.create({ name: 'test' });
const organization = await organizationApi.update(createdOrganization.id, {
name: 'test2',
description: 'test description.',
});
it('should get organizations successfully', async () => {
await organizationApi.create({ name: 'test', description: 'A test organization.' });
await organizationApi.create({ name: 'test2' });
const organizations = await organizationApi.getList();
expect(organizations).toContainEqual(
expect.objectContaining({ name: 'test', description: 'A test organization.' })
);
expect(organizations).toContainEqual(
expect.objectContaining({ name: 'test2', description: null })
);
});
it('should get organizations with pagination', async () => {
// Add organizations to exceed the default page size
await Promise.all(
Array.from({ length: 30 }).map(async () => organizationApi.create({ name: 'test' }))
);
const organizations = await organizationApi.getList();
expect(organizations).toHaveLength(20);
const organizations2 = await organizationApi.getList(
new URLSearchParams({
page: '2',
page_size: '10',
})
);
expect(organizations2.length).toBeGreaterThanOrEqual(10);
expect(organizations2[0]?.id).not.toBeFalsy();
expect(organizations2[0]?.id).toBe(organizations[10]?.id);
});
it('should be able to create and get organizations by id', async () => {
const createdOrganization = await organizationApi.create({ name: 'test' });
const organization = await organizationApi.get(createdOrganization.id);
expect(organization).toStrictEqual(createdOrganization);
});
it('should fail when try to get an organization that does not exist', async () => {
const response = await organizationApi.get('0').catch((error: unknown) => error);
expect(response instanceof HTTPError && response.response.statusCode).toBe(404);
});
it('should be able to update organization', async () => {
const createdOrganization = await organizationApi.create({ name: 'test' });
const organization = await organizationApi.update(createdOrganization.id, {
name: 'test2',
description: 'test description.',
});
expect(organization).toStrictEqual({
...createdOrganization,
name: 'test2',
description: 'test description.',
});
});
it('should be able to delete organization', async () => {
const createdOrganization = await organizationApi.create({ name: 'test' });
await organizationApi.delete(createdOrganization.id);
const response = await organizationApi
.get(createdOrganization.id)
.catch((error: unknown) => error);
expect(response instanceof HTTPError && response.response.statusCode).toBe(404);
});
it('should fail when try to delete an organization that does not exist', async () => {
const response = await organizationApi.delete('0').catch((error: unknown) => error);
expect(response instanceof HTTPError && response.response.statusCode).toBe(404);
expect(organization).toStrictEqual({
...createdOrganization,
name: 'test2',
description: 'test description.',
});
});
it('should be able to delete organization', async () => {
const createdOrganization = await organizationApi.create({ name: 'test' });
await organizationApi.delete(createdOrganization.id);
const response = await organizationApi
.get(createdOrganization.id)
.catch((error: unknown) => error);
expect(response instanceof HTTPError && response.response.statusCode).toBe(404);
});
it('should fail when try to delete an organization that does not exist', async () => {
const response = await organizationApi.delete('0').catch((error: unknown) => error);
expect(response instanceof HTTPError && response.response.statusCode).toBe(404);
});
});

View file

@ -1,3 +1,18 @@
import type { Application } from '../db-entries/index.js';
import { type z } from 'zod';
import { Applications, type Application } from '../db-entries/index.js';
export type ApplicationResponse = Application & { isAdmin: boolean };
/**
* An application that is featured for display. Usually used in a list of resources that are
* related to a group of applications.
*/
export type FeaturedApplication = Pick<Application, 'id' | 'name' | 'type'>;
/** The guard for {@link FeaturedApplication}. */
export const featuredApplicationGuard = Applications.guard.pick({
id: true,
name: true,
type: true,
}) satisfies z.ZodType<FeaturedApplication>;

View file

@ -9,6 +9,8 @@ import {
Users,
} from '../db-entries/index.js';
import { type FeaturedUser } from './user.js';
export type OrganizationRoleWithScopes = OrganizationRole & {
scopes: Array<{
id: string;
@ -67,3 +69,12 @@ export const userWithOrganizationRolesGuard: z.ZodType<UserWithOrganizationRoles
Users.guard.extend({
organizationRoles: organizationRoleEntityGuard.array(),
});
/**
* The organization entity with optional `usersCount` and `featuredUsers` fields.
* They are useful for displaying the organization list in the frontend.
*/
export type OrganizationWithFeatured = Organization & {
usersCount?: number;
featuredUsers?: FeaturedUser[];
};

View file

@ -1,8 +1,11 @@
import type { Application, Role, User } from '../db-entries/index.js';
import type { Role } from '../db-entries/index.js';
import { type FeaturedApplication } from './application.js';
import { type FeaturedUser } from './user.js';
export type RoleResponse = Role & {
usersCount: number;
featuredUsers: Array<Pick<User, 'avatar' | 'id' | 'name' | 'username' | 'primaryEmail'>>;
featuredUsers: FeaturedUser[];
applicationsCount: number;
featuredApplications: Array<Pick<Application, 'id' | 'name'>>;
featuredApplications: FeaturedApplication[];
};

View file

@ -1,6 +1,6 @@
import { z } from 'zod';
import { Users } from '../db-entries/index.js';
import { type User, Users } from '../db-entries/index.js';
import { MfaFactor } from '../foundations/index.js';
export const userInfoSelectFields = Object.freeze([
@ -63,3 +63,16 @@ export enum AdminTenantRole {
export enum PredefinedScope {
All = 'all',
}
/**
* A user that is featured for display. Usually used in a list of resources that are related to
* a group of users.
*/
export type FeaturedUser = Pick<User, 'id' | 'avatar' | 'name'>;
/** The guard for {@link FeaturedUser}. */
export const featuredUserGuard = Users.guard.pick({
id: true,
avatar: true,
name: true,
}) satisfies z.ZodType<FeaturedUser>;