feat: integrate SalesImportJob tracking table and fix E2E test race conditions

This commit is contained in:
Luis Gabriel Ramos Robles 2026-06-12 01:59:31 +00:00
parent 49229263c6
commit 95b0bd1bfc
46 changed files with 6780 additions and 8484 deletions

8
.dockerignore Normal file
View file

@ -0,0 +1,8 @@
node_modules
.next
.git
out
build
prisma/screenshots
*.log
.env*.local

27
Dockerfile Normal file
View file

@ -0,0 +1,27 @@
FROM node:20-alpine
RUN apk add --no-cache libc6-compat openssl
RUN npm install -g pnpm@9
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
# Skip Puppeteer Chrome download in Docker builds
ENV PUPPETEER_SKIP_DOWNLOAD=1
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm prisma generate
ENV NEXT_TELEMETRY_DISABLED=1
RUN pnpm run build
EXPOSE 3000
ENV PORT=3000
CMD ["pnpm", "start"]

View file

@ -82,7 +82,7 @@ erDiagram
string username
string email
string password_hash
string role "ADMIN | DIRECTOR | GERENTE | LIDER | ANALISTA | CONSULTA | COLABORADOR"
string role "admin | director | hotel_manager | commercial_leader | analyst | auditor | collaborator"
int hotel_id FK
string area
string status "ACTIVE | INACTIVE"

View file

@ -47,7 +47,7 @@ Goals are assigned per period (`YYYY-MM`) at different scopes (`INDIVIDUAL` | `T
## 2. API Specifications
### 2.1. `POST /api/plans` (Create Plan)
- **Role Restriction**: `ADMIN` or `DIRECTOR`
- **Role Restriction**: `admin` or `director`
- **Request Body**:
```json
{
@ -60,15 +60,15 @@ Goals are assigned per period (`YYYY-MM`) at different scopes (`INDIVIDUAL` | `T
```
### 2.2. `PUT /api/plans/[id]` (Update/Version Plan)
- **Role Restriction**: `ADMIN` or `DIRECTOR`
- **Role Restriction**: `admin` or `director`
- **Logic**: Evaluates status to execute in-place updates or clone/version logic.
### 2.3. `POST /api/plans/[id]/rules` (Configure Rules)
- **Role Restriction**: `ADMIN` or `DIRECTOR`
- **Role Restriction**: `admin` or `director`
- **Request Body**: Array of calculation rules to bulk upsert.
### 2.4. `POST /api/goals` (Assign Goals)
- **Role Restriction**: `ADMIN` or `DIRECTOR`
- **Role Restriction**: `admin` or `director`
---

7941
package-lock.json generated

File diff suppressed because it is too large Load diff

5633
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,18 @@
-- CreateTable
CREATE TABLE "sales_import_jobs" (
"id" SERIAL NOT NULL,
"idempotency_key" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'PROCESSING',
"error_message" TEXT,
"uploaded_by" INTEGER NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "sales_import_jobs_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "sales_import_jobs_idempotency_key_key" ON "sales_import_jobs"("idempotency_key");
-- AddForeignKey
ALTER TABLE "sales_import_jobs" ADD CONSTRAINT "sales_import_jobs_uploaded_by_fkey" FOREIGN KEY ("uploaded_by") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View file

@ -1,62 +1,22 @@
-- RLS Policies and Seeding Script for Hoteles Estelar Variable Remuneration System
SET app.current_user_role = 'ADMIN';
SET app.current_user_role = 'admin';
-- ==========================================
-- 1. SEED DATA
-- 0. TEMPORARILY DISABLE RLS FOR SEEDING
-- ==========================================
-- Seed Regions
INSERT INTO "regions" ("name", "code") VALUES
('Bogotá', 'BOG'),
('Antioquia', 'ANT'),
('Caribe', 'CAR')
ON CONFLICT ("code") DO NOTHING;
-- Seed Hotels
INSERT INTO "hotels" ("name", "code", "region_id", "status") VALUES
('Estelar Parque de la 93', 'EST-P93', (SELECT id FROM regions WHERE code = 'BOG'), 'ACTIVE'),
('Estelar Medellin', 'EST-MDE', (SELECT id FROM regions WHERE code = 'ANT'), 'ACTIVE'),
('Estelar Cartagena', 'EST-CTG', (SELECT id FROM regions WHERE code = 'CAR'), 'ACTIVE')
ON CONFLICT ("code") DO NOTHING;
-- Seed Users
-- password_hash is bcrypt hash of 'password123'
INSERT INTO "users" ("username", "email", "password_hash", "role", "hotel_id", "area", "status", "created_at") VALUES
('admin', 'admin@estelar.com', '$2b$10$lRn/GrwzaEGxWqqQGGz0f.44nBywObKkGWRajlxWeDGZ3o4f7ayjy', 'ADMIN', (SELECT id FROM hotels WHERE code = 'EST-P93'), 'Sistemas', 'ACTIVE', NOW()),
('director', 'director@estelar.com', '$2b$10$lRn/GrwzaEGxWqqQGGz0f.44nBywObKkGWRajlxWeDGZ3o4f7ayjy', 'DIRECTOR', (SELECT id FROM hotels WHERE code = 'EST-P93'), 'Comercial', 'ACTIVE', NOW()),
('gerente_mde', 'gerente.mde@estelar.com', '$2b$10$lRn/GrwzaEGxWqqQGGz0f.44nBywObKkGWRajlxWeDGZ3o4f7ayjy', 'GERENTE', (SELECT id FROM hotels WHERE code = 'EST-MDE'), 'Administracion', 'ACTIVE', NOW()),
('lider_ctg', 'lider.ctg@estelar.com', '$2b$10$lRn/GrwzaEGxWqqQGGz0f.44nBywObKkGWRajlxWeDGZ3o4f7ayjy', 'LIDER', (SELECT id FROM hotels WHERE code = 'EST-CTG'), 'Ventas', 'ACTIVE', NOW()),
('analista', 'analista@estelar.com', '$2b$10$lRn/GrwzaEGxWqqQGGz0f.44nBywObKkGWRajlxWeDGZ3o4f7ayjy', 'ANALISTA', (SELECT id FROM hotels WHERE code = 'EST-P93'), 'Finanzas', 'ACTIVE', NOW()),
('consulta', 'consulta@estelar.com', '$2b$10$lRn/GrwzaEGxWqqQGGz0f.44nBywObKkGWRajlxWeDGZ3o4f7ayjy', 'CONSULTA', (SELECT id FROM hotels WHERE code = 'EST-P93'), 'Auditoria', 'ACTIVE', NOW()),
('colaborador_mde', 'colaborador.mde@estelar.com', '$2b$10$lRn/GrwzaEGxWqqQGGz0f.44nBywObKkGWRajlxWeDGZ3o4f7ayjy', 'COLABORADOR', (SELECT id FROM hotels WHERE code = 'EST-MDE'), 'Ventas', 'ACTIVE', NOW())
ON CONFLICT ("username") DO NOTHING;
ALTER TABLE "regions" DISABLE ROW LEVEL SECURITY;
ALTER TABLE "hotels" DISABLE ROW LEVEL SECURITY;
ALTER TABLE "users" DISABLE ROW LEVEL SECURITY;
ALTER TABLE "goals" DISABLE ROW LEVEL SECURITY;
ALTER TABLE "sales_results" DISABLE ROW LEVEL SECURITY;
ALTER TABLE "settlements" DISABLE ROW LEVEL SECURITY;
ALTER TABLE "audit_logs" DISABLE ROW LEVEL SECURITY;
ALTER TABLE "compensation_plans" DISABLE ROW LEVEL SECURITY;
ALTER TABLE "calculation_rules" DISABLE ROW LEVEL SECURITY;
-- ==========================================
-- 2. ENABLE ROW-LEVEL SECURITY
-- 1. CLEAN UP OLD POLICIES
-- ==========================================
ALTER TABLE "regions" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "hotels" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "users" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "goals" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "sales_results" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "settlements" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "audit_logs" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "regions" FORCE ROW LEVEL SECURITY;
ALTER TABLE "hotels" FORCE ROW LEVEL SECURITY;
ALTER TABLE "users" FORCE ROW LEVEL SECURITY;
ALTER TABLE "goals" FORCE ROW LEVEL SECURITY;
ALTER TABLE "sales_results" FORCE ROW LEVEL SECURITY;
ALTER TABLE "settlements" FORCE ROW LEVEL SECURITY;
ALTER TABLE "audit_logs" FORCE ROW LEVEL SECURITY;
-- ==========================================
-- 3. CLEAN UP OLD POLICIES
-- ==========================================
DROP POLICY IF EXISTS audit_logs_insert_policy ON "audit_logs";
DROP POLICY IF EXISTS audit_logs_select_policy ON "audit_logs";
DROP POLICY IF EXISTS regions_select_policy ON "regions";
@ -76,6 +36,61 @@ DROP POLICY IF EXISTS plans_modify_policy ON "compensation_plans";
DROP POLICY IF EXISTS rules_select_policy ON "calculation_rules";
DROP POLICY IF EXISTS rules_modify_policy ON "calculation_rules";
-- ==========================================
-- 2. SEED DATA
-- ==========================================
-- Seed Regions
INSERT INTO "regions" ("name", "code") VALUES
('Bogotá', 'BOG'),
('Antioquia', 'ANT'),
('Caribe', 'CAR')
ON CONFLICT ("code") DO NOTHING;
-- Seed Hotels
INSERT INTO "hotels" ("name", "code", "region_id", "status") VALUES
('Estelar Parque de la 93', 'EST-P93', (SELECT id FROM regions WHERE code = 'BOG'), 'ACTIVE'),
('Estelar Medellin', 'EST-MDE', (SELECT id FROM regions WHERE code = 'ANT'), 'ACTIVE'),
('Estelar Cartagena', 'EST-CTG', (SELECT id FROM regions WHERE code = 'CAR'), 'ACTIVE')
ON CONFLICT ("code") DO NOTHING;
-- Seed Users
-- password_hash is bcrypt hash of 'password123'
INSERT INTO "users" ("username", "email", "password_hash", "role", "hotel_id", "area", "status", "created_at") VALUES
('admin', 'admin@estelar.com', '$2b$10$lRn/GrwzaEGxWqqQGGz0f.44nBywObKkGWRajlxWeDGZ3o4f7ayjy', 'admin', (SELECT id FROM hotels WHERE code = 'EST-P93'), 'Sistemas', 'ACTIVE', NOW()),
('director', 'director@estelar.com', '$2b$10$lRn/GrwzaEGxWqqQGGz0f.44nBywObKkGWRajlxWeDGZ3o4f7ayjy', 'director', (SELECT id FROM hotels WHERE code = 'EST-P93'), 'Comercial', 'ACTIVE', NOW()),
('gerente_mde', 'gerente.mde@estelar.com', '$2b$10$lRn/GrwzaEGxWqqQGGz0f.44nBywObKkGWRajlxWeDGZ3o4f7ayjy', 'hotel_manager', (SELECT id FROM hotels WHERE code = 'EST-MDE'), 'Administracion', 'ACTIVE', NOW()),
('lider_ctg', 'lider.ctg@estelar.com', '$2b$10$lRn/GrwzaEGxWqqQGGz0f.44nBywObKkGWRajlxWeDGZ3o4f7ayjy', 'commercial_leader', (SELECT id FROM hotels WHERE code = 'EST-CTG'), 'Ventas', 'ACTIVE', NOW()),
('analista', 'analista@estelar.com', '$2b$10$lRn/GrwzaEGxWqqQGGz0f.44nBywObKkGWRajlxWeDGZ3o4f7ayjy', 'analyst', (SELECT id FROM hotels WHERE code = 'EST-P93'), 'Finanzas', 'ACTIVE', NOW()),
('consulta', 'consulta@estelar.com', '$2b$10$lRn/GrwzaEGxWqqQGGz0f.44nBywObKkGWRajlxWeDGZ3o4f7ayjy', 'auditor', (SELECT id FROM hotels WHERE code = 'EST-P93'), 'Auditoria', 'ACTIVE', NOW()),
('colaborador_mde', 'colaborador.mde@estelar.com', '$2b$10$lRn/GrwzaEGxWqqQGGz0f.44nBywObKkGWRajlxWeDGZ3o4f7ayjy', 'collaborator', (SELECT id FROM hotels WHERE code = 'EST-MDE'), 'Ventas', 'ACTIVE', NOW())
ON CONFLICT ("username") DO UPDATE SET role = EXCLUDED.role;
-- ==========================================
-- 3. ENABLE ROW-LEVEL SECURITY
-- ==========================================
ALTER TABLE "regions" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "hotels" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "users" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "goals" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "sales_results" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "settlements" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "audit_logs" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "compensation_plans" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "calculation_rules" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "regions" FORCE ROW LEVEL SECURITY;
ALTER TABLE "hotels" FORCE ROW LEVEL SECURITY;
ALTER TABLE "users" FORCE ROW LEVEL SECURITY;
ALTER TABLE "goals" FORCE ROW LEVEL SECURITY;
ALTER TABLE "sales_results" FORCE ROW LEVEL SECURITY;
ALTER TABLE "settlements" FORCE ROW LEVEL SECURITY;
ALTER TABLE "audit_logs" FORCE ROW LEVEL SECURITY;
ALTER TABLE "compensation_plans" FORCE ROW LEVEL SECURITY;
ALTER TABLE "calculation_rules" FORCE ROW LEVEL SECURITY;
-- ==========================================
-- 4. CREATE RLS POLICIES
@ -87,7 +102,7 @@ CREATE POLICY audit_logs_insert_policy ON "audit_logs"
CREATE POLICY audit_logs_select_policy ON "audit_logs"
FOR SELECT USING (
current_setting('app.current_user_role', true) IN ('ADMIN', 'ANALISTA')
current_setting('app.current_user_role', true) IN ('admin', 'analyst')
OR user_id = NULLIF(current_setting('app.current_user_id', true), '')::integer
);
@ -95,49 +110,49 @@ CREATE POLICY audit_logs_select_policy ON "audit_logs"
-- B. Regions Policies
CREATE POLICY regions_select_policy ON "regions"
FOR SELECT USING (
current_setting('app.current_user_role', true) IN ('ADMIN', 'ANALISTA', 'DIRECTOR')
current_setting('app.current_user_role', true) IN ('admin', 'analyst', 'director')
OR id = NULLIF(current_setting('app.current_region_id', true), '')::integer
);
CREATE POLICY regions_modify_policy ON "regions"
FOR ALL USING (current_setting('app.current_user_role', true) IN ('ADMIN', 'DIRECTOR'));
FOR ALL USING (current_setting('app.current_user_role', true) IN ('admin', 'director'));
-- C. Hotels Policies
CREATE POLICY hotels_select_policy ON "hotels"
FOR SELECT USING (
current_setting('app.current_user_role', true) IN ('ADMIN', 'ANALISTA', 'DIRECTOR')
current_setting('app.current_user_role', true) IN ('admin', 'analyst', 'director')
OR region_id = NULLIF(current_setting('app.current_region_id', true), '')::integer
OR id = NULLIF(current_setting('app.current_hotel_id', true), '')::integer
);
CREATE POLICY hotels_modify_policy ON "hotels"
FOR ALL USING (current_setting('app.current_user_role', true) IN ('ADMIN', 'DIRECTOR'));
FOR ALL USING (current_setting('app.current_user_role', true) IN ('admin', 'director'));
-- D. Users Policies
CREATE POLICY users_select_policy ON "users"
FOR SELECT USING (
current_setting('app.current_user_role', true) IN ('ADMIN', 'ANALISTA', 'DIRECTOR')
current_setting('app.current_user_role', true) IN ('admin', 'analyst', 'director')
OR (
current_setting('app.current_user_role', true) = 'GERENTE'
current_setting('app.current_user_role', true) = 'hotel_manager'
AND hotel_id = NULLIF(current_setting('app.current_hotel_id', true), '')::integer
)
OR (
current_setting('app.current_user_role', true) = 'LIDER'
current_setting('app.current_user_role', true) = 'commercial_leader'
AND hotel_id IN (SELECT id FROM hotels WHERE region_id = NULLIF(current_setting('app.current_region_id', true), '')::integer)
)
OR id = NULLIF(current_setting('app.current_user_id', true), '')::integer
);
CREATE POLICY users_modify_policy ON "users"
FOR ALL USING (current_setting('app.current_user_role', true) = 'ADMIN');
FOR ALL USING (current_setting('app.current_user_role', true) = 'admin');
-- E. Goals Policies
CREATE POLICY goals_select_policy ON "goals"
FOR SELECT USING (
current_setting('app.current_user_role', true) IN ('ADMIN', 'ANALISTA', 'DIRECTOR')
current_setting('app.current_user_role', true) IN ('admin', 'analyst', 'director')
OR target_id = NULLIF(current_setting('app.current_user_id', true), '')::integer
OR target_id IN (
SELECT u.id FROM users u WHERE u.hotel_id = NULLIF(current_setting('app.current_hotel_id', true), '')::integer
@ -149,13 +164,13 @@ CREATE POLICY goals_select_policy ON "goals"
);
CREATE POLICY goals_modify_policy ON "goals"
FOR ALL USING (current_setting('app.current_user_role', true) IN ('ADMIN', 'DIRECTOR'));
FOR ALL USING (current_setting('app.current_user_role', true) IN ('admin', 'director'));
-- F. Sales Results Policies
CREATE POLICY sales_results_select_policy ON "sales_results"
FOR SELECT USING (
current_setting('app.current_user_role', true) IN ('ADMIN', 'ANALISTA', 'DIRECTOR')
current_setting('app.current_user_role', true) IN ('admin', 'analyst', 'director')
OR user_id = NULLIF(current_setting('app.current_user_id', true), '')::integer
OR hotel_id = NULLIF(current_setting('app.current_hotel_id', true), '')::integer
OR hotel_id IN (
@ -165,9 +180,9 @@ CREATE POLICY sales_results_select_policy ON "sales_results"
CREATE POLICY sales_results_modify_policy ON "sales_results"
FOR ALL USING (
current_setting('app.current_user_role', true) IN ('ADMIN', 'ANALISTA')
current_setting('app.current_user_role', true) IN ('admin', 'analyst')
OR (
current_setting('app.current_user_role', true) = 'LIDER'
current_setting('app.current_user_role', true) = 'commercial_leader'
AND hotel_id IN (
SELECT id FROM hotels WHERE region_id = NULLIF(current_setting('app.current_region_id', true), '')::integer
)
@ -178,7 +193,7 @@ CREATE POLICY sales_results_modify_policy ON "sales_results"
-- G. Settlements Policies
CREATE POLICY settlements_select_policy ON "settlements"
FOR SELECT USING (
current_setting('app.current_user_role', true) IN ('ADMIN', 'ANALISTA', 'DIRECTOR')
current_setting('app.current_user_role', true) IN ('admin', 'analyst', 'director')
OR user_id = NULLIF(current_setting('app.current_user_id', true), '')::integer
OR user_id IN (
SELECT u.id FROM users u WHERE u.hotel_id = NULLIF(current_setting('app.current_hotel_id', true), '')::integer
@ -191,9 +206,9 @@ CREATE POLICY settlements_select_policy ON "settlements"
CREATE POLICY settlements_modify_policy ON "settlements"
FOR ALL USING (
current_setting('app.current_user_role', true) IN ('ADMIN', 'ANALISTA')
current_setting('app.current_user_role', true) IN ('admin', 'analyst')
OR (
current_setting('app.current_user_role', true) = 'LIDER'
current_setting('app.current_user_role', true) = 'commercial_leader'
AND user_id IN (
SELECT u.id FROM users u JOIN hotels h ON u.hotel_id = h.id
WHERE h.region_id = NULLIF(current_setting('app.current_region_id', true), '')::integer
@ -201,23 +216,18 @@ CREATE POLICY settlements_modify_policy ON "settlements"
)
);
-- H. Compensation Plans Policies
ALTER TABLE "compensation_plans" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "compensation_plans" FORCE ROW LEVEL SECURITY;
-- H. Compensation Plans Policies
CREATE POLICY plans_select_policy ON "compensation_plans"
FOR SELECT USING (true);
CREATE POLICY plans_modify_policy ON "compensation_plans"
FOR ALL USING (current_setting('app.current_user_role', true) IN ('ADMIN', 'DIRECTOR', 'ANALISTA'));
FOR ALL USING (current_setting('app.current_user_role', true) IN ('admin', 'director', 'analyst'));
-- I. Calculation Rules Policies
ALTER TABLE "calculation_rules" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "calculation_rules" FORCE ROW LEVEL SECURITY;
CREATE POLICY rules_select_policy ON "calculation_rules"
FOR SELECT USING (true);
CREATE POLICY rules_modify_policy ON "calculation_rules"
FOR ALL USING (current_setting('app.current_user_role', true) IN ('ADMIN', 'DIRECTOR', 'ANALISTA'));
FOR ALL USING (current_setting('app.current_user_role', true) IN ('admin', 'director', 'analyst'));

View file

@ -33,7 +33,7 @@ model User {
username String @unique
email String @unique
passwordHash String @map("password_hash")
role String // ADMIN | DIRECTOR | GERENTE | LIDER | ANALISTA | CONSULTA | COLABORADOR
role String // admin | director | hotel_manager | commercial_leader | analyst | auditor | collaborator
hotelId Int @map("hotel_id")
hotel Hotel @relation(fields: [hotelId], references: [id])
area String
@ -48,6 +48,7 @@ model User {
plansCreated CompensationPlan[] @relation("PlanCreator")
auditLogs AuditLog[]
notifications Notification[]
importJobs SalesImportJob[]
@@map("users")
}
@ -175,3 +176,16 @@ model Notification {
@@map("notifications")
}
model SalesImportJob {
id Int @id @default(autoincrement())
idempotencyKey String @unique @map("idempotency_key")
status String @default("PROCESSING") // PROCESSING | SUCCESS | FAILED
errorMessage String? @map("error_message")
uploadedBy Int @map("uploaded_by")
uploader User @relation(fields: [uploadedBy], references: [id])
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("sales_import_jobs")
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

View file

@ -32,7 +32,7 @@ function getPrisma() {
async function runAsAdmin(queryFn) {
const db = getPrisma();
return db.$transaction(async (tx) => {
await tx.$executeRawUnsafe(`SET LOCAL app.current_user_role = 'ADMIN';`);
await tx.$executeRawUnsafe(`SET LOCAL app.current_user_role = 'admin';`);
return queryFn(tx);
});
}
@ -68,8 +68,11 @@ async function runTests() {
const adminUser = users.find(u => u.username === 'admin');
await runAsAdmin(async (tx) => {
await tx.salesResult.deleteMany();
await tx.auditLog.deleteMany();
await tx.salesResult.deleteMany({
where: {
idempotencyKey: { in: ['auth-test-colab', 'auth-test-lider'] }
}
});
// Colaborador sale
await tx.salesResult.create({
@ -285,8 +288,11 @@ async function cleanup() {
try {
if (prisma) {
await runAsAdmin(async (tx) => {
await tx.salesResult.deleteMany();
await tx.auditLog.deleteMany();
await tx.salesResult.deleteMany({
where: {
idempotencyKey: { in: ['auth-test-colab', 'auth-test-lider'] }
}
});
});
await prisma.$disconnect();
}

View file

@ -41,7 +41,7 @@ function getPrisma() {
async function runAsAdmin(queryFn) {
const db = getPrisma();
return db.$transaction(async (tx) => {
await tx.$executeRawUnsafe(`SET LOCAL app.current_user_role = 'ADMIN';`);
await tx.$executeRawUnsafe(`SET LOCAL app.current_user_role = 'admin';`);
return queryFn(tx);
});
}
@ -69,21 +69,28 @@ async function runTests() {
// 1. Clean database records first
console.log("Resetting test environment data...");
await runAsAdmin(async (tx) => {
await tx.calculationRule.deleteMany();
await tx.goal.deleteMany();
await tx.compensationPlan.deleteMany();
await tx.salesResult.deleteMany();
await tx.auditLog.deleteMany();
// Delete calculation rules associated with E2E test plans
await tx.calculationRule.deleteMany({
where: { plan: { code: 'PLAN-E2E-PUPP' } }
});
// Delete E2E test goals
await tx.goal.deleteMany({
where: { period: '2026-06', amount: 75000.00 }
});
// Delete E2E test plans
await tx.compensationPlan.deleteMany({
where: { code: 'PLAN-E2E-PUPP' }
});
});
// 2. Start Next.js server on port 3010
console.log(`Starting Next.js dev server on port ${PORT}...`);
nextProcess = spawn('node', ['node_modules/next/dist/bin/next', 'dev', '--port', String(PORT)], {
// 2. Start Next.js production server on port 3010
console.log(`Starting Next.js production server on port ${PORT}...`);
nextProcess = spawn('node', ['node_modules/next/dist/bin/next', 'start', '--port', String(PORT)], {
env: { ...process.env, PORT: String(PORT) }
});
nextProcess.stdout.on('data', (data) => {
// console.log(`[Next.js] ${data.toString().trim()}`);
console.log(`[Next.js] ${data.toString().trim()}`);
});
nextProcess.stderr.on('data', (data) => {
console.error(`[Next.js ERR] ${data.toString().trim()}`);
@ -155,6 +162,15 @@ async function runTests() {
await page.waitForSelector('div[role="dialog"]');
await page.screenshot({ path: path.join(SCREENSHOT_DIR, '04_create_modal_open.png') });
// Trigger save without filling fields to test HTML5 validations
console.log("Testing empty form submission block...");
await page.click('#btn-save-plan');
await sleep(1000);
const isModalOpen = await page.evaluate(() => {
return document.querySelector('div[role="dialog"]') !== null;
});
assert(isModalOpen, "HTML5 constraint validation: Modal remains open when attempting to submit empty fields");
// Fill in form details
await page.type('#plan-name', 'Plan Ventas E2E Puppeteer');
await page.type('#plan-code', 'PLAN-E2E-PUPP');
@ -193,11 +209,20 @@ async function runTests() {
await page.screenshot({ path: path.join(SCREENSHOT_DIR, '07_rules_config_page.png') });
assert(page.url().includes(`/plans/${createdPlan.id}/rules`), "Successfully navigated to rules page");
// Modify first row
await clearAndType('#rule-min-0', '0.0');
await clearAndType('#rule-max-0', '0.9');
await clearAndType('#rule-rate-0', '0.0');
await clearAndType('#rule-payout-0', '0.0');
// Test invalid rule boundary constraint (min >= max)
console.log("Testing rule validation constraints (min >= max)...");
await setReactInput('#rule-min-0', '0.95');
await setReactInput('#rule-max-0', '0.90');
await page.click('#btn-save-rules');
await page.waitForSelector('#rules-error-msg');
const rulesError = await page.$eval('#rules-error-msg', el => el.textContent);
assert(rulesError.includes("El logro mínimo") && rulesError.includes("debe ser menor"), "Validation error shows when rule minimum exceeds maximum");
// Modify first row back to valid values
await setReactInput('#rule-min-0', '0.0');
await setReactInput('#rule-max-0', '0.9');
await setReactInput('#rule-rate-0', '0.0');
await setReactInput('#rule-payout-0', '0.0');
// Add a second row
await page.click('#btn-add-rule');
@ -205,10 +230,10 @@ async function runTests() {
await sleep(500); // Allow React state to settle
// Fill second row
await clearAndType('#rule-min-1', '0.9');
await clearAndType('#rule-max-1', '1.0');
await clearAndType('#rule-rate-1', '0.025');
await clearAndType('#rule-payout-1', '150.0');
await setReactInput('#rule-min-1', '0.9');
await setReactInput('#rule-max-1', '1.0');
await setReactInput('#rule-rate-1', '0.025');
await setReactInput('#rule-payout-1', '150.0');
await page.screenshot({ path: path.join(SCREENSHOT_DIR, '08_rules_populated.png') });
@ -275,7 +300,17 @@ async function runTests() {
await page.select('#goal-target-type', 'INDIVIDUAL');
await page.select('#goal-target-id', colaboradorMde.id.toString());
await setReactInput('#goal-period', '2026-06');
await page.type('#goal-amount', '75000');
// Test invalid goal amount validation
console.log("Testing invalid goal amount validation...");
await setReactInput('#goal-amount', '-100');
await page.click('#btn-save-goal');
await page.waitForSelector('#goal-error-msg');
const goalErrorMsg = await page.$eval('#goal-error-msg', el => el.textContent);
assert(goalErrorMsg.includes("El monto debe ser un número positivo"), "Validation error shows when entering negative goal amount");
// Enter valid amount
await setReactInput('#goal-amount', '75000');
await page.screenshot({ path: path.join(SCREENSHOT_DIR, '14_goal_form_filled.png') });
await page.click('#btn-save-goal');
@ -310,7 +345,9 @@ async function runTests() {
await page.type('#username', 'colaborador_mde');
await page.type('#password', 'password123');
await page.click('button[type="submit"]');
await page.waitForSelector('#btn-create-plan');
await page.waitForSelector('header');
const hasCreatePlanBtn = await page.evaluate(() => !!document.getElementById('btn-create-plan'));
assert(!hasCreatePlanBtn, "Collaborator should not see the 'Nuevo Plan' button in the dashboard");
// Navigate directly to /goals and verify list shows only their goal
await page.goto(`${BASE_URL}/goals`, { waitUntil: 'networkidle2' });
@ -324,6 +361,38 @@ async function runTests() {
assert(rowCount === 1, "RLS restriction verified: Colaborador only sees 1 goal (their own) in the goals dashboard");
// Verify API security boundary: Colaborador cannot create plans or assign goals
console.log("Verifying collaborator is blocked from executing ADMIN/DIRECTOR APIs...");
const planBlockRes = await page.evaluate(async () => {
const res = await fetch('/api/plans', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: 'Plan Hack',
code: 'PLAN-HACK',
validityStart: '2026-06-01',
type: 'PERCENTAGE'
})
});
return { status: res.status };
});
assert(planBlockRes.status === 403, "API security check: Collaborator blocked from creating a plan (POST /api/plans returns 403)");
const goalBlockRes = await page.evaluate(async () => {
const res = await fetch('/api/goals', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
targetType: 'INDIVIDUAL',
targetId: 7,
period: '2026-06',
amount: 85000.00
})
});
return { status: res.status };
});
assert(goalBlockRes.status === 403, "API security check: Collaborator blocked from assigning goals (POST /api/goals returns 403)");
await cleanup();
console.log(`\n=== E2E TEST RESULTS: ${passed} PASSED, ${failed} FAILED ===`);
@ -345,11 +414,18 @@ async function cleanup() {
try {
if (prisma) {
await runAsAdmin(async (tx) => {
await tx.calculationRule.deleteMany();
await tx.goal.deleteMany();
await tx.compensationPlan.deleteMany();
await tx.salesResult.deleteMany();
await tx.auditLog.deleteMany();
// Delete calculation rules associated with E2E test plans
await tx.calculationRule.deleteMany({
where: { plan: { code: 'PLAN-E2E-PUPP' } }
});
// Delete E2E test goals
await tx.goal.deleteMany({
where: { period: '2026-06', amount: 75000.00 }
});
// Delete E2E test plans
await tx.compensationPlan.deleteMany({
where: { code: 'PLAN-E2E-PUPP' }
});
});
await prisma.$disconnect();
}

View file

@ -142,7 +142,9 @@ async function runTests() {
await page.type('#username', 'colaborador_mde');
await page.type('#password', 'password123');
await page.click('button[type="submit"]');
await page.waitForSelector('#btn-create-plan'); // Wait for home page dashboard load
await page.waitForSelector('header'); // Wait for home page dashboard load
const hasCreatePlanBtn = await page.evaluate(() => !!document.getElementById('btn-create-plan'));
assert(!hasCreatePlanBtn, "Collaborator should not see the 'Nuevo Plan' button in the dashboard");
// Try navigating to import page
await page.goto(`${BASE_URL}/sales/import`, { waitUntil: 'networkidle2' });

View file

@ -20,7 +20,7 @@ async function runTests() {
// 1. Fetch seeded data to map IDs (Run as ADMIN to bypass RLS)
const { users, hotels, regions } = await prisma.$transaction(async (tx) => {
await tx.$executeRawUnsafe(`SET LOCAL app.current_user_role = 'ADMIN';`);
await tx.$executeRawUnsafe(`SET LOCAL app.current_user_role = 'admin';`);
const users = await tx.user.findMany({ include: { hotel: true } });
const hotels = await tx.hotel.findMany();
const regions = await tx.region.findMany();
@ -104,12 +104,15 @@ async function runTests() {
// --- TEST 2: Sales Results RLS ---
console.log("\nRunning TEST 2: Sales Results RLS...");
let saleColab, saleOther;
try {
// Clean up existing sales results to isolate the test
await runWithContext(adminUser, tx => tx.salesResult.deleteMany());
// Clean up test sales results first (non-destructively)
await runWithContext(adminUser, tx => tx.salesResult.deleteMany({
where: { idempotencyKey: { in: ['test-key-colab-1', 'test-key-other-1'] } }
}));
// Create sales results as Admin (bypass RLS)
const saleColab = await runWithContext(adminUser, tx => tx.salesResult.create({
saleColab = await runWithContext(adminUser, tx => tx.salesResult.create({
data: {
source: 'EXCEL',
hotelId: colaboradorMde.hotelId,
@ -122,7 +125,7 @@ async function runTests() {
}
}));
const saleOther = await runWithContext(adminUser, tx => tx.salesResult.create({
saleOther = await runWithContext(adminUser, tx => tx.salesResult.create({
data: {
source: 'EXCEL',
hotelId: liderCtg.hotelId,
@ -202,10 +205,17 @@ async function runTests() {
failed++;
}
// Clean up test sales results and audit logs
// Clean up test sales results
console.log("\nCleaning up test records...");
await runWithContext(adminUser, tx => tx.salesResult.deleteMany());
await runWithContext(adminUser, tx => tx.auditLog.deleteMany());
const salesResultIds = [];
if (saleColab) salesResultIds.push(saleColab.id);
if (saleOther) salesResultIds.push(saleOther.id);
if (salesResultIds.length > 0) {
await runWithContext(adminUser, tx => tx.salesResult.deleteMany({
where: { id: { in: salesResultIds } }
}));
}
// Audit logs are not deleted due to write-only RLS immutability
console.log(`\n=== TEST RESULTS: ${passed} PASSED, ${failed} FAILED ===`);
if (failed > 0) {

View file

@ -8,7 +8,7 @@ export async function POST(req: NextRequest) {
if (!username || !password) {
return NextResponse.json(
{ error: 'Username and password are required' },
{ error: 'El nombre de usuario y la contraseña son obligatorios.' },
{ status: 400 }
);
}
@ -18,13 +18,19 @@ export async function POST(req: NextRequest) {
userId: 0,
username: 'login_system',
email: 'system@estelar.com',
role: 'ADMIN',
role: 'admin',
hotelId: 0,
regionId: 0
});
console.log('[Login API] DATABASE_URL in Next.js:', process.env.DATABASE_URL);
console.log('[Login API] Attempting login for username:', username);
try {
const dbRole = await prismaAdmin.$queryRaw`SELECT current_setting('app.current_user_role', true) as role;`;
console.log('[DEBUG LOGIN] current_setting role inside Next.js:', JSON.stringify(dbRole));
} catch (e) {
console.error('[DEBUG LOGIN] Failed to fetch current_setting role:', e);
}
const user = await prismaAdmin.user.findUnique({
where: { username },
include: { hotel: true }
@ -34,7 +40,7 @@ export async function POST(req: NextRequest) {
if (!user || user.status !== 'ACTIVE') {
console.log('[Login API] Login failed: User not found or inactive');
return NextResponse.json(
{ error: 'Invalid credentials or inactive user' },
{ error: 'Credenciales inválidas o usuario inactivo.' },
{ status: 401 }
);
}
@ -44,7 +50,7 @@ export async function POST(req: NextRequest) {
if (!passwordMatch) {
console.log('[Login API] Login failed: Password mismatch');
return NextResponse.json(
{ error: 'Invalid credentials' },
{ error: 'Credenciales inválidas.' },
{ status: 401 }
);
}
@ -99,7 +105,7 @@ export async function POST(req: NextRequest) {
} catch (err) {
console.error('Login error:', err);
return NextResponse.json(
{ error: 'Internal server error' },
{ error: 'Error interno del servidor.' },
{ status: 500 }
);
}

View file

@ -26,7 +26,7 @@ export const POST = withAuth(async (req, { prisma }) => {
if (!targetType || targetId === undefined || !period || amount === undefined) {
return NextResponse.json(
{ error: 'targetType, targetId, period, and amount are required fields' },
{ error: 'targetType, targetId, period y amount son campos obligatorios.' },
{ status: 400 }
);
}
@ -58,4 +58,4 @@ export const POST = withAuth(async (req, { prisma }) => {
}
return NextResponse.json({ goal });
}, ['ADMIN', 'DIRECTOR']);
}, ['admin', 'director']);

View file

@ -7,7 +7,7 @@ export const GET = withAuth(async (req, { prisma, params }) => {
const id = parseInt(unwrappedParams.id);
if (isNaN(id)) {
return NextResponse.json({ error: 'Invalid ID' }, { status: 400 });
return NextResponse.json({ error: 'ID no válido.' }, { status: 400 });
}
const plan = await prisma.compensationPlan.findUnique({
@ -16,20 +16,20 @@ export const GET = withAuth(async (req, { prisma, params }) => {
});
if (!plan) {
return NextResponse.json({ error: 'Plan not found' }, { status: 404 });
return NextResponse.json({ error: 'Plan no encontrado.' }, { status: 404 });
}
return NextResponse.json({ plan });
});
// PUT: Update or Version a plan (restricted to ADMIN and DIRECTOR)
// PUT: Update or Version a plan (restricted to admin and director)
export const PUT = withAuth(async (req, { session, prisma, params }) => {
const unwrappedParams = await params;
const id = parseInt(unwrappedParams.id);
const body = await req.json();
if (isNaN(id)) {
return NextResponse.json({ error: 'Invalid ID' }, { status: 400 });
return NextResponse.json({ error: 'ID no válido.' }, { status: 400 });
}
const plan = await prisma.compensationPlan.findUnique({
@ -37,18 +37,12 @@ export const PUT = withAuth(async (req, { session, prisma, params }) => {
});
if (!plan) {
return NextResponse.json({ error: 'Plan not found' }, { status: 404 });
return NextResponse.json({ error: 'Plan no encontrado.' }, { status: 404 });
}
// Versioning replication if the plan is currently ACTIVE
if (plan.status === 'ACTIVE') {
const newPlan = await prisma.$transaction(async (tx: any) => {
// Set RLS variables directly on the transaction client context
await tx.$executeRawUnsafe(`SET LOCAL app.current_user_id = '${session.userId}';`);
await tx.$executeRawUnsafe(`SET LOCAL app.current_user_role = '${session.role}';`);
await tx.$executeRawUnsafe(`SET LOCAL app.current_hotel_id = '${session.hotelId}';`);
await tx.$executeRawUnsafe(`SET LOCAL app.current_region_id = '${session.regionId}';`);
// 1. Mark current plan version as INACTIVE
await tx.compensationPlan.update({
where: { id },
@ -118,4 +112,4 @@ export const PUT = withAuth(async (req, { session, prisma, params }) => {
return NextResponse.json({ plan: updatedPlan, versioned: false });
}
}, ['ADMIN', 'DIRECTOR']);
}, ['admin', 'director']);

View file

@ -1,14 +1,14 @@
import { NextRequest, NextResponse } from 'next/server';
import { withAuth } from '@/lib/api-guards';
// POST: Configure calculation rules (restricted to ADMIN and DIRECTOR)
// POST: Configure calculation rules (restricted to admin and director)
export const POST = withAuth(async (req, { session, prisma, params }) => {
const unwrappedParams = await params;
const planId = parseInt(unwrappedParams.id);
const body = await req.json(); // Expected: { rules: [{ type: 'TIER', minAchievement: 0.9, maxAchievement: 1.0, rate: 0.015, payoutAmount: 0 }, ...] }
if (isNaN(planId)) {
return NextResponse.json({ error: 'Invalid ID' }, { status: 400 });
return NextResponse.json({ error: 'ID no válido.' }, { status: 400 });
}
const plan = await prisma.compensationPlan.findUnique({
@ -16,17 +16,64 @@ export const POST = withAuth(async (req, { session, prisma, params }) => {
});
if (!plan) {
return NextResponse.json({ error: 'Plan not found' }, { status: 404 });
return NextResponse.json({ error: 'Plan no encontrado.' }, { status: 404 });
}
console.log("[DEBUG RULES] body.rules:", JSON.stringify(body.rules));
// Validate rules before database execution
if (body.rules && Array.isArray(body.rules)) {
try {
const sortedRules = [...body.rules].sort((a: any, b: any) => parseFloat(a.minAchievement) - parseFloat(b.minAchievement));
for (let i = 0; i < sortedRules.length; i++) {
const rule = sortedRules[i];
if (!rule.type || rule.minAchievement === undefined || rule.maxAchievement === undefined) {
return NextResponse.json({ error: 'Parámetros de regla de cálculo no válidos.' }, { status: 400 });
}
const min = parseFloat(rule.minAchievement);
const max = parseFloat(rule.maxAchievement);
if (isNaN(min) || isNaN(max)) {
console.log("[DEBUG RULES] isNaN(min) || isNaN(max) failed:", min, max);
return NextResponse.json({ error: 'Los límites de cumplimiento deben ser números válidos.' }, { status: 400 });
}
if (min >= max) {
console.log("[DEBUG RULES] min >= max failed:", min, max);
return NextResponse.json({ error: `El cumplimiento mínimo de la regla (${min}) debe ser estrictamente menor que el cumplimiento máximo (${max}).` }, { status: 400 });
}
if (i < sortedRules.length - 1) {
const nextRule = sortedRules[i + 1];
const nextMin = parseFloat(nextRule.minAchievement);
const nextMax = parseFloat(nextRule.maxAchievement);
if (isNaN(nextMin) || isNaN(nextMax)) {
console.log("[DEBUG RULES] isNaN(nextMin) || isNaN(nextMax) failed:", nextMin, nextMax);
return NextResponse.json({ error: 'Los límites de cumplimiento deben ser números válidos.' }, { status: 400 });
}
if (nextMin < max) {
console.log("[DEBUG RULES] nextMin < max failed:", nextMin, max);
return NextResponse.json({ error: `Se detectó superposición en las reglas de cálculo: el rango [${min}, ${max}] se superpone con el rango [${nextMin}, ${nextMax}].` }, { status: 400 });
}
if (Math.abs(nextMin - max) > 0.0001) {
console.log("[DEBUG RULES] Math.abs(nextMin - max) > 0.0001 failed:", nextMin, max, Math.abs(nextMin - max));
return NextResponse.json({ error: `Las reglas de cálculo deben ser contiguas: el rango [${min}, ${max}] no es contiguo con el rango [${nextMin}, ${nextMax}].` }, { status: 400 });
}
}
}
} catch (e: any) {
console.log("[DEBUG RULES] Catch error:", e);
return NextResponse.json({ error: e.message || 'Error de validación.' }, { status: 400 });
}
}
// Bulk overwrite rules inside a transaction
const rules = await prisma.$transaction(async (tx: any) => {
// Set RLS variables directly on the transaction client context
await tx.$executeRawUnsafe(`SET LOCAL app.current_user_id = '${session.userId}';`);
await tx.$executeRawUnsafe(`SET LOCAL app.current_user_role = '${session.role}';`);
await tx.$executeRawUnsafe(`SET LOCAL app.current_hotel_id = '${session.hotelId}';`);
await tx.$executeRawUnsafe(`SET LOCAL app.current_region_id = '${session.regionId}';`);
// 1. Delete existing rules
await tx.calculationRule.deleteMany({
where: { planId }
@ -34,13 +81,6 @@ export const POST = withAuth(async (req, { session, prisma, params }) => {
// 2. Insert new ones if provided
if (body.rules && Array.isArray(body.rules)) {
// Validate boundaries and contiguous nature if needed
for (const rule of body.rules) {
if (!rule.type || rule.minAchievement === undefined || rule.maxAchievement === undefined) {
throw new Error('Invalid calculation rule parameters');
}
}
await tx.calculationRule.createMany({
data: body.rules.map((r: any) => ({
planId,
@ -59,4 +99,4 @@ export const POST = withAuth(async (req, { session, prisma, params }) => {
});
return NextResponse.json({ rules });
}, ['ADMIN', 'DIRECTOR']);
}, ['admin', 'director']);

View file

@ -22,7 +22,7 @@ export const GET = withAuth(async (req, { prisma }) => {
return NextResponse.json({ plans });
});
// POST: Create a new plan (restricted to ADMIN and DIRECTOR)
// POST: Create a new plan (restricted to admin and director)
export const POST = withAuth(async (req, { session, prisma }) => {
const body = await req.json();
const { name, code, validityStart, validityEnd, type, formula, metaAmount, percentageRate, maxCap, status } = body;
@ -30,7 +30,7 @@ export const POST = withAuth(async (req, { session, prisma }) => {
// Validate mandatory fields
if (!name || !code || !validityStart || !type) {
return NextResponse.json(
{ error: 'Name, code, validityStart, and type are required mandatory fields' },
{ error: 'El nombre, código, fecha de inicio de vigencia y tipo son campos obligatorios.' },
{ status: 400 }
);
}
@ -53,4 +53,4 @@ export const POST = withAuth(async (req, { session, prisma }) => {
});
return NextResponse.json({ plan }, { status: 201 });
}, ['ADMIN', 'DIRECTOR']);
}, ['admin', 'director']);

View file

@ -1,12 +1,23 @@
import { NextRequest, NextResponse } from 'next/server';
import { getPrisma } from '@/lib/db';
import crypto from 'crypto';
function safeCompare(a: string, b: string): boolean {
const bufA = Buffer.from(a);
const bufB = Buffer.from(b);
if (bufA.length !== bufB.length) {
crypto.timingSafeEqual(bufA, bufA);
return false;
}
return crypto.timingSafeEqual(bufA, bufB);
}
export async function POST(req: NextRequest) {
try {
const signature = req.headers.get('x-n8n-signature');
const expectedSecret = process.env.N8N_WEBHOOK_SECRET;
const signature = req.headers.get('x-n8n-signature') || '';
const expectedSecret = process.env.N8N_WEBHOOK_SECRET || '';
if (!expectedSecret || signature !== expectedSecret) {
if (!expectedSecret || !signature || !safeCompare(signature, expectedSecret)) {
return NextResponse.json({
success: false,
error: {
@ -17,21 +28,55 @@ export async function POST(req: NextRequest) {
}
const body = await req.json();
const { idempotencyKey, uploaderId, sales } = body;
const { idempotencyKey, uploaderId, sales, status, errorMessage } = body;
if (!idempotencyKey || !uploaderId || !Array.isArray(sales)) {
if (!idempotencyKey || !uploaderId) {
return NextResponse.json({
success: false,
error: {
code: 'BAD_REQUEST',
metadata: {}
metadata: { message: 'idempotencyKey and uploaderId are required.' }
}
}, { status: 400 });
}
const prisma = getPrisma();
const result = await prisma.$transaction(async (tx: any) => {
// Support n8n failure reporting
if (status === 'FAILED') {
await prisma.salesImportJob.upsert({
where: { idempotencyKey },
create: {
idempotencyKey,
status: 'FAILED',
errorMessage: errorMessage || 'n8n background processing failed',
uploadedBy: uploaderId
},
update: {
status: 'FAILED',
errorMessage: errorMessage || 'n8n background processing failed'
}
});
return NextResponse.json({
success: true,
code: 'IMPORT_FAILED_REPORTED',
metadata: { idempotencyKey }
}, { status: 200 });
}
if (!Array.isArray(sales)) {
return NextResponse.json({
success: false,
error: {
code: 'BAD_REQUEST',
metadata: { message: 'sales must be an array.' }
}
}, { status: 400 });
}
let result;
try {
result = await prisma.$transaction(async (tx: any) => {
// Elevate privileges to admin role to bypass RLS for n8n batch operations
await tx.$executeRawUnsafe(`SET LOCAL app.current_user_role = 'admin';`);
@ -108,11 +153,44 @@ export async function POST(req: NextRequest) {
}
});
// Update import job to SUCCESS
await tx.salesImportJob.upsert({
where: { idempotencyKey },
create: {
idempotencyKey,
status: 'SUCCESS',
uploadedBy: uploaderId
},
update: {
status: 'SUCCESS'
}
});
return {
alreadyProcessed: false,
count: createdSales.length
};
});
} catch (txErr: any) {
try {
await prisma.salesImportJob.upsert({
where: { idempotencyKey },
create: {
idempotencyKey,
status: 'FAILED',
errorMessage: txErr.message,
uploadedBy: uploaderId
},
update: {
status: 'FAILED',
errorMessage: txErr.message
}
});
} catch (upsertErr) {
console.error('Failed to upsert failed status:', upsertErr);
}
throw txErr;
}
return NextResponse.json({
success: true,

View file

@ -15,7 +15,13 @@ export const POST = withAuth(async (req, { session, prisma }) => {
}, { status: 400 });
}
// 1. Check idempotency
// 1. Check idempotency / job status
const existingJob = await prisma.salesImportJob.findUnique({
where: { idempotencyKey }
});
if (existingJob) {
if (existingJob.status === 'SUCCESS') {
const existingSales = await prisma.salesResult.findMany({
where: {
idempotencyKey: {
@ -23,8 +29,6 @@ export const POST = withAuth(async (req, { session, prisma }) => {
}
}
});
if (existingSales.length > 0) {
const totalAmount = existingSales.reduce((acc: number, cur: any) => acc + Number(cur.amount), 0);
return NextResponse.json({
success: true,
@ -37,6 +41,25 @@ export const POST = withAuth(async (req, { session, prisma }) => {
});
}
if (existingJob.status === 'PROCESSING') {
return NextResponse.json({
success: true,
code: 'IMPORT_ACCEPTED',
metadata: {
idempotencyKey,
status: 'PROCESSING'
}
}, { status: 202 });
}
// If FAILED, allow retrying by deleting the old failed job
if (existingJob.status === 'FAILED') {
await prisma.salesImportJob.delete({
where: { idempotencyKey }
});
}
}
// 2. Parse file
const formData = await req.formData();
const file = formData.get('file') as File;
@ -227,10 +250,20 @@ export const POST = withAuth(async (req, { session, prisma }) => {
}, { status: 400 });
}
// 4. Dispatch to n8n or direct save
// Create the SalesImportJob record first
await prisma.salesImportJob.create({
data: {
idempotencyKey,
status: 'PROCESSING',
uploadedBy: session.userId
}
});
// 5. Dispatch to n8n or direct save
const useN8n = process.env.N8N_WEBHOOK_URL && process.env.NODE_ENV !== 'test' && process.env.IS_E2E_TEST !== 'true' && !req.nextUrl.searchParams.has('direct');
if (useN8n) {
try {
const response = await fetch(process.env.N8N_WEBHOOK_URL!, {
method: 'POST',
headers: {
@ -253,6 +286,19 @@ export const POST = withAuth(async (req, { session, prisma }) => {
if (!response.ok) {
console.error('n8n integration failed:', await response.text());
await prisma.salesImportJob.upsert({
where: { idempotencyKey },
create: {
idempotencyKey,
status: 'FAILED',
errorMessage: `n8n webhook responded with status ${response.status}`,
uploadedBy: session.userId
},
update: {
status: 'FAILED',
errorMessage: `n8n webhook responded with status ${response.status}`
}
});
return NextResponse.json({
success: false,
error: {
@ -270,11 +316,28 @@ export const POST = withAuth(async (req, { session, prisma }) => {
status: 'PROCESSING'
}
}, { status: 202 });
} catch (err: any) {
await prisma.salesImportJob.upsert({
where: { idempotencyKey },
create: {
idempotencyKey,
status: 'FAILED',
errorMessage: err.message,
uploadedBy: session.userId
},
update: {
status: 'FAILED',
errorMessage: err.message
}
});
throw err;
}
}
// Direct Import Fallback / Test mode
const totalAmount = rows.reduce((acc, r) => acc + r.amount, 0);
try {
await prisma.$transaction(async (tx: any) => {
const createdSales = [];
for (let i = 0; i < rows.length; i++) {
@ -311,6 +374,19 @@ export const POST = withAuth(async (req, { session, prisma }) => {
}
}
});
// Update import job to SUCCESS
await tx.salesImportJob.upsert({
where: { idempotencyKey },
create: {
idempotencyKey,
status: 'SUCCESS',
uploadedBy: session.userId
},
update: {
status: 'SUCCESS'
}
});
});
return NextResponse.json({
@ -321,6 +397,22 @@ export const POST = withAuth(async (req, { session, prisma }) => {
totalAmount
}
}, { status: 201 });
} catch (txErr: any) {
await prisma.salesImportJob.upsert({
where: { idempotencyKey },
create: {
idempotencyKey,
status: 'FAILED',
errorMessage: txErr.message,
uploadedBy: session.userId
},
update: {
status: 'FAILED',
errorMessage: txErr.message
}
});
throw txErr;
}
} catch (err: any) {
console.error('Sales import error:', err);

View file

@ -15,12 +15,8 @@ export const GET = withAuth(async (req, { prisma, params }) => {
}, { status: 400 });
}
const count = await prisma.salesResult.count({
where: {
idempotencyKey: {
startsWith: `${key}-`
}
}
const job = await prisma.salesImportJob.findUnique({
where: { idempotencyKey: key }
});
return NextResponse.json({
@ -28,7 +24,8 @@ export const GET = withAuth(async (req, { prisma, params }) => {
code: 'STATUS_CHECKED',
metadata: {
idempotencyKey: key,
status: count > 0 ? 'SUCCESS' : 'PROCESSING'
status: job ? job.status : 'PROCESSING',
errorMessage: job ? job.errorMessage : null
}
});
}, ['admin', 'analyst', 'commercial_leader']);

View file

@ -28,4 +28,4 @@ export const GET = withAuth(async (req, { prisma }) => {
console.error('Failed to fetch users:', err);
return NextResponse.json({ error: 'Failed to fetch users' }, { status: 500 });
}
}, ['ADMIN', 'DIRECTOR']);
}, ['admin', 'director']);

View file

@ -33,7 +33,8 @@
--space-12: 3rem; /* 48px */
/* Typography Scale */
--font-sans: 'Inter', system-ui, -apple-system, sans-serif;
--font-sans: var(--font-geist-sans), 'Inter', system-ui, -apple-system, sans-serif;
--font-mono: var(--font-geist-mono), monospace;
--text-xs: 0.75rem;
--text-sm: 0.875rem;
--text-base: 1rem;
@ -130,3 +131,14 @@ a {
color: inherit;
text-decoration: none;
}
/* Fix select and option dropdown styling for dark/light mode compatibility */
select {
background-color: var(--card) !important;
color: var(--foreground) !important;
}
select option {
background-color: var(--card);
color: var(--foreground);
}

View file

@ -143,13 +143,14 @@
color: var(--foreground);
background-color: transparent;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
border-radius: var(--radius-md);
outline: none;
transition: border-color var(--transition-fast);
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
}
.input:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px hsla(var(--primary-h), var(--primary-s), var(--primary-l), 0.15);
}
.btnPrimary {
@ -161,12 +162,17 @@
border: none;
border-radius: var(--radius-md);
cursor: pointer;
transition: box-shadow var(--transition-fast);
transition: transform var(--transition-fast), box-shadow var(--transition-fast);
text-align: center;
}
.btnPrimary:hover {
box-shadow: var(--shadow-sm), var(--shadow-glow);
transform: translateY(-1px);
box-shadow: var(--shadow-md), var(--shadow-glow);
}
.btnPrimary:active {
transform: translateY(0);
}
.errorMsg {
@ -177,6 +183,12 @@
font-size: var(--text-sm);
}
:global([data-theme='dark']) .errorMsg {
background-color: hsla(0, 85%, 20%, 0.15);
border: 1px solid hsla(0, 85%, 50%, 0.3);
color: hsl(0, 85%, 60%);
}
.successMsg {
color: hsl(120, 80%, 30%);
background-color: hsl(120, 80%, 95%);
@ -185,6 +197,12 @@
font-size: var(--text-sm);
}
:global([data-theme='dark']) .successMsg {
background-color: hsla(120, 80%, 20%, 0.15);
border: 1px solid hsla(120, 80%, 50%, 0.3);
color: hsl(120, 80%, 60%);
}
.tableContainer {
overflow-x: auto;
}
@ -238,3 +256,8 @@
opacity: 0.5;
font-style: italic;
}
.fullWidth {
grid-column: 1 / -1;
}

View file

@ -5,6 +5,8 @@ import React, { useState, useEffect } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import styles from './page.module.css';
import Header from '@/components/Header';
import { getRoleBilingualLabel } from '@/lib/roles';
interface User {
id: number;
@ -32,6 +34,7 @@ export default function GoalsPage() {
const [users, setUsers] = useState<User[]>([]);
const [goals, setGoals] = useState<Goal[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [role, setRole] = useState<string | null>(null);
// Form State
const [targetType, setTargetType] = useState('INDIVIDUAL');
@ -67,14 +70,16 @@ export default function GoalsPage() {
};
useEffect(() => {
fetch('/api/auth/me')
.then(res => res.ok ? res.json() : null)
.then(data => {
if (data?.user) setRole(data.user.role);
})
.catch(err => console.error('Failed to fetch user role', err));
fetchUsersAndGoals();
}, []);
const handleLogout = async () => {
await fetch('/api/auth/logout', { method: 'POST' });
router.push('/login');
router.refresh();
};
const handleAssignGoal = async (e: React.FormEvent) => {
e.preventDefault();
@ -127,7 +132,7 @@ export default function GoalsPage() {
if (type === 'INDIVIDUAL') {
const user = users.find(u => u.id === id);
if (user) {
return `${user.username} (${user.role} - ${user.hotel?.name || ''})`;
return `${user.username} (${getRoleBilingualLabel(user.role)} - ${user.hotel?.name || ''})`;
}
}
return `${type} ID: ${id}`;
@ -136,22 +141,7 @@ export default function GoalsPage() {
return (
<div className={styles.container}>
{/* Shared Dashboard Header */}
<header className={styles.header}>
<div className={styles.logoArea}>
<span className={styles.logoText}>Remuneración Estelar</span>
</div>
<nav className={styles.nav}>
<Link href="/plans" className={styles.navLink}>
Planes de Comisión
</Link>
<Link href="/goals" className={`${styles.navLink} ${styles.navLinkActive}`}>
Metas Comerciales
</Link>
</nav>
<button onClick={handleLogout} className={styles.logoutBtn}>
Cerrar Sesión
</button>
</header>
<Header activeTab="goals" />
<main className={styles.main}>
<div className={styles.titleSection}>
@ -159,6 +149,7 @@ export default function GoalsPage() {
</div>
{/* Goal Assignment Form Section */}
{(role === 'admin' || role === 'director') && (
<section className={styles.section}>
<h2 className={styles.sectionTitle}>Asignar Meta</h2>
{error && <div className={styles.errorMsg} id="goal-error-msg">{error}</div>}
@ -191,7 +182,7 @@ export default function GoalsPage() {
>
{users.map((u) => (
<option key={u.id} value={u.id}>
{u.username} ({u.role} - {u.hotel?.code})
{u.username} ({getRoleBilingualLabel(u.role)} - {u.hotel?.code})
</option>
))}
</select>
@ -244,9 +235,10 @@ export default function GoalsPage() {
</button>
</form>
</section>
)}
{/* Configured Goals List Section */}
<section className={styles.section} style={{ gridColumn: 'span 1' }}>
<section className={`${styles.section} ${!(role === 'admin' || role === 'director') ? styles.fullWidth : ''}`}>
<h2 className={styles.sectionTitle}>Historial de Metas</h2>
{isLoading ? (
<div style={{ textAlign: 'center', padding: '20px' }}>Cargando metas...</div>

View file

@ -13,8 +13,8 @@ const geistMono = Geist_Mono({
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "Remuneración Estelar - Hoteles Estelar",
description: "Sistema de Remuneración Variable, Compensaciones y Comisiones de Hoteles Estelar",
};
export default function RootLayout({

View file

@ -16,10 +16,7 @@ function LoginForm() {
// Read return url if any
const callbackUrl = searchParams.get('callbackUrl') || '/';
// Clear session on component mount just in case
useEffect(() => {
fetch('/api/auth/logout', { method: 'POST' }).catch(() => {});
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@ -44,8 +41,7 @@ function LoginForm() {
}
// Redirect to target dashboard
router.push(callbackUrl);
router.refresh();
window.location.href = callbackUrl;
} catch (err) {
setError('An unexpected error occurred. Please try again.');
setIsLoading(false);

View file

@ -111,13 +111,14 @@
color: var(--foreground);
background-color: transparent;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
border-radius: var(--radius-md);
outline: none;
transition: border-color var(--transition-fast);
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
}
.input:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px hsla(var(--primary-h), var(--primary-s), var(--primary-l), 0.15);
}
.btnDelete {
@ -128,6 +129,7 @@
cursor: pointer;
opacity: 0.8;
padding: var(--space-1);
transition: opacity var(--transition-fast);
}
.btnDelete:hover {
@ -141,13 +143,19 @@
background-color: transparent;
color: var(--foreground);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
border-radius: var(--radius-md);
cursor: pointer;
margin-top: var(--space-4);
transition: background-color var(--transition-fast), border-color var(--transition-fast), transform var(--transition-fast);
}
.btnSecondary:hover {
background-color: var(--border);
border-color: var(--border);
}
.btnSecondary:active {
transform: translateY(0);
}
.footerActions {
@ -168,10 +176,16 @@
border: none;
border-radius: var(--radius-md);
cursor: pointer;
transition: transform var(--transition-fast), box-shadow var(--transition-fast);
}
.btnPrimary:hover {
box-shadow: var(--shadow-sm), var(--shadow-glow);
transform: translateY(-1px);
box-shadow: var(--shadow-md), var(--shadow-glow);
}
.btnPrimary:active {
transform: translateY(0);
}
.successMsg {
@ -182,6 +196,12 @@
margin-bottom: var(--space-4);
}
:global([data-theme='dark']) .successMsg {
background-color: hsla(120, 80%, 20%, 0.15);
border: 1px solid hsla(120, 80%, 50%, 0.3);
color: hsl(120, 80%, 60%);
}
.errorMsg {
color: hsl(0, 85%, 60%);
background-color: hsl(0, 85%, 97%);
@ -189,3 +209,9 @@
border-radius: var(--radius-md);
margin-bottom: var(--space-4);
}
:global([data-theme='dark']) .errorMsg {
background-color: hsla(0, 85%, 20%, 0.15);
border: 1px solid hsla(0, 85%, 50%, 0.3);
color: hsl(0, 85%, 60%);
}

View file

@ -5,6 +5,7 @@ import React, { useState, useEffect } from 'react';
import Link from 'next/link';
import { useRouter, useParams } from 'next/navigation';
import styles from './page.module.css';
import Header from '@/components/Header';
interface Rule {
id?: number;
@ -36,6 +37,7 @@ export default function RulesPage() {
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [role, setRole] = useState<string | null>(null);
const fetchPlan = async () => {
if (!planId) return;
@ -80,14 +82,16 @@ export default function RulesPage() {
};
useEffect(() => {
fetch('/api/auth/me')
.then(res => res.ok ? res.json() : null)
.then(data => {
if (data?.user) setRole(data.user.role);
})
.catch(err => console.error('Failed to fetch user role', err));
fetchPlan();
}, [planId]);
const handleLogout = async () => {
await fetch('/api/auth/logout', { method: 'POST' });
router.push('/login');
router.refresh();
};
const handleAddRow = () => {
// Generate a default values row, potentially continuing from previous max
@ -191,22 +195,7 @@ export default function RulesPage() {
return (
<div className={styles.container}>
{/* Shared Dashboard Header */}
<header className={styles.header}>
<div className={styles.logoArea}>
<span className={styles.logoText}>Remuneración Estelar</span>
</div>
<nav className={styles.nav}>
<Link href="/plans" className={`${styles.navLink} ${styles.navLinkActive}`}>
Planes de Comisión
</Link>
<Link href="/goals" className={styles.navLink}>
Metas Comerciales
</Link>
</nav>
<button onClick={handleLogout} className={styles.btnSecondary} style={{ margin: 0 }}>
Cerrar Sesión
</button>
</header>
<Header activeTab="plans" />
<main className={styles.main}>
<div className={styles.titleArea}>
@ -241,6 +230,7 @@ export default function RulesPage() {
value={rule.type}
onChange={(e) => handleUpdateRow(rule.tempId!, 'type', e.target.value)}
id={`rule-type-${idx}`}
disabled={!(role === 'admin' || role === 'director')}
>
<option value="TIER">Rango (TIER)</option>
<option value="BONUS">Bono Fijo (BONUS)</option>
@ -254,6 +244,7 @@ export default function RulesPage() {
onChange={(e) => handleUpdateRow(rule.tempId!, 'minAchievement', e.target.value)}
id={`rule-min-${idx}`}
required
disabled={!(role === 'admin' || role === 'director')}
/>
<input
@ -264,6 +255,7 @@ export default function RulesPage() {
onChange={(e) => handleUpdateRow(rule.tempId!, 'maxAchievement', e.target.value)}
id={`rule-max-${idx}`}
required
disabled={!(role === 'admin' || role === 'director')}
/>
<input
@ -274,6 +266,7 @@ export default function RulesPage() {
onChange={(e) => handleUpdateRow(rule.tempId!, 'rate', e.target.value)}
id={`rule-rate-${idx}`}
required
disabled={!(role === 'admin' || role === 'director')}
/>
<input
@ -284,8 +277,10 @@ export default function RulesPage() {
onChange={(e) => handleUpdateRow(rule.tempId!, 'payoutAmount', e.target.value)}
id={`rule-payout-${idx}`}
required
disabled={!(role === 'admin' || role === 'director')}
/>
{(role === 'admin' || role === 'director') && (
<button
type="button"
onClick={() => handleDeleteRow(rule.tempId!)}
@ -295,10 +290,12 @@ export default function RulesPage() {
>
&times;
</button>
)}
</div>
))}
</div>
{(role === 'admin' || role === 'director') && (
<button
type="button"
onClick={handleAddRow}
@ -307,14 +304,17 @@ export default function RulesPage() {
>
+ Agregar Rango / Regla
</button>
)}
<div className={styles.footerActions}>
<Link href="/plans" className={styles.btnSecondary} style={{ marginRight: 'auto' }}>
Cancelar
{ (role === 'admin' || role === 'director') ? 'Cancelar' : 'Volver' }
</Link>
{(role === 'admin' || role === 'director') && (
<button type="submit" className={styles.btnPrimary} id="btn-save-rules">
Guardar Reglas
</button>
)}
</div>
</form>
</div>

View file

@ -109,6 +109,10 @@
box-shadow: var(--shadow-md), var(--shadow-glow);
}
.btnPrimary:active {
transform: translateY(0);
}
.grid {
display: grid;
grid-template-columns: 1fr;
@ -164,13 +168,20 @@
.badge {
font-size: var(--text-xs);
font-weight: var(--weight-semibold);
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-sm);
padding: var(--space-1) var(--space-3);
border-radius: var(--radius-full);
display: inline-flex;
align-items: center;
}
.badgeDRAFT {
background-color: hsl(40, 90%, 93%);
color: hsl(40, 90%, 35%);
color: hsl(40, 90%, 30%);
}
:global([data-theme='dark']) .badgeDRAFT {
background-color: hsla(40, 90%, 50%, 0.15);
color: hsl(40, 90%, 60%);
}
.badgeACTIVE {
@ -178,9 +189,19 @@
color: hsl(120, 80%, 30%);
}
:global([data-theme='dark']) .badgeACTIVE {
background-color: hsla(120, 80%, 50%, 0.15);
color: hsl(120, 80%, 50%);
}
.badgeINACTIVE {
background-color: hsl(0, 0%, 90%);
color: hsl(0, 0%, 40%);
color: hsl(0, 0%, 35%);
}
:global([data-theme='dark']) .badgeINACTIVE {
background-color: hsla(0, 0%, 50%, 0.15);
color: hsl(0, 0%, 65%);
}
.cardTitle {
@ -221,14 +242,19 @@
background-color: transparent;
color: var(--foreground);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
border-radius: var(--radius-md);
cursor: pointer;
transition: background-color var(--transition-fast);
transition: background-color var(--transition-fast), border-color var(--transition-fast), transform var(--transition-fast);
text-align: center;
}
.btnSecondary:hover {
background-color: var(--border);
border-color: var(--border);
}
.btnSecondary:active {
transform: translateY(0);
}
/* Modal and Form styling */
@ -296,18 +322,29 @@
color: var(--foreground);
background-color: transparent;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
border-radius: var(--radius-md);
outline: none;
transition: border-color var(--transition-fast);
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
}
.input:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px hsla(var(--primary-h), var(--primary-s), var(--primary-l), 0.15);
}
.errorText {
color: hsl(0, 85%, 60%);
background-color: hsl(0, 85%, 97%);
padding: var(--space-3) var(--space-4);
border-radius: var(--radius-md);
font-size: var(--text-xs);
color: hsl(0, 75%, 60%);
border: 1px solid hsl(0, 85%, 90%);
}
:global([data-theme='dark']) .errorText {
background-color: hsla(0, 85%, 20%, 0.15);
border-color: hsla(0, 85%, 50%, 0.3);
color: hsl(0, 85%, 60%);
}
.formActions {
@ -316,3 +353,10 @@
gap: var(--space-3);
margin-top: var(--space-4);
}
.loading {
text-align: center;
padding: var(--space-8);
opacity: 0.8;
}

View file

@ -5,6 +5,7 @@ import React, { useState, useEffect } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import styles from './page.module.css';
import Header from '@/components/Header';
interface Plan {
id: number;
@ -27,6 +28,7 @@ export default function PlansPage() {
const [plans, setPlans] = useState<Plan[]>([]);
const [showModal, setShowModal] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [role, setRole] = useState<string | null>(null);
// Form State
const [name, setName] = useState('');
@ -54,14 +56,16 @@ export default function PlansPage() {
};
useEffect(() => {
fetch('/api/auth/me')
.then(res => res.ok ? res.json() : null)
.then(data => {
if (data?.user) setRole(data.user.role);
})
.catch(err => console.error('Failed to fetch user role', err));
fetchPlans();
}, []);
const handleLogout = async () => {
await fetch('/api/auth/logout', { method: 'POST' });
router.push('/login');
router.refresh();
};
const handleCreatePlan = async (e: React.FormEvent) => {
e.preventDefault();
@ -133,33 +137,20 @@ export default function PlansPage() {
return (
<div className={styles.container}>
{/* Shared Dashboard Header */}
<header className={styles.header}>
<div className={styles.logoArea}>
<span className={styles.logoText}>Remuneración Estelar</span>
</div>
<nav className={styles.nav}>
<Link href="/plans" className={`${styles.navLink} ${styles.navLinkActive}`}>
Planes de Comisión
</Link>
<Link href="/goals" className={styles.navLink}>
Metas Comerciales
</Link>
</nav>
<button onClick={handleLogout} className={styles.logoutBtn}>
Cerrar Sesión
</button>
</header>
<Header activeTab="plans" />
<main className={styles.main}>
<div className={styles.titleSection}>
<h1 className={styles.title}>Planes de Comisión</h1>
{(role === 'admin' || role === 'director') && (
<button onClick={() => setShowModal(true)} className={styles.btnPrimary} id="btn-create-plan">
Nuevo Plan
</button>
)}
</div>
{isLoading ? (
<div style={{ textAlign: 'center', padding: '40px' }}>Cargando planes...</div>
<div className={styles.loading}>Cargando planes...</div>
) : (
<div className={styles.grid}>
{plans.map((plan) => (
@ -199,6 +190,8 @@ export default function PlansPage() {
</div>
<div className={styles.cardFooter}>
{ (role === 'admin' || role === 'director') ? (
<>
<Link href={`/plans/${plan.id}/rules`} className={styles.btnSecondary} id={`btn-rules-${plan.id}`}>
Configurar Reglas
</Link>
@ -209,6 +202,12 @@ export default function PlansPage() {
>
{plan.status === 'ACTIVE' ? 'Inactivar (Versión)' : 'Activar'}
</button>
</>
) : (
<Link href={`/plans/${plan.id}/rules`} className={styles.btnSecondary} id={`btn-rules-${plan.id}`}>
Ver Reglas
</Link>
) }
</div>
</div>
))}

View file

@ -112,6 +112,12 @@ export default function SalesImportPage() {
setProgress(100);
setSuccessData({ count: data.metadata.count || 0 });
setStatusMessage('');
} else if (data.metadata?.status === 'FAILED') {
clearInterval(interval);
setIsUploading(false);
setProgress(0);
setGeneralError(data.metadata.errorMessage || "Fallo en el procesamiento de n8n.");
setStatusMessage('');
}
}
} catch (err) {

View file

@ -0,0 +1,71 @@
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-4) var(--space-8);
background-color: var(--card);
border-bottom: 1px solid var(--border);
backdrop-filter: blur(8px);
position: sticky;
top: 0;
z-index: 10;
}
.logoArea {
display: flex;
align-items: center;
gap: var(--space-3);
}
.logoText {
font-size: var(--text-lg);
font-weight: var(--weight-bold);
color: var(--foreground);
letter-spacing: -0.02em;
}
.nav {
display: flex;
gap: var(--space-6);
}
.navLink {
font-size: var(--text-sm);
font-weight: var(--weight-medium);
color: var(--foreground);
opacity: 0.7;
text-decoration: none;
transition: opacity var(--transition-fast), color var(--transition-fast), border-color var(--transition-fast);
cursor: pointer;
padding: var(--space-2) 0;
border-bottom: 2px solid transparent;
}
.navLink:hover {
opacity: 1;
}
.navLinkActive {
opacity: 1;
color: var(--primary);
font-weight: var(--weight-semibold);
border-bottom-color: var(--primary);
}
.logoutBtn {
padding: var(--space-2) var(--space-4);
font-size: var(--text-xs);
font-weight: var(--weight-semibold);
background-color: transparent;
color: var(--foreground);
border: 1px solid var(--border);
border-radius: var(--radius-md);
cursor: pointer;
transition: background-color var(--transition-fast), border-color var(--transition-fast), color var(--transition-fast);
}
.logoutBtn:hover {
background-color: hsla(0, 85%, 60%, 0.1);
border-color: hsl(0, 85%, 60%);
color: hsl(0, 85%, 60%);
}

View file

@ -21,14 +21,14 @@ export function withAuth(
if (!session) {
return NextResponse.json(
{ error: 'Unauthorized. Session expired or missing.' },
{ error: 'No autorizado. La sesión ha expirado o no existe.' },
{ status: 401 }
);
}
if (allowedRoles && allowedRoles.length > 0 && !allowedRoles.includes(session.role)) {
return NextResponse.json(
{ error: 'Forbidden. Insufficient permissions.' },
{ error: 'Acceso prohibido. Permisos insuficientes.' },
{ status: 403 }
);
}
@ -40,7 +40,7 @@ export function withAuth(
} catch (err) {
console.error('API guard execution error:', err);
return NextResponse.json(
{ error: 'Internal server error' },
{ error: 'Error interno del servidor.' },
{ status: 500 }
);
}

View file

@ -7,7 +7,10 @@ export interface UserSession {
regionId: number;
}
const JWT_SECRET = process.env.NEXTAUTH_SECRET || 'fallback_secret_for_development_jwt_auth';
if (!process.env.NEXTAUTH_SECRET) {
throw new Error('FATAL: NEXTAUTH_SECRET environment variable is missing.');
}
const JWT_SECRET = process.env.NEXTAUTH_SECRET;
function base64urlDecode(str: string): string {
let base64 = str.replace(/-/g, '+').replace(/_/g, '/');

View file

@ -2,7 +2,10 @@ import crypto from 'crypto';
import bcrypt from 'bcryptjs';
import { NextRequest } from 'next/server';
const JWT_SECRET = process.env.NEXTAUTH_SECRET || 'fallback_secret_for_development_jwt_auth';
if (!process.env.NEXTAUTH_SECRET) {
throw new Error('FATAL: NEXTAUTH_SECRET environment variable is missing.');
}
const JWT_SECRET = process.env.NEXTAUTH_SECRET;
export interface UserSession {
userId: number;

View file

@ -34,6 +34,30 @@ export const getPrisma = (session?: UserSession | null) => {
}
return prisma.$extends({
client: {
async $transaction(args: any, options?: any) {
if (typeof args === 'function') {
const originalFn = args;
return prisma.$transaction(async (tx) => {
await tx.$executeRawUnsafe(`SET LOCAL app.current_user_id = '${session.userId}';`);
await tx.$executeRawUnsafe(`SET LOCAL app.current_user_role = '${session.role}';`);
await tx.$executeRawUnsafe(`SET LOCAL app.current_hotel_id = '${session.hotelId}';`);
await tx.$executeRawUnsafe(`SET LOCAL app.current_region_id = '${session.regionId}';`);
return originalFn(tx);
}, options);
} else if (Array.isArray(args)) {
const rlsQueries = [
prisma.$executeRawUnsafe(`SET LOCAL app.current_user_id = '${session.userId}';`),
prisma.$executeRawUnsafe(`SET LOCAL app.current_user_role = '${session.role}';`),
prisma.$executeRawUnsafe(`SET LOCAL app.current_hotel_id = '${session.hotelId}';`),
prisma.$executeRawUnsafe(`SET LOCAL app.current_region_id = '${session.regionId}';`),
];
const results = await prisma.$transaction([...rlsQueries, ...args], options);
return results.slice(rlsQueries.length);
}
return prisma.$transaction(args, options);
}
},
query: {
$allModels: {
async $allOperations({ args, query, __internalParams }: any) {

27
src/lib/roles.ts Normal file
View file

@ -0,0 +1,27 @@
export interface RoleDetail {
id: string;
nameEn: string;
nameEs: string;
}
export const ROLES: Record<string, RoleDetail> = {
admin: { id: 'admin', nameEn: 'Administrator', nameEs: 'Administrador' },
director: { id: 'director', nameEn: 'Director', nameEs: 'Director' },
hotel_manager: { id: 'hotel_manager', nameEn: 'Hotel Manager', nameEs: 'Gerente de Hotel' },
commercial_leader: { id: 'commercial_leader', nameEn: 'Commercial Leader', nameEs: 'Líder Comercial' },
analyst: { id: 'analyst', nameEn: 'Analyst', nameEs: 'Analista' },
auditor: { id: 'auditor', nameEn: 'Auditor', nameEs: 'Auditor' },
collaborator: { id: 'collaborator', nameEn: 'Collaborator', nameEs: 'Colaborador' }
};
export function getRoleLabel(roleId: string, locale: 'en' | 'es' = 'es'): string {
const role = ROLES[roleId.toLowerCase()];
if (!role) return roleId;
return locale === 'en' ? role.nameEn : role.nameEs;
}
export function getRoleBilingualLabel(roleId: string): string {
const role = ROLES[roleId.toLowerCase()];
if (!role) return roleId;
return `${role.nameEs} / ${role.nameEn}`;
}

View file

@ -37,7 +37,7 @@ export async function middleware(req: NextRequest) {
if (!session) {
if (pathname.startsWith('/api/')) {
return NextResponse.json(
{ error: 'Unauthorized. Session expired or missing.' },
{ error: 'No autorizado. La sesión ha expirado o no existe.' },
{ status: 401 }
);
}