mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-02-16 10:42:08 +00:00
Feat: add user header with follow info
This commit is contained in:
@@ -19,7 +19,7 @@ const UserAvatar: FC<UserAvatarProps> = ({ user }) => {
|
|||||||
alt="user avatar"
|
alt="user avatar"
|
||||||
width={1000}
|
width={1000}
|
||||||
height={1000}
|
height={1000}
|
||||||
className="h-full w-full"
|
className="h-full w-full object-cover mask mask-circle ring ring-primary ring-offset-base-100 ring-offset-2"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ const CommentCardBody: FC<CommentCardProps> = ({
|
|||||||
return (
|
return (
|
||||||
<div ref={ref} className="flex">
|
<div ref={ref} className="flex">
|
||||||
<div className="w-[12%] py-4 justify-center">
|
<div className="w-[12%] py-4 justify-center">
|
||||||
<div className="px-1 mask mask-circle">
|
<div className="px-1">
|
||||||
<UserAvatar user={comment.postedBy} />
|
<UserAvatar user={comment.postedBy} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import FollowInfoSchema from '@/services/UserFollows/schema/FollowInfoSchema';
|
||||||
|
import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema';
|
||||||
|
import useSWRInfinite from 'swr/infinite';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const useGetUsersFollowingUser = ({
|
||||||
|
pageSize,
|
||||||
|
userId,
|
||||||
|
}: {
|
||||||
|
pageSize: number;
|
||||||
|
userId: string;
|
||||||
|
}) => {
|
||||||
|
const fetcher = async (url: string) => {
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(response.statusText);
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = await response.json();
|
||||||
|
const count = response.headers.get('X-Total-Count');
|
||||||
|
|
||||||
|
const parsed = APIResponseValidationSchema.safeParse(json);
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw new Error('API response validation failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedPayload = z.array(FollowInfoSchema).safeParse(parsed.data.payload);
|
||||||
|
if (!parsedPayload.success) {
|
||||||
|
throw new Error('API response validation failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageCount = Math.ceil(parseInt(count as string, 10) / pageSize);
|
||||||
|
|
||||||
|
return { following: parsedPayload.data, pageCount, followingCount: count };
|
||||||
|
};
|
||||||
|
|
||||||
|
const { data, error, isLoading, setSize, size } = useSWRInfinite(
|
||||||
|
(index) =>
|
||||||
|
`/api/users/${userId}/following?page_num=${index + 1}&page_size=${pageSize}`,
|
||||||
|
fetcher,
|
||||||
|
{ parallel: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
const following = data?.flatMap((d) => d.following) ?? [];
|
||||||
|
const followingCount = data?.[0].followingCount ?? 0;
|
||||||
|
|
||||||
|
const pageCount = data?.[0].pageCount ?? 0;
|
||||||
|
const isLoadingMore = size > 0 && data && typeof data[size - 1] === 'undefined';
|
||||||
|
const isAtEnd = !(size < data?.[0].pageCount!);
|
||||||
|
|
||||||
|
return {
|
||||||
|
following,
|
||||||
|
followingCount,
|
||||||
|
pageCount,
|
||||||
|
size,
|
||||||
|
setSize,
|
||||||
|
isLoading,
|
||||||
|
isLoadingMore,
|
||||||
|
isAtEnd,
|
||||||
|
error: error as unknown,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useGetUsersFollowingUser;
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import FollowInfoSchema from '@/services/UserFollows/schema/FollowInfoSchema';
|
||||||
|
import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema';
|
||||||
|
import useSWRInfinite from 'swr/infinite';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const useGetUsersFollowingUser = ({
|
||||||
|
pageSize,
|
||||||
|
userId,
|
||||||
|
}: {
|
||||||
|
pageSize: number;
|
||||||
|
userId: string;
|
||||||
|
}) => {
|
||||||
|
const fetcher = async (url: string) => {
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(response.statusText);
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = await response.json();
|
||||||
|
const count = response.headers.get('X-Total-Count');
|
||||||
|
|
||||||
|
const parsed = APIResponseValidationSchema.safeParse(json);
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw new Error('API response validation failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedPayload = z.array(FollowInfoSchema).safeParse(parsed.data.payload);
|
||||||
|
if (!parsedPayload.success) {
|
||||||
|
throw new Error('API response validation failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageCount = Math.ceil(parseInt(count as string, 10) / pageSize);
|
||||||
|
|
||||||
|
return { followers: parsedPayload.data, pageCount, followerCount: count };
|
||||||
|
};
|
||||||
|
|
||||||
|
const { data, error, isLoading, setSize, size } = useSWRInfinite(
|
||||||
|
(index) =>
|
||||||
|
`/api/users/${userId}/followers?page_num=${index + 1}&page_size=${pageSize}`,
|
||||||
|
fetcher,
|
||||||
|
{ parallel: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
const followers = data?.flatMap((d) => d.followers) ?? [];
|
||||||
|
const followerCount = data?.[0].followerCount ?? 0;
|
||||||
|
|
||||||
|
const pageCount = data?.[0].pageCount ?? 0;
|
||||||
|
const isLoadingMore = size > 0 && data && typeof data[size - 1] === 'undefined';
|
||||||
|
const isAtEnd = !(size < data?.[0].pageCount!);
|
||||||
|
|
||||||
|
return {
|
||||||
|
followers,
|
||||||
|
followerCount,
|
||||||
|
pageCount,
|
||||||
|
size,
|
||||||
|
setSize,
|
||||||
|
isLoading,
|
||||||
|
isLoadingMore,
|
||||||
|
isAtEnd,
|
||||||
|
error: error as unknown,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useGetUsersFollowingUser;
|
||||||
70
src/pages/api/users/[id]/followers.ts
Normal file
70
src/pages/api/users/[id]/followers.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { UserExtendedNextApiRequest } from '@/config/auth/types';
|
||||||
|
import NextConnectOptions from '@/config/nextConnect/NextConnectOptions';
|
||||||
|
import validateRequest from '@/config/nextConnect/middleware/validateRequest';
|
||||||
|
import ServerError from '@/config/util/ServerError';
|
||||||
|
import DBClient from '@/prisma/DBClient';
|
||||||
|
import findUserById from '@/services/User/findUserById';
|
||||||
|
import getUsersFollowingUser from '@/services/UserFollows/getUsersFollowingUser';
|
||||||
|
import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema';
|
||||||
|
|
||||||
|
import { NextApiResponse } from 'next';
|
||||||
|
import { createRouter } from 'next-connect';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
interface GetUserFollowInfoRequest extends UserExtendedNextApiRequest {
|
||||||
|
query: { id: string; page_size: string; page_num: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
const router = createRouter<
|
||||||
|
GetUserFollowInfoRequest,
|
||||||
|
NextApiResponse<z.infer<typeof APIResponseValidationSchema>>
|
||||||
|
>();
|
||||||
|
|
||||||
|
const getFollowingInfo = async (
|
||||||
|
req: GetUserFollowInfoRequest,
|
||||||
|
res: NextApiResponse<z.infer<typeof APIResponseValidationSchema>>,
|
||||||
|
) => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
|
const { id, page_num, page_size } = req.query;
|
||||||
|
|
||||||
|
const user = await findUserById(id);
|
||||||
|
if (!user) {
|
||||||
|
throw new ServerError('User not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageNum = parseInt(page_num, 10);
|
||||||
|
const pageSize = parseInt(page_size, 10);
|
||||||
|
|
||||||
|
const following = await getUsersFollowingUser({
|
||||||
|
userId: id,
|
||||||
|
pageNum,
|
||||||
|
pageSize,
|
||||||
|
});
|
||||||
|
const followingCount = await DBClient.instance.userFollow.count({
|
||||||
|
where: { following: { id } },
|
||||||
|
});
|
||||||
|
|
||||||
|
res.setHeader('X-Total-Count', followingCount);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
message: 'Retrieved users that are followed by queried user',
|
||||||
|
payload: following,
|
||||||
|
success: true,
|
||||||
|
statusCode: 200,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
validateRequest({
|
||||||
|
querySchema: z.object({
|
||||||
|
id: z.string().cuid(),
|
||||||
|
page_size: z.string().regex(/^\d+$/),
|
||||||
|
page_num: z.string().regex(/^\d+$/),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
getFollowingInfo,
|
||||||
|
);
|
||||||
|
|
||||||
|
const handler = router.handler(NextConnectOptions);
|
||||||
|
|
||||||
|
export default handler;
|
||||||
70
src/pages/api/users/[id]/following.ts
Normal file
70
src/pages/api/users/[id]/following.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { UserExtendedNextApiRequest } from '@/config/auth/types';
|
||||||
|
import NextConnectOptions from '@/config/nextConnect/NextConnectOptions';
|
||||||
|
import validateRequest from '@/config/nextConnect/middleware/validateRequest';
|
||||||
|
import ServerError from '@/config/util/ServerError';
|
||||||
|
import DBClient from '@/prisma/DBClient';
|
||||||
|
import findUserById from '@/services/User/findUserById';
|
||||||
|
import getUsersFollowedByUser from '@/services/UserFollows/getUsersFollowedByUser';
|
||||||
|
import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema';
|
||||||
|
|
||||||
|
import { NextApiResponse } from 'next';
|
||||||
|
import { createRouter } from 'next-connect';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
interface GetUserFollowInfoRequest extends UserExtendedNextApiRequest {
|
||||||
|
query: { id: string; page_size: string; page_num: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
const router = createRouter<
|
||||||
|
GetUserFollowInfoRequest,
|
||||||
|
NextApiResponse<z.infer<typeof APIResponseValidationSchema>>
|
||||||
|
>();
|
||||||
|
|
||||||
|
const getFollowingInfo = async (
|
||||||
|
req: GetUserFollowInfoRequest,
|
||||||
|
res: NextApiResponse<z.infer<typeof APIResponseValidationSchema>>,
|
||||||
|
) => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
|
const { id, page_num, page_size } = req.query;
|
||||||
|
|
||||||
|
const user = await findUserById(id);
|
||||||
|
if (!user) {
|
||||||
|
throw new ServerError('User not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageNum = parseInt(page_num, 10);
|
||||||
|
const pageSize = parseInt(page_size, 10);
|
||||||
|
|
||||||
|
const following = await getUsersFollowedByUser({
|
||||||
|
userId: id,
|
||||||
|
pageNum,
|
||||||
|
pageSize,
|
||||||
|
});
|
||||||
|
const followingCount = await DBClient.instance.userFollow.count({
|
||||||
|
where: { follower: { id } },
|
||||||
|
});
|
||||||
|
|
||||||
|
res.setHeader('X-Total-Count', followingCount);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
message: 'Retrieved users that are followed by queried user',
|
||||||
|
payload: following,
|
||||||
|
success: true,
|
||||||
|
statusCode: 200,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
validateRequest({
|
||||||
|
querySchema: z.object({
|
||||||
|
id: z.string().cuid(),
|
||||||
|
page_size: z.string().regex(/^\d+$/),
|
||||||
|
page_num: z.string().regex(/^\d+$/),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
getFollowingInfo,
|
||||||
|
);
|
||||||
|
|
||||||
|
const handler = router.handler(NextConnectOptions);
|
||||||
|
|
||||||
|
export default handler;
|
||||||
@@ -7,7 +7,9 @@ import { GetServerSideProps } from 'next';
|
|||||||
import Head from 'next/head';
|
import Head from 'next/head';
|
||||||
import { FC } from 'react';
|
import { FC } from 'react';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import Image from 'next/image';
|
import UserAvatar from '@/components/Account/UserAvatar';
|
||||||
|
import useGetUsersFollowedByUser from '@/hooks/data-fetching/user-follows/useGetUsersFollowedByUser';
|
||||||
|
import useGetUsersFollowingUser from '@/hooks/data-fetching/user-follows/useGetUsersFollowingUser';
|
||||||
|
|
||||||
interface UserInfoPageProps {
|
interface UserInfoPageProps {
|
||||||
user: z.infer<typeof GetUserSchema>;
|
user: z.infer<typeof GetUserSchema>;
|
||||||
@@ -16,17 +18,33 @@ interface UserInfoPageProps {
|
|||||||
const UserHeader: FC<{ user: z.infer<typeof GetUserSchema> }> = ({ user }) => {
|
const UserHeader: FC<{ user: z.infer<typeof GetUserSchema> }> = ({ user }) => {
|
||||||
const timeDistance = useTimeDistance(new Date(user.createdAt));
|
const timeDistance = useTimeDistance(new Date(user.createdAt));
|
||||||
|
|
||||||
return (
|
const { followingCount } = useGetUsersFollowedByUser({
|
||||||
<article className="card flex flex-col justify-center bg-base-300">
|
userId: user.id,
|
||||||
<div className="card-body">
|
pageSize: 10,
|
||||||
<header className="flex justify-between">
|
});
|
||||||
<div className="space-y-2">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold lg:text-4xl">
|
|
||||||
{user.firstName} {user.lastName}
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<h3 className="italic">
|
const { followerCount } = useGetUsersFollowingUser({
|
||||||
|
userId: user.id,
|
||||||
|
pageSize: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="card text-center items-center">
|
||||||
|
<div className="card-body items-center w-full">
|
||||||
|
<div className="w-40 h-40">
|
||||||
|
<UserAvatar user={user} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold lg:text-4xl">{user.username}</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex space-x-3 text-lg font-bold">
|
||||||
|
<span>{followingCount} Following</span>
|
||||||
|
<span>{followerCount} Followers</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className="italic">
|
||||||
joined{' '}
|
joined{' '}
|
||||||
{timeDistance && (
|
{timeDistance && (
|
||||||
<span
|
<span
|
||||||
@@ -36,30 +54,27 @@ const UserHeader: FC<{ user: z.infer<typeof GetUserSchema> }> = ({ user }) => {
|
|||||||
{`${timeDistance} ago`}
|
{`${timeDistance} ago`}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</h3>
|
</span>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const UserInfoPage: FC<UserInfoPageProps> = ({ user }) => {
|
const UserInfoPage: FC<UserInfoPageProps> = ({ user }) => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
const isDesktop = useMediaQuery('(min-width: 1024px)');
|
const isDesktop = useMediaQuery('(min-width: 1024px)');
|
||||||
|
const title = `${user.username} | The Biergarten App`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head>
|
<Head>
|
||||||
<title>{user ? `${user.firstName} ${user.lastName}` : 'User Info'}</title>
|
<title>{title}</title>
|
||||||
<meta name="description" content="User information" />
|
<meta name="description" content="User information" />
|
||||||
</Head>
|
</Head>
|
||||||
<>
|
<>
|
||||||
<main className="mb-12 mt-10 flex w-full items-center justify-center">
|
<main className="mb-12 mt-10 flex w-full items-center justify-center">
|
||||||
<Image src={user.userAvatar!.path} alt="avatar" width={200} height={200} />
|
|
||||||
<div className="w-11/12 space-y-3 xl:w-9/12 2xl:w-8/12">
|
<div className="w-11/12 space-y-3 xl:w-9/12 2xl:w-8/12">
|
||||||
<UserHeader user={user} />
|
<UserHeader user={user} />
|
||||||
|
|
||||||
{isDesktop ? <></> : <> </>}
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -24,20 +24,15 @@ const createNewUserFollows = async ({
|
|||||||
const randomUsers = users
|
const randomUsers = users
|
||||||
.filter((randomUser) => randomUser.id !== user.id)
|
.filter((randomUser) => randomUser.id !== user.id)
|
||||||
.sort(() => Math.random() - Math.random())
|
.sort(() => Math.random() - Math.random())
|
||||||
.slice(0, 20);
|
.slice(0, 100);
|
||||||
|
|
||||||
// Get the user to follow the random users, and the random users to follow the user.
|
// Get the user to follow the random users
|
||||||
const data = randomUsers.flatMap((randomUser) => [
|
const data = randomUsers.flatMap((randomUser) => [
|
||||||
{
|
{
|
||||||
followerId: user.id,
|
followerId: user.id,
|
||||||
followingId: randomUser.id,
|
followingId: randomUser.id,
|
||||||
followedAt: faker.date.between({ from: user.createdAt, to: new Date() }),
|
followedAt: faker.date.between({ from: user.createdAt, to: new Date() }),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
followerId: randomUser.id,
|
|
||||||
followingId: user.id,
|
|
||||||
followedAt: faker.date.between({ from: randomUser.createdAt, to: new Date() }),
|
|
||||||
},
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
userFollows.push(...data);
|
userFollows.push(...data);
|
||||||
|
|||||||
27
src/services/UserFollows/getUsersFollowedByUser.ts
Normal file
27
src/services/UserFollows/getUsersFollowedByUser.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import DBClient from '@/prisma/DBClient';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import FollowInfoSchema from './schema/FollowInfoSchema';
|
||||||
|
|
||||||
|
interface GetFollowingInfoByUserIdArgs {
|
||||||
|
userId: string;
|
||||||
|
pageNum: number;
|
||||||
|
pageSize: number;
|
||||||
|
}
|
||||||
|
const getUsersFollowedByUser = async ({
|
||||||
|
userId,
|
||||||
|
pageNum,
|
||||||
|
pageSize,
|
||||||
|
}: GetFollowingInfoByUserIdArgs): Promise<z.infer<typeof FollowInfoSchema>[]> => {
|
||||||
|
const usersFollowedByQueriedUser = await DBClient.instance.userFollow.findMany({
|
||||||
|
take: pageSize,
|
||||||
|
skip: (pageNum - 1) * pageSize,
|
||||||
|
where: { following: { id: userId } },
|
||||||
|
select: {
|
||||||
|
follower: { select: { username: true, userAvatar: true, id: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return usersFollowedByQueriedUser.map((u) => u.follower);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default getUsersFollowedByUser;
|
||||||
27
src/services/UserFollows/getUsersFollowingUser.ts
Normal file
27
src/services/UserFollows/getUsersFollowingUser.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import DBClient from '@/prisma/DBClient';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import FollowInfoSchema from './schema/FollowInfoSchema';
|
||||||
|
|
||||||
|
interface GetFollowingInfoByUserIdArgs {
|
||||||
|
userId: string;
|
||||||
|
pageNum: number;
|
||||||
|
pageSize: number;
|
||||||
|
}
|
||||||
|
const getUsersFollowingUser = async ({
|
||||||
|
userId,
|
||||||
|
pageNum,
|
||||||
|
pageSize,
|
||||||
|
}: GetFollowingInfoByUserIdArgs): Promise<z.infer<typeof FollowInfoSchema>[]> => {
|
||||||
|
const usersFollowingQueriedUser = await DBClient.instance.userFollow.findMany({
|
||||||
|
take: pageSize,
|
||||||
|
skip: (pageNum - 1) * pageSize,
|
||||||
|
where: { follower: { id: userId } },
|
||||||
|
select: {
|
||||||
|
following: { select: { username: true, userAvatar: true, id: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return usersFollowingQueriedUser.map((u) => u.following);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default getUsersFollowingUser;
|
||||||
9
src/services/UserFollows/schema/FollowInfoSchema.ts
Normal file
9
src/services/UserFollows/schema/FollowInfoSchema.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import GetUserSchema from '@/services/User/schema/GetUserSchema';
|
||||||
|
|
||||||
|
const FollowInfoSchema = GetUserSchema.pick({
|
||||||
|
userAvatar: true,
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
export default FollowInfoSchema;
|
||||||
Reference in New Issue
Block a user