initial push

This commit is contained in:
Marcelo Dares
2026-03-15 15:03:56 +01:00
parent d48b9d5352
commit 65aaf9275e
146 changed files with 70245 additions and 100 deletions

View File

@@ -0,0 +1,3 @@
export const runtime = "nodejs";
export { POST } from "@/app/api/onboarding/acta/route";

View File

@@ -0,0 +1,52 @@
import { NextResponse } from "next/server";
import { ContentPageType } from "@prisma/client";
import { requireAdminApiUser } from "@/lib/auth/admin";
import { prisma } from "@/lib/prisma";
import { buildAppUrl } from "@/lib/http/url";
function redirectTo(request: Request, status: string) {
return NextResponse.redirect(buildAppUrl(request, "/admin", { status }));
}
function asString(formData: FormData, key: string) {
const value = formData.get(key);
return typeof value === "string" ? value.trim() : "";
}
export async function POST(request: Request) {
const adminUser = await requireAdminApiUser();
if (!adminUser) {
return redirectTo(request, "admin_error");
}
const formData = await request.formData();
const typeValue = asString(formData, "type");
const slug = asString(formData, "slug");
const title = asString(formData, "title");
const content = asString(formData, "content");
const sortOrder = Number.parseInt(asString(formData, "sortOrder") || "0", 10);
const isPublished = formData.get("isPublished") === "on";
if (!slug || !title || !content || (typeValue !== ContentPageType.FAQ && typeValue !== ContentPageType.MANUAL)) {
return redirectTo(request, "admin_error");
}
try {
await prisma.contentPage.create({
data: {
type: typeValue as ContentPageType,
slug,
title,
content,
sortOrder: Number.isNaN(sortOrder) ? 0 : sortOrder,
isPublished,
},
});
return redirectTo(request, "content_created");
} catch {
return redirectTo(request, "admin_error");
}
}

View File

@@ -0,0 +1,54 @@
import { NextResponse } from "next/server";
import { ContentPageType } from "@prisma/client";
import { requireAdminApiUser } from "@/lib/auth/admin";
import { prisma } from "@/lib/prisma";
import { buildAppUrl } from "@/lib/http/url";
function redirectTo(request: Request, status: string) {
return NextResponse.redirect(buildAppUrl(request, "/admin", { status }));
}
function asString(formData: FormData, key: string) {
const value = formData.get(key);
return typeof value === "string" ? value.trim() : "";
}
export async function POST(request: Request) {
const adminUser = await requireAdminApiUser();
if (!adminUser) {
return redirectTo(request, "admin_error");
}
const formData = await request.formData();
const id = asString(formData, "id");
const typeValue = asString(formData, "type");
const slug = asString(formData, "slug");
const title = asString(formData, "title");
const content = asString(formData, "content");
const sortOrder = Number.parseInt(asString(formData, "sortOrder") || "0", 10);
const isPublished = formData.get("isPublished") === "on";
if (!id || !slug || !title || !content || (typeValue !== ContentPageType.FAQ && typeValue !== ContentPageType.MANUAL)) {
return redirectTo(request, "admin_error");
}
try {
await prisma.contentPage.update({
where: { id },
data: {
type: typeValue as ContentPageType,
slug,
title,
content,
sortOrder: Number.isNaN(sortOrder) ? 0 : sortOrder,
isPublished,
},
});
return redirectTo(request, "content_updated");
} catch {
return redirectTo(request, "admin_error");
}
}

View File

@@ -0,0 +1,72 @@
import { NextResponse } from "next/server";
import { OverallScoreMethod } from "@prisma/client";
import { requireAdminApiUser } from "@/lib/auth/admin";
import { DEFAULT_SCORING_CONFIG } from "@/lib/scoring-config";
import { prisma } from "@/lib/prisma";
import { buildAppUrl } from "@/lib/http/url";
function redirectTo(request: Request, status: string) {
return NextResponse.redirect(buildAppUrl(request, "/admin", { status }));
}
function asString(formData: FormData, key: string) {
const value = formData.get(key);
return typeof value === "string" ? value.trim() : "";
}
export async function POST(request: Request) {
const adminUser = await requireAdminApiUser();
if (!adminUser) {
return redirectTo(request, "admin_error");
}
const formData = await request.formData();
const thresholdCandidate = Number.parseInt(asString(formData, "lowScoreThreshold") || "70", 10);
const lowScoreThreshold = Number.isNaN(thresholdCandidate) ? 70 : Math.min(100, Math.max(0, thresholdCandidate));
const methodCandidate = asString(formData, "overallScoreMethod");
const allowedMethods = Object.values(OverallScoreMethod);
const overallScoreMethod = allowedMethods.includes(methodCandidate as OverallScoreMethod)
? (methodCandidate as OverallScoreMethod)
: OverallScoreMethod.EQUAL_ALL_MODULES;
const moduleWeights: Record<string, number> = {};
for (const [key, rawValue] of formData.entries()) {
if (!key.startsWith("weight:")) {
continue;
}
const moduleKey = key.replace("weight:", "").trim();
const numericValue = Number.parseFloat(typeof rawValue === "string" ? rawValue : String(rawValue));
if (moduleKey && !Number.isNaN(numericValue) && Number.isFinite(numericValue) && numericValue > 0) {
moduleWeights[moduleKey] = numericValue;
}
}
try {
await prisma.scoringConfig.upsert({
where: {
key: DEFAULT_SCORING_CONFIG.key,
},
update: {
lowScoreThreshold,
overallScoreMethod,
moduleWeights,
},
create: {
key: DEFAULT_SCORING_CONFIG.key,
lowScoreThreshold,
overallScoreMethod,
moduleWeights,
},
});
return redirectTo(request, "scoring_updated");
} catch {
return redirectTo(request, "admin_error");
}
}

View File

@@ -0,0 +1,42 @@
import { NextResponse } from "next/server";
import { requireAdminApiUser } from "@/lib/auth/admin";
import { runDailyLicitationsSync } from "@/lib/licitations/sync";
export const runtime = "nodejs";
export async function POST(request: Request) {
const user = await requireAdminApiUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const body = (await request.json().catch(() => ({}))) as {
municipalityId?: string;
limit?: number;
skip?: number;
targetYear?: number;
includePnt?: boolean;
force?: boolean;
};
const payload = await runDailyLicitationsSync({
municipalityId: typeof body.municipalityId === "string" ? body.municipalityId : undefined,
limit: typeof body.limit === "number" ? body.limit : undefined,
skip: typeof body.skip === "number" ? body.skip : undefined,
targetYear: typeof body.targetYear === "number" ? body.targetYear : undefined,
includePnt: body.includePnt === true,
force: body.force === true,
});
return NextResponse.json({ ok: true, payload });
} catch (error) {
return NextResponse.json(
{
error: error instanceof Error ? error.message : "No se pudo ejecutar el sync de licitaciones.",
},
{ status: 400 },
);
}
}

View File

@@ -0,0 +1,38 @@
import { NextResponse } from "next/server";
import { UserRole } from "@prisma/client";
import { requireAdminApiUser } from "@/lib/auth/admin";
import { prisma } from "@/lib/prisma";
import { buildAppUrl } from "@/lib/http/url";
function redirectTo(request: Request, status: string) {
return NextResponse.redirect(buildAppUrl(request, "/admin", { status }));
}
export async function POST(request: Request) {
const adminUser = await requireAdminApiUser();
if (!adminUser) {
return redirectTo(request, "admin_error");
}
const formData = await request.formData();
const userId = typeof formData.get("userId") === "string" ? formData.get("userId")!.toString() : "";
const roleValue = typeof formData.get("role") === "string" ? formData.get("role")!.toString() : "";
if (!userId || (roleValue !== UserRole.USER && roleValue !== UserRole.ADMIN)) {
return redirectTo(request, "admin_error");
}
try {
await prisma.user.update({
where: { id: userId },
data: {
role: roleValue as UserRole,
},
});
return redirectTo(request, "role_updated");
} catch {
return redirectTo(request, "admin_error");
}
}

View File

@@ -0,0 +1,87 @@
import { NextResponse } from "next/server";
import { isAdminIdentity } from "@/lib/auth/admin";
import { createSessionToken, setSessionCookie } from "@/lib/auth/session";
import { verifyPassword } from "@/lib/auth/password";
import { issueEmailVerificationToken, sendEmailVerificationLink } from "@/lib/auth/verification";
import { prisma } from "@/lib/prisma";
import { buildAppUrl } from "@/lib/http/url";
function redirectTo(request: Request, path: string, params: Record<string, string>) {
return NextResponse.redirect(buildAppUrl(request, path, params));
}
export async function POST(request: Request) {
const formData = await request.formData();
const email = typeof formData.get("email") === "string" ? formData.get("email")!.toString().trim().toLowerCase() : "";
const password = typeof formData.get("password") === "string" ? formData.get("password")!.toString() : "";
if (!email || !password) {
return redirectTo(request, "/login", { error: "invalid_credentials" });
}
try {
const user = await prisma.user.findUnique({ where: { email } });
if (!user) {
return redirectTo(request, "/login", { error: "invalid_credentials" });
}
const passwordMatches = await verifyPassword(password, user.passwordHash);
if (!passwordMatches) {
return redirectTo(request, "/login", { error: "invalid_credentials" });
}
if (!user.emailVerifiedAt) {
const { token } = await issueEmailVerificationToken(user.id);
const sendResult = await sendEmailVerificationLink(request, email, token);
const verifyParams: Record<string, string> = {
email,
sent: sendResult.sent ? "1" : "0",
unverified: "1",
};
if (!sendResult.sent) {
verifyParams.error = "email_delivery_failed";
}
return redirectTo(request, "/verify", verifyParams);
}
let onboardingCompleted = false;
try {
const organization = await prisma.organization.findUnique({
where: { userId: user.id },
select: { onboardingCompletedAt: true },
});
onboardingCompleted = Boolean(organization?.onboardingCompletedAt);
} catch {
// Backward compatibility for databases that have not applied onboarding v2 migration yet.
const legacyOrganization = await prisma.organization.findUnique({
where: { userId: user.id },
select: { id: true },
});
onboardingCompleted = Boolean(legacyOrganization);
}
const targetPath = isAdminIdentity(user.email, user.role)
? "/admin"
: onboardingCompleted
? "/dashboard"
: "/onboarding";
const response = NextResponse.redirect(buildAppUrl(request, targetPath));
const token = createSessionToken(user.id, user.email);
setSessionCookie(response, token);
return response;
} catch {
return redirectTo(request, "/login", { error: "server_error" });
}
}

View File

@@ -0,0 +1,11 @@
import { NextResponse } from "next/server";
import { clearSessionCookie } from "@/lib/auth/session";
import { buildAppUrl } from "@/lib/http/url";
export async function POST(request: Request) {
const response = NextResponse.redirect(buildAppUrl(request, "/login", { logged_out: "1" }));
clearSessionCookie(response);
return response;
}

View File

@@ -0,0 +1,75 @@
import { UserRole } from "@prisma/client";
import { NextResponse } from "next/server";
import { isConfiguredAdminEmail } from "@/lib/auth/admin";
import { hashPassword } from "@/lib/auth/password";
import { prisma } from "@/lib/prisma";
import { issueEmailVerificationToken, sendEmailVerificationLink } from "@/lib/auth/verification";
import { buildAppUrl } from "@/lib/http/url";
function redirectTo(request: Request, path: string, params: Record<string, string>) {
return NextResponse.redirect(buildAppUrl(request, path, params));
}
export async function POST(request: Request) {
const formData = await request.formData();
const name = typeof formData.get("name") === "string" ? formData.get("name")!.toString().trim() : "";
const email = typeof formData.get("email") === "string" ? formData.get("email")!.toString().trim().toLowerCase() : "";
const password = typeof formData.get("password") === "string" ? formData.get("password")!.toString() : "";
if (!email || !password || password.length < 8) {
return redirectTo(request, "/register", { error: "invalid_input" });
}
try {
const existingUser = await prisma.user.findUnique({ where: { email } });
if (existingUser) {
if (!existingUser.emailVerifiedAt) {
const { token } = await issueEmailVerificationToken(existingUser.id);
const sendResult = await sendEmailVerificationLink(request, email, token);
const verifyParams: Record<string, string> = {
email,
sent: sendResult.sent ? "1" : "0",
unverified: "1",
};
if (!sendResult.sent) {
verifyParams.error = "email_delivery_failed";
}
return redirectTo(request, "/verify", verifyParams);
}
return redirectTo(request, "/register", { error: "email_in_use" });
}
const passwordHash = await hashPassword(password);
const user = await prisma.user.create({
data: {
name: name || null,
email,
passwordHash,
role: isConfiguredAdminEmail(email) ? UserRole.ADMIN : UserRole.USER,
},
});
const { token } = await issueEmailVerificationToken(user.id);
const sendResult = await sendEmailVerificationLink(request, email, token);
const verifyParams: Record<string, string> = {
email,
sent: sendResult.sent ? "1" : "0",
};
if (!sendResult.sent) {
verifyParams.error = "email_delivery_failed";
}
return redirectTo(request, "/verify", verifyParams);
} catch {
return redirectTo(request, "/register", { error: "server_error" });
}
}

View File

@@ -0,0 +1,41 @@
import { NextResponse } from "next/server";
import { issueEmailVerificationToken, sendEmailVerificationLink } from "@/lib/auth/verification";
import { prisma } from "@/lib/prisma";
import { buildAppUrl } from "@/lib/http/url";
function redirectTo(request: Request, path: string, params: Record<string, string>) {
return NextResponse.redirect(buildAppUrl(request, path, params));
}
export async function POST(request: Request) {
const formData = await request.formData();
const email = typeof formData.get("email") === "string" ? formData.get("email")!.toString().trim().toLowerCase() : "";
if (!email) {
return redirectTo(request, "/verify", { error: "missing_email" });
}
try {
const user = await prisma.user.findUnique({ where: { email } });
if (user && !user.emailVerifiedAt) {
const { token } = await issueEmailVerificationToken(user.id);
const sendResult = await sendEmailVerificationLink(request, email, token);
const verifyParams: Record<string, string> = {
email,
sent: sendResult.sent ? "1" : "0",
};
if (!sendResult.sent) {
verifyParams.error = "email_delivery_failed";
}
return redirectTo(request, "/verify", verifyParams);
}
return redirectTo(request, "/verify", { email, sent: "1" });
} catch {
return redirectTo(request, "/verify", { email, error: "server_error" });
}
}

View File

@@ -0,0 +1,54 @@
import { NextResponse } from "next/server";
import { licitationsConfig } from "@/lib/licitations/config";
import { runDailyLicitationsSync } from "@/lib/licitations/sync";
export const runtime = "nodejs";
function parsePositiveInt(value: string | null) {
if (!value) {
return undefined;
}
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) && parsed >= 0 ? parsed : undefined;
}
function parseBoolean(value: string | null) {
if (!value) {
return undefined;
}
const normalized = value.trim().toLowerCase();
if (["1", "true", "yes", "si"].includes(normalized)) {
return true;
}
if (["0", "false", "no"].includes(normalized)) {
return false;
}
return undefined;
}
export async function POST(request: Request) {
const token = request.headers.get("x-sync-token") ?? "";
if (!licitationsConfig.syncCronToken || token !== licitationsConfig.syncCronToken) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const url = new URL(request.url);
const municipalityId = url.searchParams.get("municipality_id") ?? undefined;
const limit = parsePositiveInt(url.searchParams.get("limit"));
const skip = parsePositiveInt(url.searchParams.get("skip"));
const targetYear = parsePositiveInt(url.searchParams.get("target_year"));
const includePnt = parseBoolean(url.searchParams.get("include_pnt"));
const force = parseBoolean(url.searchParams.get("force"));
const payload = await runDailyLicitationsSync({ municipalityId, limit, skip, targetYear, includePnt, force });
return NextResponse.json({
ok: true,
payload,
});
}

View File

@@ -0,0 +1,160 @@
import { Prisma } from "@prisma/client";
import { NextResponse } from "next/server";
import { getSessionPayload } from "@/lib/auth/session";
import { prisma } from "@/lib/prisma";
function parseString(value: unknown) {
return typeof value === "string" ? value.trim() : "";
}
function parseEvidence(value: unknown) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
const record = value as Record<string, unknown>;
const notes = parseString(record.notes).slice(0, 2000);
const links = Array.isArray(record.links)
? record.links
.map((entry) => parseString(entry))
.filter((entry) => entry.length > 0)
.slice(0, 10)
: [];
if (!notes && links.length === 0) {
return null;
}
return {
...(notes ? { notes } : {}),
...(links.length > 0 ? { links } : {}),
};
}
function isLegacyResponseEvidenceSchemaError(error: unknown) {
return error instanceof Prisma.PrismaClientKnownRequestError && (error.code === "P2021" || error.code === "P2022");
}
export async function POST(request: Request) {
const session = await getSessionPayload();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const body = (await request.json()) as Record<string, unknown>;
const moduleKey = parseString(body.moduleKey);
const questionId = parseString(body.questionId);
const answerOptionId = parseString(body.answerOptionId);
const evidence = parseEvidence(body.evidence);
const normalizedEvidence = evidence ?? Prisma.DbNull;
if (!moduleKey || !questionId || !answerOptionId) {
return NextResponse.json({ error: "Missing response payload fields." }, { status: 400 });
}
const question = await prisma.question.findUnique({
where: { id: questionId },
select: {
id: true,
module: {
select: {
key: true,
id: true,
questions: {
select: {
id: true,
},
},
},
},
},
});
if (!question || question.module.key !== moduleKey) {
return NextResponse.json({ error: "Question does not belong to the target module." }, { status: 400 });
}
const option = await prisma.answerOption.findFirst({
where: {
id: answerOptionId,
questionId,
},
select: {
id: true,
},
});
if (!option) {
return NextResponse.json({ error: "Invalid answer option for this question." }, { status: 400 });
}
try {
await prisma.response.upsert({
where: {
userId_questionId: {
userId: session.userId,
questionId,
},
},
update: {
answerOptionId,
evidence: normalizedEvidence,
},
create: {
userId: session.userId,
questionId,
answerOptionId,
evidence: normalizedEvidence,
},
});
} catch (error) {
if (!isLegacyResponseEvidenceSchemaError(error)) {
throw error;
}
await prisma.response.upsert({
where: {
userId_questionId: {
userId: session.userId,
questionId,
},
},
update: {
answerOptionId,
},
create: {
userId: session.userId,
questionId,
answerOptionId,
},
});
}
const moduleQuestionIds = question.module.questions.map((moduleQuestion) => moduleQuestion.id);
const answeredCount = await prisma.response.count({
where: {
userId: session.userId,
questionId: {
in: moduleQuestionIds,
},
},
});
const totalQuestions = moduleQuestionIds.length;
const completion = totalQuestions > 0 ? Math.round((answeredCount / totalQuestions) * 100) : 0;
return NextResponse.json({
ok: true,
progress: {
answeredCount,
totalQuestions,
completion,
},
});
} catch {
return NextResponse.json({ error: "Unable to save response." }, { status: 400 });
}
}

View File

@@ -0,0 +1,17 @@
import { NextResponse } from "next/server";
import { getSessionPayload } from "@/lib/auth/session";
import { getLicitationRecommendationsForUser } from "@/lib/licitations/recommendations";
export async function GET(request: Request) {
const session = await getSessionPayload();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const url = new URL(request.url);
const profileId = url.searchParams.get("profile_id");
const payload = await getLicitationRecommendationsForUser(session.userId, profileId);
return NextResponse.json(payload);
}

View File

@@ -0,0 +1,45 @@
import { NextResponse } from "next/server";
import { getSessionPayload } from "@/lib/auth/session";
import { searchLicitations } from "@/lib/licitations/query";
function parsePositiveInt(value: string | null, fallback: number) {
if (!value) {
return fallback;
}
const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed) || parsed < 0) {
return fallback;
}
return parsed;
}
export async function GET(request: Request) {
const session = await getSessionPayload();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const url = new URL(request.url);
const take = Math.min(parsePositiveInt(url.searchParams.get("take"), 50), 100);
const skip = parsePositiveInt(url.searchParams.get("skip"), 0);
const payload = await searchLicitations({
state: url.searchParams.get("state"),
municipality: url.searchParams.get("municipality"),
procedureType: url.searchParams.get("procedure_type"),
q: url.searchParams.get("q"),
minAmount: url.searchParams.get("min_amount"),
maxAmount: url.searchParams.get("max_amount"),
dateFrom: url.searchParams.get("date_from"),
dateTo: url.searchParams.get("date_to"),
includeClosed: url.searchParams.get("include_closed") === "true",
take,
skip,
});
return NextResponse.json(payload);
}

View File

@@ -0,0 +1,19 @@
import { NextResponse } from "next/server";
import { getSessionPayload } from "@/lib/auth/session";
import { listMunicipalities } from "@/lib/licitations/query";
export async function GET(request: Request) {
const session = await getSessionPayload();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const url = new URL(request.url);
const state = url.searchParams.get("state");
const municipalities = await listMunicipalities({ state });
return NextResponse.json({
municipalities,
});
}

View File

@@ -0,0 +1,408 @@
import { OrganizationDocumentType, Prisma } from "@prisma/client";
import { NextResponse } from "next/server";
import { getSessionPayload } from "@/lib/auth/session";
import { extractActaDataWithAiBaseline } from "@/lib/extraction/aiExtractFields";
import { type ActaFields, type ActaLookupDictionary } from "@/lib/extraction/schema";
import { analyzePdf } from "@/lib/pdf/analyzePdf";
import {
OcrFailedError,
OcrUnavailableError,
PdfEncryptedError,
PdfNoTextDetectedError,
PdfUnreadableError,
} from "@/lib/pdf/errors";
import { prisma } from "@/lib/prisma";
import { MAX_ACTA_PDF_BYTES, removeStoredActaPdf, storeActaPdf } from "@/lib/onboarding/acta-storage";
export const runtime = "nodejs";
function isLegacySchemaError(error: unknown) {
return error instanceof Prisma.PrismaClientKnownRequestError && (error.code === "P2021" || error.code === "P2022");
}
function toOptionalString(value: string | null | undefined) {
return value ? value.trim() || undefined : undefined;
}
function toNullableString(value: string | null | undefined) {
return value ? value.trim() || null : null;
}
function isPdfFile(file: File) {
const extension = file.name.toLowerCase().endsWith(".pdf");
const mimeType = file.type === "application/pdf";
return extension || mimeType;
}
function hasPdfSignature(buffer: Buffer) {
return buffer.subarray(0, 5).toString("utf8") === "%PDF-";
}
function getExtractionConfidence(fields: ActaFields) {
const detected = Object.values(fields).filter(Boolean).length;
if (detected >= 6) {
return "high" as const;
}
if (detected >= 3) {
return "medium" as const;
}
return "low" as const;
}
function getDetectedFields(fields: ActaFields) {
return Object.entries(fields)
.filter(([, value]) => Boolean(value))
.map(([field]) => field);
}
function getDetectedLookupDictionaryFields(dictionary: ActaLookupDictionary) {
const entries: string[] = [];
for (const [key, value] of Object.entries(dictionary)) {
if (key === "version") {
continue;
}
if (value === null || value === undefined) {
continue;
}
if (typeof value === "object") {
const nestedKeys = Object.entries(value)
.filter(([, nestedValue]) => {
if (nestedValue === null || nestedValue === undefined) {
return false;
}
if (Array.isArray(nestedValue)) {
return nestedValue.length > 0;
}
return typeof nestedValue === "boolean" || Boolean(String(nestedValue).trim());
})
.map(([nestedKey]) => `${key}.${nestedKey}`);
entries.push(...nestedKeys);
continue;
}
entries.push(key);
}
return entries;
}
function mapAnalysisError(error: unknown) {
if (error instanceof PdfEncryptedError) {
return {
status: 422,
error: "El PDF esta protegido/encriptado. Sube una version sin bloqueo para extraer texto.",
code: error.code,
};
}
if (error instanceof PdfUnreadableError) {
return {
status: 422,
error: "No fue posible leer el PDF. Verifica que el archivo no este dañado.",
code: error.code,
};
}
if (error instanceof OcrUnavailableError) {
return {
status: 503,
error: "No se detecto texto suficiente y OCRmyPDF no esta disponible. Revisa instalacion local en README.",
code: error.code,
};
}
if (error instanceof PdfNoTextDetectedError) {
return {
status: 422,
error: "No se detecto texto en el PDF y OCR tampoco pudo recuperarlo.",
code: error.code,
};
}
if (error instanceof OcrFailedError) {
return {
status: 422,
error: "No se detecto texto suficiente y el OCR fallo durante el procesamiento.",
code: error.code,
};
}
return {
status: 422,
error: "No fue posible extraer texto del PDF.",
code: "PDF_ANALYSIS_FAILED",
};
}
export async function POST(request: Request) {
const session = await getSessionPayload();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const formData = await request.formData();
const actaFile = formData.get("file") ?? formData.get("acta");
if (!(actaFile instanceof File)) {
return NextResponse.json({ error: "Debes adjuntar un archivo PDF de Acta constitutiva." }, { status: 400 });
}
if (actaFile.size <= 0) {
return NextResponse.json({ error: "El archivo esta vacio." }, { status: 400 });
}
if (actaFile.size > MAX_ACTA_PDF_BYTES) {
return NextResponse.json({ error: "El archivo excede el limite de 15MB." }, { status: 400 });
}
if (!isPdfFile(actaFile)) {
return NextResponse.json({ error: "Solo se permiten archivos PDF." }, { status: 400 });
}
const fileBuffer = Buffer.from(await actaFile.arrayBuffer());
if (!hasPdfSignature(fileBuffer)) {
return NextResponse.json({ error: "El archivo no parece ser un PDF valido." }, { status: 400 });
}
let fields: ActaFields;
let lookupDictionary: ActaLookupDictionary;
let rawText: string;
let methodUsed: "direct" | "ocr";
let numPages: number;
let warnings: string[];
let extractionEngine: "ai" | "regex_fallback";
let aiModel: string | null;
let aiUsage:
| {
promptTokens: number | null;
completionTokens: number | null;
totalTokens: number | null;
}
| null;
try {
const analyzed = await analyzePdf(fileBuffer);
const extracted = await extractActaDataWithAiBaseline(analyzed.text);
lookupDictionary = extracted.lookupDictionary;
fields = extracted.fields;
rawText = analyzed.text;
methodUsed = analyzed.methodUsed;
numPages = analyzed.numPages;
warnings = [...analyzed.warnings, ...extracted.warnings];
extractionEngine = extracted.engine;
aiModel = extracted.model;
aiUsage = extracted.usage;
} catch (error) {
const mapped = mapAnalysisError(error);
const errorName = error instanceof Error ? error.name : "UnknownError";
const errorMessage = error instanceof Error ? error.message : String(error);
const errorCause = error instanceof Error && "cause" in error ? (error.cause as Error | undefined) : undefined;
const ocrStderr = error instanceof OcrFailedError ? error.stderr : undefined;
console.error("Acta analysis failed", {
mappedCode: mapped.code,
mappedStatus: mapped.status,
errorName,
errorMessage,
causeName: errorCause?.name,
causeMessage: errorCause?.message,
ocrStderr: ocrStderr ? ocrStderr.slice(0, 1200) : undefined,
});
return NextResponse.json(
{ error: mapped.error, code: mapped.code },
{
status: mapped.status,
headers: {
"x-acta-error-code": mapped.code,
},
},
);
}
const extractedFields = getDetectedFields(fields);
const detectedLookupFields = getDetectedLookupDictionaryFields(lookupDictionary);
const confidence = getExtractionConfidence(fields);
const extractionPayload = {
...fields,
industry: lookupDictionary.industry,
country: lookupDictionary.countryOfOperation,
lookupDictionary,
extractedFields,
detectedLookupFields,
confidence,
methodUsed,
numPages,
warnings,
extractionEngine,
aiModel,
aiUsage,
};
const storedFile = await storeActaPdf(session.userId, actaFile.name, fileBuffer);
try {
const existingOrg = await prisma.organization.findUnique({
where: { userId: session.userId },
select: {
id: true,
name: true,
},
});
const fallbackName = session.email.split("@")[0]?.trim() || "empresa";
const organizationName = toOptionalString(fields.name) ?? existingOrg?.name ?? fallbackName;
let organization: { id: string };
try {
organization = await prisma.organization.upsert({
where: { userId: session.userId },
update: {
name: organizationName,
tradeName: toNullableString(fields.name),
legalRepresentative: toNullableString(fields.legalRepresentative),
incorporationDate: toNullableString(fields.incorporationDate),
deedNumber: toNullableString(fields.deedNumber),
notaryName: toNullableString(fields.notaryName),
stateOfIncorporation: toNullableString(fields.stateOfIncorporation),
companyType: toNullableString(lookupDictionary.companyType),
fiscalAddress: toNullableString(fields.fiscalAddress),
businessPurpose: toNullableString(fields.businessPurpose),
industry: toNullableString(lookupDictionary.industry),
country: toNullableString(lookupDictionary.countryOfOperation),
actaExtractedData: extractionPayload as Prisma.InputJsonValue,
actaLookupDictionary: lookupDictionary as Prisma.InputJsonValue,
actaUploadedAt: new Date(),
},
create: {
userId: session.userId,
name: organizationName,
tradeName: toNullableString(fields.name),
legalRepresentative: toNullableString(fields.legalRepresentative),
incorporationDate: toNullableString(fields.incorporationDate),
deedNumber: toNullableString(fields.deedNumber),
notaryName: toNullableString(fields.notaryName),
stateOfIncorporation: toNullableString(fields.stateOfIncorporation),
companyType: toNullableString(lookupDictionary.companyType),
fiscalAddress: toNullableString(fields.fiscalAddress),
businessPurpose: toNullableString(fields.businessPurpose),
industry: toNullableString(lookupDictionary.industry),
country: toNullableString(lookupDictionary.countryOfOperation),
actaExtractedData: extractionPayload as Prisma.InputJsonValue,
actaLookupDictionary: lookupDictionary as Prisma.InputJsonValue,
actaUploadedAt: new Date(),
},
select: { id: true },
});
} catch (error) {
if (!isLegacySchemaError(error)) {
throw error;
}
organization = await prisma.organization.upsert({
where: { userId: session.userId },
update: {
name: organizationName,
},
create: {
userId: session.userId,
name: organizationName,
},
select: { id: true },
});
}
let existingDocument: { filePath: string } | null = null;
try {
existingDocument = await prisma.organizationDocument.findUnique({
where: {
userId_type: {
userId: session.userId,
type: OrganizationDocumentType.ACTA_CONSTITUTIVA,
},
},
select: {
filePath: true,
},
});
await prisma.organizationDocument.upsert({
where: {
userId_type: {
userId: session.userId,
type: OrganizationDocumentType.ACTA_CONSTITUTIVA,
},
},
update: {
organizationId: organization.id,
fileName: storedFile.fileName,
storedFileName: storedFile.storedFileName,
filePath: storedFile.filePath,
mimeType: storedFile.mimeType,
sizeBytes: storedFile.sizeBytes,
checksumSha256: storedFile.checksumSha256,
extractedData: extractionPayload as Prisma.InputJsonValue,
extractedTextSnippet: rawText.slice(0, 1600),
},
create: {
organizationId: organization.id,
userId: session.userId,
type: OrganizationDocumentType.ACTA_CONSTITUTIVA,
fileName: storedFile.fileName,
storedFileName: storedFile.storedFileName,
filePath: storedFile.filePath,
mimeType: storedFile.mimeType,
sizeBytes: storedFile.sizeBytes,
checksumSha256: storedFile.checksumSha256,
extractedData: extractionPayload as Prisma.InputJsonValue,
extractedTextSnippet: rawText.slice(0, 1600),
},
});
} catch (error) {
if (!isLegacySchemaError(error)) {
throw error;
}
}
if (existingDocument?.filePath && existingDocument.filePath !== storedFile.filePath) {
await removeStoredActaPdf(existingDocument.filePath);
}
return NextResponse.json({
ok: true,
fields,
lookupDictionary,
rawText,
methodUsed,
numPages,
warnings,
extractionEngine,
aiModel,
aiUsage,
extractedData: {
...fields,
industry: lookupDictionary.industry,
country: lookupDictionary.countryOfOperation,
lookupDictionary,
extractedFields,
detectedLookupFields,
confidence,
extractionEngine,
},
actaUploadedAt: new Date().toISOString(),
});
} catch (error) {
await removeStoredActaPdf(storedFile.filePath);
console.error("Failed to process acta upload", error);
return NextResponse.json({ error: "No fue posible procesar el acta constitutiva." }, { status: 500 });
}
}

View File

@@ -0,0 +1,157 @@
import { NextResponse } from "next/server";
import { OrganizationDocumentType } from "@prisma/client";
import { getSessionPayload } from "@/lib/auth/session";
import { prisma } from "@/lib/prisma";
function normalizeString(value: unknown) {
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function normalizeOptionalBoolean(value: unknown) {
if (typeof value === "boolean") {
return value;
}
if (typeof value !== "string") {
return null;
}
const normalized = value.trim().toLowerCase();
if (normalized === "yes" || normalized === "si" || normalized === "true") {
return true;
}
if (normalized === "no" || normalized === "false") {
return false;
}
return null;
}
export async function POST(request: Request) {
const session = await getSessionPayload();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const body = (await request.json()) as Record<string, unknown>;
const name = normalizeString(body.name);
const tradeName = normalizeString(body.tradeName);
const rfc = normalizeString(body.rfc);
const legalRepresentative = normalizeString(body.legalRepresentative);
const incorporationDate = normalizeString(body.incorporationDate);
const deedNumber = normalizeString(body.deedNumber);
const notaryName = normalizeString(body.notaryName);
const fiscalAddress = normalizeString(body.fiscalAddress);
const businessPurpose = normalizeString(body.businessPurpose);
const industry = normalizeString(body.industry);
const operatingState = normalizeString(body.operatingState);
const municipality = normalizeString(body.municipality);
const companySize = normalizeString(body.companySize);
const yearsOfOperation = normalizeString(body.yearsOfOperation);
const annualRevenueRange = normalizeString(body.annualRevenueRange);
const hasGovernmentContracts = normalizeOptionalBoolean(body.hasGovernmentContracts);
const country = normalizeString(body.country);
const primaryObjective = normalizeString(body.primaryObjective);
if (!name) {
return NextResponse.json({ error: "El nombre legal de la empresa es obligatorio." }, { status: 400 });
}
try {
const actaDocument = await prisma.organizationDocument.findUnique({
where: {
userId_type: {
userId: session.userId,
type: OrganizationDocumentType.ACTA_CONSTITUTIVA,
},
},
select: { id: true },
});
if (!actaDocument) {
return NextResponse.json({ error: "Debes cargar el Acta constitutiva antes de finalizar onboarding." }, { status: 400 });
}
await prisma.organization.upsert({
where: { userId: session.userId },
update: {
name,
tradeName,
rfc,
legalRepresentative,
incorporationDate,
deedNumber,
notaryName,
fiscalAddress,
businessPurpose,
industry,
operatingState,
municipality,
companySize,
yearsOfOperation,
annualRevenueRange,
hasGovernmentContracts,
country,
primaryObjective,
onboardingCompletedAt: new Date(),
},
create: {
userId: session.userId,
name,
tradeName,
rfc,
legalRepresentative,
incorporationDate,
deedNumber,
notaryName,
fiscalAddress,
businessPurpose,
industry,
operatingState,
municipality,
companySize,
yearsOfOperation,
annualRevenueRange,
hasGovernmentContracts,
country,
primaryObjective,
onboardingCompletedAt: new Date(),
},
});
} catch {
// Backward compatibility for databases without onboarding v2 migration.
await prisma.organization.upsert({
where: { userId: session.userId },
update: {
name,
industry,
companySize,
country,
primaryObjective,
},
create: {
userId: session.userId,
name,
industry,
companySize,
country,
primaryObjective,
},
});
}
return NextResponse.json({ ok: true, redirectTo: "/diagnostic" });
} catch {
return NextResponse.json({ error: "Invalid onboarding payload." }, { status: 400 });
}
}

View File

@@ -0,0 +1,134 @@
import { Prisma } from "@prisma/client";
import { NextResponse } from "next/server";
import { requireAdminApiUser } from "@/lib/auth/admin";
import { prisma } from "@/lib/prisma";
import {
MAX_STRATEGIC_EVIDENCE_BYTES,
isAllowedEvidenceMimeType,
storeStrategicEvidenceFile,
} from "@/lib/strategic-diagnostic/evidence-storage";
import { mapSectionKeyToEnum, recomputeStrategicDiagnosticFromStoredData } from "@/lib/strategic-diagnostic/server";
import { STRATEGIC_SECTION_KEYS, type StrategicSectionKey } from "@/lib/strategic-diagnostic/types";
function parseSection(value: unknown): StrategicSectionKey | null {
if (typeof value !== "string") {
return null;
}
const section = value.trim() as StrategicSectionKey;
return STRATEGIC_SECTION_KEYS.includes(section) ? section : null;
}
function parseCategory(value: unknown) {
if (typeof value !== "string") {
return "";
}
return value.trim();
}
function isSchemaNotReadyError(error: unknown) {
return error instanceof Prisma.PrismaClientKnownRequestError && (error.code === "P2021" || error.code === "P2022");
}
export async function POST(request: Request) {
const user = await requireAdminApiUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const formData = await request.formData();
const section = parseSection(formData.get("section"));
const category = parseCategory(formData.get("category"));
const file = formData.get("file");
if (!section) {
return NextResponse.json({ error: "Seccion de evidencia invalida." }, { status: 400 });
}
if (!category) {
return NextResponse.json({ error: "Categoria de evidencia requerida." }, { status: 400 });
}
if (!(file instanceof File)) {
return NextResponse.json({ error: "Archivo requerido." }, { status: 400 });
}
if (file.size <= 0) {
return NextResponse.json({ error: "El archivo esta vacio." }, { status: 400 });
}
if (file.size > MAX_STRATEGIC_EVIDENCE_BYTES) {
return NextResponse.json({ error: "El archivo excede el limite de 10MB." }, { status: 400 });
}
if (!isAllowedEvidenceMimeType(file.type)) {
return NextResponse.json({ error: "Tipo de archivo no permitido (usa PDF, DOC, DOCX, JPG o PNG)." }, { status: 400 });
}
const organization = await prisma.organization.findUnique({
where: { userId: user.id },
select: {
id: true,
},
});
if (!organization) {
return NextResponse.json({ error: "No existe un perfil organizacional para este usuario." }, { status: 400 });
}
const fileBuffer = Buffer.from(await file.arrayBuffer());
const stored = await storeStrategicEvidenceFile(user.id, file.name, file.type, fileBuffer);
const row = await prisma.strategicDiagnosticEvidenceDocument.create({
data: {
organizationId: organization.id,
userId: user.id,
section: mapSectionKeyToEnum(section),
category,
fileName: stored.fileName,
storedFileName: stored.storedFileName,
filePath: stored.filePath,
mimeType: stored.mimeType,
sizeBytes: stored.sizeBytes,
checksumSha256: stored.checksumSha256,
},
select: {
id: true,
category: true,
fileName: true,
filePath: true,
mimeType: true,
sizeBytes: true,
createdAt: true,
},
});
const snapshot = await recomputeStrategicDiagnosticFromStoredData(user.id);
return NextResponse.json({
ok: true,
document: {
id: row.id,
section,
category: row.category,
fileName: row.fileName,
filePath: row.filePath,
mimeType: row.mimeType,
sizeBytes: row.sizeBytes,
createdAt: row.createdAt.toISOString(),
},
payload: snapshot,
});
} catch (error) {
if (isSchemaNotReadyError(error)) {
return NextResponse.json(
{ error: "La base de datos aun no tiene las tablas de evidencias de Modulo 2. Ejecuta prisma migrate para continuar." },
{ status: 503 },
);
}
return NextResponse.json({ error: "No fue posible subir la evidencia." }, { status: 400 });
}
}

View File

@@ -0,0 +1,38 @@
import { Prisma } from "@prisma/client";
import { NextResponse } from "next/server";
import { requireAdminApiUser } from "@/lib/auth/admin";
import { saveStrategicDiagnosticData } from "@/lib/strategic-diagnostic/server";
function isSchemaNotReadyError(error: unknown) {
return error instanceof Prisma.PrismaClientKnownRequestError && (error.code === "P2021" || error.code === "P2022");
}
export async function POST(request: Request) {
const user = await requireAdminApiUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const body = (await request.json()) as Record<string, unknown>;
const payload = await saveStrategicDiagnosticData(user.id, body.data, {
forceCompleted: body.forceCompleted === true,
});
if (!payload) {
return NextResponse.json({ error: "No existe un perfil organizacional para este usuario." }, { status: 400 });
}
return NextResponse.json({ ok: true, payload });
} catch (error) {
if (isSchemaNotReadyError(error)) {
return NextResponse.json(
{ error: "La base de datos aun no tiene las tablas de Modulo 2. Ejecuta prisma migrate para continuar." },
{ status: 503 },
);
}
return NextResponse.json({ error: "No fue posible guardar el Modulo 2." }, { status: 400 });
}
}

View File

@@ -0,0 +1,191 @@
import { Prisma, WorkshopEvidenceValidationStatus, WorkshopProgressStatus } from "@prisma/client";
import { NextResponse } from "next/server";
import { requireAdminApiUser } from "@/lib/auth/admin";
import { prisma } from "@/lib/prisma";
import {
MAX_STRATEGIC_EVIDENCE_BYTES,
isAllowedEvidenceMimeType,
storeStrategicEvidenceFile,
} from "@/lib/strategic-diagnostic/evidence-storage";
import { getTalleresSnapshot } from "@/lib/talleres/server";
import { validateWorkshopEvidenceSync } from "@/lib/talleres/validation";
function isSchemaNotReadyError(error: unknown) {
return error instanceof Prisma.PrismaClientKnownRequestError && (error.code === "P2021" || error.code === "P2022");
}
function parseString(value: unknown) {
return typeof value === "string" ? value.trim() : "";
}
export async function POST(request: Request) {
const user = await requireAdminApiUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const formData = await request.formData();
const workshopId = parseString(formData.get("workshopId"));
const file = formData.get("file");
if (!workshopId) {
return NextResponse.json({ error: "workshopId es requerido." }, { status: 400 });
}
if (!(file instanceof File)) {
return NextResponse.json({ error: "Archivo requerido." }, { status: 400 });
}
if (file.size <= 0) {
return NextResponse.json({ error: "El archivo esta vacio." }, { status: 400 });
}
if (file.size > MAX_STRATEGIC_EVIDENCE_BYTES) {
return NextResponse.json({ error: "El archivo excede el limite de 10MB." }, { status: 400 });
}
if (!isAllowedEvidenceMimeType(file.type)) {
return NextResponse.json({ error: "Tipo de archivo no permitido (usa PDF, DOC, DOCX, JPG o PNG)." }, { status: 400 });
}
const workshop = await prisma.developmentWorkshop.findUnique({
where: { id: workshopId },
select: { id: true },
});
if (!workshop) {
return NextResponse.json({ error: "Taller no encontrado." }, { status: 404 });
}
const fileBuffer = Buffer.from(await file.arrayBuffer());
const stored = await storeStrategicEvidenceFile(user.id, file.name, file.type, fileBuffer);
const now = new Date();
try {
const validation = validateWorkshopEvidenceSync({
fileName: stored.fileName,
mimeType: stored.mimeType,
sizeBytes: stored.sizeBytes,
});
const validationStatus =
validation.status === "APPROVED" ? WorkshopEvidenceValidationStatus.APPROVED : WorkshopEvidenceValidationStatus.REJECTED;
const evidence = await prisma.developmentWorkshopEvidence.create({
data: {
workshopId,
userId: user.id,
validationStatus,
validationReason: validation.reason,
validationConfidence: validation.confidence,
validatedAt: now,
fileName: stored.fileName,
storedFileName: stored.storedFileName,
filePath: stored.filePath,
mimeType: stored.mimeType,
sizeBytes: stored.sizeBytes,
checksumSha256: stored.checksumSha256,
},
});
await prisma.developmentWorkshopProgress.upsert({
where: {
workshopId_userId: {
workshopId,
userId: user.id,
},
},
update: {
status:
validationStatus === WorkshopEvidenceValidationStatus.APPROVED
? WorkshopProgressStatus.APPROVED
: WorkshopProgressStatus.REJECTED,
watchedAt: now,
completedAt: validationStatus === WorkshopEvidenceValidationStatus.APPROVED ? now : null,
},
create: {
workshopId,
userId: user.id,
status:
validationStatus === WorkshopEvidenceValidationStatus.APPROVED
? WorkshopProgressStatus.APPROVED
: WorkshopProgressStatus.REJECTED,
watchedAt: now,
completedAt: validationStatus === WorkshopEvidenceValidationStatus.APPROVED ? now : null,
},
});
const payload = await getTalleresSnapshot(user.id);
return NextResponse.json({
ok: true,
evidence: {
id: evidence.id,
validationStatus: evidence.validationStatus,
validationReason: evidence.validationReason,
validationConfidence: evidence.validationConfidence,
},
payload,
});
} catch {
const evidence = await prisma.developmentWorkshopEvidence.create({
data: {
workshopId,
userId: user.id,
validationStatus: WorkshopEvidenceValidationStatus.ERROR,
validationReason: "La validacion automatica no estuvo disponible. Tu evidencia fue guardada para revision.",
validationConfidence: null,
validatedAt: now,
fileName: stored.fileName,
storedFileName: stored.storedFileName,
filePath: stored.filePath,
mimeType: stored.mimeType,
sizeBytes: stored.sizeBytes,
checksumSha256: stored.checksumSha256,
},
});
await prisma.developmentWorkshopProgress.upsert({
where: {
workshopId_userId: {
workshopId,
userId: user.id,
},
},
update: {
status: WorkshopProgressStatus.EVIDENCE_SUBMITTED,
watchedAt: now,
},
create: {
workshopId,
userId: user.id,
status: WorkshopProgressStatus.EVIDENCE_SUBMITTED,
watchedAt: now,
},
});
const payload = await getTalleresSnapshot(user.id);
return NextResponse.json({
ok: true,
warning: "Tu evidencia se guardo, pero la validacion automatica quedo pendiente.",
evidence: {
id: evidence.id,
validationStatus: evidence.validationStatus,
validationReason: evidence.validationReason,
validationConfidence: evidence.validationConfidence,
},
payload,
});
}
} catch (error) {
if (isSchemaNotReadyError(error)) {
return NextResponse.json(
{ error: "La base de datos aun no tiene las tablas de Talleres. Ejecuta prisma migrate para continuar." },
{ status: 503 },
);
}
return NextResponse.json({ error: "No fue posible subir la evidencia del taller." }, { status: 400 });
}
}

View File

@@ -0,0 +1,120 @@
import { Prisma, WorkshopProgressStatus } from "@prisma/client";
import { NextResponse } from "next/server";
import { requireAdminApiUser } from "@/lib/auth/admin";
import { prisma } from "@/lib/prisma";
import { getTalleresSnapshot } from "@/lib/talleres/server";
type ProgressAction = "WATCHED" | "SKIPPED";
function isSchemaNotReadyError(error: unknown) {
return error instanceof Prisma.PrismaClientKnownRequestError && (error.code === "P2021" || error.code === "P2022");
}
function parseString(value: unknown) {
return typeof value === "string" ? value.trim() : "";
}
function parseAction(value: unknown): ProgressAction | null {
if (value === "WATCHED" || value === "SKIPPED") {
return value;
}
return null;
}
export async function POST(request: Request) {
const user = await requireAdminApiUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const body = (await request.json()) as Record<string, unknown>;
const workshopId = parseString(body.workshopId);
const action = parseAction(body.action);
if (!workshopId || !action) {
return NextResponse.json({ error: "workshopId y action son requeridos." }, { status: 400 });
}
const workshop = await prisma.developmentWorkshop.findUnique({
where: { id: workshopId },
select: { id: true },
});
if (!workshop) {
return NextResponse.json({ error: "Taller no encontrado." }, { status: 404 });
}
const existing = await prisma.developmentWorkshopProgress.findUnique({
where: {
workshopId_userId: {
workshopId,
userId: user.id,
},
},
select: { status: true, watchedAt: true },
});
const now = new Date();
if (action === "WATCHED") {
const nextStatus = existing?.status === WorkshopProgressStatus.APPROVED ? WorkshopProgressStatus.APPROVED : WorkshopProgressStatus.WATCHED;
await prisma.developmentWorkshopProgress.upsert({
where: {
workshopId_userId: {
workshopId,
userId: user.id,
},
},
update: {
status: nextStatus,
watchedAt: existing?.watchedAt ?? now,
},
create: {
workshopId,
userId: user.id,
status: WorkshopProgressStatus.WATCHED,
watchedAt: now,
},
});
}
if (action === "SKIPPED") {
const nextStatus = existing?.status === WorkshopProgressStatus.APPROVED ? WorkshopProgressStatus.APPROVED : WorkshopProgressStatus.SKIPPED;
await prisma.developmentWorkshopProgress.upsert({
where: {
workshopId_userId: {
workshopId,
userId: user.id,
},
},
update: {
status: nextStatus,
skippedAt: now,
},
create: {
workshopId,
userId: user.id,
status: WorkshopProgressStatus.SKIPPED,
skippedAt: now,
},
});
}
const payload = await getTalleresSnapshot(user.id);
return NextResponse.json({ ok: true, payload });
} catch (error) {
if (isSchemaNotReadyError(error)) {
return NextResponse.json(
{ error: "La base de datos aun no tiene las tablas de Talleres. Ejecuta prisma migrate para continuar." },
{ status: 503 },
);
}
return NextResponse.json({ error: "No fue posible actualizar el progreso del taller." }, { status: 400 });
}
}

View File

@@ -0,0 +1,33 @@
import { Prisma } from "@prisma/client";
import { NextResponse } from "next/server";
import { requireAdminApiUser } from "@/lib/auth/admin";
import { getTalleresSnapshot } from "@/lib/talleres/server";
function isSchemaNotReadyError(error: unknown) {
return error instanceof Prisma.PrismaClientKnownRequestError && (error.code === "P2021" || error.code === "P2022");
}
export async function GET(request: Request) {
const user = await requireAdminApiUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const url = new URL(request.url);
const dimension = url.searchParams.get("dimension");
const payload = await getTalleresSnapshot(user.id, { dimension });
return NextResponse.json({ ok: true, payload });
} catch (error) {
if (isSchemaNotReadyError(error)) {
return NextResponse.json(
{ error: "La base de datos aun no tiene las tablas de Talleres. Ejecuta prisma migrate para continuar." },
{ status: 503 },
);
}
return NextResponse.json({ error: "No fue posible obtener el snapshot de talleres." }, { status: 400 });
}
}