From bea8c4a3ddac670fcca59045b0b9b72a22c8b6f2 Mon Sep 17 00:00:00 2001
From: Victor
Date: Mon, 9 Mar 2026 21:44:49 +0100
Subject: [PATCH 1/3] feat(import): allow org editors to import DEAs with org
assignment
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Organization editors (can_edit=true) can now use the CSV import at
/admin/imports. Imported DEAs are automatically associated with the
importing organization via AedOrganizationAssignment, and the assignment
type (OWNERSHIP, MAINTENANCE, VERIFICATION) is configurable during import.
Backend:
- Extend canImportAeds permission to include org editors
- Add requireImportPermission() auth helper
- GET /api/import filters by org for non-admins
- POST /api/import accepts organizationId + assignmentType
- PrismaStateStore persists organization_id on BatchJob
- aedRecordProcessor creates AedOrganizationAssignment in transaction
- Resume/cancel routes verify org membership
Frontend:
- Admin imports page accessible to org editors with badge
- ImportWizard: org selector + assignment type dropdown
No DB migration needed — BatchJob.organization_id already exists.
Co-Authored-By: Claude Opus 4.6
---
src/app/admin/imports/page.tsx | 26 +++-
src/app/api/auth/me/route.ts | 3 +-
src/app/api/import/[id]/cancel/route.ts | 32 +++--
src/app/api/import/[id]/resume/route.ts | 33 ++++--
src/app/api/import/route.ts | 68 +++++++++--
src/components/import/ImportWizard.tsx | 111 +++++++++++++++++-
.../application/services/BulkImportService.ts | 27 ++++-
.../processors/aedRecordProcessor.ts | 29 ++++-
.../infrastructure/state/PrismaStateStore.ts | 5 +
src/lib/auth.ts | 56 +++++++++
10 files changed, 347 insertions(+), 43 deletions(-)
diff --git a/src/app/admin/imports/page.tsx b/src/app/admin/imports/page.tsx
index 808f6845..83175510 100644
--- a/src/app/admin/imports/page.tsx
+++ b/src/app/admin/imports/page.tsx
@@ -33,17 +33,24 @@ export default function AdminImportsPage() {
refreshInterval: 5000,
});
+ const canImport = user?.role === "ADMIN" || user?.permissions?.canImportAeds;
+
+ // Organizations where the user can edit (for org editors)
+ const editableOrgs =
+ user?.permissions?.organizations?.filter((o) => o.permissions.can_edit) ?? [];
+ const isGlobalAdmin = user?.role === "ADMIN";
+
useEffect(() => {
if (!authLoading && !user) {
router.push("/login?redirect=/admin/imports");
return;
}
- if (!authLoading && user && user.role !== "ADMIN") {
+ if (!authLoading && user && !canImport) {
router.push("/");
return;
}
- }, [authLoading, user, router]);
+ }, [authLoading, user, router, canImport]);
const handleWizardComplete = (_batchId: string) => {
refetch();
@@ -81,7 +88,7 @@ export default function AdminImportsPage() {
);
}
- if (!user || user.role !== "ADMIN") {
+ if (!user || !canImport) {
return null;
}
@@ -105,6 +112,13 @@ export default function AdminImportsPage() {
Importaciones
Importar DEAs masivamente desde archivos CSV
+ {!isGlobalAdmin && editableOrgs.length > 0 && (
+
+ {editableOrgs.length === 1
+ ? editableOrgs[0].name
+ : `${editableOrgs.length} organizaciones`}
+
+ )}
@@ -120,7 +134,11 @@ export default function AdminImportsPage() {
Volver al historial
-
+
) : (
<>
diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts
index 62f6f476..1920de3d 100644
--- a/src/app/api/auth/me/route.ts
+++ b/src/app/api/auth/me/route.ts
@@ -70,6 +70,7 @@ export async function GET() {
// Calculate aggregated permissions from organizations
const canVerifyFromOrg = orgMemberships.some((m) => m.can_verify);
+ const canEditFromOrg = orgMemberships.some((m) => m.can_edit);
const canApproveFromOrg = orgMemberships.some((m) => m.can_approve);
// Check ownership of DEAs
@@ -95,7 +96,7 @@ export async function GET() {
canApprovePublications: isAdmin || canApproveFromOrg,
// Import/Export permissions
- canImportAeds: isAdmin || isModerator,
+ canImportAeds: isAdmin || isModerator || canEditFromOrg,
canExportAeds: isAdmin || isModerator || canVerifyFromOrg,
// Owner permissions
diff --git a/src/app/api/import/[id]/cancel/route.ts b/src/app/api/import/[id]/cancel/route.ts
index 573ea398..4c2ec722 100644
--- a/src/app/api/import/[id]/cancel/route.ts
+++ b/src/app/api/import/[id]/cancel/route.ts
@@ -23,11 +23,8 @@ interface RouteParams {
*/
export async function POST(request: NextRequest, { params }: RouteParams) {
try {
- // Verify authentication
+ // Verify authentication — requireAuth throws, never returns null
const user = await requireAuth(request);
- if (!user) {
- return NextResponse.json({ success: false, error: "No autenticado" }, { status: 401 });
- }
const { id } = await params;
@@ -36,19 +33,34 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
// Leer metadata del job para determinar el motor
const job = await prisma.batchJob.findUnique({
where: { id },
- select: { metadata: true, status: true, created_by: true },
+ select: { metadata: true, status: true, created_by: true, organization_id: true },
});
if (!job) {
return NextResponse.json({ success: false, error: `Job ${id} not found` }, { status: 404 });
}
- // Verificar que el usuario es dueño del job o admin
+ // Verificar que el usuario es dueño del job, admin, o miembro de la org del job
if (job.created_by !== user.userId && user.role !== UserRole.ADMIN) {
- return NextResponse.json(
- { success: false, error: "No autorizado para cancelar este job" },
- { status: 403 }
- );
+ let hasOrgAccess = false;
+ if (job.organization_id) {
+ const membership = await prisma.organizationMember.findUnique({
+ where: {
+ organization_id_user_id: {
+ organization_id: job.organization_id,
+ user_id: user.userId,
+ },
+ },
+ select: { can_edit: true },
+ });
+ hasOrgAccess = !!membership?.can_edit;
+ }
+ if (!hasOrgAccess) {
+ return NextResponse.json(
+ { success: false, error: "No autorizado para cancelar este job" },
+ { status: 403 }
+ );
+ }
}
const metadata = (job.metadata || {}) as Record;
diff --git a/src/app/api/import/[id]/resume/route.ts b/src/app/api/import/[id]/resume/route.ts
index 113c5f0c..173a9d83 100644
--- a/src/app/api/import/[id]/resume/route.ts
+++ b/src/app/api/import/[id]/resume/route.ts
@@ -25,11 +25,8 @@ interface RouteParams {
*/
export async function POST(request: NextRequest, { params }: RouteParams) {
try {
- // Verify authentication
+ // Verify authentication — requireAuth throws, never returns null
const user = await requireAuth(request);
- if (!user) {
- return NextResponse.json({ success: false, error: "No autenticado" }, { status: 401 });
- }
const { id } = await params;
@@ -38,19 +35,35 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
// Leer metadata del job para determinar el motor
const job = await prisma.batchJob.findUnique({
where: { id },
- select: { metadata: true, created_by: true, status: true },
+ select: { metadata: true, created_by: true, status: true, organization_id: true },
});
if (!job) {
return NextResponse.json({ success: false, error: `Job ${id} not found` }, { status: 404 });
}
- // Verificar que el usuario es dueño del job o admin
+ // Verificar que el usuario es dueño del job, admin, o miembro de la org del job
if (job.created_by !== user.userId && user.role !== UserRole.ADMIN) {
- return NextResponse.json(
- { success: false, error: "No autorizado para reanudar este job" },
- { status: 403 }
- );
+ // Check if user belongs to the job's organization with can_edit
+ let hasOrgAccess = false;
+ if (job.organization_id) {
+ const membership = await prisma.organizationMember.findUnique({
+ where: {
+ organization_id_user_id: {
+ organization_id: job.organization_id,
+ user_id: user.userId,
+ },
+ },
+ select: { can_edit: true },
+ });
+ hasOrgAccess = !!membership?.can_edit;
+ }
+ if (!hasOrgAccess) {
+ return NextResponse.json(
+ { success: false, error: "No autorizado para reanudar este job" },
+ { status: 403 }
+ );
+ }
}
const metadata = (job.metadata || {}) as Record;
diff --git a/src/app/api/import/route.ts b/src/app/api/import/route.ts
index 7a80de2a..fc659746 100644
--- a/src/app/api/import/route.ts
+++ b/src/app/api/import/route.ts
@@ -10,7 +10,8 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
-import { requireAuth } from "@/lib/auth";
+import { requireAuth, requireImportPermission } from "@/lib/auth";
+import { UserRole } from "@/generated/client/enums";
import { JobType } from "@/batch/domain";
import { getBulkImportService } from "@/import/infrastructure/factories/createBulkImportService";
import { uploadToS3 } from "@/lib/s3";
@@ -31,7 +32,7 @@ import {
export async function GET(request: NextRequest) {
try {
// Verify authentication
- await requireAuth(request);
+ const user = await requireAuth(request);
// Parse query parameters
const searchParams = request.nextUrl.searchParams;
@@ -39,12 +40,36 @@ export async function GET(request: NextRequest) {
const limit = Math.min(parseInt(searchParams.get("limit") ?? "20", 10), 100);
const skip = (page - 1) * limit;
+ // Build org filter: admins see all, org editors see only their org's imports
+ const isAdmin = user.role === UserRole.ADMIN;
+ let orgFilter: { organization_id?: { in: string[] } } = {};
+
+ if (!isAdmin) {
+ const memberships = await prisma.organizationMember.findMany({
+ where: { user_id: user.userId, can_edit: true },
+ select: { organization_id: true },
+ });
+ const orgIds = memberships.map((m: { organization_id: string }) => m.organization_id);
+ if (orgIds.length === 0) {
+ // User has no orgs with edit permission — return empty
+ return NextResponse.json({
+ batches: [],
+ pagination: { page, limit, total: 0, totalPages: 0 },
+ });
+ }
+ orgFilter = { organization_id: { in: orgIds } };
+ }
+
// Query batch jobs of type AED_CSV_IMPORT
+ const importType = JobType.AED_CSV_IMPORT;
+ const whereClause = {
+ type: importType,
+ ...orgFilter,
+ };
+
const [jobs, total] = await Promise.all([
prisma.batchJob.findMany({
- where: {
- type: JobType.AED_CSV_IMPORT,
- },
+ where: whereClause,
orderBy: {
created_at: "desc",
},
@@ -66,9 +91,7 @@ export async function GET(request: NextRequest) {
},
}),
prisma.batchJob.count({
- where: {
- type: JobType.AED_CSV_IMPORT,
- },
+ where: whereClause,
}),
]);
@@ -127,12 +150,16 @@ export async function GET(request: NextRequest) {
*/
export async function POST(request: NextRequest) {
try {
- // Verify authentication
- const user = await requireAuth(request);
-
// Parse request body
const body = await request.json();
- const { filePath, mappings, batchName, sharepointCookies } = body;
+ const { filePath, mappings, batchName, sharepointCookies, organizationId, assignmentType } =
+ body;
+
+ // Verify authentication and import permissions
+ const { user, organizationId: resolvedOrgId } = await requireImportPermission(
+ request,
+ organizationId
+ );
if (!filePath || !mappings || !Array.isArray(mappings)) {
return NextResponse.json(
@@ -141,6 +168,21 @@ export async function POST(request: NextRequest) {
);
}
+ // Validate assignmentType if provided
+ const validAssignmentTypes = [
+ "CIVIL_PROTECTION",
+ "CERTIFIED_COMPANY",
+ "OWNERSHIP",
+ "MAINTENANCE",
+ "VERIFICATION",
+ ];
+ if (assignmentType && !validAssignmentTypes.includes(assignmentType)) {
+ return NextResponse.json(
+ { error: `Tipo de asignación no válido. Opciones: ${validAssignmentTypes.join(", ")}` },
+ { status: 400 }
+ );
+ }
+
// Validar que filePath esté dentro de /tmp o del directorio temporal del OS
// para prevenir path traversal (lectura/borrado de archivos arbitrarios)
const resolvedPath = path.resolve(filePath);
@@ -219,6 +261,8 @@ export async function POST(request: NextRequest) {
sharePointAuth,
maxDurationMs: VERCEL_API_MAX_DURATION_MS,
jobName: batchName || `Importación CSV ${new Date().toISOString()}`,
+ organizationId: resolvedOrgId || undefined,
+ assignmentType: assignmentType || undefined,
});
// Crear artifact para tracking del archivo CSV
diff --git a/src/components/import/ImportWizard.tsx b/src/components/import/ImportWizard.tsx
index bd38521f..ced86526 100644
--- a/src/components/import/ImportWizard.tsx
+++ b/src/components/import/ImportWizard.tsx
@@ -5,7 +5,7 @@
"use client";
-import { ArrowLeft, CheckCircle, Loader2, AlertCircle } from "lucide-react";
+import { ArrowLeft, CheckCircle, Loader2, AlertCircle, Building2 } from "lucide-react";
import { useState } from "react";
import toast from "react-hot-toast";
@@ -14,17 +14,45 @@ import SharePointCookiesModal from "./SharePointCookiesModal";
import ValidationErrorsTable from "./ValidationErrorsTable";
import ImportPreviewTable from "./ImportPreviewTable";
+import type { UserOrganization } from "@/types";
+
type Step = "upload" | "preview" | "mapping" | "validation";
+const ASSIGNMENT_TYPE_LABELS: Record = {
+ OWNERSHIP: "Propiedad",
+ MAINTENANCE: "Mantenimiento",
+ CIVIL_PROTECTION: "Protección Civil",
+ CERTIFIED_COMPANY: "Empresa Certificada",
+ VERIFICATION: "Verificación",
+};
+
interface ImportWizardProps {
onComplete?: (batchId: string) => void;
+ /** Organizations where the user has can_edit permission */
+ organizations?: UserOrganization[];
+ /** Whether the current user is a global admin */
+ isGlobalAdmin?: boolean;
}
-export default function ImportWizard({ onComplete: _onComplete }: ImportWizardProps) {
+export default function ImportWizard({
+ onComplete: _onComplete,
+ organizations = [],
+ isGlobalAdmin = false,
+}: ImportWizardProps) {
const [currentStep, setCurrentStep] = useState("upload");
const [sessionData, setSessionData] = useState(null);
const [isImporting, setIsImporting] = useState(false);
+ // Organization context for import
+ const [selectedOrgId, setSelectedOrgId] = useState(
+ // Auto-select if non-admin with exactly one org
+ !isGlobalAdmin && organizations.length === 1 ? organizations[0].id : null
+ );
+ const [assignmentType, setAssignmentType] = useState("OWNERSHIP");
+
+ // Non-admin users MUST select an organization
+ const needsOrgSelection = !isGlobalAdmin && organizations.length > 0;
+
const steps = [
{ id: "upload", label: "Subir CSV", icon: "📤" },
{ id: "preview", label: "Preview", icon: "👁️" },
@@ -108,8 +136,10 @@ export default function ImportWizard({ onComplete: _onComplete }: ImportWizardPr
body: JSON.stringify({
filePath: sessionData.filePath,
mappings: sessionData.mappings,
- sharepointCookies: sessionData.sharepointCookies, // ← Enviar cookies de SharePoint si existen
+ sharepointCookies: sessionData.sharepointCookies,
batchName: `Importación ${new Date().toLocaleString()}`,
+ organizationId: selectedOrgId || undefined,
+ assignmentType: selectedOrgId ? assignmentType : undefined,
}),
});
@@ -190,7 +220,80 @@ export default function ImportWizard({ onComplete: _onComplete }: ImportWizardPr
Selecciona el archivo CSV que deseas importar. El sistema analizará automáticamente
las columnas y sugerirá mapeos.
-
+
+ {/* Organization & Assignment Type Selection */}
+ {(needsOrgSelection || (isGlobalAdmin && organizations.length > 0)) && (
+
+
+
+
Contexto de importación
+
+
+ {/* Organization selector */}
+
+
+
+
+
+ {/* Assignment type selector (only when org is selected) */}
+ {selectedOrgId && (
+
+
+
+
+ )}
+
+ {!isGlobalAdmin && !selectedOrgId && (
+
+ Debes seleccionar una organización para importar
+
+ )}
+
+ )}
+
+ {/* Only show upload zone when org is selected (for non-admins) or always for admins */}
+ {isGlobalAdmin || selectedOrgId ? (
+
+ ) : (
+
+ Selecciona una organización para continuar
+
+ )}
)}
diff --git a/src/import/application/services/BulkImportService.ts b/src/import/application/services/BulkImportService.ts
index fb6a2c79..e4ba6055 100644
--- a/src/import/application/services/BulkImportService.ts
+++ b/src/import/application/services/BulkImportService.ts
@@ -107,6 +107,10 @@ export interface StartImportOptions {
maxRecordsPerChunk?: number;
/** Nombre del job para la UI */
jobName?: string;
+ /** ID de la organización que importa (para asignación automática de DEAs) */
+ organizationId?: string;
+ /** Tipo de asignación organizacional (OWNERSHIP, MAINTENANCE, etc.) */
+ assignmentType?: string;
}
export interface StartImportResult {
@@ -236,6 +240,8 @@ export class BulkImportService {
maxDurationMs = VERCEL_API_MAX_DURATION_MS,
maxRecordsPerChunk = DEFAULT_CHUNK_MAX_RECORDS,
jobName,
+ organizationId,
+ assignmentType,
} = options;
const { stateStore, source, processor, duplicateChecker, hooks } =
@@ -247,6 +253,8 @@ export class BulkImportService {
skipDuplicates,
sharePointAuth,
jobName: jobName || `Import ${fileName}`,
+ organizationId,
+ assignmentType,
});
// Crear instancia de BulkImport
@@ -467,18 +475,32 @@ export class BulkImportService {
skipDuplicates: boolean;
sharePointAuth?: SharePointAuthConfig;
jobName?: string;
+ organizationId?: string;
+ assignmentType?: string;
}) {
- const { s3Url, fileName, userId, delimiter, skipDuplicates, sharePointAuth, jobName } = params;
+ const {
+ s3Url,
+ fileName,
+ userId,
+ delimiter,
+ skipDuplicates,
+ sharePointAuth,
+ jobName,
+ organizationId,
+ assignmentType,
+ } = params;
const stateStore = new PrismaStateStore(this.prisma, {
createdBy: userId,
...(jobName && { jobName }),
+ ...(organizationId && { organizationId }),
importContext: {
s3Url,
fileName,
delimiter,
sharePointAuth,
skipDuplicates,
+ assignmentType,
},
});
@@ -498,6 +520,9 @@ export class BulkImportService {
const processor = createAedRecordProcessor({
prisma: this.prisma,
fileName,
+ organizationId,
+ assignmentType,
+ userId,
});
return { stateStore, source, processor, duplicateChecker, hooks };
diff --git a/src/import/infrastructure/processors/aedRecordProcessor.ts b/src/import/infrastructure/processors/aedRecordProcessor.ts
index 99337fe3..048c3288 100644
--- a/src/import/infrastructure/processors/aedRecordProcessor.ts
+++ b/src/import/infrastructure/processors/aedRecordProcessor.ts
@@ -26,6 +26,12 @@ export interface AedRecordProcessorOptions {
prisma: PrismaClient;
/** Nombre/path del archivo CSV para source_details */
fileName?: string;
+ /** ID de la organización que importa (para asignación automática) */
+ organizationId?: string;
+ /** Tipo de asignación organizacional (default: OWNERSHIP) */
+ assignmentType?: string;
+ /** ID del usuario que importa (para assigned_by en la asignación) */
+ userId?: string;
}
// ============================================================
@@ -118,7 +124,7 @@ function hasResponsibleData(data: Record): boolean {
export function createAedRecordProcessor(
options: AedRecordProcessorOptions
): (record: ParsedRecord, context: ProcessingContext) => Promise {
- const { prisma, fileName } = options;
+ const { prisma, fileName, organizationId, assignmentType, userId } = options;
return async (record: ParsedRecord, context: ProcessingContext): Promise => {
const data = record as Record;
@@ -237,6 +243,27 @@ export function createAedRecordProcessor(
status: "DRAFT",
},
});
+
+ // ========================================
+ // 5. CREATE ORG ASSIGNMENT (conditional)
+ // ========================================
+ if (organizationId) {
+ await tx.aedOrganizationAssignment.create({
+ data: {
+ aed_id: aedId,
+ organization_id: organizationId,
+ assignment_type:
+ (assignmentType as
+ | "OWNERSHIP"
+ | "MAINTENANCE"
+ | "CIVIL_PROTECTION"
+ | "CERTIFIED_COMPANY"
+ | "VERIFICATION") || "OWNERSHIP",
+ status: "ACTIVE",
+ assigned_by: userId || null,
+ },
+ });
+ }
});
// Nota: Las imágenes se descargan/suben en el hook afterProcess
diff --git a/src/import/infrastructure/state/PrismaStateStore.ts b/src/import/infrastructure/state/PrismaStateStore.ts
index b43c7f73..dbc9c951 100644
--- a/src/import/infrastructure/state/PrismaStateStore.ts
+++ b/src/import/infrastructure/state/PrismaStateStore.ts
@@ -61,6 +61,8 @@ export interface ImportContext {
delimiter?: string;
sharePointAuth?: { rtFa?: string; fedAuth?: string };
skipDuplicates?: boolean;
+ /** Tipo de asignación org (OWNERSHIP, MAINTENANCE, etc.) */
+ assignmentType?: string;
}
export interface PrismaStateStoreOptions {
@@ -68,6 +70,8 @@ export interface PrismaStateStoreOptions {
createdBy: string;
/** Nombre del batch job */
jobName?: string;
+ /** ID de la organización que importa (para filtrado y asignación) */
+ organizationId?: string;
/**
* Contexto de importación que se persiste en metadata.import_context.
* Necesario para que el CRON pueda reconstruir ResumeImportOptions.
@@ -143,6 +147,7 @@ export class PrismaStateStore implements StateStore {
last_heartbeat: new Date(),
last_checkpoint_index: processedRecords > 0 ? processedRecords - 1 : -1,
created_by: this.options.createdBy,
+ organization_id: this.options.organizationId || null,
metadata: metadata as object,
};
diff --git a/src/lib/auth.ts b/src/lib/auth.ts
index 689dfd2e..8b6ec35b 100644
--- a/src/lib/auth.ts
+++ b/src/lib/auth.ts
@@ -3,6 +3,7 @@ import { NextRequest } from "next/server";
import type { JWTPayload } from "@/types";
+import { prisma } from "@/lib/db";
import { getCurrentUserFromRequest } from "./jwt";
import { getUserPermissionsForAed } from "@/lib/organization-permissions";
@@ -97,3 +98,58 @@ export async function requireAdminOrAedPermission(
return { user, isGlobalAdmin: false, permissions };
}
+
+export interface ImportAuthResult {
+ user: JWTPayload;
+ isGlobalAdmin: boolean;
+ /** null for global admin imports without org context */
+ organizationId: string | null;
+}
+
+/**
+ * Require import permission: global ADMIN or org member with can_edit.
+ *
+ * - Global ADMINs pass immediately. organizationId is optional for them.
+ * - Non-admins MUST provide organizationId and belong to that org with can_edit=true.
+ * - Returns { user, isGlobalAdmin, organizationId } for downstream use.
+ */
+export async function requireImportPermission(
+ request: NextRequest,
+ organizationId?: string | null
+): Promise {
+ const user = await requireAuth(request);
+
+ if (user.role === UserRole.ADMIN) {
+ return {
+ user,
+ isGlobalAdmin: true,
+ organizationId: organizationId || null,
+ };
+ }
+
+ // Non-admin: organizationId is required
+ if (!organizationId) {
+ throw new AuthError("Debes seleccionar una organización para importar", 400);
+ }
+
+ // Verify user belongs to this org with can_edit permission
+ const membership = await prisma.organizationMember.findUnique({
+ where: {
+ organization_id_user_id: {
+ organization_id: organizationId,
+ user_id: user.userId,
+ },
+ },
+ select: { can_edit: true },
+ });
+
+ if (!membership || !membership.can_edit) {
+ throw new AuthError("No tienes permisos de edición en esta organización", 403);
+ }
+
+ return {
+ user,
+ isGlobalAdmin: false,
+ organizationId,
+ };
+}
From 098f90e9187cd219032731d3d90cc0a93215d1c7 Mon Sep 17 00:00:00 2001
From: Victor
Date: Mon, 9 Mar 2026 22:14:40 +0100
Subject: [PATCH 2/3] feat(seed): add org editor and verifier test users for
SAMUR
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Add test users with working passwords (123456) and org memberships:
- editor@deamap.es → SAMUR - Protección Civil (can_edit)
- verificador@deamap.es → SAMUR - Protección Civil (can_edit + can_verify)
These users allow testing the org editor import flow on branch DBs.
Co-Authored-By: Claude Opus 4.6
---
prisma/seed-dummy.ts | 100 +++++++++++++++++++++++++++++++++++++++++--
1 file changed, 97 insertions(+), 3 deletions(-)
diff --git a/prisma/seed-dummy.ts b/prisma/seed-dummy.ts
index 8630477e..f2fe4de3 100644
--- a/prisma/seed-dummy.ts
+++ b/prisma/seed-dummy.ts
@@ -320,7 +320,37 @@ async function createTestUsers() {
});
}
- console.log(`✅ Created ${userCount + 1} test users (including admin@deamap.es)`);
+ // Create org editor user with working password (same as admin: "123456")
+ await prisma.user.upsert({
+ where: { email: "editor@deamap.es" },
+ update: {},
+ create: {
+ email: "editor@deamap.es",
+ password_hash: "$2a$12$4K2OfmPm3siMhBLTfKrQved1KQ2kVutUh4dFwQuU1NXyWHP1eb/2C", // bcrypt hash for "123456"
+ name: "Editor Organización",
+ role: "USER",
+ is_active: true,
+ is_verified: true,
+ },
+ });
+
+ // Create verifier user with working password
+ await prisma.user.upsert({
+ where: { email: "verificador@deamap.es" },
+ update: {},
+ create: {
+ email: "verificador@deamap.es",
+ password_hash: "$2a$12$4K2OfmPm3siMhBLTfKrQved1KQ2kVutUh4dFwQuU1NXyWHP1eb/2C", // bcrypt hash for "123456"
+ name: "Verificador Organización",
+ role: "USER",
+ is_active: true,
+ is_verified: true,
+ },
+ });
+
+ console.log(
+ `✅ Created ${userCount + 3} test users (including admin, editor, verificador @deamap.es)`
+ );
}
// Generate a single AED with all related data
@@ -551,6 +581,69 @@ function getStatusChangeReason(from: AedStatus | null, to: AedStatus): string {
return pickRandom(reasons[key] || reasons.default);
}
+// Create org memberships for test users
+// Links editor and verificador to SAMUR - Protección Civil (CIVIL_PROTECTION)
+async function createOrgMemberships(_orgIds: string[]) {
+ console.log("🔗 Creating organization memberships...");
+
+ // Find SAMUR - Protección Civil org by code
+ const samurCode = generateOrgCode("SAMUR - Protección Civil");
+ const samurOrg = await prisma.organization.findUnique({ where: { code: samurCode } });
+
+ if (!samurOrg) {
+ console.log(" ⚠️ SAMUR org not found, skipping memberships");
+ return;
+ }
+
+ // Assign editor@deamap.es to SAMUR with can_edit
+ const editor = await prisma.user.findUnique({ where: { email: "editor@deamap.es" } });
+ if (editor) {
+ await prisma.organizationMember.upsert({
+ where: {
+ organization_id_user_id: {
+ organization_id: samurOrg.id,
+ user_id: editor.id,
+ },
+ },
+ update: {},
+ create: {
+ organization_id: samurOrg.id,
+ user_id: editor.id,
+ can_edit: true,
+ can_verify: false,
+ can_approve: false,
+ can_manage_members: false,
+ },
+ });
+ }
+
+ // Assign verificador@deamap.es to SAMUR with can_verify + can_edit
+ const verificador = await prisma.user.findUnique({
+ where: { email: "verificador@deamap.es" },
+ });
+ if (verificador) {
+ await prisma.organizationMember.upsert({
+ where: {
+ organization_id_user_id: {
+ organization_id: samurOrg.id,
+ user_id: verificador.id,
+ },
+ },
+ update: {},
+ create: {
+ organization_id: samurOrg.id,
+ user_id: verificador.id,
+ can_edit: true,
+ can_verify: true,
+ can_approve: false,
+ can_manage_members: false,
+ },
+ });
+ }
+
+ console.log(`✅ Created org memberships for editor and verificador → ${samurOrg.name}`);
+}
+
async function main() {
console.log("🌱 Starting dummy data seed...\n");
console.log(`📊 Configuration:`);
@@ -560,8 +653,9 @@ async function main() {
// Load reference data and create base data
await loadDistrictsReference();
- await createOrganizations();
+ const orgIds = await createOrganizations();
await createTestUsers();
+ await createOrgMemberships(orgIds);
// Track sequences per district for code generation
const districtSequences = new Map();
@@ -589,7 +683,7 @@ async function main() {
console.log(`\n📊 Summary:`);
console.log(` - Districts (reference): ${madridData.districts.length}`);
console.log(` - Organizations: ${madridData.organizations.length}`);
- console.log(` - Test users: 20`);
+ console.log(` - Test users: 23 (including admin, editor, verificador)`);
console.log(` - DEAs: ${CONFIG.totalAeds}`);
console.log(` - Time: ${totalTime}s`);
From 1a0876e68d6454cc025ae435442a62c0a9b90878 Mon Sep 17 00:00:00 2001
From: Victor
Date: Mon, 9 Mar 2026 22:26:34 +0100
Subject: [PATCH 3/3] feat(import): auto-derive assignment type from org and
add navigation
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Assignment type is now auto-derived from the organization's type
(e.g., CIVIL_PROTECTION org → CIVIL_PROTECTION assignment). Only
global admins can override this via a dropdown selector.
- Add "Importar" link to main navigation (visible when canImportAeds)
- Add "Importar DEAs" quick action to org dashboard for editors
Co-Authored-By: Claude Opus 4.6
---
src/app/api/import/route.ts | 75 +++++++++++++++++++-------
src/app/org/[orgId]/page.tsx | 24 ++++++++-
src/components/Navigation.tsx | 7 +++
src/components/import/ImportWizard.tsx | 7 +--
4 files changed, 90 insertions(+), 23 deletions(-)
diff --git a/src/app/api/import/route.ts b/src/app/api/import/route.ts
index fc659746..aaa5c31d 100644
--- a/src/app/api/import/route.ts
+++ b/src/app/api/import/route.ts
@@ -25,6 +25,23 @@ import {
VERCEL_API_MAX_DURATION_MS,
} from "@/import/constants";
+/**
+ * Maps OrganizationType → AssignmentType for automatic org assignment.
+ */
+function mapOrgTypeToAssignmentType(orgType: string | null): string {
+ switch (orgType) {
+ case "CIVIL_PROTECTION":
+ return "CIVIL_PROTECTION";
+ case "CERTIFIED_COMPANY":
+ return "CERTIFIED_COMPANY";
+ case "OWNER":
+ return "OWNERSHIP";
+ default:
+ // MUNICIPALITY, HEALTH_SERVICE, VOLUNTEER_GROUP, null → OWNERSHIP
+ return "OWNERSHIP";
+ }
+}
+
/**
* GET /api/import
* List import batches with pagination
@@ -156,31 +173,51 @@ export async function POST(request: NextRequest) {
body;
// Verify authentication and import permissions
- const { user, organizationId: resolvedOrgId } = await requireImportPermission(
- request,
- organizationId
- );
+ const {
+ user,
+ isGlobalAdmin,
+ organizationId: resolvedOrgId,
+ } = await requireImportPermission(request, organizationId);
if (!filePath || !mappings || !Array.isArray(mappings)) {
return NextResponse.json(
- { error: "Faltan parámetros requeridos (filePath, mappings)" },
+ { error: "Faltan parámetros requeridos (filePath, mappings)" },
{ status: 400 }
);
}
- // Validate assignmentType if provided
- const validAssignmentTypes = [
- "CIVIL_PROTECTION",
- "CERTIFIED_COMPANY",
- "OWNERSHIP",
- "MAINTENANCE",
- "VERIFICATION",
- ];
- if (assignmentType && !validAssignmentTypes.includes(assignmentType)) {
- return NextResponse.json(
- { error: `Tipo de asignación no válido. Opciones: ${validAssignmentTypes.join(", ")}` },
- { status: 400 }
- );
+ // Resolve assignmentType:
+ // - Admin: can specify any valid type (defaults to OWNERSHIP if org selected)
+ // - Org editor: auto-derived from org type, cannot override
+ let resolvedAssignmentType: string | undefined;
+
+ if (resolvedOrgId) {
+ if (isGlobalAdmin && assignmentType) {
+ // Admin can override — validate the value
+ const validAssignmentTypes = [
+ "CIVIL_PROTECTION",
+ "CERTIFIED_COMPANY",
+ "OWNERSHIP",
+ "MAINTENANCE",
+ "VERIFICATION",
+ ];
+ if (!validAssignmentTypes.includes(assignmentType)) {
+ return NextResponse.json(
+ {
+ error: `Tipo de asignación no válido. Opciones: ${validAssignmentTypes.join(", ")}`,
+ },
+ { status: 400 }
+ );
+ }
+ resolvedAssignmentType = assignmentType;
+ } else {
+ // Derive from organization type
+ const org = await prisma.organization.findUnique({
+ where: { id: resolvedOrgId },
+ select: { type: true },
+ });
+ resolvedAssignmentType = mapOrgTypeToAssignmentType(org?.type ?? null);
+ }
}
// Validar que filePath esté dentro de /tmp o del directorio temporal del OS
@@ -262,7 +299,7 @@ export async function POST(request: NextRequest) {
maxDurationMs: VERCEL_API_MAX_DURATION_MS,
jobName: batchName || `Importación CSV ${new Date().toISOString()}`,
organizationId: resolvedOrgId || undefined,
- assignmentType: assignmentType || undefined,
+ assignmentType: resolvedAssignmentType,
});
// Crear artifact para tracking del archivo CSV
diff --git a/src/app/org/[orgId]/page.tsx b/src/app/org/[orgId]/page.tsx
index 8bb7c872..51fbbe6d 100644
--- a/src/app/org/[orgId]/page.tsx
+++ b/src/app/org/[orgId]/page.tsx
@@ -8,6 +8,7 @@ import {
Clock,
TrendingUp,
ArrowRight,
+ FileUp,
} from "lucide-react";
import Link from "next/link";
import { useEffect, useState, use } from "react";
@@ -26,7 +27,7 @@ interface OrgStats {
}
export default function OrgDashboard({ params }: { params: Promise<{ orgId: string }> }) {
- const { selectedOrganization, canManageMembers } = useOrganization();
+ const { selectedOrganization, canManageMembers, canEdit } = useOrganization();
const [stats, setStats] = useState(null);
const [loading, setLoading] = useState(true);
const resolvedParams = use(params);
@@ -187,6 +188,27 @@ export default function OrgDashboard({ params }: { params: Promise<{ orgId: stri
+ {/* Import DEAs */}
+ {canEdit && (
+
+
+
+
+
+
+
+
Importar DEAs
+
Importar DEAs masivamente desde CSV
+
+
+
+
+
+ )}
+
{/* Manage team */}
{canManageMembers && (
- {/* Assignment type selector (only when org is selected) */}
- {selectedOrgId && (
+ {/* Assignment type: admin can override, org editors see auto-derived */}
+ {selectedOrgId && isGlobalAdmin && (