diff --git a/prisma/migrations/20260320011900_add_audit_log/migration.sql b/prisma/migrations/20260320011900_add_audit_log/migration.sql new file mode 100644 index 0000000..3348545 --- /dev/null +++ b/prisma/migrations/20260320011900_add_audit_log/migration.sql @@ -0,0 +1,11 @@ +-- CreateTable +CREATE TABLE "audit_log" ( + "id" SERIAL NOT NULL, + "action" TEXT NOT NULL, + "model" TEXT NOT NULL, + "recordId" INTEGER, + "userId" INTEGER, + "timestamp" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "audit_log_pkey" PRIMARY KEY ("id") +); diff --git a/prisma/migrations/20260321120242_add_soft_delete/migration.sql b/prisma/migrations/20260321120242_add_soft_delete/migration.sql new file mode 100644 index 0000000..287fe31 --- /dev/null +++ b/prisma/migrations/20260321120242_add_soft_delete/migration.sql @@ -0,0 +1,8 @@ +-- AlterTable +ALTER TABLE "item" ADD COLUMN "deleted_at" TIMESTAMP(3); + +-- AlterTable +ALTER TABLE "loan_request" ADD COLUMN "deleted_at" TIMESTAMP(3); + +-- AlterTable +ALTER TABLE "user" ADD COLUMN "deleted_at" TIMESTAMP(3); diff --git a/prisma/migrations/20260321130945_fix_audit_display_name/migration.sql b/prisma/migrations/20260321130945_fix_audit_display_name/migration.sql new file mode 100644 index 0000000..fe8f08f --- /dev/null +++ b/prisma/migrations/20260321130945_fix_audit_display_name/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "audit_log" ADD COLUMN "displayName" TEXT; diff --git a/prisma/migrations/20260321132645_fix_record_key/migration.sql b/prisma/migrations/20260321132645_fix_record_key/migration.sql new file mode 100644 index 0000000..26dd654 --- /dev/null +++ b/prisma/migrations/20260321132645_fix_record_key/migration.sql @@ -0,0 +1,9 @@ +/* + Warnings: + + - You are about to drop the column `recordId` on the `audit_log` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "audit_log" DROP COLUMN "recordId", +ADD COLUMN "recordKey" TEXT; diff --git a/prisma/migrations/20260321140242_fix_record_id/migration.sql b/prisma/migrations/20260321140242_fix_record_id/migration.sql new file mode 100644 index 0000000..7b4dfdb --- /dev/null +++ b/prisma/migrations/20260321140242_fix_record_id/migration.sql @@ -0,0 +1,9 @@ +/* + Warnings: + + - You are about to drop the column `recordKey` on the `audit_log` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "audit_log" DROP COLUMN "recordKey", +ADD COLUMN "recordId" INTEGER; diff --git a/prisma/migrations/20260321140822_temp/migration.sql b/prisma/migrations/20260321140822_temp/migration.sql new file mode 100644 index 0000000..ae57e2b --- /dev/null +++ b/prisma/migrations/20260321140822_temp/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - You are about to drop the column `displayName` on the `audit_log` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "audit_log" DROP COLUMN "displayName"; diff --git a/prisma/migrations/20260322014732_add_soft_delete_audit/migration.sql b/prisma/migrations/20260322014732_add_soft_delete_audit/migration.sql new file mode 100644 index 0000000..84838ac --- /dev/null +++ b/prisma/migrations/20260322014732_add_soft_delete_audit/migration.sql @@ -0,0 +1,11 @@ +-- AlterTable +ALTER TABLE "ih" ADD COLUMN "deleted_at" TIMESTAMP(3); + +-- AlterTable +ALTER TABLE "ih_member" ADD COLUMN "deleted_at" TIMESTAMP(3); + +-- AlterTable +ALTER TABLE "loan_item_detail" ADD COLUMN "deleted_at" TIMESTAMP(3); + +-- AlterTable +ALTER TABLE "sloc" ADD COLUMN "deleted_at" TIMESTAMP(3); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index bee858c..572efdc 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -56,6 +56,8 @@ model User { loanRequests LoanRequest[] @relation("RequesterLoanRequests") handledLoans LoanRequest[] @relation("LoggieLoanRequests") + deletedAt DateTime? @map("deleted_at") // Soft delete + @@map("user") } @@ -69,6 +71,8 @@ model IHMember { user User @relation(fields: [userId], references: [userId], onDelete: Cascade) ih IH @relation(fields: [ihId], references: [ihId], onDelete: Cascade) + deletedAt DateTime? @map("deleted_at") // Soft delete + @@unique([userId, ihId]) @@map("ih_member") } @@ -80,6 +84,8 @@ model IH { members IHMember[] // Multiple POCs via IHMember items Item[] @relation("ItemIH") + deletedAt DateTime? @map("deleted_at") // Soft delete + @@map("ih") } @@ -100,6 +106,8 @@ model Item { ih IH @relation("ItemIH", fields: [itemIh], references: [ihId], onDelete: Cascade, onUpdate: Cascade) loanDetails LoanItemDetail[] + deletedAt DateTime? @map("deleted_at") // Soft delete + @@map("item") } @@ -112,6 +120,8 @@ model LoanItemDetail { loanRequest LoanRequest @relation(fields: [refNo], references: [refNo], onDelete: Cascade, onUpdate: Cascade) item Item @relation(fields: [itemId], references: [itemId], onDelete: Cascade, onUpdate: Cascade) + deletedAt DateTime? @map("deleted_at") // Soft delete + @@map("loan_item_detail") } @@ -133,6 +143,8 @@ model LoanRequest { loanDetails LoanItemDetail[] + deletedAt DateTime? @map("deleted_at") // Soft delete + @@map("loan_request") } @@ -141,5 +153,19 @@ model Sloc { slocName String @map("sloc_name") items Item[] @relation("ItemSloc") + deletedAt DateTime? @map("deleted_at") // Soft delete + @@map("sloc") } + +// Audit Log +model AuditLog { + id Int @id @default(autoincrement()) + action String // CREATE or UPDATE or DELETE + model String + recordId Int? + userId Int? + timestamp DateTime @default(now()) + + @@map("audit_log") +} \ No newline at end of file diff --git a/src/lib/prisma.ts b/src/lib/prisma.ts index 4ca3560..edce062 100644 --- a/src/lib/prisma.ts +++ b/src/lib/prisma.ts @@ -1,4 +1,5 @@ import { PrismaClient } from '@prisma/client'; +import { getSession } from '@/lib/auth/session'; // add prisma to the NodeJS global type interface GlobalPrisma { @@ -8,17 +9,185 @@ interface GlobalPrisma { // prevent multiple instances of Prisma Client in development declare const global: GlobalPrisma & typeof globalThis; -let prisma: PrismaClient; +let prismaBase: PrismaClient; // check to use this workaround only in development and not in production if (process.env.NODE_ENV === 'production') { - prisma = new PrismaClient(); + prismaBase= new PrismaClient(); } else { if (!global.prisma) { global.prisma = new PrismaClient(); } - prisma = global.prisma; + prismaBase = global.prisma; } -export default prisma; +// Support soft delete +const SOFT_DELETE = new Set([ + 'User', + 'IHMember', + 'IH', + 'Item', + 'LoanItemDetail', + 'LoanRequest', + 'Sloc' +]); +// Get id of record +const MODEL_ID_MAP: Record = { + User: 'userId', + IHMember: 'id', + IH: 'ihId', + Item: 'itemId', + LoanItemDetail: 'loanDetailId', + LoanRequest: 'refNo', + Sloc: 'slocId' +}; + +// Access to model client by name +const MODEL_CLIENT_MAP: Record = { + User: prismaBase.user, + IHMember: prismaBase.iHMember, + IH: prismaBase.iH, + Item: prismaBase.item, + LoanItemDetail: prismaBase.loanItemDetail, + LoanRequest: prismaBase.loanRequest, + Sloc: prismaBase.sloc, +}; + +// Audit +async function audit( + action: string, + model: string, + recordId: number | null, + userId?: number | null, +) { + try { + await prismaBase.auditLog.create({ + data: { + action: action.toUpperCase(), + model, + recordId, + userId: userId ?? null, + }, + }); + } catch (err) { + console.error('Audit log failed:', err); + } +} + +export function createPrisma() { + return prismaBase.$extends({ + query: { + $allModels: { + async create({ model, args, query }: any) { + const result = await query(args); + const id = MODEL_ID_MAP[model]; + const session = await getSession(); + const userId = session?.user.userId ?? null; + + await audit('create', model, result?.[id] ?? null, userId); + return result; + }, + + async update({ model, args, query }: any) { + const result = await query(args); + const id = MODEL_ID_MAP[model]; + const session = await getSession(); + const userId = session?.user.userId ?? null; + + await audit('update', model, result?.[id] ?? null, userId); + return result; + }, + + async delete({ model, args, query }: any) { + const id = MODEL_ID_MAP[model]; + const recordId = args?.where?.[id] ?? null; + const table = MODEL_CLIENT_MAP[model]; + const session = await getSession(); + const userId = session?.user.userId ?? null; + + if (!SOFT_DELETE.has(model)) { + const result = await table.delete(args); + await audit('delete', model, recordId, userId); + return result; + } + + const result = await table.update({ + where: args.where, + data: { deletedAt: new Date() }, + }); + await audit('delete', model, recordId, userId); + return result; + }, + + async createMany({ model, args, query }: any) { + const result = await query(args); + const session = await getSession(); + const userId = session?.user.userId ?? null; + + await audit('createMany', model, null, userId); + return result; + }, + + async updateMany({ model, args, query }: any) { + const result = await query(args); + const session = await getSession(); + const userId = session?.user.userId ?? null; + + await audit('updateMany', model, null, userId); + return result; + }, + + async deleteMany({ model, args, query }: any) { + const table = MODEL_CLIENT_MAP[model]; + const session = await getSession(); + const userId = session?.user.userId ?? null; + + if (!SOFT_DELETE.has(model)) { + const result = await table.deleteMany(args); + await audit('deleteMany', model, null, userId); + return result; + } + + const result = await table.updateMany({ + ...args, + data: { deletedAt: new Date() }, + }); + await audit('deleteMany', model, null, userId); + return result; + }, + + // For soft delete + async findMany({ model, args, query }: any) { + if (!SOFT_DELETE.has(model)) { + return query(args); + } + + return query({ + ...args, + where: { + ...args.where, + deletedAt: null, + }, + }); + }, + + async findFirst({ model, args, query }: any) { + if (!SOFT_DELETE.has(model)) { + return query(args); + } + + return query({ + ...args, + where: { + ...args.where, + deletedAt: null, + }, + }); + }, + }, + } + })}; + + const prisma = createPrisma(); + export default prisma; \ No newline at end of file