initial push
This commit is contained in:
3
src/app/api/acta/analyze/route.ts
Normal file
3
src/app/api/acta/analyze/route.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export { POST } from "@/app/api/onboarding/acta/route";
|
||||
52
src/app/api/admin/content/create/route.ts
Normal file
52
src/app/api/admin/content/create/route.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
54
src/app/api/admin/content/update/route.ts
Normal file
54
src/app/api/admin/content/update/route.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
72
src/app/api/admin/scoring/route.ts
Normal file
72
src/app/api/admin/scoring/route.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
42
src/app/api/admin/sync/route.ts
Normal file
42
src/app/api/admin/sync/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
38
src/app/api/admin/users/role/route.ts
Normal file
38
src/app/api/admin/users/role/route.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
87
src/app/api/auth/login/route.ts
Normal file
87
src/app/api/auth/login/route.ts
Normal 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" });
|
||||
}
|
||||
}
|
||||
11
src/app/api/auth/logout/route.ts
Normal file
11
src/app/api/auth/logout/route.ts
Normal 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;
|
||||
}
|
||||
75
src/app/api/auth/register/route.ts
Normal file
75
src/app/api/auth/register/route.ts
Normal 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" });
|
||||
}
|
||||
}
|
||||
41
src/app/api/auth/resend/route.ts
Normal file
41
src/app/api/auth/resend/route.ts
Normal 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" });
|
||||
}
|
||||
}
|
||||
54
src/app/api/cron/licitations-sync/route.ts
Normal file
54
src/app/api/cron/licitations-sync/route.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
160
src/app/api/diagnostic/response/route.ts
Normal file
160
src/app/api/diagnostic/response/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
17
src/app/api/licitations/recommendations/route.ts
Normal file
17
src/app/api/licitations/recommendations/route.ts
Normal 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);
|
||||
}
|
||||
45
src/app/api/licitations/route.ts
Normal file
45
src/app/api/licitations/route.ts
Normal 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);
|
||||
}
|
||||
19
src/app/api/municipalities/route.ts
Normal file
19
src/app/api/municipalities/route.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
408
src/app/api/onboarding/acta/route.ts
Normal file
408
src/app/api/onboarding/acta/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
157
src/app/api/onboarding/route.ts
Normal file
157
src/app/api/onboarding/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
134
src/app/api/strategic-diagnostic/evidence/route.ts
Normal file
134
src/app/api/strategic-diagnostic/evidence/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
38
src/app/api/strategic-diagnostic/route.ts
Normal file
38
src/app/api/strategic-diagnostic/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
191
src/app/api/talleres/evidence/route.ts
Normal file
191
src/app/api/talleres/evidence/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
120
src/app/api/talleres/progress/route.ts
Normal file
120
src/app/api/talleres/progress/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
33
src/app/api/talleres/route.ts
Normal file
33
src/app/api/talleres/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user