feat(db): Drizzle DAL with withUser/asAdmin GUC wrapper (Phase 2)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,28 @@
|
|||||||
|
import { sql } from 'drizzle-orm'
|
||||||
|
import type { DrizzleTransaction } from './with-user'
|
||||||
|
|
||||||
|
export type { DrizzleTransaction }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes to audit_log via the write_audit_log() SECURITY DEFINER function.
|
||||||
|
* Must be called inside a withUser() transaction — the function reads app_current_user_id().
|
||||||
|
* Signature mirrors the DB function exactly.
|
||||||
|
*/
|
||||||
|
export async function writeAuditLog(
|
||||||
|
tx: DrizzleTransaction,
|
||||||
|
tableName: string,
|
||||||
|
recordId: string,
|
||||||
|
action: string,
|
||||||
|
newValue?: Record<string, unknown> | null,
|
||||||
|
oldValue?: Record<string, unknown> | null
|
||||||
|
): Promise<void> {
|
||||||
|
await tx.execute(
|
||||||
|
sql`SELECT write_audit_log(
|
||||||
|
${tableName},
|
||||||
|
${recordId}::uuid,
|
||||||
|
${action},
|
||||||
|
${newValue ? JSON.stringify(newValue) : null}::jsonb,
|
||||||
|
${oldValue ? JSON.stringify(oldValue) : null}::jsonb
|
||||||
|
)`
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import 'server-only'
|
||||||
|
import { Pool } from 'pg'
|
||||||
|
import { drizzle } from 'drizzle-orm/node-postgres'
|
||||||
|
import * as schema from './schema'
|
||||||
|
|
||||||
|
if (!process.env.DATABASE_URL) {
|
||||||
|
throw new Error('DATABASE_URL not configured')
|
||||||
|
}
|
||||||
|
if (!process.env.DATABASE_URL_ADMIN) {
|
||||||
|
throw new Error('DATABASE_URL_ADMIN not configured')
|
||||||
|
}
|
||||||
|
|
||||||
|
// app_user pool: RLS enforced. Used for all normal user operations.
|
||||||
|
const userPool = new Pool({
|
||||||
|
connectionString: process.env.DATABASE_URL,
|
||||||
|
max: 10,
|
||||||
|
})
|
||||||
|
|
||||||
|
// app_admin pool: BYPASSRLS. Used for admin user CRUD and service operations.
|
||||||
|
const adminPool = new Pool({
|
||||||
|
connectionString: process.env.DATABASE_URL_ADMIN,
|
||||||
|
max: 5,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const userDb = drizzle(userPool, { schema })
|
||||||
|
export const adminDb = drizzle(adminPool, { schema })
|
||||||
|
|
||||||
|
export type UserDb = typeof userDb
|
||||||
|
export type AdminDb = typeof adminDb
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
import {
|
||||||
|
pgTable,
|
||||||
|
pgEnum,
|
||||||
|
uuid,
|
||||||
|
text,
|
||||||
|
boolean,
|
||||||
|
timestamp,
|
||||||
|
smallint,
|
||||||
|
integer,
|
||||||
|
date,
|
||||||
|
jsonb,
|
||||||
|
customType,
|
||||||
|
} from 'drizzle-orm/pg-core'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Custom type: pgvector
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const vector = customType<{ data: number[]; driverData: string }>({
|
||||||
|
dataType(config) {
|
||||||
|
return `vector(${(config as { dimensions?: number }).dimensions ?? 1536})`
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Enums
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
export const userRoleEnum = pgEnum('user_role', [
|
||||||
|
'reporter', 'supervisor', 'hse', 'capa_owner', 'management', 'admin',
|
||||||
|
])
|
||||||
|
|
||||||
|
export const incidentTypeEnum = pgEnum('incident_type', [
|
||||||
|
'injury', 'near_miss', 'hazard', 'asset_damage', 'environmental',
|
||||||
|
'security', 'fire', 'transport',
|
||||||
|
])
|
||||||
|
|
||||||
|
export const incidentStatusEnum = pgEnum('incident_status', [
|
||||||
|
'reported', 'triaged', 'investigating', 'capa_pending', 'verification', 'closed',
|
||||||
|
])
|
||||||
|
|
||||||
|
export const medicalStatusEnum = pgEnum('medical_status', [
|
||||||
|
'none', 'first_aid', 'medical_treatment', 'lti',
|
||||||
|
])
|
||||||
|
|
||||||
|
export const evidenceStageEnum = pgEnum('evidence_stage', [
|
||||||
|
'report', 'response', 'investigation', 'capa', 'verification',
|
||||||
|
])
|
||||||
|
|
||||||
|
export const rcaMethodEnum = pgEnum('rca_method', ['five_why', 'fishbone', 'other'])
|
||||||
|
|
||||||
|
export const capaPriorityEnum = pgEnum('capa_priority', ['low', 'med', 'high'])
|
||||||
|
|
||||||
|
export const capaStatusEnum = pgEnum('capa_status', [
|
||||||
|
'open', 'in_progress', 'overdue', 'pending_verification',
|
||||||
|
'verified', 'reopened', 'closed',
|
||||||
|
])
|
||||||
|
|
||||||
|
export const doshFormTypeEnum = pgEnum('dosh_form_type', ['jkkp6', 'jkkp7', 'jkkp8'])
|
||||||
|
export const doshStatusEnum = pgEnum('dosh_status', ['not_required', 'pending', 'submitted'])
|
||||||
|
|
||||||
|
export const notificationChannelEnum = pgEnum('notification_channel', [
|
||||||
|
'email', 'whatsapp', 'in_app',
|
||||||
|
])
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tables (dependency order)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const sites = pgTable('sites', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
name: text('name').notNull(),
|
||||||
|
address: text('address'),
|
||||||
|
region: text('region'),
|
||||||
|
active: boolean('active').notNull().default(true),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const zones = pgTable('zones', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
siteId: uuid('site_id').notNull().references(() => sites.id),
|
||||||
|
name: text('name').notNull(),
|
||||||
|
qrCodeToken: text('qr_code_token').notNull().unique(),
|
||||||
|
active: boolean('active').notNull().default(true),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const users = pgTable('users', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
name: text('name').notNull().default(''),
|
||||||
|
email: text('email').notNull().default('').unique(),
|
||||||
|
phone: text('phone'),
|
||||||
|
role: userRoleEnum('role').notNull().default('reporter'),
|
||||||
|
department: text('department'),
|
||||||
|
siteId: uuid('site_id').references(() => sites.id),
|
||||||
|
active: boolean('active').notNull().default(true),
|
||||||
|
passwordHash: text('password_hash').notNull().default(''),
|
||||||
|
emailVerifiedAt: timestamp('email_verified_at', { withTimezone: true }),
|
||||||
|
lastLoginAt: timestamp('last_login_at', { withTimezone: true }),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const trucks = pgTable('trucks', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
truckNo: text('truck_no').notNull().unique(),
|
||||||
|
carrier: text('carrier'),
|
||||||
|
active: boolean('active').notNull().default(true),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const incidents = pgTable('incidents', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
referenceNo: text('reference_no').unique(),
|
||||||
|
incidentType: incidentTypeEnum('incident_type').notNull(),
|
||||||
|
siteId: uuid('site_id').notNull().references(() => sites.id),
|
||||||
|
zoneId: uuid('zone_id').references(() => zones.id),
|
||||||
|
truckId: uuid('truck_id').references(() => trucks.id),
|
||||||
|
reportedBy: uuid('reported_by').notNull().references(() => users.id),
|
||||||
|
reportedAt: timestamp('reported_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
description: text('description').notNull(),
|
||||||
|
severity: smallint('severity'),
|
||||||
|
status: incidentStatusEnum('status').notNull().default('reported'),
|
||||||
|
injuryInvolved: boolean('injury_involved').notNull().default(false),
|
||||||
|
assetInvolved: boolean('asset_involved').notNull().default(false),
|
||||||
|
medicalStatus: medicalStatusEnum('medical_status'),
|
||||||
|
lostDays: integer('lost_days'),
|
||||||
|
isFatality: boolean('is_fatality').notNull().default(false),
|
||||||
|
isSeriousBodilyInjury: boolean('is_serious_bodily_injury').notNull().default(false),
|
||||||
|
isDangerousOccurrence: boolean('is_dangerous_occurrence').notNull().default(false),
|
||||||
|
isOccupationalDisease: boolean('is_occupational_disease').notNull().default(false),
|
||||||
|
triageNotes: text('triage_notes'),
|
||||||
|
triagedBy: uuid('triaged_by').references(() => users.id),
|
||||||
|
triagedAt: timestamp('triaged_at', { withTimezone: true }),
|
||||||
|
typeDetails: jsonb('type_details'),
|
||||||
|
embedding: vector('embedding', { dimensions: 768 }),
|
||||||
|
closedAt: timestamp('closed_at', { withTimezone: true }),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const evidenceFiles = pgTable('evidence_files', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
incidentId: uuid('incident_id').notNull().references(() => incidents.id),
|
||||||
|
stage: evidenceStageEnum('stage').notNull(),
|
||||||
|
fileUrl: text('file_url').notNull(),
|
||||||
|
fileType: text('file_type').notNull(),
|
||||||
|
fileHash: text('file_hash').notNull(),
|
||||||
|
uploadedBy: uuid('uploaded_by').notNull().references(() => users.id),
|
||||||
|
uploadedAt: timestamp('uploaded_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
deleted: boolean('deleted').notNull().default(false),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const investigations = pgTable('investigations', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
incidentId: uuid('incident_id').notNull().references(() => incidents.id),
|
||||||
|
investigatorId: uuid('investigator_id').notNull().references(() => users.id),
|
||||||
|
method: rcaMethodEnum('method').notNull().default('five_why'),
|
||||||
|
findingsText: text('findings_text'),
|
||||||
|
rootCauseSummary: text('root_cause_summary'),
|
||||||
|
alcoholTestResult: text('alcohol_test_result'),
|
||||||
|
urineTestResult: text('urine_test_result'),
|
||||||
|
witnessStatementRefs: text('witness_statement_refs').array(),
|
||||||
|
fiveWhySteps: jsonb('five_why_steps'),
|
||||||
|
fishboneCategories: jsonb('fishbone_categories'),
|
||||||
|
completedAt: timestamp('completed_at', { withTimezone: true }),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const capaActions = pgTable('capa_actions', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
incidentId: uuid('incident_id').notNull().references(() => incidents.id),
|
||||||
|
rootCauseRef: text('root_cause_ref'),
|
||||||
|
description: text('description').notNull(),
|
||||||
|
ownerUserId: uuid('owner_user_id').notNull().references(() => users.id),
|
||||||
|
department: text('department').notNull(),
|
||||||
|
dueDate: date('due_date').notNull(),
|
||||||
|
priority: capaPriorityEnum('priority').notNull().default('med'),
|
||||||
|
status: capaStatusEnum('status').notNull().default('open'),
|
||||||
|
completedAt: timestamp('completed_at', { withTimezone: true }),
|
||||||
|
verifiedBy: uuid('verified_by').references(() => users.id),
|
||||||
|
verifiedAt: timestamp('verified_at', { withTimezone: true }),
|
||||||
|
effectivenessRecheckDate: date('effectiveness_recheck_date'),
|
||||||
|
effectivenessRecheckRound: integer('effectiveness_recheck_round').notNull().default(0),
|
||||||
|
ownerNotes: text('owner_notes'),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const doshReports = pgTable('dosh_reports', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
incidentId: uuid('incident_id').notNull().references(() => incidents.id),
|
||||||
|
formType: doshFormTypeEnum('form_type').notNull(),
|
||||||
|
status: doshStatusEnum('status').notNull().default('not_required'),
|
||||||
|
submittedAt: timestamp('submitted_at', { withTimezone: true }),
|
||||||
|
submittedBy: uuid('submitted_by').references(() => users.id),
|
||||||
|
fileUrl: text('file_url'),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const notificationsLog = pgTable('notifications_log', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
incidentId: uuid('incident_id').references(() => incidents.id),
|
||||||
|
capaId: uuid('capa_id').references(() => capaActions.id),
|
||||||
|
channel: notificationChannelEnum('channel').notNull(),
|
||||||
|
recipient: text('recipient').notNull(),
|
||||||
|
recipientUserId: uuid('recipient_user_id').references(() => users.id),
|
||||||
|
readAt: timestamp('read_at', { withTimezone: true }),
|
||||||
|
title: text('title'),
|
||||||
|
link: text('link'),
|
||||||
|
sentAt: timestamp('sent_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
status: text('status').notNull().default('sent'),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const auditLog = pgTable('audit_log', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
tableName: text('table_name').notNull(),
|
||||||
|
recordId: uuid('record_id').notNull(),
|
||||||
|
action: text('action').notNull(),
|
||||||
|
changedBy: uuid('changed_by').references(() => users.id),
|
||||||
|
changedAt: timestamp('changed_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
oldValue: jsonb('old_value'),
|
||||||
|
newValue: jsonb('new_value'),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const appSettings = pgTable('app_settings', {
|
||||||
|
key: text('key').primaryKey(),
|
||||||
|
value: text('value').notNull(),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
updatedBy: uuid('updated_by').references(() => users.id),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const incidentAddenda = pgTable('incident_addenda', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
incidentId: uuid('incident_id').notNull().references(() => incidents.id),
|
||||||
|
author: uuid('author').notNull().references(() => users.id),
|
||||||
|
body: text('body').notNull(),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
})
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import 'server-only'
|
||||||
|
import { sql } from 'drizzle-orm'
|
||||||
|
import { userDb, adminDb, type UserDb, type AdminDb } from './index'
|
||||||
|
|
||||||
|
export type DrizzleTransaction = Parameters<Parameters<typeof userDb.transaction>[0]>[0]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs fn inside a transaction with app.user_id SET LOCAL to userId.
|
||||||
|
* All RLS policies read this GUC via app_current_user_id().
|
||||||
|
* The GUC is LOCAL so it is automatically cleared when the transaction ends.
|
||||||
|
*/
|
||||||
|
export async function withUser<T>(
|
||||||
|
userId: string,
|
||||||
|
fn: (tx: DrizzleTransaction) => Promise<T>
|
||||||
|
): Promise<T> {
|
||||||
|
return userDb.transaction(async (tx) => {
|
||||||
|
await tx.execute(sql`SELECT set_config('app.user_id', ${userId}, true)`)
|
||||||
|
return fn(tx)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs fn against the admin (BYPASSRLS) pool.
|
||||||
|
* Use only for: admin user CRUD, service operations that legitimately bypass RLS.
|
||||||
|
* Never use for regular user data access.
|
||||||
|
*/
|
||||||
|
export async function asAdmin<T>(fn: (db: AdminDb) => Promise<T>): Promise<T> {
|
||||||
|
return fn(adminDb)
|
||||||
|
}
|
||||||
Generated
+1260
-2
File diff suppressed because it is too large
Load Diff
@@ -15,11 +15,13 @@
|
|||||||
"@anthropic-ai/sdk": "^0.111.0",
|
"@anthropic-ai/sdk": "^0.111.0",
|
||||||
"@supabase/ssr": "^0.12.0",
|
"@supabase/ssr": "^0.12.0",
|
||||||
"@supabase/supabase-js": "^2.110.2",
|
"@supabase/supabase-js": "^2.110.2",
|
||||||
|
"drizzle-orm": "^0.45.2",
|
||||||
"file-type": "^22.0.1",
|
"file-type": "^22.0.1",
|
||||||
"idb": "^8.0.3",
|
"idb": "^8.0.3",
|
||||||
"next": "^15.5.20",
|
"next": "^15.5.20",
|
||||||
"openai": "^6.46.0",
|
"openai": "^6.46.0",
|
||||||
"pdf-lib": "^1.17.1",
|
"pdf-lib": "^1.17.1",
|
||||||
|
"pg": "^8.22.0",
|
||||||
"react": "^19.2.7",
|
"react": "^19.2.7",
|
||||||
"react-dom": "^19.2.7",
|
"react-dom": "^19.2.7",
|
||||||
"resend": "^6.17.2"
|
"resend": "^6.17.2"
|
||||||
@@ -29,10 +31,12 @@
|
|||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
|
"@types/pg": "^8.20.0",
|
||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"@vitejs/plugin-react": "^6.0.3",
|
"@vitejs/plugin-react": "^6.0.3",
|
||||||
|
"drizzle-kit": "^0.31.10",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.2.10",
|
"eslint-config-next": "16.2.10",
|
||||||
"jsdom": "^29.1.1",
|
"jsdom": "^29.1.1",
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// no-op stub for server-only in test environment
|
||||||
|
export {}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
|
||||||
|
// Mock the pools before importing with-user
|
||||||
|
vi.mock('../../../lib/db/index', () => {
|
||||||
|
const mockTx = {
|
||||||
|
execute: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}
|
||||||
|
const mockUserDb = {
|
||||||
|
transaction: vi.fn().mockImplementation(async (fn: (tx: typeof mockTx) => Promise<unknown>) => fn(mockTx)),
|
||||||
|
}
|
||||||
|
const mockAdminDb = { query: vi.fn() }
|
||||||
|
return {
|
||||||
|
userDb: mockUserDb,
|
||||||
|
adminDb: mockAdminDb,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
import { withUser, asAdmin } from '../../../lib/db/with-user'
|
||||||
|
import { userDb, adminDb } from '../../../lib/db/index'
|
||||||
|
|
||||||
|
describe('withUser', () => {
|
||||||
|
beforeEach(() => vi.clearAllMocks())
|
||||||
|
|
||||||
|
it('sets app.user_id GUC via set_config inside transaction', async () => {
|
||||||
|
const userId = 'test-user-uuid-1234'
|
||||||
|
const fn = vi.fn().mockResolvedValue('result')
|
||||||
|
|
||||||
|
const result = await withUser(userId, fn)
|
||||||
|
|
||||||
|
expect(result).toBe('result')
|
||||||
|
// transaction was called
|
||||||
|
expect((userDb as unknown as { transaction: ReturnType<typeof vi.fn> }).transaction).toHaveBeenCalledOnce()
|
||||||
|
// fn received the tx object
|
||||||
|
expect(fn).toHaveBeenCalledOnce()
|
||||||
|
// The tx.execute was called (GUC set_config invoked)
|
||||||
|
const tx = fn.mock.calls[0][0] as { execute: ReturnType<typeof vi.fn> }
|
||||||
|
expect(tx.execute).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('propagates errors from fn', async () => {
|
||||||
|
const fn = vi.fn().mockRejectedValue(new Error('query failed'))
|
||||||
|
await expect(withUser('uid', fn)).rejects.toThrow('query failed')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('asAdmin', () => {
|
||||||
|
it('passes adminDb to fn', async () => {
|
||||||
|
const fn = vi.fn().mockResolvedValue('admin-result')
|
||||||
|
const result = await asAdmin(fn)
|
||||||
|
expect(result).toBe('admin-result')
|
||||||
|
expect(fn).toHaveBeenCalledWith(adminDb)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -14,6 +14,7 @@ export default defineConfig({
|
|||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@': path.resolve(__dirname, '.'),
|
'@': path.resolve(__dirname, '.'),
|
||||||
|
'server-only': path.resolve(__dirname, 'tests/__mocks__/server-only.ts'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user