Merge pull request 'feature/audit-log' (#6) from feature/audit-log into main

Reviewed-on: #6
This commit was merged in pull request #6.
This commit is contained in:
2026-01-10 02:01:59 +00:00
30 changed files with 2645 additions and 733 deletions

View File

@@ -33,10 +33,9 @@
"@tanstack/react-router-ssr-query": "^1.131.7",
"@tanstack/react-start": "^1.132.0",
"@tanstack/router-plugin": "^1.132.0",
"better-auth": "^1.4.7",
"better-auth": "^1.4.10",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"i18next": "^25.7.3",
"next-themes": "^0.4.6",
"prisma": "^7.1.0",
"radix-ui": "^1.4.3",

1157
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,16 @@
-- CreateTable
CREATE TABLE "audit" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"action" TEXT NOT NULL,
"tableName" TEXT NOT NULL,
"recordId" TEXT NOT NULL,
"oldValue" TEXT,
"newValue" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "audit_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "audit" ADD CONSTRAINT "audit_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -18,6 +18,7 @@ model User {
updatedAt DateTime @updatedAt
sessions Session[]
accounts Account[]
audit Audit[]
role String?
banned Boolean? @default(false)
@@ -142,3 +143,18 @@ model Setting {
@@map("setting")
}
model Audit {
id String @id @default(uuid())
userId String
action String
tableName String
recordId String
oldValue String?
newValue String?
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("audit")
}

View File

@@ -4,13 +4,9 @@
"locales": ["en", "vi"],
"modules": [
"https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format@4/dist/index.js",
"https://cdn.jsdelivr.net/npm/@inlang/plugin-m-function-matcher@2/dist/index.js",
"https://cdn.jsdelivr.net/npm/@inlang/plugin-i18next@latest/dist/index.js"
"https://cdn.jsdelivr.net/npm/@inlang/plugin-m-function-matcher@2/dist/index.js"
],
"plugin.inlang.messageFormat": {
"pathPattern": "./messages/{locale}.json"
},
"plugin.inlang.i18next": {
"pathPattern": "./messages/{locale}.json"
}
}

View File

@@ -73,17 +73,6 @@ export default function Header() {
)}
</div>
</header>
{/* <Link
to="/demo/start/ssr"
onClick={() => setIsOpen(false)}
className="flex-1 flex items-center gap-3 p-3 rounded-lg hover:bg-gray-800 transition-colors mb-2"
activeProps={{
className:
'flex-1 flex items-center gap-3 p-3 rounded-lg bg-cyan-600 hover:bg-cyan-700 transition-colors mb-2',
}}
>
<span className="font-medium">Start - SSR Demos</span>
</Link> */}
</>
);
}

View File

@@ -2,7 +2,6 @@ import { authClient } from '@/lib/auth-client';
import { m } from '@/paraglide/messages';
import { KeyIcon } from '@phosphor-icons/react';
import { useForm } from '@tanstack/react-form';
import i18next from 'i18next';
import { toast } from 'sonner';
import z from 'zod';
import { Button } from '../ui/button';
@@ -72,9 +71,14 @@ const ChangePasswordForm = () => {
},
onError: (ctx) => {
console.log(ctx.error.code);
toast.error(i18next.t(`backend_${ctx.error.code}` as any), {
richColors: true,
});
toast.error(
(
m[`backend_${ctx.error.code}` as keyof typeof m] as () => string
)(),
{
richColors: true,
},
);
},
},
);

View File

@@ -5,7 +5,6 @@ import { ProfileInput, profileUpdateSchema } from '@/service/profile.schema';
import { UserCircleIcon } from '@phosphor-icons/react';
import { useForm } from '@tanstack/react-form';
import { useQueryClient } from '@tanstack/react-query';
import i18next from 'i18next';
import { useRef } from 'react';
import { toast } from 'sonner';
import { useAuth } from '../auth/auth-provider';
@@ -67,9 +66,16 @@ const ProfileForm = () => {
});
},
onError: (ctx) => {
toast.error(i18next.t(`backend.${ctx.error.code}` as any), {
richColors: true,
});
toast.error(
(
m[
`backend_${ctx.error.code}` as keyof typeof m
] as () => string
)(),
{
richColors: true,
},
);
},
},
);

View File

@@ -27,10 +27,7 @@ const SettingsForm = () => {
const updateMutation = useMutation({
mutationFn: updateAdminSettings,
onSuccess: () => {
// setLocale(variables.data.site_language as Locale);
queryClient.invalidateQueries({
queryKey: [...settingQueries.all, 'list'],
});
queryClient.invalidateQueries(settingQueries.listAdmin());
toast.success(m.settings_messages_update_success(), {
richColors: true,
});

View File

@@ -3,7 +3,6 @@ import { m } from '@/paraglide/messages';
import { useForm } from '@tanstack/react-form';
import { useQueryClient } from '@tanstack/react-query';
import { createLink, useNavigate } from '@tanstack/react-router';
import i18next from 'i18next';
import { toast } from 'sonner';
import z from 'zod';
import { Button } from '../ui/button';
@@ -14,7 +13,7 @@ import { Input } from '../ui/input';
const SignInFormSchema = z.object({
email: z
.string()
.nonempty(m.common_is_required({ field: m.login_page_form_email }))
.nonempty(m.common_is_required({ field: m.login_page_form_email() }))
.email(m.login_page_messages_email_invalid()),
password: z.string().nonempty(
m.common_is_required({
@@ -52,9 +51,14 @@ const SignInForm = () => {
});
},
onError: (ctx) => {
toast.error(i18next.t(`backend.${ctx.error.code}` as any), {
richColors: true,
});
toast.error(
(
m[`backend_${ctx.error.code}` as keyof typeof m] as () => string
)(),
{
richColors: true,
},
);
},
},
);

View File

@@ -9,7 +9,6 @@ import {
} from '@phosphor-icons/react';
import { useQueryClient } from '@tanstack/react-query';
import { createLink, Link, useNavigate } from '@tanstack/react-router';
import i18next from 'i18next';
import { toast } from 'sonner';
import { useAuth } from '../auth/auth-provider';
import AvatarUser from '../avatar/AvatarUser';
@@ -49,9 +48,14 @@ const NavUser = () => {
});
},
onError: (ctx) => {
toast.error(i18next.t(`backend_${ctx.error.code}` as any), {
richColors: true,
});
toast.error(
(
m[`backend_${ctx.error.code}` as keyof typeof m] as () => string
)(),
{
richColors: true,
},
);
},
},
});

View File

@@ -1,13 +0,0 @@
import { createServerFn } from '@tanstack/react-start'
export const getPunkSongs = createServerFn({
method: 'GET',
}).handler(async () => [
{ id: 1, name: 'Teenage Dirtbag', artist: 'Wheatus' },
{ id: 2, name: 'Smells Like Teen Spirit', artist: 'Nirvana' },
{ id: 3, name: 'The Middle', artist: 'Jimmy Eat World' },
{ id: 4, name: 'My Own Worst Enemy', artist: 'Lit' },
{ id: 5, name: 'Fat Lip', artist: 'Sum 41' },
{ id: 6, name: 'All the Small Things', artist: 'blink-182' },
{ id: 7, name: 'Beverly Hills', artist: 'Weezer' },
])

View File

@@ -57,3 +57,8 @@ export type Invitation = Prisma.InvitationModel
*
*/
export type Setting = Prisma.SettingModel
/**
* Model Audit
*
*/
export type Audit = Prisma.AuditModel

View File

@@ -79,3 +79,8 @@ export type Invitation = Prisma.InvitationModel
*
*/
export type Setting = Prisma.SettingModel
/**
* Model Audit
*
*/
export type Audit = Prisma.AuditModel

File diff suppressed because one or more lines are too long

View File

@@ -391,7 +391,8 @@ export const ModelName = {
Organization: 'Organization',
Member: 'Member',
Invitation: 'Invitation',
Setting: 'Setting'
Setting: 'Setting',
Audit: 'Audit'
} as const
export type ModelName = (typeof ModelName)[keyof typeof ModelName]
@@ -407,7 +408,7 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
omit: GlobalOmitOptions
}
meta: {
modelProps: "user" | "session" | "account" | "verification" | "organization" | "member" | "invitation" | "setting"
modelProps: "user" | "session" | "account" | "verification" | "organization" | "member" | "invitation" | "setting" | "audit"
txIsolationLevel: TransactionIsolationLevel
}
model: {
@@ -1003,6 +1004,80 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
}
}
}
Audit: {
payload: Prisma.$AuditPayload<ExtArgs>
fields: Prisma.AuditFieldRefs
operations: {
findUnique: {
args: Prisma.AuditFindUniqueArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$AuditPayload> | null
}
findUniqueOrThrow: {
args: Prisma.AuditFindUniqueOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$AuditPayload>
}
findFirst: {
args: Prisma.AuditFindFirstArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$AuditPayload> | null
}
findFirstOrThrow: {
args: Prisma.AuditFindFirstOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$AuditPayload>
}
findMany: {
args: Prisma.AuditFindManyArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$AuditPayload>[]
}
create: {
args: Prisma.AuditCreateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$AuditPayload>
}
createMany: {
args: Prisma.AuditCreateManyArgs<ExtArgs>
result: BatchPayload
}
createManyAndReturn: {
args: Prisma.AuditCreateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$AuditPayload>[]
}
delete: {
args: Prisma.AuditDeleteArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$AuditPayload>
}
update: {
args: Prisma.AuditUpdateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$AuditPayload>
}
deleteMany: {
args: Prisma.AuditDeleteManyArgs<ExtArgs>
result: BatchPayload
}
updateMany: {
args: Prisma.AuditUpdateManyArgs<ExtArgs>
result: BatchPayload
}
updateManyAndReturn: {
args: Prisma.AuditUpdateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$AuditPayload>[]
}
upsert: {
args: Prisma.AuditUpsertArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$AuditPayload>
}
aggregate: {
args: Prisma.AuditAggregateArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.AggregateAudit>
}
groupBy: {
args: Prisma.AuditGroupByArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.AuditGroupByOutputType>[]
}
count: {
args: Prisma.AuditCountArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.AuditCountAggregateOutputType> | number
}
}
}
}
} & {
other: {
@@ -1157,6 +1232,20 @@ export const SettingScalarFieldEnum = {
export type SettingScalarFieldEnum = (typeof SettingScalarFieldEnum)[keyof typeof SettingScalarFieldEnum]
export const AuditScalarFieldEnum = {
id: 'id',
userId: 'userId',
action: 'action',
tableName: 'tableName',
recordId: 'recordId',
oldValue: 'oldValue',
newValue: 'newValue',
createdAt: 'createdAt'
} as const
export type AuditScalarFieldEnum = (typeof AuditScalarFieldEnum)[keyof typeof AuditScalarFieldEnum]
export const SortOrder = {
asc: 'asc',
desc: 'desc'
@@ -1338,6 +1427,7 @@ export type GlobalOmitConfig = {
member?: Prisma.MemberOmit
invitation?: Prisma.InvitationOmit
setting?: Prisma.SettingOmit
audit?: Prisma.AuditOmit
}
/* Types for Logging */

View File

@@ -58,7 +58,8 @@ export const ModelName = {
Organization: 'Organization',
Member: 'Member',
Invitation: 'Invitation',
Setting: 'Setting'
Setting: 'Setting',
Audit: 'Audit'
} as const
export type ModelName = (typeof ModelName)[keyof typeof ModelName]
@@ -192,6 +193,20 @@ export const SettingScalarFieldEnum = {
export type SettingScalarFieldEnum = (typeof SettingScalarFieldEnum)[keyof typeof SettingScalarFieldEnum]
export const AuditScalarFieldEnum = {
id: 'id',
userId: 'userId',
action: 'action',
tableName: 'tableName',
recordId: 'recordId',
oldValue: 'oldValue',
newValue: 'newValue',
createdAt: 'createdAt'
} as const
export type AuditScalarFieldEnum = (typeof AuditScalarFieldEnum)[keyof typeof AuditScalarFieldEnum]
export const SortOrder = {
asc: 'asc',
desc: 'desc'

View File

@@ -16,4 +16,5 @@ export type * from './models/Organization.ts'
export type * from './models/Member.ts'
export type * from './models/Invitation.ts'
export type * from './models/Setting.ts'
export type * from './models/Audit.ts'
export type * from './commonInputTypes.ts'

File diff suppressed because it is too large Load Diff

View File

@@ -232,6 +232,7 @@ export type UserWhereInput = {
banExpires?: Prisma.DateTimeNullableFilter<"User"> | Date | string | null
sessions?: Prisma.SessionListRelationFilter
accounts?: Prisma.AccountListRelationFilter
audit?: Prisma.AuditListRelationFilter
members?: Prisma.MemberListRelationFilter
invitations?: Prisma.InvitationListRelationFilter
}
@@ -250,6 +251,7 @@ export type UserOrderByWithRelationInput = {
banExpires?: Prisma.SortOrderInput | Prisma.SortOrder
sessions?: Prisma.SessionOrderByRelationAggregateInput
accounts?: Prisma.AccountOrderByRelationAggregateInput
audit?: Prisma.AuditOrderByRelationAggregateInput
members?: Prisma.MemberOrderByRelationAggregateInput
invitations?: Prisma.InvitationOrderByRelationAggregateInput
}
@@ -271,6 +273,7 @@ export type UserWhereUniqueInput = Prisma.AtLeast<{
banExpires?: Prisma.DateTimeNullableFilter<"User"> | Date | string | null
sessions?: Prisma.SessionListRelationFilter
accounts?: Prisma.AccountListRelationFilter
audit?: Prisma.AuditListRelationFilter
members?: Prisma.MemberListRelationFilter
invitations?: Prisma.InvitationListRelationFilter
}, "id" | "email">
@@ -323,6 +326,7 @@ export type UserCreateInput = {
banExpires?: Date | string | null
sessions?: Prisma.SessionCreateNestedManyWithoutUserInput
accounts?: Prisma.AccountCreateNestedManyWithoutUserInput
audit?: Prisma.AuditCreateNestedManyWithoutUserInput
members?: Prisma.MemberCreateNestedManyWithoutUserInput
invitations?: Prisma.InvitationCreateNestedManyWithoutUserInput
}
@@ -341,6 +345,7 @@ export type UserUncheckedCreateInput = {
banExpires?: Date | string | null
sessions?: Prisma.SessionUncheckedCreateNestedManyWithoutUserInput
accounts?: Prisma.AccountUncheckedCreateNestedManyWithoutUserInput
audit?: Prisma.AuditUncheckedCreateNestedManyWithoutUserInput
members?: Prisma.MemberUncheckedCreateNestedManyWithoutUserInput
invitations?: Prisma.InvitationUncheckedCreateNestedManyWithoutUserInput
}
@@ -359,6 +364,7 @@ export type UserUpdateInput = {
banExpires?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
sessions?: Prisma.SessionUpdateManyWithoutUserNestedInput
accounts?: Prisma.AccountUpdateManyWithoutUserNestedInput
audit?: Prisma.AuditUpdateManyWithoutUserNestedInput
members?: Prisma.MemberUpdateManyWithoutUserNestedInput
invitations?: Prisma.InvitationUpdateManyWithoutUserNestedInput
}
@@ -377,6 +383,7 @@ export type UserUncheckedUpdateInput = {
banExpires?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
sessions?: Prisma.SessionUncheckedUpdateManyWithoutUserNestedInput
accounts?: Prisma.AccountUncheckedUpdateManyWithoutUserNestedInput
audit?: Prisma.AuditUncheckedUpdateManyWithoutUserNestedInput
members?: Prisma.MemberUncheckedUpdateManyWithoutUserNestedInput
invitations?: Prisma.InvitationUncheckedUpdateManyWithoutUserNestedInput
}
@@ -550,6 +557,20 @@ export type UserUpdateOneRequiredWithoutInvitationsNestedInput = {
update?: Prisma.XOR<Prisma.XOR<Prisma.UserUpdateToOneWithWhereWithoutInvitationsInput, Prisma.UserUpdateWithoutInvitationsInput>, Prisma.UserUncheckedUpdateWithoutInvitationsInput>
}
export type UserCreateNestedOneWithoutAuditInput = {
create?: Prisma.XOR<Prisma.UserCreateWithoutAuditInput, Prisma.UserUncheckedCreateWithoutAuditInput>
connectOrCreate?: Prisma.UserCreateOrConnectWithoutAuditInput
connect?: Prisma.UserWhereUniqueInput
}
export type UserUpdateOneRequiredWithoutAuditNestedInput = {
create?: Prisma.XOR<Prisma.UserCreateWithoutAuditInput, Prisma.UserUncheckedCreateWithoutAuditInput>
connectOrCreate?: Prisma.UserCreateOrConnectWithoutAuditInput
upsert?: Prisma.UserUpsertWithoutAuditInput
connect?: Prisma.UserWhereUniqueInput
update?: Prisma.XOR<Prisma.XOR<Prisma.UserUpdateToOneWithWhereWithoutAuditInput, Prisma.UserUpdateWithoutAuditInput>, Prisma.UserUncheckedUpdateWithoutAuditInput>
}
export type UserCreateWithoutSessionsInput = {
id?: string
name: string
@@ -563,6 +584,7 @@ export type UserCreateWithoutSessionsInput = {
banReason?: string | null
banExpires?: Date | string | null
accounts?: Prisma.AccountCreateNestedManyWithoutUserInput
audit?: Prisma.AuditCreateNestedManyWithoutUserInput
members?: Prisma.MemberCreateNestedManyWithoutUserInput
invitations?: Prisma.InvitationCreateNestedManyWithoutUserInput
}
@@ -580,6 +602,7 @@ export type UserUncheckedCreateWithoutSessionsInput = {
banReason?: string | null
banExpires?: Date | string | null
accounts?: Prisma.AccountUncheckedCreateNestedManyWithoutUserInput
audit?: Prisma.AuditUncheckedCreateNestedManyWithoutUserInput
members?: Prisma.MemberUncheckedCreateNestedManyWithoutUserInput
invitations?: Prisma.InvitationUncheckedCreateNestedManyWithoutUserInput
}
@@ -613,6 +636,7 @@ export type UserUpdateWithoutSessionsInput = {
banReason?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
banExpires?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
accounts?: Prisma.AccountUpdateManyWithoutUserNestedInput
audit?: Prisma.AuditUpdateManyWithoutUserNestedInput
members?: Prisma.MemberUpdateManyWithoutUserNestedInput
invitations?: Prisma.InvitationUpdateManyWithoutUserNestedInput
}
@@ -630,6 +654,7 @@ export type UserUncheckedUpdateWithoutSessionsInput = {
banReason?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
banExpires?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
accounts?: Prisma.AccountUncheckedUpdateManyWithoutUserNestedInput
audit?: Prisma.AuditUncheckedUpdateManyWithoutUserNestedInput
members?: Prisma.MemberUncheckedUpdateManyWithoutUserNestedInput
invitations?: Prisma.InvitationUncheckedUpdateManyWithoutUserNestedInput
}
@@ -647,6 +672,7 @@ export type UserCreateWithoutAccountsInput = {
banReason?: string | null
banExpires?: Date | string | null
sessions?: Prisma.SessionCreateNestedManyWithoutUserInput
audit?: Prisma.AuditCreateNestedManyWithoutUserInput
members?: Prisma.MemberCreateNestedManyWithoutUserInput
invitations?: Prisma.InvitationCreateNestedManyWithoutUserInput
}
@@ -664,6 +690,7 @@ export type UserUncheckedCreateWithoutAccountsInput = {
banReason?: string | null
banExpires?: Date | string | null
sessions?: Prisma.SessionUncheckedCreateNestedManyWithoutUserInput
audit?: Prisma.AuditUncheckedCreateNestedManyWithoutUserInput
members?: Prisma.MemberUncheckedCreateNestedManyWithoutUserInput
invitations?: Prisma.InvitationUncheckedCreateNestedManyWithoutUserInput
}
@@ -697,6 +724,7 @@ export type UserUpdateWithoutAccountsInput = {
banReason?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
banExpires?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
sessions?: Prisma.SessionUpdateManyWithoutUserNestedInput
audit?: Prisma.AuditUpdateManyWithoutUserNestedInput
members?: Prisma.MemberUpdateManyWithoutUserNestedInput
invitations?: Prisma.InvitationUpdateManyWithoutUserNestedInput
}
@@ -714,6 +742,7 @@ export type UserUncheckedUpdateWithoutAccountsInput = {
banReason?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
banExpires?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
sessions?: Prisma.SessionUncheckedUpdateManyWithoutUserNestedInput
audit?: Prisma.AuditUncheckedUpdateManyWithoutUserNestedInput
members?: Prisma.MemberUncheckedUpdateManyWithoutUserNestedInput
invitations?: Prisma.InvitationUncheckedUpdateManyWithoutUserNestedInput
}
@@ -732,6 +761,7 @@ export type UserCreateWithoutMembersInput = {
banExpires?: Date | string | null
sessions?: Prisma.SessionCreateNestedManyWithoutUserInput
accounts?: Prisma.AccountCreateNestedManyWithoutUserInput
audit?: Prisma.AuditCreateNestedManyWithoutUserInput
invitations?: Prisma.InvitationCreateNestedManyWithoutUserInput
}
@@ -749,6 +779,7 @@ export type UserUncheckedCreateWithoutMembersInput = {
banExpires?: Date | string | null
sessions?: Prisma.SessionUncheckedCreateNestedManyWithoutUserInput
accounts?: Prisma.AccountUncheckedCreateNestedManyWithoutUserInput
audit?: Prisma.AuditUncheckedCreateNestedManyWithoutUserInput
invitations?: Prisma.InvitationUncheckedCreateNestedManyWithoutUserInput
}
@@ -782,6 +813,7 @@ export type UserUpdateWithoutMembersInput = {
banExpires?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
sessions?: Prisma.SessionUpdateManyWithoutUserNestedInput
accounts?: Prisma.AccountUpdateManyWithoutUserNestedInput
audit?: Prisma.AuditUpdateManyWithoutUserNestedInput
invitations?: Prisma.InvitationUpdateManyWithoutUserNestedInput
}
@@ -799,6 +831,7 @@ export type UserUncheckedUpdateWithoutMembersInput = {
banExpires?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
sessions?: Prisma.SessionUncheckedUpdateManyWithoutUserNestedInput
accounts?: Prisma.AccountUncheckedUpdateManyWithoutUserNestedInput
audit?: Prisma.AuditUncheckedUpdateManyWithoutUserNestedInput
invitations?: Prisma.InvitationUncheckedUpdateManyWithoutUserNestedInput
}
@@ -816,6 +849,7 @@ export type UserCreateWithoutInvitationsInput = {
banExpires?: Date | string | null
sessions?: Prisma.SessionCreateNestedManyWithoutUserInput
accounts?: Prisma.AccountCreateNestedManyWithoutUserInput
audit?: Prisma.AuditCreateNestedManyWithoutUserInput
members?: Prisma.MemberCreateNestedManyWithoutUserInput
}
@@ -833,6 +867,7 @@ export type UserUncheckedCreateWithoutInvitationsInput = {
banExpires?: Date | string | null
sessions?: Prisma.SessionUncheckedCreateNestedManyWithoutUserInput
accounts?: Prisma.AccountUncheckedCreateNestedManyWithoutUserInput
audit?: Prisma.AuditUncheckedCreateNestedManyWithoutUserInput
members?: Prisma.MemberUncheckedCreateNestedManyWithoutUserInput
}
@@ -866,6 +901,7 @@ export type UserUpdateWithoutInvitationsInput = {
banExpires?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
sessions?: Prisma.SessionUpdateManyWithoutUserNestedInput
accounts?: Prisma.AccountUpdateManyWithoutUserNestedInput
audit?: Prisma.AuditUpdateManyWithoutUserNestedInput
members?: Prisma.MemberUpdateManyWithoutUserNestedInput
}
@@ -883,9 +919,98 @@ export type UserUncheckedUpdateWithoutInvitationsInput = {
banExpires?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
sessions?: Prisma.SessionUncheckedUpdateManyWithoutUserNestedInput
accounts?: Prisma.AccountUncheckedUpdateManyWithoutUserNestedInput
audit?: Prisma.AuditUncheckedUpdateManyWithoutUserNestedInput
members?: Prisma.MemberUncheckedUpdateManyWithoutUserNestedInput
}
export type UserCreateWithoutAuditInput = {
id?: string
name: string
email: string
emailVerified?: boolean
image?: string | null
createdAt?: Date | string
updatedAt?: Date | string
role?: string | null
banned?: boolean | null
banReason?: string | null
banExpires?: Date | string | null
sessions?: Prisma.SessionCreateNestedManyWithoutUserInput
accounts?: Prisma.AccountCreateNestedManyWithoutUserInput
members?: Prisma.MemberCreateNestedManyWithoutUserInput
invitations?: Prisma.InvitationCreateNestedManyWithoutUserInput
}
export type UserUncheckedCreateWithoutAuditInput = {
id?: string
name: string
email: string
emailVerified?: boolean
image?: string | null
createdAt?: Date | string
updatedAt?: Date | string
role?: string | null
banned?: boolean | null
banReason?: string | null
banExpires?: Date | string | null
sessions?: Prisma.SessionUncheckedCreateNestedManyWithoutUserInput
accounts?: Prisma.AccountUncheckedCreateNestedManyWithoutUserInput
members?: Prisma.MemberUncheckedCreateNestedManyWithoutUserInput
invitations?: Prisma.InvitationUncheckedCreateNestedManyWithoutUserInput
}
export type UserCreateOrConnectWithoutAuditInput = {
where: Prisma.UserWhereUniqueInput
create: Prisma.XOR<Prisma.UserCreateWithoutAuditInput, Prisma.UserUncheckedCreateWithoutAuditInput>
}
export type UserUpsertWithoutAuditInput = {
update: Prisma.XOR<Prisma.UserUpdateWithoutAuditInput, Prisma.UserUncheckedUpdateWithoutAuditInput>
create: Prisma.XOR<Prisma.UserCreateWithoutAuditInput, Prisma.UserUncheckedCreateWithoutAuditInput>
where?: Prisma.UserWhereInput
}
export type UserUpdateToOneWithWhereWithoutAuditInput = {
where?: Prisma.UserWhereInput
data: Prisma.XOR<Prisma.UserUpdateWithoutAuditInput, Prisma.UserUncheckedUpdateWithoutAuditInput>
}
export type UserUpdateWithoutAuditInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
name?: Prisma.StringFieldUpdateOperationsInput | string
email?: Prisma.StringFieldUpdateOperationsInput | string
emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
role?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
banned?: Prisma.NullableBoolFieldUpdateOperationsInput | boolean | null
banReason?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
banExpires?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
sessions?: Prisma.SessionUpdateManyWithoutUserNestedInput
accounts?: Prisma.AccountUpdateManyWithoutUserNestedInput
members?: Prisma.MemberUpdateManyWithoutUserNestedInput
invitations?: Prisma.InvitationUpdateManyWithoutUserNestedInput
}
export type UserUncheckedUpdateWithoutAuditInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
name?: Prisma.StringFieldUpdateOperationsInput | string
email?: Prisma.StringFieldUpdateOperationsInput | string
emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
role?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
banned?: Prisma.NullableBoolFieldUpdateOperationsInput | boolean | null
banReason?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
banExpires?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
sessions?: Prisma.SessionUncheckedUpdateManyWithoutUserNestedInput
accounts?: Prisma.AccountUncheckedUpdateManyWithoutUserNestedInput
members?: Prisma.MemberUncheckedUpdateManyWithoutUserNestedInput
invitations?: Prisma.InvitationUncheckedUpdateManyWithoutUserNestedInput
}
/**
* Count Type UserCountOutputType
@@ -894,6 +1019,7 @@ export type UserUncheckedUpdateWithoutInvitationsInput = {
export type UserCountOutputType = {
sessions: number
accounts: number
audit: number
members: number
invitations: number
}
@@ -901,6 +1027,7 @@ export type UserCountOutputType = {
export type UserCountOutputTypeSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
sessions?: boolean | UserCountOutputTypeCountSessionsArgs
accounts?: boolean | UserCountOutputTypeCountAccountsArgs
audit?: boolean | UserCountOutputTypeCountAuditArgs
members?: boolean | UserCountOutputTypeCountMembersArgs
invitations?: boolean | UserCountOutputTypeCountInvitationsArgs
}
@@ -929,6 +1056,13 @@ export type UserCountOutputTypeCountAccountsArgs<ExtArgs extends runtime.Types.E
where?: Prisma.AccountWhereInput
}
/**
* UserCountOutputType without action
*/
export type UserCountOutputTypeCountAuditArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
where?: Prisma.AuditWhereInput
}
/**
* UserCountOutputType without action
*/
@@ -958,6 +1092,7 @@ export type UserSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = r
banExpires?: boolean
sessions?: boolean | Prisma.User$sessionsArgs<ExtArgs>
accounts?: boolean | Prisma.User$accountsArgs<ExtArgs>
audit?: boolean | Prisma.User$auditArgs<ExtArgs>
members?: boolean | Prisma.User$membersArgs<ExtArgs>
invitations?: boolean | Prisma.User$invitationsArgs<ExtArgs>
_count?: boolean | Prisma.UserCountOutputTypeDefaultArgs<ExtArgs>
@@ -1009,6 +1144,7 @@ export type UserOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = run
export type UserInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
sessions?: boolean | Prisma.User$sessionsArgs<ExtArgs>
accounts?: boolean | Prisma.User$accountsArgs<ExtArgs>
audit?: boolean | Prisma.User$auditArgs<ExtArgs>
members?: boolean | Prisma.User$membersArgs<ExtArgs>
invitations?: boolean | Prisma.User$invitationsArgs<ExtArgs>
_count?: boolean | Prisma.UserCountOutputTypeDefaultArgs<ExtArgs>
@@ -1021,6 +1157,7 @@ export type $UserPayload<ExtArgs extends runtime.Types.Extensions.InternalArgs =
objects: {
sessions: Prisma.$SessionPayload<ExtArgs>[]
accounts: Prisma.$AccountPayload<ExtArgs>[]
audit: Prisma.$AuditPayload<ExtArgs>[]
members: Prisma.$MemberPayload<ExtArgs>[]
invitations: Prisma.$InvitationPayload<ExtArgs>[]
}
@@ -1432,6 +1569,7 @@ export interface Prisma__UserClient<T, Null = never, ExtArgs extends runtime.Typ
readonly [Symbol.toStringTag]: "PrismaPromise"
sessions<T extends Prisma.User$sessionsArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.User$sessionsArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$SessionPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
accounts<T extends Prisma.User$accountsArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.User$accountsArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$AccountPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
audit<T extends Prisma.User$auditArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.User$auditArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$AuditPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
members<T extends Prisma.User$membersArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.User$membersArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$MemberPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
invitations<T extends Prisma.User$invitationsArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.User$invitationsArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$InvitationPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
/**
@@ -1909,6 +2047,30 @@ export type User$accountsArgs<ExtArgs extends runtime.Types.Extensions.InternalA
distinct?: Prisma.AccountScalarFieldEnum | Prisma.AccountScalarFieldEnum[]
}
/**
* User.audit
*/
export type User$auditArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
/**
* Select specific fields to fetch from the Audit
*/
select?: Prisma.AuditSelect<ExtArgs> | null
/**
* Omit specific fields from the Audit
*/
omit?: Prisma.AuditOmit<ExtArgs> | null
/**
* Choose, which related nodes to fetch as well
*/
include?: Prisma.AuditInclude<ExtArgs> | null
where?: Prisma.AuditWhereInput
orderBy?: Prisma.AuditOrderByWithRelationInput | Prisma.AuditOrderByWithRelationInput[]
cursor?: Prisma.AuditWhereUniqueInput
take?: number
skip?: number
distinct?: Prisma.AuditScalarFieldEnum | Prisma.AuditScalarFieldEnum[]
}
/**
* User.members
*/

View File

@@ -8,6 +8,7 @@ import {
owner,
} from '@/lib/auth/organization-permissions';
import { ac, admin, user } from '@/lib/auth/permissions';
import { createAuditLog } from '@/service/audit.api';
import { betterAuth } from 'better-auth';
import { prismaAdapter } from 'better-auth/adapters/prisma';
import { admin as adminPlugin, organization } from 'better-auth/plugins';
@@ -67,6 +68,73 @@ 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: 'update',
tableName: '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: 'change_password',
tableName: 'account',
recordId: account.id,
oldValue: 'Change Password',
newValue: 'Change Password',
userId: account.userId,
});
}
},
},
},
session: {
create: {
after: async (session, context) => {
if (context?.path.includes('/sign-in')) {
await createAuditLog({
action: 'sign_in',
tableName: 'session',
recordId: session.id,
oldValue: '',
newValue: JSON.stringify(session),
userId: session.userId,
});
}
},
},
delete: {
after: async (session, context) => {
if (context?.path === '/sign-out') {
await createAuditLog({
action: 'sign_out',
tableName: 'session',
recordId: session.id,
oldValue: JSON.stringify(session),
newValue: '',
userId: session.userId,
});
}
},
},
},
},
});

View File

@@ -1,7 +1,6 @@
import { createServerFn } from '@tanstack/react-start';
import { getRequestHeaders } from '@tanstack/react-start/server';
import { auth } from '../auth';
import { authClient } from '../auth-client';
export type Session = typeof auth.$Infer.Session;
@@ -12,8 +11,3 @@ export const getSession = createServerFn({ method: 'GET' }).handler(
return session;
},
);
export const sessionPush = () => {
const session = authClient.getSession();
return session;
};

View File

@@ -14,7 +14,7 @@ import { Route as appIndexRouteImport } from './routes/(app)/index'
import { Route as authSignUpRouteImport } from './routes/(auth)/sign-up'
import { Route as authSignInRouteImport } from './routes/(auth)/sign-in'
import { Route as appauthRouteRouteImport } from './routes/(app)/(auth)/route'
import { Route as ApiAuthSplatRouteImport } from './routes/api/auth.$'
import { Route as ApiAuthSplatRouteImport } from './routes/api.auth.$'
import { Route as appauthSettingsRouteImport } from './routes/(app)/(auth)/settings'
import { Route as appauthDashboardRouteImport } from './routes/(app)/(auth)/dashboard'
import { Route as appauthAccountRouteRouteImport } from './routes/(app)/(auth)/account/route'

View File

@@ -1,11 +1,11 @@
import { createFileRoute, Outlet, redirect } from '@tanstack/react-router';
import { createFileRoute, Outlet } from '@tanstack/react-router';
export const Route = createFileRoute('/(app)/(auth)')({
beforeLoad: async ({ context }) => {
if (!context.userSession) {
throw redirect({ to: '/sign-in' });
}
},
// beforeLoad: async ({ context }) => {
// if (!context.userSession) {
// throw redirect({ to: '/sign-in' });
// }
// },
component: RouteComponent,
});

View File

@@ -1,7 +1,6 @@
import NotFound from '@/components/NotFound';
import { Toaster } from '@/components/ui/sonner';
import { getLocale } from '@/paraglide/runtime';
import { sessionQueries } from '@/service/queries';
import {
CheckIcon,
InfoIcon,
@@ -20,43 +19,41 @@ import React from 'react';
import TanStackQueryDevtools from '../integrations/tanstack-query/devtools';
import appCss from '../styles.css?url';
interface MyRouterContext {
queryClient: QueryClient;
}
export const Route = createRootRouteWithContext<MyRouterContext>()({
beforeLoad: async ({ context }) => {
const userSession = await context.queryClient.fetchQuery(
sessionQueries.user(),
);
return { userSession };
export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()(
{
// beforeLoad: async ({ context }) => {
// const userSession = await context.queryClient.fetchQuery(
// sessionQueries.user(),
// );
// return { userSession };
// },
head: () => ({
meta: [
{
charSet: 'utf-8',
},
{
name: 'viewport',
content: 'width=device-width, initial-scale=1',
},
{
title: 'Fuware',
},
{
description: 'Fuware is a platform for managing your business.',
},
],
links: [
{
rel: 'stylesheet',
href: appCss,
},
],
}),
shellComponent: RootDocument,
notFoundComponent: () => <NotFound />,
},
head: () => ({
meta: [
{
charSet: 'utf-8',
},
{
name: 'viewport',
content: 'width=device-width, initial-scale=1',
},
{
title: 'Fuware',
},
{
description: 'Fuware is a platform for managing your business.',
},
],
links: [
{
rel: 'stylesheet',
href: appCss,
},
],
}),
shellComponent: RootDocument,
notFoundComponent: () => <NotFound />,
});
);
function RootDocument({ children }: { children: React.ReactNode }) {
return (
@@ -79,20 +76,20 @@ function RootDocument({ children }: { children: React.ReactNode }) {
warning: <WarningIcon className="text-yellow-500" size={16} />,
}}
/>
<React.Suspense>
<TanStackDevtools
config={{
position: 'bottom-right',
}}
plugins={[
{
name: 'Tanstack Router',
render: <TanStackRouterDevtoolsPanel />,
},
TanStackQueryDevtools,
]}
/>
</React.Suspense>
{/* <React.Suspense> */}
<TanStackDevtools
config={{
position: 'bottom-right',
}}
plugins={[
{
name: 'Tanstack Router',
render: <TanStackRouterDevtoolsPanel />,
},
TanStackQueryDevtools,
]}
/>
{/* </React.Suspense> */}
<Scripts />
</body>
</html>

15
src/service/audit.api.ts Normal file
View File

@@ -0,0 +1,15 @@
import { prisma } from '@/db';
import { Audit } from '@/generated/prisma/client';
export async function createAuditLog(data: Omit<Audit, 'id' | 'createdAt'>) {
try {
await prisma.audit.create({
data: {
...data,
},
});
} catch (error) {
console.log(error);
throw error;
}
}

View File

@@ -1,5 +1,4 @@
import { getSession } from '@/lib/auth/session';
// import { sessionPush } from '@/lib/auth/session';
import { queryOptions } from '@tanstack/react-query';
import { getAdminSettings, getUserSettings } from './setting.api';
@@ -9,7 +8,6 @@ export const sessionQueries = {
queryOptions({
queryKey: [...sessionQueries.all, 'session'],
queryFn: () => getSession(),
// queryFn: () => sessionPush(),
staleTime: 1000 * 60 * 20,
retry: false,
}),

View File

@@ -1,54 +1,87 @@
import { prisma } from '@/db';
import { Setting } from '@/generated/prisma/client';
import { authMiddleware } from '@/lib/middleware';
import { extractDiffObjects } from '@/utils/help';
import { createServerFn } from '@tanstack/react-start';
import { createAuditLog } from './audit.api';
import { settingSchema, userSettingSchema } from './setting.schema';
// import { settingSchema } from './setting.schema';
type AdminSettingReturn = {
[key: string]: Setting;
[key: string]: Pick<Setting, 'id' | 'key' | 'value'> | string;
};
async function getAllAdminSettings(valueOnly = false) {
const settings = await prisma.setting.findMany({
where: {
relation: 'admin',
},
select: {
id: true,
key: true,
value: true,
},
});
const results: AdminSettingReturn = {};
settings.forEach((setting) => {
results[setting.key] = valueOnly ? setting.value : setting;
});
return results;
}
// Settings for admin
export const getAdminSettings = createServerFn({ method: 'GET' })
.middleware([authMiddleware])
.handler(async () => {
const settings = await prisma.setting.findMany({
where: {
relation: 'admin',
},
});
const results: AdminSettingReturn = {};
settings.forEach((setting) => {
results[setting.key] = setting;
});
return results;
return await getAllAdminSettings();
});
export const updateAdminSettings = createServerFn({ method: 'POST' })
.inputValidator(settingSchema)
.middleware([authMiddleware])
.handler(async ({ data }) => {
// Update each setting
const updates = Object.entries(data).map(([key, value]) =>
prisma.setting.upsert({
where: { key },
update: { value },
create: {
key,
value,
description: key, // or provide proper descriptions
relation: 'admin',
},
}),
);
.handler(async ({ data, context }) => {
try {
const oldSetting = await getAllAdminSettings(true);
await prisma.$transaction(updates);
// Update each setting
const updates = Object.entries(data).map(([key, value]) =>
prisma.setting.upsert({
where: { key },
update: { value },
create: {
key,
value,
description: key, // or provide proper descriptions
relation: 'admin',
},
}),
);
return { success: true };
const updated = await prisma.$transaction(updates);
const [oldValue, newValue] = extractDiffObjects(oldSetting, data);
const keyEdit = Object.keys(oldValue);
const listId = updated
.filter((s) => keyEdit.includes(s.key))
.map((s) => s.id)
.join(', ');
await createAuditLog({
action: 'update',
tableName: 'setting',
recordId: listId,
oldValue: JSON.stringify(oldValue),
newValue: JSON.stringify(newValue),
userId: context.user.id,
});
return { success: true };
} catch (error) {
console.log(error);
throw error;
}
});
// Setting for user
@@ -65,10 +98,15 @@ export const getUserSettings = createServerFn({ method: 'GET' })
relation: 'user',
key: context.user.id,
},
select: {
id: true,
key: true,
value: true,
},
});
return {
settings: settings as Setting,
settings,
value: JSON.parse(settings.value) as UserSetting,
};
} catch (error) {
@@ -82,7 +120,17 @@ export const updateUserSettings = createServerFn({ method: 'POST' })
.handler(async ({ data, context }) => {
// Update each setting
try {
await prisma.setting.upsert({
const oldSetting = await prisma.setting.findUnique({
where: {
relation: 'user',
key: context.user.id,
},
select: {
value: true,
},
});
const updated = await prisma.setting.upsert({
where: { key: context.user.id },
update: { value: JSON.stringify(data) },
create: {
@@ -93,6 +141,17 @@ export const updateUserSettings = createServerFn({ method: 'POST' })
},
});
if (oldSetting) {
await createAuditLog({
action: 'update',
tableName: 'setting',
recordId: updated.id,
oldValue: oldSetting.value,
newValue: JSON.stringify(data),
userId: context.user.id,
});
}
return { success: true };
} catch (error) {
throw error;

27
src/utils/help.ts Normal file
View File

@@ -0,0 +1,27 @@
export function jsonSupport(jsonSTR: string) {
try {
const data = JSON.parse(jsonSTR);
return JSON.stringify(data, undefined, 2);
} catch {
return jsonSTR;
}
}
type AnyRecord = Record<string, unknown>;
export function extractDiffObjects<T extends AnyRecord>(
a: T,
b: T,
): [Partial<T>, Partial<T>] {
return (Object.keys(a) as (keyof T)[]).reduce(
([accA, accB], key) => {
if (a[key] !== b[key]) {
accA[key] = a[key];
accB[key] = b[key];
}
return [accA, accB];
},
[{}, {}] as [Partial<T>, Partial<T>],
);
}