develop #14

Merged
sam merged 15 commits from develop into main 2026-02-24 01:55:24 +00:00
26 changed files with 339 additions and 213 deletions
Showing only changes of commit 5ffdd7454a - Show all commits

View File

@@ -20,18 +20,33 @@ const Pagination = ({
const getPageNumbers = () => { const getPageNumbers = () => {
const pages: (number | string)[] = []; const pages: (number | string)[] = [];
if (totalPages <= 5) { if (totalPages <= 6) {
// Hiển thị tất cả nếu trang ít // Hiển thị tất cả nếu trang ít
for (let i = 1; i <= totalPages; i++) { for (let i = 1; i <= totalPages; i++) {
pages.push(i); pages.push(i);
} }
} else { } else {
if (currentPage <= 3) { if (currentPage <= 3) {
pages.push(1, 2, 3, 'dot', totalPages); pages.push(1, 2, 3, 4, 'dot', totalPages);
} else if (currentPage >= totalPages - 2) { } else if (currentPage >= totalPages - 2) {
pages.push(1, 'dot', totalPages - 2, totalPages - 1, totalPages); pages.push(
1,
'dot',
totalPages - 3,
totalPages - 2,
totalPages - 1,
totalPages,
);
} else { } else {
pages.push(1, 'dot', currentPage, 'dot', totalPages); pages.push(
1,
'dot',
currentPage - 1,
currentPage,
currentPage + 1,
'dot',
totalPages,
);
} }
} }
@@ -48,6 +63,7 @@ const Pagination = ({
variant="outline" variant="outline"
size="icon-sm" size="icon-sm"
disabled={currentPage === 1} disabled={currentPage === 1}
onClick={() => onPageChange(Number(currentPage - 1))}
className="cursor-pointer" className="cursor-pointer"
> >
<CaretLeftIcon /> <CaretLeftIcon />
@@ -85,6 +101,7 @@ const Pagination = ({
variant="outline" variant="outline"
size="icon-sm" size="icon-sm"
disabled={currentPage === totalPages} disabled={currentPage === totalPages}
onClick={() => onPageChange(Number(currentPage + 1))}
className="cursor-pointer" className="cursor-pointer"
> >
<CaretRightIcon /> <CaretRightIcon />

View File

@@ -1,5 +1,6 @@
import { ClientSession, useSession } from '@lib/auth-client'; import { sessionQueries } from '@/service/queries';
import { BetterFetchError } from 'better-auth/client'; import { ClientSession } from '@lib/auth-client';
import { useQuery } from '@tanstack/react-query';
import { createContext, useContext, useMemo } from 'react'; import { createContext, useContext, useMemo } from 'react';
export type UserContext = { export type UserContext = {
@@ -7,12 +8,13 @@ export type UserContext = {
isAuth: boolean; isAuth: boolean;
isAdmin: boolean; isAdmin: boolean;
isPending: boolean; isPending: boolean;
error: BetterFetchError | null; error: Error | null;
}; };
const AuthContext = createContext<UserContext | null>(null); const AuthContext = createContext<UserContext | null>(null);
export function AuthProvider({ children }: { children: React.ReactNode }) { export function AuthProvider({ children }: { children: React.ReactNode }) {
const { data: session, isPending, error } = useSession(); const { data: session, isPending, error } = useQuery(sessionQueries.user());
const contextSession: UserContext = useMemo( const contextSession: UserContext = useMemo(
() => ({ () => ({

View File

@@ -1,8 +1,9 @@
import { useAppForm } from '@hooks/use-app-form'; import { useAppForm } from '@hooks/use-app-form';
import { authClient } from '@lib/auth-client';
import { m } from '@paraglide/messages'; import { m } from '@paraglide/messages';
import { KeyIcon } from '@phosphor-icons/react'; import { KeyIcon } from '@phosphor-icons/react';
import { ChangePassword, ChangePasswordFormSchema } from '@service/user.schema'; import { changePassword } from '@service/profile.api';
import { ChangePassword, changePasswordFormSchema } from '@service/user.schema';
import { useMutation } from '@tanstack/react-query';
import { Card, CardContent, CardHeader, CardTitle } from '@ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@ui/card';
import { Field, FieldGroup } from '@ui/field'; import { Field, FieldGroup } from '@ui/field';
import { toast } from 'sonner'; import { toast } from 'sonner';
@@ -14,37 +15,33 @@ const defaultValues: ChangePassword = {
}; };
const ChangePasswordForm = () => { const ChangePasswordForm = () => {
const { mutate: changePasswordMutation, isPending } = useMutation({
mutationFn: changePassword,
onSuccess: () => {
form.reset();
toast.success(m.change_password_messages_change_password_success(), {
richColors: true,
});
},
onError: (error: ReturnError) => {
console.error(error);
const code = error.code as Parameters<
typeof m.backend_message
>[0]['code'];
toast.error(m.backend_message({ code }), {
richColors: true,
});
},
});
const form = useAppForm({ const form = useAppForm({
defaultValues, defaultValues,
validators: { validators: {
onSubmit: ChangePasswordFormSchema, onSubmit: changePasswordFormSchema,
onChange: ChangePasswordFormSchema, onChange: changePasswordFormSchema,
}, },
onSubmit: async ({ value }) => { onSubmit: async ({ value }) => {
await authClient.changePassword( changePasswordMutation({ data: value });
{
newPassword: value.newPassword,
currentPassword: value.currentPassword,
revokeOtherSessions: true,
},
{
onSuccess: () => {
form.reset();
toast.success(
m.change_password_messages_change_password_success(),
{
richColors: true,
},
);
},
onError: (ctx) => {
console.error(ctx.error.code);
toast.error(m.backend_message({ code: ctx.error.code }), {
richColors: true,
});
},
},
);
}, },
}); });
@@ -70,6 +67,7 @@ const ChangePasswordForm = () => {
{(field) => ( {(field) => (
<field.TextField <field.TextField
label={m.change_password_form_current_password()} label={m.change_password_form_current_password()}
type="password"
/> />
)} )}
</form.AppField> </form.AppField>
@@ -91,7 +89,10 @@ const ChangePasswordForm = () => {
</form.AppField> </form.AppField>
<Field> <Field>
<form.AppForm> <form.AppForm>
<form.SubscribeButton label={m.ui_change_password_btn()} /> <form.SubscribeButton
label={m.ui_change_password_btn()}
disabled={isPending}
/>
</form.AppForm> </form.AppForm>
</Field> </Field>
</FieldGroup> </FieldGroup>

View File

@@ -1,13 +1,13 @@
import { Skeleton } from '@/components/ui/skeleton';
import { updateProfile } from '@/service/profile.api';
import { useAuth } from '@components/auth/auth-provider'; import { useAuth } from '@components/auth/auth-provider';
import AvatarUser from '@components/avatar/avatar-user'; import AvatarUser from '@components/avatar/avatar-user';
import RoleBadge from '@components/avatar/role-badge'; import RoleBadge from '@components/avatar/role-badge';
import { useAppForm } from '@hooks/use-app-form'; import { useAppForm } from '@hooks/use-app-form';
import { authClient } from '@lib/auth-client';
import { m } from '@paraglide/messages'; import { m } from '@paraglide/messages';
import { UserCircleIcon } from '@phosphor-icons/react'; import { UserCircleIcon } from '@phosphor-icons/react';
import { uploadProfileImage } from '@service/profile.api';
import { ProfileInput, profileUpdateSchema } from '@service/profile.schema'; import { ProfileInput, profileUpdateSchema } from '@service/profile.schema';
import { useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Card, CardContent, CardHeader, CardTitle } from '@ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@ui/card';
import { Field, FieldGroup, FieldLabel } from '@ui/field'; import { Field, FieldGroup, FieldLabel } from '@ui/field';
import { Input } from '@ui/input'; import { Input } from '@ui/input';
@@ -24,6 +24,31 @@ const ProfileForm = () => {
const { data: session, isPending } = useAuth(); const { data: session, isPending } = useAuth();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { mutate: updateProfileMutation, isPending: isRunning } = useMutation({
mutationFn: updateProfile,
onSuccess: () => {
form.reset();
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
queryClient.refetchQueries({
queryKey: ['auth', 'session'],
});
toast.success(m.profile_messages_update_success(), {
richColors: true,
});
},
onError: (error: ReturnError) => {
console.error(error);
const code = error.code as Parameters<
typeof m.backend_message
>[0]['code'];
toast.error(m.backend_message({ code }), {
richColors: true,
});
},
});
const form = useAppForm({ const form = useAppForm({
defaultValues: { defaultValues: {
...defaultValues, ...defaultValues,
@@ -34,55 +59,19 @@ const ProfileForm = () => {
onChange: profileUpdateSchema, onChange: profileUpdateSchema,
}, },
onSubmit: async ({ value }) => { onSubmit: async ({ value }) => {
try { const formData = new FormData();
let imageKey; formData.set('name', value.name);
if (value.image) { if (value.image) {
// upload image formData.set('file', value.image);
const formData = new FormData();
formData.set('file', value.image);
const { imageKey: uploadedKey } = await uploadProfileImage({
data: formData,
});
imageKey = uploadedKey;
}
await authClient.updateUser(
{
name: value.name,
image: imageKey,
},
{
onSuccess: () => {
form.reset();
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
queryClient.refetchQueries({
queryKey: ['auth', 'session'],
});
toast.success(m.profile_messages_update_success(), {
richColors: true,
});
},
onError: (ctx) => {
console.error(ctx.error.code);
toast.error(m.backend_message({ code: ctx.error.code }), {
richColors: true,
});
},
},
);
} catch (error) {
console.error('update load file', error);
toast.error(JSON.stringify(error), {
richColors: true,
});
} }
updateProfileMutation({ data: formData });
}, },
}); });
if (isPending) return null; if (isPending || !session?.user?.name) {
if (!session?.user?.name) return null; return <Skeleton className="h-100 col-span-1" />;
}
return ( return (
<Card className="@container/card col-span-1"> <Card className="@container/card col-span-1">
@@ -136,7 +125,10 @@ const ProfileForm = () => {
</Field> </Field>
<Field> <Field>
<form.AppForm> <form.AppForm>
<form.SubscribeButton label={m.ui_update_btn()} /> <form.SubscribeButton
label={m.ui_update_btn()}
disabled={isRunning}
/>
</form.AppForm> </form.AppForm>
</Field> </Field>
</FieldGroup> </FieldGroup>

View File

@@ -13,7 +13,7 @@ import { slugify } from '@utils/helper';
import { toast } from 'sonner'; import { toast } from 'sonner';
type EditHouseFormProps = { type EditHouseFormProps = {
data: OrganizationWithMembers; data: HouseWithMembers;
onSubmit: (open: boolean) => void; onSubmit: (open: boolean) => void;
mutateKey: string; mutateKey: string;
}; };

View File

@@ -7,10 +7,12 @@ import DeleteUserHouseAction from './delete-user-house-dialog';
import EditHouseAction from './edit-house-dialog'; import EditHouseAction from './edit-house-dialog';
type CurrentUserActionGroupProps = { type CurrentUserActionGroupProps = {
oneHouse: boolean;
activeHouse: ReturnType<typeof authClient.useActiveOrganization>['data']; activeHouse: ReturnType<typeof authClient.useActiveOrganization>['data'];
}; };
const CurrentUserActionGroup = ({ const CurrentUserActionGroup = ({
oneHouse,
activeHouse, activeHouse,
}: CurrentUserActionGroupProps) => { }: CurrentUserActionGroupProps) => {
return ( return (
@@ -22,10 +24,7 @@ const CurrentUserActionGroup = ({
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="flex flex-row gap-2"> <CardContent className="flex flex-row gap-2">
<EditHouseAction <EditHouseAction data={activeHouse as HouseWithMembers} isPersonal>
data={activeHouse as OrganizationWithMembers}
isPersonal
>
<Button <Button
type="button" type="button"
size="icon-lg" size="icon-lg"
@@ -35,7 +34,7 @@ const CurrentUserActionGroup = ({
<span className="sr-only">{m.ui_edit_house_btn()}</span> <span className="sr-only">{m.ui_edit_house_btn()}</span>
</Button> </Button>
</EditHouseAction> </EditHouseAction>
<DeleteUserHouseAction activeHouse={activeHouse} /> {!oneHouse && <DeleteUserHouseAction activeHouse={activeHouse} />}
</CardContent> </CardContent>
</Card> </Card>
); );

View File

@@ -2,8 +2,6 @@ import { authClient } from '@lib/auth-client';
import { cn } from '@lib/utils'; import { cn } from '@lib/utils';
import { m } from '@paraglide/messages'; import { m } from '@paraglide/messages';
import { CheckIcon, WarehouseIcon } from '@phosphor-icons/react'; import { CheckIcon, WarehouseIcon } from '@phosphor-icons/react';
import { housesQueries } from '@service/queries';
import { useQuery } from '@tanstack/react-query';
import { Button } from '@ui/button'; import { Button } from '@ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@ui/card';
import parse from 'html-react-parser'; import parse from 'html-react-parser';
@@ -20,11 +18,15 @@ import { Skeleton } from '../ui/skeleton';
import CreateNewHouse from './create-house-dialog'; import CreateNewHouse from './create-house-dialog';
type CurrentUserHouseListProps = { type CurrentUserHouseListProps = {
houses: HouseWithMembersCount[];
activeHouse: ReturnType<typeof authClient.useActiveOrganization>['data']; activeHouse: ReturnType<typeof authClient.useActiveOrganization>['data'];
}; };
const CurrentUserHouseList = ({ activeHouse }: CurrentUserHouseListProps) => { const CurrentUserHouseList = ({
const { data: houses } = useQuery(housesQueries.currentUser()); activeHouse,
houses,
}: CurrentUserHouseListProps) => {
// const { data: houses } = useQuery(housesQueries.currentUser());
const activeHouseAction = async ({ const activeHouseAction = async ({
id, id,

View File

@@ -33,7 +33,7 @@ import RoleBadge from '../avatar/role-badge';
import { Spinner } from '../ui/spinner'; import { Spinner } from '../ui/spinner';
type DeleteHouseProps = { type DeleteHouseProps = {
data: OrganizationWithMembers; data: HouseWithMembers;
}; };
const DeleteHouseAction = ({ data }: DeleteHouseProps) => { const DeleteHouseAction = ({ data }: DeleteHouseProps) => {

View File

@@ -16,7 +16,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@ui/tooltip';
import { useState } from 'react'; import { useState } from 'react';
type EditHouseProps = { type EditHouseProps = {
data: OrganizationWithMembers; data: HouseWithMembers;
children: React.ReactNode; children: React.ReactNode;
isPersonal?: boolean; isPersonal?: boolean;
}; };

View File

@@ -7,7 +7,7 @@ import DeleteHouseAction from './delete-house-dialog';
import EditHouseAction from './edit-house-dialog'; import EditHouseAction from './edit-house-dialog';
import ViewDetailHouse from './view-house-detail-dialog'; import ViewDetailHouse from './view-house-detail-dialog';
export const houseColumns: ColumnDef<OrganizationWithMembers>[] = [ export const houseColumns: ColumnDef<HouseWithMembers>[] = [
{ {
accessorKey: 'name', accessorKey: 'name',
header: m.houses_page_ui_table_header_name(), header: m.houses_page_ui_table_header_name(),

View File

@@ -24,7 +24,7 @@ import { formatters } from '@utils/formatters';
import RoleBadge from '../avatar/role-badge'; import RoleBadge from '../avatar/role-badge';
type ViewDetailProps = { type ViewDetailProps = {
data: OrganizationWithMembers; data: HouseWithMembers;
}; };
const ViewDetailHouse = ({ data }: ViewDetailProps) => { const ViewDetailHouse = ({ data }: ViewDetailProps) => {

View File

@@ -1,6 +0,0 @@
import { sessionQueries } from '@service/queries';
import { useQuery } from '@tanstack/react-query';
export function useSessionQuery() {
return useQuery(sessionQueries.user());
}

View File

@@ -77,43 +77,6 @@ export const auth = betterAuth({
}); });
}, },
}, },
update: {
before: async (user, ctx) => {
if (ctx?.context.session && ctx?.path === '/update-user') {
const newUser = JSON.parse(JSON.stringify(user));
const keys = Object.keys(newUser);
const oldUser = Object.fromEntries(
Object.entries(ctx?.context.session?.user).filter(([key]) =>
keys.includes(key),
),
);
await createAuditLog({
action: LOG_ACTION.UPDATE,
tableName: DB_TABLE.USER,
recordId: ctx?.context.session?.user.id,
oldValue: JSON.stringify(oldUser),
newValue: JSON.stringify(newUser),
userId: ctx?.context.session?.user.id,
});
}
},
},
},
account: {
update: {
after: async (account, context) => {
if (context?.path === '/change-password') {
await createAuditLog({
action: LOG_ACTION.CHANGE_PASSWORD,
tableName: DB_TABLE.ACCOUNT,
recordId: account.id,
oldValue: 'Change Password',
newValue: 'Change Password',
userId: account.userId,
});
}
},
},
}, },
session: { session: {
create: { create: {

1
src/lib/errors/index.ts Normal file
View File

@@ -0,0 +1 @@
export * from './parse-error';

View File

@@ -0,0 +1,77 @@
import { Prisma } from '@/generated/prisma/client';
export type ErrorBody = {
message?: unknown;
code?: unknown;
status?: unknown;
};
function hasErrorBody(error: unknown): error is { body: ErrorBody } {
return (
typeof error === 'object' &&
error !== null &&
'body' in error &&
typeof (error as any).body === 'object'
);
}
export function parseError(error: unknown): {
message: string;
code?: string;
status?: number;
} {
// Recognize AppError even if it's a plain object from network
if (
typeof error === 'object' &&
error !== null &&
'name' in error &&
(error as any).name === 'AppError' &&
'code' in error &&
'message' in error &&
'status' in error
) {
return {
message: (error as any).message,
code: (error as any).code,
status: (error as any).status,
};
}
// better-auth / fetch error (có body)
if (hasErrorBody(error)) {
return {
message:
typeof error.body.message === 'string'
? error.body.message
: 'Unknown error',
code: typeof error.body.code === 'string' ? error.body.code : undefined,
status:
typeof error.body.status === 'number' ? error.body.status : undefined,
};
}
// Prisma (giữ nguyên code như "P2002")
if (error instanceof Prisma.PrismaClientKnownRequestError) {
return {
message: error.message,
code: error.code,
status: 400,
};
}
// Error thường
if (error instanceof Error) {
return {
message: error.message,
code: 'NORMAL_ERROR',
status: undefined,
};
}
// Fallback
return {
message: String(error),
code: 'NORMAL_ERROR',
status: undefined,
};
}

View File

@@ -21,9 +21,12 @@ function RouteComponent() {
return ( return (
<div className="@container/main p-4"> <div className="@container/main p-4">
<div className="*:data-[slot=card]:from-primary/5 *:data-[slot=card]:to-card dark:*:data-[slot=card]:bg-card *:data-[slot=card]:bg-linear-to-br *:data-[slot=card]:shadow-xs grid grid-cols-1 @xl/main:grid-cols-2 @5xl/main:grid-cols-3 gap-4"> <div className="*:data-[slot=card]:from-primary/5 *:data-[slot=card]:to-card dark:*:data-[slot=card]:bg-card *:data-[slot=card]:bg-linear-to-br *:data-[slot=card]:shadow-xs grid grid-cols-1 @xl/main:grid-cols-2 @5xl/main:grid-cols-3 gap-4">
<CurrentUserHouseList activeHouse={activeHouse} /> <CurrentUserHouseList activeHouse={activeHouse} houses={houses} />
<CurrentUserMemberList activeHouse={activeHouse} /> <CurrentUserMemberList activeHouse={activeHouse} />
<CurrentUserActionGroup activeHouse={activeHouse} /> <CurrentUserActionGroup
activeHouse={activeHouse}
oneHouse={houses.length === 1}
/>
</div> </div>
</div> </div>
); );

View File

@@ -1,8 +1,8 @@
import { prisma } from '@/db'; import { prisma } from '@/db';
import { AuditWhereInput } from '@/generated/prisma/models'; import { AuditWhereInput } from '@/generated/prisma/models';
import { parseError } from '@lib/errors';
import { authMiddleware } from '@lib/middleware'; import { authMiddleware } from '@lib/middleware';
import { createServerFn } from '@tanstack/react-start'; import { createServerFn } from '@tanstack/react-start';
import { parseError } from '@utils/helper';
import { auditListSchema } from './audit.schema'; import { auditListSchema } from './audit.schema';
export const getAllAudit = createServerFn({ method: 'GET' }) export const getAllAudit = createServerFn({ method: 'GET' })

View File

@@ -1,3 +1,4 @@
import { AppError } from '@/lib/errors';
import fs, { writeFile } from 'fs/promises'; import fs, { writeFile } from 'fs/promises';
import path from 'path'; import path from 'path';
@@ -19,8 +20,10 @@ export async function saveFile(key: string, file: Buffer | File) {
return key; return key;
} catch (error) { } catch (error) {
console.error(`Error saving file: ${key}`, error); console.error(`Error saving file: ${key}`, error);
throw new Error( throw new AppError(
'FILE_SAVE_ERROR',
`Failed to save file: ${error instanceof Error ? error.message : 'Unknown error'}`, `Failed to save file: ${error instanceof Error ? error.message : 'Unknown error'}`,
500,
); );
} }
} }

View File

@@ -2,10 +2,10 @@ import { prisma } from '@/db';
import { OrganizationWhereInput } from '@/generated/prisma/models'; import { OrganizationWhereInput } from '@/generated/prisma/models';
import { DB_TABLE, LOG_ACTION } from '@/types/enum'; import { DB_TABLE, LOG_ACTION } from '@/types/enum';
import { auth } from '@lib/auth'; import { auth } from '@lib/auth';
import { parseError } from '@lib/errors';
import { authMiddleware } from '@lib/middleware'; import { authMiddleware } from '@lib/middleware';
import { createServerFn } from '@tanstack/react-start'; import { createServerFn } from '@tanstack/react-start';
import { getRequestHeaders } from '@tanstack/react-start/server'; import { getRequestHeaders } from '@tanstack/react-start/server';
import { parseError } from '@utils/helper';
import { import {
baseHouse, baseHouse,
houseCreateBESchema, houseCreateBESchema,
@@ -33,31 +33,30 @@ export const getAllHouse = createServerFn({ method: 'GET' })
], ],
}; };
const [list, total]: [OrganizationWithMembers[], number] = const [list, total]: [HouseWithMembers[], number] = await Promise.all([
await Promise.all([ await prisma.organization.findMany({
await prisma.organization.findMany({ where,
where, orderBy: { createdAt: 'desc' },
orderBy: { createdAt: 'desc' }, include: {
include: { members: {
members: { select: {
select: { role: true,
role: true, user: {
user: { select: {
select: { id: true,
id: true, name: true,
name: true, email: true,
email: true, image: true,
image: true,
},
}, },
}, },
}, },
}, },
take: limit, },
skip, take: limit,
}), skip,
await prisma.organization.count({ where }), }),
]); await prisma.organization.count({ where }),
]);
const totalPage = Math.ceil(+total / limit); const totalPage = Math.ceil(+total / limit);

View File

@@ -1,17 +1,92 @@
import { prisma } from '@/db';
import { auth } from '@/lib/auth';
import { parseError } from '@/lib/errors';
import { DB_TABLE, LOG_ACTION } from '@/types/enum';
import { authMiddleware } from '@lib/middleware'; import { authMiddleware } from '@lib/middleware';
import { createServerFn } from '@tanstack/react-start'; import { createServerFn } from '@tanstack/react-start';
import { saveFile } from '@utils/disk-storage'; import { getRequestHeaders } from '@tanstack/react-start/server';
import z from 'zod'; import z from 'zod';
import { saveFile } from './disk-storage';
import { createAuditLog } from './repository';
import { changePasswordBESchema } from './user.schema';
export const uploadProfileImage = createServerFn({ method: 'POST' }) export const updateProfile = createServerFn({ method: 'POST' })
.middleware([authMiddleware]) .middleware([authMiddleware])
.inputValidator(z.instanceof(FormData)) .inputValidator(z.instanceof(FormData))
.handler(async ({ data: formData }) => { .handler(async ({ data: formData, context: { user } }) => {
const uuid = crypto.randomUUID(); try {
const file = formData.get('file') as File; let imageKey;
if (!(file instanceof File)) throw new Error('File not found'); const file = formData.get('file') as File;
const imageKey = `${uuid}.${file.type.split('/')[1]}`; if (file) {
const buffer = Buffer.from(await file.arrayBuffer()); const uuid = crypto.randomUUID();
await saveFile(imageKey, buffer); if (!(file instanceof File)) throw new Error('File not found');
return { imageKey }; const buffer = Buffer.from(await file.arrayBuffer());
imageKey = await saveFile(`${uuid}.${file.type.split('/')[1]}`, buffer);
}
const getOldUser = await prisma.user.findUnique({
where: { id: user.id },
});
const name = formData.get('name') as string;
const newUser = JSON.parse(JSON.stringify({ name, image: imageKey }));
const keys = Object.keys(newUser);
const oldUser = Object.fromEntries(
Object.entries(getOldUser || {}).filter(([key]) => keys.includes(key)),
);
const headers = getRequestHeaders();
const result = await auth.api.updateUser({
body: newUser,
headers,
});
await createAuditLog({
action: LOG_ACTION.UPDATE,
tableName: DB_TABLE.USER,
recordId: user.id,
oldValue: JSON.stringify(oldUser),
newValue: JSON.stringify(newUser),
userId: user.id,
});
return result;
} catch (error) {
console.error(error);
const { message, code } = parseError(error);
throw { message, code };
}
});
export const changePassword = createServerFn({ method: 'POST' })
.middleware([authMiddleware])
.inputValidator(changePasswordBESchema)
.handler(async ({ data, context: { user } }) => {
try {
const headers = getRequestHeaders();
const result = await auth.api.changePassword({
body: {
newPassword: data.newPassword, // required
currentPassword: data.currentPassword, // required
revokeOtherSessions: true,
},
headers,
});
await createAuditLog({
action: LOG_ACTION.CHANGE_PASSWORD,
tableName: DB_TABLE.ACCOUNT,
recordId: user.id,
oldValue: 'Change Password',
newValue: 'Change Password',
userId: user.id,
});
return result;
} catch (error) {
// console.error(error);
const { message, code } = parseError(error);
throw { message, code };
}
}); });

View File

@@ -1,6 +1,5 @@
import { prisma } from '@/db'; import { prisma } from '@/db';
import { Audit, Setting } from '@/generated/prisma/client'; import { Audit, Setting } from '@/generated/prisma/client';
import { parseError } from '@utils/helper';
type AdminSettingValue = Pick<Setting, 'id' | 'key' | 'value'>; type AdminSettingValue = Pick<Setting, 'id' | 'key' | 'value'>;
@@ -42,17 +41,11 @@ export async function getAllAdminSettings(valueOnly = false) {
} }
export const createAuditLog = async (data: Omit<Audit, 'id' | 'createdAt'>) => { export const createAuditLog = async (data: Omit<Audit, 'id' | 'createdAt'>) => {
try { await prisma.audit.create({
await prisma.audit.create({ data: {
data: { ...data,
...data, },
}, });
});
} catch (error) {
console.error(error);
const { message, code } = parseError(error);
throw { message, code };
}
}; };
export const getInitialOrganization = async (userId: string) => { export const getInitialOrganization = async (userId: string) => {
@@ -65,5 +58,6 @@ export const getInitialOrganization = async (userId: string) => {
}, },
}, },
}); });
return organization; return organization;
}; };

View File

@@ -1,7 +1,8 @@
import { prisma } from '@/db'; import { prisma } from '@/db';
import { parseError } from '@/lib/errors';
import { authMiddleware } from '@lib/middleware'; import { authMiddleware } from '@lib/middleware';
import { createServerFn } from '@tanstack/react-start'; import { createServerFn } from '@tanstack/react-start';
import { extractDiffObjects, parseError } from '@utils/helper'; import { extractDiffObjects } from '@utils/helper';
import { createAuditLog, getAllAdminSettings } from './repository'; import { createAuditLog, getAllAdminSettings } from './repository';
import { settingSchema, userSettingSchema } from './setting.schema'; import { settingSchema, userSettingSchema } from './setting.schema';

View File

@@ -1,10 +1,10 @@
import { prisma } from '@/db'; import { prisma } from '@/db';
import { DB_TABLE, LOG_ACTION } from '@/types/enum'; import { DB_TABLE, LOG_ACTION } from '@/types/enum';
import { auth } from '@lib/auth'; import { auth } from '@lib/auth';
import { parseError } from '@lib/errors';
import { authMiddleware } from '@lib/middleware'; import { authMiddleware } from '@lib/middleware';
import { createServerFn } from '@tanstack/react-start'; import { createServerFn } from '@tanstack/react-start';
import { getRequestHeaders } from '@tanstack/react-start/server'; import { getRequestHeaders } from '@tanstack/react-start/server';
import { parseError } from '@utils/helper';
import { createAuditLog } from './repository'; import { createAuditLog } from './repository';
import { import {
baseUser, baseUser,

View File

@@ -5,7 +5,7 @@ export const baseUser = z.object({
id: z.string().nonempty(m.users_page_message_user_not_found()), id: z.string().nonempty(m.users_page_message_user_not_found()),
}); });
export const ChangePasswordFormSchema = z export const changePasswordFormSchema = z
.object({ .object({
currentPassword: z.string().nonempty( currentPassword: z.string().nonempty(
m.common_is_required({ m.common_is_required({
@@ -33,7 +33,20 @@ export const ChangePasswordFormSchema = z
} }
}); });
export type ChangePassword = z.infer<typeof ChangePasswordFormSchema>; export const changePasswordBESchema = z.object({
currentPassword: z.string().nonempty(
m.common_is_required({
field: m.change_password_form_current_password(),
}),
),
newPassword: z.string().nonempty(
m.common_is_required({
field: m.change_password_form_new_password(),
}),
),
});
export type ChangePassword = z.infer<typeof changePasswordFormSchema>;
export const userListSchema = z.object({ export const userListSchema = z.object({
page: z.coerce.number().min(1).default(1), page: z.coerce.number().min(1).default(1),

10
src/types/db.d.ts vendored
View File

@@ -12,7 +12,7 @@ declare global {
}; };
}>; }>;
type OrganizationWithMembers = Prisma.OrganizationGetPayload<{ type HouseWithMembers = Prisma.OrganizationGetPayload<{
include: { include: {
members: { members: {
select: { select: {
@@ -30,8 +30,14 @@ declare global {
}; };
}>; }>;
type HouseWithMembersCount = HouseWithMembers & {
_count: {
members: number;
};
};
type ReturnError = Error & { type ReturnError = Error & {
message: string;
code: string; code: string;
message: string;
}; };
} }

View File

@@ -26,22 +26,6 @@ export function extractDiffObjects<T extends AnyRecord>(
); );
} }
export function parseError(error: unknown) {
if (typeof error === 'object' && error !== null && 'body' in error) {
const e = error as any;
return {
message: e.body?.message ?? 'Unknown error',
code: e.body?.code,
};
}
if (error instanceof Error) {
return { message: error.message };
}
return { message: String(error) };
}
export function slugify(text: string) { export function slugify(text: string) {
return text return text
.toLowerCase() .toLowerCase()