diff --git a/packages/console/src/pages/Organizations/OrganizationsTable/index.tsx b/packages/console/src/pages/Organizations/OrganizationsTable/index.tsx
index 631774a87..1ce48e317 100644
--- a/packages/console/src/pages/Organizations/OrganizationsTable/index.tsx
+++ b/packages/console/src/pages/Organizations/OrganizationsTable/index.tsx
@@ -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 }) => (
+
+ ),
},
]}
rowIndexKey="id"
diff --git a/packages/core/src/queries/application.ts b/packages/core/src/queries/application.ts
index e6c0fd636..6f39f4337 100644
--- a/packages/core/src/queries/application.ts
+++ b/packages/core/src/queries/application.ts
@@ -163,14 +163,18 @@ export const createApplicationQueries = (pool: CommonQueryMethods) => {
`);
};
- const findApplicationsByIds = async (applicationIds: string[]) =>
- applicationIds.length > 0
- ? pool.any(sql`
- select ${sql.join(Object.values(fields), sql`, `)}
- from ${table}
- where ${fields.id} in (${sql.join(applicationIds, sql`, `)})
- `)
- : [];
+ const findApplicationsByIds = async (
+ applicationIds: string[]
+ ): Promise => {
+ if (applicationIds.length === 0) {
+ return [];
+ }
+ return pool.any(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`
diff --git a/packages/core/src/queries/organizations.ts b/packages/core/src/queries/organizations.ts
index 60e68d1fc..6bdc04f58 100644
--- a/packages/core/src/queries/organizations.ts
+++ b/packages/core/src/queries/organizations.ts
@@ -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 {
+ 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(sql`
+ select
+ ${users.fields.id},
+ ${users.fields.avatar},
+ ${users.fields.name}
+ ${mainSql}
+ limit 3
+ `),
+ ]);
+
+ return [Number(count), data];
+ }
+
async getOrganizationsByUserId(userId: string): Promise> {
const roles = convertToIdentifiers(OrganizationRoles, true);
const organizations = convertToIdentifiers(Organizations, true);
@@ -62,7 +92,7 @@ class UserRelationQueries extends TwoRelationsQueries
- ): Promise<[totalCount: number, entities: Readonly]> {
+ ): Promise<[totalNumber: number, entities: Readonly]> {
const roles = convertToIdentifiers(OrganizationRoles, true);
const users = convertToIdentifiers(Users, true);
const { fields } = convertToIdentifiers(OrganizationUserRelations, true);
diff --git a/packages/core/src/routes/organization/index.ts b/packages/core/src/routes/organization/index.ts
index eb711c419..8f823f980 100644
--- a/packages/core/src/routes/organization/index.ts
+++ b/packages/core/src/routes/organization/index.ts
@@ -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(...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
+ ).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 } });
diff --git a/packages/core/src/routes/role.test.ts b/packages/core/src/routes/role.test.ts
index 7e8e0641a..a19395dbc 100644
--- a/packages/core/src/routes/role.test.ts
+++ b/packages/core/src/routes/role.test.ts
@@ -101,8 +101,6 @@ describe('role routes', () => {
id: mockUser.id,
avatar: mockUser.avatar,
name: mockUser.name,
- username: mockUser.username,
- primaryEmail: mockUser.primaryEmail,
},
],
applicationsCount: 0,
diff --git a/packages/core/src/routes/role.ts b/packages/core/src/routes/role.ts
index d98482aa7..44f6e8cac 100644
--- a/packages/core/src/routes/role.ts
+++ b/packages/core/src/routes/role.ts
@@ -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(...[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(...[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')),
};
})
);
diff --git a/packages/core/src/utils/RelationQueries.ts b/packages/core/src/utils/RelationQueries.ts
index 2810d0c7b..868b3099b 100644
--- a/packages/core/src/utils/RelationQueries.ts
+++ b/packages/core/src/utils/RelationQueries.ts
@@ -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>,
options?: GetEntitiesOptions
- ): Promise<[totalCount: number, entities: ReadonlyArray>]> {
+ ): Promise<[totalNumber: number, entities: ReadonlyArray>]> {
const { limit, offset } = options ?? {};
const snakeCaseWhere = snakecaseKeys(where);
const forTable = sql.identifier([forSchema.table]);
diff --git a/packages/core/src/utils/SchemaRouter.ts b/packages/core/src/utils/SchemaRouter.ts
index 84bd32c24..4ed5bee46 100644
--- a/packages/core/src/utils/SchemaRouter.ts
+++ b/packages/core/src/utils/SchemaRouter.ts
@@ -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> = 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);
diff --git a/packages/core/src/utils/search.ts b/packages/core/src/utils/search.ts
index 524558329..9fe693f16 100644
--- a/packages/core/src/utils/search.ts
+++ b/packages/core/src/utils/search.ts
@@ -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 = (
+ searchFields: readonly Key[],
+ guardedQuery: {
+ q?: string;
+ }
+): Optional> => {
+ const { q } = guardedQuery;
+ return cond(
+ q &&
+ searchFields.length > 0 && {
+ fields: searchFields,
+ keyword: q,
+ }
+ );
+};
diff --git a/packages/integration-tests/src/api/organization.ts b/packages/integration-tests/src/api/organization.ts
index dd7adfd19..5adc40d59 100644
--- a/packages/integration-tests/src/api/organization.ts
+++ b/packages/integration-tests/src/api/organization.ts
@@ -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 {
+ // eslint-disable-next-line no-restricted-syntax -- This API has different response type
+ return super.getList(query) as Promise;
+ }
+
async addUsers(id: string, userIds: string[]): Promise {
await authedAdminApi.post(`${this.path}/${id}/users`, { json: { userIds } });
}
diff --git a/packages/integration-tests/src/tests/api/organization.test.ts b/packages/integration-tests/src/tests/api/organization.test.ts
index 8fc468bb8..107b8c110 100644
--- a/packages/integration-tests/src/tests/api/organization.test.ts
+++ b/packages/integration-tests/src/tests/api/organization.test.ts
@@ -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);
+ });
});
diff --git a/packages/schemas/src/types/application.ts b/packages/schemas/src/types/application.ts
index d81926093..8095bd226 100644
--- a/packages/schemas/src/types/application.ts
+++ b/packages/schemas/src/types/application.ts
@@ -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;
+
+/** The guard for {@link FeaturedApplication}. */
+export const featuredApplicationGuard = Applications.guard.pick({
+ id: true,
+ name: true,
+ type: true,
+}) satisfies z.ZodType;
diff --git a/packages/schemas/src/types/organization.ts b/packages/schemas/src/types/organization.ts
index 0f015063b..e59d1cf3a 100644
--- a/packages/schemas/src/types/organization.ts
+++ b/packages/schemas/src/types/organization.ts
@@ -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>;
+ featuredUsers: FeaturedUser[];
applicationsCount: number;
- featuredApplications: Array>;
+ featuredApplications: FeaturedApplication[];
};
diff --git a/packages/schemas/src/types/user.ts b/packages/schemas/src/types/user.ts
index 57ae22221..24a28b65b 100644
--- a/packages/schemas/src/types/user.ts
+++ b/packages/schemas/src/types/user.ts
@@ -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;
+
+/** The guard for {@link FeaturedUser}. */
+export const featuredUserGuard = Users.guard.pick({
+ id: true,
+ avatar: true,
+ name: true,
+}) satisfies z.ZodType;