diff --git a/App/.env.example b/App/.env.example index a22649f..248cb9c 100644 --- a/App/.env.example +++ b/App/.env.example @@ -49,6 +49,13 @@ EMAIL_FROM_NAME="Pelagia Portal" # Start the service with: cd GstService && npm run dev GST_SERVICE_URL=http://localhost:3003 +# ── EPFO / UAN lookup microservice (crewing) ────────────────── +# Run the EpfoService/ microservice alongside the app (default localhost:3004). +# Start with: cd EpfoService && npm run dev +# Runs in STUB mode unless EPFO_LIVE=true (the live portal selectors/OTP must be +# validated against a real session first). Aadhaar is NOT handled here (manual). +EPFO_SERVICE_URL=http://localhost:3004 + # ── Forgejo issue reporting (Report Issue button) ───────────── # Token needs write:issue scope on the repo below. FORGEJO_URL=https://git.pelagiamarine.com diff --git a/App/CLAUDE.md b/App/CLAUDE.md index ec16016..ed2a1f5 100644 --- a/App/CLAUDE.md +++ b/App/CLAUDE.md @@ -204,6 +204,7 @@ A crew-management module built incrementally per the **wiki `Crewing-Implementat - **SITE_STAFF login on onboard/placement** — `lib/crew-login.ts` `maybeCreateSiteStaffLogin` creates a passwordless `SITE_STAFF` `User` (sharing the `CRW-` employee no.) when a `grantsLogin` rank is onboarded (`onboardCandidate`) or placed (`placeCrew`) and the crew member has an email; the login's `siteId` is set to the assignment's site. - **Own-site scoping (§8.7)** — `User.siteId` added; the Crew directory filters a `SITE_STAFF` user with a home site to crew whose active assignment is at that site (graceful: no `siteId` → unscoped). The link is set at login creation above. - **PPE / next-of-kin verify gates** — `PpeIssue` / `NextOfKin` gained `verificationStatus` + `verifiedById`; `verifyPpe` / `verifyNextOfKin` (`verify_site_records` — MPO) and queue sections in `/crewing/verification`. +- **EPFO / UAN assisted verification (A3):** `EpfoService/` is a standalone Express + Playwright proxy (the **GstService pattern**) that does an OTP-handshake UAN lookup against the EPFO member portal — `POST /otp` then `POST /verify`. The app proxies via `/api/epfo/otp` + `/api/epfo` (gated by `verify_bank_epf`), and the **EPFO check** affordance in the verification queue records the returned member name onto `EpfDetail.epfoMemberName` (`recordEpfoCheck`). The live portal navigation is **stubbed behind `EPFO_LIVE`** (deterministic in dev/CI: OTP `000000` → matched) until the real selectors/OTP are validated. **Aadhaar is intentionally not handled** (UIDAI-restricted — stays assisted-manual; only `aadhaarLast4` stored, masked). - Still deferred (not self-contained): the public careers intake API (A2, external) and the Pay-status pay rows (Phase 6 payroll). ### GST Calculation @@ -229,6 +230,7 @@ RESEND_API_KEY, EMAIL_FROM, EMAIL_FROM_NAME FORGEJO_URL, FORGEJO_REPO, FORGEJO_TOKEN GST_SERVICE_URL # GstService microservice (defaults to localhost:3003) +EPFO_SERVICE_URL # EpfoService microservice for UAN lookup (defaults to localhost:3004) NEXT_PUBLIC_INVENTORY_ENABLED # Inventory feature flag NEXT_PUBLIC_CREWING_ENABLED # Crewing module feature flag (opt-in "true"; off by default) NEXT_PUBLIC_ENV_LABEL # When set, shows a non-prod banner (EnvBanner). Leave unset in prod. diff --git a/App/app/(portal)/crewing/verification/actions.ts b/App/app/(portal)/crewing/verification/actions.ts index 8d5045e..6a8e2ed 100644 --- a/App/app/(portal)/crewing/verification/actions.ts +++ b/App/app/(portal)/crewing/verification/actions.ts @@ -132,3 +132,34 @@ export async function verifyNextOfKin(id: string, approve: boolean, remarks?: st g.userId ); } + +// ── EPFO assisted lookup (Accounts) ──────────────────────────────────────────── +// Records the result of an EpfoService UAN check on the crew member's EpfDetail +// (A3 "record the result"). The actual lookup runs in the browser via /api/epfo; +// this just persists the returned member name + a timestamp for the audit trail. + +export async function recordEpfoCheck(crewMemberId: string, memberName: string | null): Promise { + const g = await guard("verify_bank_epf"); + if ("error" in g) return g; + + const rec = await db.epfDetail.findUnique({ where: { crewMemberId }, select: { id: true } }); + if (!rec) return { error: "EPF details not found" }; + + await db.epfDetail.update({ + where: { crewMemberId }, + data: { epfoMemberName: memberName, epfoCheckedAt: new Date() }, + }); + await db.crewAction.create({ + data: { + actionType: "RECORD_UPDATED", + actorId: g.userId, + crewMemberId, + note: memberName ? `EPFO check matched: ${memberName}` : "EPFO check: no match", + metadata: { record: "epfo_check" }, + }, + }); + + revalidatePath(PATH); + revalidatePath(`/crewing/crew/${crewMemberId}`); + return { ok: true }; +} diff --git a/App/app/(portal)/crewing/verification/verification-manager.tsx b/App/app/(portal)/crewing/verification/verification-manager.tsx index c39e0f3..18eced7 100644 --- a/App/app/(portal)/crewing/verification/verification-manager.tsx +++ b/App/app/(portal)/crewing/verification/verification-manager.tsx @@ -4,7 +4,7 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; import type { SeafarerDocType } from "@prisma/client"; import { AdminDialog } from "@/components/ui/admin-dialog"; -import { verifyDocument, verifyBankEpf, verifyPpe, verifyNextOfKin } from "./actions"; +import { verifyDocument, verifyBankEpf, verifyPpe, verifyNextOfKin, recordEpfoCheck } from "./actions"; import { verifyAppraisal } from "../appraisals/actions"; import type { PpeItem } from "@prisma/client"; @@ -60,6 +60,85 @@ function Actions({ onVerify, onReject }: { onVerify: () => Promise<{ ok: true } ); } +// EPFO assisted lookup (Accounts): OTP handshake against EpfoService via /api/epfo, +// then record the returned member name onto the EpfDetail (A3). Aadhaar is not +// checked here (UIDAI-restricted — stays manual). +function EpfoAssist({ crewMemberId, uan }: { crewMemberId: string; uan: string | null }) { + const router = useRouter(); + const [open, setOpen] = useState(false); + const [step, setStep] = useState<"start" | "otp" | "result">("start"); + const [sessionId, setSessionId] = useState(""); + const [mobileHint, setMobileHint] = useState(""); + const [otp, setOtp] = useState(""); + const [result, setResult] = useState<{ matched: boolean; name: string | null } | null>(null); + const [pending, setPending] = useState(false); + const [error, setError] = useState(""); + + if (!uan) return null; + + function reset() { setStep("start"); setSessionId(""); setOtp(""); setResult(null); setError(""); setMobileHint(""); } + + async function requestOtp() { + setPending(true); setError(""); + try { + const r = await fetch("/api/epfo/otp", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ uan }) }); + const d = await r.json(); + if (!r.ok) throw new Error(d.error || "Failed to request OTP"); + setSessionId(d.sessionId); setMobileHint(d.mobileHint || ""); setStep("otp"); + } catch (e) { setError(String(e instanceof Error ? e.message : e)); } + setPending(false); + } + async function verify() { + setPending(true); setError(""); + try { + const r = await fetch("/api/epfo", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ sessionId, uan, otp }) }); + const d = await r.json(); + if (!r.ok) throw new Error(d.error || "Lookup failed"); + setResult({ matched: Boolean(d.matched), name: d.name ?? null }); setStep("result"); + } catch (e) { setError(String(e instanceof Error ? e.message : e)); } + setPending(false); + } + async function record() { + setPending(true); + await recordEpfoCheck(crewMemberId, result?.name ?? null); + setPending(false); setOpen(false); reset(); router.refresh(); + } + + return ( + <> + + setOpen(false)}> +
+

Assisted UAN lookup via the EPFO portal. An OTP is sent to the member's registered mobile. (Aadhaar is verified manually — not via this check.)

+

UAN: {uan}

+ + {step === "start" && ( + + )} + {step === "otp" && ( +
+

OTP sent to {mobileHint || "the registered mobile"}.

+ setOtp(e.target.value)} /> + +
+ )} + {step === "result" && ( +
+ {result?.matched ? ( +

Matched — EPFO member: {result.name}

+ ) : ( +

No matching EPFO member for this UAN.

+ )} + +
+ )} + {error &&

{error}

} +
+
+ + ); +} + function Card({ title, sub, empty, children }: { title: string; sub: string; empty: boolean; children: React.ReactNode }) { return (
@@ -169,7 +248,12 @@ export function VerificationManager({ docs, bank, epf, appraisals, ppe, nok, can {e.uan ?? "—"} {e.aadhaarLast4 ?? "—"} {e.pfNumber ?? "—"} - verifyBankEpf(e.crewMemberId, "epf", true)} onReject={(r) => verifyBankEpf(e.crewMemberId, "epf", false, r)} /> + +
+ + verifyBankEpf(e.crewMemberId, "epf", true)} onReject={(r) => verifyBankEpf(e.crewMemberId, "epf", false, r)} /> +
+ ))} diff --git a/App/app/api/epfo/otp/route.ts b/App/app/api/epfo/otp/route.ts new file mode 100644 index 0000000..a9fb77a --- /dev/null +++ b/App/app/api/epfo/otp/route.ts @@ -0,0 +1,30 @@ +import { auth } from "@/auth"; +import { hasPermission } from "@/lib/permissions"; +import { NextRequest, NextResponse } from "next/server"; + +const EPFO_SERVICE = process.env.EPFO_SERVICE_URL ?? "http://localhost:3004"; + +/** POST /api/epfo/otp { uan } → { sessionId, mobileHint } — request an EPFO OTP. */ +export async function POST(req: NextRequest) { + const session = await auth(); + if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + if (!hasPermission(session.user.role, "verify_bank_epf")) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + const body = await req.json().catch(() => ({})); + if (!body.uan) return NextResponse.json({ error: "uan is required" }, { status: 400 }); + + try { + const res = await fetch(`${EPFO_SERVICE}/otp`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ uan: body.uan }), + cache: "no-store", + }); + const data = await res.json(); + return NextResponse.json(data, { status: res.ok ? 200 : res.status }); + } catch (e) { + return NextResponse.json({ error: `EPFO service unavailable: ${String(e)}` }, { status: 502 }); + } +} diff --git a/App/app/api/epfo/route.ts b/App/app/api/epfo/route.ts new file mode 100644 index 0000000..433bbc8 --- /dev/null +++ b/App/app/api/epfo/route.ts @@ -0,0 +1,32 @@ +import { auth } from "@/auth"; +import { hasPermission } from "@/lib/permissions"; +import { NextRequest, NextResponse } from "next/server"; + +const EPFO_SERVICE = process.env.EPFO_SERVICE_URL ?? "http://localhost:3004"; + +/** POST /api/epfo { sessionId, uan, otp } → { matched, name, status } — submit the OTP. */ +export async function POST(req: NextRequest) { + const session = await auth(); + if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + if (!hasPermission(session.user.role, "verify_bank_epf")) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + const body = await req.json().catch(() => ({})); + if (!body.sessionId || !body.uan || !body.otp) { + return NextResponse.json({ error: "sessionId, uan and otp are required" }, { status: 400 }); + } + + try { + const res = await fetch(`${EPFO_SERVICE}/verify`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sessionId: body.sessionId, uan: body.uan, otp: body.otp }), + cache: "no-store", + }); + const data = await res.json(); + return NextResponse.json(data, { status: res.ok ? 200 : res.status }); + } catch (e) { + return NextResponse.json({ error: `EPFO service unavailable: ${String(e)}` }, { status: 502 }); + } +} diff --git a/App/prisma/migrations/20260622170900_crewing_epfo_check/migration.sql b/App/prisma/migrations/20260622170900_crewing_epfo_check/migration.sql new file mode 100644 index 0000000..93fd59c --- /dev/null +++ b/App/prisma/migrations/20260622170900_crewing_epfo_check/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "EpfDetail" ADD COLUMN "epfoCheckedAt" TIMESTAMP(3), +ADD COLUMN "epfoMemberName" TEXT; diff --git a/App/prisma/schema.prisma b/App/prisma/schema.prisma index cacd927..348239d 100644 --- a/App/prisma/schema.prisma +++ b/App/prisma/schema.prisma @@ -908,6 +908,9 @@ model EpfDetail { pfNumber String? verificationStatus GateResult @default(PENDING) verifiedById String? + // EPFO assisted-lookup result (recorded from the EpfoService check, A3). + epfoMemberName String? + epfoCheckedAt DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } diff --git a/App/tests/integration/verification.test.ts b/App/tests/integration/verification.test.ts index eb52feb..aeb26e9 100644 --- a/App/tests/integration/verification.test.ts +++ b/App/tests/integration/verification.test.ts @@ -10,7 +10,7 @@ vi.mock("@/lib/feature-flags", () => ({ CREWING_ENABLED: true, INVENTORY_ENABLED import { auth } from "@/auth"; import { db } from "@/lib/db"; -import { verifyDocument, verifyBankEpf } from "@/app/(portal)/crewing/verification/actions"; +import { verifyDocument, verifyBankEpf, recordEpfoCheck } from "@/app/(portal)/crewing/verification/actions"; import { makeSession, getSeedUser } from "./helpers"; import type { Role } from "@prisma/client"; @@ -101,3 +101,20 @@ describe("bank/EPF verification (Accounts)", () => { expect(await verifyBankEpf(crewId, "bank", true)).toEqual({ error: "Unauthorized" }); }); }); + +describe("EPFO assisted check (recordEpfoCheck)", () => { + it("records the EPFO member name + timestamp (Accounts)", async () => { + const { crewId } = await crewWithRecords(); + as(accountsId, "ACCOUNTS"); + expect("ok" in (await recordEpfoCheck(crewId, "EPFO Member (stub)"))).toBe(true); + const epf = await db.epfDetail.findUniqueOrThrow({ where: { crewMemberId: crewId } }); + expect(epf.epfoMemberName).toBe("EPFO Member (stub)"); + expect(epf.epfoCheckedAt).not.toBeNull(); + }); + + it("is rejected for the MPO (no verify_bank_epf)", async () => { + const { crewId } = await crewWithRecords(); + as(manningId, "MANNING"); + expect(await recordEpfoCheck(crewId, "x")).toEqual({ error: "Unauthorized" }); + }); +}); diff --git a/EpfoService/README.md b/EpfoService/README.md new file mode 100644 index 0000000..770632b --- /dev/null +++ b/EpfoService/README.md @@ -0,0 +1,51 @@ +# EpfoService + +EPFO / UAN **assisted-lookup** proxy for PPMS crewing — mirrors `GstService`. +Drives the EPFO member portal headlessly (Playwright) to fetch a member record +for a UAN, so Accounts can confirm a crew member's EPF details against the source. + +## Why it differs from GstService + +- The GST portal has an anonymous **captcha** lookup. The EPFO member portal does + not — "Know your UAN" is gated by an **OTP to the member's registered mobile**. + So the handshake is two steps (`/otp` then `/verify`). +- **Aadhaar is out of scope.** UIDAI restricts Aadhaar verification to licensed + AUA/KUA via consented e-KYC; it cannot be portal-scraped. PPMS keeps Aadhaar + **assisted-manual** (stores only the last 4 digits, masked). + +## Endpoints + +| Method | Path | Body | Returns | +|---|---|---|---| +| GET | `/health` | — | `{ status, mode, sessionCount }` | +| POST | `/otp` | `{ uan }` | `{ sessionId, mobileHint }` | +| POST | `/verify` | `{ sessionId, uan, otp }` | `{ matched, name, status }` | + +## Modes + +- **Stub (default):** `EPFO_LIVE` unset/`false`. Deterministic responses — OTP + `000000` → matched member, anything else → not matched. Lets the app + integration run end-to-end in dev/CI without the live portal. +- **Live:** `EPFO_LIVE=true`. Drives the real portal. **The page selectors and the + OTP/captcha flow are marked `TODO(live)` and must be validated against a real + session before enabling** — the portal layout is the source of truth. + +## Env + +``` +PORT=3004 +SESSION_TTL_MS=300000 +EPFO_LIVE=false +EPFO_PORTAL_URL=https://unifiedportal-mem.epfindia.gov.in/memberinterface/ +``` + +## Run + +``` +pnpm install +pnpm dev # tsx watch +# or +pnpm build && pnpm start +``` + +The PPMS app reaches it via `EPFO_SERVICE_URL` (proxied through `/api/epfo`). diff --git a/EpfoService/package.json b/EpfoService/package.json new file mode 100644 index 0000000..fb72276 --- /dev/null +++ b/EpfoService/package.json @@ -0,0 +1,21 @@ +{ + "name": "epfo-service", + "version": "0.1.0", + "description": "EPFO/UAN proxy — assisted UAN lookup from the EPFO member portal via Playwright (OTP handshake). Mirrors GstService. Aadhaar is NOT handled here (UIDAI-restricted).", + "main": "dist/index.js", + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc", + "start": "node dist/index.js" + }, + "dependencies": { + "express": "^4.18.2", + "playwright": "^1.49.0" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/node": "^22.0.0", + "tsx": "^4.19.2", + "typescript": "^5.7.2" + } +} diff --git a/EpfoService/src/index.ts b/EpfoService/src/index.ts new file mode 100644 index 0000000..ecf1070 --- /dev/null +++ b/EpfoService/src/index.ts @@ -0,0 +1,137 @@ +/** + * EpfoService — EPFO / UAN assisted-lookup proxy (mirrors GstService). + * + * The EPFO member portal does not offer an anonymous lookup like the GST portal: + * the "Know your UAN" / member flow is gated by an **OTP to the member's + * registered mobile**. So the handshake is two steps: + * POST /otp { uan } → opens a session, requests the OTP + * POST /verify { sessionId, uan, otp } → submits the OTP, returns the member + * record (name, DOB, status, …) + * + * The real portal navigation is gated behind EPFO_LIVE=true. Until the live + * selectors/OTP are validated against a real session, the service runs in STUB + * mode (deterministic responses) so the app integration is exercisable in dev. + * + * Aadhaar verification is intentionally OUT OF SCOPE here — UIDAI restricts it to + * licensed AUA/KUA via consented e-KYC; it cannot be portal-scraped. Aadhaar + * stays assisted-manual in PPMS. + */ +import express from "express"; +import type { Browser, BrowserContext, Page } from "playwright"; + +const PORT = Number(process.env.PORT ?? 3004); +const SESSION_TTL_MS = Number(process.env.SESSION_TTL_MS ?? 5 * 60 * 1000); // 5 min +const LIVE = process.env.EPFO_LIVE === "true"; +const PORTAL_URL = process.env.EPFO_PORTAL_URL ?? "https://unifiedportal-mem.epfindia.gov.in/memberinterface/"; + +function log(level: string, msg: string, ctx?: Record) { + const line = JSON.stringify({ ts: new Date().toISOString(), level, msg, ...ctx }); + (level === "ERROR" || level === "WARN" ? process.stderr : process.stdout).write(line + "\n"); +} + +// ── Sessions ─────────────────────────────────────────────────────────────────── + +interface Session { + uan: string; + createdAt: number; + context?: BrowserContext; + page?: Page; +} +const sessions = new Map(); +let seq = 0; +const newSessionId = () => `epfo_${Date.now().toString(36)}_${(seq++).toString(36)}`; + +setInterval(() => { + const now = Date.now(); + let pruned = 0; + for (const [id, s] of sessions) { + if (now - s.createdAt > SESSION_TTL_MS) { + s.context?.close().catch(() => {}); + sessions.delete(id); + pruned++; + } + } + if (pruned) log("INFO", "Pruned expired sessions", { pruned, remaining: sessions.size }); +}, 60_000).unref(); + +// ── Browser (only launched in LIVE mode) ─────────────────────────────────────── + +let _browser: Browser | null = null; +async function getBrowser(): Promise { + if (_browser?.isConnected()) return _browser; + const { chromium } = await import("playwright"); + _browser = await chromium.launch({ headless: true, args: ["--no-sandbox", "--disable-setuid-sandbox"] }); + _browser.on("disconnected", () => { _browser = null; }); + return _browser; +} + +const isUan = (s: unknown): s is string => typeof s === "string" && /^\d{12}$/.test(s); +const mobileHint = (m?: string) => (m && m.length >= 4 ? `••••••${m.slice(-4)}` : "••••••••"); + +// ── App ──────────────────────────────────────────────────────────────────────── + +const app = express(); +app.use(express.json()); + +app.get("/health", (_req, res) => { + res.json({ status: "ok", mode: LIVE ? "live" : "stub", sessionCount: sessions.size }); +}); + +/** POST /otp { uan } → { sessionId, mobileHint } — request an OTP to the member's mobile. */ +app.post("/otp", async (req, res) => { + const { uan } = req.body ?? {}; + if (!isUan(uan)) return res.status(400).json({ error: "A 12-digit UAN is required" }); + + const sessionId = newSessionId(); + + if (!LIVE) { + sessions.set(sessionId, { uan, createdAt: Date.now() }); + log("INFO", "OTP requested (stub)", { sessionId }); + return res.json({ sessionId, mobileHint: mobileHint(), stub: true }); + } + + try { + const browser = await getBrowser(); + const context = await browser.newContext(); + const page = await context.newPage(); + await page.goto(PORTAL_URL, { waitUntil: "domcontentloaded", timeout: 30_000 }); + // TODO(live): drive the member portal's "Know your UAN" OTP request: + // fill UAN, solve the on-page captcha, click "Get OTP", read the masked mobile. + // Selectors must be validated against a real session before enabling EPFO_LIVE. + sessions.set(sessionId, { uan, createdAt: Date.now(), context, page }); + return res.json({ sessionId, mobileHint: mobileHint() }); + } catch (e) { + log("ERROR", "POST /otp failed", { err: String(e) }); + return res.status(502).json({ error: `EPFO portal error: ${String(e)}` }); + } +}); + +/** POST /verify { sessionId, uan, otp } → { matched, name, status } — submit the OTP. */ +app.post("/verify", async (req, res) => { + const { sessionId, uan, otp } = req.body ?? {}; + const s = sessionId && sessions.get(sessionId); + if (!s) return res.status(410).json({ error: "Session expired — request a new OTP" }); + if (!isUan(uan) || s.uan !== uan) return res.status(400).json({ error: "UAN mismatch" }); + if (typeof otp !== "string" || !/^\d{4,8}$/.test(otp)) return res.status(400).json({ error: "A valid OTP is required" }); + + if (!LIVE) { + sessions.delete(sessionId); + // Deterministic stub: OTP 000000 → matched member; anything else → not matched. + const matched = otp === "000000"; + log("INFO", "Verify (stub)", { sessionId, matched }); + return res.json({ matched, name: matched ? "EPFO Member (stub)" : null, status: matched ? "ACTIVE" : null, stub: true }); + } + + try { + // TODO(live): submit the OTP and scrape the member record (name/DOB/status). + const result = { matched: false, name: null as string | null, status: null as string | null }; + s.context?.close().catch(() => {}); + sessions.delete(sessionId); + return res.json(result); + } catch (e) { + log("ERROR", "POST /verify failed", { err: String(e) }); + return res.status(502).json({ error: `EPFO portal error: ${String(e)}` }); + } +}); + +app.listen(PORT, () => log("INFO", `EpfoService listening`, { port: PORT, mode: LIVE ? "live" : "stub" })); diff --git a/EpfoService/tsconfig.json b/EpfoService/tsconfig.json new file mode 100644 index 0000000..8e8f606 --- /dev/null +++ b/EpfoService/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "CommonJS", + "moduleResolution": "node", + "outDir": "dist", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true + }, + "include": ["src"] +}