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 && (